From be9719e61feab28612499680be9efd7411c5254f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 26 Sep 2026 08:56:08 +0200 Subject: Let a button press cut the current reply (barge-in) Every press bumps a turn counter and kills the running aplay; queued audio from an older turn is dropped, and reply() now runs on its own thread and returns once its turn is stale, so the button loop is never blocked. A cut-off exchange is left out of history so user and assistant messages still alternate. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 24 +++++++++++++-- assistant.py | 99 +++++++++++++++++++++++++++++++++++++++--------------------- 2 files changed, 87 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bfc29f4..0e58c38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -362,6 +362,28 @@ file: the model fills parameters but cannot reach `mailctl tag` or confirmation before acting for exactly this reason. - These tools speak personal data (mail subjects, amounts) out loud. +### Barge-in (2026-09-26) + +A button press cuts the current reply. A global `turn` counter goes up on +every press; queued audio items carry their turn and the speaker drops +stale ones; the press kills the running `aplay` at once (that also drops +the ~1 s already in its pipe). `reply()` runs on its own thread and +returns when its turn is stale: after STT, between streamed pieces, +before tool rounds. The button loop is never blocked by a reply any more. + +- Live: stopped instantly while speaking and while a search was running, + nothing of the old reply played afterwards (by ear). A correction + ("anzi, dimmi...") pressed during a search got its own clean answer. +- A reply whose text had already finished generating is cut in audio + only; the log shows no `interrupted` then, and the exchange stays in + history. A reply cut while generating is dropped from history whole, + so user and assistant messages still alternate. +- Known ceilings (`ponytail:` comments): a running tool is not killed, + its result is dropped; a press during the old reply's STT can + overwrite the WAV mid-upload. +- Unrelated but seen: whisper garbled "i Mille" into "i 1000.000e" and + the model invented an answer for it instead of searching. + ### TTS (2026-09-25) `piper-tts` on CPU (onnxruntime), ~5 s sentence per voice: @@ -418,8 +440,6 @@ mic, TTS, glue); push-to-talk came for free from the mic button. Open, in no particular order: - **Run as a service/autostart** (it currently runs by hand). -- **Barge-in:** pressing the button while it speaks does not stop the - reply yet. - **Check presentation mode during games:** `statusctl presentation get` reads the stored flag; confirm it is 1 while a game holds it on. - **Latency:** STT ~1.2 s is the biggest share; the double encoder pass diff --git a/assistant.py b/assistant.py index 635fb81..7724b73 100644 --- a/assistant.py +++ b/assistant.py @@ -260,35 +260,51 @@ def ask(messages, calls, tools=None): yield delta["content"] +turn = 0 # bumped by every button press: older replies and audio are stale +player = None # the aplay speaking now, killed by a new press + + def speaker(q): + global player from kokoro_onnx import Kokoro k = Kokoro(CFG["kokoro"]["model"], CFG["kokoro"]["voices"]) # Each voice is a weighted blend of Kokoro style vectors. voices = {l: sum(w * k.get_voice_style(n) for n, w in v["blend"].items()) for l, v in CFG["voice"].items()} - play = None + play, playing = None, None # aplay and the turn it speaks for while True: - item = q.get() - if item is None: # end of reply - if play: - play.stdin.close() - play.wait() - play = None + t, item = q.get() + if t != turn: # from a reply the user cut off continue - if isinstance(item, float): # seconds of silence - if play: - play.stdin.write(bytes(int(sr * item) * 2)) - continue - text, lang = item - audio, sr = k.create(text, voice=voices[lang], lang=KLANG[lang]) - if play is None: - play = subprocess.Popen(["aplay", "-q", "-r", str(sr), "-f", "S16_LE", "-c", "1"], - stdin=subprocess.PIPE) - # A new stream wakes the suspended sink and the first ~0.2 s is - # lost: lead with silence so it eats that, not the first word. - play.stdin.write(bytes(int(sr * 0.3) * 2)) - # The pipe buffer lets synthesis of the next sentence overlap playback. - play.stdin.write((audio.clip(-1, 1) * 32767).astype("