tts: ship F5 backend by default with pipelined playlist streaming
This commit is contained in:
parent
a4818b2afb
commit
d8949d0b6c
8 changed files with 230 additions and 120 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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(":")) {
|
||||||
|
|
|
||||||
|
|
@ -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(':')) {
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue