fix(mac): release the mic on every path — exit, signals, sleep races + keep-awake toggle - #22
Merged
Conversation
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
…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
This was referenced Jul 26, 2026
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>
This was referenced Jul 29, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
AVAudioEngineinput 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()calledNSApp.terminatewith 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 (noNSSupportsSuddenTerminationin 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
.idleSystemSleepDisabledactivity 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 viarefreshTitle(), released on stop/sleep/terminate.3. Audit round — the remaining gaps
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.swiftnow routes them viaDispatchSourceSignal→NSApp.terminate(SIG_IGNset beforeresume()so there's no delivery gap). SIGKILL/Force-Quit/crash remain uncoverable — documented residual risk.stopListeningcouldn't cancel the pending +0.4sfinishListening; sleep in that window could start the engine going into sleep, andasyncAftertimers fire immediately at wake — bypassing the settle delay. Every intentional stop now bumpsrestartGeneration; 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.willSleepand actual sleep could restart capture behind the intentional stop. Asleepinggate spans willSleep → post-wake settle; the wake resume is the only exit and is generation-guarded against quick re-sleeps.engine.stop()now unconditional (releasesprepare()-stage resources on failed starts); live format re-validated immediately beforeinstallTap(converts the realistic device-changed cases of an uncatchable NSException into a catchable error — narrowed, not eliminated; no atomic API exists).start()gates the task store on request identity — gating onrunningwould 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.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:
killall LaughCounter/ Ctrl-C (terminal run) — after each, the mic must still register input in System Settings without re-plugging.🤖 Generated with Claude Code
https://claude.ai/code/session_0163EGo8fLif2oRwRHhmXKnV