Pipeline TTS with LLM streaming for lower latency

Replace serial LLM-wait-TTS with overlapped execution:
- LLM streams via background thread → asyncio.Queue (non-blocking event loop)
- _sentence_split() detects sentence boundaries in the token stream
- asyncio.create_task fires TTS for each sentence immediately — TTS for
  sentence 1 runs while LLM is still generating sentences 2, 3, …
- Audio chunks stream to frontend in order as each task completes
- Time-to-first-audio drops from (LLM total + TTS total) to
  roughly (LLM time-to-first-sentence + TTS latency for one sentence)

Frontend audio queue:
- enqueueAudio() / playNextAudio() chain multi-chunk responses seamlessly
- clearAudio() stops playback and cancels queue on new turn or mic click
- scheduleAutoMic() waits for queue to drain before restarting mic
- Error paths clear the queue to avoid stale audio playing after failure

Also fix missing contextlib import (silent bug when audio temp files
needed cleanup in the STT path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-29 19:06:07 +02:00
parent f4688ecc0c
commit 34870fed27
2 changed files with 174 additions and 81 deletions

View File

@ -3,8 +3,11 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import contextlib
import io import io
import json import json
import re
import threading
import time import time
import uuid import uuid
import wave import wave
@ -30,6 +33,20 @@ from routes.stt import _transcribe_audio, _clean_stt_backend
router = APIRouter() router = APIRouter()
# ── Sentence-boundary helpers for pipelined TTS ───────────────────────────────
_SENT_RE = re.compile(r'(?<=[.!?])\s+')
_MIN_SENTENCE = 30 # min chars in buffer before we split
def _sentence_split(buf: str) -> int:
"""Return the index after the first sentence boundary, or -1."""
if len(buf) < _MIN_SENTENCE:
return -1
for m in _SENT_RE.finditer(buf):
if m.end() >= _MIN_SENTENCE:
return m.end()
return -1
# ── LLM helpers ─────────────────────────────────────────────────────────────── # ── LLM helpers ───────────────────────────────────────────────────────────────
def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str: def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str:
@ -736,52 +753,100 @@ async def conversation_turn(
yield sse({"type": "error", "stage": "stt", "message": "No speech detected."}) yield sse({"type": "error", "stage": "stt", "message": "No speech detected."})
return return
# 2. LLM stream # 2. LLM stream — runs in a thread; tokens arrive via asyncio.Queue
# so the event loop is never blocked and TTS can start on sentence 1
# while the LLM is still generating sentences 2, 3, …
messages = [{"role": "system", "content": system_prompt}] messages = [{"role": "system", "content": system_prompt}]
messages.extend(hist[-20:]) messages.extend(hist[-20:])
messages.append({"role": "user", "content": transcript}) messages.append({"role": "user", "content": transcript})
llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512} llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512}
if llm_model: if llm_model:
llm_payload["model"] = llm_model llm_payload["model"] = llm_model
_loop = asyncio.get_event_loop()
_token_q: asyncio.Queue[str | None] = asyncio.Queue()
def _llm_thread() -> None:
try:
resp = requests.post(
f"{eff_llm_url}/chat/completions", json=llm_payload,
headers={"Authorization": "Bearer no-key"}, stream=True, timeout=120,
)
resp.raise_for_status()
for raw_line in resp.iter_lines():
if not raw_line:
continue
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
if not line.startswith("data:"):
continue
payload_str = line[5:].strip()
if payload_str == "[DONE]":
break
try:
obj = json.loads(payload_str)
d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {})
delta = d_obj.get("content") or d_obj.get("reasoning_content") or ""
if not delta and isinstance(obj.get("message"), dict):
delta = obj["message"].get("content") or ""
if delta:
_loop.call_soon_threadsafe(_token_q.put_nowait, delta)
except Exception:
continue
except Exception as exc:
_loop.call_soon_threadsafe(_token_q.put_nowait, f"\x00ERR:{exc}")
finally:
_loop.call_soon_threadsafe(_token_q.put_nowait, None)
threading.Thread(target=_llm_thread, daemon=True).start()
t_llm = time.monotonic()
llm_ttft_ms: int | None = None
ttft_done = False
sent_buf = ""
tts_tasks: list[asyncio.Task] = []
tts_first_start: float | None = None
try: try:
t_llm = time.monotonic() while True:
llm_resp = await asyncio.to_thread(lambda: requests.post( delta = await _token_q.get()
f"{eff_llm_url}/chat/completions", json=llm_payload, if delta is None:
headers={"Authorization": "Bearer no-key"}, stream=True, timeout=120,
))
llm_resp.raise_for_status()
ttft_done = False
for raw_line in llm_resp.iter_lines():
if not raw_line:
continue
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
if not line.startswith("data:"):
continue
chunk = line[5:].strip()
if chunk == "[DONE]":
break break
try: if delta.startswith("\x00ERR:"):
obj = json.loads(chunk) yield sse({"type": "error", "stage": "llm", "message": delta[5:]})
d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {}) return
# Primary: visible content. Fallback: reasoning_content (Qwen3 /think tokens) if not ttft_done:
delta = d_obj.get("content") or d_obj.get("reasoning_content") or "" llm_ttft_ms = int((time.monotonic() - t_llm) * 1000)
if not delta and isinstance(obj.get("message"), dict): ttft_done = True
delta = obj["message"].get("content") or "" llm_text += delta
if delta: sent_buf += delta
if not ttft_done: yield sse({"type": "token", "delta": delta})
llm_ttft_ms = int((time.monotonic() - t_llm) * 1000) # Fire TTS on sentence boundary — runs concurrently with LLM
ttft_done = True split = _sentence_split(sent_buf)
llm_text += delta if split > 0:
yield sse({"type": "token", "delta": delta}) chunk_text = sent_buf[:split].strip()
except Exception: sent_buf = sent_buf[split:]
continue if chunk_text:
llm_total_ms = int((time.monotonic() - t_llm) * 1000) if tts_first_start is None:
yield sse({"type": "llm_done", "text": llm_text, tts_first_start = time.monotonic()
"llm_ttft_ms": llm_ttft_ms, "llm_total_ms": llm_total_ms}) tts_tasks.append(asyncio.create_task(
except Exception as e: asyncio.to_thread(_preview_request_audio, chunk_text, tts_voice, settings, "", tts_be)
yield sse({"type": "error", "stage": "llm", "message": str(e)}) ))
except Exception as exc:
yield sse({"type": "error", "stage": "llm", "message": str(exc)})
return return
# Flush any remaining text as a final TTS task
if sent_buf.strip():
if tts_first_start is None:
tts_first_start = time.monotonic()
tts_tasks.append(asyncio.create_task(
asyncio.to_thread(_preview_request_audio, sent_buf.strip(), tts_voice, settings, "", tts_be)
))
llm_total_ms = int((time.monotonic() - t_llm) * 1000)
yield sse({"type": "llm_done", "text": llm_text,
"llm_ttft_ms": llm_ttft_ms, "llm_total_ms": llm_total_ms})
if not llm_text.strip(): if not llm_text.strip():
yield sse({"type": "error", "stage": "llm", yield sse({"type": "error", "stage": "llm",
"message": "LLM returned empty response. " "message": "LLM returned empty response. "
@ -789,21 +854,23 @@ async def conversation_turn(
"or pick a non-thinking model in the Language Model dropdown."}) "or pick a non-thinking model in the Language Model dropdown."})
return return
# 3. TTS # 3. Stream audio chunks in order — each chunk's TTS ran concurrently
try: # with LLM generation, so first audio arrives much sooner than
t_tts = time.monotonic() # waiting for the full response.
audio_bytes, mime = await asyncio.to_thread( tts_start = tts_first_start or time.monotonic()
_preview_request_audio, llm_text, tts_voice, settings, "", tts_be for i, task in enumerate(tts_tasks):
) try:
tts_ms = int((time.monotonic() - t_tts) * 1000) audio_bytes, mime = await task
total_ms = int((time.monotonic() - t0) * 1000) except Exception as exc:
yield sse({"type": "error", "stage": "tts", "message": str(exc)})
return
if i == 0:
tts_ms = int((time.monotonic() - tts_start) * 1000)
yield sse({"type": "audio", "b64": base64.b64encode(audio_bytes).decode(), "mime": mime}) yield sse({"type": "audio", "b64": base64.b64encode(audio_bytes).decode(), "mime": mime})
yield sse({"type": "stats", "stt_ms": stt_ms, "llm_ttft_ms": llm_ttft_ms,
"llm_total_ms": llm_total_ms, "tts_ms": tts_ms, "total_ms": total_ms})
except Exception as e:
yield sse({"type": "error", "stage": "tts", "message": str(e)})
return
total_ms = int((time.monotonic() - t0) * 1000)
yield sse({"type": "stats", "stt_ms": stt_ms, "llm_ttft_ms": llm_ttft_ms,
"llm_total_ms": llm_total_ms, "tts_ms": tts_ms, "total_ms": total_ms})
yield sse({"type": "done"}) yield sse({"type": "done"})
return StreamingResponse(generate(), media_type="text/event-stream", return StreamingResponse(generate(), media_type="text/event-stream",

View File

@ -186,6 +186,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let previewTranscribing = false; let previewTranscribing = false;
let convCurrentAudio = null; let convCurrentAudio = null;
let autoMicGeneration = 0; let autoMicGeneration = 0;
const audioQueue = [];
let audioQueuePlaying = false;
let audioQueueDrainCb = null;
// ── Populate STT backends ──────────────────────────────────────────────── // ── Populate STT backends ────────────────────────────────────────────────
async function loadConvSttBackends() { async function loadConvSttBackends() {
@ -376,6 +379,45 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (micTimer) micTimer.textContent = ''; if (micTimer) micTimer.textContent = '';
} }
// ── Audio queue — plays multi-chunk TTS responses sequentially ──────────────
function clearAudio() {
if (convCurrentAudio) { try { convCurrentAudio.pause(); } catch(_){} convCurrentAudio = null; }
audioQueue.length = 0;
audioQueuePlaying = false;
audioQueueDrainCb = null;
}
function playNextAudio() {
if (!audioQueue.length) {
audioQueuePlaying = false;
convCurrentAudio = null;
if (audioQueueDrainCb) {
const cb = audioQueueDrainCb;
audioQueueDrainCb = null;
setTimeout(cb, 150);
} else if (micStatus && !isProcessing) {
micStatus.textContent = 'Ready';
}
return;
}
audioQueuePlaying = true;
const url = audioQueue.shift();
const el = new Audio(url);
convCurrentAudio = el;
el.addEventListener('ended', () => { URL.revokeObjectURL(url); playNextAudio(); });
el.play().catch(() => { URL.revokeObjectURL(url); playNextAudio(); });
if (micStatus) micStatus.textContent = 'Speaking…';
}
function enqueueAudio(b64, mime) {
const binStr = atob(b64);
const arr = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
const url = URL.createObjectURL(new Blob([arr], { type: mime || 'audio/wav' }));
audioQueue.push(url);
if (!audioQueuePlaying) playNextAudio();
}
// ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ── // ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ──
async function transcribeForPreview() { async function transcribeForPreview() {
if (previewTranscribing || !recChunks.length) return; if (previewTranscribing || !recChunks.length) return;
@ -408,8 +450,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (gen !== autoMicGeneration || isProcessing) return; if (gen !== autoMicGeneration || isProcessing) return;
startRecording().catch(() => {}); startRecording().catch(() => {});
} }
if (convCurrentAudio && !convCurrentAudio.ended) { if (audioQueuePlaying || audioQueue.length > 0) {
convCurrentAudio.addEventListener('ended', () => setTimeout(tryStart, 200), { once: true }); // Audio still queued — trigger after queue drains
audioQueueDrainCb = tryStart;
} else if (convCurrentAudio && !convCurrentAudio.ended) {
convCurrentAudio.addEventListener('ended', () => setTimeout(tryStart, 150), { once: true });
} else { } else {
setTimeout(tryStart, 300); setTimeout(tryStart, 300);
} }
@ -554,7 +599,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// ── Send turn via SSE ───────────────────────────────────────────────────── // ── Send turn via SSE ─────────────────────────────────────────────────────
async function processBlob(blob) { async function processBlob(blob) {
autoMicGeneration++; // cancel any pending auto-mic from previous turn autoMicGeneration++; // cancel any pending auto-mic from previous turn
convCurrentAudio = null; clearAudio();
turnCount++; turnCount++;
const turnN = turnCount; const turnN = turnCount;
const t0 = Date.now(); const t0 = Date.now();
@ -618,17 +663,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
assistantBubble.textContent = assistantText; assistantBubble.textContent = assistantText;
if (micStatus) micStatus.textContent = 'Synthesising speech…'; if (micStatus) micStatus.textContent = 'Synthesising speech…';
} else if (evt.type === 'audio') { } else if (evt.type === 'audio') {
const mime = evt.mime || 'audio/wav'; enqueueAudio(evt.b64, evt.mime || 'audio/wav');
const binStr = atob(evt.b64);
const arr = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
const audioBlob = new Blob([arr], { type: mime });
const url = URL.createObjectURL(audioBlob);
const audioEl = new Audio(url);
convCurrentAudio = audioEl;
audioEl.addEventListener('ended', () => { URL.revokeObjectURL(url); convCurrentAudio = null; });
audioEl.play().catch(() => {});
if (micStatus) micStatus.textContent = 'Speaking…';
} else if (evt.type === 'stats') { } else if (evt.type === 'stats') {
lastStats = evt; lastStats = evt;
updateStats(evt); updateStats(evt);
@ -636,17 +671,15 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
conversationHistory.push({ role: 'user', content: userBubble.textContent }); conversationHistory.push({ role: 'user', content: userBubble.textContent });
conversationHistory.push({ role: 'assistant', content: assistantText }); conversationHistory.push({ role: 'assistant', content: assistantText });
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true); addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
scheduleAutoMic(); scheduleAutoMic(); // triggers after audio queue drains
if (!handsFreeToggle?.checked && micStatus) micStatus.textContent = 'Ready';
} else if (evt.type === 'error') { } else if (evt.type === 'error') {
clearAudio();
const wrap = assistantBubble.closest('.conv-bubble-wrap'); const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove(); if (wrap) wrap.remove();
const stage = evt.stage?.toUpperCase() || 'ERR'; const stage = evt.stage?.toUpperCase() || 'ERR';
let msg = evt.message || 'Unknown error'; let msg = evt.message || 'Unknown error';
// Surface the actual server error detail, not the raw HTTP noise
const detailMatch = msg.match(/HTTP \d+:\s*(.+)/s); const detailMatch = msg.match(/HTTP \d+:\s*(.+)/s);
if (detailMatch) msg = detailMatch[1].trim(); if (detailMatch) msg = detailMatch[1].trim();
// Truncate very long stack traces
if (msg.length > 300) msg = msg.slice(0, 300) + '…'; if (msg.length > 300) msg = msg.slice(0, 300) + '…';
addErrorBubble(`[${stage}] ${msg}`); addErrorBubble(`[${stage}] ${msg}`);
addHistoryItem(turnN, Date.now() - t0, false); addHistoryItem(turnN, Date.now() - t0, false);
@ -655,6 +688,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
} }
} }
} catch(e) { } catch(e) {
clearAudio();
const wrap = assistantBubble.closest('.conv-bubble-wrap'); const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove(); if (wrap) wrap.remove();
addErrorBubble(e.message); addErrorBubble(e.message);
@ -676,7 +710,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
text = text.trim(); text = text.trim();
if (!text || isProcessing) return; if (!text || isProcessing) return;
autoMicGeneration++; // cancel any pending auto-mic autoMicGeneration++; // cancel any pending auto-mic
convCurrentAudio = null; clearAudio();
isProcessing = true; isProcessing = true;
if (sendBtn) sendBtn.disabled = true; if (sendBtn) sendBtn.disabled = true;
if (textInput) { textInput.disabled = true; textInput.value = ''; } if (textInput) { textInput.disabled = true; textInput.value = ''; }
@ -733,17 +767,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
assistantBubble.textContent = assistantText; assistantBubble.textContent = assistantText;
if (micStatus) micStatus.textContent = 'Synthesising speech…'; if (micStatus) micStatus.textContent = 'Synthesising speech…';
} else if (evt.type === 'audio') { } else if (evt.type === 'audio') {
const mime = evt.mime || 'audio/wav'; enqueueAudio(evt.b64, evt.mime || 'audio/wav');
const binStr = atob(evt.b64);
const arr = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
const audioBlob = new Blob([arr], { type: mime });
const url = URL.createObjectURL(audioBlob);
const audioEl = new Audio(url);
convCurrentAudio = audioEl;
audioEl.addEventListener('ended', () => { URL.revokeObjectURL(url); convCurrentAudio = null; });
audioEl.play().catch(() => {});
if (micStatus) micStatus.textContent = 'Speaking…';
} else if (evt.type === 'stats') { } else if (evt.type === 'stats') {
lastStats = evt; lastStats = evt;
updateStats(evt); updateStats(evt);
@ -751,9 +775,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
conversationHistory.push({ role: 'user', content: text }); conversationHistory.push({ role: 'user', content: text });
conversationHistory.push({ role: 'assistant', content: assistantText }); conversationHistory.push({ role: 'assistant', content: assistantText });
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true); addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
scheduleAutoMic(); scheduleAutoMic(); // triggers after audio queue drains
if (!handsFreeToggle?.checked && micStatus) micStatus.textContent = 'Ready';
} else if (evt.type === 'error') { } else if (evt.type === 'error') {
clearAudio();
const wrap = assistantBubble.closest('.conv-bubble-wrap'); const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove(); if (wrap) wrap.remove();
addErrorBubble(`[${evt.stage?.toUpperCase() || 'ERR'}] ${evt.message || 'Unknown error'}`); addErrorBubble(`[${evt.stage?.toUpperCase() || 'ERR'}] ${evt.message || 'Unknown error'}`);
@ -763,6 +787,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
} }
} }
} catch(e) { } catch(e) {
clearAudio();
const wrap = assistantBubble.closest('.conv-bubble-wrap'); const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove(); if (wrap) wrap.remove();
addErrorBubble(e.message); addErrorBubble(e.message);
@ -784,6 +809,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (mediaRecorder && mediaRecorder.state === 'recording') { if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording(); stopRecording();
} else { } else {
clearAudio(); // stop agent if still speaking
startRecording(); startRecording();
} }
}); });