tts: dropdown voice picker, sticky Play footer, default voice foxhop
This commit is contained in:
parent
535f4ce027
commit
a7f71b5a8b
6 changed files with 115 additions and 61 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue