Skip to content

Fix AirPods dictation: gate recording on mic liveness, and recover the tail - #133

Merged
alexkroman merged 19 commits into
mainfrom
claude/airpods-lag-diagnosis-63h108
Aug 13, 2026
Merged

Fix AirPods dictation: gate recording on mic liveness, and recover the tail#133
alexkroman merged 19 commits into
mainfrom
claude/airpods-lag-diagnosis-63h108

Conversation

@alexkroman

@alexkroman alexkroman commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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 AVAudioRecorder per
session.

1. start() doesn't return until the input is live. (#134's fix, adopted.) record()
returning true only means the AudioQueue started, not that frames are arriving, so start()
polls recorder.currentTime until it advances past 0 — the recorder's clock only moves once the
device 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 ms
otherwise) and failing open on timeout, so a broken or silent 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.

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. RecordingCueGate keys on .recording, so it
rides 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 .connecting phase
carries the press acknowledgement instead — a breathing "Connecting…" pill, no ● REC tag, no
meter.

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 than
duplicating 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 (AVAudioRecorder resolves its device once and never
re-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 still
travelling over the link lands in the file instead of being truncated — the missing last word.
It runs after .transcribing is claimed, so it delays the transcript, never the "it heard me"
cue. Cancels take a new MicCaptureProtocol.cancelCapture() (default: stop-and-discard, so every
stub 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. AudioRouteMonitor watches both the default output
device and the current device's nominal sample rate, re-targeting the second listener when the
first fires.

Structure notes for review

  • AudioRoute is raw CoreAudio reads only — it needs real hardware, so it's excluded from the
    coverage gate, and policy must not hide there. What a transport means lives in the pure,
    tested AudioTransport and MicLiveness.
  • AudioRoute.InputSnapshot identifies a device by AudioDeviceID, not its persistent UID. IDs
    are 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.
  • The warm-recorder lifecycle is split into MicCapture+Warm.swift for the lint file-length
    budget, which is why the prepared-recorder state, logger, removeFile and makeRecorder are
    internal 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, both
sanitizers, 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 on
TestClock), AudioTransportTests, DurationMillisecondsTests, a rewritten RecordingCueGate
suite pinning that .connecting is silent and that a failed bring-up never chimes, the
.connecting rows across the PipelinePhase/OverlayUIState/MenuBarStatus projection tables,
a session test pinning [.idle, .connecting, .recording] in order, and one pinning that cancels
route through cancelCapture() while releases go through stop().

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 to
    feel hung when the mic is genuinely broken?
  • MicCapture.preparedRecorderLifetime (60 s) — after a dictation, does output audio recover soon
    enough 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.sh passes (or CI will, if I'm not on a Mac)
  • I read AGENTS.md and this doesn't reintroduce anything
    deliberately removed
  • Docs updated if behavior changed

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
@alexkroman
alexkroman enabled auto-merge August 13, 2026 01:56
@alexkroman
alexkroman disabled auto-merge August 13, 2026 01:56
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Dev build

Download Blurt.app — built from 6d25ada, Debug-Local,
ad-hoc signed. Expires in 14 days.

Installing it
cd ~/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 Blurt

It is ad-hoc signed and not notarized: Gatekeeper refuses to open it until
the quarantine flag is cleared, and macOS treats it as a different app from a
released Blurt, so you have to re-grant Microphone, Accessibility, and Input
Monitoring. Reinstall the release DMG
when you are done reviewing.

Expect that re-grant once per dev build, including a second build of this
same PR. TCC pins an Accessibility grant to the signature that took it, and an
ad-hoc signature is just a hash of the binary, so every build is a new app as far
as tccd is concerned. Blurt clears the orphaned grant at launch, which is what
keeps the Accessibility step from getting stuck on a Blurt row that is switched on
and still denied. If you are coming from a build old enough to predate that,
clear the grant yourself once:

tccutil reset Accessibility dev.alex.blurt

claude added 3 commits August 13, 2026 02:04
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
@alexkroman alexkroman changed the title Cut AirPods dictation lag at both ends of an utterance Fix AirPods dictation: gate recording on mic liveness, and recover the tail Aug 13, 2026
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
claude added 5 commits August 13, 2026 18:58
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
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

The compile failure on 7382f09 is the new generic read<T> helper in AudioRoute.swift:97: passing &value of unconstrained T to AudioObjectGetPropertyData's raw-pointer parameter trips "forming 'UnsafeMutableRawPointer' to a variable of type 'T'", and -warnings-as-errors makes it fatal in CI only. Both instantiations are trivial 4-byte types, so the code's behaviorally fine — making the pointer explicit silences it:

let status = withUnsafeMutableBytes(of: &value) { raw in
  AudioObjectGetPropertyData(object, &address, 0, nil, &size, raw.baseAddress!)
}

Generated by Claude Code

claude added 4 commits August 13, 2026 19:52
- 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
@alexkroman
alexkroman enabled auto-merge August 13, 2026 20:10
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
@alexkroman
alexkroman added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 13, 2026
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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 DictationSession+Press.swift / DictationSession+Commands.swift / MicCapture+Warm.swift lines (MicCapture, AudioRoute, and AudioRouteMonitor are gate-excluded, so tests there won't move the number). Happy to draft those tests if useful.


Generated by Claude Code

@alexkroman
alexkroman added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 13, 2026
@alexkroman
alexkroman added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 13, 2026
@alexkroman
alexkroman added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 13, 2026
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
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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

@alexkroman
alexkroman added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 13, 2026
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

That red is my #141 tests, sorry — the three that call production warmUp() hit CoreAudio on the headless runner (no input device): 3 assertion failures plus one blocked call that wedged the test process into the job timeout. #142 reworks them to go through the installWarmRecorder seam with bounded waits (tests only). Coverage impact gets measured by that PR's own check run.


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
@alexkroman
alexkroman added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 2b1a7f7 Aug 13, 2026
10 checks passed
@alexkroman
alexkroman deleted the claude/airpods-lag-diagnosis-63h108 branch August 13, 2026 23:00
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