Repository navigation
Keep the meeting mic and system audio going through device switches, hiccups and failed recoveries - #1780
Merged
Conversation
- Watch AVAudioEngineConfigurationChange so switching the Mac's output or default device restarts the mic right away. Before, only the watchdog noticed, after its 3s stall check, so each switch lost up to ~5s of the user's voice. - A failed mic recovery no longer stops the meeting. System audio keeps recording and the watchdog retries on its cooldown; it still gives up after 5 failed attempts in a row. A retry also works when the earlier attempt left no graph or no open mic segment, which used to make every later attempt return early and record silence forever. - Fall back to the built-in mic when the chosen mic can't start (third graph attempt at meeting start and in recovery), when a previous recovery failed, or when the mic never delivered a frame. - Don't warn "Failed to remove clip file" when there was no clip to remove. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
9 of 11 tasks
…nge-recovery-lx5xm9
…s on main Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
A route change or stall now restarts the engine that is already bound to the pinned mic when that mic is still there, instead of building a fresh AVAudioEngine whose input node briefly opens the default input (AirPods flip into call mode). A failed in-place restart immediately retries once with a fresh graph. Bluetooth input, voice processing, processing changes and moved formats keep the fresh-graph path. The recovery segment is now listed with the recording as soon as its writer is installed, so a Stop that lands during the restart merges it instead of deleting the last seconds of mic audio (deep review M4). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
Keeps both recoverFromDeviceChange parameters (afterSystemWake from #1771, freshGraphRequested from the in-place restart). The fresh-graph retry carries afterSystemWake and is not counted as a second device switch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
- A ring overflow (the consumer fell behind for a few hundred ms) no longer ends system audio. The audio queued before the hole is kept, the tap is rebuilt and the hole is padded; after 3 overflows in one recording it still ends cleanly. The ring holds 128 callbacks (~1.4 s) instead of 32, and recording takes a latency-critical activity so App Nap can't stall the drain timer. (deep review M5) - A wake or route reconnect that never delivers a buffer gets its own reconnect instead of spending the recording's one stall reconnect. (M6) - The reconnect pad is measured from the HAL's host-time stamps of the last kept and first new sample, falling back to drain-tick times. (M8) - The tap format is polled every 50 ms instead of 250 ms, and a change in buffer size confirms it right away. (M11) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
Keeps the in-place mic restart ahead of #1767's settled-format rebuild in device recovery, and gives the in-place graph the new voiceProcessingEnabled field (always false there). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
… [skip ci] - prepareForSystemSleep now waits (up to 1 s) for the release, so the tap is off the output before the will-sleep handler returns. (deep review M14) - After a wake, a tap that stays silent while a call app plays is rebuilt once, not three times. A fresh tap that is still silent means the far end is quiet, and more rebuilds only cut real audio. (M7) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
The ring now counts the frames it drops after an overflow, and the reconnect starts the interruption where that audio began. A long stall used to get a pad of just the rebuild time, leaving call audio running early against the mic. An overflow while an earlier reconnect's pad is still pending skips the queued audio (the host would drop it under the write-hold) and keeps the first start so one pad covers everything. The last allowed overflow keeps its queued audio before ending, the reconnect budget is only spent once the queued audio is out, fail() ends the latency hint, and the ring prefaults its storage so the IOProc takes no page faults. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
…ip ci] A recovery segment is listed before its first frame now, so Stop can close a header-only WAV. The merger counted it as skipped, reported degraded fidelity and left the primary and every recovery segment on disk. A segment with no payload lost nothing, so it no longer counts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
From the mic review of this branch: - Recovery bails before touching hardware when the recording no longer owns a mic file, so a post-Stop recovery (and the unit tests with no engine) never builds a graph that opens the default input. - The route-change observer now respects the 5-attempt limit, measures its flap guard from when the last recovery ended, and leaves a running engine alone, so it can't loop past the watchdog's give-up or swap a slow-starting mic for the built-in one. - A fresh-graph retry after a failed in-place restart no longer spends a second attempt, and after any failed attempt the next one builds fresh. - An in-place restart that comes back on a different input counts as a failed attempt. - Stop during the first-frame wait is a stale recovery, not a failed one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
…ip ci] The malformed-buffer overflow branches now count the buffer that trips the latch, and a route change found after the ring latched overflow also backs the interruption start over the dropped audio, so its pad covers the whole hole instead of just the rebuild. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
Keeps this branch's overflow reconnect (its last-resort fail() now carries #1781's buffer_overflow reason) and teaches #1781's reconnect counter about the overflow and noFirstBuffer triggers: overflow gets its own on-device count, a reconnect with no first buffer counts as a stall reconnect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
This was referenced Sep 23, 2026
- Mic: a retry after a failed restart pads from the last frame the recording kept, not frames a failed attempt took and then deleted, so the mic can't end up seconds early against system audio. - Mic: a tap block delivered after the route change stopped the engine no longer reads as a flowing mic; only a running engine can be flowing. - Mic: recovery timestamps and the new gap anchor are lock-protected. - System audio: an overflow reconnect reports .fellBehind, which holds writes like a reconnect but no longer counts as a device switch or lowers capture_quality. A reconnect with no first buffer reports the same kind as the reconnect it retries and gets its own on-device count. - System audio: the dropped-audio pad fix-up runs inside the format reconnect, so an overflow cut short by a format change is padded too. - TranscriptedCore CLAUDE.md describes the new recovery behavior. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
…[skip ci] From the next-release deep review of #1780: - S1 (M8 still open): the host released the system write-hold only on main after `.gap`, so the reconnect's first buffer (and any before main ran) was dropped and the new host-clock pad didn't cover it. The sending thread now queues the pad on the file queue and releases the hold before the capture hands over that buffer; main only records the gap metadata. - S2: will-sleep marks the recording as sleeping before releasing the tap, which can now take up to a second, so a route-change mic recovery can't start in that window. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
… [skip ci] From the next-release deep review of #1780: - S3: with the lid closed the MacBook mic is cut off in hardware and records silence without ever failing, so the built-in fallback now skips the laptop's own mic while the lid is closed (read from IOKit's AppleClamshellState; any read failure counts as open) and takes a display or other built-in mic instead, or none. IOKit is linked explicitly in the app, fast-test and package builds. - M4: an overflow found at Stop now keeps the audio queued before the hole (up to ~1.4 s with the bigger ring) instead of dropping the ring, unless an earlier reconnect's pad is still pending. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
…skip ci] - The closed-lid check now covers the start pick and the stabilization fallback, not just the post-failure fallback, and spots the laptop mic by transport so localized names count. - The call-audio gap pad no longer reads isRecording off main; the file queue's generation check already drops a stale pad. - Removed the test-only recordSystemAudioGap, fixed its doc comment, and dropped the always-false anchor policy parameter. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
…p ci] - MacLidState moves to its own file, byte-identical to #1784's, so the merge sees one file instead of two definitions. - The laptop-mic check is #1784's isLidMicrophone. The transport-based check dropped the headphone-jack mic, which still hears with the lid closed. - The lid filter applies once, in bestBuiltInInput, through the same lidClosed parameters #1784 adds, for the start pick, the stabilization fallback and the post-failure fallback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
…nge-recovery-lx5xm9
claude Bot
pushed a commit
that referenced
this pull request
Sep 24, 2026
A call app keeps its output running while nobody talks, so a tap hearing zeros is often a quiet call (a lobby, a pause), not a lost one. - Report after 60s instead of 30s. - If the same tap then hears signal, it was a false alarm: the warning goes away and the meeting is not marked degraded. Only signal that needed a rebuild or a new output confirms the loss and keeps "Call audio is back" plus the degraded mark. - One silent-tap rebuild per wake instead of three, matching #1780's M7. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgXMcLRGBtiSiybdgyD1VJ
The route-change check ran on a global queue and read the main-only isRecording there. It now reads it on main after the settle delay and hands it to the off-main check. Stop advances the generation, so a stop in between still fails the sessionIsCurrent check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b
6 of 7 tasks
…nge-recovery-lx5xm9 # Conflicts: # Sources/TranscriptedCore/Audio/Audio.swift
7 of 9 tasks
claude Bot
pushed a commit
that referenced
this pull request
Sep 24, 2026
…c + call audio Resolved per release/merge-notes/1796-vs-1780.md (both sides matched the pre-resolved heads, so the saved resolved files were used as is): keep #1796's SystemAudioSilenceWatch and drop #1780's WakeSilenceWatch; keep all three reconnect counters; RecoveryTrigger gets both sides' cases, and silentWhilePlaying maps to the systemWake event; overflow and no-first-buffer rebuilds arm the rebuild watch like a stall does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgXMcLRGBtiSiybdgyD1VJ
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.
Requested by Justin · project thread
Before: switching speakers or headphones mid-meeting dropped about 5 seconds of your mic, and each mic restart on a surviving non-Bluetooth mic could flip AirPods into call mode. Pressing Stop during a mic restart threw away the last couple of seconds. If one mic recovery failed, the whole meeting ended. If the chosen mic couldn't start, the meeting failed to start even when the Mac's own mic was right there. On the system-audio side, a third of a second of the Mac being busy ended call audio for the rest of the meeting, a slow wake used up the one reconnect a later real stall needed, and every reconnect threw away its first buffer without padding for it.
After: the mic restarts about a quarter second after an output switch, reusing its engine when the pinned mic survives, so the AirPods aren't touched on that path. Stop mid-restart keeps that audio. A failed recovery keeps the meeting (and system audio) recording and retries, falling back to a built-in mic that can actually hear you (never a lid-closed MacBook mic, at start or later). A mic that won't start falls back the same way instead of failing the meeting. System audio rides out a busy moment by reconnecting and padding the whole hole, slow wakes get their own reconnect, and reconnect pads now line up with the audio clock.
Not part of 1.1.62. Up to date with main (after 1.1.62, #1785, #1772, #1797, #1799, #1790, #1802).
Why
Audio follow-ups from the 1.1.62 review: the ~5s mic gap per output switch, one failed recovery ending the meeting, no built-in fallback at start, clip-removal log noise, the fresh-engine AirPods flip found in the #1771 diagnosis, deep review M3, M4, M5, M6, M7, M8, M11 and M14, plus four independent reviews of this branch (mic side, system-audio side, a full-PR pass, and the next-release deep review in
reviews/next-release/1780.md), a verification pass, and a closing review (reviews/next-release/1780-closing.md: ready once CI is green). Every should-fix item is in; the follow-ups are listed under Notes.Product Impact
meetingsmeeting reliabilityWhat changed
Mic
AudioobservesAVAudioEngineConfigurationChange. After a 0.25s settle it readsisRecordingon main, then checks off main whether the changed engine is the live meeting graph and whether it stopped; if so it runsrecoverFromDeviceChange. Pure decision inMicEngineConfigurationChangePolicy: ignores dictation's engine and detached graphs, ignores sleep, waits if another recovery is running, leaves a running engine alone (only a running engine can be "still flowing"; a stopped one can still hand over a last tap block), leaves a route that flaps within 1s of the last recovery ending to the watchdog, and never goes past the watchdog's 5-attempt limit. Will-sleep now sets the sleep mark before releasing the call-audio tap, so no route-change recovery starts while the Mac goes to sleep.AVAudioEnginewhose input node briefly opens the macOS default input. Gate inMicInPlaceRestartPolicy. A failed in-place restart retries once with a fresh graph after 0.2s (not counted as another switch or attempt); after any failed attempt the next one builds fresh, and an in-place restart that comes back on a different input counts as failed. Fresh-graph paths (unplugged mic, retries, fallback, VP on, start) still touch the default input; that's Record the Mac mic directly so AirPods never flip into call mode #1784's job.stop(); the watchdog retries and still gives up after 5 in a row. Fixed two latent bugs that made retries no-ops. Recovery bails before touching hardware when the recording no longer owns a mic file. A retry pads from the last frame the recording actually kept (MicRecoveryGapAnchorPolicy), so frames a failed attempt took and then deleted can't leave the mic seconds early.builtInFallbackAfterFailure.MacLidState, IOKitAppleClamshellState, unreadable = open) the MacBook's own mic (isLidMicrophone) is left out of every built-in pick: the start pick, the stabilization fallback and the post-failure fallback. The filter runs once, inbestBuiltInInput. The headphone-jack mic and display mics still count.MacLidState.swift,isLidMicrophoneand thelidClosed:parameters are shared line for line with Record the Mac mic directly so AirPods never flip into call mode #1784, so the two PRs merge without a duplicate type (notes in project filesrelease/merge-notes/1780-vs-1784.md).SpeakerClipExtractor.removeClipFileskips the warning when the file wasn't there.System audio (
CoreAudioSystemAudioCapture,CoreAudioTapBufferRing,Audio).fellBehind, which holds writes like a reconnect but doesn't count as a device switch or lowercapture_quality. Recording takes a.latencyCriticalactivity (idle sleep allowed, ended on failure) so App Nap can't coalesce the 10ms drain timer into an overflow.noFirstBufferreconnect of its own (warns like a stall, reports the same event kind as the reconnect it retries, own on-device count).firstNewSample - lastKeptSampleEndwhen both are stamped and plausible. The host now queues the pad and releases the write-hold on the thread that sends.gap, before the reconnect's first buffer arrives, so that buffer is no longer thrown away uncounted. The pad write is gated by the file queue's generation check, not a main-thread flag.How I checked it
python3 scripts/dev/check-build-source-lists.pyMacLidState, conflicts only in keep-both hunks, resolutions written to project filesrelease/merge-notes/.app-build,checks,spm-tests) on the current head: running now. There's no Swift toolchain in the authoring session, so CI is the first compile of the system-audio half and the new tests.MicRecoveryFallbackTests(route-change decisions incl. stopped-engine tap block, attempt limit, running engine; fallback policy incl. lid closed at start and after failure, the jack mic and a localized MacBook name; in-place gate; gap anchor; writer handoff),AudioInitializationTests(segment register/unregister/Stop wins),MicRecordingFileMergerTests(header-only segment),CoreAudioSystemAudioCaptureTests(overflow keeps queued audio, pad covers dropped audio, long stall, overflow during a pending pad, repeated overflows, overflow at Stop keeps queued audio, only route reconnects report a device switch, host-clock pad, buffer-size format check, slow wake keeps the stall reconnect and warns, sleep release),SystemAudioRecoveryParityTests(.gapreleases the hold before main runs; falling behind pads without a route change),CoreAudioTapBufferRingTests,ClipRemovalPolicyTests.Risk Review
builtInFallbackAfterFailurecategory string for the existingselection_reason; new logs carry transport classes, counts and scratch file basenames only.IOKitto the app, fast-test and package link lists.Notes
selection(), which Record the Mac mic directly so AirPods never flip into call mode #1784 also rewrites, so it's a follow-up..latencyCriticalstays on for the recording: overflow from a coalesced drain timer is the failure M5 is about. Revisit if battery data says otherwise.Audio.swiftgap and sleep hunks plus one test rename git won't flag; see project filesrelease/merge-notes/1787-vs-1780.md.Hardware test (Justin's Mac; step-by-step copy in project files
release/after-1.1.62-mic-and-call-audio-test.md):_mic_recovery.wav.Agent handoff
COORD_DONE: BRIEF | this PR | mic + system audio recovery; M3-M8, M11, M14; all reviews addressed, closing review ready once CI green; follow-ups: mic-lost-for-good, fallback cue, lid-closed default input | none | build source lists; CI running | merge when CI green, then hardware test🤖 Generated with Claude Code
https://claude.ai/code/session_01NZkqPnF73GpVNv9N5LYX9b