feat(voice): polish voice loop UX — filler kill, barge-in, DTX flush, time/RO TTS
End-to-end voice UX iteration after DAVE E2E shipped. Each change addresses a
real symptom Marius hit in live testing today:
- Kill the 3s filler ("mă gândesc"): Claude p50 is 4-7s so the filler always
fired BEFORE the response and collided with it. Removed all filler infra
from pipeline.py + tts_stream.py (FILLER_DELAY_S, _filler_task, push_filler,
load_thinking_wav, thinking.wav cache).
- Barge-in: ttsq.clear() at the top of on_segment_done drops stale frames so
a new utterance cuts off Echo's previous response cleanly.
- DTX silence flush: Discord stops sending RTP packets when the user goes
silent (DTX), so the inline silence-check in sink.write() never fired for
the trailing audio of an utterance — STT was missed entirely. Added a
background poller thread that checks the silence-flush condition every
200ms independent of incoming packets.
- Discord audio cadence fix: EchoStreamingAudioSource.read() blocked 100ms
per call when pcm_queue was empty, wrecking Discord's 20ms frame pacing →
client interpreted the stream as stutter and discarded leading frames
(Marius heard "4 de minute în București" instead of the full sentence).
Switched to get_frame_nowait() — instant return, silence frame on empty.
- RO time expansion: "23:09" was being read as "douăzeci și trei:nouă"
with literal colon. Added expand_time() with feminine-correct minute
formatting (un minut / două minute / douăzeci de minute / una de minute).
- Supertonic Unicode sanitize centralized in tools/tts.py: Romanian curly
quotes (`„`, `"`, `"`, `—`, `…`) crash Supertonic with HTTP 500. Map them
to ASCII at the synthesize() entry so BOTH voice mode and /audio command
are covered without duplication. normalize.py re-exports for compat.
- Whisper offline: WhisperModel(..., local_files_only=True) — no more
huggingface.co metadata GET on every startup. Model is already cached.
- Diagnostic logging across the chain: sink first-packet, VAD first-speech,
voice stream block (Claude → callback), push_text (text → clauses queued),
TTS pushed (clauses → frames). Lets future "spoke but Echo silent" bugs
pinpoint exactly where the chain breaks.
- Captured Supertonic curly-quote lesson in tasks/lessons.md.
All 76 voice tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
20
tools/tts.py
20
tools/tts.py
@@ -23,6 +23,24 @@ VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
|
||||
DEFAULT_VOICE = "M2"
|
||||
DEFAULT_LANG = "ro"
|
||||
|
||||
# Punctuation Supertonic synthesis rejects with HTTP 500 (Romanian curly quotes,
|
||||
# smart dashes, ellipsis, angle quotes). Mapped to ASCII so a stray „foo" in
|
||||
# any caller's text doesn't kill the whole request.
|
||||
_TTS_PUNCT_MAP = {
|
||||
'„': '"', '“': '"', '”': '"',
|
||||
'‘': "'", '’': "'", '‚': "'",
|
||||
'«': '"', '»': '"',
|
||||
'–': '-', '—': '-',
|
||||
'…': '...',
|
||||
}
|
||||
|
||||
|
||||
def sanitize_for_supertonic(text: str) -> str:
|
||||
"""Replace Unicode punctuation Supertonic rejects with ASCII equivalents."""
|
||||
for src, dst in _TTS_PUNCT_MAP.items():
|
||||
text = text.replace(src, dst)
|
||||
return text
|
||||
|
||||
|
||||
def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
|
||||
"""Call Supertonic server and save audio to a temp WAV file.
|
||||
@@ -34,6 +52,8 @@ def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG)
|
||||
if not text or not text.strip():
|
||||
return {"ok": False, "error": "Text gol."}
|
||||
|
||||
text = sanitize_for_supertonic(text)
|
||||
|
||||
voice = voice.upper()
|
||||
if voice not in VOICES:
|
||||
voice = DEFAULT_VOICE
|
||||
|
||||
Reference in New Issue
Block a user