Skip to content

Separate hardware device changes from our own IO state (v0.4.1) - #87

Merged
missingbulb merged 3 commits into
mainfrom
claude/mic-dead-after-sleep-cho0l5
Jul 30, 2026
Merged

Separate hardware device changes from our own IO state (v0.4.1)#87
missingbulb merged 3 commits into
mainfrom
claude/mic-dead-after-sleep-cho0l5

Conversation

@missingbulb

Copy link
Copy Markdown
Owner

Driven entirely by the first field output from AudioDiagnostics on the owner's Mac.

The noise

The first restart after launch logged a WARN:

input devices changed — inputs=[*"Logitech BRIO"/usb/48000Hz/2ch/alive=y/running=y …]

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 — the same reason scheduleStartRetry announces its slow mode once instead of once a minute.

Device identity changes (a device appearing or vanishing, or a changed rate, channel count, or alive flag) stay a WARN. An IO-state flip is now an INFO with its own wording.

Why that split isn't cosmetic

The same output showed the input device set is not just the microphone:

inputs=[*"Logitech BRIO"/usb/48000Hz/2ch/alive=y/running=n
        "CADefaultDeviceAggregate-70999-0"/grup/48000Hz/2ch/alive=y/running=n]

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 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 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 alive flag, and a correct running=n → running=y transition 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

claude added 3 commits July 30, 2026 12:24
…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
@missingbulb
missingbulb merged commit 8152166 into main Jul 30, 2026
@missingbulb
missingbulb deleted the claude/mic-dead-after-sleep-cho0l5 branch July 30, 2026 17:27
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>
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