From f295cc1f466f130cb26cd4ade9fabe7310de4bfb Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 9 Jul 2026 01:54:39 -0700 Subject: [PATCH] fix(gateway-chat): chunk by quoted section, not sentence (prosody) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-sentence chunking generated each sentence cold, flattening intonation/prosody that spans the whole quoted line. Chunk by QUOTED SECTION instead — each contiguous quote is generated whole (max_tokens 2400) so its prosody stays intact; multiple quotes in a reply still play serially on the shared clock. extractQuotes already returns exactly these spans; dropped splitSentences. --- stacks/gateway-chat/conf/index.html | 16 +++++++--------- tools/gateway-chat.html | 16 +++++++--------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/stacks/gateway-chat/conf/index.html b/stacks/gateway-chat/conf/index.html index ae593a9..69337b4 100644 --- a/stacks/gateway-chat/conf/index.html +++ b/stacks/gateway-chat/conf/index.html @@ -160,16 +160,14 @@ function extractQuotes(text){ while ((m = re.exec(text)) !== null){ const q = (m[1] || m[2] || '').trim(); if (q) out.push(q); } return out; } -function splitSentences(t){ - return (t.match(/[^.!?…]+[.!?…]+["”’')\]]*\s*|[^.!?…]+$/g) || [t]).map(s => s.trim()).filter(Boolean); -} -// stream ONE sentence and queue its PCM onto the shared speechHead clock; resolves when the -// sentence's audio is fully received (it plays on while the NEXT sentence starts generating). +// stream ONE quoted section (whole, so intonation/prosody across it is preserved) and queue its +// 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'; const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/json' }, - body: JSON.stringify({ text: sentence, voice: ($('ttsVoice').value.trim() || 'baddy'), max_tokens: 900 }) + body: JSON.stringify({ text: sentence, voice: ($('ttsVoice').value.trim() || 'baddy'), max_tokens: 2400 }) }); if (!res.ok || !res.body) return false; const reader = res.body.getReader(); @@ -195,14 +193,14 @@ async function speakQuotes(text, msg){ if (!$('ttsOn').checked) return; const quotes = extractQuotes(text); if (!quotes.length) return; - const sentences = splitSentences(quotes.join(' ')); // pre-chunk quoted text by sentence + const chunks = quotes; // one chunk per QUOTED SECTION (keeps prosody) const tag = document.createElement('span'); tag.textContent = ' 🔊'; - tag.style.cursor = 'pointer'; tag.title = 'mOrpheus — ' + sentences.length + ' sentence(s), click to replay'; + tag.style.cursor = 'pointer'; tag.title = 'mOrpheus — ' + chunks.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 sentences){ // play sentences serially, in order + for (const s of chunks){ // play quoted sections serially, in order if (mine !== ttsGen) return; if (!(await streamOne(s, mine))){ tag.textContent = ' 🔇'; return; } } diff --git a/tools/gateway-chat.html b/tools/gateway-chat.html index ae593a9..69337b4 100644 --- a/tools/gateway-chat.html +++ b/tools/gateway-chat.html @@ -160,16 +160,14 @@ function extractQuotes(text){ while ((m = re.exec(text)) !== null){ const q = (m[1] || m[2] || '').trim(); if (q) out.push(q); } return out; } -function splitSentences(t){ - return (t.match(/[^.!?…]+[.!?…]+["”’')\]]*\s*|[^.!?…]+$/g) || [t]).map(s => s.trim()).filter(Boolean); -} -// stream ONE sentence and queue its PCM onto the shared speechHead clock; resolves when the -// sentence's audio is fully received (it plays on while the NEXT sentence starts generating). +// stream ONE quoted section (whole, so intonation/prosody across it is preserved) and queue its +// 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'; const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/json' }, - body: JSON.stringify({ text: sentence, voice: ($('ttsVoice').value.trim() || 'baddy'), max_tokens: 900 }) + body: JSON.stringify({ text: sentence, voice: ($('ttsVoice').value.trim() || 'baddy'), max_tokens: 2400 }) }); if (!res.ok || !res.body) return false; const reader = res.body.getReader(); @@ -195,14 +193,14 @@ async function speakQuotes(text, msg){ if (!$('ttsOn').checked) return; const quotes = extractQuotes(text); if (!quotes.length) return; - const sentences = splitSentences(quotes.join(' ')); // pre-chunk quoted text by sentence + const chunks = quotes; // one chunk per QUOTED SECTION (keeps prosody) const tag = document.createElement('span'); tag.textContent = ' 🔊'; - tag.style.cursor = 'pointer'; tag.title = 'mOrpheus — ' + sentences.length + ' sentence(s), click to replay'; + tag.style.cursor = 'pointer'; tag.title = 'mOrpheus — ' + chunks.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 sentences){ // play sentences serially, in order + for (const s of chunks){ // play quoted sections serially, in order if (mine !== ttsGen) return; if (!(await streamOne(s, mine))){ tag.textContent = ' 🔇'; return; } }