feat(gateway): butler filler line while Tatlock works

Tatlock turns take 10-25s, which is a long silence after a request. Speak
a canned "Let me check on that for you, sir" immediately (synthesized once
and cached), then re-assert the thinking state so the device keeps its
effort-face spinner up until the real reply arrives. On the device, guard
the playback-done handler so the filler audio finishing doesn't drop the
spinner back to idle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
This commit is contained in:
2026-07-15 21:29:35 +02:00
co-authored by Claude Opus 4.8
parent 6b7fbb60c9
commit 09f8afcadd
4 changed files with 37 additions and 1 deletions
+3
View File
@@ -19,6 +19,9 @@ class Settings(BaseSettings):
tts_model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX"
tts_voice: str = "bm_george"
# Spoken immediately after a query is accepted, to fill the long Tatlock wait.
filler_text: str = "Let me check on that for you, sir."
# embedded fallback only (requires the [speech] extra)
embedded_stt_model: str = "small"
embedded_stt_device: str = "cuda"
+26
View File
@@ -24,6 +24,21 @@ PCM_CHUNK_BYTES = 4096
# device registry: every endpoint that ever connected, keyed by client IP
DEVICES: dict[str, dict] = {}
# Butler filler audio (the "let me check…" line) is synthesized once, then replayed.
_filler_pcm: bytes | None = None
_filler_tried = False
async def _ensure_filler() -> bytes | None:
global _filler_pcm, _filler_tried
if not _filler_tried:
_filler_tried = True
try:
_filler_pcm = await asyncio.to_thread(tts.synthesize, settings.filler_text)
except Exception:
logger.exception("filler synth failed; continuing without it")
return _filler_pcm
def _now() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
@@ -111,6 +126,17 @@ async def _handle_utterance(ws: WebSocket, tatlock: TatlockClient, pcm: bytes) -
await ws.send_json({"type": "state", "value": "idle"})
return
# Acknowledge immediately with a canned line so the long Tatlock wait isn't dead
# air, then re-assert "thinking" to keep the spinner up while it works.
filler = await _ensure_filler()
if filler:
await ws.send_json({"type": "reply_text", "text": settings.filler_text})
await ws.send_json({"type": "audio_start", "sample_rate": settings.sample_rate})
for offset in range(0, len(filler), PCM_CHUNK_BYTES):
await ws.send_bytes(filler[offset : offset + PCM_CHUNK_BYTES])
await ws.send_json({"type": "audio_end"})
await ws.send_json({"type": "state", "value": "thinking"})
reply = await tatlock.ask(transcript)
await ws.send_json({"type": "reply_text", "text": reply})