Skip to content

fix(mac): release the mic on every path — exit, signals, sleep races + keep-awake toggle - #22

Merged
missingbulb merged 3 commits into
mainfrom
claude/webcam-microphone-dead-state-fitgde
Jul 19, 2026
Merged

fix(mac): release the mic on every path — exit, signals, sleep races + keep-awake toggle#22
missingbulb merged 3 commits into
mainfrom
claude/webcam-microphone-dead-state-fitgde

Conversation

@missingbulb

@missingbulb missingbulb commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Problem

After using LaughCounter, the webcam microphone goes into a dead state — no sound registered anywhere (System Settings → Sound → Input shows nothing) until the webcam is physically unplugged and reconnected. Root cause class: the process ending (or capture restarting unsafely) while the AVAudioEngine input tap's CoreAudio IOProc is still registered on the device — some USB webcam mics wedge on that.

Changes

Three commits, in increasing depth. The third round came from an exhaustive multi-agent audit (5 lenses × adversarial verification) of every path where capture ends.

1. Release the mic on terminate (applicationWillTerminate)

quit() called NSApp.terminate with the engine running and the speech task live — no teardown existed anywhere. Added the terminate handler: flush, stop speech, remove tap, stop engine. Covers menu Quit, ⌘Q, and logout/shutdown (no NSSupportsSuddenTermination in Info.plist, so logout does go through it).

2. Opt-in "Keep Mac awake while listening" menu toggle

Off by default, persisted. While enabled and listening, holds a .idleSystemSleepDisabled activity so laughs are still heard when the Mac would idle-sleep (mic can't run during sleep and can't wake the machine, so preventing idle sleep is the only option). Kept in sync via refreshTitle(), released on stop/sleep/terminate.

