close
Skip to content

feat(voice): add a push-to-talk key for voice input - #1608

Merged
CrysisDeu merged 1 commit into
mainfrom
feat/voice-push-to-talk
Aug 10, 2026
Merged

feat(voice): add a push-to-talk key for voice input#1608
CrysisDeu merged 1 commit into
mainfrom
feat/voice-push-to-talk

Conversation

@CrysisDeu

@CrysisDeu CrysisDeu commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a keyboard binding for voice dictation. Hold a bare modifier to talk; tap it to latch recording on. Default is right Option on macOS and Alt+Shift+Space elsewhere.

Settings → Voice → Speech-to-Text gains four rows: the shortcut key, how the key works, the tap/hold cutoff, and a live test strip.

Idle Holding past the cutoff
A key that is not the bound one Press on/off mode hides the cutoff

Light theme and the released-key state are in temp-screenshots/voice-ptt/.

Why these choices

Why a bare modifier, and why the user picks which one. Wispr Flow, VoiceInk and superwhisper all converge on the same shape: hold one lone modifier, chosen by the user. A bare modifier is the only key class that types nothing while held, emits no auto-repeat, and cannot collide with an editor chord — and the composer almost always has focus while dictating. Their most popular default is fn/Globe, which Chromium does not deliver as a key event at all; the right-side modifiers are the closest reachable equivalent.

Why not the right Option default everywhere. On most Windows and Linux layouts the right Alt key is AltGr: it reports ctrlKey && altKey and composes characters. A lone left Alt reveals the window menu. Those platforms get the ⌥⇧Space chord instead, which is unclaimed on all three OSes (plain Alt+Space is the Windows system menu; adding Shift leaves that path).

Why the binding is browser-local, not server config. Same reason getPreferredMicId is: the right key depends on the keyboard in front of you, and one account reaches the dashboard from several machines. A server-side default would push one machine's choice onto every other device. This also keeps the change frontend-only — no schema, no error-code contract, no migration.

Why the test strip exists. Two things cannot be answered from code or from CI: whether a given keyboard has the chosen key at all, and whether its release event reaches the page. Only pressing the key on the keyboard in front of you settles either. A dropdown will happily list Right Option on a board that has none, so without the strip the first symptom of a bad choice is voice input silently never starting. It is also the only discoverability surface available — formatShortcut renders modifier+key chords, not a lone side-specific modifier, so "hold right Option to talk" cannot be advertised in the shortcuts reference. It watches keys only and never opens the microphone.

Why the arming delay is not just a delay. It separates a tap from a hold, and it doubles as the getUserMedia pre-warm window. Acquisition plus the first audio frame costs 50–200 ms on macOS, and a single-key press has no earlier moment to hide that in — so the opening word gets clipped, and Whisper hallucinates the silent warmup into a canned phrase. Arming calls prewarm() immediately and start() only once the threshold passes, by which point the stream is live.

Implementation notes for review

  • ChatPage's toggleVoice is split into startVoice/stopVoice. That function carried the STT availability gate, the sttDisarmedRef reset and the streaming-manual-stop disarm. Calling voice.start() raw from the key driver would skip them, and a key-started dictation would then be rebuilt from stale pre-dictation text. Both entry points now share one preamble.
  • useVoiceInput now exports start/stop. It previously exported only toggle; a hold binding needs explicit start and stop, and driving it through toggle inverts the state under a race.
  • Four guards against a release that never arrives — the defining failure of a hold binding: window.blur, document.visibilitychange, a getModifierState reconciliation on any later keystroke, and a hard duration cap. A stuck-open microphone is the worst outcome here, so all four commit what was said rather than discarding it.
  • SettingsStepper.value widened to number | string so an already-localised duration ("0.5 seconds") can be displayed; the unit word cannot be split into suffix because locales place and inflect it differently. The component only interpolates the value, so numeric state stays with the caller.
  • The mode maps live at module scope as flat Records indexed inline at the i18nT() call, which is the shape check-i18n-keys.mjs resolves statically. Declaring them inside the component pushed the dynamic-key count from 11 to 14.

Copy went through a first-run usability review

A reviewer with no context on the implementation read only the screenshots and the English strings. It found a real defect: the strip contradicted itself — a small grey "never records anything" footnote sat four lines under a bright green "releasing stops recording". Verdict: a reader resolves that by distrusting both.

Fixed by leading with the reassurance and putting every verdict in the conditional ("in a real chat this would record while you hold"). Also folded in from that review:

  • The block now has a heading naming the feature, and says the default works out of the box. Previously nothing on the screen said what any of it was for.
  • Rows reordered to key → behaviour → cutoff → test. Choosing "how the key behaves" before choosing the key is unanswerable, and the strip's own prompt already assumes the order.
  • auto-repeat ×0 deleted — a debug counter that leaked into the UI, where ×0 reads as failure.
  • R ⌥ / L ⌥ spelled out to Right Option ⌥ everywhere. The abbreviation was worst exactly where the copy most needed to be unmistakable: the wrong-key warning. A test pins this, including a guard against the false pass where an unresolved i18nT returns the dotted catalog key.
  • HybridBoth, with a per-mode explainer that follows the selection. A cold reader had no way to learn what "Hybrid" combined.
  • Jargon out: modifier, threshold, trigger, the release is captured, key-up, ms.
  • The strip dropped its monospace treatment — that console styling is a large part of why a debug counter felt normal to ship.

One recommendation was not taken: the reviewer wanted the cutoff row disabled-but-visible outside Both mode rather than hidden, arguing that a row appearing and disappearing reads as a bug. Hiding it was a deliberate product decision (the cutoff has no meaning in the other modes). The underlying concern is addressed instead by naming the dependency in the row's own description, so when it is visible the tie to Both is explicit rather than inferred.

Not in this PR

  • System-wide push-to-talk (dictating into other apps). globalShortcut.register has no key-up callback, so a true hold binding needs a native event monitor plus Accessibility permission on macOS. This is exactly why website/electron/mochi/shortcuts.js reserves a voiceInput accelerator and deliberately leaves it unbound. That work would also unblock that dormant default.
  • A custom-chord recorder. The bare modifiers cover the platform defaults and every key these apps converge on. A recorder is its own surface with capture/cancel semantics — SearchEverywhereConfig in ShortcutsPanel is the pattern to follow when it lands. A chord binding is reachable today only as the non-mac default, which is why the dropdown surfaces it as a read-only entry rather than hiding it.
  • Arrow keys (+) as a binding. Prototyped and dropped: they carry caret semantics in the composer, drive menus and listboxes, and are the primary navigation mechanism for screen-reader users. The first keypress also cannot be intercepted, because it is indistinguishable from real navigation until the second key arrives.

Testing

62 new unit tests across lib/pushToTalk.test.ts and hooks/usePushToTalk.test.ts, covering all three modes, auto-repeat suppression, the bare-modifier vs chord preventDefault difference, terminal-target skipping, all four stuck-microphone guards, the release-during-async-start() race, live rebinding from another window, and per-field config coercion.

Screenshots are produced by website/scripts/capture-voice-ptt.mjs — the repo's gateway-free harness (real built SPA + route interception, no microphone). The keystrokes in it are real Playwright keyboard events, which is also what confirms AltRight arrives with location=2.

Local gates: tsc, eslint, full vitest, all i18n gates including the render-time gate, isort, flake8, and the dist-related backend tests. Two pre-existing failures were confirmed identical on main and are not from this branch: src/i18n/format.test.ts (passes under TZ=UTC) and three mypy errors in src/kiro_crew/hooks.py (os.listxattr/getxattr/setxattr are Linux-only in typeshed, so macOS reports them and CI's Linux runner does not — the file's diff here is empty).

Review round 1 closed a stuck-microphone hole

