tts: dropdown voice picker, sticky Play footer, default voice foxhop

This commit is contained in:
russell@unturf.com 2026-05-25 08:17:32 -04:00
parent 535f4ce027
commit a7f71b5a8b
No known key found for this signature in database
6 changed files with 115 additions and 61 deletions

View file

@ -30,8 +30,8 @@ export async function readPageWithHermes(button = null) {
const processedContent = await processContentWithHermes(content);
// Get voice preference from settings (format: "model:voice", e.g., "tts-1-f5:atlas")
let voice = "atlas";
// Get voice preference from settings (format: "model:voice", e.g., "tts-1-f5:foxhop")
let voice = "foxhop";
let model = "tts-1-f5";
try {
const savedVoice = localStorage.getItem("uncloseai_selected_voice");

View file

@ -103,7 +103,8 @@ async function populateVoiceDropdown(voiceSelection) {
'ruby', 'sage', 'sofia', 'amber', 'brooke', 'cora', 'diana', 'eden',
'faye', 'gemma', 'hope', 'ivy', 'atlas', 'caleb', 'felix', 'hugo',
'jasper', 'kai', 'leo', 'marcus', 'owen', 'theo', 'archer', 'blake',
'cole', 'dane', 'ezra', 'finn', 'grant', 'heath', 'ivan', 'jude'
'cole', 'dane', 'ezra', 'finn', 'grant', 'heath', 'ivan', 'jude',
'foxhop'
];
const defaultVoices = F5_VOICES.map(voice => ({ model: 'tts-1-f5', voice }));
renderVoiceOptions(voiceSelection, defaultVoices);
@ -116,10 +117,10 @@ function renderVoiceOptions(voiceSelection, voices) {
voiceSelection.innerHTML = ''; // Clear existing options
// Get saved voice preference (vault first, fallback to default)
let savedVoice = 'tts-1-f5:atlas'; // Default
let savedVoice = 'tts-1-f5:foxhop'; // Default
try {
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
savedVoice = window.UncloseVault.get('uncloseai_selected_voice', 'tts-1-f5:atlas');
savedVoice = window.UncloseVault.get('uncloseai_selected_voice', 'tts-1-f5:foxhop');
}
} catch (error) {
console.warn('Failed to read voice preference:', error);
@ -134,54 +135,47 @@ function renderVoiceOptions(voiceSelection, voices) {
groupedVoices[v.model].push(v.voice);
});
// Create radio buttons grouped by model
Object.keys(groupedVoices).sort().forEach(model => {
// Add model label
const modelLabel = document.createElement('div');
modelLabel.className = 'tts-model-label';
modelLabel.textContent = model;
voiceSelection.appendChild(modelLabel);
// Render as a single <select> with <optgroup> per model. This was the
// original intent (see this function's docstring): the prior radio
// grid grew to 40+ rows and pushed the Play button off-screen.
const select = document.createElement("select");
select.name = "tts-voice";
select.className = "tts-voice-select";
select.setAttribute("aria-label", "Voice");
// Add voices for this model
groupedVoices[model].forEach(voice => {
const label = document.createElement('label');
label.className = 'tts-voice-label';
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'tts-voice';
radio.value = `${model}:${voice}`;
// Check if this is the saved voice
if (`${model}:${voice}` === savedVoice) {
radio.checked = true;
}
// Save selection on change (through vault)
radio.addEventListener('change', () => {
try {
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
window.UncloseVault.set('uncloseai_selected_voice', radio.value);
} else {
console.warn('Vault locked - voice preference not persisted');
}
} catch (error) {
console.warn('Failed to save voice preference:', error);
}
Object.keys(groupedVoices)
.sort()
.forEach((model) => {
const group = document.createElement("optgroup");
group.label = model;
groupedVoices[model].forEach((voice) => {
const opt = document.createElement("option");
opt.value = `${model}:${voice}`;
opt.textContent = voice;
if (opt.value === savedVoice) opt.selected = true;
group.appendChild(opt);
});
label.appendChild(radio);
label.appendChild(document.createTextNode(voice));
voiceSelection.appendChild(label);
select.appendChild(group);
});
// Persist selection through the vault (no-op if vault is locked).
select.addEventListener("change", () => {
try {
if (
typeof window !== "undefined" &&
window.UncloseVault &&
window.UncloseVault.isUnlocked()
) {
window.UncloseVault.set("uncloseai_selected_voice", select.value);
} else {
console.warn("Vault locked - voice preference not persisted");
}
} catch (error) {
console.warn("Failed to save voice preference:", error);
}
});
// If no voice is checked, check the first one
if (!voiceSelection.querySelector('input[type="radio"]:checked')) {
const firstRadio = voiceSelection.querySelector('input[type="radio"]');
if (firstRadio) {
firstRadio.checked = true;
}
}
voiceSelection.appendChild(select);
}
export function openTTSModal() {
@ -310,18 +304,25 @@ export function openTTSModal() {
resultDiv.id = "tts-result";
resultDiv.className = "tts-result";
// Content container with padding
// Content container with padding (scrollable middle row)
const contentContainer = document.createElement("div");
contentContainer.className = "tts-modal-content";
contentContainer.appendChild(textArea);
contentContainer.appendChild(voiceSelection);
contentContainer.appendChild(speedControl);
contentContainer.appendChild(modeControl);
contentContainer.appendChild(playButton);
contentContainer.appendChild(resultDiv);
// Sticky footer holds the primary action so the Play button stays
// visible even when the voice grid pushes the content past the dialog
// viewport. Matches the article grid's third `auto` row.
const footer = document.createElement("div");
footer.className = "tts-modal-footer";
footer.appendChild(playButton);
article.appendChild(header);
article.appendChild(contentContainer);
article.appendChild(footer);
modal.appendChild(article);
// Safari <15.4 lacks native <dialog>; fall back to a positioned overlay.
@ -347,12 +348,12 @@ export function openTTSModal() {
return;
}
const selectedRadio = document.querySelector('input[name="tts-voice"]:checked');
if (!selectedRadio) {
const voiceEl = document.querySelector('[name="tts-voice"]');
if (!voiceEl || !voiceEl.value) {
alert("Please select a voice first");
return;
}
const selectedValue = selectedRadio.value;
const selectedValue = voiceEl.value;
const speed = parseFloat(speedSlider.value);
// Parse model:voice format (e.g., "tts-1-f5:aria" or legacy "aria")

View file

@ -349,7 +349,7 @@ function cleanTextForTTS(text) {
// Function to read text using TTS - for Read Page button (uses Hermes).
// Multi-chunk uses pipelined playlist: playback starts as soon as chunk 1 lands.
export async function speakText(text, voice = "atlas", rate = 0.9, model = "tts-1-f5") {
export async function speakText(text, voice = "foxhop", rate = 0.9, model = "tts-1-f5") {
try {
const spokenText = await extractSpokenTokens(text);
const chunks = splitTextIntoChunks(spokenText);
@ -414,13 +414,13 @@ const LEGACY_OPENAI_VOICES = new Set([
]);
// Normalize a model+voice pair: remap legacy / unknown models to F5, and
// remap legacy OpenAI voice names + aria (which has loud F5 phantom bleed) to
// "clara" which produces clean audio per multi-voice transcription test.
// remap legacy OpenAI voice names + phantom voices to "foxhop" (self-recorded
// default).
const PHANTOM_VOICES = new Set(["aria", "luna", "sage", "cole", "jude"]);
export function normalizeModelVoice(model, voice) {
const isQwen = !model || model === "tts-1" || model.includes("qwen");
const normModel = isQwen ? "tts-1-f5" : model;
const normVoice = LEGACY_OPENAI_VOICES.has(voice) || PHANTOM_VOICES.has(voice) ? "atlas" : voice;
const normVoice = LEGACY_OPENAI_VOICES.has(voice) || PHANTOM_VOICES.has(voice) ? "foxhop" : voice;
return { model: normModel, voice: normVoice };
}
@ -449,7 +449,7 @@ export function primeAudioPlayback() {
// Direct TTS function for modal - no Hermes preprocessing.
// Multi-chunk uses pipelined playlist: playback starts as soon as chunk 1 lands.
export async function speakTextDirect(text, voice = "atlas", rate = 0.9, model = "tts-1-f5") {
export async function speakTextDirect(text, voice = "foxhop", rate = 0.9, model = "tts-1-f5") {
try {
const cleanedText = cleanTextForTTS(text);
const chunks = splitTextIntoChunks(cleanedText);
@ -477,7 +477,7 @@ export async function speakTextDirect(text, voice = "atlas", rate = 0.9, model =
// Streaming TTS function - plays audio as chunks arrive using MediaSource API
// Returns immediately with audio element; done promise resolves when stream completes
// Automatically chunks long text and streams each chunk sequentially
export async function speakTextStreaming(text, voice = "atlas", rate = 0.9, model = "tts-1-f5") {
export async function speakTextStreaming(text, voice = "foxhop", rate = 0.9, model = "tts-1-f5") {
const cleanedText = cleanTextForTTS(text);
const textChunks = splitTextIntoChunks(cleanedText);
console.log("TTS streaming input preview:", cleanedText.substring(0, 100) + "...");
@ -794,7 +794,7 @@ export async function generateTitleForTTS(text) {
// Handle TTS - respects streaming mode setting
// Caches audio blobs in memory so replay doesn't regenerate
// Cache is keyed by text+voice+model and clears when the tab closes
export async function handleTTS(text, voice = "atlas", rate = 0.9, model = "tts-1-f5") {
export async function handleTTS(text, voice = "foxhop", rate = 0.9, model = "tts-1-f5") {
try {
const cacheKey = `${model}:${voice}:${text}`;

View file

@ -667,10 +667,24 @@ dialog#tts-modal {
overflow-y: auto;
}
/* Sticky footer holding the primary Play action */
.tts-modal-footer {
display: grid;
place-items: center;
padding: 12px 20px;
border-top: 1px solid #e0e0e0;
background: white;
}
dialog#tts-modal .tts-modal-footer .tts-play-btn {
margin: 0;
}
/* TTS modal article */
.tts-modal-article {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
height: 100%;
overflow: hidden;
}
@ -730,6 +744,19 @@ dialog#tts-modal {
cursor: pointer;
}
/* Single <select> with <optgroup> per model (replaces the radio grid).
Spans all tracks of the surrounding .tts-voice-container grid. */
.tts-voice-select {
grid-column: 1 / -1;
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ccc;
border-radius: 6px;
background: white;
color: #333;
}
/* Speed control */
.tts-speed-control {
margin: 16px 0;

View file

@ -741,6 +741,19 @@ dialog#tts-modal {
overflow-y: auto;
}
/* Sticky footer holding the primary Play action */
.tts-modal-footer {
display: grid;
place-items: center;
padding: 12px 20px;
border-top: 1px solid #e0e0e0;
background: white;
}
dialog#tts-modal .tts-modal-footer .tts-play-btn {
margin: 0;
}
/* TTS modal article */
.tts-modal-article {
display: grid;
@ -804,6 +817,19 @@ dialog#tts-modal {
cursor: pointer;
}
/* Single <select> with <optgroup> per model (replaces the radio grid).
Spans all tracks of the surrounding .tts-voice-container grid. */
.tts-voice-select {
grid-column: 1 / -1;
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ccc;
border-radius: 6px;
background: white;
color: #333;
}
/* Speed control */
.tts-speed-control {
margin: 16px 0;

View file

@ -409,7 +409,7 @@ const UncloseVault = {
// Model selection
selectedModel: null,
selectedEndpoint: null,
selectedVoice: 'tts-1-f5:atlas',
selectedVoice: 'tts-1-f5:foxhop',
// Custom API
useCustomAPI: false,