From d8949d0b6c79bdd34a84edbf565db04715299185 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 24 May 2026 10:03:50 -0400 Subject: [PATCH] tts: ship F5 backend by default with pipelined playlist streaming --- public/src/chat.js | 2 +- public/src/config.js | 6 +- public/src/page-reader.js | 6 +- public/src/tts-modal.js | 40 ++-- public/src/tts.js | 286 ++++++++++++++++++++-------- public/src/ui.js | 4 +- public/src/uncloseai-embed-modal.js | 4 +- public/src/vault.js | 2 +- 8 files changed, 230 insertions(+), 120 deletions(-) diff --git a/public/src/chat.js b/public/src/chat.js index d5a88e6..3ec6e98 100644 --- a/public/src/chat.js +++ b/public/src/chat.js @@ -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; diff --git a/public/src/config.js b/public/src/config.js index 6fd4ee3..21dd92a 100644 --- a/public/src/config.js +++ b/public/src/config.js @@ -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"; diff --git a/public/src/page-reader.js b/public/src/page-reader.js index c846110..77cd4ad 100644 --- a/public/src/page-reader.js +++ b/public/src/page-reader.js @@ -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(":")) { diff --git a/public/src/tts-modal.js b/public/src/tts-modal.js index a7adbaa..411fe66 100644 --- a/public/src/tts-modal.js +++ b/public/src/tts-modal.js @@ -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(':')) { diff --git a/public/src/tts.js b/public/src/tts.js index 6216848..2342da7 100644 --- a/public/src/tts.js +++ b/public/src/tts.js @@ -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