Separate hardware device changes from our own IO state (v0.4.1) - #87
Merged
Conversation
…0.4.1 The first field output from AudioDiagnostics logged "input devices changed" as a WARN on the first restart after launch, when nothing about the hardware had changed — only isRunningSomewhere had flipped, because we had started capturing. Two very different events wear the same diff, and conflating them buries the one that matters. Device identity changes (appearing, vanishing, rate, channels, alive) stay a WARN; an IO-state flip is now an INFO with its own wording. The same output revealed why that distinction is not cosmetic: the input device set contains a private CADefaultDeviceAggregate that coreaudiod creates per client when an app captures from the default input, and it was already present before capture started — merely constructing AudioHub creates it, since makeEngine() materializes inputNode and touching that property opens the default input device. So renewEngine() does not just open and close the microphone on every retry; it tears down and recreates an aggregate device inside coreaudiod, which is heavier and has to re-resolve the default input each time. The device set therefore changes on every cycle as the aggregate comes and goes, which is exactly why alarming on "the list changed" would alarm on our own restarts. Captured in mac-audio-lifecycle.md. Refs #79 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPk1B3eLuVNBti1CQnBDXq
The first heartbeat reported `buffers=3511 frames=28762112 peak=0.0041`, where buffers and frames are cumulative but peak was the maximum over the last five-second sample only. Sitting beside two interval totals it reads as though it covered the same span, so a laugh mid-interval followed by a quiet moment would be reported as near-silence. Peak now spans the heartbeat interval, alongside a buffer count for that same window, so every number on the line describes one span. That makes a third failure mode nameable. A stall is no buffers; a dead device is nothing to open; buffers arriving with every sample exactly zero is neither — the stream is alive and carrying digital silence. A real microphone always has a noise floor (the quiet room above measured 0.0041, about -48 dBFS), so an exact zero across ten minutes is never the room being quiet: it is a muted input or a stream no longer connected to hardware. Logged as ERROR, once per interval. Verified against the same heartbeat: 8192.0 frames per buffer matching installTap's bufferSize exactly, 5.859 buffers/sec against a theoretical 5.859, and 599.21s of audio in 600s of wall clock — the shortfall being the gap between diagnostics starting and capture starting. The counters are gapless and trustworthy. Refs #79 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPk1B3eLuVNBti1CQnBDXq
The sections added this round read as a case against the retry ladder, and a future session could mistake that case for a verdict. It is not one, so the doc now says so in its own words: every observed episode recovered cleanly, churn is confirmed to happen but not to cause the wedge, and the fix waits for an AUDIO STALL line that says which theory is right. Also captures three things worth reusing. That the suffix in CADefaultDeviceAggregate-<pid>-<n> is a per-client counter, so it measures how many times coreaudiod has rebuilt the aggregate for us. That the device leaves the bus on its own about twice an hour, so per-restart costs are paid far more often than a sleep-shaped model suggests. And the design constraint for whatever cheap availability probe eventually replaces building an engine to ask: a device can be enumerable and alive=y while reporting an unreadable name and a zero sample rate, so the probe must require a nonzero rate rather than mere presence. Refs #88 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPk1B3eLuVNBti1CQnBDXq
This was referenced Jul 31, 2026
missingbulb
added a commit
that referenced
this pull request
Aug 7, 2026
Three lessons from the 2026-07-26..08-02 window, into the laughcounter local pack as prose. #74/#75: diagnosing the installTap crash stalled on "which binary is installed?" — the menu said only "LaughCounter", and several distinct builds all reported 0.2.1, because the release workflow keys its Release on v<version> from Info.plist, so a merge that leaves CFBundleShortVersionString alone refreshes the same Release behind the latest/download link. Durable part: show version and build from Bundle.main (not a source constant that could disagree with the DMG), and bump per distinguishable build. #56: the scheduler ran green nightly while silently skipping baselining ("no vendored mount (no stamp)") because the vendored loadConfig dropped the `claudinite` key it had just validated. Durable part: a job whose success and whose no-op look identical from outside is telling you nothing — read the skip line; and a bug inside the mechanism that updates itself has to be fixed out of band. #34: claudinite-isolation fired on CLAUDE.md's mount path, which carried nothing a reader could act on. Durable part: before adding an `accept`, delete the flagged text and see whether anything actionable went with it — an accept is for a crossing that must exist. Nothing new from the mac window (#59, #73, #77, #78, #87, #100, #101, #108): dev/procedures/mac-audio-lifecycle.md already records the engine-per-start rule, the inputFormat-vs-outputFormat trap, the aggregate churn, the three-state health reporting, the witnessed-arrival settle rule and the observation-gap rule in full. #55, #69, #84 and #99 are already carried by this pack's existing prose and the on-device-privacy checks. #32's "prove the check is live" is the see-it-fail discipline the canon owns. Conversation-logs half: the 2026-08-01 logs are the #108 session (fully covered above) and unattended task runs; no new friction lesson. No retention_days configured, so no prune. Refs #113. Claude-Session: https://claude.ai/code/session_01G52dxZvLCxyZJJnvLrYcQs 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.
Driven entirely by the first field output from
AudioDiagnosticson the owner's Mac.The noise
The first restart after launch logged a
WARN:Nothing about the hardware had changed. Only
isRunningSomewherehad flipped, because we had started capturing. Two very different events wear the same diff, and conflating them buries the one that matters — the same reasonscheduleStartRetryannounces its slow mode once instead of once a minute.Device identity changes (a device appearing or vanishing, or a changed rate, channel count, or
aliveflag) stay aWARN. An IO-state flip is now anINFOwith its own wording.Why that split isn't cosmetic
The same output showed the input device set is not just the microphone:
That second entry is a private aggregate device (
grup=kAudioDeviceTransportTypeAggregate) which coreaudiod creates per client when an app captures from the default input, so the client survives the default changing under it. It was already present in the very first sample, before capture started — meaning merely constructingAudioHubcreates it, sincemakeEngine()materializesinputNodeand touching that property opens the default input device.So
renewEngine()does not just open and close the microphone on every retry. It tears down and recreates an aggregate device inside coreaudiod, which is heavier than a device open and has to re-resolve the default input each time. That is what the retry ladder performs once a minute for as long as the mic is missing — the suspect for the wedge, now measured rather than inferred.It also means the device set genuinely changes on every such cycle, as the aggregate comes and goes. A diagnostic that alarmed on "the list changed" would therefore alarm on our own restarts, which is exactly the split above.
Testing
The HAL layer is now confirmed working against real hardware — correct device name, transport, rate, channel count and
aliveflag, and a correctrunning=n → running=ytransition when capture began. That was the part CI could not verify.Version bumped to v0.4.1 (build 9) so this DMG is distinguishable from v0.4.0's; per #74 a merge that doesn't move the version silently refreshes the same Release.
Lesson captured in
dev/procedures/mac-audio-lifecycle.md.Refs #79 — root cause still unconfirmed, so this remains instrument work, not the fix.
Generated by Claude Code