Skip to content

Voice light follows the microphone, not a 180-second guess - #7

Merged
camwilso merged 4 commits into
mainfrom
feature/voice-mic-signal
Aug 14, 2026
Merged

Voice light follows the microphone, not a 180-second guess#7
camwilso merged 4 commits into
mainfrom
feature/voice-mic-signal

Conversation

@camwilso

@camwilso camwilso commented Aug 14, 2026

Copy link
Copy Markdown
Owner

The problem

The voice rainbow lit when the voice key was pressed and turned off on a 180-second timer, not when dictation actually ended. And a tap with text already in the chat input types a space — dictation never starts — but the ring lit anyway.

Dictation is undetectable directly: no Claude Code hook, no state file, and dictated prompts record as typed in the transcript. The keypress belief is the only way to know recording was asked for.

The fix

Corroborate the belief with the one real signal available: CoreAudio's kAudioDevicePropertyDeviceIsRunningSomewhere — the truth behind the orange menu-bar dot. Event-driven, no polling, no mic permission needed. The ring requires both, and the mic is the half that paints:

  • A beginning. The ring stays dark until the mic actually starts — a tap is a request, not a recording. A tap that only typed a space (text was in the input) never lights at all; a real dictation lights a beat after the tap. The belief's 3-second grace window is how long the mic gets to start before the tap is judged a typed space.
  • An ending. Once lit, the ring goes dark the moment the mic stops — second tap, Escape, submit, anything.
  • Degradation, not regression. The mic is system-wide by design. A call alone never lights the ring (no belief), and a call holding the mic open past dictation's end falls back to the old bounds (prompt-submit, 180s limit). A mic starting minutes after a failed tap can't light the dead belief either.

The space itself is out of reach: the key synthesizes the space that is Claude Code's dictation toggle, and whether it toggles or types depends on chat-input state the board cannot see.

Shape

  • OpenBoardKit/VoiceSignal.swift — the pure state machine, clock-injected.
  • OpenBoardKit/MicActivity.swift — the CoreAudio listener pair: running-state on the current default input device, plus default-device moves (AirPods connecting would otherwise strand the listener on hardware nobody records from).
  • BoardController — owns the wiring: mic transitions paint, a sweep task retires beliefs whose mic never started, and a mic already running when the belief begins counts as confirmation (dictation joining a call can't flip a flag that's already up).

Tests

9 cases in VoiceSignalTests.swift covering every transition above. 453/453 passing. Hardware-tested on the pad: the tap-with-text case no longer lights the ring.

🤖 Generated with Claude Code

camwilso and others added 4 commits August 14, 2026 01:23
The ring lit on the keypress and stayed lit on a timer, because dictation
reports nothing back — not Claude Code, not macOS. It still reports nothing,
but the microphone does: CoreAudio's "device is running somewhere" property
(the truth behind the orange menu-bar dot) says whether anything is actually
recording, event-driven, no polling, no mic permission.

So the belief is now corroborated. A tap that never started dictation — text
in the input box turns the same tap into a typed space — goes dark when a
3-second grace window expires unconfirmed, instead of lying for three
minutes. A real dictation goes dark the moment the mic stops, however it was
stopped: second tap, Escape, submit. The mic is system-wide, so the
conjunction is deliberate — a call alone never lights it, and a call held
open past dictation's end degrades to the old bounds, never worse than the
belief-only version.

VoiceSignal is the pure state machine, clock-injected and tested; MicActivity
is the CoreAudio listener pair (running state on the default input device,
plus the default-device move that would otherwise strand the first listener
on unplugged hardware); BoardController owns the wiring and the repaints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardware test surfaced the honest version. Lighting on the tap and giving the
mic three seconds to veto still lights the ring for taps that only typed a
space — the exact lie, just shorter. So the gate flips: the belief opens a
grace window, and the mic starting is what paints. A failed tap never lights
at all; a real dictation lights a beat after the tap, when recording actually
begins.

The grace task becomes bookkeeping — it sweeps an unconfirmed belief and logs
that the tap typed a space, repainting nothing because nothing was lit. The
space itself is unavoidable from here: the key synthesizes the space that IS
the dictation toggle, and whether it toggles or types depends on input state
the board cannot see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Space's dual role is the root of the remaining complaint: with text in the
chat input the voice tap types a space and starts nothing. Claude Code's
voice:pushToTalk action is rebindable, and a modifier chord invokes it while
typing nothing, whatever the input holds. New voiceChord preference (default
off — it only works once ~/.claude/keybindings.json binds ⌃Y) switches the
voiceTap key from tapping space to tapping the chord. Belief and mic
corroboration are unchanged; the log now says which variant fired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chord preference shipped needing a hand-edit to keybindings.json, which
means it shipped for one machine. KeybindingInstall mirrors HookInstall's
discipline for the same file-that-is-not-ours reasons: audit is read-only and
runs at launch (only when the preference is on — a missing binding then is a
dead key), install writes only from the settings toggle, backs up first, and
preserves every binding that is not ours. A ⌃Y the user already bound to
something else is named and refused, never overwritten.

The Device pane grows a "voice key" section: one toggle, the binding written
when it flips on, and the same new-sessions-only caveat the hook repair
carries — keybindings load when a session starts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@camwilso
camwilso merged commit e013f19 into main Aug 14, 2026
1 check passed
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