Both CI review mirrors found blocking defects in the async-startup path, and both were real. Three of the four watchdogs could not reach the case.

  • The guard was dead code twice over. ChatPage's startVoice swallowed the start promise, so the handler meant to stop a session whose startup finished after the key was released never ran in production. It also could not have worked if it had: disarm bumps the generation counter the guard compared against, so the comparison was always false by the time a released hold resolved. The guard now keys on the phase, and startVoice returns the promise.
  • A startup can fail to settle at all. The streaming path awaits a ready frame; a socket that opens and then goes silent leaves that await pending forever, and the hard cap has already been cleared by the release. Any cleanup chained on the promise inherits its liveness. So a disarm during startup no longer waits for anything — it calls cancel(), which trips the streaming session's cancelled flag and closes the socket (whose onclose settles the pending await) and releases the batch path's warm mic so acquireWarm rejects instead of handing back a stream. Nothing was captured yet, so discarding loses no audio.
  • A rejected startup needed the same treatment. useStreamingStt builds its AudioContext and worklet after getUserMedia and the socket handshake, outside any try, and useVoiceInput's streaming branch re-raises — so a throw there leaves the mic stream open with no session to stop.
  • Non-blocking, taken anyway: the settle handler's phase test was an unconditional "not mine", so releasing and then immediately latching inside the getUserMedia window had the old hold's resolution kill the brand-new session. A per-call sequence number scopes the handler to the session it opened.

Each of the four is pinned by a test verified to fail against the code before it. The original race test was a false pass: it counted stop() calls, which release-time disarm satisfies either way, so it could not distinguish the bug from the fix.

The i18n gate CI runs is stricter than the local aggregate

eslint.i18n.strict.config.js reads INSIDE ALL-CAPS module constants; the local i18n:check does not, and the [added-lines] check is zero-tolerance with no baseline to raise. Of the four literals it caught in lib/pushToTalk.ts:

  • The getModifierState('Alt'|'Control'|'Meta'|'Shift') name table is gone — a KeyboardEvent's own modifier flags carry identical information for those four families, so stillHeld reads them instead. Simpler, and no literals.
  • The KeyboardEvent.code list is exempted by a new words.exclude shape, enumerated rather than generalised: ^(?:Alt|Control|Meta|Shift)(?:Left|Right)$. The obvious ^[A-Z][a-zA-Z0-9]*$ PascalCase wildcard was rejected because it would also exempt 'Save', 'Delete' and 'Done' — the single-word-copy class the config already names as its hardest false negative. Eight members of a closed DOM set cannot match prose.
  • The numpad keycap label was real user-visible copy and moved into the catalog.

Review round 2 — the advisory reviewers found the worst bug

The two reviewers that never turn a check red found more than the blocking gates did. Four more reachable defects, all fixed:

  • A bound bare modifier used as an ordinary modifier armed the trigger. Found independently by GPT and by the UX review, which named the everyday case: a macOS user typing ⌥e for "é" releases Option under the 500 ms cutoff, and the hybrid tap path latched recording on. Held slightly longer, it started a hold outright. High frequency for anyone writing an accented language. A non-matching keydown now ends an armed press as a chord — discarding while arming (nothing was captured, and this is also what stops the release counting as a tap) and committing while holding, so a real utterance survives an accidental keypress.
  • A keystroke raised a dialog. With STT not yet set up, the key path called the same setVoiceSetupOpen(true) as the mic button, so a keystroke that used to type a character threw an unsolicited modal. The key path now starts silently; the button still explains itself. A passive binding must not interrupt.
  • Releasing during a streaming startup discarded speech. useStreamingStt connects its worklet and buffers PCM before the server's ready frame, so a hold released during a slow handshake really does have audio in it. Streaming now commits on that path (streamStop() sends the stop frame and arms its own 8 s force-cleanup, so the stuck-mic ceiling still holds); batch still discards, because it has no recorder yet and nothing was captured. GPT's suggested fix here was to disable push-to-talk whenever streaming is on — that deletes the feature for the recommended STT configuration, so the branch was taken instead of the blanket.
  • Spelling the key names out shipped Mac vocabulary everywhere. The fix for an unreadable R ⌥ handed Windows users "Right Option ⌥" for a key their keyboard labels Alt. Both platforms now have their own eight names. This one was self-inflicted by the previous round's copy fix.

Two more from the UX review: the test strip's capture-phase document listeners had no editable-target filter, so typing in the Language field below it flashed the amber wrong-key state on every keystroke; and the AltGr note moved from an always-visible amber warn box to muted helper text, since it is rationale rather than an actionable problem.

Known and deferred — the design review is right about the layer

Design Review 🟡 CONCERNS observes that the stuck-mic guarantee is built in the consumer, against the producer's leaky startup internals — and that the mic-button path therefore inherits the same reject-leaves-mic-open and never-settling-handshake exposure unguarded, while the new tests mock VoiceControls and so pin nothing about the integration.

That is accurate. The right fix is at the producer: try/finally around useStreamingStt's post-getUserMedia build plus a handshake timeout, so start() always either rejects promptly or tears down what it acquired. That fixes every caller and would let most of this hook's sequence/phase machinery collapse. It is a separate change against shared voice code with its own blast radius and its own tests, so it is not folded in here — this PR defends the path it adds, and the producer-side seal is the follow-up.

One thing a reviewer on a real keyboard can settle that CI cannot

Whether the OS delivers auto-repeat for a held modifier, and whether both key-up events arrive when two keys are released in sequence, cannot be proven with synthetic events — Playwright emits no auto-repeat at all. The four watchdogs are written to be correct either way, but if you have a non-US layout or a keyboard without a right Option, pressing the test strip is the fastest way to find a hole.

@CrysisDeu
CrysisDeu requested a review from a team as a code owner August 5, 2026 07:31
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — 🔴 changes requested (blocking)

GPT 5.6 found at least one blocking issue that must be resolved before merging 990ec04cd896a04c86a3738b0ee8af582d6cdce0.

This comment is updated in place on each push.

BLOCKING -- website/src/pages/ChatPage.tsx:1915 -- PTT release discards the authoritative streaming final
stop: stopVoice,
Streaming hold with a partial -> release calls stopVoice -> draining corrections and trailing words are disarmed -> composer retains an incomplete hypothesis.
Fix: Use a PTT stop path that rolls back the partial and leaves the draining final armed.
[GPT-REVIEWED] 990ec04
[BLOCK-MERGE] 990ec04
False positive or not applicable? A repository writer can comment:
/ai-review override gpt 990ec04cd896a04c86a3738b0ee8af582d6cdce0: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of 990ec04cd896a04c86a3738b0ee8af582d6cdce0 — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound feature, right layer for the binding — but the stuck-mic guarantee is built as consumer-side scaffolding against producer internals, and eager capture-on-keydown has unpriced side effects.

Watch

  • The hook's four-ref coordination machinery (ownerRef/startPendingRef/startSeqRef/genRef, plus useVoiceInput's new startGenRef) exists to compensate for useStreamingStt.start() building its AudioContext/worklet outside any try and awaiting a ready frame that can never settle. The deferred producer-side seal (try/finally + handshake timeout) would collapse most of it — but 62 tests now pin the consumer machinery, and the mic-button path ships unguarded against the same reject-leaves-mic-open exposure. Track the follow-up as a real commitment, not a note; every month it slips, the workaround ossifies.
  • "Open capture NOW, before the tap/hold question is settled" means every keydown of the bound modifier calls getUserMedia — so a macOS user typing ⌥e with the default AltRight binding flashes the OS mic-in-use indicator on every accented character, and a user who has never granted mic permission gets the browser's permission prompt from a bare keystroke. The silent flag suppresses your modal but cannot suppress browser chrome; this is the same "passive binding must not interrupt" defect the PR fixed one layer down.

Suggestions

  • Query navigator.permissions (where available) before the keydown-triggered start(), and fall back to threshold-deferred capture when permission is not yet granted — the clipped-first-word cost only matters once the user has actually opted into the mic.

[DESIGN-REVIEWED] 990ec04

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

Advisory UX-level review of 990ec04cd896a04c86a3738b0ee8af582d6cdce0 — updated in place on each push; does not block merge.

The evidence is complete. The copy and states are unusually well-worked; the real findings are in defaults and control. Composing the review.

UX-Verdict: CONCERNS

Well-crafted copy and test strip, but the binding is default-on with no off switch, and on Windows/Linux the default key is unrestorable once changed.

Watch

  • No way to disable the feature. usePushToTalk is always active when STT is on, and "How the key works" offers only Press on/off / Hold to talk / Both — no Off. A user whose bound key keeps colliding (accidental taps latch the mic on under the default Both mode, with only the distant mic-button pulse explaining why) can mitigate but never opt out. Frequency low, but persistence is every session and the escape route doesn't exist. Fix: add an Off option to the mode group or a toggle on the heading row.
  • Windows/Linux default is irreversible. options = bare ? [...SELECTABLE_BARE_CODES] : ['__chord__', ...] — the Alt + Shift + Space chord entry disappears the moment a bare key is picked, and the panel has no reset, so the binding the heading calls "works out of the box" can never be restored without clearing localStorage. Also recommended = defaultBinding().code is Space there, so the non-mac dropdown shows no "(recommended)" anchor at all. Fix: keep the chord selectable (or add a reset affordance).
  • Incidental key press can raise the browser mic prompt. Capture opens on keydown (launch('gesture') before the tap/hold/chord question resolves), so a macOS user typing ⌥e via right Option triggers getUserMedia — a browser permission dialog mid-typing if never granted, and an OS mic-indicator flash on every such chord after. The in-app modal was correctly silenced; this one can't be. Fix: defer getUserMedia until a second key hasn't arrived within a short grace window, or at least note it in ptt_key_desc.

Suggestions

  • Append the binding to the mic button's components.chatInput.voice_input tooltip ("Voice input — hold Right Option ⌥") so the hotkey has a visible counterpart where it's used; today it's discoverable only inside Settings → Voice.

[UX-REVIEWED] 990ec04

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 990ec04cd896a04c86a3738b0ee8af582d6cdce0 — this comment is updated in place on each push.

Review details

I've reviewed the full diff exhaustively. This is a carefully engineered, heavily-reasoned frontend-only PR (localStorage-backed config, key state machine, streaming stop deferral, re-entrancy latches). Let me confirm my read of one last risk area — the pre-ready stop path releasing the mic while keeping the socket.

The logic is sound: pre-ready stop() freezes the buffer (detaches worklet, stops tracks) and defers the stop frame until the flush in start(), with an 8s ceiling that cleanup() and the flush both clear. settleStart/failStart/launch are seq-scoped and teardown is synchronous off refs. No stuck-mic or lost-audio path survived falsification.

No blocking findings.

FINDING — temp-screenshots/voice-ptt/01-ptt-hybrid-idle-dark.png:1 (and the 5 sibling PNGs) — six binary files under a temp-screenshots/ path (self-labeled temporary) are committed to the repo, permanently bloating history for artifacts the PR text itself treats as throwaway → Fix: drop the temp-screenshots/voice-ptt/*.png files from the PR (the website/scripts/capture-voice-ptt.mjs generator already regenerates them on demand).

[OPUS-REVIEWED] 990ec04

Verdict parsed from the review's SHA-scoped output markers for commit 990ec04cd896a04c86a3738b0ee8af582d6cdce0.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 990ec04cd896a04c86a3738b0ee8af582d6cdce0: <one-sentence reason>

@CrysisDeu
CrysisDeu force-pushed the feat/voice-push-to-talk branch from f853a0f to c077385 Compare August 5, 2026 08:24
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 5, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Dispositions for the findings on f853a0fb4e008fb4d3663c4991c872b02ddaabb7, now at c077385870996c0fe768aed947a901487d50ff90.

usePushToTalk.ts:137 — pending startup survives key release — FIXED, and it was worse than reported.

Two defects were stacked. ChatPage's startVoice called voice.start() without returning it, so the deferred-stop handler never ran in production at all. And the handler could not have worked regardless: disarm bumps the generation counter it compared against, so genRef.current === gen was always false by the time a released hold resolved. startVoice now returns the promise, and the handler keys on the phase instead of the generation.

Follow-on from the same finding — a startup that never settles — FIXED. Cleanup chained on the promise inherits its liveness, and the streaming path awaits a ready frame that a silent-but-open socket never delivers, with the hard cap already cleared by the release. A disarm during startup therefore no longer waits for anything: it calls cancel() synchronously. That is also the correct verb rather than stop() — mid-startup there is no recorder and no live socket for stop() to end, whereas cancel() trips the streaming session's cancelled flag and closes the socket (whose onclose settles the pending await, as useStreamingStt.cancel's own comment notes) and releases the batch path's warm mic so acquireWarm rejects. Nothing was captured yet, so discarding loses no audio.

Rejected startup — FIXED. useStreamingStt constructs its AudioContext and worklet after getUserMedia and the socket handshake, outside any try, and useVoiceInput's streaming branch re-raises rather than catching, so a throw there left the mic stream open with no session to stop. The rejection branch now tears down through cancel() and returns the machine to idle.

Evidence: three regression tests, each verified to fail against the prior code and pass after. The original race test was a false pass — it asserted a stop() call count, which release-time disarm satisfies either way, so it could not distinguish the defect from the fix; it now asserts the end state with a fake that models recording going true only on resolve.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 5, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/voice-push-to-talk branch from c077385 to d8d1a2d Compare August 5, 2026 10:30
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 5, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Dispositions for the findings on c077385870996c0fe768aed947a901487d50ff90, now at d8d1a2d974223b0869b7f48f32ff0e99cc5381cb.

usePushToTalk.ts:222 — ordinary modifier chords latch recording — FIXED.

Confirmed and worse than the summary suggests: with the hybrid default, a chord released under the 500 ms cutoff took the tap path, which latches recording on. On macOS that is how most special characters are typed (⌥e, ⌥3, ⌥5), so this fired constantly for anyone writing an accented language. A non-matching keydown now ends an armed press as a chord: discarded while arming — nothing was captured, and that is also what stops the release from counting as a tap — and committed while holding, so a real utterance survives an accidental keypress rather than being thrown away. Four regression tests, each verified to fail against the prior code.

usePushToTalk.ts:137 — streaming hold release discards buffered speech — FIXED, but not by the suggested route.

The finding is right about the data loss. useStreamingStt connects its worklet and pushes PCM into a bounded buffer before ready arrives, flushing it once the handshake lands, and setRecording(true) happens before await readyPromise — so a hold released during a slow handshake really does have speech in it, and cancel() threw it away.

The suggested fix — disable push-to-talk while voice.streamEnabled — is not taken: streaming is the recommended STT configuration, so that removes the feature for the users most likely to use it. Fixed by branching instead. Streaming commits on release-during-startup: streamStop() is not a no-op there (the socket is already open, so it sends the stop frame) and it arms its own 8 s force-cleanup, so the stuck-mic ceiling this guard exists for still holds. Batch still cancels, because it has no recorder yet and nothing was captured. Two tests pin both branches.

Not from this round, for completeness: the two findings on f853a0fb (pending startup surviving key release; the never-settling handshake) remain fixed as described in the earlier disposition — startVoice returns the promise, the guard keys on the phase rather than the generation disarm bumps, and a disarm during startup calls cancel() synchronously rather than chaining on a promise that may never settle.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 5, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/voice-push-to-talk branch from d8d1a2d to ea576d1 Compare August 5, 2026 16:45
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 5, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Dispositions for the advisory verdicts on c077385870996c0fe768aed947a901487d50ff90, now at ea576d1532e9796231f5689a7946d14032369c7e. These checks pass, so nothing in the readiness rollup would have forced an answer — each one gets an explicit outcome anyway.

UX Review 🟡 CONCERNS — 3 fixed, 1 fixed (suggestion)

Accent typists trigger dictation — FIXED. You and the GPT gate found this independently, and your framing named the everyday case the gate did not: ⌥e for "é" releases under the 500 ms cutoff, and the hybrid default's tap path latches recording on. A non-matching keydown now ends an armed press as a chord — discarded while arming (which is also what stops the release counting as a tap), committed while holding so a real utterance is not lost to a stray key. Four regression tests, each verified to fail against the prior code.

The second half of that finding was a separate defect and is also fixed: with STT not set up, the key path called the same setVoiceSetupOpen(true) as the mic button, so a keystroke that used to type a character raised an unsolicited modal. The key path now starts silently (startVoice({ silent: true })); clicking the mic button still explains itself. A passive binding must not interrupt.

Test strip reacts to all typing on the page — FIXED. Correct: the capture-phase document listeners had no target filter, so editing the Language row below flashed the amber wrong-key state per keystroke. isTypingTarget now drops INPUT / TEXTAREA / contenteditable. <select> is deliberately not excluded — picking the shortcut key leaves focus on that dropdown, which is exactly when a user reaches for the strip, and a select takes no character input. Pinned by a unit test on the predicate rather than a rendered assertion, because the strip paints its live state from a requestAnimationFrame loop and a render-level test there is both slower and easier to get silently wrong.

Mac vocabulary shipped to Windows/Linux — FIXED. Self-inflicted by the previous round: spelling the names out (the fix for an unreadable R ⌥) handed non-mac users "Right Option ⌥" and "Right Command ⌘". bareCap was platform-aware; the spelled-out names were not. There are now two eight-key label sets and bindingLabel picks by platform, so Windows/Linux sees "Right Alt", "Right Windows", "Right Ctrl". Test asserts the mac path contains "Option" and the non-mac path contains neither "Option" nor ⌥.

Suggestion — demote the AltGr note — DONE. It is rationale rather than an actionable problem, and amber on every non-mac visit turns alarm styling into wallpaper. Now muted helper text.

Design Review 🟡 CONCERNS — accepted and deferred

The observation is correct and it is the sharpest comment on this PR: the stuck-mic guarantee is built in the consumer, against the producer's leaky startup internals. The consequences you name both hold — the mic-button path inherits the same reject-leaves-mic-open and never-settling-ready exposure unguarded, and the new tests mock VoiceControls, so nothing here pins the integration against a later useStreamingStt refactor.

Your suggested direction is the right one: move the failure contract into the producer — try/finally around the post-getUserMedia build, plus a handshake timeout — so start() always either rejects promptly or tears down what it acquired. That fixes every caller and would let most of this hook's sequence/phase machinery collapse.

Deferred rather than folded in, deliberately: it is a change to shared voice-input code on a path this PR does not otherwise touch, it needs its own tests against the real streaming module rather than a mock, and it would widen a diff that is already 30 files. Tracking it as the immediate follow-up to this PR, and the PR description now states plainly that the guard covers only the path this change adds. If you would rather see it in this PR than after it, say so and I will pull it in.

Opus 5 Review — no verdict for this SHA

Recorded for completeness rather than answered: that lane failed closed on c0773858 with Claude result reported subtype success with is_error:true about 32 minutes in — the review step itself errored, so no verdict was produced and the marker grep found nothing. The [OPUS-REVIEWED] / [BLOCK-MERGE] strings visible in that job's log are the prompt template, not output. Its stale comment on the PR is from f853a0fb, and the finding it carries (startVoice swallowing the start promise) was fixed in round 1. Re-running on this push.

@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 5, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Dispositions for the GPT 5.6 findings on d8d1a2d974223b0869b7f48f32ff0e99cc5381cb, now at ea576d1532e9796231f5689a7946d14032369c7e. All three were legitimate and reachable; all three are fixed, each pinned by a test verified to fail against the pre-fix code.

Note for whoever reads the checks rather than the logs: this round's verdict never made it into a PR comment — the review step emitted [BLOCK-MERGE] + [GPT-REVIEWED] into the job log and the gate failed correctly, but nothing was posted, so the finding text existed only in the job log.


1. BLOCKING — website/src/hooks/usePushToTalk.ts:156 · streaming release discards buffered speech — fixed

The finding is right, and the real defect was one level up in ChatPage.stopVoice, not in the stop() call the reviewer quoted.

stopVoice set sttDisarmedRef for every streaming stop, justified in its own comment by "the dictated text is already in the composer (onPartial writes each hypothesis into input)". That premise holds only after a partial has landed. useStreamingStt buffers PCM from before the server's ready frame, so a release that beats the first partial leaves the composer holding nothing while the draining final carries the whole utterance — and the disarm dropped it. That is the ordinary outcome of the first press of a session, where the handshake is slowest.

Fix: gate the disarm on the premise it was already asserting — streamEnabledRef.current && frozenInputRef.current !== null. frozenInputRef is set on the first partial, so it is the "composer holds a copy" signal. The stop() in usePushToTalk stays: committing is correct, and it is what keeps the buffered PCM.

Kept deliberately: the original guard still fires once partials have landed, because a final rebuilding from the stale snapshot would clobber text typed while the socket drains. Both directions are now pinned — keeps the utterance when a streaming stop beats the first partial (fails pre-fix) and still drops the draining final once partials have populated the composer (guards the narrowing).

2. BLOCKING — website/src/hooks/usePushToTalk.ts:267 · second press cannot cancel pending startup — fixed

Confirmed, and it applied to both non-hold starts (toggle mode and the hybrid tap-latch), neither of which tracked its startup at all.

voice.recording is false for the whole getUserMedia + handshake window, so a press arriving there fell through the "already capturing" test and called start() again. useVoiceInput's re-entrancy guard swallows the second call, so the first startup still went live: the user pressed to switch the mic off and it came on instead, with no key held to turn it back off.

Fix: every start() in the hook now goes through one launch(kind) that records what the session was opened for — 'hold' or 'latch' — in pendingKindRef, and attaches the settle/fail handlers that the toggle and latch paths previously skipped entirely. That gives two things the old code could not express:

  • the second press ends a pending session (streaming commits, batch cancels) instead of opening a second one;
  • settleStart can tell an orphan (a hold whose key is already up) from a latch the user wants left running, so it stops the former without stopping the latter.

Teardown in that window clears the kind but deliberately does not bump startSeqRef, so the settle handler still runs as the backstop for a startup that ignores the teardown and goes live anyway. My first attempt did bump it and broke exactly that — caught by the round-1 async start race test, which is why it is still there.

3. FINDING — website/src/components/PttTestStrip.tsx:101 · Alt+Shift+Space never reads as matched — fixed

Legitimate, and worse than a strip-only cosmetic issue: Alt+Shift+Space is the shipped Windows/Linux default. A chord reaches the document as separate keydowns (Alt, Shift, Space) and if (heldRef.current) return kept the first one, so the strip pinned itself to AltLeft and reported the default binding as "that's a different key" — the one surface whose job is to prove the shortcut works told every non-macOS user it was broken.

Fix: a later keydown that does satisfy the binding re-anchors the tracked press onto that completing key and restarts the hold clock there, which is also where the real trigger starts its arming timer. Auto-repeat (same code) is still ignored, a genuinely wrong key still reports wrong, and a matched press is not hijacked by a subsequent non-matching key (⌥ then e) — all three pinned.


Gates on ea576d15: tsc -b clean, eslint 0 errors, i18n:check (12 gates) and i18n:render both green against git merge-base HEAD kirocrew/main, gen:settings no diff, 116/116 tests across the touched surface.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 5, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/voice-push-to-talk branch from ea576d1 to bab1793 Compare August 5, 2026 20:14
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 5, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 6, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Disposition for the GPT 5.6 finding on ea17c6bf69d5581632e769e381b0268762021bb5, now at 06a98e58d08da40819e103ef47e6165fd1086704 (rebased onto current main). Fixed. This one is a genuine stuck microphone and I had a blind spot that let it survive eight rounds.

(The verdict posted as a comment this time, unlike the previous five rounds.)


BLOCKING — usePushToTalk.ts:427 · toggle mode treats ordinary modifier chords as dictation — fixed

Correct, and worse than the summary suggests. if (mode === 'toggle') { launch('latch'); return } latched at the keydown and returned without ever setting a phase, so the press stayed idle — and idle is the state that every safety path in this hook bails out of:

  • the chord reconciliation opens with const phase = phaseRef.current; if (phase === 'idle') return,
  • onKeyUp opens with if (was === 'idle') return,
  • onBlur / onVisibility both test phaseRef.current !== 'idle'.

So in toggle mode, pressing the bound modifier and then any other key — then E to type é — turned the microphone on, and nothing in the gesture machinery could turn it off again. Not the chord path, not the release, not losing window focus. The only way out was noticing and clicking the mic button. On the streaming transport that is an open socket, not just a local buffer.

Why this survived eight rounds, stated plainly: this is the round-2 defect ("a bare modifier armed on ordinary chords — ⌥e latched recording on"), and I fixed it by teaching the arming path to reconcile. Toggle mode never enters that path, so the fix never applied to it, and every regression test I wrote for the chord class ran under MAC_ALT_RIGHT — the hybrid default. The mode that skipped the machinery was also the mode the tests skipped.

The fix arms toggle presses like any other press. Capture still opens on the keydown, so the owner's decision stands and the opening word is still in the recording; only the ownership is deferred to the release, which is exactly what makes the press revocable. A joining key now discards it, the release promotes it to the latch, and a second press ends it as before. No hold timer is armed, because toggle mode has no hold semantics (the panel hides the cutoff row for it), so the press simply stays arming until it is released, joined, or the window blurs.

Worth noting the change also puts toggle mode inside the blur and visibility guards for the first time, which it was previously invisible to.

Your suggested fix and mine are the same shape — arm as a gesture, cancel joined chords, promote to latch on keyup. I implemented it directly.

Test. usePushToTalk.test.ts gains "a chord does not latch recording in TOGGLE mode either": press AltRight, type ⌥e, release. Verified against the pre-fix hook, where it fails with expected [ 'start' ] to deeply equal [ 'start', 'cancel' ] — the mic left recording with no teardown, precisely as reported. The two existing toggle tests (immediate start, second press stops) pass unchanged, which is the check that toggle semantics did not shift.


Gates on 06a98e58: tsc -b clean, eslint 0 errors, i18n:check RC=0, 130/130 across the seven voice/dictation/push-to-talk suites, re-verified after the rebase. 46/46 in usePushToTalk.test.ts specifically.

i18n:render still cannot run locally (base-tree build OOM-killed under host load). This round changed no strings.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 6, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/voice-push-to-talk branch from 06a98e5 to e6b90dd Compare August 6, 2026 01:37
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 6, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Disposition for the GPT 5.6 finding on 06a98e58d08da40819e103ef47e6165fd1086704, now at e6b90dda15e4f7c5f216271509c98c3dec88579f. Fixed, and the fix exposed a stale test fake that had to be corrected rather than satisfied.


BLOCKING — usePushToTalk.ts:257 · release can leave a pending microphone startup recording — fixed

Correct, including the mechanism. This was the last teardown site in the hook still branching on the wrong signal: it called stop() whenever commit was true, on the assumption that not pending implies the session is live. That assumption holds for every startup this hook launched itself. It does not hold for one it did not.

The reachable path, confirmed against the code:

  1. The mic button starts a session; voice.recording is still false during acquisition, and startPendingRef is false because this hook never launched it.
  2. A push-to-talk press therefore slips past the second-press guard (recording || (startPending && owner !== null) — both halves false) and calls start() again.
  3. useVoiceInput's re-entrancy latch swallows that call and returns nothing, so launch() reads it as a synchronous control and sets startPendingRef = false.
  4. The hold crosses the threshold; the release runs disarm(true)was === 'holding', not pending → stop().
  5. stop() cannot reach a session whose capture has not begun. The original mic-button startup then resolves and goes live — phase already back to idle, ownerRef null, nothing watching it.

That site now uses the same recording discriminator as the other three teardown paths: commit && voice.recording commits, everything else cancels. cancel() is the only call that aborts a pre-capture startup, and since the producer change earlier in this PR it actually does.

A correction to my own test suite, not a workaround for it. The fix initially failed an existing test — leaves a still-held session running when startup resolves late — whose fake resolved start() while leaving recording false. The producer never occupies that state: setRecording(true) lands before start() resolves. So the fake was asserting the release path's behaviour for an impossible input, and it was passing only because the code was reading commit instead of recording. I corrected the fake to set recording on resolution, matching the three other fakes fixed for exactly this flaw in an earlier round. This was the last stale one in the file.

Test. usePushToTalk.test.ts gains "aborts a mic-button startup instead of a no-op stop on release": a swallowed start() (no state change, no promise — what the latch actually does), hold past the threshold, release. Verified against the pre-fix hook, where it is the only failure of the 47 (expected "spy" to be called at least once) — which also confirms the corrected fake is not what carries the new assertion.


Gates on e6b90dda: tsc -b clean, eslint 0 errors, 131/131 across the seven voice/dictation/push-to-talk suites, 47/47 in usePushToTalk.test.ts. Branch is level with main.

Not ours: Backend Tests (Windows) (1) fails on test/test_acp_runtime.py::TestAcpRuntimePidTracking::test_kill_untracks_pid (assert [] == [4242]). This branch's diff contains zero Python — only website/** and six screenshots — and that test has flaked on unrelated PRs in this repo. Not touched.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: checking Automated validation is still running labels Aug 6, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/voice-push-to-talk branch from e6b90dd to 10aca02 Compare August 10, 2026 01:41
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: e6b90dda15e4f7c5f216271509c98c3dec88579f -> now 10aca02d0. Rebased from 368 commits behind onto current main; CONFLICTING cleared.

usePushToTalk.ts:253 — cold streaming holds discard buffered speech — FIXED, and the code comment defending it was wrong.

Correct and reachable, and I want to be specific about why, because the file argued the opposite. The comment claimed the pre-ready commit was safe because "streamStop() is not a no-op here (the socket is open, so it sends the stop frame)". Reading useStreamingStt settles it: that sentence conflates the call does something with the call commits the speech.

What actually happens when the key is released before ready:

  1. setRecording(true) fires as soon as the worklet connects, so voiceRef.current.recording is already true while start() is still parked on await readyPromise.
  2. PCM goes into a local buffer array — ws.send(chunk) is gated on ready, which is still false.
  3. disarm() sees recording === true and calls stop(); the socket is OPEN, so it sends {"type":"stop"} immediately.
  4. The flush loop only runs after await readyPromise resolves — by which point the backend has already been told to end the Transcribe stream.

So the utterance is transcribed as silence. This is the normal case for a short push-to-talk tap (press, say "yes", release inside the ~2-3s Transcribe startup), not an edge case.

The fix lives in useStreamingStt, not in the PTT hook, because the ordering hazard belongs to the transport and every caller has it (meetings transcription calls the same stop). stop() now checks whether ready has landed: if it has not, it records the intent and returns instead of sending the frame, and the flush site sends stop the moment the buffered audio has gone out — audio first, then stop. Deferring is bounded: an armed 8s ceiling releases the mic if ready never arrives at all, matching the existing force-cleanup constant and the buffer cap (past 8s the oldest frames are already dropped FIFO, so waiting longer cannot preserve a whole utterance anyway). cleanup() resets the flag and clears the timer, so cancel() — which routes through it — cannot leave a pending stop that a late ready would fire on an abandoned session. I also rewrote the comment in usePushToTalk to state the mechanism that now makes the commit correct, rather than leaving the reasoning that was false.

Four tests in website/src/test/useStreamingStt.stopBeforeReady.test.tsx drive the real hook against a fake socket that records every frame in order, so they assert the wire ordering rather than merely that stop was called. Red-before-green: reverting the deferral fails the ordering case with expected [ 'stop' ] to deeply equal [] — at release time the socket's only frame is the stop, and the three spoken frames never left the client. The other three lock the unchanged fast path (already-ready stops still go out immediately), the 8s ceiling, and the cancel case.

Rebase notes — three things worth naming:

  • useVoiceInput.ts: main's fix(voice): Honor mic picks, mark the live device #2281 (honour mic picks) and this PR touched the same three spots. All three resolved as unions, not choices: main's setDeviceId('') teardown plus this PR's if (gen === startGenRef.current) guard; main's track-stop on the error path; and a return object carrying both main's deviceId and this PR's start/stop.
  • SttSettings.tsx: the conflict looked like an import collision but was a real API change — main replaced micAudioConstraints() with acquireMicStream(exactId?), so the call site had to move too, not just the import. It now reads await acquireMicStream(), the same shape main's own copy of this file uses.
  • ja and ko were added to the repo after this branch cut, so the PR's translation pass never covered them while catalogParity checks every registered catalog. Wrote all 54 strings for both. Key names stay in Latin (Option / Control / Alt / Command) with a localized side word, which is how both OSes label them in ja and ko. koStyle.test.ts then caught three of mine for wrapping interpolations in curly double quotes — style/ko.md §1 requires ‘ ’ — so those are corrected, and all 11 language style suites pass.

settingsRegistry.gen.ts is generated and was auto-merged, so I re-ran gen-settings-registry.mjs rather than trusting the merge; output is byte-identical (95 entries).

One incidental change outside the feature: {"type":"stop"} moved to a named STOP_FRAME constant. Extracting the stop path put that literal on a line this branch wrote, and the i18n added-lines gate reads it as untranslated copy. Naming it is the honest fix — it is wire protocol, never shown to anyone — rather than widening the repo-wide callees exemption in eslint.i18n.config.js to cover send, which would mask real copy in every other file.

Verified on 10aca02d0: frontend 876 files / 11785 tests, 0 failures; i18n:check 13/13 PASS; all 11 locale style suites pass; duplicate-key scan clean across all 14 catalogs (object_pairs_hook, not json.loads); tsc -b clean; eslint 0 errors (574 warnings, ceiling 1116); mypy clean across 857 files; flake8 + isort clean. One commit, level with main. No backend files touched.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Aug 10, 2026
Hold a bare modifier to dictate, or tap it to latch recording on. The
default is right Option on macOS and Alt+Shift+Space elsewhere, which is
where Wispr Flow, VoiceInk and superwhisper all converge: a lone modifier
is the only key class that types nothing while held, emits no auto-repeat
and cannot collide with an editor chord -- and the composer almost always
has focus while dictating.

Settings -> Voice gains the key picker, a mode picker (press on/off, hold
to talk, or both), the tap/hold cutoff, and a live test strip. The strip
exists because two things cannot be answered from code or CI: whether a
given keyboard has the chosen key at all, and whether its release event
reaches the page. It is also the only discoverability surface available --
formatShortcut renders modifier+key chords, not a lone side-specific
modifier -- so without it "hold right Option to talk" is unguessable. It
watches keys only and never opens the microphone.

The binding is stored browser-local rather than in the server-side STT
config, for the same reason getPreferredMicId is: the right key depends
on the keyboard in front of you, and one account reaches the dashboard
from several machines. A server-side default would push the wrong key to
every other device.

ChatPage's toggleVoice is split into startVoice/stopVoice so the key
driver goes through the same STT availability gating and dictation
snapshot resets as the mic button -- calling voice.start() raw would skip
them and a key-started dictation would be rebuilt from stale
pre-dictation text. useVoiceInput now exports start/stop, because a
hold binding needs explicit start and stop; driving it through toggle
inverts the state under a race.

Four guards against the defining failure of a hold binding, a release
that never arrives: window blur, document visibility, a getModifierState
reconciliation on any later keystroke, and a hard duration cap.

The arming delay is load-bearing rather than a nuisance: it both
separates a tap from a hold and doubles as the getUserMedia pre-warm
window, so the opening word is not lost to a silent 50-200ms warmup that
Whisper otherwise hallucinates into a canned phrase.

Copy went through a first-run usability review, which caught the strip
contradicting itself -- a grey "never records anything" footnote under a
bright green "releasing stops recording". The reassurance now leads and
every verdict is in the conditional. Debug vocabulary (auto-repeat
counts, key-up, milliseconds) and the abbreviated "R / L" key names are
gone.

Two blocking findings from the review mirrors closed a stuck-microphone hole
that three of the four watchdogs could not reach:

  - ChatPage's startVoice swallowed the start promise, so the guard meant to
    stop a session whose async startup finished after the key was released
    never ran in production at all. It also could not have worked: disarm
    bumps the generation counter the guard compared against, so the test was
    always false by the time a released hold resolved. The guard now keys on
    the phase, and startVoice returns the promise.
  - A startup can fail to settle AT ALL -- the streaming path awaits a `ready`
    frame, and a socket that opens then goes silent leaves that await pending
    forever, while the hard cap has already been cleared by the release. So a
    disarm during startup no longer waits for anything: it calls cancel(),
    which trips the streaming session's cancelled flag and closes the socket
    (whose onclose settles the pending await) and releases the batch path's
    warm mic. Nothing was captured yet, so discarding loses no audio.
  - A REJECTED startup gets the same treatment: useStreamingStt builds its
    AudioContext and worklet after getUserMedia and the socket handshake,
    outside any try, and useVoiceInput's streaming branch re-raises -- so a
    throw there leaves the mic open with no session to stop.

Each of the three is pinned by a test verified to fail against the code before
it. The original race test was a false pass: it counted stop() calls, which
release-time disarm satisfies either way, so it could not distinguish the bug
from the fix.

The i18n gate that CI runs reads INSIDE ALL-CAPS module constants, which the
local aggregate does not, and its added-lines check is zero-tolerance with no
baseline to raise. Of the four literals it caught, the getModifierState name
table is gone (the event's own modifier flags carry identical information for
these four families), the KeyboardEvent.code list is exempted by an enumerated
shape -- the eight side-specific modifier codes, spelled out rather than a
PascalCase wildcard that would also exempt 'Save' and 'Delete' -- and the
numpad keycap label was real copy and moved into the catalog.

Round 2 closed four more reachable defects, three of them found by the
advisory reviewers that never turn a check red:

  - A bound bare modifier used as an ORDINARY modifier armed the trigger. On
    macOS that is how you type most special characters, so `⌥e` for "é"
    released under the 500ms cutoff and the hybrid tap path LATCHED recording
    on; held slightly longer it started a hold outright. A non-matching keydown
    now ends an armed press as a chord -- discarding while arming (nothing was
    captured, and this is also what stops the release counting as a tap) and
    committing while holding (a real utterance survives an accidental key).
  - A keystroke must never raise a dialog. With STT not yet set up, the key
    path called the same `setVoiceSetupOpen(true)` as the mic button, so a
    keystroke that used to type a character threw an unsolicited modal. The
    key path now starts silently; the button still explains itself.
  - Releasing during a STREAMING startup discarded speech. useStreamingStt
    connects its worklet and buffers PCM before the server's `ready` frame, so
    a hold released during a slow handshake really does have audio in it.
    Streaming now commits on that path (`streamStop()` sends the stop frame and
    arms its own 8s force-cleanup, so the stuck-mic ceiling still holds); batch
    still discards, because it has no recorder yet and nothing was captured.
  - Spelling the key names out -- the fix for an unreadable `R ⌥` -- shipped Mac
    vocabulary everywhere: Windows users were offered "Right Option ⌥" for a key
    their keyboard labels Alt. Both platforms now have their own eight names.

Also from the UX review: the test strip's capture-phase document listeners had
no editable-target filter, so typing in the Language field below it flashed the
amber wrong-key state on every keystroke; and the AltGr note moved from an
always-visible amber warn box to muted helper text, since it is rationale rather
than an actionable problem and alarm styling on every non-mac visit is wallpaper.

KNOWN AND DEFERRED, per the design review: the stuck-mic guarantee is built in
the CONSUMER. useStreamingStt constructs its AudioContext and worklet after
getUserMedia and the socket handshake outside any try, and useVoiceInput's
streaming branch re-raises, so the mic-BUTTON path inherits the same
reject-leaves-mic-open and never-settling-handshake exposure unguarded. Sealing
that at the producer (try/finally around the post-getUserMedia build plus a
handshake timeout) fixes every caller and would let most of this hook's
sequence/phase machinery collapse. It is a separate change against shared voice
code with its own blast radius, so it is not folded in here.

Round 3 closed three more reachable defects, all found by the CI reviewer
reading the release and second-press paths again:

  - A cold streaming release deleted the utterance. stopVoice() disarmed the
    draining final on the premise that "the text is already in the composer" --
    true only once a partial has landed. Before the server's first partial the
    composer holds nothing and that final is the only copy, which is the
    ordinary outcome of the first press of a session. The disarm is now gated on
    frozenInputRef being set, i.e. on the premise it was already asserting.

  - A second press could not cancel a startup still in flight. The
    already-capturing test read voice.recording, false for the whole
    getUserMedia + handshake window, so the press fell through and opened a
    second start() that useVoiceInput's re-entrancy guard swallowed -- leaving
    the first startup to go live against a user who had just pressed to switch
    it off. Every start() now goes through one launcher that records what the
    session was opened for ('hold' vs 'latch'), so a later press can end a
    pending one and the settle handler can still tell an orphan from a latch.

  - The test strip could never match the default Windows/Linux binding. A chord
    arrives as separate keydowns and the strip kept whichever key came first, so
    Alt+Shift+Space always read as "that's a different key" -- the one surface
    whose job is to prove the shortcut works reported the shipped default as
    broken. It now re-anchors onto the key that completes the binding.

Each fix is pinned by a test verified to fail against the pre-fix code.

Round 5 moved capture to the keydown, which is what the arming window should
have been doing all along.

`prewarm()` is a no-op on the streaming path, so waiting for the threshold put
the mic acquisition AND a ~2-3s Transcribe handshake in front of the opening
syllable -- the word the user starts on was simply not in the recording. Batch
was better only because `prewarm()` had already acquired the device; its recorder
still did not start until the threshold.

`start()` now runs on the keydown, and ONE session serves the whole gesture:
crossing the threshold promotes it to a hold, a tap in hybrid mode adopts it as
the latch, and a tap in hold-only mode or a chord discards it. Because the
session is opened before anyone knows what the press will become, `ownerRef`
records who holds it once the gesture RESOLVES ('gesture' while the key is down,
'latch' for a deliberate latch, null once torn down) -- and that, not
intent-at-open, is what the settle handler consults to tell a live session from
an orphan.

Nothing is transmitted for a discarded press: `useStreamingStt` buffers PCM
locally in a bounded window and only flushes it after the server's `ready`
frame, which lands well after the 500ms threshold has already resolved the
gesture, so `cancel()` drops the buffer unsent.

This also removes machinery rather than adding it -- `beginHold` no longer calls
`start()` (a second one would be swallowed by the producer's re-entrancy guard),
the tap-latch path no longer opens a session of its own, and `prewarm` leaves the
`VoiceControls` contract entirely.

Round 6 corrected the discriminator the pending-startup teardown uses. It asked
which TRANSPORT was in use; the question that actually matters is whether
capture has BEGUN. `useStreamingStt` flips `recording` true at the moment its
worklet is wired and PCM is buffering, and only then awaits the server's `ready`
frame -- so `recording` is exactly that boundary. Keying on `streamEnabled`
committed on the streaming path even when the release beat the permission grant,
where no socket exists, `stop()` is a no-op, and the startup would run to
completion and transmit audio for a press the user had already finished. All
three pending-teardown sites now commit only when `recording` is true and abort
otherwise, and `streamEnabled` leaves the `VoiceControls` contract.

The keydown change also stranded text on the discard path: a fast partial can
reach the composer before the press is revealed as a chord or a sub-threshold
tap, and the driver was wired to the hook's raw `cancel`, which drops the capture
without rolling the composer back. It now passes `cancelVoice`, the same
streaming rollback Esc uses, which removes the dictated region at the
frozenInputRef boundary and no-ops when nothing verifiably removable was written.

Round 7 moved the fix into the producer, which is where the last three findings
were actually pointing.

`useVoiceInput.start()` holds a re-entrancy latch for the whole async startup and
released it only in the `finally` after `await streamStart()`. Cancelling while
`getUserMedia` was still awaiting the permission dialog therefore left the latch
held for as long as that dialog stayed open -- nothing settles that await -- so
the next press hit `if (startingRef.current) return` and recorded NOTHING. On a
push-to-talk binding that is an ordinary sequence: press, release during the
first-run permission prompt, press again.

`cancel()` now releases the latch, and a per-startup generation makes that safe:
the abandoned startup can no longer clear a latch a newer press already holds,
claim ownership of a stream `useStreamingStt` has already bailed on, or run its
error teardown against the replacement session -- that path would otherwise null
the warm promise the new startup is awaiting, drop its ownership and surface a mic
error for a recording that is running fine. Releasing this startup's own tracks
stays unconditional, since an orphaned track is a live microphone.

This is the seam the previous three rounds kept compensating for in the consumer:
a startup that cannot be aborted before its socket exists. `usePushToTalk` needed
no change for it.

Round 8 closes the other half of the discard path. `cancelVoice` reconstructed the
dictated region as `frozen + separator + partial`, but `onPartial` writes it through
`spliceDictation`, which inserts at the snapshotted caret -- so for any dictation
started with the caret mid-draft the composer reads `before + partial + after` and
the append-only reconstruction failed its `startsWith` check. That fell through to
the deliberately conservative leave-unchanged branch, stranding the partial inside
the user's sentence. Acceptable for an Esc cancel, where the user knowingly started
a dictation; not acceptable now that a chord like the macOS dead key sequence opens
capture on the keydown, because the stranded word is one the user never asked to
dictate.

The rollback now reconstructs the region by calling the same `spliceDictation` the
write used, so the two cannot drift again, and it covers both the append and
mid-caret shapes. `frozenCaretRef` is cleared alongside `frozenInputRef`, since a
surviving caret would aim the next session's first splice at a position from the
discarded one.

Round 9 fixes the panel's own copy, which was mac-only prose rendered on every
platform. `ptt_heading_desc` promised "Right Option ⌥ works out of the box" and
`ptt_key_desc` offered "a key like Option, Control or Shift" -- both shown
unconditionally, so a Windows or Linux visitor read a claim about a key their
keyboard does not have, directly above a test strip saying "Press Alt + Shift +
Space". Same defect class already fixed for the key NAMES, so both strings get
the same platform split via a `PTT_COPY_KEY` module const (the i18n dynamic-key
gate only resolves direct indexing of a module-level const at the call site).
Two new keys across all ten hand-written locales plus the regenerated
pseudolocale.

Round 10 closes the same stuck-microphone hole in TOGGLE mode. The chord
reconciliation, the keyup handler and the blur/visibility guards all key off a
non-idle phase, but the toggle branch called `launch('latch')` on the keydown and
returned WITHOUT setting one -- so a toggle-mode press was invisible to every one
of them. Pressing the bound modifier and then another key (⌥ then E for `é`)
turned the microphone on and nothing in the gesture machinery could turn it off
again; only clicking the mic button could. That is the round-2 defect exactly,
surviving in the one mode whose arming path round 2 never touched.

Toggle presses are now armed like any other press -- capture still opens on the
keydown, and only the OWNERSHIP is deferred to the release, which is what makes
the press revocable. A joining key discards it, the release promotes it to the
latch, and a second press ends it as before. No hold timer is armed, since toggle
mode has no hold semantics.

Round 11 closes the last teardown site that still branched on the wrong signal.
`disarm`'s settled-hold path called `stop()` whenever `commit` was true, on the
assumption that "not pending" means the session is live. It does not when the
startup is one we never launched: a mic-button start leaves OUR `startPending`
false, so a press during its acquisition window falls through the second-press
guard and calls `start()` again, the producer's re-entrancy latch swallows it and
returns nothing, and `launch` reads that as a synchronous control and clears
`startPending`. Releasing then called `stop()` against a session whose capture had
not begun -- a no-op -- and the original startup went live afterwards with the
phase already back to idle and nothing watching it. That site now uses the same
`recording` discriminator as the other three, so it cancels instead.

One existing test had to be corrected rather than satisfied: its fake resolved
`start()` while leaving `recording` false, a state the producer never occupies
(`setRecording(true)` lands before the promise resolves). The same fake-fidelity
flaw was corrected for three other fakes in an earlier round; this was the last
one, and it was the reason the release path could look right while keying off a
value the fake never set.
@CrysisDeu
CrysisDeu force-pushed the feat/voice-push-to-talk branch from 10aca02 to 990ec04 Compare August 10, 2026 02:19
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: 10aca02d00466ecbe4ada1651953175435fac52d -> now 990ec04cd.

useStreamingStt.ts:338 — pre-ready stop keeps capturing after release — FIXED. This one was caused by my own previous round's fix, and it is worse than a transcript bug.

Correct and reachable. Deferring the stop frame until the buffer flushes solved the lost-utterance problem, but I left everything else running: the worklet's port.onmessage appends to the same buffer while ready is false, so audio captured after the user let go kept accumulating and rode out on the flush. Two consequences, and the second is the one that matters: words the user never meant to say land in the transcript, and audio recorded after release is sent to the transcription service.

Fixed by freezing capture at the moment the pending stop is recorded, which is what the deferral should have done from the start — defer the frame, never the end of capture:

  • detach workletRef.current.port.onmessage, so no new frame can reach the buffer
  • stop the level meter and emit level 0
  • stop the mic tracks

The socket and the already-buffered PCM deliberately survive, because they are exactly what the flush still has to send.

New test stops capturing at release — post-release speech never ships feeds two frames while the key is held, releases, then feeds three more before ready arrives, and asserts the wire carries ['audio', 'audio', 'stop']. Red-before-green is precise here: with the detach reverted the socket receives five audio frames (expected [ 'audio', 'audio', 'audio', …(3) ] to deeply equal [ 'audio', 'audio', 'stop' ]), and with it restored only the two captured while the key was down. The other four cases in that file still pass unchanged.

Backend Tests (Windows) (2) — NOT this branch, and main is red on the same shard.

The failure is test/test_dashboard_chat_pins.py::test_load_transient_io_error_preserves_existing_stateFailed: DID NOT RAISE <class 'OSError'> at test/test_dashboard_chat_pins.py:1945. The ContextVar ... was created in a different Context lines above it in the log are teardown noise from turn_dispatch.py:325, not the cause.

Evidence it is not mine, in the order I checked it:

  • this PR touches zero backend files (git diff --name-only origin/main...HEAD matches nothing under src/ or test/)
  • test_dashboard_chat_pins.py and dashboard/state.py (the code under test) are both unchanged by this branch
  • the file passes 77/77 locally on this branch and on a pristine origin/main worktree, so it is Windows-specific
  • origin/main itself reports failure on Backend Tests (Windows) (2), and the failing test arrives with the very commit that added it (3c7c23ed2 feat(chat): add message-level pinning (#1676))

The shard is otherwise 8853 passed / 1 failed. Nothing in this PR can move it, and I have not touched it.

Verified on 990ec04cd (rebased again, main had moved 2 commits; no conflicts): frontend 876 files / 11787 tests, 0 failures; i18n:check 13/13 PASS; duplicate-key scan clean across all 14 catalogs via object_pairs_hook rather than json.loads; settingsRegistry.gen.ts re-generated rather than trusted from the merge, output identical (95 entries); en-XA regenerated; tsc -b clean; eslint 0 errors. One commit, level with main.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 990ec04cd. Every other check is green (47 pass, and the Backend Tests (Windows) (2) red from the previous round cleared on its own, consistent with it being main's).

ChatPage.tsx:1915 — PTT release discards the authoritative streaming final — ESCALATED, not fixed. The finding is substantively right; the one-line fix as prescribed would trade the data loss for a different one, and the sound fix is a behaviour change to pre-existing shared code that needs your call.

What I verified, in order:

1. The mechanism predates this PR, and this PR narrowed it. main's toggleVoice disarms the draining final on every streaming stop:

} else if (streamEnabledRef.current) { sttDisarmedRef.current = true }

This PR split that into startVoice/stopVoice and made the stop path stricter — it only disarms when a partial has actually landed:

if (streamEnabledRef.current && frozenInputRef.current !== null) { sttDisarmedRef.current = true }

That change exists precisely to fix the case GPT is pointing at in its cold form: a short press against a cold stream where the release beats the first partial, where disarming would delete the whole utterance. So this PR strictly reduced the number of cases where speech is lost. It did not introduce the disarm.

2. GPT is still right about what remains. sttDisarmedRef gates onPartial (ChatPage.tsx:1721) as well as applyVoiceText (:1665). The hook re-emits finals.join(' ') through onPartial on every final message, and stop() deliberately leaves onmessage attached so the backend can drain. So once disarmed, any segment Transcribe finalizes during the drain never reaches the composer at all — the user keeps the last unstable hypothesis. For push-to-talk that is not a corner: a hold is short, so the tail of the utterance is exactly the part still unstable at release. The feature makes pre-existing behaviour bite where it previously rarely did, which is a fair reason to raise it here.

3. The prescribed fix is not sufficient on its own. "Leave the draining final armed" cannot just be un-disarming, because applyVoiceText appends (base + ' ' + text) rather than splicing at the frozen boundary. With a hypothesis already in the composer, the close-time final would append the whole utterance on top of it — "hello hello". That duplication is what the disarm was guarding against, and it is why the rollback half of GPT's suggestion is needed. But rolling back only fixes the append; it does not restore the drain-time partials, because those are gated by the same single flag.

4. The sound fix is a flag split, and that is the decision I want from you. One boolean is currently doing two jobs: suppress the close-time append and suppress further composer updates. Correct behaviour needs them separated — keep drain-time onPartial armed so stabilized text keeps replacing the hypothesis at the frozen boundary, and suppress only the close-time append. That flag is read by three handlers (applyVoiceText, onPartial, onEndpoint) and set by five call sites (startVoice reset, stopVoice, cancelVoice, the slot-switch effect, and send()), and it is shared by the mic button, Enter-mid-dictation, and slot switching — not just PTT. Each of those has its own reason to suppress, and at least one (send()) suppresses precisely because the text was already sent, where a late partial must not resurrect it.

I am not making that change unilaterally inside this PR: it rewrites pre-existing behaviour on four surfaces this feature does not own, on a path I cannot exercise by hand here (no microphone in this environment), and the tradeoff — may a late final overwrite text the user typed while the socket drained — is a product decision, not a mechanical one.

Three ways forward, your call:

  • Split the flag in this PR, accepting that it changes the mic button, send(), and slot-switch behaviour too, with tests for each.
  • Fix it for the PTT path only — roll back the dictated region on PTT release and let the close-time append land on clean text — leaving the mic button exactly as main has it.
  • Take this PR as the strict improvement it already is (cold-stream loss fixed, warm-stream behaviour unchanged from main) and track the flag split as its own change, since it is main's behaviour that is being redesigned.

Everything else on this SHA is green, and the two earlier BLOCKINGs from this review lane are fixed and covered by tests.

@CrysisDeu
CrysisDeu merged commit 30f5d69 into main Aug 10, 2026
49 of 51 checks passed
@CrysisDeu
CrysisDeu deleted the feat/voice-push-to-talk branch August 10, 2026 02: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.

1 participant