tts: ship F5 backend by default with pipelined playlist streaming

This commit is contained in:
russell@unturf.com 2026-05-24 10:03:50 -04:00
parent a4818b2afb
commit d8949d0b6c
No known key found for this signature in database
8 changed files with 230 additions and 120 deletions

View file

@ -238,7 +238,7 @@ export async function handleUserInput() {
playPauseButton.textContent = "Processing..."; playPauseButton.textContent = "Processing...";
playPauseButton.disabled = true; // Disable button while processing playPauseButton.disabled = true; // Disable button while processing
const mainVoiceSelect = document.getElementById("read-page-voice"); const mainVoiceSelect = document.getElementById("read-page-voice");
const selectedVoice = mainVoiceSelect ? mainVoiceSelect.value : "alloy"; const selectedVoice = mainVoiceSelect ? mainVoiceSelect.value : "aria";
const result = await speakText(accumulatedContent, selectedVoice, 0.9); const result = await speakText(accumulatedContent, selectedVoice, 0.9);
aiAudio = result.audio; aiAudio = result.audio;
aiBlob = result.blob; aiBlob = result.blob;

View file

@ -16,10 +16,10 @@
// Code is seeds to sprout on any abandoned technology. // Code is seeds to sprout on any abandoned technology.
// Feature flags // Feature flags
// TTS disabled by default (tickets #002, #003). Embedders can re-enable: // TTS enabled by default (F5-TTS backend + pipelined playlist). Embedders can disable:
// window.UNCLOSEAI_ENABLE_TTS = true; // window.UNCLOSEAI_ENABLE_TTS = false;
export function isTTSEnabled() { export function isTTSEnabled() {
return typeof window !== "undefined" && window.UNCLOSEAI_ENABLE_TTS === true; return typeof window === "undefined" || window.UNCLOSEAI_ENABLE_TTS !== false;
} }
export const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech"; export const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech";

View file

@ -30,9 +30,9 @@ export async function readPageWithHermes(button = null) {
const processedContent = await processContentWithHermes(content); const processedContent = await processContentWithHermes(content);
// Get voice preference from settings (format: "model:voice", e.g., "tts-1-qwen:onyx") // Get voice preference from settings (format: "model:voice", e.g., "tts-1-f5:aria")
let voice = "alloy"; let voice = "aria";
let model = "tts-1-qwen"; let model = "tts-1-f5";
try { try {
const savedVoice = localStorage.getItem("uncloseai_selected_voice"); const savedVoice = localStorage.getItem("uncloseai_selected_voice");
if (savedVoice && savedVoice.includes(":")) { if (savedVoice && savedVoice.includes(":")) {

View file

@ -97,31 +97,15 @@ async function populateVoiceDropdown(voiceSelection) {
return voices; return voices;
} catch (error) { } catch (error) {
console.error('Failed to fetch voices:', error); console.error('Failed to fetch voices:', error);
// Fallback to default voices (20 voices from Qwen3-TTS) // Fallback to F5-TTS cloned-voice catalog (40 voices, matches sidecar)
const defaultVoices = [ const F5_VOICES = [
// Standard OpenAI-compatible voices 'aria', 'clara', 'elena', 'grace', 'hazel', 'iris', 'luna', 'maya',
{ model: 'tts-1-qwen', voice: 'alloy' }, 'ruby', 'sage', 'sofia', 'amber', 'brooke', 'cora', 'diana', 'eden',
{ model: 'tts-1-qwen', voice: 'echo' }, 'faye', 'gemma', 'hope', 'ivy', 'atlas', 'caleb', 'felix', 'hugo',
{ model: 'tts-1-qwen', voice: 'fable' }, 'jasper', 'kai', 'leo', 'marcus', 'owen', 'theo', 'archer', 'blake',
{ model: 'tts-1-qwen', voice: 'onyx' }, 'cole', 'dane', 'ezra', 'finn', 'grant', 'heath', 'ivan', 'jude'
{ model: 'tts-1-qwen', voice: 'nova' },
{ model: 'tts-1-qwen', voice: 'shimmer' },
// Extended voices
{ model: 'tts-1-qwen', voice: 'amber' },
{ model: 'tts-1-qwen', voice: 'breeze' },
{ model: 'tts-1-qwen', voice: 'coral' },
{ model: 'tts-1-qwen', voice: 'dawn' },
{ model: 'tts-1-qwen', voice: 'ember' },
{ model: 'tts-1-qwen', voice: 'frost' },
{ model: 'tts-1-qwen', voice: 'glow' },
{ model: 'tts-1-qwen', voice: 'haze' },
{ model: 'tts-1-qwen', voice: 'ivy' },
{ model: 'tts-1-qwen', voice: 'jade' },
{ model: 'tts-1-qwen', voice: 'kite' },
{ model: 'tts-1-qwen', voice: 'lark' },
{ model: 'tts-1-qwen', voice: 'mist' },
{ model: 'tts-1-qwen', voice: 'nectar' }
]; ];
const defaultVoices = F5_VOICES.map(voice => ({ model: 'tts-1-f5', voice }));
renderVoiceOptions(voiceSelection, defaultVoices); renderVoiceOptions(voiceSelection, defaultVoices);
return defaultVoices; return defaultVoices;
} }
@ -132,10 +116,10 @@ function renderVoiceOptions(voiceSelection, voices) {
voiceSelection.innerHTML = ''; // Clear existing options voiceSelection.innerHTML = ''; // Clear existing options
// Get saved voice preference (vault first, fallback to default) // Get saved voice preference (vault first, fallback to default)
let savedVoice = 'tts-1:onyx'; // Default let savedVoice = 'tts-1-f5:aria'; // Default
try { try {
if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) { if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) {
savedVoice = window.UncloseVault.get('uncloseai_selected_voice', 'tts-1:onyx'); savedVoice = window.UncloseVault.get('uncloseai_selected_voice', 'tts-1-f5:aria');
} }
} catch (error) { } catch (error) {
console.warn('Failed to read voice preference:', error); console.warn('Failed to read voice preference:', error);
@ -353,8 +337,8 @@ export function openTTSModal() {
const selectedValue = document.querySelector('input[name="tts-voice"]:checked').value; const selectedValue = document.querySelector('input[name="tts-voice"]:checked').value;
const speed = parseFloat(speedSlider.value); const speed = parseFloat(speedSlider.value);
// Parse model:voice format (e.g., "tts-1-qwen:alloy" or legacy "alloy") // Parse model:voice format (e.g., "tts-1-f5:aria" or legacy "aria")
let model = 'tts-1-qwen'; let model = 'tts-1-f5';
let voice = selectedValue; let voice = selectedValue;
if (selectedValue.includes(':')) { if (selectedValue.includes(':')) {

View file

@ -66,52 +66,190 @@ const AUDIO_FORMAT = detectAudioFormat();
// In-memory TTS cache - keyed by text+voice+model, cleared when tab closes // In-memory TTS cache - keyed by text+voice+model, cleared when tab closes
const ttsCache = new Map(); const ttsCache = new Map();
// Split long text into chunks at sentence boundaries for TTS API limits // Split long text into chunks at sentence boundaries.
// OpenAI-compatible TTS endpoints typically cap at ~4096 characters // F5-TTS has no input cap, but smaller chunks shorten time-to-first-audio:
const TTS_CHUNK_MAX = 1500; // conservative limit for TTS server compatibility // chunk 1 is intentionally small (~500 chars ~ 5s wall) so playback starts ASAP,
// later chunks fetch in pipeline while chunk 1 plays.
const TTS_CHUNK_MAX = 2500; // upper bound per chunk (server-side comfortable)
const TTS_FIRST_CHUNK_MAX = 500; // smaller first chunk for low time-to-first-audio
function splitOne(remaining, limit) {
if (remaining.length <= limit) return [remaining, ''];
const slice = remaining.substring(0, limit);
let splitAt = -1;
for (let i = slice.length - 1; i > limit * 0.3; i--) {
const ch = slice[i];
if ((ch === '.' || ch === '!' || ch === '?' || ch === '\n') &&
(i === slice.length - 1 || slice[i + 1] === ' ' || slice[i + 1] === '\n')) {
splitAt = i + 1;
break;
}
}
if (splitAt === -1) splitAt = slice.lastIndexOf(' ');
if (splitAt <= 0) splitAt = limit;
return [remaining.substring(0, splitAt).trim(), remaining.substring(splitAt).trim()];
}
function splitTextIntoChunks(text) { function splitTextIntoChunks(text) {
if (text.length <= TTS_CHUNK_MAX) return [text]; if (text.length <= TTS_FIRST_CHUNK_MAX) return [text];
const chunks = []; const chunks = [];
let remaining = text; let remaining = text;
let isFirst = true;
while (remaining.length > 0) { while (remaining.length > 0) {
if (remaining.length <= TTS_CHUNK_MAX) { const limit = isFirst ? TTS_FIRST_CHUNK_MAX : TTS_CHUNK_MAX;
chunks.push(remaining); const [head, tail] = splitOne(remaining, limit);
break; chunks.push(head);
} remaining = tail;
isFirst = false;
// Find the last sentence boundary within the limit
const slice = remaining.substring(0, TTS_CHUNK_MAX);
// Search backwards for sentence-ending punctuation followed by space or newline
let splitAt = -1;
for (let i = slice.length - 1; i > TTS_CHUNK_MAX * 0.3; i--) {
const ch = slice[i];
if ((ch === '.' || ch === '!' || ch === '?' || ch === '\n') &&
(i === slice.length - 1 || slice[i + 1] === ' ' || slice[i + 1] === '\n')) {
splitAt = i + 1;
break;
}
}
// Fallback: split at last space
if (splitAt === -1) {
splitAt = slice.lastIndexOf(' ');
}
// Last resort: hard cut
if (splitAt <= 0) {
splitAt = TTS_CHUNK_MAX;
}
chunks.push(remaining.substring(0, splitAt).trim());
remaining = remaining.substring(splitAt).trim();
} }
console.log(`TTS: split ${text.length} chars into ${chunks.length} chunks:`, chunks.map((c, i) => `chunk${i + 1}=${c.length}chars`)); console.log(`TTS: split ${text.length} chars into ${chunks.length} chunks:`, chunks.map((c, i) => `chunk${i + 1}=${c.length}chars`));
return chunks; return chunks;
} }
// Pipelined playlist player: fetches all chunks in parallel, plays each as soon
// as ready, chains via 'ended'. Exposes Audio-compatible API so downstream UI
// (pause/play, download, progress) works unchanged. Bonus: fixes Safari MP3-concat
// bug by playing each chunk as its own <audio> element.
class PlaylistAudio {
constructor(chunks, voice, model, rate) {
this._chunks = chunks;
this._rate = rate;
this._audios = new Array(chunks.length);
this._blobs = new Array(chunks.length);
this._idx = 0;
this._paused = true;
this._wantPlay = false;
this._ended = false;
this.onplay = null;
this.onpause = null;
this.onended = null;
this._fetchPromises = chunks.map((text, i) => this._fetchAndPrep(text, voice, model, i));
this.allFetched = Promise.allSettled(this._fetchPromises);
}
async _fetchAndPrep(text, voice, model, idx) {
try {
const blob = await fetchTTSChunk(text, voice, model);
this._blobs[idx] = blob;
const audio = new Audio(URL.createObjectURL(blob));
audio.playbackRate = this._rate;
audio.addEventListener('ended', () => this._advance(idx));
this._audios[idx] = audio;
console.log(`PlaylistAudio: chunk ${idx + 1}/${this._chunks.length} ready`);
if (idx === this._idx && this._wantPlay) {
this._wantPlay = false;
this._playCurrent();
}
} catch (e) {
console.error(`PlaylistAudio: chunk ${idx} fetch failed:`, e);
if (idx === this._idx) this._advance(idx);
}
}
_advance(fromIdx) {
if (fromIdx !== this._idx) return;
this._idx++;
if (this._idx >= this._chunks.length) {
this._ended = true;
this._paused = true;
if (this.onended) this.onended();
return;
}
if (this._audios[this._idx]) {
this._playCurrent();
} else {
console.log(`PlaylistAudio: chunk ${this._idx + 1} not ready yet, waiting`);
this._wantPlay = true;
}
}
_playCurrent() {
const audio = this._audios[this._idx];
if (!audio) return;
const wasPaused = this._paused;
this._paused = false;
const playPromise = audio.play();
if (playPromise && playPromise.catch) {
playPromise.catch(e => console.warn('PlaylistAudio play blocked:', e.message));
}
if (wasPaused && this.onplay) this.onplay();
}
play() {
if (this._ended) return Promise.resolve();
this._paused = false;
if (this._audios[this._idx]) {
this._playCurrent();
} else {
this._wantPlay = true;
}
return Promise.resolve();
}
pause() {
this._paused = true;
this._wantPlay = false;
const audio = this._audios[this._idx];
if (audio && !audio.paused) {
audio.pause();
if (this.onpause) this.onpause();
}
}
get paused() { return this._paused; }
get duration() {
let total = 0;
for (const a of this._audios) {
if (!a || isNaN(a.duration)) return NaN;
total += a.duration;
}
return total;
}
get currentTime() {
let t = 0;
for (let i = 0; i < this._idx; i++) {
if (this._audios[i] && !isNaN(this._audios[i].duration)) t += this._audios[i].duration;
}
const active = this._audios[this._idx];
if (active && !isNaN(active.currentTime)) t += active.currentTime;
return t;
}
set playbackRate(r) {
this._rate = r;
for (const a of this._audios) if (a) a.playbackRate = r;
}
get playbackRate() { return this._rate; }
async getCombinedBlob() {
await this.allFetched;
const validBlobs = this._blobs.filter(Boolean);
if (validBlobs.length === 0) return null;
return new Blob(validBlobs, { type: validBlobs[0].type });
}
}
// Build a pipelined playlist result that matches the existing TTS return shape:
// { audio, blob, blobUrl, streamed, done }. blob/blobUrl populate via done.
function makePlaylistResult(text, chunks, voice, model, rate) {
const playlist = new PlaylistAudio(chunks, voice, model, rate);
const result = { audio: playlist, blob: null, blobUrl: null, streamed: true };
result.done = playlist.getCombinedBlob().then(blob => {
if (blob) {
result.blob = blob;
result.blobUrl = URL.createObjectURL(blob);
setLastTTS(text, { audio: playlist, blob });
}
return { blob, blobUrl: result.blobUrl };
});
return result;
}
// Simple JavaScript text cleaning for TTS // Simple JavaScript text cleaning for TTS
function cleanTextForTTS(text) { function cleanTextForTTS(text) {
return text return text
@ -132,28 +270,23 @@ function cleanTextForTTS(text) {
.trim(); .trim();
} }
// Function to read text using TTS - for Read Page button (uses Hermes) // Function to read text using TTS - for Read Page button (uses Hermes).
// Automatically chunks long text after Hermes preprocessing // Multi-chunk uses pipelined playlist: playback starts as soon as chunk 1 lands.
export async function speakText(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { export async function speakText(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
try { try {
const spokenText = await extractSpokenTokens(text); const spokenText = await extractSpokenTokens(text);
const chunks = splitTextIntoChunks(spokenText); const chunks = splitTextIntoChunks(spokenText);
const blobs = []; if (chunks.length > 1) {
for (const chunk of chunks) { return makePlaylistResult(text, chunks, voice, model, rate);
blobs.push(await fetchTTSChunk(chunk, voice, model));
} }
const audioBlob = new Blob(blobs, { type: blobs[0].type }); const blob = await fetchTTSChunk(chunks[0], voice, model);
const audioUrl = URL.createObjectURL(audioBlob); const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl); const audio = new Audio(audioUrl);
audio.playbackRate = rate; audio.playbackRate = rate;
setLastTTS(text, { audio, blob });
setLastTTS(text, { audio, blob: audioBlob }); return { audio, blob, blobUrl: audioUrl, done: Promise.resolve({ blob, blobUrl: audioUrl }) };
const result = { audio, blob: audioBlob, blobUrl: audioUrl };
result.done = Promise.resolve({ blob: audioBlob, blobUrl: audioUrl });
return result;
} catch (error) { } catch (error) {
console.error("Error in TTS:", error); console.error("Error in TTS:", error);
throw error; throw error;
@ -183,31 +316,26 @@ async function fetchTTSChunk(chunkText, voice, model) {
return await response.blob(); return await response.blob();
} }
// Direct TTS function for modal - no Hermes preprocessing // Direct TTS function for modal - no Hermes preprocessing.
// Automatically chunks long text and concatenates audio // Multi-chunk uses pipelined playlist: playback starts as soon as chunk 1 lands.
export async function speakTextDirect(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { export async function speakTextDirect(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
try { try {
const cleanedText = cleanTextForTTS(text); const cleanedText = cleanTextForTTS(text);
const chunks = splitTextIntoChunks(cleanedText); const chunks = splitTextIntoChunks(cleanedText);
console.log("TTS input preview:", cleanedText.substring(0, 100) + "..."); console.log("TTS input preview:", cleanedText.substring(0, 100) + "...");
// Fetch all chunks (sequentially to maintain order, parallel would risk OOM on large texts) if (chunks.length > 1) {
const blobs = []; return makePlaylistResult(text, chunks, voice, model, rate);
for (const chunk of chunks) {
blobs.push(await fetchTTSChunk(chunk, voice, model));
} }
// Concatenate all audio blobs const blob = await fetchTTSChunk(chunks[0], voice, model);
const audioBlob = new Blob(blobs, { type: blobs[0].type }); const audioUrl = URL.createObjectURL(blob);
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl); const audio = new Audio(audioUrl);
audio.playbackRate = rate; audio.playbackRate = rate;
setLastTTS(text, { audio, blob });
setLastTTS(text, { audio, blob: audioBlob }); const result = { audio, blob, blobUrl: audioUrl };
result.done = Promise.resolve({ blob, blobUrl: audioUrl });
const result = { audio, blob: audioBlob, blobUrl: audioUrl };
result.done = Promise.resolve({ blob: audioBlob, blobUrl: audioUrl });
return result; return result;
} catch (error) { } catch (error) {
console.error("Error in TTS:", error); console.error("Error in TTS:", error);
@ -218,7 +346,7 @@ export async function speakTextDirect(text, voice = "alloy", rate = 0.9, model =
// Streaming TTS function - plays audio as chunks arrive using MediaSource API // Streaming TTS function - plays audio as chunks arrive using MediaSource API
// Returns immediately with audio element; done promise resolves when stream completes // Returns immediately with audio element; done promise resolves when stream completes
// Automatically chunks long text and streams each chunk sequentially // Automatically chunks long text and streams each chunk sequentially
export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { export async function speakTextStreaming(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
const cleanedText = cleanTextForTTS(text); const cleanedText = cleanTextForTTS(text);
const textChunks = splitTextIntoChunks(cleanedText); const textChunks = splitTextIntoChunks(cleanedText);
console.log("TTS streaming input preview:", cleanedText.substring(0, 100) + "..."); console.log("TTS streaming input preview:", cleanedText.substring(0, 100) + "...");
@ -229,23 +357,21 @@ export async function speakTextStreaming(text, voice = "alloy", rate = 0.9, mode
console.log(`TTS streaming: canStream=${canStream}, format=${requestFormat}, mseMime=${mseMime}, textChunks=${textChunks.length}`); console.log(`TTS streaming: canStream=${canStream}, format=${requestFormat}, mseMime=${mseMime}, textChunks=${textChunks.length}`);
// If MSE isn't available or multiple chunks, fall back to buffered with chunking // Multi-chunk: pipelined playlist (parallel fetches, plays as soon as chunk 1 lands)
if (!canStream || textChunks.length > 1) { if (textChunks.length > 1) {
if (!canStream) { console.log(`TTS: ${textChunks.length} text chunks, using pipelined playlist`);
console.warn(`No streaming support for ${requestFormat}, using full buffer`); return makePlaylistResult(text, textChunks, voice, model, rate);
} else { }
console.log(`TTS: ${textChunks.length} text chunks, using buffered concatenation`);
} // Single chunk without MSE support: simple buffered fetch
const blobs = []; if (!canStream) {
for (const chunk of textChunks) { console.warn(`No streaming support for ${requestFormat}, using full buffer`);
blobs.push(await fetchTTSChunk(chunk, voice, model)); const blob = await fetchTTSChunk(textChunks[0], voice, model);
} const audioUrl = URL.createObjectURL(blob);
const audioBlob = new Blob(blobs, { type: blobs[0].type });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl); const audio = new Audio(audioUrl);
audio.playbackRate = rate; audio.playbackRate = rate;
setLastTTS(text, { audio, blob: audioBlob }); setLastTTS(text, { audio, blob });
return { audio, blob: audioBlob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: audioBlob, blobUrl: audioUrl }) }; return { audio, blob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob, blobUrl: audioUrl }) };
} }
// Single chunk: use true MSE streaming // Single chunk: use true MSE streaming
@ -529,7 +655,7 @@ export async function generateTitleForTTS(text) {
// Handle TTS - respects streaming mode setting // Handle TTS - respects streaming mode setting
// Caches audio blobs in memory so replay doesn't regenerate // Caches audio blobs in memory so replay doesn't regenerate
// Cache is keyed by text+voice+model and clears when the tab closes // Cache is keyed by text+voice+model and clears when the tab closes
export async function handleTTS(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") { export async function handleTTS(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
try { try {
const cacheKey = `${model}:${voice}:${text}`; const cacheKey = `${model}:${voice}:${text}`;

View file

@ -221,7 +221,7 @@ async function* sendMessageWithCustomHistory(messageHistory) {
} }
// Simple TTS function for chat messages (no preprocessing needed) // Simple TTS function for chat messages (no preprocessing needed)
async function speakChatText(text, voice = "alloy", rate = 1.0) { async function speakChatText(text, voice = "aria", rate = 1.0) {
try { try {
const response = await fetch(TTS_API_URL, { const response = await fetch(TTS_API_URL, {
method: "POST", method: "POST",
@ -230,7 +230,7 @@ async function speakChatText(text, voice = "alloy", rate = 1.0) {
Authorization: `Bearer ${API_KEY}`, Authorization: `Bearer ${API_KEY}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: "tts-1-qwen", model: "tts-1-f5",
voice: voice, voice: voice,
input: text, input: text,
}), }),

View file

@ -61,8 +61,8 @@ function parseVoiceSelection(selectedValue) {
const [model, voice] = selectedValue.split(":"); const [model, voice] = selectedValue.split(":");
return { model, voice }; return { model, voice };
} }
// Legacy format - assume tts-1-qwen model // Legacy format - assume tts-1-f5 model
return { model: "tts-1-qwen", voice: selectedValue }; return { model: "tts-1-f5", voice: selectedValue };
} }
// Import required functions dynamically to avoid circular dependencies // Import required functions dynamically to avoid circular dependencies

View file

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