3. Audit round — the remaining gaps

  • Signals: NSApplication installs no signal handlers, so SIGTERM (Activity Monitor "Quit", killall), SIGINT (Ctrl-C in dev), SIGHUP all killed the process with no teardown — the wedge again, on the most likely quit gestures for a menu-bar-only app. main.swift now routes them via DispatchSourceSignalNSApp.terminate (SIG_IGN set before resume() so there's no delivery gap). SIGKILL/Force-Quit/crash remain uncoverable — documented residual risk.
  • Stale-restart races: stopListening couldn't cancel the pending +0.4s finishListening; sleep in that window could start the engine going into sleep, and asyncAfter timers fire immediately at wake — bypassing the settle delay. Every intentional stop now bumps restartGeneration; all delayed closures (finish, settle, wake resume) capture and check it. Stops also reset the in-flight/queued/suppress latches so they can't stay stuck.
  • Sleep gate: CoreAudio device-teardown notifications between willSleep and actual sleep could restart capture behind the intentional stop. A sleeping gate spans willSleep → post-wake settle; the wake resume is the only exit and is generation-guarded against quick re-sleeps.
  • Swallowed config changes reconciled: genuine device events landing in the suppress window are no longer silently lost — settle-time reconciliation restarts when the engine halted while "listening", and retries once per swallowed event after a failed start (a dead mic emits no events → can't poll).
  • AudioHub: engine.stop() now unconditional (releases prepare()-stage resources on failed starts); live format re-validated immediately before installTap (converts the realistic device-changed cases of an uncatchable NSException into a catchable error — narrowed, not eliminated; no atomic API exists).
  • VoiceCommand: state was touched from main, the Speech callback queue, and the audio tap thread with no synchronization (UB). Now one lock guards everything, a generation counter prevents a delayed restart resurrecting a stop, and the two-phase start() gates the task store on request identity — gating on running would let an immediate-error restart between the phases store a dead task and permanently block the idempotency guard. Speech also stops/resumes across sleep now.
  • Lessons captured in dev/procedures/mac-audio-lifecycle.md.

Testing

macOS-only target — cannot be compiled in the Linux session; the feature-branch CI build (macOS runner) is the compile gate for this PR. Behavioral verification on a Mac:

  • Mic release: listen on a USB webcam mic, then quit via menu / ⌘Q / killall LaughCounter / Ctrl-C (terminal run) — after each, the mic must still register input in System Settings without re-plugging.
  • Sleep races: click "Restart listening" and immediately close the lid; on wake, exactly one "listening started" within ~2s and no start before the settle delay (check the activity log).
  • Keep-awake: toggle on, let the display sleep — machine stays awake and keeps counting; toggle off — normal idle sleep resumes.

🤖 Generated with Claude Code

https://claude.ai/code/session_0163EGo8fLif2oRwRHhmXKnV

claude added 2 commits July 19, 2026 19:01
The webcam mic went dead after quitting — no input registered in System
Settings until the device was physically unplugged and reconnected.

Cause: no exit-path teardown. `quit()` called `NSApp.terminate` while the
AVAudioEngine was still running with its input tap installed and the
SFSpeechRecognizer task still holding the audio stream. The process died
with its CoreAudio IOProc still registered on the input device, and some
USB webcam mics get wedged by that and go silent until re-plugged.

Add `applicationWillTerminate` — reached for every exit path (menu Quit,
Cmd-Q, logout/shutdown) — that flushes any in-progress laugh, stops the
speech recogniser, and tears down the input tap + engine so the IOProc is
deregistered and the device is handed cleanly back to the system.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163EGo8fLif2oRwRHhmXKnV
While the Mac is asleep no app can use the mic (the audio hardware is
powered down) and the mic cannot wake the machine, so catching laughs
during a long movie that would otherwise idle-sleep requires holding a
power assertion.

Add an opt-in menu toggle (off by default, persisted in UserDefaults).
While it is enabled *and* the app is listening, hold a
`.idleSystemSleepDisabled` ProcessInfo activity so idle system sleep is
blocked (the display may still sleep — only audio needs to keep running).
The assertion is kept in sync via refreshTitle(), the chokepoint every
`listening` transition already passes through, and is released on stop,
sleep, and terminate. It does not block lid-close or manual Sleep and
costs battery, hence opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163EGo8fLif2oRwRHhmXKnV
@missingbulb missingbulb changed the title fix(mac): release the microphone cleanly on quit fix(mac): release the mic cleanly on quit + opt-in keep-awake toggle Jul 19, 2026
…udit

An adversarially-verified audit of every path where capture ends confirmed
the terminate fix alone was not enough:

- Signals: NSApplication installs no signal handlers, so SIGTERM (Activity
  Monitor Quit, killall), SIGINT (Ctrl-C in dev), and SIGHUP killed the
  process without applicationWillTerminate — the IOProc-left-registered
  wedge again. main.swift now routes them through DispatchSourceSignal into
  NSApp.terminate (SIG_IGN before resume, so no delivery gap). SIGKILL and
  crashes remain uncoverable; documented as residual risk.

- Stale restarts: stopListening could not cancel the pending +0.4s
  finishListening, so sleep arriving in that gap could start the engine
  going INTO sleep (asyncAfter timers also fire immediately at wake,
  bypassing the settle delay). Every intentional stop now bumps
  restartGeneration; the delayed finish/settle/wake closures capture it and
  abort if it moved. stopListening also resets the in-flight/queued/
  suppress latches so they can never stay stuck blocking future starts.

- Sleep gate: config-change notifications posted while the machine heads
  into sleep (CoreAudio device teardown) could restart capture behind the
  intentional willSleep stop. A `sleeping` gate now spans willSleep through
  the post-wake settle delay; the wake resume is the only path out and is
  generation-guarded against quick re-sleeps.

- Reconciliation: genuine device events swallowed by the suppress window
  are no longer lost — at settle time the engine-halted-while-listening and
  failed-start-then-device-arrived cases each trigger one recovery restart
  (one retry per swallowed event, so a dead mic can't cause polling).

- AudioHub: stop() now calls engine.stop() unconditionally (releases
  prepare()-stage resources on failed-start paths); start() re-validates
  the live hardware format right before installTap, converting the
  realistic device-changed cases of an uncatchable NSException into a
  catchable error.

- VoiceCommand: all mutable state now behind one lock (it was touched from
  main, the Speech callback queue, and the audio tap thread — UB), with a
  generation counter so a stop cannot be resurrected by a delayed restart,
  and the two-phase start() gated on request identity so an immediate-error
  restart between the phases cannot store a dead task and permanently block
  the idempotency guard. Speech also stops/resumes across sleep now.

- Lessons captured in dev/procedures/mac-audio-lifecycle.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163EGo8fLif2oRwRHhmXKnV
@missingbulb missingbulb changed the title fix(mac): release the mic cleanly on quit + opt-in keep-awake toggle fix(mac): release the mic on every path — exit, signals, sleep races + keep-awake toggle Jul 19, 2026
@missingbulb
missingbulb merged commit beda6cb into main Jul 19, 2026
1 check passed
@missingbulb
missingbulb deleted the claude/webcam-microphone-dead-state-fitgde branch July 19, 2026 21:14
missingbulb pushed a commit that referenced this pull request Jul 29, 2026
…every device change

The app died on every .AVAudioEngineConfigurationChange: six for six over
fifteen days on the owner's Mac mini, three crash reports with an identical
signature, the longest outage fifteen days of nobody noticing the counter
was gone.

    required condition is false: format.sampleRate == inputHWFormat.sampleRate
    -[AVAudioNode installTapOnBus:bufferSize:format:block:]
    AudioHub.start(format:) / AppDelegate.finishListening()

An AVAudioEngine caches the input hardware format when its input node is
first materialized, and that cache does not follow the device. After
CoreAudio tears the input down — a USB mic re-enumerating, or coreaudiod
dropping its contexts when the display sleeps — outputFormat(forBus:0) keeps
reporting the old rate while installTap validates against the live hardware
format. The pre-tap guard from #22 could never catch it: it compared the
stale cache against itself and always passed. Not the microsecond race its
comment assumed — a design gap.

So prepareFormat() now builds a fresh engine every time, stopping the old one
first (dropping the last reference to a running engine would abandon its
IOProc on the device, which is the wedge condition #22 exists to prevent).

Because the engine instance now changes, AudioHub owns the configuration-change
observation and republishes it through onConfigurationChange; an observer
registered by AppDelegate against audio.engine would have gone quiet after the
first restart, and a quiet config-change observer means the app stops noticing
that its microphone vanished.

Adds ObjCExceptionTrap as a backstop — Swift cannot catch NSException, so a
few lines of Objective-C in their own SwiftPM target (our source, not a
dependency) turn anything still raised by installTap into an ordinary error
the existing teardown and retry path handles.

This crash was also the mic-wedge cause: an uncaught exception aborts the
process, so applicationWillTerminate never ran and the tap's IOProc stayed
registered — the webcam mic went dead until physically re-plugged.

Closes #61

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYgSqFgDXf6BXWZFUTi8d
missingbulb added a commit that referenced this pull request Jul 29, 2026
… the counter on return from standby (#59)

* feat(mac): reactivate the counter when the Mac returns from standby

macOS exposes no "returned from standby" event — only the ordinary
NSWorkspace.didWakeNotification, which also fires for dark/Power-Nap wakes
where nobody is there and the machine re-sleeps at once. Fan the wake in
with screensDidWake, sessionDidBecomeActive and the distributed
com.apple.screenIsUnlocked, and coalesce the burst into a single resume
identified by an id (a bare bool deadlocks when a re-sleep races the timer).

Resume by intent, not by engine state: `listeningIntent` records that the
counter is meant to be running, so a return reactivates only a counter that
was actually active before the Mac went down — while a start that merely
failed stays eligible for recovery, which branching on `listening` would
have made permanent.

After a long standby the USB bus was powered down and the mic can still be
re-enumerating when the settle window closes; that start throws and nothing
else ever fires to recover from it, leaving the app silently not listening.
So stretch the settle 1.0s → 2.5s for sleeps past the standby threshold, and
add a bounded retry ladder (2s → 32s, five attempts, generation-guarded)
that resets on success, on a system return, and on a manual resume. Bounded
because a mic that is gone emits nothing and must not be polled forever;
slow because rapid-cycling is what wedges a USB mic.

Documents the behaviour in mac/README.md and the reasoning in
dev/procedures/mac-audio-lifecycle.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYgSqFgDXf6BXWZFUTi8d

Closes #60

* feat(mac): add Pause listening, making the standby resume condition reachable

The return-from-standby path reactivates the counter only if it was active
before, but nothing could switch it off by hand — the intent was false only
when the microphone was denied. Add a Pause listening menu item (⌘P) that
releases the mic until Start listening is picked again.

Store the intent as `offReason: String?` (nil = meant to be running) rather
than a bool beside a separate reason, so the menu status, the tooltip and the
wake log all render one field and cannot disagree about whether the counter
is off or why: paused, no microphone access, starting up.

Not persisted: relaunching starts listening again, which is what an always-on
box should do after a power cut. Sleeping while paused comes back paused.

Refs #60

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYgSqFgDXf6BXWZFUTi8d

* fix(mac): rebuild the audio engine per start — installTap crashed on every device change

The app died on every .AVAudioEngineConfigurationChange: six for six over
fifteen days on the owner's Mac mini, three crash reports with an identical
signature, the longest outage fifteen days of nobody noticing the counter
was gone.

    required condition is false: format.sampleRate == inputHWFormat.sampleRate
    -[AVAudioNode installTapOnBus:bufferSize:format:block:]
    AudioHub.start(format:) / AppDelegate.finishListening()

An AVAudioEngine caches the input hardware format when its input node is
first materialized, and that cache does not follow the device. After
CoreAudio tears the input down — a USB mic re-enumerating, or coreaudiod
dropping its contexts when the display sleeps — outputFormat(forBus:0) keeps
reporting the old rate while installTap validates against the live hardware
format. The pre-tap guard from #22 could never catch it: it compared the
stale cache against itself and always passed. Not the microsecond race its
comment assumed — a design gap.

So prepareFormat() now builds a fresh engine every time, stopping the old one
first (dropping the last reference to a running engine would abandon its
IOProc on the device, which is the wedge condition #22 exists to prevent).

Because the engine instance now changes, AudioHub owns the configuration-change
observation and republishes it through onConfigurationChange; an observer
registered by AppDelegate against audio.engine would have gone quiet after the
first restart, and a quiet config-change observer means the app stops noticing
that its microphone vanished.

Adds ObjCExceptionTrap as a backstop — Swift cannot catch NSException, so a
few lines of Objective-C in their own SwiftPM target (our source, not a
dependency) turn anything still raised by installTap into an ordinary error
the existing teardown and retry path handles.

This crash was also the mic-wedge cause: an uncaught exception aborts the
process, so applicationWillTerminate never ran and the tap's IOProc stayed
registered — the webcam mic went dead until physically re-plugged.

Closes #61

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYgSqFgDXf6BXWZFUTi8d

* fix(mac): name the exception trap's selector run: so Swift imports it unchanged

CI caught it: Swift strips a trailing noun that restates the argument type, so
runBlock: imported as run(_:) and the spelled-out name was marked obsoleted.
Naming the selector run: keeps both sides in agreement.

Refs #61

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYgSqFgDXf6BXWZFUTi8d

* chore(mac): bump the app version to 0.3.0

Minor bump: this release carries the installTap crash fix (#61), the
return-from-standby resume (#60), and the new Pause listening item.

Also gives the fixed build a version string that tells it apart from the
crashing one — both read 0.2.1 until now, which made "which build is
installed?" answerable only from a crash backtrace.

Merging this to main cuts the v0.3.0 Release and republishes
LaughCounter.dmg behind the latest/download link.

Refs #61

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYgSqFgDXf6BXWZFUTi8d

---------

Co-authored-by: Claude <noreply@anthropic.com>
missingbulb added a commit that referenced this pull request Aug 7, 2026
* Claudinite growth: discover local pack macos-audio-lifecycle

The macOS microphone lifecycle is a technology domain this repo carries real,
hard-won knowledge for (dev/procedures/mac-audio-lifecycle.md, 505 lines of case
history across #22/#61/#72/#76/#79/#88/#107), that no canon pack homes and that
neither existing local pack owns — on-device-privacy owns the privacy boundary,
laughcounter the repo's build/packaging and check-authoring lessons.

Nothing enforced any of it. This lands the invariants as a pack with three
checks, each red on a fixture and green on the repo's real files:

- engine-construction-confined — only AudioHub builds an AVAudioEngine, because
  constructing one opens the default input and churns a hidden coreaudiod
  aggregate device; AudioDiagnostics answers availability from HAL property
  queries that open nothing.
- signal-teardown-routing — a tree that installs an audio tap must route
  SIGTERM/SIGINT/SIGHUP to NSApp.terminate, with SIG_IGN before resume().
- no-sudden-termination — Info.plist must never opt into sudden termination,
  which would let logout SIGKILL the app past every teardown path.

The judgment half lands as RULES.md prose (open no device to ask a question,
presence is not usability, a duration is only as good as the observation behind
it, clock choice across sleep, generation-guarded deferred work, never claim
"listening" on engine.start() returning, compile-green is not a gate, assume no
toolchain on the owner's Mac). The case history stays in dev/procedures/ rather
than being copied, so there is one source for it.

Also narrows laughcounter's routing excludes so the new territory routes cleanly.

Refs #112

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N85UAG925YfhU2HGSSPPya

* Rename local pack macos-audio-lifecycle to macos-audio

Directory, pack id, check ids, declaration in .claudinite-checks.json, and
all doc/test references. Pack tests and check_the_world stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAmGDLuvoy6LrE6Ec6YJs6

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants