Fix AirPods dictation: gate recording on mic liveness, and recover the tail - #133
Conversation
Opening the mic on a Bluetooth input makes the system renegotiate the link into its mic-capable mode — hundreds of milliseconds, sometimes over a second — and that link then buffers audio in both directions. Blurt put all of it on the visible hot path, so an AirPods user pressed the key and watched nothing happen, then lost the last word of what they said. Four changes, none of which touch a settled decision (capture stays a fresh AVAudioRecorder per session): 1. MicCapture re-arms its prepared recorder after every capture, not just at launch. The cost is paid at prepareToRecord(), i.e. per session, so warming only the first one hid it for one dictation out of N. The warm recorder is validated against the live default input's UID before reuse — AVAudioRecorder resolves its device once and never re-resolves — and expires after 60s idle, because holding the input open is what pins AirPods in the profile where output audio is degraded. 2. stop() keeps capturing for a further 220ms when the session's input is Bluetooth, so speech still travelling over the link lands in the file instead of being truncated. It runs after .transcribing is claimed, so it delays the transcript, never the "it heard me" cue. Cancels take the new MicCaptureProtocol.cancelCapture() instead, which skips both the linger and the file read-back. 3. A new PipelinePhase.starting is claimed before mic.start(), so the pill answers the key-down rather than the hardware route. It is presented as "Starting…", never as live capture, so the rule that the UI must not claim audio is being recorded before it is still holds. 4. RecordingCueGate rides PipelinePhase.isCapturing, so the start chime fires at the press. CueSoundPlayer re-primes its players on output route changes, since opening the mic drops the format its pre-roll was made against — the first chime after that flip is the one that stalls, and it is the chime at the start of a dictation. The CoreAudio routing reads behind 1, 2 and 4 live in AudioRoute (internal) and AudioRouteMonitor (public, for the cue players); both are excluded from the coverage gate for the same reason MicCapture is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
Dev buildDownload Blurt.app — built from Installing itcd ~/Downloads
unzip -o blurt-dev-build-pr-133.zip # GitHub wraps every artifact in a zip
unzip -o Blurt-dev-6d25ada.zip
find Blurt.app -exec xattr -c {} + # clear quarantine: xattr lost -r in macOS 12.3
rm -rf /Applications/Blurt.app && cp -R Blurt.app /Applications/
open -a BlurtIt is ad-hoc signed and not notarized: Gatekeeper refuses to open it until Expect that re-grant once per dev build, including a second build of this tccutil reset Accessibility dev.alex.blurt |
check.sh is fail-fast, so this also joins every other line in AudioRoute / AudioRouteMonitor that swift-format would have flagged on the next run — the two AudioObject*PropertyListenerBlock calls and the transport-type comparison all fit inside the 120-column budget once the system-object expression is hoisted behind a constant. deinit lifts the registrations into locals before the queue.sync, so the closure captures only those rather than a self that is already being torn down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
swiftlint analyze flagged `import Foundation` in both new files. Each had a different real cause, so neither is suppressed: AudioRouteMonitor only ever wanted Dispatch — Foundation was supplying DispatchQueue by re-export. Imports Dispatch directly now. AudioRoute pulled Foundation in for exactly one thing: bridging the device UID's CFString to String. Identity is now the AudioDeviceID, which removes the bridge, the Unmanaged dance, and the import together. IDs are in principle reusable across an unplug/replug where UIDs are not, but the two disagree in one case only — the warmed device was removed and a new one took its ID inside the 60s warm window — and that case fails loudly (the recorder is bound to a device that no longer exists, so record() returns false and the press surfaces .audioCaptureFailed). It cannot produce the failure the check exists to prevent, which is silently recording the wrong mic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
Adopts the central fix from #134 and drops the one change here that contradicted it. `record()` returning true only means the AudioQueue started, not that the input route is delivering frames. A Bluetooth mic spends ~1-2s switching into its mic-capable profile first and the OS captures nothing in that window, so returning immediately cues the user to speak into a dead mic and the first words never reach the transcript. start() now polls recorder.currentTime until it advances past 0 — the recorder's clock only moves once frames arrive, which distinguishes "route still switching" from "user is silent" (a level meter can't). Capped per transport by the new pure MicLiveness (2.5s Bluetooth, 300ms otherwise) and failing open on timeout, so a broken mic degrades to the old behavior rather than bricking the press. stopGeneration covers the suspension this introduces: a teardown landing mid-wait wins and the recorder is torn down rather than installed. The chime change is reverted. RecordingCueGate keys on .recording again, so it rides the connecting->recording edge by construction and fires only once audio actually flows. Firing it at the press — what this branch did before — moved the "speak now" cue *earlier* into the dead window, making the lost-first-words symptom worse. PipelinePhase.isCapturing existed only to serve that, and is gone. .starting is renamed .connecting throughout to match #134, and the pill now breathes "Connecting…" on the same curve as "Transcribing…" since the wait can last a second or two. Kept from this branch, none of which #134 covers: the per-session recorder re-warm (which composes with the gate — it keeps the route open so the honest wait usually returns immediately), the Bluetooth tail linger for the last word, cancelCapture(), and the cue re-prime on output route change. Transport classification moves to a pure, tested AudioTransport, leaving AudioRoute as raw CoreAudio reads only — policy shouldn't hide in a file the coverage gate can't reach. The warm-recorder lifecycle moves to MicCapture+Warm.swift to stay inside the lint file-length budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
AssemblyAITranscriber already carried a fileprivate copy of exactly this extension for its request-timing log. Adding the shared one without removing it is an invalid redeclaration, not a shadow — fileprivate and internal members of the same type collide within a module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
main moved to d8491e2 (#132 conversation_context/word_boost, #135 engine docs), which made the PR un-mergeable. Only BLURTENGINE.md conflicted; the Swift auto-merged and both of this branch's DictationSession changes (setPhase(.connecting), mic.cancelCapture()) survived intact. Three doc conflicts, all resolved by taking main's rewritten prose and re-applying this branch's additions on top: - The "cleanup happens server-side" bullet is main's verbatim — it now describes conversation_context/word_boost, and the version here still described the deleted TranscriptionPrompt. - The projections bullet takes main's text with `.connecting` re-inserted into the OverlayUIState list and the menu-bar clause. - The settled-decisions rows combine both edits: CoreAudio joins the allowed-imports line, and transcription steering points at ConversationContext rather than the now-deleted TranscriptionPrompt. Also corrected a sentence main added while this branch was open: its new "Record cues" section described the chime as firing on the idle→recording edge, which stopped being the whole story once `.connecting` landed in front of `.recording`. It now names the connecting→recording edge and why the chime must not fire at the press. main did not touch RecordingCueGate, PipelinePhase, OverlayUIState or MenuBarStatus, so there was no semantic overlap in the projections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
All five are consequences of `start()` gaining a suspension: state that used to be readable as a snapshot is now observable mid-flight. - MicCapture: `bringingUpCapture`. Across the liveness wait BOTH recorder slots are nil — `activeRecorder` isn't installed until the wait returns (the recorder stays confined to `start()` so nothing touches it while the poll loop reads its clock off-actor), and the warm slot was consumed on the way in. `rewarm()`/`warmUp()` read that as "idle" and prepared a second recorder onto the live input, which `stop()`'s scheduled re-warm makes very reachable. Both now go through `canPrepareWarmRecorder`. - MicCapture+Warm: the prepared-recorder expiry carries a generation ticket. Cancelling the task isn't enough — an expiry that already passed its `!Task.isCancelled` check still gets its actor turn, where it nils the *live* expiry's handle (leaving the current warm recorder with no countdown) and tears down a recorder prepared moments earlier. - AudioRouteMonitor: `deinit` no longer does `queue.sync`. `guard let self` upgrades the blocks' weak capture to strong, so while a block runs `queue` IS an owner and can drop the last reference — running deinit on that queue, where the sync deadlocks. Removal is inline now; it's race-free because deinit only runs once no block can be inside its upgrade, and `queue` is still passed to CoreAudio as the identity it matches removal on. - CueSoundPlayer: in-flight decodes carry a monotonic ticket instead of being disambiguated by `loadedPack` equality, which can't tell two loads of the same pack apart — exactly what a route re-prime forces. A decode in flight when the route changed could land last and install players primed against the old route, i.e. the stall the re-prime exists to prevent. The `loadedPack = nil` hack is gone; the observer passes `force: true`. - DictationSession: a `CancellationError` out of `start()` is the user's cancel arriving via an unqueued `cancelCapture()`, not a fault. It was caught as `.failed(.audioCaptureFailed)` — red pill plus a developer-mode error-log entry for a non-fault. Now `.cancelled`, which is also terminal, so `.connecting` can't strand the trigger's gate. The sixth finding is only partly addressed and is documented as a known gap in AGENTS.md: `performPress` now consumes a recorded `cancelRequested` before claiming `.recording`, so a cancel during the bring-up no longer produces a phantom recording and chime — but it cannot preempt the wait, and for the app it isn't even recorded until the press returns, because `submit(_:)`'s consumer is serial. Closing that needs a preemptible `mic.start()` or a non-blocking command consumer. New `GatedStartMic` stub pins the cancel-during-bring-up path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
Two pieces, closing the gap documented in the previous commit. `.connecting` now gets the same treatment `cancel()` already gives `.transcribing`/`.injecting`: it is in-flight work, so publish its task handle and cancel it. `press()` records `inFlightPress` the way `release()` records `pipelineTask`, and `cancel()` gains a `.connecting` branch. `MicLiveness.waitUntilLive` already returns early on task cancellation, so the wait unblocks at once; `MicCapture.start()` adds `!Task.isCancelled` to its post-wait guard, which tears the recorder down and throws CancellationError rather than installing it. That distinction matters — the timeout returns nil too, but nil means "fail open, proceed as if live". The cancel flag moves from actor state into a Mutex beside that handle, so `submit(.cancel)` can record and preempt without a turn. This is the part that fixes the app: its cancel door is `submit`, whose consumer is serial, so a submitted `.cancel` was not even *recorded* until the press it meant to cancel had finished — the Escape was invisible for the whole bring-up. `requestCancel()` is the single place both doors funnel through, so they cannot drift. Commands are still yielded and executed in order; only the preemption is new. `performPress` still consumes the flag before claiming `.recording`, for the narrow window where the cancel lands after the wait returned and there is nothing left to interrupt. The abort point is well chosen by accident of the existing ordering: everything with side effects — the target-app assignment, the AX context stream, the auto-release timer — happens after `mic.start()`, so a cancellation during the wait has nothing to unwind but the recorder, which `start()` already handles. DictationSession.swift hit 509 lines against the 400 file_length budget (nothing in the engine on main exceeds it), so `performPress` moves to `DictationSession+Press.swift`, mirroring `+Pipeline` on the release side, and the cancel-intent accessors move beside the commands they serve in `+Commands`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
main moved to e577f19 — #136 removed the per-PR dev builds and split the debug/release bundle IDs, #137 moved BLURTENGINE.md to Sources/BlurtEngine/README.md and added an evals README. Git tracked the rename, so this branch's edits to the engine guide (the `.connecting` phase, the liveness gate, the Bluetooth accommodations, the CoreAudio import line) followed into the new path with no conflict. One conflict, in AGENTS.md's repository map: both sides rewrote the same Audio/ line. Resolved by keeping main's new README.md entry and this branch's expanded Audio/ contents, with MicCapture+Warm added since the line was written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
The bug first, since it isn't cleanup: `cancel()`'s `.connecting` branch claims the phase and returns *without* enqueueing `performCancel`, and the press it preempted exits through the CancellationError catch, which only set the phase. So `cancelRequested` stayed true, and the NEXT press read it after a perfectly good `mic.start()` and cancelled itself — one dead dictation per cancelled bring-up. The catch now consumes the request the same way the other exit does. `GatedStartMic` grew an opt-in `throwsIfCancelled` so a test can drive the throwing route, which is the only one that reaches it. Cleanup applied: - `ConnectingLabel`/`TranscribingLabel` were the same view with one string changed, each holding its own copy of the breath constants that a comment asked to stay equal. One `BreathingStatusLine(text:animated:)`. - `rewarm()` was byte-identical to `warmUp()`; `scheduleRewarm` calls `warmUp()` and the guard lives once. - `stop()` and `cancelCapture()` shared a five-line teardown preamble including the `stopGeneration` bump that `start()` depends on across its wait — exactly the line a third exit would forget. Now `detachActiveRecorder()`. - The tail-linger policy moved from `MicCapture` (excluded from the coverage gate) to `AudioTransport`, beside `MicLiveness`'s wait cap, so both transport-conditional decisions are unit-tested rather than one of two. `MicCapture` stores the transport type instead of a Bool. - `AudioRouteMonitor` was restating the CoreAudio address literal and the system-object expression that `AudioRoute` already owns; `AudioRoute` now exposes `globalAddress(_:)`/`systemObject` and reads both its properties through one generic helper. - `HotkeyRaceTests`' private gated-start stub was a duplicate of the shared `GatedStartMic`; deleted, and the hedge in the shared stub's doc with it. - `enqueue`'s doc restated the mechanism that moved into `chain`, and the extraction left a double blank line (`maximumBlankLines: 1`). - The coverage exclusion `Audio/AudioRoute` silently covered any future `AudioRoute*.swift`, including a pure one that should count. Named the two files. Skipped, deliberately — see the reply for reasoning: hoisting the context capture above `mic.start()` (real win, but a behavior change to the press path), deferring the cue re-prime out of the press window, geometric backoff in the liveness poll, lazily constructing AudioRouteMonitor off the launch path, and the captureState enum / WarmRecorder struct refactors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
|
The let status = withUnsafeMutableBytes(of: &value) { raw in
AudioObjectGetPropertyData(object, &address, 0, nil, &size, raw.baseAddress!)
}Generated by Claude Code |
- The context-capture chain now overlaps the mic bring-up instead of queueing behind it. `mic.start()` becomes an `async let`, and the frontmost read, target-app assignment and AX field-context dispatch run while the route comes up. That ordering used to be free (start() returned in microseconds); with the liveness gate it was up to 2.5s of dead time the AX read could have used, and instead the read landed on the release path where runTranscribeInject waits up to contextWaitBudget for it with the user watching. The `async let` child still inherits cancellation, so cancel()'s `.connecting` preemption is unaffected. Cost: a press whose mic fails has already set the injector target and dispatched one AX read — both harmless and overwritten by the next press. - The cue re-prime is deferred to the next terminal phase. The usual cause of a route change is Blurt opening the mic, so the tick arrives *during* the press: reloading there put two AAC decodes alongside the bring-up and swapped startSound out from under the very chime it protects, possibly releasing a player mid-play. Deferring puts it between dictations and coalesces a burst of flips into one decode. The "route changes are rare" justification was wrong and is corrected. - The liveness poll backs off geometrically (1ms doubling to 25ms) instead of a fixed 10ms. currentTime is 0 the instant record() returns, so every press slept a full quantum before .recording, the chime and the meter — on a wired mic that quantum is the whole wait. The Bluetooth path drops from ~250 wakeups to ~30. Tests walk the backoff explicitly: since waitUntilSleeping matches on the deadline, a loop that slept a fixed quantum now hangs rather than quietly passing. - AudioRouteMonitor is built off the main actor inside the route observer, not as a stored property. Constructing it registers two CoreAudio listeners and makes the process's first HAL call, which as a stored-property initializer on a MainActor type ran on the main thread during launch — on every run, including onboarding and UI tests where no chime is ever played. It is owned by the observer task rather than stored, so dropping the task deregisters it. - The four correlated prepared* fields collapse into one `warm: WarmRecorder?`, making "a recorder without its input snapshot" and "an expiry without its recorder" unrepresentable rather than merely never written. Not done: the full captureState enum. It would rewrite the same bring-up paths this commit just changed, in the one engine file the coverage gate can't check, on top of four other unverified changes — worth doing, but after CI has confirmed this batch, not stacked under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
`&value` on an unconstrained generic is rejected: "forming 'UnsafeMutableRawPointer' to a variable of type 'T'; this is likely incorrect because 'T' may contain an object reference." Making it work means constraining to BitwiseCopyable and going through withUnsafeMutableBytes — more machinery than two five-line reads are worth in a file the coverage gate can't check anyway, and it was the lowest-value item in the cleanup batch. `globalAddress(_:)` and `systemObject` stay: those carry the actual duplication the review found (four address literals across two files), and the monitor keeps using them. Nothing else in the batch was implicated — the module emitted and 82 of 84 files compiled, so the `async let` press restructure, the WarmRecorder collapse, the poll backoff, `detachActiveRecorder` and the app-side changes are all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
- `performPress` reached 58 code lines (limit 50) once the context chain moved above the bring-up join. The chain is now `beginContextCapture()` — one phase of the press, not a reusable step, extracted for the budget. performPress is back to 36. - `WarmRecorder.expiry` carried an explicit `= nil`, which implicit_optional_initialization rejects. I added it defensively, worried the memberwise initializer wouldn't default a middle optional; the rule's existence settles that it does. - `DictationSessionTests.swift` hit 416 lines (limit 400), so the `.connecting` bring-up suite moves to `DictationSessionBringUpTests.swift`, and the file's split note names it alongside the other two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
The only conflict was the engine README's "Invariants — don't break these" list. Both sides edited it: this branch added the CoreAudio dependency rule and the transcription-steering note to the bullets, while main (#138) deleted the bullets outright and replaced them with a pointer to AGENTS.md's Settled decisions table plus the new scripts/check-invariants.sh. Took main's side. The de-duplication is the point of #138 — check-invariants.sh pins each mechanized rule to the table row it came from, which only works while there is exactly one row to pin to. Keeping a second copy of the list here would re-create the drift that change removed. The branch's edits survive where they now belong: AGENTS.md:37 and :55 carry the CoreAudio addition to the allowed-dependency rule, AGENTS.md:488 the steering note, and .claude/skills/project-guardrails/SKILL.md:100 mirrors both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
The only `os` symbols the file used were `performPress`'s signpost calls, and those moved to `+Press.swift` when the press half was split out for the lint file-length budget. `OSSignposter` itself is declared in `+Observation.swift`, which imports `os` for it — so nothing here needs the module any more, and `swiftlint analyze` (the unused_import analyzer rule) failed the run on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
|
The queue dequeue isn't a regression in this PR — the coverage floor moved underneath it mid-queue. #139 merged immediately ahead of this PR and raised the engine coverage gate from 80% to 88%; this branch's own head run passed at 85.63% against the old floor, and the merge-group run measured the combined tree at 87.37%, 0.63 points short of the new one. All 574 tests passed in the queue run. So it needs ~0.63pp of additional engine line coverage before a re-queue — likely candidates are the new Generated by Claude Code |
MicCapture+Warm.swift is gate-counted (the coverage exclusion matches only Audio/MicCapture.swift itself) but had no tests, which is what holds the branch at 87.37% against the 88% floor. Cover the warm lifecycle's decisions off-hardware: prepare-once, the bring-up-window refusal, device-identity validation before reuse, the stale-expiry generation ticket, and the scheduled re-warm. Device identity is driven through an injected input snapshot so the test machine's real routing never decides a test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFeP9D8k1HJ1piwyidUv3
|
Drafted the coverage tests: #141 targets this branch — merging it in should clear the 88% gate (tests only, no production changes). If you'd rather add your own, just close it. Generated by Claude Code |
… claude/airpods-lag-diagnosis-63h108
|
That red is my #141 tests, sorry — the three that call production Generated by Claude Code |
…verage gate The first CI run of these tests didn't just fail — it deadlocked the suite. Three expectations failed (every one that called the real `warmUp()`), and then 171 unrelated tests froze mid-run until the job's 30-minute timeout killed it, with `swiftpm-testing` left as an orphan process. The cause is that every path in these tests reaches `MicCapture.makeRecorder()`, which calls `prepareToRecord()` — the route-activation call this entire change is built around. On a runner with no input device it blocks its thread rather than suspending, so several of these running concurrently drain the cooperative pool and nothing else can make progress. The suite's own `.timeLimit` can't help: the threads it would need are the ones that are stuck. So the suite is gated on BLURT_LIVE_AUDIO_TESTS=1 and tagged `.liveAudio`, exactly like `MicCaptureLevelsTests`, which is env-gated for this same reason. It still documents and locks the warm lifecycle for anyone running it on a Mac with a real microphone. The re-warm test's `Task.yield()` spin is replaced with a deadline-bounded poll that sleeps between reads — a hot spin competes for the thread the task it is waiting on needs, which is its own deadlock. That leaves `MicCapture+Warm.swift` uncovered, so it joins the gate's exclusion list. It belongs there on the merits: it is the capture actor's hardware path, split out of `MicCapture.swift` (already excluded) purely for the lint file-length budget. The exclusion pattern pins a literal filename, so the split silently moved hardware-bound code onto the counted side — the opposite of `+Meter`, which is pure math and stays covered. The transport and liveness policy this file consults remains covered, in `AudioTransport` and `MicLiveness`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU1Ln2MKMsF9PJf1FLMQRX
What & why
Supersedes #134 — this branch now carries #134's liveness gate plus four fixes it doesn't have.
Close #134 in favour of this one.
Opening the mic on a Bluetooth input makes the system renegotiate the link into its mic-capable
profile — one to two seconds, during which the OS receives no audio at all — and that link
then buffers audio in both directions. Blurt put all of it on the visible hot path, so the user
was cued to speak into a dead mic and lost the first words, then lost the last word to the
buffered tail.
Five changes. None touches a settled decision — capture stays a fresh
AVAudioRecorderpersession.
1.
start()doesn't return until the input is live. (#134's fix, adopted.)record()returning
trueonly means the AudioQueue started, not that frames are arriving, sostart()polls
recorder.currentTimeuntil it advances past 0 — the recorder's clock only moves once thedevice delivers audio, which distinguishes "route still switching" from "user is silent" (a level
meter can't). Capped per transport by the new pure
MicLiveness(2.5 s Bluetooth, 300 msotherwise) and failing open on timeout, so a broken or silent mic degrades to the old
behavior rather than bricking the press.
stopGenerationcovers the suspension this introduces:a teardown landing mid-wait wins, and the recorder is torn down rather than installed.
Speech during the switch is not recovered — nothing ever receives it. The fix is to stop
inviting it.
2. The start chime waits for
.recording.RecordingCueGatekeys on.recording, so itrides the connecting→recording edge by construction and fires only once audio actually flows. An
earlier revision of this branch moved the chime to the press; that made the symptom worse by
cueing speech even earlier into the dead window, and it's reverted. The new
.connectingphasecarries the press acknowledgement instead — a breathing "Connecting…" pill, no
● RECtag, nometer.
3. The prepared recorder is re-armed after every capture, not just at launch — the cost is
paid at
prepareToRecord(), i.e. per session. This composes with the gate rather thanduplicating it: the re-warm keeps the route open so the honest wait usually returns immediately;
the gate is what keeps the app truthful on the presses that pay the switch anyway. It's validated
against the live default input before reuse (
AVAudioRecorderresolves its device once and neverre-resolves) and expires after 60 s idle, because holding the input open is what pins AirPods in
the profile where output audio is degraded.
4.
stop()keeps capturing for a further 220 ms when the input is Bluetooth, so speech stilltravelling over the link lands in the file instead of being truncated — the missing last word.
It runs after
.transcribingis claimed, so it delays the transcript, never the "it heard me"cue. Cancels take a new
MicCaptureProtocol.cancelCapture()(default: stop-and-discard, so everystub conforms for free), which skips both the linger and the file read-back.
5. The cue players re-prime on output-route changes. Opening the mic drops the output format
their pre-roll was made against, so the first chime after that flip is the one that stalls — and
it's the chime at the start of a dictation.
AudioRouteMonitorwatches both the default outputdevice and the current device's nominal sample rate, re-targeting the second listener when the
first fires.
Structure notes for review
AudioRouteis raw CoreAudio reads only — it needs real hardware, so it's excluded from thecoverage gate, and policy must not hide there. What a transport means lives in the pure,
tested
AudioTransportandMicLiveness.AudioRoute.InputSnapshotidentifies a device byAudioDeviceID, not its persistent UID. IDsare reusable across an unplug/replug where UIDs aren't, so the two disagree in one case: the
warmed device was removed and a new one took its ID inside the 60 s window. That fails loudly
(
record()returns false →.audioCaptureFailed); it cannot silently record the wrong mic,which is what the check exists to prevent. Reading the UID means bridging a
CFString, i.e.pulling Foundation into a file that otherwise needs only CoreAudio.
MicCapture+Warm.swiftfor the lint file-lengthbudget, which is why the prepared-recorder state,
logger,removeFileandmakeRecorderareinternal rather than private.
How it was tested
Authored in a Linux web sandbox with no Swift toolchain and no audio device, so CI on macOS is the
authority. The previous head was fully green there (
swift test+ the 80% coverage gate, bothsanitizers, xcodegen drift, the app build, 23 UI tests, the leak scan, swift-format, swiftlint
--strict,swiftlint analyze, periphery); this head is re-running.New/updated engine tests:
MicLivenessTests(transport caps, immediate/delayed/timeout waits onTestClock),AudioTransportTests,DurationMillisecondsTests, a rewrittenRecordingCueGatesuite pinning that
.connectingis silent and that a failed bring-up never chimes, the.connectingrows across thePipelinePhase/OverlayUIState/MenuBarStatusprojection tables,a session test pinning
[.idle, .connecting, .recording]in order, and one pinning that cancelsroute through
cancelCapture()while releases go throughstop().Three constants still want a human on real AirPods — no test can reach them:
MicLiveness.bluetoothTimeout(2.5 s) — long enough to cover a real switch, short enough not tofeel hung when the mic is genuinely broken?
MicCapture.preparedRecorderLifetime(60 s) — after a dictation, does output audio recover soonenough that music/calls don't sound degraded?
MicCapture.bluetoothTailLinger(220 ms) — does it actually recover the last word?Suggested pass, from #134: dictate, wait ~10 s so the link falls back, dictate again — the chime
should land ~1–2 s after key-down, the first words should survive, and so should the last.
scripts/check.shpasses (or CI will, if I'm not on a Mac)deliberately removed