diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bbadea..65a6bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/firmware/main/desklock_main.c b/firmware/main/desklock_main.c index 65eedcf..2d4fd04 100644 --- a/firmware/main/desklock_main.c +++ b/firmware/main/desklock_main.c @@ -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 diff --git a/gateway/src/desklock_gateway/config.py b/gateway/src/desklock_gateway/config.py index 166a2d5..17709d3 100644 --- a/gateway/src/desklock_gateway/config.py +++ b/gateway/src/desklock_gateway/config.py @@ -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" diff --git a/gateway/src/desklock_gateway/main.py b/gateway/src/desklock_gateway/main.py index c919f7f..66bbf0c 100644 --- a/gateway/src/desklock_gateway/main.py +++ b/gateway/src/desklock_gateway/main.py @@ -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})