diff --git a/tools/gateway-chat.html b/tools/gateway-chat.html index 69337b4..dc26531 100644 --- a/tools/gateway-chat.html +++ b/tools/gateway-chat.html @@ -67,11 +67,11 @@
- + - - -
Settings persist locally. No tools field is ever sent. Reasoning models show their reasoning_content above the answer. With 🔊 on, quoted text in each reply auto-plays via mOrpheus (irv-ml1, internal).
+ + +
Settings persist locally. No tools field is ever sent. Reasoning models show their reasoning_content above the answer. With 🔊 on, quoted text in each reply auto-plays via ext-tts (Zonos, streamed direct from zonos-gateway).
@@ -88,8 +88,14 @@ const $ = id => document.getElementById(id); const cfg = ['base','key','model','sys','temp','max','ttsUrl','ttsVoice']; cfg.forEach(k => { const v = localStorage.getItem('gc_'+k); if (v !== null) $(k).value = v; }); if (!$('base').value) $('base').value = 'http://10.250.50.70:4000/v1'; -if (!$('ttsUrl').value) $('ttsUrl').value = 'http://10.100.79.3:8299/tts'; -if (!$('ttsVoice').value) $('ttsVoice').value = 'baddy'; +// ext-tts default = zonos-gateway direct (streams; bypasses LiteLLM audio buffering). Migrate any stale +// mOrpheus URL (:8299/:8210//tts) OR the LiteLLM URL (:4000) to the direct gateway, persisting so it sticks. +if (!$('ttsUrl').value || /:(8299|8210)|\/tts(\/stream)?$|10\.250\.50\.70:4000/.test($('ttsUrl').value)){ + $('ttsUrl').value = 'http://10.100.79.3:8890/v1/audio/speech'; localStorage.setItem('gc_ttsUrl', $('ttsUrl').value); +} +if (!$('ttsVoice').value || $('ttsVoice').value.trim() === 'baddy'){ + $('ttsVoice').value = 'Cora'; localStorage.setItem('gc_ttsVoice', $('ttsVoice').value); +} cfg.forEach(k => $(k).addEventListener('input', e => localStorage.setItem('gc_'+k, e.target.value))); // 🔊 auto-play toggle is a checkbox (persist .checked) $('ttsOn').checked = localStorage.getItem('gc_ttsOn') === '1'; @@ -164,10 +170,14 @@ function extractQuotes(text){ // PCM onto the shared speechHead clock; resolves when its audio is fully received (it plays on // while the NEXT quote starts generating). Chunk by QUOTE, not sentence — per-sentence lost prosody. async function streamOne(sentence, mine){ - const url = $('ttsUrl').value.trim().replace(/\/+$/,'').replace(/\/tts$/,'') + '/tts/stream'; + // ext-tts via zonos-gateway (direct, streams — bypasses LiteLLM's audio buffering): ttsUrl is the full + // OpenAI /v1/audio/speech endpoint. The gateway relays Zonos's raw float32 PCM @ 44.1kHz — decode as + // little-endian float32 (NOT int16) at 44100 Hz. The Bearer header is sent but the gateway ignores it. + const url = $('ttsUrl').value.trim().replace(/\/+$/,''); const res = await fetch(url, { - method:'POST', headers:{ 'Content-Type':'application/json' }, - body: JSON.stringify({ text: sentence, voice: ($('ttsVoice').value.trim() || 'baddy'), max_tokens: 2400 }) + method:'POST', + headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer ' + $('key').value }, + body: JSON.stringify({ model: 'ext-tts', input: sentence, voice: ($('ttsVoice').value.trim() || 'Cora') }) }); if (!res.ok || !res.body) return false; const reader = res.body.getReader(); @@ -177,12 +187,12 @@ async function streamOne(sentence, mine){ if (done || mine !== ttsGen) break; // stop if a newer reply took over let bytes = new Uint8Array(leftover.length + value.length); bytes.set(leftover); bytes.set(value, leftover.length); - const n = bytes.length - (bytes.length % 2); // whole int16 samples only + const n = bytes.length - (bytes.length % 4); // whole float32 samples only (ext-tts PCM) leftover = bytes.slice(n); if (!n) continue; - const dv = new DataView(bytes.buffer, 0, n), N = n / 2, f32 = new Float32Array(N); - for (let i = 0; i < N; i++) f32[i] = dv.getInt16(i * 2, true) / 32768; - const abuf = audioCtx.createBuffer(1, N, 24000); abuf.getChannelData(0).set(f32); + const dv = new DataView(bytes.buffer, 0, n), N = n / 4, f32 = new Float32Array(N); + for (let i = 0; i < N; i++) f32[i] = dv.getFloat32(i * 4, true); // Zonos float32 PCM, little-endian + const abuf = audioCtx.createBuffer(1, N, 44100); abuf.getChannelData(0).set(f32); const src = audioCtx.createBufferSource(); src.buffer = abuf; src.connect(audioCtx.destination); if (speechHead < audioCtx.currentTime + 0.05) speechHead = audioCtx.currentTime + 0.05; // underrun catch-up src.start(speechHead); speechHead += abuf.duration; // queue back-to-back on the shared clock @@ -193,17 +203,17 @@ async function speakQuotes(text, msg){ if (!$('ttsOn').checked) return; const quotes = extractQuotes(text); if (!quotes.length) return; - const chunks = quotes; // one chunk per QUOTED SECTION (keeps prosody) + // Streaming backend (zonos-gateway) → send ALL quoted dialogue as ONE streamed request; + // the stream itself is the incremental playback, so no per-quote chunking is needed. + const joined = quotes.join(' '); const tag = document.createElement('span'); tag.textContent = ' 🔊'; - tag.style.cursor = 'pointer'; tag.title = 'mOrpheus — ' + chunks.length + ' quote(s), click to replay'; + tag.style.cursor = 'pointer'; tag.title = 'ext-tts — ' + quotes.length + ' quote(s), click to replay'; const play = async () => { const mine = ++ttsGen; // supersede any in-flight playback primeAudio(); if (audioCtx.state === 'suspended') await audioCtx.resume(); speechHead = audioCtx.currentTime + 0.12; // reset the shared clock for this reply - for (const s of chunks){ // play quoted sections serially, in order - if (mine !== ttsGen) return; - if (!(await streamOne(s, mine))){ tag.textContent = ' 🔇'; return; } - } + if (mine !== ttsGen) return; + if (!(await streamOne(joined, mine))){ tag.textContent = ' 🔇'; return; } if (mine === ttsGen) tag.textContent = ' 🔈'; }; tag.onclick = () => play().catch(() => tag.textContent = ' 🔇');