Voice assistant: tap-to-talk + hands-free conversation mode (WI-173, WI-174 v1) - #680
Merged
Merged
Conversation
WI-173: a tap on the mic raced the recognizer start, so a take never
auto-sent on silence. `startListening`'s native path is async — it awaits
the plugin import, permission and `start()` — and a `pointerup` during
those awaits ran `stopListening`, which called `removeAllListeners()`
between the startup's `addListener` and `start()` and stopped a recognizer
that was not up yet: the take was orphaned and nothing was sent.
- Defer a release that lands during native startup (`nativeStarting`)
instead of tearing the recognizer down; the take runs on, ended by
silence — tap-to-talk, as the design has always promised.
- Own silence detection in JS (the Android dictation-mode end-of-speech is
late and untunable): a timer rearmed on every partial, gated off while
the button is held so a mid-sentence pause never ends a hold.
- A grace window after `listeningState: stopped` so the final result
(delivered as one more `partialResults` after `stopped`) is what gets
sent, not the last interim guess.
- Durations read from `vogt.assistant.voice.{silence_duration_ms,
final_result_grace_ms}` localStorage with generic defaults (1000/300),
the same namespace the hands-free mode (WI-174) uses — no vendor or
estate specifics.
WI-172 (repo slice): correct the file header comment — the Android WebView
has no `speechSynthesis`, so the APK speaks through `POST /api/assistant/tts`,
and only when a TTS backend is configured (the engine defaults its speech
base-URL lists to empty so `assistant_tts_enabled` reads false rather than
advertising a mouth that cannot speak).
Tests: tap-releases-before-start still sends on silence; a release mid-start
does not tear the recognizer down; the final result after `stopped` is sent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BojRRh8yXBBqwemk6WkqVo
The pure, backend-agnostic loop behind hands-free mode: speak → quiet → send → speak the reply → re-open the mic, no touch between turns. Every effect is a port and time is an injected clock, so the interleavings a device is worst at showing — a release mid-arming, a final result after the stop, an idle session, an approval that must never be answered by voice — are unit-tested off a list of port calls rather than a microphone. - States idle → arming → listening → endpointing → sending → speaking → listening, plus paused_for_approval (mic shut until the on-screen approve/deny — a misheard "yes" authorises nothing) and an orthogonal muted flag that keeps the session alive while capture stops. - v1 half-duplex: mic closed while a reply plays, re-opened on speechFinished (no echo-safe capture on the WebView path, so barge-in/interrupt_response is designed-for but off). - Turn detection is client-owned on every backend; config in the OpenAI Realtime vocabulary (silence_duration_ms, idle_timeout_ms, max_turn_ms, max_empty_turns, interrupt_response) read from vogt.assistant.voice.* localStorage with generic defaults, so a Realtime-shaped backend adopts them unrenamed. No vendor or estate specifics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BojRRh8yXBBqwemk6WkqVo
…WI-174 v1) Drives the pure loop from voiceTurn.ts on the real recognizers and synth: - A Conversation head toggle, shown always so it is discoverable and disabled with its reason when the device has no live recognizer or no way to speak; turning it on turns spoken replies on (the loop needs a mouth) and primes the synth inside the user gesture. - Dedicated conversation capture (native plugin + Web Speech) that forwards partials and the recognizer's own stop to the machine, kept separate from the push/tap-to-talk path so WI-173 is untouched. Server STT is v1-excluded (no partials/endpoint of its own) and the toggle says so. - applyReply split into recordReply + speak: in a conversation both send and resolve route their reply through the machine, which speaks it and re-opens the mic when playback ends (speak/playServerTts gained an onDone callback); a failed turn re-opens rather than stranding the loop in "sending". - A live status chip (role=status, aria-live): Listening… / Sending / Speaking / Paused for approval / Muted / Ended. In a conversation the mic renders as a mute toggle (tap, or M on desktop); leaving the surface ends the session. - The existing foreground-service effect already holds while ttsOn, which a conversation implies — so a backgrounded conversation survives screen-off. Tests: the control is disabled-with-reason when nothing can speak; turning it on opens the recognizer and shows a live status; and a whole turn runs hands-free — sends on silence, speaks the reply, and re-opens the mic with no touch (the half a device demo cannot show). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BojRRh8yXBBqwemk6WkqVo
…op double send - voiceTurn: fence closeMic so a synchronous onend → recognizerStopped from our own stop (Web Speech's stop() fires onend) cannot re-enter the turn end and send it twice; enter `sending` before closing so an induced stop reads as sending. Unit test locks it. - gui.spec: a real-browser hands-free round trip on a controllable fake recognizer + synth — turn on, speak, go quiet, the turn sends on silence, the reply is spoken, and the mic re-opens for the next turn with no touch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BojRRh8yXBBqwemk6WkqVo
- USER_GUIDE §2.5: a quick tap opens the mic and sends on silence; the hands-free conversation control — speak, go quiet, the turn sends, the reply is spoken, the mic re-opens with no touch; the status line, mute (tap or M), approval-still-on-screen, the end conditions, and the vogt.assistant.voice.* tuning keys. - ENGINE.md §6 Voice: tap-to-talk (JS-owned silence + start-race handling); correct the TTS note (the Android WebView has no speechSynthesis, so the APK uses POST /api/assistant/tts, honest-empty when unconfigured); a hands-free bullet with the state machine, half-duplex v1, and the OpenAI-Realtime-named config keys. The full design note lives in docs/local/VOICE_HANDSFREE_DESIGN.md (gitignored, local working note). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BojRRh8yXBBqwemk6WkqVo
thedancingdeveloper
force-pushed
the
feat/voice-handsfree
branch
from
September 10, 2026 02:36
9208f5b to
c389532
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers the phone voice uplift: the mic-tap race fix that makes tap-to-talk work (WI-173), the hands-free conversation loop (WI-174 v1), and the generic slice of the prod-TTS honesty fix (WI-172). Client-only, backend-agnostic — no engine or API change.
WI-173 — tap-to-talk (race fix + JS silence)
startListening's native path is async; apointerupduring startup ranremoveAllListenersbetween the startup'saddListenerandstart()and stopped a recognizer that wasn't up yet — the take was orphaned and nothing sent. Now a release during startup is deferred (nativeStarting) and the take runs on, ended by silence. JS owns silence detection (the dictation-mode end-of-speech is late/untunable), gated off while the button is held, with a grace afterlisteningState: stoppedso the final result is what's sent.WI-174 v1 — hands-free conversation
web/src/voiceTurn.ts— a pure, backend-agnostic state machine (idle → arming → listening → endpointing → sending → speaking → listening, pluspaused_for_approvaland an orthogonalmuted). Every effect is a port; time is an injected clock. 14 unit tests cover the interleavings a device is worst at showing.Assistant.tsx— a Conversation head toggle (disabled-with-reason when there's no live recognizer or no TTS; turning it on turns Spoken replies on), dedicated native/Web-Speech capture that feeds the machine (the tap path is untouched),applyReplysplit so both send and resolve route through the loop, half-duplex speak-then-reopen, a live status chip (role=status/aria-live), and mic-as-mute (tap orM).vogt.assistant.voice.*localStorage in the OpenAI-Realtime vocabulary, so a Realtime-shaped backend adopts it unrenamed.WI-172 — generic slice
Corrected the
Assistant.tsxheader comment + ENGINE.md: the Android WebView has nospeechSynthesis, so the APK speaks throughPOST /api/assistant/tts, honest-empty when unconfigured. The advertise-no-backend lie is not in this repo (the engine defaults its speech base-URL lists empty on purpose); it's reintroduced only by a private estate overlay, which stays an operator action.Tests
voiceTurnmachine tests, 3 wiring tests incl. a full hands-free round trip, and a real-browser Playwright hands-free round trip (gui.spec.ts).Notes for the reviewer
assistant-phone-structuredneeds regenerating on a stable box — the new head button legitimately changes it, and the dev box has documented render drift (height flaps run-to-run onmaintoo), so no local regen was committed.gui.specis not CI-gated.docs/local/VOICE_HANDSFREE_DESIGN.md(gitignored working note); user/engine docs are in USER_GUIDE §2.5 and ENGINE.md §6.🤖 Generated with Claude Code
https://claude.ai/code/session_01BojRRh8yXBBqwemk6WkqVo