Skip to content

feat(tools): pause_voice_input — pause the mic without ending the call - #105

Merged
TheSmokeDev merged 2 commits into
mainfrom
feat/pause-voice-input
Sep 3, 2026
Merged

feat(tools): pause_voice_input — pause the mic without ending the call#105
TheSmokeDev merged 2 commits into
mainfrom
feat/pause-voice-input

Conversation

@TheSmokeDev

@TheSmokeDev TheSmokeDev commented Sep 3, 2026

Copy link
Copy Markdown
Owner

What

pause_voice_input: "stop listening" / "mute the mic" is now a tool call by the voice model. The session stays connected, playback keeps playing, background work keeps running and its results are still announced — only the operator's speech stops reaching the provider. Resume is the same tool with paused: false and the operator's own control, because a paused microphone cannot hear the word "resume": Enter in the terminal (toggle; p/r explicit), /talk pause / /talk resume in Discord. Both directions get a spoken receipt.

Why

Until now the only way to stop Talk listening was Ctrl+C, which ends the session and orphans the spoken-delivery path — a run that lands after the hang-up has nobody to speak it to until a reconnect adopts it. Ported idea from bielcarpi/hermes-live-voice (MIT) — idea only, written fresh against our audio surface; no code copied.

How it works

  • The flag lives on the capture surface. DuplexAudio and DiscordAudio grow pause_input / resume_input / input_paused — the same one-interface pattern playback_pending followed in fix(audio): gate announcements on playback drain, not the server terminal #87 (the surface contract test now pins eleven names). Both rooms honour it identically: blocks captured while paused are dropped in the callback (never queued stale), the already-queued ones are discarded, the reader answers empty while the flag is up, and resume drains the one block that can race the flag. The Discord bridge keeps taking the host's buffers (they must not grow) and re-arming its inactivity timer (the bot must not be evicted), and keeps its pacing clock current so a resume does not replay the pause as a burst of catch-up silence frames.
  • talk_pause is the registry — one live capture surface per process, the talk_runs.attach_owner contract. run_talk_session attaches its audio before any tool can run and detaches on every exit path. With nothing attached a pause is refused, never armed: a flag remembered against a session that has not started would silently mute the next one.
  • Operator controls. Terminal: a daemon watcher that polls (msvcrt.kbhit on Windows, select elsewhere, cooked mode, no tty state ever changed) — never a blocking readline, which would outlive the session and swallow the operator's next line at the Hermes prompt /talk returns to. Offered only when stdin is a real terminal; the connected line says whether the key exists. Discord: /talk pause / /talk resume (mute/unmute accepted), /talk status says when it is paused; the canonical core lane refuses (host-owned, input-only).
  • Receipts. The model's tool result for its own flips (it also tells the operator how to resume); a contained, tools-off, self-deleting announcement (input_pause_commands) for the operator's — never both for one flip, so nothing is said twice.
  • Classification. READ_ONLY_TALK_TOOLS: it changes nothing outside the session and can only narrow what it does. In a Discord room any speaker may mute listening; nobody gains authority by it and resume is the operator's typed command. Advertised only where an operator resume control is guaranteed (see Review fixes below; the original PAUSABLE_LANES lane gate was replaced) — the dashboard tab's microphone lives in the browser, so its mint never offers it.

How to test

uv sync --extra dev
uv run pytest -q          # 1514 passed, 39 skipped, 5 xfailed on this box
uv run ruff check .       # clean (ruff 0.16.5)
uv run pytest -q tests/test_pause.py   # the 32 new cases

git diff --stat == git diff --ignore-all-space --stat (no line-ending churn).

Counts: 32 new tests in tests/test_pause.py (capture stops on both surfaces, queued/raced blocks discarded, playback + playback_pending + heard boundary untouched, Discord host buffers drained and timer armed, no silence burst on resume, registry refuses/flips-once/concurrency, tool receipts and text-flag parsing, lane-conditional advertising, keyboard watcher, /talk command routing, and a live-session run where an operator pause stops input_audio_buffer.append and is announced in the contained shape). Plus the surface-contract pin (8 → 11 names), the base-tool pin, and a /talk pause|resume registration test.

What a live check would confirm (not possible on this box)

  1. hermes talk, say "stop listening": the model calls the tool, speaks the receipt naming Enter, and nothing said afterwards produces a transcript or response; a delegated run started before the pause still lands and is still spoken. Enter → "listening again" spoken; speech resumes normally with no stale block leading the first turn.
  2. Discord talk join, /talk pause typed in the linked text channel: the room hears "microphone paused", the bot is not evicted by the host's inactivity timer during a long pause, /talk status reports the pause, /talk resume restores capture without a burst of silence frames on the wire.
  3. OpenAI/Grok/Gemini server VAD each detect the first turn after a resume cleanly (no audio flowed during the pause, so no open speech segment is left behind).

Review fixes (second commit, e3d0201)

Per the #105 review — the core stays; what changed is the operator's way back on the terminal.

Decision: pause_voice_input is advertised only where an operator resume path is guaranteed, and that decision is made once, before the tools are built. run_talk_session resolves a resume control first: /talk resume on the Discord lane; Enter on the CLI lane only when the session owns the tty (keyboard_control=True and keyboard_pause_control_available() — the very predicate the watcher starts on). default_talk_tools(pausable=...) offers the tool exactly when a control exists (the lane argument is gone; default False), the watcher starts iff the control is the keyboard, and the same control is registered with talk_pause.attach_session(resume_control=). With a non-tty stdin (mintty, a pipe, a launcher wrapper) there is no key, so no pause is offered and the connected line does not mention Enter.

  • Execution-side half of the same gate. talk_pause.set_paused(True) on a session with no registered control returns the new NO_RESUME_PATH and flips nothing — a pause call that arrives anyway (a relayed tool name, a stale schema) cannot arm a pause nobody can undo. Resuming is always allowed. This also covers the dashboard-relay case from should-fix 7 for any process whose attached surface has no way back.
  • Receipt names this room's control (should-fix 6): PAUSE_RECEIPTS[PAUSED] is a template filled from talk_pause.resume_control() — "Enter in the terminal" or "/talk resume in Discord", never both, never the other room's.
  • Windows extended keys (must-fix 2): msvcrt.getwch() returns '\x00'/'\xe0' and then the scan code; both are consumed together, so Down-Arrow ('P') no longer pauses and Insert ('R') no longer resumes. The letter branch is ASCII-only. Tested with the exact byte sequences against a fake msvcrt: [DownArrow, Insert, F1, x, é, Enter, p, R] → [None, None, None, None, None, toggle, pause, resume].
  • The Hermes TUI never gets the watcher (must-fix 3): _talk_command calls cli_entry(keyboard_control=False), and cli_entry defaults the grant to args is not None — only the argparse-dispatched standalone hermes talk owns its tty. Inside /talk the session therefore offers no pause at all (the prompt owns the keyboard; a second reader would race prompt_toolkit's for every byte and, on POSIX, park in readline() on a newline raw mode never delivers). One manual run must confirm: from the interactive hermes CLI on a real tty, type /talk; the connected line must read Ctrl+C to hang up. with no Enter clause, saying "stop listening" must NOT produce a pause (the tool is not in the session's list), pressing Enter mid-call must neither flip the microphone nor be swallowed, and after Ctrl+C the Hermes prompt must accept the next typed line normally. Separately, standalone hermes talk in a real console must show the Enter clause and toggle on Enter.
  • Capture-side drops now fail against a mutant (should-fix 5): the terminal and Discord drop tests assert the queues (_input.qsize() == 0, _inbound.qsize() == 0) right after a paused callback/deliver, so the reader gate can no longer mask a removed drop.
  • Docs (should-fix 4): README, VOICE-COMMANDS and the CHANGELOG now say plainly where the tool is offered — standalone hermes talk on a tty, and the legacy Discord lane; /talk pause//talk resume is the universal path, including on provider-host-tools.

Gates after rebase onto a270e80: 1637 passed, 40 skipped, 5 xfailed; ruff check . clean; git diff origin/main --stat == --ignore-all-space --stat (18 files, +1758/−28). tests/test_pause.py is 47 tests (+15). Eight mutants each killed: prefix ignored; callback drop removed; drain-loop drop removed; advertise gate always on; execution gate removed; cli_entry always grants; /talk grants; watcher started regardless.

Fixes #100

— SmokeDev

"Stop listening" / "mute the mic" is now a tool call by the voice model.
The session stays connected, playback keeps playing, background work keeps
running and its results are still announced; only the operator's speech
stops reaching the provider. Until now the only way to stop Talk listening
was Ctrl+C, which ends the session and orphans the spoken-delivery path.

- The flag lives on the capture surface. DuplexAudio and DiscordAudio grow
  pause_input / resume_input / input_paused, the same one-interface pattern
  playback_pending followed in #87, so both rooms honour it identically:
  blocks captured while paused are dropped in the callback (never queued
  stale), the already-queued ones are discarded, the reader answers empty,
  and resume drains the one block that can race the flag. The Discord
  bridge keeps taking the host's buffers and re-arming its inactivity timer
  so the bot is not evicted from the channel, and keeps its pacing clock
  current so a resume does not replay the pause as a burst of silence.
- talk_pause is the registry: one live capture surface per process (the
  talk_runs.attach_owner contract), attached by run_talk_session before any
  tool can run, refusing — never arming — when nothing is attached.
- Resume: the same tool with paused=false, and the operator's own control,
  because a paused microphone cannot hear the word "resume": Enter in the
  terminal (toggle; p/r explicit — a polling watcher, never a blocking stdin
  read that could swallow the next Hermes prompt line), /talk pause and
  /talk resume in Discord (/talk status says when it is paused).
- Spoken receipt both ways: the model's tool result for its own flips, a
  contained tools-off announcement for the operator's.
- Classified read-only: it changes nothing outside the session and can only
  narrow what it does; a pause is never a path to authority. Advertised only
  on lanes whose microphone this process pumps — the dashboard tab's lives
  in the browser.

Tests: 32 new in tests/test_pause.py plus the surface/base-tool pins.
Live check not possible on this box; see the PR body for what it would add.

Ported idea from bielcarpi/hermes-live-voice (MIT) — idea only, no code.

Fixes #100

— SmokeDev
Review fixes for #105 (hermes-talk#100). The core stays: the flag lives on
the capture surface, both rooms honour it, playback and background work
are untouched. What changes is the operator's way back on the terminal —
the one thing the spec said Ctrl+C must not be the answer to.

- The pause decision is made ONCE, before the tools are built, and the
  same fact drives every surface. run_talk_session resolves an operator
  resume control: `/talk resume` on the Discord lane; Enter on the CLI
  lane only when the session owns a real tty (keyboard_control=True AND
  keyboard_pause_control_available(), the very predicate the watcher
  starts on). default_talk_tools(pausable=...) advertises pause_voice_input
  exactly when a control exists (the `lane` argument is gone; default
  False), the watcher starts iff the control is the keyboard, and the
  control is registered with talk_pause.attach_session(resume_control=).
- talk_pause refuses to pause a session with no registered control
  (new outcome NO_RESUME_PATH) — the execution-side half of the gate, so
  a pause call that arrives anyway (a relayed tool name, a stale schema)
  cannot arm a pause nobody can undo. Resuming is always allowed.
- The PAUSED receipt names the control THIS session registered, never the
  other room's key or command (the wrong-room receipt from the review).
- /talk at the Hermes prompt never watches stdin: prompt_toolkit holds
  that tty in raw mode with its own reader (a second reader races it for
  every byte and, on POSIX, parks in readline() waiting for a newline raw
  mode never delivers). _talk_command passes cli_entry(keyboard_control=
  False); the standalone `hermes talk` subcommand (argparse args) is the
  only lane that gets the key, and so the only terminal lane that offers
  the pause. A non-tty stdin (mintty, a pipe, a launcher wrapper) offers
  no pause either.
- Windows: an extended key arrives from msvcrt.getwch() as a '\x00'/'\xe0'
  prefix and then its scan code; both bytes are consumed together, so
  Down-Arrow ('P') no longer pauses and Insert ('R') no longer resumes.
  The letter branch is ASCII-only.

Tests: 47 in tests/test_pause.py (+15): the Windows byte sequences with a
fake msvcrt, the advertisement predicate == the watcher's own, the
NO_RESUME_PATH gate from every source, the receipt per room, the
standalone/TUI/non-tty session matrix (advertised + watched vs neither +
refused), cli_entry's grant, and the capture-side drops asserted on the
queues so the reader gate can no longer mask them. Eight mutants each
killed (prefix ignored; callback drop removed; drain-loop drop removed;
advertise gate always on; execution gate removed; cli_entry always grants;
/talk grants; watcher started regardless).

— SmokeDev
@TheSmokeDev
TheSmokeDev force-pushed the feat/pause-voice-input branch from 64c093e to e3d0201 Compare September 3, 2026 16:50
@TheSmokeDev
TheSmokeDev merged commit d7ba9d1 into main Sep 3, 2026
12 checks passed
@TheSmokeDev
TheSmokeDev deleted the feat/pause-voice-input branch September 3, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(tools): pause_voice_input — let the model pause the mic without ending the session

1 participant