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.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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(":")) {
|
||||
|
|
|
|||
|
|
@ -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(':')) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue