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:
@@ -10,6 +10,8 @@ until the first tagged release.
|
||||
|
||||
### Added
|
||||
|
||||
- After a request, the butler immediately says "Let me check on that for you,
|
||||
sir" and keeps a spinner up while it works, so the long wait isn't dead air.
|
||||
- Spoken volume commands ("volume up/down", "mute/unmute", "set volume to N",
|
||||
"this one goes to eleven") are handled instantly on the gateway, bypassing the
|
||||
assistant — no waiting on a reply just to change the volume.
|
||||
|
||||
@@ -122,7 +122,12 @@ static void app_task(void *arg)
|
||||
|
||||
void app_on_playback_done(void)
|
||||
{
|
||||
face_set(FACE_IDLE);
|
||||
/* Only fall back to idle if we're still the speaker. After the butler filler
|
||||
* line, the gateway re-asserts "thinking" (FACE_EFFORT) while Tatlock works —
|
||||
* don't clobber that spinner when the filler audio finishes. */
|
||||
if (face_get() == FACE_SPEAKING) {
|
||||
face_set(FACE_IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
#if FACE_LOADTEST
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user