From 6446f886a75e877b8974a9fa9bfbf27d6a80f088 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:17:03 +0000 Subject: [PATCH 1/2] fix: gate .recording on mic liveness so AirPods stop losing first words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blurt flipped to .recording — revealing the pill and playing the "speak now" chime — the instant AVAudioRecorder.record() returned true. On a Bluetooth input that only means the AudioQueue started: AirPods spend ~1-2 s switching A2DP→HFP first (again after every idle gap, since macOS drops back to A2DP), and the OS captures nothing in that window, so the user was cued to speak into a mic that wasn't delivering yet. MicCapture.start() now holds until the recorder's clock advances past 0 (the AudioQueue timeline only moves once the device delivers frames, which distinguishes a still-switching route from a silent user), polled every 50 ms with a transport-aware cap (MicLiveness, new): ~2.5 s for Bluetooth/BluetoothLE per kAudioDevicePropertyTransportType, ~300 ms for everything else. On timeout it FAILS OPEN — proceeding exactly as before — so a silent or broken mic degrades to today's behavior instead of bricking the press. The wait/gap is logged for field measurement. The session shows that wait as a new non-terminal .connecting phase, claimed before mic.start(): the pill appears immediately in a distinct "Connecting…" warming-up state (no REC tag, no waveform), and the start chime rides the connecting→recording edge (RecordingCueGate needed no change — it keys on .recording), so .recording keeps meaning "audio is actually being captured". The menu bar stays at rest through the bring-up for the same honesty rule. A release or cancel landing during the bring-up queues behind the press on the session's serial command queue and finalizes/tears down cleanly once start() returns. Note the fix cues the user honestly rather than recovering early speech — audio spoken during the switch never reaches the OS and cannot be captured. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01CNFeP9D8k1HJ1piwyidUv3 --- AGENTS.md | 18 ++++- .../Blurt/Overlay/OverlayPillContent.swift | 25 ++++++- App/Blurt/Blurt/Overlay/OverlayView.swift | 8 +- BLURTENGINE.md | 12 +-- Sources/BlurtEngine/Audio/MicCapture.swift | 41 ++++++++++ Sources/BlurtEngine/Audio/MicLiveness.swift | 59 +++++++++++++++ .../Pipeline/DictationSession.swift | 6 ++ .../BlurtEngine/Pipeline/MenuBarStatus.swift | 5 +- .../BlurtEngine/Pipeline/OverlayUIState.swift | 9 ++- .../BlurtEngine/Pipeline/PipelinePhase.swift | 9 ++- .../DictationSessionTests.swift | 41 ++++++++++ .../BlurtEngineTests/MenuBarStatusTests.swift | 4 + Tests/BlurtEngineTests/MicLivenessTests.swift | 75 +++++++++++++++++++ .../OverlayUIStateTests.swift | 10 ++- .../BlurtEngineTests/PipelinePhaseTests.swift | 1 + .../RecordingCueGateTests.swift | 17 +++++ .../Stubs/GatedStartMic.swift | 29 +++++++ 17 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 Sources/BlurtEngine/Audio/MicLiveness.swift create mode 100644 Tests/BlurtEngineTests/MicLivenessTests.swift create mode 100644 Tests/BlurtEngineTests/Stubs/GatedStartMic.swift diff --git a/AGENTS.md b/AGENTS.md index 1d497c98..b8efbd88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,6 +382,14 @@ A **fresh recorder per session** resolves the current default input device at `r is deliberate — see [Settled decisions](#settled-decisions--dont-reintroduce-these) for the `AVAudioEngine` failure it replaced. +`start()` returns only once the device is actually delivering frames: `record()` returning true just +means the AudioQueue started, and a Bluetooth input (AirPods) spends ~1–2 s switching A2DP→HFP first, +during which the OS captures nothing. `MicLiveness` polls the recorder's clock (which only advances +once frames flow — unlike the meter, this distinguishes a still-switching route from a silent user) +with a transport-aware cap (`kAudioDevicePropertyTransportType`: Bluetooth ~2.5 s, everything else +~300 ms) and **fails open** on timeout, so a broken mic degrades to the old behavior instead of +bricking the press. `DictationSession` shows this wait as the `.connecting` phase. + The overlay meter (`levels`) comes from the recorder's dBFS power on a ~20 Hz timer (`MicCapture.meterIntervalSeconds` — public because the pill caps its animation redraws to the same cadence and reads it from here rather than restating it), mapped to `0…1` by @@ -453,8 +461,8 @@ stream and signposts) and `+Pipeline.swift` (the post-release transcribe→injec It exposes `press()` / `release()` / `cancel()` / `cancelRecording()`, a synchronous fire-and-forget `submit(_: Command)` mirroring those four for callback-shaped hosts (commands run in exact emit order — the tap wires straight into it, no per-callback `Task` spawning), and a -`phase: PipelinePhase` (`idle | recording | transcribing | injecting | failed | cancelled`, plus the -terminal successes `pasted` / `noTarget`). `phaseStream()` yields the current phase immediately then +`phase: PipelinePhase` (`idle | connecting | recording | transcribing | injecting | failed | +cancelled`, plus the terminal successes `pasted` / `noTarget`). `phaseStream()` yields the current phase immediately then every transition, and is **multi-observer** (one continuation per call), though hosts should still render from one consumer and project the phase into their own state. @@ -472,8 +480,12 @@ round trip, and the log wrote to the user's real `~/Library/Logs/Blurt`. STT err are wrapped in `.sttFailed`. The pipeline is just transcribe → inject, and an empty transcript returns to `.idle` without injecting. -Three perceived-latency choices to preserve: +Four perceived-latency choices to preserve: +- `press()` claims `.connecting` _before_ `mic.start()`, and `.recording` only after it returns — + `start()` holds until the input device actually delivers frames (`MicLiveness`; a Bluetooth + A2DP→HFP switch takes ~1–2 s, capped per transport, failing open on timeout), so the pill + acknowledges the press immediately while the start chime keeps meaning "speak now". - `.injecting` projects to `OverlayUIState.processing`, **not** `.idle` — the shell reads an idle projection as "dismiss", so mapping this working phase to idle faded the pill out mid-dictation and blinked it back for "Pasted". diff --git a/App/Blurt/Blurt/Overlay/OverlayPillContent.swift b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift index 57015493..ab8ad574 100644 --- a/App/Blurt/Blurt/Overlay/OverlayPillContent.swift +++ b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift @@ -7,8 +7,8 @@ enum OverlayBrandPalette { } /// The shared type, tracking, and cyan color for the overlay's status-line -/// text ("Transcribing…", "Pasted", and "Copied") so they can't drift out of -/// sync. +/// text ("Connecting…", "Transcribing…", "Pasted", and "Copied") so they can't +/// drift out of sync. struct StatusLineText: View { let text: String @@ -82,6 +82,27 @@ struct TranscribingLabel: View { } } +/// The "Connecting…" status line shown while the mic route comes up (the +/// engine's `.connecting` phase — a Bluetooth input takes ~1–2 s to switch +/// A2DP→HFP). Styled as a status line like `TranscribingLabel`, but with a +/// faster, deeper breath so it reads as "wait" rather than the calm +/// transcribing heartbeat. Under Reduce Motion it holds steady at full opacity. +struct ConnectingLabel: View { + /// Whether to run the breathing motion (off under Reduce Motion). + let animated: Bool + + // Roughly twice the tempo and twice the depth of TranscribingLabel's breath: + // busy enough to say "not ready yet, hold on" at a glance, while staying the + // same status-line idiom as the rest of the pill. + private let breathPeriod: Double = 0.9 + private let minOpacity: Double = 0.35 + + var body: some View { + StatusLineText("Connecting…") + .pulsingOpacity(period: breathPeriod, minOpacity: minOpacity, animated: animated) + } +} + /// The "● REC" recording tag: a pulsing magenta dot + "REC" caption, sitting to /// the left of the waveform — the native echo of the site demo's magenta pixel /// tag. Magenta (the brand --hot) stands in for the conventional red record dot; diff --git a/App/Blurt/Blurt/Overlay/OverlayView.swift b/App/Blurt/Blurt/Overlay/OverlayView.swift index 559a4fba..874b5376 100644 --- a/App/Blurt/Blurt/Overlay/OverlayView.swift +++ b/App/Blurt/Blurt/Overlay/OverlayView.swift @@ -32,7 +32,7 @@ struct OverlayView: View { switch state { case .error: return Color(red: 0.62, green: 0.13, blue: 0.13) - case .recording, .processing, .pasted, .noTarget, .idle: + case .connecting, .recording, .processing, .pasted, .noTarget, .idle: return Color(white: 0.16) } } @@ -84,6 +84,12 @@ struct OverlayView: View { // background would collapse with it) keeps the pill's shape intact for // `hide()`'s pre-hide reset. Color.clear + case .connecting: + // The mic route is still coming up (a Bluetooth input switching profiles): + // a breathing status line, deliberately without the REC tag or waveform — + // the "speak now" cues arrive with `.recording`, once audio actually flows. + ConnectingLabel(animated: !reduceMotion) + .transition(.opacity) case .recording: // "● REC" tag beside the live waveform, mirroring the site demo's recording // pill (magenta tag + bars). The bars fill the width left of the tag. diff --git a/BLURTENGINE.md b/BLURTENGINE.md index a0ac2609..56c15a83 100644 --- a/BLURTENGINE.md +++ b/BLURTENGINE.md @@ -83,14 +83,16 @@ For callback-shaped hosts that can't `await` — an event tap, a button action `phase` / `phaseStream()` expose the pipeline's `PipelinePhase`: ```text -idle → recording → transcribing → injecting → pasted | noTarget - │ │ - └── failed(BlurtError) / cancelled (from any stage) +idle → connecting → recording → transcribing → injecting → pasted | noTarget + │ │ + └── failed(BlurtError) / cancelled (from any stage) ``` +`.connecting` is the mic bring-up: the press has been accepted but `MicCapture.start()` is still waiting for the input device to deliver frames (a Bluetooth route takes ~1–2 s to switch A2DP→HFP; wired inputs pass through near-instantly). Show it as a "warming up" state without your "speak now" cues — `.recording` still means audio is actually being captured, and a start chime should ride the `connecting → recording` edge (see `RecordingCueGate`). + - `phaseStream()` yields the current phase immediately, then every transition. It is a **multi-observer** stream: every call gets its own continuation and all of them see later transitions, so an extra consumer (a diagnostic, a second window) is safe. Still, prefer one renderer that projects the phase into your own state over a fan-out of long-lived consumers — one source of UI truth is easier to reason about than several. - `.pasted` and `.noTarget` are terminal _success_ states, not errors. `.noTarget` means transcription worked but nothing editable was focused (or the target app quit), so the text was left on the clipboard — show a quiet "copied" notice, not a failure. -- Two ready-made projections keep UI mapping out of your shell: `phase.overlayState` (`OverlayUIState`: idle / recording / processing / error(message:) / pasted / noTarget, with accessibility labels and — for the transient notices — `noticeDwellSeconds`, how long to hold one before reverting to idle) and `phase.menuBarStatus` (coarser: idle / recording / transcribing, never shows errors, with `symbolName`/`accessibilityLabel` presentation). +- Two ready-made projections keep UI mapping out of your shell: `phase.overlayState` (`OverlayUIState`: idle / connecting / recording / processing / error(message:) / pasted / noTarget, with accessibility labels and — for the transient notices — `noticeDwellSeconds`, how long to hold one before reverting to idle) and `phase.menuBarStatus` (coarser: idle / recording / transcribing, never shows errors, with `symbolName`/`accessibilityLabel` presentation). - Pill geometry is available too, if you're drawing something like Blurt's overlay: `OverlayPlacement` resolves how big the panel is (`panelSize(pillSize:shadowMargin:)`, sized to hold the pill plus room for its shadow) and where it goes (clearance, clamping a dragged origin back on screen), and `MeterBarGeometry` gives the level meter its shape. Build a `MeterBarRow(availableSize:)` once per layout — it resolves how many bars fit and how tall they may be — then ask it for `height(at:level:time:animated:)` per bar; `MeterBarGeometry.breathingOpacity(time:period:minOpacity:)` is the pulse the record dot and status label share. All pure math; pass `animated: false` to honor Reduce Motion. ### Errors @@ -122,7 +124,7 @@ func warmUp() async // pre-open the device; default: no-op Only `start()`/`stop()` must be implemented — `levels` and `warmUp()` have defaults, so a stub or headless capture conforms for free while hosts still read the meter and warm the device through the same seam they inject. -`MicCapture` records with `AVAudioRecorder` straight to a temp 16 kHz / mono / 16-bit PCM WAV — exactly the geometry the dictation API wants — and reads it back as raw S16LE bytes on `stop()` (no float detour; the blob uploads as-is). A **fresh recorder per session** resolves the current default input device at `record()` time, which is why device switches (headset ↔ built-in) just work. Do **not** replace this with a long-lived `AVAudioEngine`/`installTap` graph: that design was tried, bound itself to one device, and failed with `-10868` or all-zero buffers on device switches. +`MicCapture` records with `AVAudioRecorder` straight to a temp 16 kHz / mono / 16-bit PCM WAV — exactly the geometry the dictation API wants — and reads it back as raw S16LE bytes on `stop()` (no float detour; the blob uploads as-is). A **fresh recorder per session** resolves the current default input device at `record()` time, which is why device switches (headset ↔ built-in) just work. `start()` returns only once the device is actually delivering frames (`MicLiveness`: poll the recorder's clock, capped ~2.5 s for Bluetooth transports and ~300 ms otherwise, failing open on timeout), which is what backs the session's `.connecting` phase. Do **not** replace this with a long-lived `AVAudioEngine`/`installTap` graph: that design was tried, bound itself to one device, and failed with `-10868` or all-zero buffers on device switches. `MicCapture`'s `levels` is a ~20 Hz meter of the recorder's dBFS power mapped to `0…1` (floored at −50 dBFS so room ambient reads as silence) — feed it to a voice-bars view; it costs nothing when unobserved. Its `warmUp()` pre-creates and prepares a recorder so the first `start()` skips hardware route discovery (Blurt calls it at launch, once mic permission is granted, so warming never triggers the permission prompt). diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index 11a8bfd4..ed5252e2 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -1,4 +1,5 @@ @preconcurrency import AVFoundation +import CoreAudio import Foundation import os @@ -97,6 +98,24 @@ public actor MicCapture: MicCaptureProtocol { throw BlurtError.audioCaptureFailed(underlying: MicCaptureError.noInputDevice) } + // record() returning true only means the AudioQueue started — not that the + // input route is delivering frames. A Bluetooth mic (AirPods) spends ~1–2 s + // switching A2DP→HFP first, and the OS captures nothing in that window, so + // returning here immediately cues the user to speak into a dead mic. Hold — + // DictationSession keeps the pill in `.connecting` — until the recorder's + // clock advances (frames are flowing), capped per transport; on timeout + // proceed anyway (fail open), which is exactly the old behavior. + let timeout = MicLiveness.timeout(forTransportType: Self.defaultInputTransportType()) + let gap = await MicLiveness.waitUntilLive(timeout: timeout, clock: ContinuousClock()) { + recorder.currentTime + } + if let gap { + Self.logger.info("input live after \(Int((gap / .milliseconds(1)).rounded())) ms") + } else { + let capMs = Int((timeout / .milliseconds(1)).rounded()) + Self.logger.error("input liveness unconfirmed after \(capMs) ms — proceeding") + } + activeRecorder = recorder lastEmittedLevel = nil Self.logger.info("start recording to \(recorder.url.lastPathComponent, privacy: .public)") @@ -142,6 +161,28 @@ public actor MicCapture: MicCaptureProtocol { return recorder } + /// The CoreAudio transport type of the current default input device + /// (`kAudioDevicePropertyTransportType`), or nil when either read fails — + /// which `MicLiveness.timeout` treats as the conservative non-Bluetooth cap. + private static func defaultInputTransportType() -> UInt32? { + var deviceID = AudioDeviceID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let systemObject = AudioObjectID(kAudioObjectSystemObject) + guard AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceID) == noErr, + deviceID != kAudioObjectUnknown + else { return nil } + var transport: UInt32 = 0 + size = UInt32(MemoryLayout.size) + address.mSelector = kAudioDevicePropertyTransportType + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &transport) == noErr + else { return nil } + return transport + } + // MARK: - Level metering private func startMeterTimer() { diff --git a/Sources/BlurtEngine/Audio/MicLiveness.swift b/Sources/BlurtEngine/Audio/MicLiveness.swift new file mode 100644 index 00000000..97a9614b --- /dev/null +++ b/Sources/BlurtEngine/Audio/MicLiveness.swift @@ -0,0 +1,59 @@ +import CoreAudio +import Foundation + +/// The pure decision half of `MicCapture.start()`'s liveness gate: how long to +/// wait for the input device to actually deliver frames, and the polling loop +/// that detects when it has. Kept out of the hardware-bound capture actor (the +/// same split as `MicCapture+Meter`) so the timeout policy and the wait's +/// edge cases are unit-tested against an injected clock. +enum MicLiveness { + /// How often the recorder's clock is re-checked while waiting. + static let pollInterval: Duration = .milliseconds(50) + + /// Wait cap for Bluetooth inputs: bringing an AirPods mic up means an + /// A2DP→HFP profile switch that takes ~1–2 s, and macOS drops the link back + /// to A2DP a few seconds after every stop — so every dictation after an idle + /// gap pays it again, not just the first. + static let bluetoothTimeout: Duration = .milliseconds(2500) + + /// Wait cap for every other transport (and an unreadable one): wired and + /// built-in inputs deliver frames near-instantly, so a route that hasn't + /// within this budget is broken and the gate fails open without ever making + /// a healthy mic feel laggy. + static let defaultTimeout: Duration = .milliseconds(300) + + /// The wait cap for an input device of the given CoreAudio transport type + /// (`kAudioDevicePropertyTransportType`); nil means the type couldn't be + /// read, which gets the conservative default cap. + static func timeout(forTransportType transportType: UInt32?) -> Duration { + switch transportType { + case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: + bluetoothTimeout + default: + defaultTimeout + } + } + + /// Polls `currentTime` every `pollInterval` until it advances past zero. The + /// recorder's clock only moves once the input device delivers frames, which + /// is what distinguishes "route still switching" (clock stuck at 0) from + /// "user is silent" (clock advancing over quiet audio) — a meter level can't. + /// + /// Returns the elapsed wait once frames flow, or nil when `timeout` (or a + /// task cancellation) won the race. The caller FAILS OPEN on nil — proceeding + /// exactly as if live — because a silent or broken mic must degrade to the + /// old behavior, never brick the press. + static func waitUntilLive( + timeout: Duration, + clock: some Clock, + currentTime: @escaping @Sendable () -> TimeInterval + ) async -> Duration? { + let start = clock.now + let deadline = start.advanced(by: timeout) + while currentTime() <= 0 { + guard clock.now < deadline, !Task.isCancelled else { return nil } + try? await clock.sleep(for: pollInterval) + } + return start.duration(to: clock.now) + } +} diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index f264cdf4..e9eb7cdb 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -215,6 +215,12 @@ public actor DictationSession { setPhase(.failed(blocker)) return } + // Claimed before mic.start(): its liveness gate can hold recording for a + // couple of seconds on a Bluetooth route (A2DP→HFP), and the press must be + // visibly acknowledged without cueing the user to speak — the pill shows a + // warming-up state, and the start chime rides the connecting→recording + // edge (RecordingCueGate), so it fires only once audio actually flows. + setPhase(.connecting) // Times the startup path — the concurrent focus capture + mic.start (and the // detached connection warm-up kicked off below) — up to the moment recording // actually begins. Ended on both the success and failure exits (mic.start is diff --git a/Sources/BlurtEngine/Pipeline/MenuBarStatus.swift b/Sources/BlurtEngine/Pipeline/MenuBarStatus.swift index 7bdc0bba..6a17b523 100644 --- a/Sources/BlurtEngine/Pipeline/MenuBarStatus.swift +++ b/Sources/BlurtEngine/Pipeline/MenuBarStatus.swift @@ -40,7 +40,10 @@ extension PipelinePhase { switch self { case .recording: .recording case .transcribing: .transcribing - case .idle, .injecting, .cancelled, .failed, .pasted, .noTarget: .idle + // `.connecting` reads as idle: the filled glyph means "audio is being + // captured", and during the mic bring-up it isn't yet — the same honesty + // rule that holds the start chime. The pill carries the warming-up state. + case .idle, .connecting, .injecting, .cancelled, .failed, .pasted, .noTarget: .idle } } } diff --git a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift index 19b1ca9a..db4a8124 100644 --- a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift +++ b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift @@ -3,6 +3,11 @@ /// is unit-testable; the shell just renders whatever this resolves to. public enum OverlayUIState: Equatable, Sendable { case idle + /// The press landed but the mic isn't delivering audio yet (the pipeline's + /// `.connecting` phase — a Bluetooth route takes ~1–2 s to come up). The + /// shell shows a distinct warming-up treatment, deliberately without the + /// "speak now" recording cues. + case connecting case recording case processing /// A dictation attempt failed. The shell shows this as a brief red flash on @@ -27,6 +32,7 @@ public enum OverlayUIState: Equatable, Sendable { public var accessibilityLabel: String { switch self { case .idle: "Blurt." + case .connecting: "Connecting to the microphone." case .recording: "Recording." case .processing: "Processing." case .error(let message): message @@ -46,7 +52,7 @@ public enum OverlayUIState: Equatable, Sendable { switch self { case .pasted: 0.8 case .error, .noTarget: 1.6 - case .idle, .recording, .processing: nil + case .idle, .connecting, .recording, .processing: nil } } } @@ -64,6 +70,7 @@ extension PipelinePhase { // content — then `.pasted` arrived and faded it back in. A visible blink at // the end of a dictation, worst when the user has switched apps mid-transcribe. case .injecting: .processing + case .connecting: .connecting case .recording: .recording case .transcribing: .processing // A setup blocker (a missing API key) is an expected state, not a fault: the diff --git a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift index 93d68089..9b54f75b 100644 --- a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift +++ b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift @@ -2,6 +2,13 @@ import Foundation public enum PipelinePhase: Equatable, Sendable { case idle + /// The press landed and the mic is being brought up: `MicCapture.start()`'s + /// liveness gate is still waiting for the input route to deliver frames (a + /// Bluetooth A2DP→HFP switch takes ~1–2 s). The pill shows a distinct + /// warming-up state and the start chime waits — `.recording` keeps meaning + /// "audio is actually being captured", so the user is never cued to speak + /// into a mic that isn't delivering yet. + case connecting case recording case transcribing case injecting @@ -26,7 +33,7 @@ public enum PipelinePhase: Equatable, Sendable { public var isTerminal: Bool { switch self { case .idle, .failed, .cancelled, .pasted, .noTarget: true - case .recording, .transcribing, .injecting: false + case .connecting, .recording, .transcribing, .injecting: false } } diff --git a/Tests/BlurtEngineTests/DictationSessionTests.swift b/Tests/BlurtEngineTests/DictationSessionTests.swift index 29ffa690..8fa187e2 100644 --- a/Tests/BlurtEngineTests/DictationSessionTests.swift +++ b/Tests/BlurtEngineTests/DictationSessionTests.swift @@ -238,6 +238,47 @@ extension DictationSessionTests { #expect(await terminal == .pasted) } + @Test("press claims .connecting before .recording") + func pressSequencesConnectingBeforeRecording() async throws { + let fixture = makeSession(mode: .transcript("Hi.")) + let stream = await fixture.session.phaseStream() + + await fixture.session.press() + + // The pill's warming-up state must precede .recording — mic.start()'s + // liveness gate runs between the two, so the start chime (the + // connecting→recording edge) fires only once audio actually flows. + var seen: [PipelinePhase] = [] + for await phase in stream { + seen.append(phase) + if phase == .recording { break } + } + #expect(seen == [.idle, .connecting, .recording]) + } + + @Test("release landed during the mic bring-up still finalizes cleanly") + func releaseDuringConnectingFinalizes() async throws { + // A start() that blocks until the test releases it — the liveness gate + // holding out for a Bluetooth route — with the key-up arriving mid-wait. + let mic = GatedStartMic() + let session = DictationSession( + mic: mic, transcriber: StubTranscriber(mode: .transcript("Hi.")), + injector: StubInjector(), keyTermsProvider: { [] }, seams: .offline) + + session.submit(.press) + session.submit(.release) + await mic.waitUntilStartEntered() + #expect(await session.phase == .connecting) + + // The queued release waits its turn, sees .recording once start() returns, + // and runs the normal stop→transcribe path — no stuck pill, no lost stop. + await mic.allowStartToFinish() + await session.waitForIdle() + + #expect(await session.phase == .pasted) + #expect(await mic.stopCalls == 1) + } + @Test("cancel during active recording stops mic, discards audio, and transitions to .cancelled") func cancelDuringRecording() async throws { let fixture = makeSession(mode: .transcript("Hello")) diff --git a/Tests/BlurtEngineTests/MenuBarStatusTests.swift b/Tests/BlurtEngineTests/MenuBarStatusTests.swift index c66aa177..5ea9b6a7 100644 --- a/Tests/BlurtEngineTests/MenuBarStatusTests.swift +++ b/Tests/BlurtEngineTests/MenuBarStatusTests.swift @@ -13,6 +13,10 @@ struct MenuBarStatusTests { /// which is how `.noTarget` went uncovered. `.failed` keeps its own test below, /// since its mapping encodes a deliberate policy rather than a coarser icon. static let projections: [(phase: PipelinePhase, expected: MenuBarStatus)] = [ + // The mic bring-up stays at rest: the filled glyph means "audio is being + // captured", and during `.connecting` it isn't yet — the pill carries the + // warming-up state. + (.connecting, .idle), (.recording, .recording), (.transcribing, .transcribing), (.idle, .idle), diff --git a/Tests/BlurtEngineTests/MicLivenessTests.swift b/Tests/BlurtEngineTests/MicLivenessTests.swift new file mode 100644 index 00000000..331db87a --- /dev/null +++ b/Tests/BlurtEngineTests/MicLivenessTests.swift @@ -0,0 +1,75 @@ +import CoreAudio +import Foundation +import Synchronization +import Testing + +@testable import BlurtEngine + +/// The pure half of `MicCapture.start()`'s liveness gate: the transport-aware +/// wait cap and the poll-until-the-recorder's-clock-advances loop. Driven +/// against `TestClock` so the Bluetooth cap is exercised without waiting real +/// seconds. +@Suite("MicLiveness", .timeLimit(.minutes(1))) +struct MicLivenessTests { + @Test("Bluetooth transports get the long cap; everything else the short one") + func transportTimeouts() { + // The A2DP→HFP switch is the whole reason the gate exists — both Bluetooth + // transport types must get the multi-second budget. + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBluetooth) + == MicLiveness.bluetoothTimeout) + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBluetoothLE) + == MicLiveness.bluetoothTimeout) + // Wired/built-in inputs deliver frames near-instantly; a long cap there + // would make a genuinely broken mic feel like a hang. + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBuiltIn) + == MicLiveness.defaultTimeout) + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeUSB) + == MicLiveness.defaultTimeout) + // An unreadable transport must not be treated as Bluetooth. + #expect(MicLiveness.timeout(forTransportType: nil) == MicLiveness.defaultTimeout) + } + + @Test("already-advancing recorder clock confirms immediately, without sleeping") + func immediateLiveness() async { + let clock = TestClock() + // Never advanced: a sleep would park forever, so returning at all proves + // the fast path never sleeps (the suite's time limit backs that up). + let gap = await MicLiveness.waitUntilLive(timeout: .seconds(1), clock: clock) { 0.1 } + #expect(gap == .zero) + } + + @Test("a clock that advances after a few polls confirms with the elapsed gap") + func livenessAfterPolls() async { + let clock = TestClock() + let polls = Mutex(0) + async let gap = MicLiveness.waitUntilLive(timeout: MicLiveness.bluetoothTimeout, clock: clock) { + // Stuck at 0 for the first two checks — the route still switching — then + // the recorder clock starts moving. + polls.withLock { polls in + polls += 1 + return polls < 3 ? 0 : 0.05 + } + } + for _ in 1...2 { + await clock.waitUntilSleeping(for: MicLiveness.pollInterval) + clock.advance(by: MicLiveness.pollInterval) + } + #expect(await gap == MicLiveness.pollInterval * 2) + } + + @Test("a clock that never advances times out with nil — the fail-open signal") + func timeoutFailsOpen() async { + let clock = TestClock() + let timeout = MicLiveness.pollInterval * 2 + async let gap = MicLiveness.waitUntilLive(timeout: timeout, clock: clock) { 0 } + for _ in 1...2 { + await clock.waitUntilSleeping(for: MicLiveness.pollInterval) + clock.advance(by: MicLiveness.pollInterval) + } + #expect(await gap == nil) + } +} diff --git a/Tests/BlurtEngineTests/OverlayUIStateTests.swift b/Tests/BlurtEngineTests/OverlayUIStateTests.swift index f3ffb5b3..f52577b6 100644 --- a/Tests/BlurtEngineTests/OverlayUIStateTests.swift +++ b/Tests/BlurtEngineTests/OverlayUIStateTests.swift @@ -15,6 +15,10 @@ struct OverlayUIStateTests { /// carve-out — stay as their own tests below. static let projections: [(phase: PipelinePhase, expected: OverlayUIState)] = [ (.idle, .idle), + // The mic bring-up (a Bluetooth route switching profiles) gets its own + // warming-up treatment — not `.recording`, whose "speak now" cues must + // wait for audio to actually flow. + (.connecting, .connecting), (.recording, .recording), (.transcribing, .processing), // `.injecting` is a *working* phase, so it must not project to `.idle`: the @@ -71,7 +75,9 @@ struct OverlayUIStateTests { // A genuine failure is not a setup state. #expect(PipelinePhase.failed(.targetAppLost).setupBlocker == nil) // Neither is any non-failed phase. - for phase in [PipelinePhase.idle, .recording, .transcribing, .injecting, .pasted, .noTarget] { + for phase in [ + PipelinePhase.idle, .connecting, .recording, .transcribing, .injecting, .pasted, .noTarget, + ] { #expect(phase.setupBlocker == nil) } // And the pill projection agrees with the classification. @@ -98,6 +104,7 @@ struct OverlayUIStateAccessibilityLabelTests { /// (echo the carried message verbatim), not a constant, so it keeps its own test. static let labels: [(state: OverlayUIState, spoken: String)] = [ (.idle, "Blurt."), + (.connecting, "Connecting to the microphone."), (.recording, "Recording."), (.processing, "Processing."), (.pasted, "Your dictation was pasted."), @@ -137,6 +144,7 @@ struct OverlayUIStateNoticeDwellTests { @Test func steadyStatesHaveNoDwell() { // Held for as long as the pipeline is in them — no auto-revert. #expect(OverlayUIState.idle.noticeDwellSeconds == nil) + #expect(OverlayUIState.connecting.noticeDwellSeconds == nil) #expect(OverlayUIState.recording.noticeDwellSeconds == nil) #expect(OverlayUIState.processing.noticeDwellSeconds == nil) } diff --git a/Tests/BlurtEngineTests/PipelinePhaseTests.swift b/Tests/BlurtEngineTests/PipelinePhaseTests.swift index be7373de..63d3588c 100644 --- a/Tests/BlurtEngineTests/PipelinePhaseTests.swift +++ b/Tests/BlurtEngineTests/PipelinePhaseTests.swift @@ -29,6 +29,7 @@ struct PipelinePhaseTests { @Test("active phases are not terminal") func activePhasesAreNotTerminal() { + #expect(!PipelinePhase.connecting.isTerminal) #expect(!PipelinePhase.recording.isTerminal) #expect(!PipelinePhase.transcribing.isTerminal) #expect(!PipelinePhase.injecting.isTerminal) diff --git a/Tests/BlurtEngineTests/RecordingCueGateTests.swift b/Tests/BlurtEngineTests/RecordingCueGateTests.swift index 8efdc0d0..41785dec 100644 --- a/Tests/BlurtEngineTests/RecordingCueGateTests.swift +++ b/Tests/BlurtEngineTests/RecordingCueGateTests.swift @@ -32,6 +32,23 @@ struct RecordingCueGateTests { #expect(gate.cue(for: .recording) == nil) } + @Test("the start cue waits out .connecting and fires on the edge into .recording") + func connectingDelaysStartCue() { + var gate = RecordingCueGate() + // The mic bring-up must not chime — the start cue is the "speak now" + // signal, and during `.connecting` the input isn't delivering audio yet. + #expect(gate.cue(for: .connecting) == nil) + #expect(gate.cue(for: .recording) == .start) + } + + @Test("a press that fails during .connecting never chimes") + func failedConnectingIsSilent() { + var gate = RecordingCueGate() + #expect(gate.cue(for: .connecting) == nil) + // mic.start() threw — no start cue was played, so no stop cue either. + #expect(gate.cue(for: .failed(.audioCaptureFailed(underlying: MicCaptureError.noInputDevice))) == nil) + } + @Test("transitions between two non-recording phases are silent") func silentBetweenNonRecordingPhases() { var gate = RecordingCueGate() diff --git a/Tests/BlurtEngineTests/Stubs/GatedStartMic.swift b/Tests/BlurtEngineTests/Stubs/GatedStartMic.swift new file mode 100644 index 00000000..c7558356 --- /dev/null +++ b/Tests/BlurtEngineTests/Stubs/GatedStartMic.swift @@ -0,0 +1,29 @@ +import Foundation + +@testable import BlurtEngine + +/// Mic stub whose `start()` signals entry and then blocks until the test +/// releases it — standing in for the real capture's liveness wait — so commands +/// (a release, a cancel) can be landed deterministically while the press is +/// suspended in `mic.start()` and the session sits in `.connecting`. The +/// entry/finish choreography lives in the shared `Gate`, mirroring +/// `GatedStopMic`. +actor GatedStartMic: MicCaptureProtocol { + private(set) var startCalls = 0 + private(set) var stopCalls = 0 + private let gate = Gate() + + func start() async throws { + startCalls += 1 + await gate.enter() + } + + func stop() async throws -> Data { + stopCalls += 1 + // These suites exercise the connecting window, not the too-short-audio guard. + return StubPCM.aboveMinimum + } + + func waitUntilStartEntered() async { await gate.waitUntilEntered() } + func allowStartToFinish() async { gate.allowToFinish() } +} From 6f91766f292904b8d4c85221d4fd51adef1ba2b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:46:31 +0000 Subject: [PATCH 2/2] Apply review feedback: stop-wins guard in start(), delayed Connecting reveal + announcement, doc/test cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrency review: - MicCapture: a stop() that interleaves during start()'s liveness wait now wins — start() re-checks a stop generation counter after the wait and tears the recorder down instead of installing it (previously the mic stayed hot and the temp WAV leaked until start() resumed). - Document why the off-actor recorder.currentTime polling is safe (sole reference by confinement while start() is suspended). - AGENTS.md: add CoreAudio to the engine's system-framework allowlist (and the project-guardrails skill's copy, which must stay in agreement). HIG review: - ConnectingLabel: raise the breath trough to the documented ~55% legibility floor — the faster 0.9 s period alone carries distinctness. - ConnectingLabel: hold the label for 200 ms before revealing, so a fast (wired/built-in) bring-up shows only the dark capsule instead of flashing "Connecting…" mid fade-in. - OverlayWindowController: announce .connecting to VoiceOver after the same 200 ms hold — Bluetooth-length waits get non-visual feedback, fast routes stay silent and go straight to the start chime. Cleanup review: - Hoist the Duration→milliseconds helper to Duration+Milliseconds.swift (internal) and reuse it in MicCapture instead of two hand-derived copies. - Fix stale "idle→recording edge" wording on RecordingCueGate and its suite (the production edge is connecting→recording). - Document on MicCaptureProtocol that start() may hold until frames flow (~2.5 s on Bluetooth) and hosts render the window as connecting. - MicLiveness: drop pollInterval 50 ms → 10 ms (KeyInjector precedent); tests drive an injected clock, so they stay fast. - Fold failedConnectingIsSilent into silentBetweenNonRecordingPhases. - Assert GatedStartMic.startCalls in releaseDuringConnectingFinalizes. - AGENTS.md repo map: mention the liveness gate in the Audio/ entry. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01CNFeP9D8k1HJ1piwyidUv3 --- .claude/skills/project-guardrails/SKILL.md | 2 +- AGENTS.md | 11 +++--- .../Blurt/Overlay/OverlayPillContent.swift | 35 ++++++++++++++--- .../Overlay/OverlayWindowController.swift | 39 ++++++++++++++++++- Sources/BlurtEngine/Audio/MicCapture.swift | 33 +++++++++++++++- .../Audio/MicCaptureProtocol.swift | 6 +++ Sources/BlurtEngine/Audio/MicLiveness.swift | 8 +++- .../BlurtEngine/Duration+Milliseconds.swift | 8 ++++ .../Pipeline/RecordingCueGate.swift | 3 +- .../STT/AssemblyAITranscriber.swift | 9 ----- .../DictationSessionTests.swift | 1 + .../RecordingCueGateTests.swift | 14 +++---- 12 files changed, 132 insertions(+), 37 deletions(-) create mode 100644 Sources/BlurtEngine/Duration+Milliseconds.swift diff --git a/.claude/skills/project-guardrails/SKILL.md b/.claude/skills/project-guardrails/SKILL.md index 1b45e8ba..4ed8ecb8 100644 --- a/.claude/skills/project-guardrails/SKILL.md +++ b/.claude/skills/project-guardrails/SKILL.md @@ -67,7 +67,7 @@ one, stop and ask the user first. This is the fast "don't" list; AGENTS.md's generated from `project.yml`; edit that and run `xcodegen generate`. check.sh fails on pbxproj drift (a PreToolUse hook also blocks edits to it). - The engine has **no external SPM dependencies** (Foundation/Security/ - AVFoundation only). Don't add one to `Sources/BlurtEngine/`. + AVFoundation/CoreAudio only). Don't add one to `Sources/BlurtEngine/`. - Unit tests use **Swift Testing**, not XCTest (the `BlurtUITests` XCUITest bundle is the one exception — XCUIAutomation requires XCTest). **Never touch the real Keychain in tests** — `APIKeyStore` is the production item; use an diff --git a/AGENTS.md b/AGENTS.md index b8efbd88..dc45e6c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,10 +29,10 @@ Four reflexes before you touch anything: ### The two layers -| Layer | What it is | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Sources/BlurtEngine/` | Swift package (`swift-tools-version:6.2`, `platforms: [.macOS(.v15)]`) owning the pipeline. Pure logic behind protocol seams, no AppKit-shell deps, **no external SPM deps** — Foundation/Security/AVFoundation plus toolchain modules like Synchronization, with AppKit types only at the seams. | -| `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`). | +| Layer | What it is | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Sources/BlurtEngine/` | Swift package (`swift-tools-version:6.2`, `platforms: [.macOS(.v15)]`) owning the pipeline. Pure logic behind protocol seams, no AppKit-shell deps, **no external SPM deps** — Foundation/Security/AVFoundation/CoreAudio plus toolchain modules like Synchronization, with AppKit types only at the seams. | +| `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`). | The engine's dependency-free rule is a **rule**; the app merely happens to carry none today. The former `mxcl/AppUpdater` dependency and its in-place self-updater were removed (see [Updates](#updates)), @@ -47,7 +47,8 @@ the design; BLURTENGINE.md covers the _what_ of the API surface. ```text Sources/BlurtEngine/ the engine (dependency-free Swift package) - Audio/ MicCapture (+meter), SoundPack/Catalog/Store — record cues + Audio/ MicCapture (+meter, MicLiveness start gate), SoundPack/Catalog/Store — + record cues Config/ Keychain-backed API key, key terms, developer mode, DefaultsKey + PersistedSettings (every defaults key, and the reset sweep over them) FocusCapture/ Accessibility reads of the frontmost app / focused field diff --git a/App/Blurt/Blurt/Overlay/OverlayPillContent.swift b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift index ab8ad574..8bb9a51a 100644 --- a/App/Blurt/Blurt/Overlay/OverlayPillContent.swift +++ b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift @@ -85,21 +85,44 @@ struct TranscribingLabel: View { /// The "Connecting…" status line shown while the mic route comes up (the /// engine's `.connecting` phase — a Bluetooth input takes ~1–2 s to switch /// A2DP→HFP). Styled as a status line like `TranscribingLabel`, but with a -/// faster, deeper breath so it reads as "wait" rather than the calm -/// transcribing heartbeat. Under Reduce Motion it holds steady at full opacity. +/// faster breath so it reads as "wait" rather than the calm transcribing +/// heartbeat. Under Reduce Motion it holds steady at full opacity. +/// +/// The label holds off for `revealDelay` before appearing: a wired or built-in +/// mic clears the liveness gate in well under 100 ms, so an immediate label +/// flashed "Connecting…" mid fade-in on every fast press. A fast bring-up shows +/// only the dark capsule; the label appears only once the wait is real. struct ConnectingLabel: View { /// Whether to run the breathing motion (off under Reduce Motion). let animated: Bool - // Roughly twice the tempo and twice the depth of TranscribingLabel's breath: - // busy enough to say "not ready yet, hold on" at a glance, while staying the - // same status-line idiom as the rest of the pill. + /// How long a bring-up must persist before the label appears — long enough + /// that fast (wired/built-in) routes never show it, short next to the ~1–2 s + /// Bluetooth wait it exists for. `OverlayWindowController` holds the + /// `.connecting` VoiceOver announcement for the same delay, so the visual and + /// spoken feedback agree on when a bring-up is worth mentioning. + static let revealDelay: Duration = .milliseconds(200) + + // Twice the tempo of TranscribingLabel's breath: busy enough to say "not + // ready yet, hold on" at a glance, while staying the same status-line idiom + // as the rest of the pill. The trough stays at TranscribingLabel's ~55% + // floor — what keeps the 10 pt cyan legible against the dark tint — so the + // faster period alone carries the distinctness. private let breathPeriod: Double = 0.9 - private let minOpacity: Double = 0.35 + private let minOpacity: Double = 0.55 + + @State private var revealed = false var body: some View { StatusLineText("Connecting…") .pulsingOpacity(period: breathPeriod, minOpacity: minOpacity, animated: animated) + .opacity(revealed ? 1 : 0) + .animation(animated ? .easeInOut(duration: 0.15) : nil, value: revealed) + .task { + try? await Task.sleep(for: Self.revealDelay) + guard !Task.isCancelled else { return } + revealed = true + } } } diff --git a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift index 64fb0d65..2883014f 100644 --- a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift +++ b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift @@ -54,6 +54,15 @@ final class OverlayWindowController { // (`OverlayUIState.noticeDwellSeconds`, unit-tested there). private var errorRevertTask: Task? + // Holds the `.connecting` VoiceOver announcement until the bring-up has + // persisted past `ConnectingLabel.revealDelay` — the same hold the label + // itself applies. A fast (wired/built-in) route resolves within the delay, + // cancels this, and stays silent all the way to the start chime; only a real + // (Bluetooth-length) wait gets spoken. Without it, VoiceOver users had no + // non-visual feedback at all during a bring-up: announcements fired only for + // the dwell notices, and this non-activating panel never takes focus. + private var connectingAnnounceTask: Task? + // The pill fades in fast — the appear is tied to the user's keypress, so a snappy // ramp reads as instant response — but fades out gently. Asymmetric on purpose. private static let appearFadeDuration: Double = 0.08 @@ -104,13 +113,14 @@ final class OverlayWindowController { /// OverlayWindowController lives for the whole app session, so this never runs /// in practice — but tearing the observer down (and cancelling any pending - /// error-flash revert) mirrors the `[weak self]` care above and documents that - /// the registrations are owned, not leaked. + /// error-flash revert or connecting announcement) mirrors the `[weak self]` + /// care above and documents that the registrations are owned, not leaked. deinit { if let didMoveObserver { NotificationCenter.default.removeObserver(didMoveObserver) } errorRevertTask?.cancel() + connectingAnnounceTask?.cancel() } func show(state: OverlayUIState) { @@ -118,6 +128,11 @@ final class OverlayWindowController { // press while the red pill is up should win, not get stomped back to idle. errorRevertTask?.cancel() errorRevertTask = nil + // Likewise a pending connecting announcement: once the state has moved on + // (to `.recording`, or a failure), announcing "Connecting" would be stale — + // and on a fast bring-up this cancel is what keeps the pill silent. + connectingAnnounceTask?.cancel() + connectingAnnounceTask = nil // Idle means "no dictation happening" — the pill rides the pipeline and is // hidden at rest, so fade it out. The displayed state is left untouched so @@ -137,6 +152,24 @@ final class OverlayWindowController { if bridge.state != state { bridge.state = state } + // The mic bring-up gets the same VoiceOver treatment as the dwell notices + // below — this panel never takes focus, so an announcement is the only + // non-visual channel — but held for the label's reveal delay first (see + // `connectingAnnounceTask`): a fast route flips to `.recording` within the + // delay and goes straight to the start chime. + if case .connecting = state { + connectingAnnounceTask = Task { + try? await Task.sleep(for: ConnectingLabel.revealDelay) + guard !Task.isCancelled else { return } + NSAccessibility.post( + element: NSApp as Any, + notification: .announcementRequested, + userInfo: [ + .announcement: state.accessibilityLabel, + .priority: NSAccessibilityPriorityLevel.high.rawValue, + ]) + } + } // The red error flash and the neutral "copied" notice are both transient: the // pill is otherwise only up during active dictation, so they linger briefly to // be read, then settle back to idle. Announce them for VoiceOver since this @@ -171,6 +204,8 @@ final class OverlayWindowController { func hide() { errorRevertTask?.cancel() errorRevertTask = nil + connectingAnnounceTask?.cancel() + connectingAnnounceTask = nil guard panel.isVisible else { // Still settle the content when the panel is already off screen (the pill // may have been hidden mid-notice) — `dismissPanel` would have done it. diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index ed5252e2..a9144495 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -36,6 +36,15 @@ public actor MicCapture: MicCaptureProtocol { private var preparedRecorder: AVAudioRecorder? /// The recorder for the in-flight session; nil between `stop()` and `start()`. private var activeRecorder: AVAudioRecorder? + /// Incremented by every `stop()`. `start()` snapshots it before suspending in + /// the liveness wait — its one internal suspension — and re-checks after, so a + /// `stop()` that interleaves during the wait wins: without this, a reentrant + /// caller's stop saw `activeRecorder == nil`, returned an empty "clean stop", + /// and the not-yet-installed recorder kept capturing (mic indicator hot, temp + /// WAV leaked) until `start()` resumed. Unreachable through `DictationSession` + /// (its serial command queue runs release/cancel only after the press turn + /// completes), but this is a public actor — any host can call it unqueued. + private var stopGeneration = 0 /// Polls the active recorder's meter and feeds `levels` while recording. private var meterTask: Task? /// The last value `emitLevel` put on the stream, so an unchanged tick can be @@ -106,13 +115,32 @@ public actor MicCapture: MicCaptureProtocol { // clock advances (frames are flowing), capped per transport; on timeout // proceed anyway (fail open), which is exactly the old behavior. let timeout = MicLiveness.timeout(forTransportType: Self.defaultInputTransportType()) + let stopGenerationBeforeWait = stopGeneration let gap = await MicLiveness.waitUntilLive(timeout: timeout, clock: ContinuousClock()) { + // Off-actor read (`waitUntilLive` is nonisolated), safe by confinement: + // the polls run sequentially within one task, and nothing else references + // this recorder while `start()` is suspended in the wait — it isn't + // `activeRecorder` yet, `preparedRecorder` was cleared above, and the + // meter task hasn't started. (The `@Sendable` closure capturing the + // non-Sendable AVAudioRecorder is accepted only via the + // `@preconcurrency` import; the compiler can't re-check this argument.) recorder.currentTime } + + // A stop() that landed while the wait was suspended wins: tear the recorder + // down instead of installing it, so the caller's stop stays a real stop + // (see `stopGeneration`). + guard stopGeneration == stopGenerationBeforeWait else { + recorder.stop() + Self.removeFile(at: recorder.url) + Self.logger.info("start aborted — stop() landed during the liveness wait") + throw CancellationError() + } + if let gap { - Self.logger.info("input live after \(Int((gap / .milliseconds(1)).rounded())) ms") + Self.logger.info("input live after \(Int(gap.milliseconds.rounded())) ms") } else { - let capMs = Int((timeout / .milliseconds(1)).rounded()) + let capMs = Int(timeout.milliseconds.rounded()) Self.logger.error("input liveness unconfirmed after \(capMs) ms — proceeding") } @@ -123,6 +151,7 @@ public actor MicCapture: MicCaptureProtocol { } public func stop() async throws -> Data { + stopGeneration += 1 meterTask?.cancel() meterTask = nil guard let recorder = activeRecorder else { return Data() } diff --git a/Sources/BlurtEngine/Audio/MicCaptureProtocol.swift b/Sources/BlurtEngine/Audio/MicCaptureProtocol.swift index 9323daed..191b473d 100644 --- a/Sources/BlurtEngine/Audio/MicCaptureProtocol.swift +++ b/Sources/BlurtEngine/Audio/MicCaptureProtocol.swift @@ -2,6 +2,12 @@ import Foundation public protocol MicCaptureProtocol: Sendable { /// Begin capturing 16 kHz mono 16-bit PCM. Throws on permission/device failure. + /// May hold until the input device actually delivers frames — a Bluetooth + /// route spends up to ~2.5 s switching profiles (A2DP→HFP) before any audio + /// flows — and hosts render the whole in-flight call as a distinct + /// "connecting" state (the pipeline's `.connecting` phase), with the + /// "speak now" cues arriving only on return. A conformer that returns + /// before frames flow cues the user to speak into a dead mic. func start() async throws /// Stop capture and return the captured audio as raw S16LE PCM bytes — the /// exact encoding the dictation request uploads, so no conversion pass sits on diff --git a/Sources/BlurtEngine/Audio/MicLiveness.swift b/Sources/BlurtEngine/Audio/MicLiveness.swift index 97a9614b..3e8be0ff 100644 --- a/Sources/BlurtEngine/Audio/MicLiveness.swift +++ b/Sources/BlurtEngine/Audio/MicLiveness.swift @@ -7,8 +7,12 @@ import Foundation /// same split as `MicCapture+Meter`) so the timeout policy and the wait's /// edge cases are unit-tested against an injected clock. enum MicLiveness { - /// How often the recorder's clock is re-checked while waiting. - static let pollInterval: Duration = .milliseconds(50) + /// How often the recorder's clock is re-checked while waiting. Every press + /// whose first read is still 0 pays at least one quantum before `.recording` + /// and the start chime, so keep it short — 10 ms, the same cadence as + /// `KeyInjector.waitUntilFrontmost`'s poll-until-deadline loop — rather than + /// taxing the common (wired/built-in) case to wait for the Bluetooth one. + static let pollInterval: Duration = .milliseconds(10) /// Wait cap for Bluetooth inputs: bringing an AirPods mic up means an /// A2DP→HFP profile switch that takes ~1–2 s, and macOS drops the link back diff --git a/Sources/BlurtEngine/Duration+Milliseconds.swift b/Sources/BlurtEngine/Duration+Milliseconds.swift new file mode 100644 index 00000000..75505ded --- /dev/null +++ b/Sources/BlurtEngine/Duration+Milliseconds.swift @@ -0,0 +1,8 @@ +extension Duration { + /// This duration in milliseconds as a Double (for latency logging). Expressed + /// as a ratio of two `Duration`s rather than reassembled from `components`, + /// which meant restating the attoseconds-per-millisecond constant by hand. + var milliseconds: Double { + self / Duration.milliseconds(1) + } +} diff --git a/Sources/BlurtEngine/Pipeline/RecordingCueGate.swift b/Sources/BlurtEngine/Pipeline/RecordingCueGate.swift index fd74569e..26c03959 100644 --- a/Sources/BlurtEngine/Pipeline/RecordingCueGate.swift +++ b/Sources/BlurtEngine/Pipeline/RecordingCueGate.swift @@ -10,7 +10,8 @@ public enum RecordingCue: Equatable, Sendable { /// Edge-detector deciding when the record start/stop chimes fire. The host calls /// `cue(for:)` on *every* pipeline phase, so the gate fires `.start` only on the -/// idle→recording edge and `.stop` only on the recording→not-recording edge, +/// edge into `.recording` (in production, connecting→recording — audio actually +/// flowing) and `.stop` only on the recording→not-recording edge, /// staying silent while a phase repeats and across transitions between two /// non-recording phases. Value type holding a single edge bit; the host owns one /// instance for the app's lifetime. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index a120df41..04c1420c 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -339,15 +339,6 @@ private final class MetricsLogger: NSObject, URLSessionTaskDelegate, @unchecked } } -extension Duration { - /// This duration in milliseconds as a Double (for latency logging). Expressed - /// as a ratio of two `Duration`s rather than reassembled from `components`, - /// which meant restating the attoseconds-per-millisecond constant by hand. - fileprivate var milliseconds: Double { - self / Duration.milliseconds(1) - } -} - /// Errors specific to the AssemblyAI transport. These get wrapped in /// `BlurtError.sttFailed` before reaching the UI. enum AssemblyAIError: Error, LocalizedError { diff --git a/Tests/BlurtEngineTests/DictationSessionTests.swift b/Tests/BlurtEngineTests/DictationSessionTests.swift index 8fa187e2..62acd1da 100644 --- a/Tests/BlurtEngineTests/DictationSessionTests.swift +++ b/Tests/BlurtEngineTests/DictationSessionTests.swift @@ -276,6 +276,7 @@ extension DictationSessionTests { await session.waitForIdle() #expect(await session.phase == .pasted) + #expect(await mic.startCalls == 1) #expect(await mic.stopCalls == 1) } diff --git a/Tests/BlurtEngineTests/RecordingCueGateTests.swift b/Tests/BlurtEngineTests/RecordingCueGateTests.swift index 41785dec..ef6d0a36 100644 --- a/Tests/BlurtEngineTests/RecordingCueGateTests.swift +++ b/Tests/BlurtEngineTests/RecordingCueGateTests.swift @@ -5,7 +5,8 @@ import Testing /// The record start/stop chimes fire on the *edges* of the recording phase, not /// on every phase tick. `AppCoordinator.render` calls the cue gate on every /// pipeline phase (idle, recording, transcribing, injecting, pasted, …), so the -/// gate must fire `.start` only on the idle→recording edge and `.stop` only on +/// gate must fire `.start` only on the edge into `.recording` (in production, +/// connecting→recording) and `.stop` only on /// the recording→not-recording edge, staying silent on repeats and on /// transitions between two non-recording phases. Lifting that edge detection out /// of the AppKit `CueSoundPlayer` lets `swift test` cover it — the same split as @@ -41,14 +42,6 @@ struct RecordingCueGateTests { #expect(gate.cue(for: .recording) == .start) } - @Test("a press that fails during .connecting never chimes") - func failedConnectingIsSilent() { - var gate = RecordingCueGate() - #expect(gate.cue(for: .connecting) == nil) - // mic.start() threw — no start cue was played, so no stop cue either. - #expect(gate.cue(for: .failed(.audioCaptureFailed(underlying: MicCaptureError.noInputDevice))) == nil) - } - @Test("transitions between two non-recording phases are silent") func silentBetweenNonRecordingPhases() { var gate = RecordingCueGate() @@ -58,6 +51,9 @@ struct RecordingCueGateTests { #expect(gate.cue(for: .transcribing) == nil) #expect(gate.cue(for: .injecting) == nil) #expect(gate.cue(for: .pasted) == nil) + #expect(gate.cue(for: .connecting) == nil) + // A press that fails during the mic bring-up (connecting→failed) played no + // start cue, so no stop cue either. #expect(gate.cue(for: .failed(.apiKeyMissing)) == nil) }