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.disabled = true; // Disable button while processing
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);
aiAudio = result.audio;
aiBlob = result.blob;

View file

@ -16,10 +16,10 @@
// Code is seeds to sprout on any abandoned technology.
// Feature flags
// TTS disabled by default (tickets #002, #003). Embedders can re-enable:
// window.UNCLOSEAI_ENABLE_TTS = true;
// TTS enabled by default (F5-TTS backend + pipelined playlist). Embedders can disable:
// window.UNCLOSEAI_ENABLE_TTS = false;
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";

View file

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

View file

@ -97,31 +97,15 @@ async function populateVoiceDropdown(voiceSelection) {
return voices;
} catch (error) {
console.error('Failed to fetch voices:', error);
// Fallback to default voices (20 voices from Qwen3-TTS)
const defaultVoices = [
// Standard OpenAI-compatible voices
{ model: 'tts-1-qwen', voice: 'alloy' },
{ model: 'tts-1-qwen', voice: 'echo' },
{ model: 'tts-1-qwen', voice: 'fable' },
{ model: 'tts-1-qwen', voice: 'onyx' },
{ 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' }
// Fallback to F5-TTS cloned-voice catalog (40 voices, matches sidecar)
const F5_VOICES = [
'aria', 'clara', 'elena', 'grace', 'hazel', 'iris', 'luna', 'maya',
'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'
];
const defaultVoices = F5_VOICES.map(voice => ({ model: 'tts-1-f5', voice }));
renderVoiceOptions(voiceSelection, defaultVoices);
return defaultVoices;
}
@ -132,10 +116,10 @@ function renderVoiceOptions(voiceSelection, voices) {
voiceSelection.innerHTML = ''; // Clear existing options
// Get saved voice preference (vault first, fallback to default)
let savedVoice = 'tts-1:onyx'; // Default
let savedVoice = 'tts-1-f5:aria'; // Default
try {
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) {
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 speed = parseFloat(speedSlider.value);
// Parse model:voice format (e.g., "tts-1-qwen:alloy" or legacy "alloy")
let model = 'tts-1-qwen';
// Parse model:voice format (e.g., "tts-1-f5:aria" or legacy "aria")
let model = 'tts-1-f5';
let voice = selectedValue;
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
const ttsCache = new Map();
// Split long text into chunks at sentence boundaries for TTS API limits
// OpenAI-compatible TTS endpoints typically cap at ~4096 characters
const TTS_CHUNK_MAX = 1500; // conservative limit for TTS server compatibility
// Split long text into chunks at sentence boundaries.
// F5-TTS has no input cap, but smaller chunks shorten time-to-first-audio:
// 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) {
if (text.length <= TTS_CHUNK_MAX) return [text];
if (text.length <= TTS_FIRST_CHUNK_MAX) return [text];
const chunks = [];
let remaining = text;
let isFirst = true;
while (remaining.length > 0) {
if (remaining.length <= TTS_CHUNK_MAX) {
chunks.push(remaining);
break;
}
// 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();
const limit = isFirst ? TTS_FIRST_CHUNK_MAX : TTS_CHUNK_MAX;
const [head, tail] = splitOne(remaining, limit);
chunks.push(head);
remaining = tail;
isFirst = false;
}
console.log(`TTS: split ${text.length} chars into ${chunks.length} chunks:`, chunks.map((c, i) => `chunk${i + 1}=${c.length}chars`));
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
function cleanTextForTTS(text) {
return text
@ -132,28 +270,23 @@ function cleanTextForTTS(text) {
.trim();
}
// Function to read text using TTS - for Read Page button (uses Hermes)
// Automatically chunks long text after Hermes preprocessing
export async function speakText(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") {
// 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 = "aria", rate = 0.9, model = "tts-1-f5") {
try {
const spokenText = await extractSpokenTokens(text);
const chunks = splitTextIntoChunks(spokenText);
const blobs = [];
for (const chunk of chunks) {
blobs.push(await fetchTTSChunk(chunk, voice, model));
if (chunks.length > 1) {
return makePlaylistResult(text, chunks, voice, model, rate);
}
const audioBlob = new Blob(blobs, { type: blobs[0].type });
const audioUrl = URL.createObjectURL(audioBlob);
const blob = await fetchTTSChunk(chunks[0], voice, model);
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob: audioBlob });
const result = { audio, blob: audioBlob, blobUrl: audioUrl };
result.done = Promise.resolve({ blob: audioBlob, blobUrl: audioUrl });
return result;
setLastTTS(text, { audio, blob });
return { audio, blob, blobUrl: audioUrl, done: Promise.resolve({ blob, blobUrl: audioUrl }) };
} catch (error) {
console.error("Error in TTS:", error);
throw error;
@ -183,31 +316,26 @@ async function fetchTTSChunk(chunkText, voice, model) {
return await response.blob();
}
// Direct TTS function for modal - no Hermes preprocessing
// Automatically chunks long text and concatenates audio
export async function speakTextDirect(text, voice = "alloy", rate = 0.9, model = "tts-1-qwen") {
// 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 = "aria", rate = 0.9, model = "tts-1-f5") {
try {
const cleanedText = cleanTextForTTS(text);
const chunks = splitTextIntoChunks(cleanedText);
console.log("TTS input preview:", cleanedText.substring(0, 100) + "...");
// Fetch all chunks (sequentially to maintain order, parallel would risk OOM on large texts)
const blobs = [];
for (const chunk of chunks) {
blobs.push(await fetchTTSChunk(chunk, voice, model));
if (chunks.length > 1) {
return makePlaylistResult(text, chunks, voice, model, rate);
}
// Concatenate all audio blobs
const audioBlob = new Blob(blobs, { type: blobs[0].type });
const audioUrl = URL.createObjectURL(audioBlob);
const blob = await fetchTTSChunk(chunks[0], voice, model);
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob: audioBlob });
const result = { audio, blob: audioBlob, blobUrl: audioUrl };
result.done = Promise.resolve({ blob: audioBlob, blobUrl: audioUrl });
setLastTTS(text, { audio, blob });
const result = { audio, blob, blobUrl: audioUrl };
result.done = Promise.resolve({ blob, blobUrl: audioUrl });
return result;
} catch (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
// 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 = "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 textChunks = splitTextIntoChunks(cleanedText);
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}`);
// If MSE isn't available or multiple chunks, fall back to buffered with chunking
if (!canStream || textChunks.length > 1) {
if (!canStream) {
console.warn(`No streaming support for ${requestFormat}, using full buffer`);
} else {
console.log(`TTS: ${textChunks.length} text chunks, using buffered concatenation`);
}
const blobs = [];
for (const chunk of textChunks) {
blobs.push(await fetchTTSChunk(chunk, voice, model));
}
const audioBlob = new Blob(blobs, { type: blobs[0].type });
const audioUrl = URL.createObjectURL(audioBlob);
// Multi-chunk: pipelined playlist (parallel fetches, plays as soon as chunk 1 lands)
if (textChunks.length > 1) {
console.log(`TTS: ${textChunks.length} text chunks, using pipelined playlist`);
return makePlaylistResult(text, textChunks, voice, model, rate);
}
// Single chunk without MSE support: simple buffered fetch
if (!canStream) {
console.warn(`No streaming support for ${requestFormat}, using full buffer`);
const blob = await fetchTTSChunk(textChunks[0], voice, model);
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
setLastTTS(text, { audio, blob: audioBlob });
return { audio, blob: audioBlob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob: audioBlob, blobUrl: audioUrl }) };
setLastTTS(text, { audio, blob });
return { audio, blob, blobUrl: audioUrl, streamed: false, done: Promise.resolve({ blob, blobUrl: audioUrl }) };
}
// Single chunk: use true MSE streaming
@ -529,7 +655,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 = "alloy", rate = 0.9, model = "tts-1-qwen") {
export async function handleTTS(text, voice = "aria", rate = 0.9, model = "tts-1-f5") {
try {
const cacheKey = `${model}:${voice}:${text}`;

View file

@ -221,7 +221,7 @@ async function* sendMessageWithCustomHistory(messageHistory) {
}
// 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 {
const response = await fetch(TTS_API_URL, {
method: "POST",
@ -230,7 +230,7 @@ async function speakChatText(text, voice = "alloy", rate = 1.0) {
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "tts-1-qwen",
model: "tts-1-f5",
voice: voice,
input: text,
}),

View file

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

View file

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