Skip to content

Make the input-device settle gate survive sleep, which it never did - #108

Merged
missingbulb merged 1 commit into
mainfrom
claude/mic-input-sound-settings-q0m1ak
Aug 1, 2026
Merged

Make the input-device settle gate survive sleep, which it never did#108
missingbulb merged 1 commit into
mainfrom
claude/mic-input-sound-settings-q0m1ak

Conversation

@missingbulb

Copy link
Copy Markdown
Owner

Fixes the no-signal wedge recurrence reported on 0.5.0 after a sleep: menu bar on ⚠️, System Settings → Sound → Input meter dead — the same system-wide shape as #88, with the settle gate #88 added in place and doing nothing at all on the wake path.

What was wrong

requireSettledInputDevice() asks AudioDiagnostics how long a usable input device has been continuously present and refuses to open one that has not settled for 6s. But the sampling timer does not fire while the machine is asleep, and usableInputSince was carried straight through that gap. A mic that powered down, re-enumerated and came back was still reported as continuously present since before the sleep — as a long elapsed run in 0.5.0, as .greatestFiniteMagnitude via witnessedArrival == false after #101.

Either way the gate passed instantly and the wake resume opened the device with zero settle: precisely the "opened it in the same second it appeared" hazard the gate exists to prevent, on precisely the transition it was built for.

The fix

A duration is a claim about a span you observed, so a blind spot inside the run invalidates the run. AudioDiagnostics now tracks observation as its own state:

  • usableInputFor: TimeInterval?inputAvailability, a three-state enum. .absent ("no default input worth opening") and .unobserved ("we have not been watching") are different answers and only one is about the hardware. Collapsing them would have put an unmeasured claim about the mic into the log — the sin Mic dead system-wide after sleep while the app claims it is listening #79 and Mic wedges system-wide after sleep while the app still holds it — root cause unconfirmed #88 were both about.
  • noteObservationInterrupted() is called from willSleep, so a sleep is declared rather than inferred. Inferring from elapsed time misses a sleep shorter than the tolerance, and the USB bus is powered down for a short sleep too. An elapsed-time backstop in sample() covers interruptions nobody announces — measured with Date(), not systemUptime, the one place in this file that inverts the usual rule: the monotonic clock by design refuses to count the very span at issue.
  • The run is keyed on the device's UID, so a mic swapped for another between two samples restarts the clock instead of inheriting the run of the device that just left. A set-level "is anything usable?" predicate cannot see that happen at all.
  • usableInput() judges the system default input — the device AVAudioEngine actually opens. A healthy mic behind a broken default is not a mic we can capture from. This also subsumes the old exclude-aggregates rule (our hidden CADefaultDeviceAggregate is never the system default) and fixes its collateral damage: a user's deliberately-built aggregate device is a fine thing to capture from, and the old predicate refused it.

Version bumped to 0.5.2 (build 12) so the fix ships as its own Release rather than refreshing v0.5.1 (#74).

Behaviour

Path Before After
Launch, mic already present settled, starts immediately unchanged — #101's fix stands
Display sleep (system awake) device vanishes/returns, witnessed, 6s settle unchanged
System sleep → wake gate passes instantly, opens with no settle .unobserved → refuse → first post-wake sample re-establishes a witnessed arrival → full 6s settle

Post-wake, the retry ladder lands the start ~8–15s after wake instead of ~3s. Refusals are free — requireSettledInputDevice() throws on a HAL property read, before anything builds an AVAudioEngine or opens a device.

What this does and does not prove

It does not prove the mid-enumeration theory. What it settles is narrower and worth keeping separate: the mitigation shipped in 0.5.0 was never actually exercised on the wake path, so 0.5.0 recurring says nothing about whether the mitigation works. The next recurrence is the first one that will.

The reporting side did work as designed — the ⚠️ no-signal state from #100 is what made this diagnosable from a menu-bar screenshot instead of a log reconstruction.

Testing

CI builds the DMG (swift build runs there; no macOS toolchain in the session). Per dev/procedures/mac-audio-lifecycle.md, compile-green is explicitly not a gate for this file — the change is not verified until the app has actually started on a Mac and been slept. Suggested check on the owner's Mac: install 0.5.2, pmset sleepnow, wake, and confirm the activity log shows a refusal (the input device has not been watched since the machine slept and/or input device appeared Ns ago) followed by listening started within ~15s.

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


Generated by Claude Code

The no-signal wedge recurred on 0.5.0 after a sleep — menu bar on ⚠️,
System Settings → Sound → Input meter dead — with the settle gate #88
added in place and doing nothing at all on the wake path.

The gate asks AudioDiagnostics how long a usable input device has been
continuously present, and refuses to open one that has not settled for
6s. But the sampling timer does not fire while the machine is asleep, and
`usableInputSince` was carried straight through the gap: a mic that
powered down, re-enumerated and came back was reported as continuously
present since before the sleep (as a long elapsed run in 0.5.0, as
`.greatestFiniteMagnitude` via `witnessedArrival == false` in 0.5.1).
Either way the resume opened the device with zero settle — precisely the
"opened it in the same second it appeared" hazard the gate exists to
prevent, on precisely the transition it was built for.

A duration is a claim about a span you observed, so a blind spot inside
the run invalidates the run. AudioDiagnostics now tracks observation as
its own state:

- `usableInputFor: TimeInterval?` becomes `inputAvailability`, a
  three-state enum. `.absent` and `.unobserved` are different answers and
  only one of them is about the hardware; collapsing them would put an
  unmeasured claim about the mic in the log.
- `noteObservationInterrupted()` is called from `willSleep`, so a sleep
  is declared rather than inferred — inferring from elapsed time misses a
  sleep shorter than the tolerance, and the USB bus is powered down for a
  short sleep too. An elapsed-time backstop in `sample()` covers
  interruptions nobody announces, measured with `Date()` rather than
  `systemUptime`, which by design does not count the very span at issue.
- The run is keyed on the device's UID, so a mic swapped for another
  between two samples restarts the clock instead of inheriting the run of
  the device that just left.
- `usableInput()` judges the system default input — the device
  AVAudioEngine actually opens. That subsumes the old exclude-aggregates
  rule (our hidden CADefaultDeviceAggregate is never the system default)
  and fixes its collateral damage: a user's own aggregate device is a
  fine thing to capture from, and the old predicate refused it.

Launch behaviour is unchanged — a device already present when sampling
begins still reports settled, so #101's fix stands.

This does not prove the mid-enumeration theory. What it settles is
narrower: the 0.5.0 mitigation was never exercised on the wake path, so
its recurrence says nothing about whether the mitigation works. The next
one will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017b6fTEr6ETCYxJGq97tUuY
@missingbulb
missingbulb merged commit 9130883 into main Aug 1, 2026
1 check passed
@missingbulb
missingbulb deleted the claude/mic-input-sound-settings-q0m1ak branch August 1, 2026 05:57
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