From dd02040aad4b8ed32a363a9177f9368f412547fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:00:59 +0000 Subject: [PATCH 1/4] Add microphone selection to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Microphone picker in Settings > General pins dictation to a specific input device, persisted as the device's CoreAudio UID (MicDeviceStore / BlurtMicDeviceUID). Un-pinned capture — the default — keeps the shipped AVAudioRecorder WAV path untouched; a pinned capture records through a fresh-per-session AudioQueue bound to the device via kAudioQueueProperty_CurrentDevice, behind a new CaptureRecorder seam inside MicCapture. The transport-keyed policies (liveness timeout, Bluetooth tail linger) and the warm-recorder identity check key off the pinned device's snapshot; a pinned device that isn't connected falls back to the system default per press (MicDeviceSelection.effective, pure and unit-tested) without unpinning. Device enumeration and UID translation live in AudioInputDevices (hardware-bound, coverage-excluded like AudioRoute). Engine tests stay off real CoreAudio: the new live suites ride the BLURT_LIVE_AUDIO_TESTS gate, and the pure decode/fallback/store rules are covered by MicDeviceStoreTests and the roster tests (now 12 keys). --- AGENTS.md | 13 + .../Blurt/Wizard/SettingsWindowRoot.swift | 3 +- .../Blurt/Wizard/Steps/SoundStepView.swift | 76 ++++++ App/Blurt/BlurtUITests/SettingsUITests.swift | 16 ++ App/Blurt/Shared/UITestIdentifiers.swift | 1 + .../BlurtEngine/Audio/AudioInputDevices.swift | 121 ++++++++++ Sources/BlurtEngine/Audio/AudioRoute.swift | 6 +- .../BlurtEngine/Audio/CaptureRecorder.swift | 132 ++++++++++ .../BlurtEngine/Audio/MicCapture+Warm.swift | 40 ++-- Sources/BlurtEngine/Audio/MicCapture.swift | 205 ++++++++-------- .../BlurtEngine/Audio/MicCaptureError.swift | 18 ++ .../BlurtEngine/Audio/MicDeviceStore.swift | 73 ++++++ .../Audio/PinnedAudioQueueRecorder.swift | 225 ++++++++++++++++++ Sources/BlurtEngine/Config/DefaultsKey.swift | 3 + Sources/BlurtEngine/README.md | 2 +- .../AudioInputDevicesTests.swift | 70 ++++++ .../MicCaptureFormatTests.swift | 10 + .../MicCaptureLevelsTests.swift | 2 +- .../MicCaptureWarmTests.swift | 63 +++-- .../MicDeviceStoreTests.swift | 62 +++++ .../PersistedSettingsTests.swift | 10 +- scripts/check.sh | 16 +- 22 files changed, 1020 insertions(+), 147 deletions(-) create mode 100644 Sources/BlurtEngine/Audio/AudioInputDevices.swift create mode 100644 Sources/BlurtEngine/Audio/CaptureRecorder.swift create mode 100644 Sources/BlurtEngine/Audio/MicDeviceStore.swift create mode 100644 Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift create mode 100644 Tests/BlurtEngineTests/AudioInputDevicesTests.swift create mode 100644 Tests/BlurtEngineTests/MicDeviceStoreTests.swift diff --git a/AGENTS.md b/AGENTS.md index 659a2b5d..a9a14c4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -445,6 +445,16 @@ 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. +**Microphone selection** rides a backend seam inside the actor (`CaptureRecorder`): un-pinned +capture — the default — keeps the `AVAudioRecorder` path above unchanged, while a device pinned in +Settings (`MicDeviceStore`, persisted as the device UID) records through `PinnedAudioQueueRecorder`, +an AudioQueue bound to that device via `kAudioQueueProperty_CurrentDevice` — still fresh per +session. The transport-keyed policies (liveness cap, tail linger) and the warm-recorder identity +check key off the **pinned** device's snapshot, and a pinned device that isn't connected falls back +to the system default per press (`MicDeviceSelection.effective` — pure and unit-tested; the pin +stays stored). Device enumeration and UID translation live in `AudioInputDevices` (hardware-bound, +coverage-excluded, like `AudioRoute`). + **Bluetooth inputs are the reason for four of this actor's moving parts.** Opening the mic on AirPods (or any Bluetooth headset) makes the system renegotiate the link into its mic-capable mode — one to two seconds, during which the OS receives no audio at all — and that link then buffers audio @@ -837,6 +847,9 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: Only the active profile is ever sent, so that budget is not divided between them. One of the three stores with a setter, for the encoded-on-write reason below: the settings sheet and the main window's switcher write through it rather than binding the raw slots), + **`MicDeviceStore`** (`BlurtMicDeviceUID`, the input device dictation is pinned to, as its + CoreAudio UID — empty/unset follows the system default; re-read at every press, and a pinned + device that isn't connected falls back to the system default without unpinning), **`OverlayOriginStore`** (the pill's dragged origin, x/y), **`LastUpdateCheckStore`** (`BlurtLastUpdateCheck`, the stamp throttling the automatic launch update check). - **`DefaultsKey`** (`Config/DefaultsKey.swift`) defines every key those stores write, one case each, diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 9e403154..b34f7a27 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -65,7 +65,7 @@ private struct SettingsPane: View { } /// The everyday setup a user changes: the AssemblyAI key, the dictation -/// shortcut, the cue sound, and the transcription key terms. +/// shortcut, the microphone, the cue sound, and the transcription key terms. private struct GeneralSettingsTab: View { let coordinator: AppCoordinator @@ -73,6 +73,7 @@ private struct GeneralSettingsTab: View { SettingsPane { APIKeyStepView(apiKey: coordinator.apiKey) HotkeyStepView(coordinator: coordinator) + MicrophoneStepView() SoundStepView(coordinator: coordinator) KeyTermsStepView() } diff --git a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift b/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift index 73f11aa6..e4dba5d1 100644 --- a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift @@ -48,3 +48,79 @@ struct SoundStepView: View { } } } + +/// The microphone section of the settings screen: a menu picker that either +/// follows the system's default input (the default) or pins dictation to one +/// specific input device, persisted as the device's UID via `MicDeviceStore`. +/// `MicCapture` re-reads the selection at every press, so a change applies to +/// the next dictation with nothing to push — and a pinned device that isn't +/// connected gracefully falls back to the system default +/// (`MicDeviceSelection.effective`), so no choice here can break dictation. +/// +/// In this file beside `SoundStepView` — the other audio section, whose picker +/// shape this mirrors — rather than its own, so the app target's file list (and +/// the generated project) is unchanged. +struct MicrophoneStepView: View { + // Empty means "no device pinned" — the unset default belongs to + // `MicDeviceSelection.fromPersisted` (below), which reads it as "same as + // system". See `HotkeyStepView` for why the view must not restate it. + @AppStorage(MicDeviceStore.defaultsKey) private var micDeviceUID = "" + + /// The input devices present when the pane appeared, re-read each time it + /// does. A snapshot rather than a live listener: the picker's menu is built + /// when it opens, and a device plugged in mid-session shows up on the next + /// visit — the capture path resolves the UID fresh at every press regardless. + @State private var devices: [AudioInputDevice] = [] + /// The current default input's name, for the "Same as system (…)" label; nil + /// (no device, or the read failed) drops the parenthetical. + @State private var systemDefaultName: String? + + private var selection: Binding { + Binding( + get: { micDeviceUID }, + set: { newValue in + // Write through the store (see `HotkeyStepView` for why): the store owns + // the encoding, `@AppStorage` observes the key to re-render. + MicDeviceStore().selection = MicDeviceSelection.fromPersisted(newValue) + }) + } + + /// The stored pin when no connected device carries it — kept selectable so + /// the picker can render the persisted choice instead of a blank control, and + /// the user sees why dictation is currently using the system default. + private var missingPinnedUID: String? { + guard !micDeviceUID.isEmpty, !devices.contains(where: { $0.uid == micDeviceUID }) else { + return nil + } + return micDeviceUID + } + + private var systemDefaultLabel: String { + systemDefaultName.map { "Same as system (\($0))" } ?? "Same as system" + } + + var body: some View { + Section { + PickerSettingRow( + title: "Input device", systemImage: "mic", + accessibilityID: UITestIdentifiers.micPicker, selection: selection + ) { + Text(systemDefaultLabel).tag("") + ForEach(devices) { device in + Text(device.name).tag(device.uid) + } + if let missingPinnedUID { + Text("Disconnected microphone").tag(missingPinnedUID) + } + } + } header: { + Text("Microphone") + } footer: { + Text("Dictation records from this microphone. While it isn't connected, the system default is used.") + } + .onAppear { + devices = AudioInputDevices.all() + systemDefaultName = AudioInputDevices.systemDefaultInputName() + } + } +} diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 61e68409..87540254 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -75,6 +75,22 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertEqual(picker.value as? String, "right ⌥") } + /// The microphone picker exists and defaults to following the system input. + /// Only the first option is asserted: the rest of the menu lists whatever + /// input devices the machine happens to have (a CI runner may have none), and + /// the parenthetical default-device name varies with it — so the assertion is + /// a prefix, and nothing here opens the menu or pins a device. + func testMicrophonePickerDefaultsToSystemInput() { + let settings = openSettingsWindow() + + let picker = settings.popUpButtons[UITestIdentifiers.micPicker] + XCTAssertTrue(picker.waitForExistence(timeout: 10), "Microphone picker not found") + let value = picker.value as? String ?? "" + XCTAssertTrue( + value.hasPrefix("Same as system"), + "The picker should start on the system default (got: \(value))") + } + /// The sound-cue picker changes the persisted selection. Selecting "None" /// works regardless of the runner's persisted starting value. func testSoundPickerChangesSelection() { diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index f16cab73..93090502 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -61,6 +61,7 @@ enum UITestIdentifiers { static let apiKeyError = "settings.apiKey.error" static let keyTermsField = "settings.keyTerms.field" static let hotkeyPicker = "settings.hotkey.picker" + static let micPicker = "settings.mic.picker" static let soundPicker = "settings.sound.picker" static let developerToggle = "settings.developer.toggle" static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle" diff --git a/Sources/BlurtEngine/Audio/AudioInputDevices.swift b/Sources/BlurtEngine/Audio/AudioInputDevices.swift new file mode 100644 index 00000000..3d11b06f --- /dev/null +++ b/Sources/BlurtEngine/Audio/AudioInputDevices.swift @@ -0,0 +1,121 @@ +import CoreAudio +import Foundation + +/// One selectable input device: its persistent CoreAudio UID (what +/// `MicDeviceStore` pins) and its user-facing name (what the Settings picker +/// shows). +public struct AudioInputDevice: Identifiable, Sendable { + public let uid: String + public let name: String + public var id: String { uid } +} + +/// Read-only enumeration of the machine's audio *input* devices — the list the +/// Settings microphone picker offers — plus the UID→device translation the +/// capture path resolves a pinned selection with. +/// +/// A sibling of `AudioRoute`, not part of it, because these reads bridge +/// `CFString`s (device names and UIDs) and therefore need Foundation, which +/// `AudioRoute` deliberately avoids (see `AudioRoute.InputSnapshot.deviceID`). +/// Like `AudioRoute` it is raw reads only — the policy for a UID that no longer +/// resolves is `MicDeviceSelection.effective`, which is pure and unit-tested — +/// and like `AudioRoute` it needs real hardware to answer anything, so it is +/// excluded from the coverage gate and must not be where a decision hides. +public enum AudioInputDevices { + /// Every device with at least one input stream, sorted by name for a stable + /// picker order. Empty when there are none, or CoreAudio refused the read. + public static func all() -> [AudioInputDevice] { + deviceIDs() + .compactMap { deviceID -> AudioInputDevice? in + guard hasInputStreams(deviceID), + let uid = stringProperty(kAudioDevicePropertyDeviceUID, of: deviceID), + let name = stringProperty(kAudioObjectPropertyName, of: deviceID) + else { return nil } + return AudioInputDevice(uid: uid, name: name) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// The current default input device's name — what the picker's "Same as + /// system (…)" option shows — or nil when there is no input device or a read + /// failed (the picker then says "Same as system" with no parenthetical). + public static func systemDefaultInputName() -> String? { + guard let snapshot = AudioRoute.currentInput() else { return nil } + return stringProperty(kAudioObjectPropertyName, of: snapshot.deviceID) + } + + /// The pinned device as an `InputSnapshot` — the same shape + /// `AudioRoute.currentInput()` answers for the default input, so the + /// transport-keyed policies (`MicLiveness.timeout`, the tail linger) and the + /// warm-recorder identity check key off the pinned device exactly as they key + /// off the default one. Nil when no device carries this UID right now, which + /// is the missing-device signal `MicDeviceSelection.effective` falls back on. + static func input(forUID uid: String) -> AudioRoute.InputSnapshot? { + guard let deviceID = deviceID(forUID: uid) else { return nil } + return AudioRoute.InputSnapshot( + deviceID: deviceID, transportType: AudioRoute.transportType(of: deviceID)) + } + + // MARK: - CoreAudio reads + + /// Every audio device the system object lists, input or not — the input + /// filter is `hasInputStreams`. + private static func deviceIDs() -> [AudioDeviceID] { + var address = AudioRoute.globalAddress(kAudioHardwarePropertyDevices) + var size = UInt32(0) + let stride = MemoryLayout.size + guard + AudioObjectGetPropertyDataSize(AudioRoute.systemObject, &address, 0, nil, &size) == noErr, + size >= UInt32(stride) + else { return [] } + var deviceIDs = [AudioDeviceID](repeating: 0, count: Int(size) / stride) + let status = AudioObjectGetPropertyData( + AudioRoute.systemObject, &address, 0, nil, &size, &deviceIDs) + guard status == noErr else { return [] } + return deviceIDs + } + + /// Whether the device has any input streams — asked as the *size* of its + /// input-scope stream list, so no variable-length buffer needs decoding just + /// to learn "more than zero". + private static func hasInputStreams(_ deviceID: AudioDeviceID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, + mScope: kAudioObjectPropertyScopeInput, + mElement: kAudioObjectPropertyElementMain) + var size = UInt32(0) + let status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) + return status == noErr && size > 0 + } + + /// A CFString property (name, UID) bridged to `String`, or nil when the read + /// failed. The HAL hands these back retained, hence `takeRetainedValue`. + private static func stringProperty( + _ selector: AudioObjectPropertySelector, of objectID: AudioObjectID + ) -> String? { + var address = AudioRoute.globalAddress(selector) + var value: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = AudioObjectGetPropertyData(objectID, &address, 0, nil, &size, &value) + guard status == noErr, let value else { return nil } + return value.takeRetainedValue() as String + } + + /// Translate a device UID to the live `AudioDeviceID` carrying it, via the + /// system object's translation property (the UID rides in as the qualifier). + /// Nil covers a failed read and the `kAudioObjectUnknown` sentinel — i.e. the + /// device isn't connected right now. + private static func deviceID(forUID uid: String) -> AudioDeviceID? { + var address = AudioRoute.globalAddress(kAudioHardwarePropertyTranslateUIDToDevice) + var cfUID = uid as CFString + var deviceID = AudioDeviceID(0) + var size = UInt32(MemoryLayout.size) + let status = withUnsafeMutablePointer(to: &cfUID) { qualifier in + AudioObjectGetPropertyData( + AudioRoute.systemObject, &address, UInt32(MemoryLayout.size), qualifier, + &size, &deviceID) + } + guard status == noErr, deviceID != 0 else { return nil } + return deviceID + } +} diff --git a/Sources/BlurtEngine/Audio/AudioRoute.swift b/Sources/BlurtEngine/Audio/AudioRoute.swift index bf7e8e98..57717471 100644 --- a/Sources/BlurtEngine/Audio/AudioRoute.swift +++ b/Sources/BlurtEngine/Audio/AudioRoute.swift @@ -109,8 +109,10 @@ enum AudioRoute { /// The device's transport type, or nil when the read failed — /// `AudioTransport` and `MicLiveness` both treat nil as "not Bluetooth", which - /// is the conservative direction for each. - private static func transportType(of deviceID: AudioDeviceID) -> UInt32? { + /// is the conservative direction for each. Internal rather than private so + /// `AudioInputDevices.input(forUID:)` builds a pinned device's `InputSnapshot` + /// from the same read the default input's snapshot comes from. + static func transportType(of deviceID: AudioDeviceID) -> UInt32? { var address = globalAddress(kAudioDevicePropertyTransportType) var transport = UInt32(0) var size = UInt32(MemoryLayout.size) diff --git a/Sources/BlurtEngine/Audio/CaptureRecorder.swift b/Sources/BlurtEngine/Audio/CaptureRecorder.swift new file mode 100644 index 00000000..9635de33 --- /dev/null +++ b/Sources/BlurtEngine/Audio/CaptureRecorder.swift @@ -0,0 +1,132 @@ +@preconcurrency import AVFoundation +import Foundation + +/// The one-session recording surface `MicCapture` drives, abstracted over which +/// backend records: an un-pinned capture — the shipping default — keeps the +/// `AVAudioRecorder` WAV path exactly as it was (`WAVFileRecorder`), and a +/// capture pinned to a specific device takes `PinnedAudioQueueRecorder`, the +/// macOS capture surface that accepts a per-instance device. Both are built +/// fresh per session — the invariant the capture design rests on — and both +/// feed the same liveness gate, meter, warm-recorder and tail-linger machinery +/// through this seam, so pinning changes which device is opened and nothing +/// else. +/// +/// `Sendable` because `MicCapture.start()`'s liveness probes read the recorder +/// off-actor; conformers are `@unchecked Sendable` on the same confinement +/// argument the raw recorder relied on there — the polls run sequentially in +/// one task while `start()` is suspended and nothing else references the +/// recorder in that window (the AudioQueue backend additionally locks the state +/// its CoreAudio callback thread shares). +protocol CaptureRecorder: AnyObject, Sendable { + /// Begin capturing. False when no usable input device is available. + func record() -> Bool + /// Seconds the recorder's clock has advanced since `record()` — 0 until the + /// underlying queue is started and clocking. Consulted only as the liveness + /// gate's has-the-clock-moved probe; frames of digital silence advance it + /// exactly like real audio (see `MicLiveness.waitUntilLive`). + var currentTime: TimeInterval { get } + /// Refresh and read the input's average power in dBFS. Feeds both the + /// liveness gate's silence-floor probe and the overlay meter. + func meteredPowerDB() -> Float + /// End capture and return everything recorded as raw S16LE PCM at the + /// dictation API's rate, releasing the device and any temp storage. + func stopAndReadPCM() throws -> Data + /// End capture and throw the audio away, releasing the device and any temp + /// storage — the teardown behind a failed `record()`, an aborted bring-up, a + /// discarded warm recorder, and a cancel. + func stopAndDiscard() + /// A short name for the capture-start log line (the WAV path's temp filename; + /// a fixed tag for the pinned queue). + var logName: String { get } +} + +/// The system-default backend: `AVAudioRecorder` recording straight to a temp +/// 16 kHz / mono / 16-bit WAV, read back as raw S16LE bytes on stop. This is +/// the shipped capture path, moved behind the seam verbatim — every capture +/// that isn't pinned to a device still goes through exactly this. +final class WAVFileRecorder: CaptureRecorder, @unchecked Sendable { + private let recorder: AVAudioRecorder + + init() throws { + recorder = try MicCapture.makeRecorder() + } + + func record() -> Bool { recorder.record() } + + var currentTime: TimeInterval { recorder.currentTime } + + func meteredPowerDB() -> Float { + recorder.updateMeters() + return recorder.averagePower(forChannel: 0) + } + + func stopAndReadPCM() throws -> Data { + recorder.stop() + defer { MicCapture.removeFile(at: recorder.url) } + return try MicCapture.decodePCM(fromFileAt: recorder.url) + } + + func stopAndDiscard() { + recorder.stop() + MicCapture.removeFile(at: recorder.url) + } + + var logName: String { recorder.url.lastPathComponent } +} + +// The construction and file plumbing behind `WAVFileRecorder`, plus the +// backend choice itself. Statics on `MicCapture` (moved here from +// `MicCapture.swift` for its lint file-length budget, like `+Meter` and +// `+Warm`): `decodePCM` keeps its home so `MicCaptureFormatTests` pins the +// decode against the same symbol the capture uses. +extension MicCapture { + /// The backend for a resolved input: a pinned UID gets the AudioQueue + /// recorder bound to that device; no pin gets the WAV recorder on the system + /// default. The only place the two backends are told apart. + static func makeBackend(pinnedUID: String?) throws -> any CaptureRecorder { + guard let pinnedUID else { return try WAVFileRecorder() } + return try PinnedAudioQueueRecorder(deviceUID: pinnedUID) + } + + /// Build a recorder that writes mono 16-bit little-endian PCM at the target + /// rate into a unique temp file. `prepareToRecord()` does the heavy route/buffer + /// setup so the subsequent `record()` starts promptly. + static func makeRecorder() throws -> AVAudioRecorder { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("blurt-\(UUID().uuidString).wav") + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: targetSampleRate, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + ] + let recorder = try AVAudioRecorder(url: url, settings: settings) + recorder.isMeteringEnabled = true + recorder.prepareToRecord() + return recorder + } + + /// Read a recorded PCM file back as raw S16LE bytes — the dictation API's upload + /// encoding. The on-disk WAV already holds 16-bit int samples, so asking + /// `AVAudioFile` for the int16 common format makes this a straight copy-out: + /// no detour through Float32 (which the default `processingFormat` would + /// impose, and which the transcriber would only convert straight back). Int16 + /// is host-endian; Apple platforms (arm64/x86_64) are little-endian, so the + /// bytes are already the S16LE the dictation API expects. + static func decodePCM(fromFileAt url: URL) throws -> Data { + let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatInt16, interleaved: true) + let frameCount = AVAudioFrameCount(file.length) + guard frameCount > 0, + let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) + else { return Data() } + try file.read(into: buffer) + guard let channel = buffer.int16ChannelData?[0] else { return Data() } + return Data(bytes: channel, count: Int(buffer.frameLength) * SyncSTTLimits.bytesPerSample) + } + + static func removeFile(at url: URL) { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/Sources/BlurtEngine/Audio/MicCapture+Warm.swift b/Sources/BlurtEngine/Audio/MicCapture+Warm.swift index b5cc270f..f79540da 100644 --- a/Sources/BlurtEngine/Audio/MicCapture+Warm.swift +++ b/Sources/BlurtEngine/Audio/MicCapture+Warm.swift @@ -1,12 +1,11 @@ -import AVFoundation import Foundation // The warm-recorder lifecycle — prepare ahead of the press, validate it still // matches the live input, and let it expire — split from `MicCapture.swift` to // stay within the lint file-length budget, like `MicCapture+Meter`. Members it -// reaches (`warm`, `preparedGeneration`, `logger`, `removeFile`, `makeRecorder`) -// are internal rather than private for that reason: `private` is file-scoped and -// can't cross the split. +// reaches (`warm`, `preparedGeneration`, `logger`, `deviceSelection`, +// `makeBackend`, `resolveInput`) are internal rather than private for that +// reason: `private` is file-scoped and can't cross the split. extension MicCapture { /// Pre-create and prepare a recorder so the first `start()` skips first-time /// hardware route discovery. Does NOT begin capture — no mic indicator. Safe to @@ -28,21 +27,25 @@ extension MicCapture { activeRecorder == nil && warm == nil && !bringingUpCapture } - /// The warm recorder if it is still bound to `input`, else nil — discarding - /// (and cleaning up after) one that isn't. + /// The warm recorder if it is still bound to `resolved`'s input *and* was + /// prepared under the same pin, else nil — discarding (and cleaning up after) + /// one that isn't. /// /// Reuse requires *positively* confirming the device is unchanged: an /// unreadable route on either side leaves us unable to tell, and a recorder /// bound to the wrong device doesn't fail loudly — it records the wrong mic, or /// silence. Paying route activation is the cheaper mistake, so unknown means - /// discard. - func takeWarmRecorder(matching input: AudioRoute.InputSnapshot?) -> AVAudioRecorder? { + /// discard. The pin must match too, not just the device — see + /// `WarmRecorder.pinnedUID`. + func takeWarmRecorder(matching resolved: ResolvedInput) -> (any CaptureRecorder)? { guard let held = warm else { return nil } held.expiry?.cancel() warm = nil - guard let warmed = held.input, let input, warmed.deviceID == input.deviceID else { - Self.removeFile(at: held.recorder.url) - Self.logger.info("discarded warm recorder — input device changed since warm-up") + guard held.pinnedUID == resolved.pinnedUID, + let warmed = held.input, let input = resolved.input, warmed.deviceID == input.deviceID + else { + held.recorder.stopAndDiscard() + Self.logger.info("discarded warm recorder — input device or selection changed since warm-up") return nil } return held.recorder @@ -61,15 +64,18 @@ extension MicCapture { } } - /// Builds a recorder, records the input it is bound to, and starts its idle - /// countdown. A failure is non-fatal: `start()` then prepares lazily, exactly - /// as it did before any warm recorder existed. + /// Builds a recorder for the current selection, records the input (and pin) + /// it is bound to, and starts its idle countdown. A failure is non-fatal: + /// `start()` then prepares lazily, exactly as it did before any warm recorder + /// existed. func prepareWarmRecorder() { do { preparedGeneration += 1 + let resolved = Self.resolveInput(selection: deviceSelection()) warm = WarmRecorder( - recorder: try Self.makeRecorder(), - input: AudioRoute.currentInput(), + recorder: try Self.makeBackend(pinnedUID: resolved.pinnedUID), + input: resolved.input, + pinnedUID: resolved.pinnedUID, generation: preparedGeneration) armPreparedRecorderExpiry(generation: preparedGeneration) Self.logger.info("prepared a warm recorder") @@ -103,7 +109,7 @@ extension MicCapture { func releasePreparedRecorder(generation: Int) { guard let held = warm, held.generation == generation else { return } warm = nil - Self.removeFile(at: held.recorder.url) + held.recorder.stopAndDiscard() Self.logger.info("released idle warm recorder") } } diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index eb3cc9a0..43f812b5 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -1,15 +1,17 @@ -@preconcurrency import AVFoundation import Foundation import os -/// Captures mic audio with `AVAudioRecorder`, which records straight to the -/// 16 kHz / mono / 16-bit PCM the dictation API wants — so there's no manual tap, -/// sample-rate conversion, or PCM plumbing here. Each session uses a freshly -/// created recorder, which resolves the *current* default input device at -/// `record()` time. That's the whole reason this is no longer an `AVAudioEngine`: -/// the engine's input graph bound to one device and went stale on a device switch -/// (mic ↔ built-in), raising `-10868` (`kAudioUnitErr_FormatNotSupported`) or -/// quietly capturing all-zero buffers. A per-session recorder can't go stale. +/// Captures mic audio as the 16 kHz / mono / 16-bit PCM the dictation API wants +/// — so there's no manual tap, sample-rate conversion, or PCM plumbing here. +/// Each session uses a freshly created recorder (`CaptureRecorder`): the +/// `AVAudioRecorder`-backed WAV path when the user follows the system default +/// input — the shipping behavior, which resolves the *current* default device +/// at `record()` time — or an AudioQueue bound to one specific device when a +/// microphone is pinned in Settings (`MicDeviceSelection`). The fresh recorder +/// per session is the whole reason this is no longer a long-lived engine graph: +/// that graph bound its input to one device and went stale on a device switch, +/// raising `-10868` or quietly capturing all-zero buffers; per-session +/// recorders can't go stale. public actor MicCapture: MicCaptureProtocol { // Subsystem/category make these lines findable via: // log show --predicate 'subsystem == "dev.alex.blurt"' --last 1h @@ -23,8 +25,16 @@ public actor MicCapture: MicCaptureProtocol { /// The geometry the recorder converts hardware audio to on the fly. The dictation /// API's rate (`SyncSTTLimits.sampleRate`) — the same one the pipeline hands /// the transcriber — so `stop()` returns bytes ready to upload with no - /// resampling or re-encoding pass. - private static let targetSampleRate = Double(SyncSTTLimits.sampleRate) + /// resampling or re-encoding pass. Internal because `makeRecorder` reads it + /// from `CaptureRecorder.swift` (split out for this file's length budget). + static let targetSampleRate = Double(SyncSTTLimits.sampleRate) + + /// Reads the persisted microphone selection, once per session bring-up (and + /// per warm-up). A closure so tests inject a fixed selection instead of the + /// process defaults; production reads `MicDeviceStore` per capture, so a + /// Settings change applies to the very next press — the same re-read-per-use + /// rule as the key terms. + let deviceSelection: @Sendable () -> MicDeviceSelection /// A recorder prepared ahead of the press so `start()` doesn't pay hardware /// route activation on the hot path, together with everything that belongs to @@ -58,13 +68,18 @@ public actor MicCapture: MicCaptureProtocol { /// A prepared-but-not-started recorder and the state that only makes sense /// alongside it. struct WarmRecorder { - let recorder: AVAudioRecorder - /// The default input it was built against. `AVAudioRecorder` resolves its - /// device once, at `prepareToRecord()`, and never re-resolves — so without - /// this a recorder warmed while the built-in mic was default would keep - /// recording from it after the user connected their AirPods. See - /// `AudioRoute.InputSnapshot.deviceID` for why identity is the device ID. + let recorder: any CaptureRecorder + /// The input it was built against. A recorder resolves its device once, at + /// prepare time, and never re-resolves — so without this a recorder warmed + /// while the built-in mic was default would keep recording from it after + /// the user connected their AirPods. See `AudioRoute.InputSnapshot.deviceID` + /// for why identity is the device ID. let input: AudioRoute.InputSnapshot? + /// The device UID the recorder was pinned to, or nil for the system + /// default. Matched alongside the device identity in `takeWarmRecorder`: a + /// recorder warmed under one selection must not serve a press made under + /// another, even when both currently resolve to the same device. + let pinnedUID: String? /// Releases this recorder once it has gone unused for /// `preparedRecorderLifetime`. See that constant for why holding one open /// forever is not an option. @@ -74,7 +89,7 @@ public actor MicCapture: MicCaptureProtocol { } /// The recorder for the in-flight session; nil between `stop()` and `start()`. - var activeRecorder: AVAudioRecorder? + var activeRecorder: (any CaptureRecorder)? /// The in-flight session's input transport, sampled once at `start()`. Read by /// `stop()` to size the tail linger — sampled at start rather than re-read at /// stop so a device switch mid-utterance can't make the two halves of one @@ -139,7 +154,10 @@ public actor MicCapture: MicCaptureProtocol { /// behavior that shipped before the re-warm existed. static let preparedRecorderLifetime = Duration.seconds(60) - public init() { + public init( + deviceSelection: @escaping @Sendable () -> MicDeviceSelection = { MicDeviceStore().selection } + ) { + self.deviceSelection = deviceSelection // The continuation is fed from a ~20 Hz meter timer; the levels stream is a // meter, not the captured signal — the consumer only renders the most recent // value — so cap it at the newest single element. @@ -149,23 +167,26 @@ public actor MicCapture: MicCaptureProtocol { } public func start() async throws { - // One CoreAudio read per session, answering both questions this capture has - // about its input: whether the warm recorder is still bound to it, and - // whether its link buffers a tail worth waiting for at stop. - let input = AudioRoute.currentInput() - // Reuse the warm recorder when it is still bound to the current default - // input; otherwise build a fresh one. `??` only evaluates `makeRecorder()` - // when nothing usable was warmed. - let recorder = try takeWarmRecorder(matching: input) ?? Self.makeRecorder() + // One trip through CoreAudio per session, answering every question this + // capture has about its input: which device the selection resolves to + // (pinned, or the system default — including the missing-pin fallback), + // whether the warm recorder is still bound to it, and whether its link + // buffers a tail worth waiting for at stop. + let resolved = Self.resolveInput(selection: deviceSelection()) + // Reuse the warm recorder when it is still bound to the resolved input; + // otherwise build a fresh one. `??` only evaluates `makeBackend` when + // nothing usable was warmed. + let recorder = try takeWarmRecorder(matching: resolved) + ?? Self.makeBackend(pinnedUID: resolved.pinnedUID) // record() returns false when no usable input device is available (unplugged, // asleep, route lost). Surface that as a thrown Swift error so // DictationSession.press() reports `.audioCaptureFailed` instead of recording - // nothing. (Unlike AVAudioEngine's installTap, no path here can raise an - // uncatchable Obj-C exception, so there's no degenerate-format guard to keep.) + // nothing. (No path here can raise an uncatchable Obj-C exception, so there's + // no degenerate-format guard to keep.) guard recorder.record() else { Self.logger.error("recorder.record() returned false — no usable input device") - Self.removeFile(at: recorder.url) + recorder.stopAndDiscard() throw BlurtError.audioCaptureFailed(underlying: MicCaptureError.noInputDevice) } @@ -186,7 +207,7 @@ public actor MicCapture: MicCaptureProtocol { // owns both policies and the reasoning, including why the clock alone was // not enough. The re-warm above is what usually makes this return at once; // the gate is what makes it correct when it doesn't. - let timeout = MicLiveness.timeout(forTransportType: input?.transportType) + let timeout = MicLiveness.timeout(forTransportType: resolved.input?.transportType) let generationBeforeWait = stopGeneration // Both probes read off-actor (`waitUntilLive` is nonisolated), safe by // confinement: the polls run sequentially in one task and nothing else @@ -196,10 +217,7 @@ public actor MicCapture: MicCaptureProtocol { let gap = await MicLiveness.waitUntilLive( timeout: timeout, clock: ContinuousClock(), currentTime: { recorder.currentTime }, - inputPowerDB: { - recorder.updateMeters() - return recorder.averagePower(forChannel: 0) - }) + inputPowerDB: { recorder.meteredPowerDB() }) // Two ways the bring-up can be abandoned while suspended, both ending the // same way — tear the recorder down instead of installing it, so nothing is @@ -213,18 +231,16 @@ public actor MicCapture: MicCaptureProtocol { // distinguished from the timeout, which returns nil too but is a reported // *failure* (the throw below) — a cancel is the user's own act, not a fault. guard stopGeneration == generationBeforeWait, !Task.isCancelled else { - recorder.stop() - Self.removeFile(at: recorder.url) + recorder.stopAndDiscard() Self.logger.info("start aborted — teardown or cancellation during the liveness wait") throw CancellationError() } // The gate's outcome, worded in `MicLiveness` so the line a field report is // read off is unit-tested. See `logSummary` for what it has to carry. - recorder.updateMeters() let summary = MicLiveness.logSummary( - gap: gap, timeout: timeout, transportType: input?.transportType, - powerDB: recorder.averagePower(forChannel: 0)) + gap: gap, timeout: timeout, transportType: resolved.input?.transportType, + powerDB: recorder.meteredPowerDB()) Self.logger.log(level: gap == nil ? .error : .info, "\(summary, privacy: .public)") // No confirmed signal within the cap: fail CLOSED — tear the capture down and // surface the same `.audioCaptureFailed` presentation as the record()-false @@ -234,15 +250,14 @@ public actor MicCapture: MicCaptureProtocol { // so no stopGeneration bump — a concurrent stop() correctly sees no active // capture — and `bringingUpCapture` is cleared by the defer above. guard gap != nil else { - recorder.stop() - Self.removeFile(at: recorder.url) + recorder.stopAndDiscard() throw BlurtError.audioCaptureFailed(underlying: MicCaptureError.inputNeverDelivered) } activeRecorder = recorder - activeTransportType = input?.transportType + activeTransportType = resolved.input?.transportType lastEmittedLevel = nil - Self.logger.info("start recording to \(recorder.url.lastPathComponent, privacy: .public)") + Self.logger.info("start recording to \(recorder.logName, privacy: .public)") startMeterTimer() } @@ -253,22 +268,18 @@ public actor MicCapture: MicCaptureProtocol { guard let recorder = detachActiveRecorder() else { return Data() } if linger > .zero { // Keep capturing for a moment past key-up so the audio still travelling - // over the link lands in the file instead of being truncated. See + // over the link lands in the recording instead of being truncated. See // `AudioTransport.tailLinger(forTransportType:)`. try? await Task.sleep(for: linger) } - recorder.stop() - - let url = recorder.url defer { - Self.removeFile(at: url) // Re-arm for the *next* press now that the device is free, so the route // activation this session just paid for isn't paid again. Scheduled rather // than done inline: preparing re-opens the input, which is the slow part, // and `stop()` is on the release path the transcript waits behind. scheduleRewarm() } - let pcm = try Self.decodePCM(fromFileAt: url) + let pcm = try recorder.stopAndReadPCM() let sampleCount = pcm.count / SyncSTTLimits.bytesPerSample let durationMs = SyncSTTLimits.durationMs(ofPCMBytes: pcm.count) @@ -293,8 +304,7 @@ public actor MicCapture: MicCaptureProtocol { /// developer-mode failure log keeps working for hosts that take it. public func cancelCapture() { guard let recorder = detachActiveRecorder() else { return } - recorder.stop() - Self.removeFile(at: recorder.url) + recorder.stopAndDiscard() Self.logger.info("cancelled capture, discarded audio") scheduleRewarm() } @@ -306,7 +316,7 @@ public actor MicCapture: MicCaptureProtocol { /// the generation bump in particular is what `start()` re-checks across its /// liveness wait to know a teardown landed, so a third exit that forgot it /// would let an abandoned bring-up install itself and keep capturing. - private func detachActiveRecorder() -> AVAudioRecorder? { + private func detachActiveRecorder() -> (any CaptureRecorder)? { stopGeneration += 1 meterTask?.cancel() meterTask = nil @@ -314,26 +324,39 @@ public actor MicCapture: MicCaptureProtocol { return activeRecorder } - // MARK: - Recorder construction - - /// Build a recorder that writes mono 16-bit little-endian PCM at the target - /// rate into a unique temp file. `prepareToRecord()` does the heavy route/buffer - /// setup so the subsequent `record()` starts promptly. - static func makeRecorder() throws -> AVAudioRecorder { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("blurt-\(UUID().uuidString).wav") - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM, - AVSampleRateKey: targetSampleRate, - AVNumberOfChannelsKey: 1, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - ] - let recorder = try AVAudioRecorder(url: url, settings: settings) - recorder.isMeteringEnabled = true - recorder.prepareToRecord() - return recorder + // MARK: - Input resolution + + /// The input a capture (or warm-up) resolved to bind to: the snapshot the + /// transport policies key off — of the *pinned* device when one is pinned and + /// present, so the liveness cap, tail linger and warm-recorder identity check + /// all follow the device actually recorded — plus the UID the recorder must + /// pin to, nil when it records the system default (no pin, or the fallback). + struct ResolvedInput { + let input: AudioRoute.InputSnapshot? + let pinnedUID: String? + } + + /// One trip through the selection policy: resolve the pinned UID to a live + /// device (or notice it's gone), let `MicDeviceSelection.effective` — the + /// pure, unit-tested rule — pick the fallback, and read the snapshot of + /// whichever input won. Hardware-adjacent glue in a coverage-excluded file; + /// the decision itself stays testable. + static func resolveInput(selection: MicDeviceSelection) -> ResolvedInput { + var pinnedInput: AudioRoute.InputSnapshot? + if case .pinned(let uid) = selection { + pinnedInput = AudioInputDevices.input(forUID: uid) + } + switch selection.effective(pinnedDevicePresent: pinnedInput != nil) { + case .pinned(let uid): + return ResolvedInput(input: pinnedInput, pinnedUID: uid) + case .systemDefault: + if case .pinned = selection { + // Graceful degradation, not an error: the pin stays stored, and this + // capture records what an un-pinned one would. + logger.info("pinned microphone not connected — recording the system default input") + } + return ResolvedInput(input: AudioRoute.currentInput(), pinnedUID: nil) + } } // MARK: - Level metering @@ -354,8 +377,7 @@ public actor MicCapture: MicCaptureProtocol { private func emitLevel() { guard let recorder = activeRecorder else { return } - recorder.updateMeters() - let level = Self.linearLevel(fromPowerDB: recorder.averagePower(forChannel: 0)) + let level = Self.linearLevel(fromPowerDB: recorder.meteredPowerDB()) // Only yield transitions. `linearLevel` floors room ambient to exactly 0, so a // silent stretch would otherwise push the same value 20×/s, resuming the // host's `@MainActor` observer each time just to have it discard the @@ -368,32 +390,9 @@ public actor MicCapture: MicCaptureProtocol { // The dB→0...1 conversion `emitLevel` uses lives in `MicCapture+Meter.swift` // — pure math the coverage gate counts, unlike this hardware-bound actor. The - // warm-recorder lifecycle (`warmUp`, `takeWarmRecorder`, the re-warm and its - // expiry) lives in `MicCapture+Warm.swift`, split off for the lint - // file-length budget — which is why the prepared-recorder state, `logger`, - // `removeFile` and `makeRecorder` are internal rather than private. - - // MARK: - File helpers - - /// Read a recorded PCM file back as raw S16LE bytes — the dictation API's upload - /// encoding. The on-disk WAV already holds 16-bit int samples, so asking - /// `AVAudioFile` for the int16 common format makes this a straight copy-out: - /// no detour through Float32 (which the default `processingFormat` would - /// impose, and which the transcriber would only convert straight back). Int16 - /// is host-endian; Apple platforms (arm64/x86_64) are little-endian, so the - /// bytes are already the S16LE the dictation API expects. - static func decodePCM(fromFileAt url: URL) throws -> Data { - let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatInt16, interleaved: true) - let frameCount = AVAudioFrameCount(file.length) - guard frameCount > 0, - let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) - else { return Data() } - try file.read(into: buffer) - guard let channel = buffer.int16ChannelData?[0] else { return Data() } - return Data(bytes: channel, count: Int(buffer.frameLength) * SyncSTTLimits.bytesPerSample) - } - - static func removeFile(at url: URL) { - try? FileManager.default.removeItem(at: url) - } + // warm-recorder lifecycle lives in `MicCapture+Warm.swift`, and the recorder + // backends with their construction and file plumbing in + // `CaptureRecorder.swift` — both split off for the lint file-length budget, + // which is why the prepared-recorder state, `logger`, `deviceSelection`, + // `targetSampleRate` and those statics are internal rather than private. } diff --git a/Sources/BlurtEngine/Audio/MicCaptureError.swift b/Sources/BlurtEngine/Audio/MicCaptureError.swift index 1455abde..6504a733 100644 --- a/Sources/BlurtEngine/Audio/MicCaptureError.swift +++ b/Sources/BlurtEngine/Audio/MicCaptureError.swift @@ -27,3 +27,21 @@ enum MicCaptureError: LocalizedError { } } } + +/// A CoreAudio call the pinned-device recorder couldn't get past, carrying which +/// call and the `OSStatus` it answered — the two facts a field report needs to +/// be actionable. Reaches the overlay the same way `MicCaptureError` does +/// (interpolated by `BlurtError.audioCaptureFailed`), so the message carries the +/// same read-like-a-sentence requirement, pinned in `MicCaptureFormatTests`. +/// Declared here beside `MicCaptureError` — not in the recorder's own file — +/// because it is pure, so the coverage gate counts it and the message test keeps +/// it green. +struct AudioQueueError: LocalizedError { + /// The CoreAudio call that refused, e.g. `"AudioQueueNewInput"`. + let operation: String + let status: OSStatus + + var errorDescription: String? { + "The selected microphone couldn't be opened (\(operation): error \(status))." + } +} diff --git a/Sources/BlurtEngine/Audio/MicDeviceStore.swift b/Sources/BlurtEngine/Audio/MicDeviceStore.swift new file mode 100644 index 00000000..74792404 --- /dev/null +++ b/Sources/BlurtEngine/Audio/MicDeviceStore.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Which input device dictation records from: the system's default input (the +/// unset default, and Blurt's only behavior before microphone selection +/// existed), or one specific device pinned by its persistent CoreAudio UID. +/// +/// This is the *pure* half of microphone selection — the decode rule and the +/// missing-device fallback — kept apart from the hardware questions (what +/// devices exist, what a UID currently resolves to), which live in +/// `AudioInputDevices` and are excluded from the coverage gate. These rules are +/// what `swift test` pins. +public enum MicDeviceSelection: Equatable, Sendable { + /// Follow the system's default input, re-resolved at every capture. + case systemDefault + /// Record from the device with this UID (`kAudioDevicePropertyDeviceUID`). + /// The UID rather than an `AudioDeviceID` because only the UID survives an + /// unplug/replug and a reboot — see `AudioRoute.InputSnapshot.deviceID` for + /// the two identities' trade-offs. + case pinned(uid: String) + + /// Decode the persisted slot: the empty string — which is also what an unset + /// key reads back as — means "same as system". Shared by `MicDeviceStore` and + /// the Settings picker (which observes the raw slot via `@AppStorage`), so + /// the two can't disagree about what an untouched install means; the same + /// rule as `SoundPackCatalog.fromPersisted` and `TriggerKey.fromPersisted`. + public static func fromPersisted(_ raw: String) -> MicDeviceSelection { + raw.isEmpty ? .systemDefault : .pinned(uid: raw) + } + + /// The raw value the store writes for this selection — `fromPersisted`'s + /// inverse, owned here so the encode and decode rules sit side by side. + var persistedValue: String { + switch self { + case .systemDefault: "" + case .pinned(let uid): uid + } + } + + /// The selection a capture should actually bind to, given whether the pinned + /// device is currently present. A pinned device that has disappeared falls + /// back to the system default rather than failing the press: unplugging a USB + /// mic should degrade dictation to the built-in one, not break it. The pin + /// itself stays stored, so reconnecting the device pins it again with no trip + /// through Settings. + func effective(pinnedDevicePresent: Bool) -> MicDeviceSelection { + guard case .pinned = self, !pinnedDevicePresent else { return self } + return .systemDefault + } +} + +/// Persists the microphone selection in `UserDefaults` as the pinned device's +/// UID string, empty (or unset) meaning "same as system". Same shape as +/// `TriggerKeyStore`; lives in the engine so its key is a `DefaultsKey` case and +/// the setting joins `PersistedSettings.resetAll`'s sweep by construction. +public struct MicDeviceStore { + /// UserDefaults key holding the pinned device UID. Public so SwiftUI views + /// can observe it directly (e.g. `@AppStorage`) and re-render on change. + public static var defaultsKey: String { DefaultsKey.micDeviceUID.key } + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public var selection: MicDeviceSelection { + get { + MicDeviceSelection.fromPersisted(defaults.string(forKey: Self.defaultsKey) ?? "") + } + nonmutating set { + defaults.set(newValue.persistedValue, forKey: Self.defaultsKey) + } + } +} diff --git a/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift b/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift new file mode 100644 index 00000000..e1acf2d9 --- /dev/null +++ b/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift @@ -0,0 +1,225 @@ +import AudioToolbox +import Foundation +import Synchronization + +/// The pinned-device backend: an AudioQueue input recording 16 kHz / mono / +/// 16-bit S16LE straight into memory, bound to one device by its UID via +/// `kAudioQueueProperty_CurrentDevice` — the per-instance device selection the +/// WAV recorder's API doesn't expose (it always resolves the system default). +/// This is the same capture machinery that recorder wraps, driven one level +/// down, so its semantics carry over: created and primed ahead of `record()` +/// (the warm-up analog of `prepareToRecord()`), a clock and an average-power +/// meter for the liveness gate, and a fresh instance per session. +/// +/// Only ever constructed for a *pinned* selection (`MicCapture.makeBackend`). +/// The un-pinned default path stays on `WAVFileRecorder`, untouched. +/// +/// Hardware-bound like `MicCapture`, and excluded from the coverage gate for +/// the same reason; its live test rides the `BLURT_LIVE_AUDIO_TESTS` gate in +/// `AudioInputDevicesTests`. +/// +/// `@unchecked Sendable`: the capture-path confinement argument on +/// `CaptureRecorder` covers the queue handle (created in `init`, immutable +/// after), and everything the CoreAudio callback thread also touches lives in +/// `SharedState` behind a `Mutex`. +final class PinnedAudioQueueRecorder: CaptureRecorder, @unchecked Sendable { + /// The state the CoreAudio callback thread and the capture path share. A + /// separate object — not `self` — so the C callback's context pointer exists + /// before the queue does, and `init` never has to hand out a half-built + /// `self`. Passed unretained: the recorder owns it for its whole life and + /// disposes the queue (synchronously) before either is released, so no + /// callback can outlive it. + private final class SharedState: Sendable { + let cell = Mutex(Guarded()) + } + + private struct Guarded { + /// Every byte the callback has delivered, in arrival order — already the + /// raw S16LE blob `stopAndReadPCM` returns, so there is no file and no + /// decode pass on the release path. + var captured = Data() + /// True between a successful `record()` and the teardown; the callback + /// re-enqueues its buffer only while this holds, so a buffer can't be + /// handed back to a queue that is stopping. + var running = false + /// Whether the queue has been stopped and disposed — teardown is reachable + /// from three methods plus `deinit`, and must run once. + var disposed = false + } + + private let queue: AudioQueueRef + private let shared: SharedState + + /// Creates the queue bound to `deviceUID`, enables metering, and primes the + /// capture buffers — the prepare-ahead work, so a warm instance makes + /// `record()` cheap. Throws `AudioQueueError` when any CoreAudio call + /// refuses (an unknown UID surfaces here, on the device property). + init(deviceUID: String) throws { + var format = Self.captureFormat + let shared = SharedState() + var created: AudioQueueRef? + try Self.check( + AudioQueueNewInput( + &format, Self.handleInput, Unmanaged.passUnretained(shared).toOpaque(), + nil, nil, 0, &created), + "AudioQueueNewInput") + guard let created else { + throw AudioQueueError(operation: "AudioQueueNewInput returned no queue", status: noErr) + } + do { + // The pin itself. Must land before the queue starts; a UID that no longer + // resolves is refused here, which `MicCapture.resolveInput` pre-empts by + // falling back to the system default when the device is absent. + var uid = deviceUID as CFString + try Self.check( + AudioQueueSetProperty( + created, kAudioQueueProperty_CurrentDevice, &uid, + UInt32(MemoryLayout.size)), + "AudioQueueSetProperty(CurrentDevice)") + var meteringEnabled: UInt32 = 1 + try Self.check( + AudioQueueSetProperty( + created, kAudioQueueProperty_EnableLevelMetering, &meteringEnabled, + UInt32(MemoryLayout.size)), + "AudioQueueSetProperty(EnableLevelMetering)") + // An input queue captures only into buffers already enqueued, so priming + // belongs to construction, not to record(). + for _ in 0.. Bool { + // Raised before the start, not after: the first callback can land while + // AudioQueueStart is still on this stack, and it must already see running + // to re-enqueue its buffer. Lowered again on refusal — nothing started. + shared.cell.withLock { $0.running = true } + guard AudioQueueStart(queue, nil) == noErr else { + shared.cell.withLock { $0.running = false } + return false + } + return true + } + + /// The queue's timeline in seconds, or 0 while it isn't started or has no + /// valid sample time yet. Frames of digital silence advance it like real + /// audio — the liveness gate's power term is what tells those apart. + var currentTime: TimeInterval { + var timestamp = AudioTimeStamp() + let status = AudioQueueGetCurrentTime(queue, nil, ×tamp, nil) + guard status == noErr, timestamp.mFlags.contains(.sampleTimeValid) else { return 0 } + return max(0, timestamp.mSampleTime / Self.captureFormat.mSampleRate) + } + + /// The queue's average input power in dBFS. A failed read answers -160 — the + /// digital-silence floor — which is the conservative direction for both + /// consumers: the liveness gate keeps waiting (and fails closed at its cap) + /// rather than declaring a device live on no evidence, and the meter rests. + func meteredPowerDB() -> Float { + var meter = AudioQueueLevelMeterState() + var size = UInt32(MemoryLayout.size) + let status = AudioQueueGetProperty(queue, kAudioQueueProperty_CurrentLevelMeterDB, &meter, &size) + guard status == noErr else { return -160 } + return meter.mAveragePower + } + + func stopAndReadPCM() -> Data { + dispose() + return shared.cell.withLock { $0.captured } + } + + func stopAndDiscard() { + dispose() + shared.cell.withLock { $0.captured = Data() } + } + + var logName: String { "pinned input queue" } + + /// Idempotent teardown: the first caller stops the queue synchronously — + /// after which no callback runs — and disposes it; later callers (and + /// `deinit`, which backstops a recorder dropped without a stop) find + /// `disposed` set and do nothing. + private func dispose() { + let alreadyDisposed = shared.cell.withLock { guarded in + let was = guarded.disposed + guarded.running = false + guarded.disposed = true + return was + } + guard !alreadyDisposed else { return } + _ = AudioQueueStop(queue, true) + _ = AudioQueueDispose(queue, true) + } + + /// The capture callback, on a CoreAudio-owned thread: append what the buffer + /// carries and hand the buffer back while the session is still running. It + /// touches nothing but `SharedState`, under its lock. + private static func handleInput( + userData: UnsafeMutableRawPointer?, + queue: AudioQueueRef, + buffer: AudioQueueBufferRef, + startTime: UnsafePointer, + packetCount: UInt32, + packetDescriptions: UnsafePointer? + ) { + guard let userData else { return } + let shared = Unmanaged.fromOpaque(userData).takeUnretainedValue() + let byteCount = Int(buffer.pointee.mAudioDataByteSize) + let stillRunning = shared.cell.withLock { guarded in + if byteCount > 0 { + guarded.captured.append(Data(bytes: buffer.pointee.mAudioData, count: byteCount)) + } + return guarded.running + } + guard stillRunning else { return } + _ = AudioQueueEnqueueBuffer(queue, buffer, 0, nil) + } + + private static func check(_ status: OSStatus, _ operation: String) throws { + guard status == noErr else { throw AudioQueueError(operation: operation, status: status) } + } + + /// The dictation API's geometry — the same 16 kHz mono S16LE the WAV path + /// records — asked of the queue directly, which converts from the hardware + /// format on the fly exactly as the WAV recorder does. Derived from + /// `SyncSTTLimits` so the two backends can't drift apart. + private static var captureFormat: AudioStreamBasicDescription { + AudioStreamBasicDescription( + mSampleRate: Double(SyncSTTLimits.sampleRate), + mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked, + mBytesPerPacket: UInt32(SyncSTTLimits.bytesPerSample), + mFramesPerPacket: 1, + mBytesPerFrame: UInt32(SyncSTTLimits.bytesPerSample), + mChannelsPerFrame: 1, + mBitsPerChannel: 16, + mReserved: 0) + } + + /// 100 ms of audio per buffer, three in flight — small enough that the meter + /// and the captured tail stay fresh, large enough that the callback isn't hot. + private static var bufferByteSize: UInt32 { + UInt32(SyncSTTLimits.sampleRate * SyncSTTLimits.bytesPerSample / 10) + } + + private static let bufferCount = 3 +} diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 134583ad..58006c80 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -42,6 +42,9 @@ enum DefaultsKey: String, CaseIterable { case overlayCustomOriginX = "OverlayCustomOriginX" case overlayCustomOriginY = "OverlayCustomOriginY" case lastUpdateCheck = "LastUpdateCheck" + /// The CoreAudio UID of the input device dictation is pinned to, or empty for + /// "same as system" (`MicDeviceStore`). + case micDeviceUID = "MicDeviceUID" /// The key this case actually reads and writes, under the configured host /// identity. A computed property rather than a stored string because the diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 132eac14..1f35b0df 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -168,7 +168,7 @@ Only `start()`/`stop()` must be implemented — `cancelCapture()`, `levels` and `cancelCapture()` is what the session calls when a dictation is cancelled, and it exists because the two teardowns want opposite things: `stop()` may legitimately spend time preserving the audio, while a cancel has nothing to preserve and must take effect at once. Override it only if stopping cheaply differs from stopping carefully in your capture. -`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. 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. When the host pins a specific microphone (`MicDeviceStore`), the same seam records through an AudioQueue bound to that device instead — still fresh per session, with a per-press fallback to the system default while the pinned device isn't connected. `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/Tests/BlurtEngineTests/AudioInputDevicesTests.swift b/Tests/BlurtEngineTests/AudioInputDevicesTests.swift new file mode 100644 index 00000000..d0302367 --- /dev/null +++ b/Tests/BlurtEngineTests/AudioInputDevicesTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// Live-hardware checks for the microphone-selection plumbing: the device +/// enumeration the Settings picker lists, the UID→snapshot translation the +/// capture path pins with, and the pinned AudioQueue recorder itself. +/// +/// Gated on BLURT_LIVE_AUDIO_TESTS=1 like the other capture suites — every test +/// here talks to the real CoreAudio HAL (and the recorder test opens a real +/// input device), which a headless CI runner cannot answer for. Documents and +/// locks the behavior for a human running it on a Mac with a microphone; +/// `AudioInputDevices.swift` and `PinnedAudioQueueRecorder.swift` are excluded +/// from the coverage gate for the same reason `MicCapture.swift` is. +@Suite( + "AudioInputDevices & pinned recorder (live)", + .enabled( + if: ProcessInfo.processInfo.environment["BLURT_LIVE_AUDIO_TESTS"] == "1", + "set BLURT_LIVE_AUDIO_TESTS=1 to run (needs a real microphone)"), + .tags(.liveAudio), + .timeLimit(.minutes(1))) +struct AudioInputDevicesTests { + @Test("enumeration lists named, uniquely-identified input devices") + func enumerationListsInputDevices() throws { + let devices = AudioInputDevices.all() + try #require(!devices.isEmpty, "a machine running this suite needs at least one input device") + + // Every entry must be renderable in the picker and pinnable by the store. + #expect(devices.allSatisfy { !$0.uid.isEmpty && !$0.name.isEmpty }) + #expect(Set(devices.map(\.uid)).count == devices.count, "device UIDs must be unique") + + // The default input has a name for the "Same as system (…)" label. + #expect(AudioInputDevices.systemDefaultInputName() != nil) + } + + @Test("a listed UID translates back to a live input snapshot; a bogus one doesn't") + func uidTranslationRoundTrips() throws { + let devices = AudioInputDevices.all() + let first = try #require(devices.first) + + let snapshot = try #require(AudioInputDevices.input(forUID: first.uid)) + #expect(snapshot.deviceID != 0) + + // The missing-device signal `MicDeviceSelection.effective` falls back on. + #expect(AudioInputDevices.input(forUID: "blurt-test-no-such-device") == nil) + } + + @Test("the pinned recorder captures S16LE audio from the device it was built for") + func pinnedRecorderCapturesAudio() async throws { + // Pin to the current default input's UID — the one device a machine running + // this suite is known to have working. + let defaultInput = try #require(AudioRoute.currentInput()) + let device = try #require( + AudioInputDevices.all().first { + AudioInputDevices.input(forUID: $0.uid)?.deviceID == defaultInput.deviceID + }, "the default input should be among the enumerated devices") + + let recorder = try PinnedAudioQueueRecorder(deviceUID: device.uid) + try #require(recorder.record(), "the pinned recorder should start on a live device") + + // Give the queue a moment to clock and deliver, as the liveness gate would. + try await Task.sleep(for: .milliseconds(500)) + #expect(recorder.currentTime > 0, "the queue's clock should advance while recording") + + let pcm = recorder.stopAndReadPCM() + #expect(!pcm.isEmpty, "half a second of capture should deliver samples") + #expect(pcm.count % SyncSTTLimits.bytesPerSample == 0, "the blob must be whole S16LE samples") + } +} diff --git a/Tests/BlurtEngineTests/MicCaptureFormatTests.swift b/Tests/BlurtEngineTests/MicCaptureFormatTests.swift index 17258a51..34b22bb1 100644 --- a/Tests/BlurtEngineTests/MicCaptureFormatTests.swift +++ b/Tests/BlurtEngineTests/MicCaptureFormatTests.swift @@ -73,6 +73,16 @@ struct MicCaptureFormatTests { #expect(MicCaptureError.inputNeverDelivered.errorDescription == "The microphone didn't start.") } + @Test func audioQueueErrorNamesTheCallAndStatus() { + // The pinned-device recorder's failure, on the same route to the pill — the + // sentence has to carry the refusing call and its OSStatus, the two facts a + // field report is read off. + let error = AudioQueueError(operation: "AudioQueueNewInput", status: -66681) + #expect( + error.errorDescription + == "The selected microphone couldn't be opened (AudioQueueNewInput: error -66681).") + } + // MARK: - Helpers /// Little-endian Int16 at `offset` — decodes the raw S16LE blob under test. diff --git a/Tests/BlurtEngineTests/MicCaptureLevelsTests.swift b/Tests/BlurtEngineTests/MicCaptureLevelsTests.swift index 07fc5c07..138d1217 100644 --- a/Tests/BlurtEngineTests/MicCaptureLevelsTests.swift +++ b/Tests/BlurtEngineTests/MicCaptureLevelsTests.swift @@ -26,7 +26,7 @@ struct MicCaptureLevelsTests { .tags(.liveAudio), .timeLimit(.minutes(1))) func levelsYieldDuringCapture() async throws { - let mic = MicCapture() + let mic = MicCapture(deviceSelection: { .systemDefault }) let collector = Task { () -> [Float] in var collected: [Float] = [] diff --git a/Tests/BlurtEngineTests/MicCaptureWarmTests.swift b/Tests/BlurtEngineTests/MicCaptureWarmTests.swift index f63dbfea..270c6bd6 100644 --- a/Tests/BlurtEngineTests/MicCaptureWarmTests.swift +++ b/Tests/BlurtEngineTests/MicCaptureWarmTests.swift @@ -33,7 +33,7 @@ struct MicCaptureWarmTests { @Test("warmUp prepares a recorder once; a second call has been overtaken and no-ops") func warmUpPreparesOnce() async throws { - let mic = MicCapture() + let mic = MicCapture(deviceSelection: { .systemDefault }) #expect(await mic.canPrepareWarmRecorder) await mic.warmUp() @@ -56,7 +56,7 @@ struct MicCaptureWarmTests { // wait both `activeRecorder` and `warm` are nil, so a guard reading only // those would call a live capture "idle" and prepare a second recorder onto // the already-open input. - let mic = MicCapture() + let mic = MicCapture(deviceSelection: { .systemDefault }) await mic.setBringingUpCapture(true) await mic.warmUp() #expect(await mic.hasWarmRecorder == false) @@ -70,7 +70,7 @@ struct MicCaptureWarmTests { @Test("a warm recorder is reused only while still bound to the live default input") func warmRecorderReusedWhenDeviceUnchanged() async throws { - let mic = MicCapture() + let mic = MicCapture(deviceSelection: { .systemDefault }) try await mic.installWarmRecorder(boundTo: builtIn) #expect(await mic.takeWarm(matching: builtIn)) @@ -89,7 +89,7 @@ struct MicCaptureWarmTests { // bound to the wrong device doesn't fail loudly — it records the wrong mic, // or silence — so unknown means discard: paying route activation again is // the cheaper mistake. - let mic = MicCapture() + let mic = MicCapture(deviceSelection: { .systemDefault }) // The user connected their AirPods after the warm-up. try await mic.installWarmRecorder(boundTo: builtIn) @@ -105,12 +105,34 @@ struct MicCaptureWarmTests { #expect(await mic.takeWarm(matching: nil) == false) } + @Test("a selection change between warm-up and press discards the warm recorder") + func warmRecorderDiscardedOnPinChange() async throws { + // The pin is part of the warm recorder's identity, not just the device it + // resolves to: even when both selections currently resolve to the same + // device, a recorder warmed un-pinned follows a later default switch where + // a pinned one must not — so a match on device ID alone would reuse the + // wrong backend. + let mic = MicCapture(deviceSelection: { .systemDefault }) + + // Warmed while following the system default; the press arrives pinned. + try await mic.installWarmRecorder(boundTo: builtIn) + #expect(await mic.takeWarm(matching: builtIn, pinnedUID: "uid:test") == false) + + // Warmed under a pin; the press arrives un-pinned. + try await mic.installWarmRecorder(boundTo: builtIn, pinnedUID: "uid:test") + #expect(await mic.takeWarm(matching: builtIn) == false) + + // The same pin on both sides still reuses. + try await mic.installWarmRecorder(boundTo: builtIn, pinnedUID: "uid:test") + #expect(await mic.takeWarm(matching: builtIn, pinnedUID: "uid:test")) + } + @Test("an expiry with a stale generation ticket leaves a later warm recorder alone") func staleExpiryTicketDoesNothing() async throws { // Cancellation alone doesn't cover this: an expiry already past its // cancellation check still gets its actor turn, and without the ticket it // would tear down a recorder prepared a moment ago. - let mic = MicCapture() + let mic = MicCapture(deviceSelection: { .systemDefault }) try await mic.installWarmRecorder(boundTo: builtIn) let generation = await mic.preparedGeneration @@ -136,7 +158,7 @@ struct MicCaptureWarmTests { // it never terminates at all. Sleeping yields the thread outright, and the // deadline turns "never landed" into a failed expectation rather than a hang // the suite's time limit has to clean up. - let mic = MicCapture() + let mic = MicCapture(deviceSelection: { .systemDefault }) let before = await mic.preparedGeneration await mic.scheduleRewarm() let deadline = ContinuousClock().now.advanced(by: .seconds(5)) @@ -150,8 +172,9 @@ struct MicCaptureWarmTests { } /// Actor-isolated test seams over `MicCapture`'s internal warm-recorder state. -/// Extensions because `AVAudioRecorder` is not `Sendable`, so neither the `warm` -/// slot nor `takeWarmRecorder`'s return can cross the actor boundary into a +/// Extensions because the recorder backends are only `@unchecked Sendable` +/// under the capture path's confinement argument, so neither the `warm` slot +/// nor `takeWarmRecorder`'s return should cross the actor boundary into a /// test — each helper reduces it to a `Sendable` answer on the actor instead. extension MicCapture { /// Whether a warm recorder is currently held. @@ -164,22 +187,26 @@ extension MicCapture { bringingUpCapture = value } - /// Installs a warm recorder bound to a *known* input — `prepareWarmRecorder` - /// with the `AudioRoute.currentInput()` read replaced by `input`, so the - /// device-identity tests don't depend on what the test machine's routing - /// happens to answer. - func installWarmRecorder(boundTo input: AudioRoute.InputSnapshot?) throws { + /// Installs a warm recorder bound to a *known* input (and pin) — + /// `prepareWarmRecorder` with the resolution read replaced by `input` / + /// `pinnedUID`, so the identity tests don't depend on what the test machine's + /// routing happens to answer. + func installWarmRecorder( + boundTo input: AudioRoute.InputSnapshot?, pinnedUID: String? = nil + ) throws { preparedGeneration += 1 warm = WarmRecorder( - recorder: try Self.makeRecorder(), input: input, generation: preparedGeneration) + recorder: try WAVFileRecorder(), input: input, pinnedUID: pinnedUID, + generation: preparedGeneration) } /// Consumes the warm slot through the production validation, reporting whether /// the recorder was reusable. A reused recorder's temp file is cleaned up here, /// the job `start()` would otherwise inherit; the discard path already does so. - func takeWarm(matching input: AudioRoute.InputSnapshot?) -> Bool { - guard let recorder = takeWarmRecorder(matching: input) else { return false } - Self.removeFile(at: recorder.url) + func takeWarm(matching input: AudioRoute.InputSnapshot?, pinnedUID: String? = nil) -> Bool { + let resolved = ResolvedInput(input: input, pinnedUID: pinnedUID) + guard let recorder = takeWarmRecorder(matching: resolved) else { return false } + recorder.stopAndDiscard() return true } @@ -188,6 +215,6 @@ extension MicCapture { /// Through `takeWarmRecorder` — which cancels the expiry — rather than a bare /// `warm = nil`, so teardown can't diverge from how production empties the slot. func discardWarmRecorder() { - _ = takeWarmRecorder(matching: nil) + _ = takeWarmRecorder(matching: ResolvedInput(input: nil, pinnedUID: nil)) } } diff --git a/Tests/BlurtEngineTests/MicDeviceStoreTests.swift b/Tests/BlurtEngineTests/MicDeviceStoreTests.swift new file mode 100644 index 00000000..dc5b01d5 --- /dev/null +++ b/Tests/BlurtEngineTests/MicDeviceStoreTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// The pure half of microphone selection: the persisted-slot decode shared by +/// the store and the Settings picker, the store's round trip, and the +/// missing-device fallback the capture path applies per press. +@Suite("MicDeviceStore") +struct MicDeviceStoreTests { + @Test("unset reads as same-as-system") + func unsetReadsAsSystemDefault() { + let store = MicDeviceStore(defaults: freshDefaults()) + #expect(store.selection == .systemDefault) + } + + @Test("the empty string is same-as-system, anything else is a pin") + func fromPersistedDecodesEmptyAsSystemDefault() { + // The empty string is also what an unset key reads back as, so the decode + // rule gives the untouched install exactly one meaning. + #expect(MicDeviceSelection.fromPersisted("") == .systemDefault) + #expect(MicDeviceSelection.fromPersisted("uid:built-in") == .pinned(uid: "uid:built-in")) + } + + @Test("a pinned selection round-trips through the store") + func pinRoundTrips() { + let defaults = freshDefaults() + let store = MicDeviceStore(defaults: defaults) + + store.selection = .pinned(uid: "AppleUSBAudioEngine:test") + #expect(store.selection == .pinned(uid: "AppleUSBAudioEngine:test")) + // The on-disk shape is the bare UID string — the contract the Settings + // picker's `@AppStorage` observation reads. + #expect(defaults.string(forKey: MicDeviceStore.defaultsKey) == "AppleUSBAudioEngine:test") + } + + @Test("selecting same-as-system overwrites a stored pin") + func systemDefaultOverwritesPin() { + let store = MicDeviceStore(defaults: freshDefaults()) + store.selection = .pinned(uid: "uid:external") + + store.selection = .systemDefault + #expect(store.selection == .systemDefault) + } + + @Test("a pinned device that disappeared falls back to the system default") + func missingPinFallsBack() { + // The graceful degradation: unplugging the pinned mic must degrade the next + // press to the system default, never fail it — while the pin itself stays + // stored (`effective` is per-capture; nothing rewrites the slot). + let pinned = MicDeviceSelection.pinned(uid: "uid:gone") + #expect(pinned.effective(pinnedDevicePresent: false) == .systemDefault) + #expect(pinned.effective(pinnedDevicePresent: true) == pinned) + } + + @Test("same-as-system is unaffected by device presence") + func systemDefaultIsStableUnderEffective() { + #expect(MicDeviceSelection.systemDefault.effective(pinnedDevicePresent: true) == .systemDefault) + #expect( + MicDeviceSelection.systemDefault.effective(pinnedDevicePresent: false) == .systemDefault) + } +} diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index a20378c6..7504d456 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -34,14 +34,17 @@ struct PersistedSettingsTests { // The launch update check's throttle: left out of the sweep, a UI-test run // (or a "clean install") would inherit yesterday's stamp and skip the check. #expect(PersistedSettings.allDefaultsKeys.contains(LastUpdateCheckStore.defaultsKey)) + // The pinned microphone: left out, a reset "clean install" would keep + // recording from a previously pinned device. + #expect(PersistedSettings.allDefaultsKeys.contains(MicDeviceStore.defaultsKey)) } @Test("the roster carries no stale or duplicate keys") func rosterHasNoStrays() { - // Exactly the eight known stores' keys (OverlayOriginStore contributes two, + // Exactly the nine known stores' keys (OverlayOriginStore contributes two, // StyleProfileStore three): a removed store must leave the roster in the same // change, and a key listed twice would hint at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 11) + #expect(PersistedSettings.allDefaultsKeys.count == 12) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } @@ -65,11 +68,12 @@ struct PersistedSettingsTests { OverlayOriginStore.xDefaultsKey, OverlayOriginStore.yDefaultsKey, LastUpdateCheckStore.defaultsKey, + MicDeviceStore.defaultsKey, ] #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. - #expect(storeKeys.count == 11) + #expect(storeKeys.count == 12) } @Test("resetAll clears every roster key and leaves unrelated ones alone") diff --git a/scripts/check.sh b/scripts/check.sh index c91fa655..33cc70ee 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -629,8 +629,22 @@ else # when the user switches output — and the listener half # can only fire on an actual route change. Same # justification as MicCapture.swift above. + # - AudioInputDevices.swift : the input-device enumeration and UID translation + # behind the Settings microphone picker — HAL reads with + # the same justification as AudioRoute.swift. The pure + # selection/fallback rules it serves (MicDeviceSelection, + # MicDeviceStore) stay covered. + # - CaptureRecorder.swift / PinnedAudioQueueRecorder.swift : the two capture + # backends behind MicCapture's recorder seam — the WAV + # recorder construction (prepareToRecord, the + # route-activation call: see MicCapture+Warm above for + # what covering that in CI did) and the device-pinned + # AudioQueue. Their live suites ride the same env gate + # (MicCaptureLevelsTests, AudioInputDevicesTests); the + # pure decode in CaptureRecorder.swift keeps its + # MicCaptureFormatTests coverage, it just isn't counted. COVERAGE="$(xcrun llvm-cov export -summary-only -instr-profile "$PROFDATA" "$XCTEST_BIN" \ - -ignore-filename-regex='Tests/|Audio/MicCapture(\+Warm)?\.swift|Audio/AudioRoute(Monitor)?\.swift' \ + -ignore-filename-regex='Tests/|Audio/MicCapture(\+Warm)?\.swift|Audio/AudioRoute(Monitor)?\.swift|Audio/AudioInputDevices\.swift|Audio/CaptureRecorder\.swift|Audio/PinnedAudioQueueRecorder\.swift' \ | python3 -c 'import sys,json; print(round(json.load(sys.stdin)["data"][0]["totals"]["lines"]["percent"],2))')" echo "engine line coverage: ${COVERAGE}%" if ! awk -v c="$COVERAGE" -v min="$MIN_COVERAGE" 'BEGIN{ exit (c+0 < min+0) }'; then From 137d4ee16b4b7f2621034771284bae819121c5a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:06:04 +0000 Subject: [PATCH 2/4] Fix CI: C-callback form, parameter count, format break - AudioQueueNewInput takes a capture-free closure literal forwarding to the static callback: a C function pointer cannot be formed from a static-method reference, only a top-level func or a literal closure. - The static callback drops the unused packet timing/description parameters (raw LPCM never needs them), which also satisfies swiftlint's five-parameter limit. - MicCapture.start(): break the warm-take/make-backend assignment the way swift-format asks (AddLines at the try). --- Sources/BlurtEngine/Audio/MicCapture.swift | 3 ++- .../Audio/PinnedAudioQueueRecorder.swift | 22 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index 43f812b5..43551de1 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -176,7 +176,8 @@ public actor MicCapture: MicCaptureProtocol { // Reuse the warm recorder when it is still bound to the resolved input; // otherwise build a fresh one. `??` only evaluates `makeBackend` when // nothing usable was warmed. - let recorder = try takeWarmRecorder(matching: resolved) + let recorder = + try takeWarmRecorder(matching: resolved) ?? Self.makeBackend(pinnedUID: resolved.pinnedUID) // record() returns false when no usable input device is available (unplugged, diff --git a/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift b/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift index e1acf2d9..520ff2ac 100644 --- a/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift +++ b/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift @@ -58,9 +58,19 @@ final class PinnedAudioQueueRecorder: CaptureRecorder, @unchecked Sendable { var format = Self.captureFormat let shared = SharedState() var created: AudioQueueRef? + // A literal closure, not a `Self.handleInput` reference: a C function + // pointer can only be formed from a top-level `func` or a capture-free + // closure literal — a static-method *reference* is rejected — so the + // literal forwards to the static implementation (calling it is fine; + // naming the type is not a capture). It drops the packet timing/description + // arguments on the way: raw LPCM never needs them. try Self.check( AudioQueueNewInput( - &format, Self.handleInput, Unmanaged.passUnretained(shared).toOpaque(), + &format, + { userData, queue, buffer, _, _, _ in + PinnedAudioQueueRecorder.handleInput(userData: userData, queue: queue, buffer: buffer) + }, + Unmanaged.passUnretained(shared).toOpaque(), nil, nil, 0, &created), "AudioQueueNewInput") guard let created else { @@ -172,14 +182,10 @@ final class PinnedAudioQueueRecorder: CaptureRecorder, @unchecked Sendable { /// The capture callback, on a CoreAudio-owned thread: append what the buffer /// carries and hand the buffer back while the session is still running. It - /// touches nothing but `SharedState`, under its lock. + /// touches nothing but `SharedState`, under its lock. Called through the + /// closure literal in `init` (see there for why it can't be passed directly). private static func handleInput( - userData: UnsafeMutableRawPointer?, - queue: AudioQueueRef, - buffer: AudioQueueBufferRef, - startTime: UnsafePointer, - packetCount: UInt32, - packetDescriptions: UnsafePointer? + userData: UnsafeMutableRawPointer?, queue: AudioQueueRef, buffer: AudioQueueBufferRef ) { guard let userData else { return } let shared = Unmanaged.fromOpaque(userData).takeUnretainedValue() From 656796dbfb07d959d1d61e5ed06e87084ccd7a72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:11:01 +0000 Subject: [PATCH 3/4] Fix CI: hand the device-pin CFString over via withUnsafeMutablePointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift refuses the implicit inout-to-UnsafeRawPointer conversion for a variable whose type carries an object reference, so the kAudioQueueProperty_CurrentDevice value (the CFString reference itself) goes through withUnsafeMutablePointer — the same pattern AudioInputDevices already uses for the UID-translation qualifier, which this CI run compiled cleanly. Also swap the one key-path-inside-#expect in AudioInputDevicesTests for an explicit closure, per AGENTS.md's rethrows/key-path macro trap. --- .../Audio/PinnedAudioQueueRecorder.swift | 19 +++++++++++++------ .../AudioInputDevicesTests.swift | 5 ++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift b/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift index 520ff2ac..e4d36ce3 100644 --- a/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift +++ b/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift @@ -79,13 +79,20 @@ final class PinnedAudioQueueRecorder: CaptureRecorder, @unchecked Sendable { do { // The pin itself. Must land before the queue starts; a UID that no longer // resolves is refused here, which `MicCapture.resolveInput` pre-empts by - // falling back to the system default when the device is absent. + // falling back to the system default when the device is absent. The + // property's value is the CFString reference itself, handed over through + // `withUnsafeMutablePointer` — a bare `&uid` is refused ("forming + // 'UnsafeRawPointer' to a variable of type 'CFString'") because the type + // carries an object reference; same pattern as `AudioInputDevices`' + // UID-translation qualifier. var uid = deviceUID as CFString - try Self.check( - AudioQueueSetProperty( - created, kAudioQueueProperty_CurrentDevice, &uid, - UInt32(MemoryLayout.size)), - "AudioQueueSetProperty(CurrentDevice)") + try withUnsafeMutablePointer(to: &uid) { pointer in + try Self.check( + AudioQueueSetProperty( + created, kAudioQueueProperty_CurrentDevice, pointer, + UInt32(MemoryLayout.size)), + "AudioQueueSetProperty(CurrentDevice)") + } var meteringEnabled: UInt32 = 1 try Self.check( AudioQueueSetProperty( diff --git a/Tests/BlurtEngineTests/AudioInputDevicesTests.swift b/Tests/BlurtEngineTests/AudioInputDevicesTests.swift index d0302367..3465173b 100644 --- a/Tests/BlurtEngineTests/AudioInputDevicesTests.swift +++ b/Tests/BlurtEngineTests/AudioInputDevicesTests.swift @@ -27,8 +27,11 @@ struct AudioInputDevicesTests { try #require(!devices.isEmpty, "a machine running this suite needs at least one input device") // Every entry must be renderable in the picker and pinnable by the store. + // Closures rather than key paths inside the macros — the rethrows + key-path + // combination is the documented `#expect` trap (see AGENTS.md's testing notes). #expect(devices.allSatisfy { !$0.uid.isEmpty && !$0.name.isEmpty }) - #expect(Set(devices.map(\.uid)).count == devices.count, "device UIDs must be unique") + let uids = devices.map { $0.uid } + #expect(Set(uids).count == uids.count, "device UIDs must be unique") // The default input has a name for the "Same as system (…)" label. #expect(AudioInputDevices.systemDefaultInputName() != nil) From 3cfe848d8ae058b9d262a6a9e285ea36220880fc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:29:46 +0000 Subject: [PATCH 4/4] Move capture to a single AVCaptureSession backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-directed (Alex Kroman, 2026-08-25): replace both capture backends — the AVAudioRecorder/WAV path and the device-pinned AudioQueue — with one AVCaptureSession recorder (CaptureSessionRecorder) behind the existing CaptureRecorder seam. Still fresh per session: the session is built around the press-time resolution of the selection (pinned device via AVCaptureDevice(uniqueID:), else the default input, with the same pure missing-device fallback), its data output converts to 16 kHz mono 16-bit LPCM, and the delegate accumulates upload-ready S16LE in memory — no temp file, no decode pass. Liveness gate inputs re-derived on the new API with MicLiveness itself unchanged: the clock is the frames actually delivered (summed off each sample buffer), power is the connection's AVCaptureAudioChannel averagePowerLevel; fail-closed timeout, transport-keyed caps, and the Bluetooth tail linger all still key off the resolved device's CoreAudio snapshot. silenceFloorDB stays -115 dBFS — the dBFS math (0 = full scale, one int16 LSB ~ -90) is meter-independent — but was calibrated on the retired meter and must be re-verified on hardware. Warm-up now pre-builds the session without starting it — the device stays closed and no input indicator shows while idle — so route activation lands inside the connecting window at record()'s startRunning(). A built-but-idle session holds nothing open, so the 60 s warm expiry (which existed to un-pin AirPods from their degraded output profile) is deleted along with its generation tickets. Settled-decision prose updated consistently in AGENTS.md's table, the project-guardrails skill, and check-invariants.sh's advice string; the AVAudioEngine/installTap ban is unchanged and its anchors still pass --self-test. Tests updated to the new backend (warm suite identity probe replaces the generation counter; live suites stay env-gated). --- .claude/skills/project-guardrails/SKILL.md | 14 +- AGENTS.md | 93 ++++--- .../BlurtEngine/Audio/CaptureRecorder.swift | 135 ++-------- .../Audio/CaptureSessionRecorder.swift | 181 +++++++++++++ .../BlurtEngine/Audio/MicCapture+Warm.swift | 83 ++---- Sources/BlurtEngine/Audio/MicCapture.swift | 123 +++------ .../BlurtEngine/Audio/MicCaptureError.swift | 18 -- Sources/BlurtEngine/Audio/MicLiveness.swift | 19 +- .../Audio/PinnedAudioQueueRecorder.swift | 238 ------------------ Sources/BlurtEngine/README.md | 10 +- .../AudioInputDevicesTests.swift | 18 +- .../MicCaptureFormatTests.swift | 86 +------ .../MicCaptureWarmTests.swift | 122 ++++----- scripts/check-invariants.sh | 2 +- scripts/check.sh | 45 ++-- 15 files changed, 431 insertions(+), 756 deletions(-) create mode 100644 Sources/BlurtEngine/Audio/CaptureSessionRecorder.swift delete mode 100644 Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift diff --git a/.claude/skills/project-guardrails/SKILL.md b/.claude/skills/project-guardrails/SKILL.md index aecb1a23..0a0f94c3 100644 --- a/.claude/skills/project-guardrails/SKILL.md +++ b/.claude/skills/project-guardrails/SKILL.md @@ -24,12 +24,14 @@ genuinely correct, and reaching for it means it's time to stop and ask. ## Audio -- **No `AVAudioEngine` / `installTap` capture path.** `MicCapture` uses - `AVAudioRecorder` with a **fresh recorder per session** on purpose — a - long-lived engine bound its input graph to one device and went stale on a - mic↔built-in switch (`-10868`, all-zero buffers). Keep recording to a 16 kHz - mono 16-bit WAV (the Sync API's geometry) so `stop()` reads it back with no - resample pass. +- **No `AVAudioEngine` / `installTap` capture path.** `MicCapture` builds a + **fresh `AVCaptureSession` recorder per capture** (`CaptureSessionRecorder`; + owner-directed move from `AVAudioRecorder`, 2026-08-25) — a long-lived engine + bound its input graph to one device and went stale on a mic↔built-in switch + (`-10868`, all-zero buffers), so no recorder survives across a device change. + Keep the data output converting to 16 kHz mono 16-bit S16LE (the Sync API's + geometry), captured straight to memory so `stop()` returns upload-ready bytes + with no resample pass. ## Transcription pipeline diff --git a/AGENTS.md b/AGENTS.md index a9a14c4a..88e489ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,9 +51,12 @@ workflow and the _why_ behind the design; the engine's README covers the _what_ ```text Sources/BlurtEngine/ the engine (dependency-free Swift package) README.md the engine's developer guide (quick start, seams, error table) - Audio/ MicCapture (+meter/+warm), MicLiveness (mic bring-up gate), AudioRoute - (+Monitor)/AudioTransport — CoreAudio routing, SoundPack/Catalog/Store - (the voice *descriptors*; the voices themselves are app-side) + Audio/ MicCapture (+meter/+warm) over CaptureRecorder/CaptureSessionRecorder + (the AVCaptureSession backend), MicLiveness (mic bring-up gate), + AudioRoute (+Monitor)/AudioTransport — CoreAudio routing, + AudioInputDevices + MicDeviceStore (microphone selection), + SoundPack/Catalog/Store (the voice *descriptors*; the voices + themselves are app-side) HostIdentity.swift the host's Keychain service, log subsystem, defaults prefix, log directory, product name and release feed — one overridable value Config/ Keychain-backed API key, key terms, developer mode, style profiles, @@ -367,7 +370,7 @@ rule along with the row. | Don't | Because | | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Add an external SPM dependency to the engine | Dependency-free by rule (biggest supply-chain risk); a `check.sh` guard fails on `.package(` in `Package.swift` or a `url:`/`github:` package in `project.yml`. Extend `BlurtEngine` instead. | -| Use `AVAudioEngine` / `installTap` for capture | A long-lived engine bound its input graph to one device and went stale on a mic↔built-in switch — `-10868` (`kAudioUnitErr_FormatNotSupported`) or all-zero buffers. `MicCapture` uses a fresh `AVAudioRecorder` per session. | +| Use `AVAudioEngine` / `installTap` for capture | A long-lived engine bound its input graph to one device and went stale on a mic↔built-in switch — `-10868` (`kAudioUnitErr_FormatNotSupported`) or all-zero buffers. `MicCapture` builds a fresh `AVCaptureSession` recorder per capture (owner-directed move from `AVAudioRecorder`, 2026-08-25). | | Add streaming STT | The dictation API returns the full (already rewritten) text in one response; the overlay shows "Transcribing…" then the full text. | | Add a client-side LLM cleanup pass | Cleanup is the dictation API's server-side rewrite, requested by the `llm` block on the same `/transcribe` call. No LLM Gateway client, no `StylerProtocol`, no styling stage, no second request — transcription steering belongs in `ConversationContext`. | | Add local models or model downloads | Transcription is a remote AssemblyAI call: no on-device ASR/LLM, no model cache, no download UI. | @@ -435,33 +438,33 @@ DictationKeyTap (CGEventTap + DictationKeyGate) → AppCoordinator → Dictation ### `MicCapture` — `Sources/BlurtEngine/Audio/MicCapture.swift` -An actor implementing `MicCaptureProtocol`. It captures with **`AVAudioRecorder`**, recording -straight to a temp 16 kHz / mono / 16-bit PCM WAV — the exact geometry the dictation API wants, so -`stop()` reads the file back as raw S16LE bytes (`Data`, via `AVAudioFile`'s int16 common format) -with no resampling or float-conversion pass. That blob is what the dictation request uploads, byte -for byte. - -A **fresh recorder per session** resolves the current default input device at `record()` time; this -is deliberate — see [Settled decisions](#settled-decisions--dont-reintroduce-these) for the -`AVAudioEngine` failure it replaced. - -**Microphone selection** rides a backend seam inside the actor (`CaptureRecorder`): un-pinned -capture — the default — keeps the `AVAudioRecorder` path above unchanged, while a device pinned in -Settings (`MicDeviceStore`, persisted as the device UID) records through `PinnedAudioQueueRecorder`, -an AudioQueue bound to that device via `kAudioQueueProperty_CurrentDevice` — still fresh per -session. The transport-keyed policies (liveness cap, tail linger) and the warm-recorder identity -check key off the **pinned** device's snapshot, and a pinned device that isn't connected falls back -to the system default per press (`MicDeviceSelection.effective` — pure and unit-tested; the pin -stays stored). Device enumeration and UID translation live in `AudioInputDevices` (hardware-bound, -coverage-excluded, like `AudioRoute`). - -**Bluetooth inputs are the reason for four of this actor's moving parts.** Opening the mic on +An actor implementing `MicCaptureProtocol`. It captures with an **`AVCaptureSession`** recorder +(`CaptureSessionRecorder`, behind the actor's `CaptureRecorder` seam; the owner-directed move from +`AVAudioRecorder`, 2026-08-25): the session's audio data output converts to 16 kHz / mono / 16-bit +LPCM — the exact geometry the dictation API wants — and the delegate accumulates the raw S16LE +bytes in memory, so `stop()` returns the blob the dictation request uploads byte for byte, with no +temp file, no read-back, and no resampling or float-conversion pass. + +A **fresh recorder per session**, built around the _current_ resolution of the user's selection at +press time; this is deliberate — see +[Settled decisions](#settled-decisions--dont-reintroduce-these) for the `AVAudioEngine` failure it +replaced. + +**Microphone selection** is that resolution: a device pinned in Settings (`MicDeviceStore`, +persisted as the device UID — `AVCaptureDevice(uniqueID:)` takes the same string) or the system +default input. The transport-keyed policies (liveness cap, tail linger) and the warm-recorder +identity check key off the **pinned** device's snapshot, and a pinned device that isn't connected +falls back to the system default per press (`MicDeviceSelection.effective` — pure and unit-tested; +the pin stays stored). Device enumeration and UID translation live in `AudioInputDevices` +(hardware-bound, coverage-excluded, like `AudioRoute`). + +**Bluetooth inputs are the reason for three of this actor's moving parts.** Opening the mic on AirPods (or any Bluetooth headset) makes the system renegotiate the link into its mic-capable mode — one to two seconds, during which the OS receives no audio at all — and that link then buffers audio in both directions. So: - **`start()` does not return until the input is live.** `record()` returning `true` only means the - AudioQueue started, not that frames are arriving, so `start()` polls the recorder until **both** + session started running, not that frames are arriving, so `start()` polls the recorder until **both** its clock has advanced past 0 **and** its meter reads above `MicLiveness.silenceFloorDB`. The clock alone is not enough: macOS can deliver all-zero buffers from a stale or not-yet-switched device, and frames of digital silence advance the clock exactly like real audio — so the gate was @@ -493,31 +496,27 @@ in both directions. So: `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 warm recorder is re-armed after every capture**, not just at launch. The cost above is paid - at `prepareToRecord()`, i.e. per session, so warming only the first one hid it for one dictation - out of N. `stop()`/`cancelCapture()` schedule a re-warm; `start()` consumes it. -- **A warm recorder is validated before reuse.** `AVAudioRecorder` resolves its device once and - never re-resolves, so `MicCapture` records the default input's identity (`AudioRoute.currentInput()`) - alongside the warm recorder and discards it when the device has changed — otherwise a recorder - warmed before the user connected their AirPods would keep recording the built-in mic. Unknown - counts as changed. -- **The warm recorder expires** (`preparedRecorderLifetime`, 60 s). A prepared recorder holds the - input device open, which is exactly what pins AirPods in the profile where _output_ audio is - degraded — so it is not held indefinitely. Back-to-back dictations land inside the window; a press - past it just prepares lazily, which is the pre-re-warm behavior. - -The re-warm and the liveness gate are complements, not alternatives: the re-warm shortens how _often_ -the profile switch is paid (a warm recorder has already held the route open), and the gate is what -keeps the app honest on the presses that pay it anyway. +- **The warm recorder is re-built after every capture**, not just at launch + (`stop()`/`cancelCapture()` schedule a re-warm; `start()` consumes it). Building a session does + **not** engage the microphone — no capture, no input indicator — so a warm recorder is free to + hold idle with no expiry; what it pre-pays is session construction, while the route activation + itself happens at `record()`'s `startRunning()`, inside the connecting window the liveness gate + covers. (The retired recorder pre-opened the route at warm time instead, which is why its warm + slot needed a 60 s expiry — an open input pins AirPods in the profile where _output_ audio is + degraded. A built-but-idle session holds nothing, so the expiry went with the backend.) +- **A warm recorder is validated before reuse.** The session attaches its device once, at build + time, and never re-resolves, so `MicCapture` records the resolved input's identity (and the pin it + was built under) alongside the warm recorder and discards it when either has changed — otherwise a + recorder warmed before the user connected their AirPods would keep recording the built-in mic. + Unknown counts as changed. `stop()` also waits out `AudioTransport.tailLinger(forTransportType:)` (220 ms on Bluetooth, `.zero` otherwise — the policy sits beside `MicLiveness`'s wait cap so both are unit-tested) before ending the recording **when the -session's 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 `cancelCapture()` instead, which skips both the -linger and the file read-back: the audio is being discarded, so neither is worth delaying the user's -cancel for. +session's input is Bluetooth**, so speech still travelling over the link lands in the recording +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 `cancelCapture()` instead, which +skips the linger: the audio is being discarded, so it isn't worth delaying the user's cancel for. The routing facts behind all of that live in **`AudioRoute`** (`Audio/AudioRoute.swift`, internal): which device is the default input, and its raw CoreAudio transport type. **Raw reads only** — what a diff --git a/Sources/BlurtEngine/Audio/CaptureRecorder.swift b/Sources/BlurtEngine/Audio/CaptureRecorder.swift index 9635de33..920fa50f 100644 --- a/Sources/BlurtEngine/Audio/CaptureRecorder.swift +++ b/Sources/BlurtEngine/Audio/CaptureRecorder.swift @@ -1,132 +1,47 @@ -@preconcurrency import AVFoundation import Foundation -/// The one-session recording surface `MicCapture` drives, abstracted over which -/// backend records: an un-pinned capture — the shipping default — keeps the -/// `AVAudioRecorder` WAV path exactly as it was (`WAVFileRecorder`), and a -/// capture pinned to a specific device takes `PinnedAudioQueueRecorder`, the -/// macOS capture surface that accepts a per-instance device. Both are built -/// fresh per session — the invariant the capture design rests on — and both -/// feed the same liveness gate, meter, warm-recorder and tail-linger machinery -/// through this seam, so pinning changes which device is opened and nothing -/// else. +/// The one-session recording surface `MicCapture` drives. A seam rather than a +/// direct dependency so the capture actor's machinery — the liveness gate, the +/// meter, the warm recorder, the tail linger — is written against this small +/// contract instead of a concrete recorder; `CaptureSessionRecorder` is the one +/// production conformer (owner-directed move to `AVCaptureSession`, +/// 2026-08-25), built fresh per session, which is the invariant the capture +/// design rests on. /// /// `Sendable` because `MicCapture.start()`'s liveness probes read the recorder /// off-actor; conformers are `@unchecked Sendable` on the same confinement -/// argument the raw recorder relied on there — the polls run sequentially in -/// one task while `start()` is suspended and nothing else references the -/// recorder in that window (the AudioQueue backend additionally locks the state -/// its CoreAudio callback thread shares). +/// argument the retired recorders relied on there — the polls run sequentially +/// in one task while `start()` is suspended and nothing else references the +/// recorder in that window (the session backend additionally locks the state +/// its delegate queue shares). protocol CaptureRecorder: AnyObject, Sendable { /// Begin capturing. False when no usable input device is available. func record() -> Bool - /// Seconds the recorder's clock has advanced since `record()` — 0 until the - /// underlying queue is started and clocking. Consulted only as the liveness - /// gate's has-the-clock-moved probe; frames of digital silence advance it - /// exactly like real audio (see `MicLiveness.waitUntilLive`). + /// Seconds the recorder's clock has advanced since `record()` — 0 until + /// audio is actually being delivered. Consulted only as the liveness gate's + /// has-the-clock-moved probe; frames of digital silence advance it exactly + /// like real audio (see `MicLiveness.waitUntilLive`). var currentTime: TimeInterval { get } /// Refresh and read the input's average power in dBFS. Feeds both the /// liveness gate's silence-floor probe and the overlay meter. func meteredPowerDB() -> Float /// End capture and return everything recorded as raw S16LE PCM at the - /// dictation API's rate, releasing the device and any temp storage. + /// dictation API's rate, releasing the device. func stopAndReadPCM() throws -> Data - /// End capture and throw the audio away, releasing the device and any temp - /// storage — the teardown behind a failed `record()`, an aborted bring-up, a - /// discarded warm recorder, and a cancel. + /// End capture and throw the audio away, releasing the device — the teardown + /// behind a failed `record()`, an aborted bring-up, a discarded warm + /// recorder, and a cancel. func stopAndDiscard() - /// A short name for the capture-start log line (the WAV path's temp filename; - /// a fixed tag for the pinned queue). + /// A short name for the capture-start log line. var logName: String { get } } -/// The system-default backend: `AVAudioRecorder` recording straight to a temp -/// 16 kHz / mono / 16-bit WAV, read back as raw S16LE bytes on stop. This is -/// the shipped capture path, moved behind the seam verbatim — every capture -/// that isn't pinned to a device still goes through exactly this. -final class WAVFileRecorder: CaptureRecorder, @unchecked Sendable { - private let recorder: AVAudioRecorder - - init() throws { - recorder = try MicCapture.makeRecorder() - } - - func record() -> Bool { recorder.record() } - - var currentTime: TimeInterval { recorder.currentTime } - - func meteredPowerDB() -> Float { - recorder.updateMeters() - return recorder.averagePower(forChannel: 0) - } - - func stopAndReadPCM() throws -> Data { - recorder.stop() - defer { MicCapture.removeFile(at: recorder.url) } - return try MicCapture.decodePCM(fromFileAt: recorder.url) - } - - func stopAndDiscard() { - recorder.stop() - MicCapture.removeFile(at: recorder.url) - } - - var logName: String { recorder.url.lastPathComponent } -} - -// The construction and file plumbing behind `WAVFileRecorder`, plus the -// backend choice itself. Statics on `MicCapture` (moved here from -// `MicCapture.swift` for its lint file-length budget, like `+Meter` and -// `+Warm`): `decodePCM` keeps its home so `MicCaptureFormatTests` pins the -// decode against the same symbol the capture uses. extension MicCapture { - /// The backend for a resolved input: a pinned UID gets the AudioQueue - /// recorder bound to that device; no pin gets the WAV recorder on the system - /// default. The only place the two backends are told apart. + /// The recorder for a resolved input. One conformer either way — the session + /// pins itself to the device when a UID is passed and follows the system + /// default when nil — so this stays the single place a backend is chosen, + /// exactly as it was when there were two. static func makeBackend(pinnedUID: String?) throws -> any CaptureRecorder { - guard let pinnedUID else { return try WAVFileRecorder() } - return try PinnedAudioQueueRecorder(deviceUID: pinnedUID) - } - - /// Build a recorder that writes mono 16-bit little-endian PCM at the target - /// rate into a unique temp file. `prepareToRecord()` does the heavy route/buffer - /// setup so the subsequent `record()` starts promptly. - static func makeRecorder() throws -> AVAudioRecorder { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("blurt-\(UUID().uuidString).wav") - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM, - AVSampleRateKey: targetSampleRate, - AVNumberOfChannelsKey: 1, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - ] - let recorder = try AVAudioRecorder(url: url, settings: settings) - recorder.isMeteringEnabled = true - recorder.prepareToRecord() - return recorder - } - - /// Read a recorded PCM file back as raw S16LE bytes — the dictation API's upload - /// encoding. The on-disk WAV already holds 16-bit int samples, so asking - /// `AVAudioFile` for the int16 common format makes this a straight copy-out: - /// no detour through Float32 (which the default `processingFormat` would - /// impose, and which the transcriber would only convert straight back). Int16 - /// is host-endian; Apple platforms (arm64/x86_64) are little-endian, so the - /// bytes are already the S16LE the dictation API expects. - static func decodePCM(fromFileAt url: URL) throws -> Data { - let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatInt16, interleaved: true) - let frameCount = AVAudioFrameCount(file.length) - guard frameCount > 0, - let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) - else { return Data() } - try file.read(into: buffer) - guard let channel = buffer.int16ChannelData?[0] else { return Data() } - return Data(bytes: channel, count: Int(buffer.frameLength) * SyncSTTLimits.bytesPerSample) - } - - static func removeFile(at url: URL) { - try? FileManager.default.removeItem(at: url) + try CaptureSessionRecorder(pinnedUID: pinnedUID) } } diff --git a/Sources/BlurtEngine/Audio/CaptureSessionRecorder.swift b/Sources/BlurtEngine/Audio/CaptureSessionRecorder.swift new file mode 100644 index 00000000..b002d7f5 --- /dev/null +++ b/Sources/BlurtEngine/Audio/CaptureSessionRecorder.swift @@ -0,0 +1,181 @@ +@preconcurrency import AVFoundation +import CoreMedia +import Dispatch +import Foundation +import Synchronization + +/// The one capture backend (owner-directed move to `AVCaptureSession`, +/// 2026-08-25): a session built fresh per capture around a single audio device +/// — the device pinned in Settings when one is set, the system default +/// otherwise — whose data output converts to the dictation API's 16 kHz mono +/// 16-bit LPCM on the fly and delivers it here as sample buffers, accumulated +/// in memory as the raw S16LE blob `stopAndReadPCM` returns. No temp file, no +/// resample pass, no decode on the release path. +/// +/// Fresh-per-session is load-bearing, exactly as it was for the recorders this +/// replaces: the session's input is attached to one device at build time and +/// never re-resolves, so reuse across a device switch is what `MicCapture`'s +/// warm validation exists to prevent (see `takeWarmRecorder`). +/// +/// Building the session does **not** engage the microphone — no capture, no +/// input indicator — which is what makes the warm-up safe to hold idle. The +/// device is only opened by `record()`'s `startRunning()`, so the route +/// activation cost lands inside the press's connecting window (the liveness +/// wait's clock starts *after* `record()` returns, so a slow bring-up eats +/// none of the frame-arrival budget). +/// +/// Hardware-bound like `MicCapture`, and excluded from the coverage gate for +/// the same reason; its live test rides the `BLURT_LIVE_AUDIO_TESTS` gate in +/// `AudioInputDevicesTests`. +/// +/// `@unchecked Sendable`: the capture-path confinement argument on +/// `CaptureRecorder` covers the session/output handles (configured in `init`, +/// then only read), and everything the delegate queue also touches lives +/// behind the `Mutex`. +final class CaptureSessionRecorder: NSObject, CaptureRecorder, @unchecked Sendable { + private struct Guarded { + /// Every byte the delegate has delivered, in arrival order — already the + /// raw S16LE blob `stopAndReadPCM` returns. + var captured = Data() + /// Frames delivered so far, summed off each sample buffer's own count — + /// the recorder's clock. Frames of digital silence advance it exactly like + /// real audio; the liveness gate's power term is what tells those apart. + var capturedSampleCount = 0 + } + + private let session = AVCaptureSession() + private let output = AVCaptureAudioDataOutput() + /// The serial queue sample buffers are delivered on; only the delegate + /// method runs here, and it touches nothing but `state`. + private let delegateQueue = DispatchQueue( + label: HostIdentity.current.queueLabel("MicCaptureSession")) + private let state = Mutex(Guarded()) + let logName: String + + /// Builds and fully configures the session — device input, converted-format + /// data output, delegate — without starting it. Throws only when + /// `AVCaptureDeviceInput` refuses the device (e.g. no microphone + /// authorization); "no device at all" instead leaves the session inputless, + /// so `record()` answers false and `MicCapture.start()` surfaces the same + /// `.audioCaptureFailed(noInputDevice)` it always has. + init(pinnedUID: String?) throws { + logName = pinnedUID == nil ? "capture session (default input)" : "capture session (pinned input)" + super.init() + + session.beginConfiguration() + defer { session.commitConfiguration() } + if let device = Self.device(forPinnedUID: pinnedUID) { + let input = try AVCaptureDeviceInput(device: device) + if session.canAddInput(input) { + session.addInput(input) + } + } + // The dictation API's geometry, converted by the output itself — the same + // six keys the retired WAV recorder asked of its file, so capture still + // lands in upload-ready S16LE with no resample pass anywhere. + output.audioSettings = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: Double(SyncSTTLimits.sampleRate), + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + ] + output.setSampleBufferDelegate(self, queue: delegateQueue) + if session.canAddOutput(output) { + session.addOutput(output) + } + } + + deinit { + // Backstop for a recorder dropped without a stop, so an orphaned instance + // can't keep the microphone engaged for the rest of the process. + if session.isRunning { + session.stopRunning() + } + } + + /// The device the session records from: the pinned device when its UID still + /// resolves (`AVCaptureDevice.uniqueID` is the CoreAudio device UID on + /// macOS, the same string `MicDeviceStore` persists), else the default input + /// — the same per-capture fallback `MicCapture.resolveInput` applies, kept + /// here too so the race where the device vanishes between resolution and + /// build degrades identically. Nil when the machine has no input at all. + private static func device(forPinnedUID pinnedUID: String?) -> AVCaptureDevice? { + if let pinnedUID, let pinned = AVCaptureDevice(uniqueID: pinnedUID) { + return pinned + } + return AVCaptureDevice.default(for: .audio) + } + + /// Opens the device and starts capture. `startRunning()` is synchronous — + /// this is where the route-activation cost lives now that warm-up only + /// pre-builds — and false means no usable input: nothing was attached at + /// build time, or the session refused to run. + func record() -> Bool { + guard !session.inputs.isEmpty else { return false } + session.startRunning() + return session.isRunning + } + + /// Seconds of audio delivered so far — the frames the delegate has actually + /// received, over the capture rate. 0 until the first buffer lands, which is + /// the "has the clock moved" probe `MicLiveness` short-circuits on. + var currentTime: TimeInterval { + Double(state.withLock { $0.capturedSampleCount }) / Double(SyncSTTLimits.sampleRate) + } + + /// The capture connection's average input power in dBFS + /// (`AVCaptureAudioChannel.averagePowerLevel`), feeding both the liveness + /// gate's silence-floor probe and the overlay meter. A missing connection or + /// channel answers -160 — reads as digital silence, so the gate keeps + /// waiting (and fails closed at its cap) and the meter rests, the + /// conservative direction for both. + func meteredPowerDB() -> Float { + guard let channel = output.connection(with: .audio)?.audioChannels.first else { return -160 } + return channel.averagePowerLevel + } + + func stopAndReadPCM() -> Data { + session.stopRunning() + return state.withLock { $0.captured } + } + + func stopAndDiscard() { + session.stopRunning() + state.withLock { + $0.captured = Data() + $0.capturedSampleCount = 0 + } + } +} + +extension CaptureSessionRecorder: AVCaptureAudioDataOutputSampleBufferDelegate { + /// Sample buffers, on `delegateQueue`: copy the converted S16LE bytes out of + /// the block buffer and account the frames, under the lock. A buffer whose + /// bytes can't be copied is dropped whole — better a short gap than a blob + /// whose byte count and sample count disagree. + func captureOutput( + _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return } + let length = CMBlockBufferGetDataLength(blockBuffer) + guard length > 0 else { return } + var chunk = Data(count: length) + let status = chunk.withUnsafeMutableBytes { raw -> OSStatus in + // Empty is excluded above, so a nil base can't happen; answered as a + // plain non-noErr status rather than trapping, since dropping the buffer + // is this method's failure mode for every other copy problem too. + guard let base = raw.baseAddress else { return OSStatus(-1) } + return CMBlockBufferCopyDataBytes( + blockBuffer, atOffset: 0, dataLength: length, destination: base) + } + guard status == kCMBlockBufferNoErr else { return } + let sampleCount = CMSampleBufferGetNumSamples(sampleBuffer) + state.withLock { + $0.captured.append(chunk) + $0.capturedSampleCount += sampleCount + } + } +} diff --git a/Sources/BlurtEngine/Audio/MicCapture+Warm.swift b/Sources/BlurtEngine/Audio/MicCapture+Warm.swift index f79540da..598b966c 100644 --- a/Sources/BlurtEngine/Audio/MicCapture+Warm.swift +++ b/Sources/BlurtEngine/Audio/MicCapture+Warm.swift @@ -1,45 +1,45 @@ import Foundation -// The warm-recorder lifecycle — prepare ahead of the press, validate it still -// matches the live input, and let it expire — split from `MicCapture.swift` to -// stay within the lint file-length budget, like `MicCapture+Meter`. Members it -// reaches (`warm`, `preparedGeneration`, `logger`, `deviceSelection`, +// The warm-recorder lifecycle — build ahead of the press, validate it still +// matches the resolved input, and consume or discard — split from +// `MicCapture.swift` to stay within the lint file-length budget, like +// `MicCapture+Meter`. Members it reaches (`warm`, `logger`, `deviceSelection`, // `makeBackend`, `resolveInput`) are internal rather than private for that // reason: `private` is file-scoped and can't cross the split. extension MicCapture { - /// Pre-create and prepare a recorder so the first `start()` skips first-time - /// hardware route discovery. Does NOT begin capture — no mic indicator. Safe to - /// call multiple times; a failure here just leaves `start()` to prepare lazily. + /// Pre-build a recorder so the first `start()` skips session construction. + /// Does NOT begin capture — the device stays closed and no mic indicator + /// shows (see `CaptureSessionRecorder`) — so a warm recorder is free to hold + /// idle indefinitely. Safe to call multiple times; a failure here just + /// leaves `start()` to build lazily. public func warmUp() { guard canPrepareWarmRecorder else { return } prepareWarmRecorder() } - /// Whether it is safe to open the input for a *warm* recorder right now: - /// nothing is capturing, nothing is mid-bring-up, and no warm recorder is - /// already held. A scheduled re-warm that fails this has been overtaken — - /// a press got there first — and has nothing to do. + /// Whether it makes sense to build a warm recorder right now: nothing is + /// capturing, nothing is mid-bring-up, and no warm recorder is already held. + /// A scheduled re-warm that fails this has been overtaken — a press got + /// there first — and has nothing to do. /// /// `bringingUpCapture` is the load-bearing term. Both `activeRecorder` and /// `warm` are nil across `start()`'s liveness wait, so testing them alone reads - /// a live capture as "idle" and prepares a second recorder onto the open input. + /// a live capture as "idle" and builds a recorder the press is about to race. var canPrepareWarmRecorder: Bool { activeRecorder == nil && warm == nil && !bringingUpCapture } /// The warm recorder if it is still bound to `resolved`'s input *and* was - /// prepared under the same pin, else nil — discarding (and cleaning up after) + /// built under the same pin, else nil — discarding (and cleaning up after) /// one that isn't. /// /// Reuse requires *positively* confirming the device is unchanged: an /// unreadable route on either side leaves us unable to tell, and a recorder /// bound to the wrong device doesn't fail loudly — it records the wrong mic, or - /// silence. Paying route activation is the cheaper mistake, so unknown means - /// discard. The pin must match too, not just the device — see - /// `WarmRecorder.pinnedUID`. + /// silence. Rebuilding is the cheaper mistake, so unknown means discard. The + /// pin must match too, not just the device — see `WarmRecorder.pinnedUID`. func takeWarmRecorder(matching resolved: ResolvedInput) -> (any CaptureRecorder)? { guard let held = warm else { return nil } - held.expiry?.cancel() warm = nil guard held.pinnedUID == resolved.pinnedUID, let warmed = held.input, let input = resolved.input, warmed.deviceID == input.deviceID @@ -52,64 +52,31 @@ extension MicCapture { } /// Queues a re-warm to run once the current actor turn finishes, so the caller - /// (`stop()` / `cancelCapture()`) returns before the input is re-opened. + /// (`stop()` / `cancelCapture()`) returns before the next session is built. /// /// `warmUp()` rather than a separate re-warm entry point: the two differ only - /// in when they are called, and both answer the same question — is it safe to - /// open the input for a warm recorder right now — so they share the guard - /// rather than each keeping a copy of it. + /// in when they are called, and both answer the same question — does a warm + /// recorder make sense right now — so they share the guard rather than each + /// keeping a copy of it. func scheduleRewarm() { Task { [weak self] in await self?.warmUp() } } - /// Builds a recorder for the current selection, records the input (and pin) - /// it is bound to, and starts its idle countdown. A failure is non-fatal: - /// `start()` then prepares lazily, exactly as it did before any warm recorder - /// existed. + /// Builds a recorder for the current selection and records the input (and + /// pin) it is bound to. A failure is non-fatal: `start()` then builds + /// lazily, exactly as it would with nothing warmed. func prepareWarmRecorder() { do { - preparedGeneration += 1 let resolved = Self.resolveInput(selection: deviceSelection()) warm = WarmRecorder( recorder: try Self.makeBackend(pinnedUID: resolved.pinnedUID), input: resolved.input, - pinnedUID: resolved.pinnedUID, - generation: preparedGeneration) - armPreparedRecorderExpiry(generation: preparedGeneration) + pinnedUID: resolved.pinnedUID) Self.logger.info("prepared a warm recorder") } catch { Self.logger.error("warm-up failed: \(error.localizedDescription, privacy: .public)") } } - - /// Arms the idle countdown for the warm recorder identified by `generation`. - /// The ticket is what makes a stale expiry harmless — see - /// `releasePreparedRecorder(generation:)`. - func armPreparedRecorderExpiry(generation: Int) { - warm?.expiry?.cancel() - warm?.expiry = Task { [weak self] in - try? await Task.sleep(for: Self.preparedRecorderLifetime) - guard !Task.isCancelled else { return } - await self?.releasePreparedRecorder(generation: generation) - } - } - - /// Tears down an idle warm recorder, freeing the input device — which is what - /// lets a Bluetooth output route return to its full-quality profile. See - /// `preparedRecorderLifetime`. - /// - /// A stale expiry — one whose recorder was consumed by a press, or replaced by - /// a later re-warm — must do nothing at all, which is what `generation` buys. - /// Cancellation alone doesn't cover it: an expiry that already passed its - /// `!Task.isCancelled` check still gets its actor turn, and would otherwise nil - /// out the *live* expiry's handle (leaving the current warm recorder with no - /// countdown at all) and tear down a recorder prepared a moment ago. - func releasePreparedRecorder(generation: Int) { - guard let held = warm, held.generation == generation else { return } - warm = nil - held.recorder.stopAndDiscard() - Self.logger.info("released idle warm recorder") - } } diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index 43551de1..9cc541db 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -2,14 +2,13 @@ import Foundation import os /// Captures mic audio as the 16 kHz / mono / 16-bit PCM the dictation API wants -/// — so there's no manual tap, sample-rate conversion, or PCM plumbing here. -/// Each session uses a freshly created recorder (`CaptureRecorder`): the -/// `AVAudioRecorder`-backed WAV path when the user follows the system default -/// input — the shipping behavior, which resolves the *current* default device -/// at `record()` time — or an AudioQueue bound to one specific device when a -/// microphone is pinned in Settings (`MicDeviceSelection`). The fresh recorder -/// per session is the whole reason this is no longer a long-lived engine graph: -/// that graph bound its input to one device and went stale on a device switch, +/// — so there's no manual tap, resample pass, or PCM plumbing here. Each +/// session uses a freshly built recorder (`CaptureRecorder`, backed by +/// `CaptureSessionRecorder`): an `AVCaptureSession` around the *current* +/// resolution of the user's selection — the device pinned in Settings +/// (`MicDeviceSelection`), or the system default input. The fresh recorder per +/// session is the whole reason this is not a long-lived engine graph: that +/// graph bound its input to one device and went stale on a device switch, /// raising `-10868` or quietly capturing all-zero buffers; per-session /// recorders can't go stale. public actor MicCapture: MicCaptureProtocol { @@ -22,13 +21,6 @@ public actor MicCapture: MicCaptureProtocol { public nonisolated let levels: AsyncStream private nonisolated let levelsContinuation: AsyncStream.Continuation - /// The geometry the recorder converts hardware audio to on the fly. The dictation - /// API's rate (`SyncSTTLimits.sampleRate`) — the same one the pipeline hands - /// the transcriber — so `stop()` returns bytes ready to upload with no - /// resampling or re-encoding pass. Internal because `makeRecorder` reads it - /// from `CaptureRecorder.swift` (split out for this file's length budget). - static let targetSampleRate = Double(SyncSTTLimits.sampleRate) - /// Reads the persisted microphone selection, once per session bring-up (and /// per warm-up). A closure so tests inject a fixed selection instead of the /// process defaults; production reads `MicDeviceStore` per capture, so a @@ -36,41 +28,28 @@ public actor MicCapture: MicCaptureProtocol { /// rule as the key terms. let deviceSelection: @Sendable () -> MicDeviceSelection - /// A recorder prepared ahead of the press so `start()` doesn't pay hardware - /// route activation on the hot path, together with everything that belongs to - /// *that* recorder: the input it is bound to, its idle countdown, and the - /// ticket that countdown carries. - /// - /// One optional rather than four parallel fields, so "a recorder without its - /// input snapshot" and "an expiry without its recorder" are unrepresentable - /// instead of merely never written. + /// A recorder built ahead of the press so `start()` doesn't pay session + /// construction on the hot path, together with the input identity it was + /// built against. Building does **not** engage the microphone — the device + /// only opens at `record()` — so holding one idle costs nothing, needs no + /// expiry, and never shows an input indicator. (Route activation itself is + /// paid by `record()`'s `startRunning()`, inside the connecting window the + /// liveness gate already covers.) /// /// Filled by `warmUp()` at launch and **re-filled after every capture** (see - /// `scheduleRewarm`), because that cost is paid per session, not once: - /// `prepareToRecord()` is where the route is resolved and opened, and on a - /// Bluetooth input that means renegotiating the link into its mic-capable - /// mode — hundreds of milliseconds, sometimes over a second, during which the - /// user has pressed the key and nothing has happened. Warming only the first - /// session (the previous behavior) hid that cost for one dictation out of N. - /// - /// Still a *fresh recorder per session*, which is the invariant the - /// `AVAudioEngine` rewrite bought: the warm recorder is validated against the - /// live default input before it is used (`takeWarmRecorder`) and discarded - /// rather than reused when the device has changed underneath it. + /// `scheduleRewarm`). Still a *fresh recorder per session*, which is the + /// invariant the long-lived-graph revert bought: the warm recorder is + /// validated against the live resolved input before it is used + /// (`takeWarmRecorder`) and discarded rather than reused when the device or + /// the selection has changed underneath it. var warm: WarmRecorder? - /// Bumped for each warm recorder prepared, so an expiry whose recorder has - /// since been consumed or replaced can recognise itself as stale. See - /// `releasePreparedRecorder(generation:)` for why cancelling the task isn't - /// sufficient on its own. - var preparedGeneration = 0 - - /// A prepared-but-not-started recorder and the state that only makes sense + /// A built-but-not-started recorder and the identity that only makes sense /// alongside it. struct WarmRecorder { let recorder: any CaptureRecorder - /// The input it was built against. A recorder resolves its device once, at - /// prepare time, and never re-resolves — so without this a recorder warmed + /// The input it was built against. A recorder attaches its device once, at + /// build time, and never re-resolves — so without this a recorder warmed /// while the built-in mic was default would keep recording from it after /// the user connected their AirPods. See `AudioRoute.InputSnapshot.deviceID` /// for why identity is the device ID. @@ -80,12 +59,6 @@ public actor MicCapture: MicCaptureProtocol { /// recorder warmed under one selection must not serve a press made under /// another, even when both currently resolve to the same device. let pinnedUID: String? - /// Releases this recorder once it has gone unused for - /// `preparedRecorderLifetime`. See that constant for why holding one open - /// forever is not an option. - var expiry: Task? - /// The ticket `expiry` carries, so a stale one can identify itself. - let generation: Int } /// The recorder for the in-flight session; nil between `stop()` and `start()`. @@ -101,7 +74,7 @@ public actor MicCapture: MicCaptureProtocol { /// re-checks after, so a teardown that interleaves during the wait wins. /// Without it, 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 + /// (mic indicator hot) 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, and any /// host can call it unqueued. @@ -141,19 +114,6 @@ public actor MicCapture: MicCaptureProtocol { /// `meterIntervalSeconds` as the `Duration` the meter task sleeps for. private static let meterInterval = Duration.seconds(meterIntervalSeconds) - /// How long a prepared-but-unused recorder is held before being torn down. - /// - /// The warm recorder is the fix for per-session route activation, but it is - /// not free to hold: a prepared recorder keeps the input device open, and an - /// open input is exactly what pins AirPods in their mic-capable profile, where - /// *output* audio is degraded. Holding one indefinitely would trade dictation - /// latency for permanently worse music. This bounds that: back-to-back - /// dictations — the case the warm recorder exists for — land well inside the - /// window, and a user who stops dictating gets their output route back shortly - /// after. The next press past the window simply prepares lazily, which is the - /// behavior that shipped before the re-warm existed. - static let preparedRecorderLifetime = Duration.seconds(60) - public init( deviceSelection: @escaping @Sendable () -> MicDeviceSelection = { MicDeviceStore().selection } ) { @@ -197,17 +157,16 @@ public actor MicCapture: MicCaptureProtocol { bringingUpCapture = true defer { bringingUpCapture = false } - // `record()` returning true only means the AudioQueue started — not that the - // input route is delivering frames. A Bluetooth mic spends up to a couple of - // seconds switching into its mic-capable profile first, and the OS captures - // nothing in that window, so returning here immediately cues the user to - // speak into a dead mic and the first words never reach the transcript. Hold - // — `DictationSession` keeps the pill in `.connecting` and the start chime - // waits — until the recorder is clocking *and* metering real samples, capped - // per transport and FAILING CLOSED on timeout (the throw below). `MicLiveness` - // owns both policies and the reasoning, including why the clock alone was - // not enough. The re-warm above is what usually makes this return at once; - // the gate is what makes it correct when it doesn't. + // `record()` returning true only means the session started running — not + // that the input route is delivering frames. A Bluetooth mic spends up to a + // couple of seconds switching into its mic-capable profile first, and the OS + // captures nothing in that window, so returning here immediately cues the + // user to speak into a dead mic and the first words never reach the + // transcript. Hold — `DictationSession` keeps the pill in `.connecting` and + // the start chime waits — until the recorder is clocking *and* metering real + // samples, capped per transport and FAILING CLOSED on timeout (the throw + // below). `MicLiveness` owns both policies and the reasoning, including why + // the clock alone was not enough. let timeout = MicLiveness.timeout(forTransportType: resolved.input?.transportType) let generationBeforeWait = stopGeneration // Both probes read off-actor (`waitUntilLive` is nonisolated), safe by @@ -222,7 +181,7 @@ public actor MicCapture: MicCaptureProtocol { // Two ways the bring-up can be abandoned while suspended, both ending the // same way — tear the recorder down instead of installing it, so nothing is - // left capturing and no temp file is orphaned: + // left capturing: // // - A teardown landed (`stopGeneration` moved), so the caller's stop has to // stay a real stop rather than returning an empty "clean" one. @@ -293,10 +252,9 @@ public actor MicCapture: MicCaptureProtocol { /// `DictationSession`'s cancels. /// /// Deliberately *not* `stop()`-and-discard: the user asked for nothing to - /// happen, so neither of `stop()`'s costs is worth paying. The Bluetooth tail - /// linger would delay the `.cancelled` phase (and with it the pill's dismissal) - /// to preserve audio about to be deleted, and reading the whole recording back - /// off disk would decode a blob with no consumer. + /// happen, so `stop()`'s one cost isn't worth paying. The Bluetooth tail + /// linger would delay the `.cancelled` phase (and with it the pill's + /// dismissal) to preserve audio about to be deleted. /// /// Not marked `throws`, because nothing on this path can fail — a /// non-throwing implementation satisfies the `throws` requirement fine. The @@ -392,8 +350,7 @@ public actor MicCapture: MicCaptureProtocol { // The dB→0...1 conversion `emitLevel` uses lives in `MicCapture+Meter.swift` // — pure math the coverage gate counts, unlike this hardware-bound actor. The // warm-recorder lifecycle lives in `MicCapture+Warm.swift`, and the recorder - // backends with their construction and file plumbing in - // `CaptureRecorder.swift` — both split off for the lint file-length budget, - // which is why the prepared-recorder state, `logger`, `deviceSelection`, - // `targetSampleRate` and those statics are internal rather than private. + // seam with its factory in `CaptureRecorder.swift` — both split off for the + // lint file-length budget, which is why the warm state, `logger`, + // `deviceSelection` and `makeBackend` are internal rather than private. } diff --git a/Sources/BlurtEngine/Audio/MicCaptureError.swift b/Sources/BlurtEngine/Audio/MicCaptureError.swift index 6504a733..1455abde 100644 --- a/Sources/BlurtEngine/Audio/MicCaptureError.swift +++ b/Sources/BlurtEngine/Audio/MicCaptureError.swift @@ -27,21 +27,3 @@ enum MicCaptureError: LocalizedError { } } } - -/// A CoreAudio call the pinned-device recorder couldn't get past, carrying which -/// call and the `OSStatus` it answered — the two facts a field report needs to -/// be actionable. Reaches the overlay the same way `MicCaptureError` does -/// (interpolated by `BlurtError.audioCaptureFailed`), so the message carries the -/// same read-like-a-sentence requirement, pinned in `MicCaptureFormatTests`. -/// Declared here beside `MicCaptureError` — not in the recorder's own file — -/// because it is pure, so the coverage gate counts it and the message test keeps -/// it green. -struct AudioQueueError: LocalizedError { - /// The CoreAudio call that refused, e.g. `"AudioQueueNewInput"`. - let operation: String - let status: OSStatus - - var errorDescription: String? { - "The selected microphone couldn't be opened (\(operation): error \(status))." - } -} diff --git a/Sources/BlurtEngine/Audio/MicLiveness.swift b/Sources/BlurtEngine/Audio/MicLiveness.swift index 6e21a774..c8e079f1 100644 --- a/Sources/BlurtEngine/Audio/MicLiveness.swift +++ b/Sources/BlurtEngine/Audio/MicLiveness.swift @@ -9,7 +9,7 @@ enum MicLiveness { /// The first re-check delay, doubling up to `maxPollInterval`. /// /// Geometric rather than a fixed quantum because the two cases this loop - /// serves want opposite things. `AVAudioRecorder.currentTime` is 0 the instant + /// serves want opposite things. The recorder's clock is 0 the instant /// `record()` returns, so *every* press sleeps at least once before /// `.recording`, the start chime and the meter — on a wired mic that quantum /// is the entire wait, and it should be as small as possible. A real Bluetooth @@ -61,12 +61,17 @@ enum MicLiveness { /// /// This is not a speech threshold and must never be raised into one. All it /// has to separate is "the buffers are literally zeroes" from "the ADC is - /// handing us something": `averagePower(forChannel:)` reports dBFS in roughly - /// `[-160, 0]`, a zero-filled buffer bottoms out at or near -160, and a single - /// least-significant bit of a 16-bit sample is already about -90, so any real - /// analog noise floor — including a live mic in a dead-quiet room, which still - /// reads its own self-noise — sits far above this. -115 is in the empty band - /// between the two. + /// handing us something": the capture meter + /// (`AVCaptureAudioChannel.averagePowerLevel`, dBFS with full scale at 0 — + /// the same scale the retired recorder's meter reported) reads a zero-filled + /// buffer at or near its floor, while a single least-significant bit of a + /// 16-bit sample is already about -90, so any real analog noise floor — + /// including a live mic in a dead-quiet room, which still reads its own + /// self-noise — sits far above this. -115 is in the empty band between the + /// two. The value was calibrated against the retired meter; the dBFS math is + /// identical, but where the session meter actually bottoms out (and what it + /// reads before its first update) must be re-verified on real hardware — + /// AirPods mid-profile-switch especially. /// /// A speech-level floor (`MicCapture.meterFloorDB` is -50) would turn this /// gate into voice-activity detection and fail every press in a quiet room diff --git a/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift b/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift deleted file mode 100644 index e4d36ce3..00000000 --- a/Sources/BlurtEngine/Audio/PinnedAudioQueueRecorder.swift +++ /dev/null @@ -1,238 +0,0 @@ -import AudioToolbox -import Foundation -import Synchronization - -/// The pinned-device backend: an AudioQueue input recording 16 kHz / mono / -/// 16-bit S16LE straight into memory, bound to one device by its UID via -/// `kAudioQueueProperty_CurrentDevice` — the per-instance device selection the -/// WAV recorder's API doesn't expose (it always resolves the system default). -/// This is the same capture machinery that recorder wraps, driven one level -/// down, so its semantics carry over: created and primed ahead of `record()` -/// (the warm-up analog of `prepareToRecord()`), a clock and an average-power -/// meter for the liveness gate, and a fresh instance per session. -/// -/// Only ever constructed for a *pinned* selection (`MicCapture.makeBackend`). -/// The un-pinned default path stays on `WAVFileRecorder`, untouched. -/// -/// Hardware-bound like `MicCapture`, and excluded from the coverage gate for -/// the same reason; its live test rides the `BLURT_LIVE_AUDIO_TESTS` gate in -/// `AudioInputDevicesTests`. -/// -/// `@unchecked Sendable`: the capture-path confinement argument on -/// `CaptureRecorder` covers the queue handle (created in `init`, immutable -/// after), and everything the CoreAudio callback thread also touches lives in -/// `SharedState` behind a `Mutex`. -final class PinnedAudioQueueRecorder: CaptureRecorder, @unchecked Sendable { - /// The state the CoreAudio callback thread and the capture path share. A - /// separate object — not `self` — so the C callback's context pointer exists - /// before the queue does, and `init` never has to hand out a half-built - /// `self`. Passed unretained: the recorder owns it for its whole life and - /// disposes the queue (synchronously) before either is released, so no - /// callback can outlive it. - private final class SharedState: Sendable { - let cell = Mutex(Guarded()) - } - - private struct Guarded { - /// Every byte the callback has delivered, in arrival order — already the - /// raw S16LE blob `stopAndReadPCM` returns, so there is no file and no - /// decode pass on the release path. - var captured = Data() - /// True between a successful `record()` and the teardown; the callback - /// re-enqueues its buffer only while this holds, so a buffer can't be - /// handed back to a queue that is stopping. - var running = false - /// Whether the queue has been stopped and disposed — teardown is reachable - /// from three methods plus `deinit`, and must run once. - var disposed = false - } - - private let queue: AudioQueueRef - private let shared: SharedState - - /// Creates the queue bound to `deviceUID`, enables metering, and primes the - /// capture buffers — the prepare-ahead work, so a warm instance makes - /// `record()` cheap. Throws `AudioQueueError` when any CoreAudio call - /// refuses (an unknown UID surfaces here, on the device property). - init(deviceUID: String) throws { - var format = Self.captureFormat - let shared = SharedState() - var created: AudioQueueRef? - // A literal closure, not a `Self.handleInput` reference: a C function - // pointer can only be formed from a top-level `func` or a capture-free - // closure literal — a static-method *reference* is rejected — so the - // literal forwards to the static implementation (calling it is fine; - // naming the type is not a capture). It drops the packet timing/description - // arguments on the way: raw LPCM never needs them. - try Self.check( - AudioQueueNewInput( - &format, - { userData, queue, buffer, _, _, _ in - PinnedAudioQueueRecorder.handleInput(userData: userData, queue: queue, buffer: buffer) - }, - Unmanaged.passUnretained(shared).toOpaque(), - nil, nil, 0, &created), - "AudioQueueNewInput") - guard let created else { - throw AudioQueueError(operation: "AudioQueueNewInput returned no queue", status: noErr) - } - do { - // The pin itself. Must land before the queue starts; a UID that no longer - // resolves is refused here, which `MicCapture.resolveInput` pre-empts by - // falling back to the system default when the device is absent. The - // property's value is the CFString reference itself, handed over through - // `withUnsafeMutablePointer` — a bare `&uid` is refused ("forming - // 'UnsafeRawPointer' to a variable of type 'CFString'") because the type - // carries an object reference; same pattern as `AudioInputDevices`' - // UID-translation qualifier. - var uid = deviceUID as CFString - try withUnsafeMutablePointer(to: &uid) { pointer in - try Self.check( - AudioQueueSetProperty( - created, kAudioQueueProperty_CurrentDevice, pointer, - UInt32(MemoryLayout.size)), - "AudioQueueSetProperty(CurrentDevice)") - } - var meteringEnabled: UInt32 = 1 - try Self.check( - AudioQueueSetProperty( - created, kAudioQueueProperty_EnableLevelMetering, &meteringEnabled, - UInt32(MemoryLayout.size)), - "AudioQueueSetProperty(EnableLevelMetering)") - // An input queue captures only into buffers already enqueued, so priming - // belongs to construction, not to record(). - for _ in 0.. Bool { - // Raised before the start, not after: the first callback can land while - // AudioQueueStart is still on this stack, and it must already see running - // to re-enqueue its buffer. Lowered again on refusal — nothing started. - shared.cell.withLock { $0.running = true } - guard AudioQueueStart(queue, nil) == noErr else { - shared.cell.withLock { $0.running = false } - return false - } - return true - } - - /// The queue's timeline in seconds, or 0 while it isn't started or has no - /// valid sample time yet. Frames of digital silence advance it like real - /// audio — the liveness gate's power term is what tells those apart. - var currentTime: TimeInterval { - var timestamp = AudioTimeStamp() - let status = AudioQueueGetCurrentTime(queue, nil, ×tamp, nil) - guard status == noErr, timestamp.mFlags.contains(.sampleTimeValid) else { return 0 } - return max(0, timestamp.mSampleTime / Self.captureFormat.mSampleRate) - } - - /// The queue's average input power in dBFS. A failed read answers -160 — the - /// digital-silence floor — which is the conservative direction for both - /// consumers: the liveness gate keeps waiting (and fails closed at its cap) - /// rather than declaring a device live on no evidence, and the meter rests. - func meteredPowerDB() -> Float { - var meter = AudioQueueLevelMeterState() - var size = UInt32(MemoryLayout.size) - let status = AudioQueueGetProperty(queue, kAudioQueueProperty_CurrentLevelMeterDB, &meter, &size) - guard status == noErr else { return -160 } - return meter.mAveragePower - } - - func stopAndReadPCM() -> Data { - dispose() - return shared.cell.withLock { $0.captured } - } - - func stopAndDiscard() { - dispose() - shared.cell.withLock { $0.captured = Data() } - } - - var logName: String { "pinned input queue" } - - /// Idempotent teardown: the first caller stops the queue synchronously — - /// after which no callback runs — and disposes it; later callers (and - /// `deinit`, which backstops a recorder dropped without a stop) find - /// `disposed` set and do nothing. - private func dispose() { - let alreadyDisposed = shared.cell.withLock { guarded in - let was = guarded.disposed - guarded.running = false - guarded.disposed = true - return was - } - guard !alreadyDisposed else { return } - _ = AudioQueueStop(queue, true) - _ = AudioQueueDispose(queue, true) - } - - /// The capture callback, on a CoreAudio-owned thread: append what the buffer - /// carries and hand the buffer back while the session is still running. It - /// touches nothing but `SharedState`, under its lock. Called through the - /// closure literal in `init` (see there for why it can't be passed directly). - private static func handleInput( - userData: UnsafeMutableRawPointer?, queue: AudioQueueRef, buffer: AudioQueueBufferRef - ) { - guard let userData else { return } - let shared = Unmanaged.fromOpaque(userData).takeUnretainedValue() - let byteCount = Int(buffer.pointee.mAudioDataByteSize) - let stillRunning = shared.cell.withLock { guarded in - if byteCount > 0 { - guarded.captured.append(Data(bytes: buffer.pointee.mAudioData, count: byteCount)) - } - return guarded.running - } - guard stillRunning else { return } - _ = AudioQueueEnqueueBuffer(queue, buffer, 0, nil) - } - - private static func check(_ status: OSStatus, _ operation: String) throws { - guard status == noErr else { throw AudioQueueError(operation: operation, status: status) } - } - - /// The dictation API's geometry — the same 16 kHz mono S16LE the WAV path - /// records — asked of the queue directly, which converts from the hardware - /// format on the fly exactly as the WAV recorder does. Derived from - /// `SyncSTTLimits` so the two backends can't drift apart. - private static var captureFormat: AudioStreamBasicDescription { - AudioStreamBasicDescription( - mSampleRate: Double(SyncSTTLimits.sampleRate), - mFormatID: kAudioFormatLinearPCM, - mFormatFlags: kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked, - mBytesPerPacket: UInt32(SyncSTTLimits.bytesPerSample), - mFramesPerPacket: 1, - mBytesPerFrame: UInt32(SyncSTTLimits.bytesPerSample), - mChannelsPerFrame: 1, - mBitsPerChannel: 16, - mReserved: 0) - } - - /// 100 ms of audio per buffer, three in flight — small enough that the meter - /// and the captured tail stay fresh, large enough that the callback isn't hot. - private static var bufferByteSize: UInt32 { - UInt32(SyncSTTLimits.sampleRate * SyncSTTLimits.bytesPerSample / 10) - } - - private static let bufferCount = 3 -} diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 1f35b0df..daef9625 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -168,15 +168,15 @@ Only `start()`/`stop()` must be implemented — `cancelCapture()`, `levels` and `cancelCapture()` is what the session calls when a dictation is cancelled, and it exists because the two teardowns want opposite things: `stop()` may legitimately spend time preserving the audio, while a cancel has nothing to preserve and must take effect at once. Override it only if stopping cheaply differs from stopping carefully in your capture. -`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. When the host pins a specific microphone (`MicDeviceStore`), the same seam records through an AudioQueue bound to that device instead — still fresh per session, with a per-press fallback to the system default while the pinned device isn't connected. +`MicCapture` records through a per-session `AVCaptureSession` (`CaptureSessionRecorder`, behind its `CaptureRecorder` seam — the owner-directed move from `AVAudioRecorder`, 2026-08-25) whose data output converts to 16 kHz / mono / 16-bit LPCM on the fly — exactly the geometry the dictation API wants — accumulating the raw S16LE bytes in memory so `stop()` returns them as-is (no temp file, no float detour). A **fresh recorder per session** is built around the current resolution of the host's microphone selection at press time — the device pinned via `MicDeviceStore`, or the system default — which is why device switches (headset ↔ built-in) just work, with a per-press fallback to the default while a pinned device isn't connected. 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). +`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-builds a recorder — session, device input, converted-format output — so the first `start()` skips that construction; building never engages the microphone (no input indicator), and the route activation itself happens at `record()`, inside the connecting window the liveness gate covers (Blurt calls `warmUp()` at launch, once mic permission is granted, so warming never triggers the permission prompt). -**Bluetooth inputs get four accommodations**, because opening the mic on AirPods makes the system renegotiate the link into its mic-capable mode — one to two seconds, during which the OS receives no audio at all — and that link then buffers audio. +**Bluetooth inputs get three accommodations**, because opening the mic on AirPods makes the system renegotiate the link into its mic-capable mode — one to two seconds, during which the OS receives no audio at all — and that link then buffers audio. -The load-bearing one is that **`start()` doesn't return until the input is live.** `record()` returning `true` only means the AudioQueue started, so `start()` polls the recorder until its clock has advanced past 0 **and** its meter reads above `MicLiveness.silenceFloorDB` — the clock alone advances over the all-zero buffers a stale or not-yet-switched device delivers, so it confirmed a route that was still renegotiating. The floor separates _digital_ silence from any real analog input, not speech from quiet. The wait is capped per transport by `MicLiveness` (2.5 s Bluetooth, 300 ms on a recognised local transport, 1 s for an aggregate/virtual/unreadable one) and **fails closed** on timeout: `start()` tears the recorder down and throws `.audioCaptureFailed`, which the session surfaces as the same error pill as any other capture failure — never a recording the mic wasn't delivering for. Speech during the switch is not recovered by this — nothing receives it — the point is to stop inviting it. +The load-bearing one is that **`start()` doesn't return until the input is live.** `record()` returning `true` only means the session started running, so `start()` polls the recorder until its clock has advanced past 0 **and** its meter reads above `MicLiveness.silenceFloorDB` — the clock alone advances over the all-zero buffers a stale or not-yet-switched device delivers, so it confirmed a route that was still renegotiating. The floor separates _digital_ silence from any real analog input, not speech from quiet. The wait is capped per transport by `MicLiveness` (2.5 s Bluetooth, 300 ms on a recognised local transport, 1 s for an aggregate/virtual/unreadable one) and **fails closed** on timeout: `start()` tears the recorder down and throws `.audioCaptureFailed`, which the session surfaces as the same error pill as any other capture failure — never a recording the mic wasn't delivering for. Speech during the switch is not recovered by this — nothing receives it — the point is to stop inviting it. -The other three reduce how often that wait is paid, and fix the tail. `MicCapture` re-arms the warm recorder after _every_ capture rather than only at launch (the cost is per session, paid at `prepareToRecord()`); it records which device the warm recorder was built against and discards it when the default input has changed, since `AVAudioRecorder` resolves its device once and never re-resolves; and it releases an unused warm recorder after 60 s, because holding the input device open is what pins AirPods in the profile where output audio is degraded. On top of that, `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. `cancelCapture()` skips both that linger and the file read-back. +The other two trim the bring-up and fix the tail. `MicCapture` re-builds the warm recorder after _every_ capture rather than only at launch, and it records which device (and pin) the warm recorder was built against, discarding it when either has changed — the session attaches its device once, at build time, and never re-resolves. (A built-but-idle session holds the device closed, so there is no expiry: the retired recorder pre-opened the route at warm time and had to be released after 60 s to un-pin AirPods from their degraded-output profile.) On top of that, `stop()` keeps capturing for a further 220 ms when the input is Bluetooth, so speech still travelling over the link lands in the recording instead of being truncated — the missing last word. `cancelCapture()` skips that linger. ### `TranscriberProtocol` → `AssemblyAITranscriber` diff --git a/Tests/BlurtEngineTests/AudioInputDevicesTests.swift b/Tests/BlurtEngineTests/AudioInputDevicesTests.swift index 3465173b..9feb615c 100644 --- a/Tests/BlurtEngineTests/AudioInputDevicesTests.swift +++ b/Tests/BlurtEngineTests/AudioInputDevicesTests.swift @@ -5,13 +5,13 @@ import Testing /// Live-hardware checks for the microphone-selection plumbing: the device /// enumeration the Settings picker lists, the UID→snapshot translation the -/// capture path pins with, and the pinned AudioQueue recorder itself. +/// capture path pins with, and the session recorder itself. /// /// Gated on BLURT_LIVE_AUDIO_TESTS=1 like the other capture suites — every test /// here talks to the real CoreAudio HAL (and the recorder test opens a real /// input device), which a headless CI runner cannot answer for. Documents and /// locks the behavior for a human running it on a Mac with a microphone; -/// `AudioInputDevices.swift` and `PinnedAudioQueueRecorder.swift` are excluded +/// `AudioInputDevices.swift` and `CaptureSessionRecorder.swift` are excluded /// from the coverage gate for the same reason `MicCapture.swift` is. @Suite( "AudioInputDevices & pinned recorder (live)", @@ -49,7 +49,7 @@ struct AudioInputDevicesTests { #expect(AudioInputDevices.input(forUID: "blurt-test-no-such-device") == nil) } - @Test("the pinned recorder captures S16LE audio from the device it was built for") + @Test("the session recorder captures S16LE audio from the device it was pinned to") func pinnedRecorderCapturesAudio() async throws { // Pin to the current default input's UID — the one device a machine running // this suite is known to have working. @@ -59,12 +59,16 @@ struct AudioInputDevicesTests { AudioInputDevices.input(forUID: $0.uid)?.deviceID == defaultInput.deviceID }, "the default input should be among the enumerated devices") - let recorder = try PinnedAudioQueueRecorder(deviceUID: device.uid) - try #require(recorder.record(), "the pinned recorder should start on a live device") + let recorder = try CaptureSessionRecorder(pinnedUID: device.uid) + try #require(recorder.record(), "the session recorder should start on a live device") - // Give the queue a moment to clock and deliver, as the liveness gate would. + // Give the session a moment to clock and deliver, as the liveness gate would. try await Task.sleep(for: .milliseconds(500)) - #expect(recorder.currentTime > 0, "the queue's clock should advance while recording") + #expect(recorder.currentTime > 0, "the clock should advance while buffers arrive") + // The meter must read as a live analog input, not digital silence — this is + // the reading `MicLiveness.silenceFloorDB` gates on, and the assertion that + // proves the session meter's floor semantics on real hardware. + #expect(recorder.meteredPowerDB() > MicLiveness.silenceFloorDB, "a live mic must out-read the silence floor") let pcm = recorder.stopAndReadPCM() #expect(!pcm.isEmpty, "half a second of capture should deliver samples") diff --git a/Tests/BlurtEngineTests/MicCaptureFormatTests.swift b/Tests/BlurtEngineTests/MicCaptureFormatTests.swift index 34b22bb1..2f2916b1 100644 --- a/Tests/BlurtEngineTests/MicCaptureFormatTests.swift +++ b/Tests/BlurtEngineTests/MicCaptureFormatTests.swift @@ -1,13 +1,13 @@ -@preconcurrency import AVFoundation import Foundation import Testing @testable import BlurtEngine -/// Pure-logic tests for `MicCapture`'s level-metering math and recorded-file -/// decoding. The recorder capture lifecycle itself needs a real device and is -/// exercised by the env-gated `MicCaptureLevelsTests`. -@Suite("MicCapture decoding & metering") +/// Pure-logic tests for `MicCapture`'s level-metering math and the capture +/// errors' user-facing wording. The recorder capture lifecycle itself needs a +/// real device and is exercised by the env-gated `MicCaptureLevelsTests` and +/// `AudioInputDevicesTests`. +@Suite("MicCapture metering & errors") struct MicCaptureFormatTests { // MARK: - dBFS → linear level @@ -34,30 +34,7 @@ struct MicCaptureFormatTests { #expect(MicCapture.linearLevel(fromPowerDB: -11) > MicCapture.linearLevel(fromPowerDB: -22)) } - // MARK: - PCM file decoding - - @Test func decodePCMRoundTripsRecordedAudio() throws { - let known: [Float] = [0, 0.5, -0.5, 1.0, -1.0, 0.25, -0.25, 0] - let url = try Self.writeWAV(samples: known) - defer { try? FileManager.default.removeItem(at: url) } - - let pcm = try MicCapture.decodePCM(fromFileAt: url) - - // Two bytes per sample, no RIFF/WAVE header — the blob uploads as-is. - #expect(pcm.count == known.count * 2) - for (i, want) in known.enumerated() { - let got = Float(Self.readInt16LE(pcm, i * 2)) / 32_767 - // int16 quantization tolerance (full scale is ±32767, ~3e-5 per step; - // the write side's ±32768-vs-±32767 scaling convention also fits inside). - #expect(abs(got - want) < 0.001) - } - } - - @Test func decodePCMReturnsEmptyForEmptyFile() throws { - let url = try Self.writeWAV(samples: []) - defer { try? FileManager.default.removeItem(at: url) } - #expect(try MicCapture.decodePCM(fromFileAt: url).isEmpty) - } + // MARK: - Error wording @Test func noInputDeviceHasHumanReadableMessage() { // This message reaches the overlay via BlurtError.audioCaptureFailed's @@ -72,55 +49,4 @@ struct MicCaptureFormatTests { // also keep MicCaptureError.swift's errorDescription fully covered. #expect(MicCaptureError.inputNeverDelivered.errorDescription == "The microphone didn't start.") } - - @Test func audioQueueErrorNamesTheCallAndStatus() { - // The pinned-device recorder's failure, on the same route to the pill — the - // sentence has to carry the refusing call and its OSStatus, the two facts a - // field report is read off. - let error = AudioQueueError(operation: "AudioQueueNewInput", status: -66681) - #expect( - error.errorDescription - == "The selected microphone couldn't be opened (AudioQueueNewInput: error -66681).") - } - - // MARK: - Helpers - - /// Little-endian Int16 at `offset` — decodes the raw S16LE blob under test. - private static func readInt16LE(_ data: Data, _ offset: Int) -> Int16 { - Int16(bitPattern: UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8)) - } - - /// Write the given mono samples to a temp 16 kHz / 16-bit PCM WAV — the same - /// on-disk format `MicCapture` records — and return its URL. The file is closed - /// (flushed) before returning so `decodePCM` reads a complete file. - private static func writeWAV(samples: [Float]) throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("blurt-test-\(UUID().uuidString).wav") - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM, - // The engine's rate, not a restated literal: this helper claims to write - // "the same on-disk format MicCapture records", and MicCapture derives its - // targetSampleRate from here too. Pinned independently, a rate change would - // leave decodePCM's round trip exercising a format the recorder never makes, - // with the suite still green. (SyncSTTLimitsTests pins the value itself.) - AVSampleRateKey: Double(SyncSTTLimits.sampleRate), - AVNumberOfChannelsKey: 1, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - ] - // Scoped so the AVAudioFile is released (and the file flushed) before we read. - try { - let file = try AVAudioFile(forWriting: url, settings: settings) - guard !samples.isEmpty else { return } - let buffer = try #require( - AVAudioPCMBuffer( - pcmFormat: file.processingFormat, frameCapacity: AVAudioFrameCount(samples.count))) - buffer.frameLength = AVAudioFrameCount(samples.count) - let channel = try #require(buffer.floatChannelData) - for (i, sample) in samples.enumerated() { channel[0][i] = sample } - try file.write(from: buffer) - }() - return url - } } diff --git a/Tests/BlurtEngineTests/MicCaptureWarmTests.swift b/Tests/BlurtEngineTests/MicCaptureWarmTests.swift index 270c6bd6..ed907070 100644 --- a/Tests/BlurtEngineTests/MicCaptureWarmTests.swift +++ b/Tests/BlurtEngineTests/MicCaptureWarmTests.swift @@ -3,23 +3,18 @@ import Testing @testable import BlurtEngine -/// The warm-recorder lifecycle (`MicCapture+Warm`): prepare ahead of the press, -/// refuse to double-prepare, validate against the live input before reuse, and -/// tear down on a stale expiry ticket. +/// The warm-recorder lifecycle (`MicCapture+Warm`): build ahead of the press, +/// refuse to double-build, and validate against the resolved input (device +/// *and* pin) before reuse. /// -/// Gated on BLURT_LIVE_AUDIO_TESTS=1, like `MicCaptureLevelsTests`, because every -/// test here goes through `MicCapture.makeRecorder()` — and that calls -/// `prepareToRecord()`, which is *the* route-activation call this whole change is -/// about. On a runner with no input device it is not merely unreliable, it is -/// hostile: it blocks the calling thread rather than suspending, so several of -/// these running concurrently occupy the cooperative pool and wedge the entire -/// `swift test` run, not just this suite. That is not a hypothetical — an -/// ungated first attempt failed three expectations here and then hung 171 -/// unrelated tests until the job's 30-minute timeout killed it. -/// -/// So this suite documents and locks the warm lifecycle for a human running it -/// on a real Mac with a real microphone; `MicCapture+Warm.swift` is excluded -/// from the coverage gate for the same reason `MicCapture.swift` is. +/// Gated on BLURT_LIVE_AUDIO_TESTS=1, like `MicCaptureLevelsTests`, because +/// every test here builds a real `CaptureSessionRecorder` — a live +/// `AVCaptureSession` with a real `AVCaptureDeviceInput`, which needs an input +/// device and the microphone authorization only a human's Mac has. On a +/// headless runner device attachment is at best absent and at worst blocks on +/// a TCC prompt nothing will answer, so the suite documents and locks the warm +/// lifecycle for a human running it locally; `MicCapture+Warm.swift` is +/// excluded from the coverage gate for the same reason `MicCapture.swift` is. @Suite( "MicCapture warm recorder (live)", .enabled( @@ -31,21 +26,21 @@ struct MicCaptureWarmTests { private let builtIn = AudioRoute.InputSnapshot(deviceID: 7, transportType: nil) private let airPods = AudioRoute.InputSnapshot(deviceID: 8, transportType: nil) - @Test("warmUp prepares a recorder once; a second call has been overtaken and no-ops") + @Test("warmUp builds a recorder once; a second call has been overtaken and no-ops") func warmUpPreparesOnce() async throws { let mic = MicCapture(deviceSelection: { .systemDefault }) #expect(await mic.canPrepareWarmRecorder) await mic.warmUp() #expect(await mic.hasWarmRecorder) - // The slot is taken, so it is no longer safe to open the input for another. + // The slot is taken, so a second build has nothing to do. #expect(await mic.canPrepareWarmRecorder == false) // A second warm-up — e.g. a scheduled re-warm that lost its race with a - // launch-time warmUp — must not stack a second open recorder onto the input. - let generation = await mic.preparedGeneration + // launch-time warmUp — must not replace the recorder already built. + let identity = await mic.warmRecorderIdentity await mic.warmUp() - #expect(await mic.preparedGeneration == generation) + #expect(await mic.warmRecorderIdentity == identity) await mic.discardWarmRecorder() } @@ -54,8 +49,8 @@ struct MicCaptureWarmTests { func warmUpRefusedDuringBringUp() async throws { // The regression `bringingUpCapture` exists for: across `start()`'s liveness // wait both `activeRecorder` and `warm` are nil, so a guard reading only - // those would call a live capture "idle" and prepare a second recorder onto - // the already-open input. + // those would call a live capture "idle" and build a recorder the press is + // about to race. let mic = MicCapture(deviceSelection: { .systemDefault }) await mic.setBringingUpCapture(true) await mic.warmUp() @@ -68,7 +63,7 @@ struct MicCaptureWarmTests { await mic.discardWarmRecorder() } - @Test("a warm recorder is reused only while still bound to the live default input") + @Test("a warm recorder is reused only while still bound to the live resolved input") func warmRecorderReusedWhenDeviceUnchanged() async throws { let mic = MicCapture(deviceSelection: { .systemDefault }) try await mic.installWarmRecorder(boundTo: builtIn) @@ -87,8 +82,7 @@ struct MicCaptureWarmTests { func warmRecorderDiscardedOnDeviceChangeOrUnknown() async throws { // Reuse requires *positively* confirming the device is unchanged. A recorder // bound to the wrong device doesn't fail loudly — it records the wrong mic, - // or silence — so unknown means discard: paying route activation again is - // the cheaper mistake. + // or silence — so unknown means discard: rebuilding is the cheaper mistake. let mic = MicCapture(deviceSelection: { .systemDefault }) // The user connected their AirPods after the warm-up. @@ -111,7 +105,7 @@ struct MicCaptureWarmTests { // resolves to: even when both selections currently resolve to the same // device, a recorder warmed un-pinned follows a later default switch where // a pinned one must not — so a match on device ID alone would reuse the - // wrong backend. + // wrong binding. let mic = MicCapture(deviceSelection: { .systemDefault }) // Warmed while following the system default; the press arrives pinned. @@ -127,42 +121,20 @@ struct MicCaptureWarmTests { #expect(await mic.takeWarm(matching: builtIn, pinnedUID: "uid:test")) } - @Test("an expiry with a stale generation ticket leaves a later warm recorder alone") - func staleExpiryTicketDoesNothing() async throws { - // Cancellation alone doesn't cover this: an expiry already past its - // cancellation check still gets its actor turn, and without the ticket it - // would tear down a recorder prepared a moment ago. - let mic = MicCapture(deviceSelection: { .systemDefault }) - try await mic.installWarmRecorder(boundTo: builtIn) - let generation = await mic.preparedGeneration - - await mic.releasePreparedRecorder(generation: generation - 1) - #expect(await mic.hasWarmRecorder) - - // The live ticket is what tears the idle recorder down, freeing the input. - await mic.releasePreparedRecorder(generation: generation) - #expect(await mic.hasWarmRecorder == false) - } - - @Test("a scheduled re-warm eventually prepares a recorder on its own turn") + @Test("a scheduled re-warm eventually builds a recorder on its own turn") func scheduledRewarmPreparesARecorder() async throws { - // `stop()`/`cancelCapture()` schedule rather than prepare inline because - // preparing re-opens the input — the slow part — and both sit on paths the - // user is waiting behind. All this can pin deterministically is the other - // half of that contract: the scheduled task does land, and prepares. - // Polled on a deadline with a real sleep between reads, NOT a bare - // `Task.yield()` spin. `scheduleRewarm` hands the work to a child task that - // needs a cooperative thread to run on, and `warmUp()` then blocks that - // thread inside CoreAudio — so a hot spin here competes with the very task - // it is waiting for, and on a machine where the recorder never materialises - // it never terminates at all. Sleeping yields the thread outright, and the - // deadline turns "never landed" into a failed expectation rather than a hang - // the suite's time limit has to clean up. + // `stop()`/`cancelCapture()` schedule rather than build inline because the + // caller sits on a path the user is waiting behind. All this can pin + // deterministically is the other half of that contract: the scheduled task + // does land, and builds. Polled on a deadline with a real sleep between + // reads — the scheduled child task needs a cooperative thread, and a hot + // spin here competes with the very task it is waiting for; the deadline + // turns "never landed" into a failed expectation rather than a hang the + // suite's time limit has to clean up. let mic = MicCapture(deviceSelection: { .systemDefault }) - let before = await mic.preparedGeneration await mic.scheduleRewarm() let deadline = ContinuousClock().now.advanced(by: .seconds(5)) - while await mic.preparedGeneration == before, ContinuousClock().now < deadline { + while await mic.hasWarmRecorder == false, ContinuousClock().now < deadline { try await Task.sleep(for: .milliseconds(10)) } #expect(await mic.hasWarmRecorder) @@ -172,14 +144,20 @@ struct MicCaptureWarmTests { } /// Actor-isolated test seams over `MicCapture`'s internal warm-recorder state. -/// Extensions because the recorder backends are only `@unchecked Sendable` -/// under the capture path's confinement argument, so neither the `warm` slot -/// nor `takeWarmRecorder`'s return should cross the actor boundary into a -/// test — each helper reduces it to a `Sendable` answer on the actor instead. +/// Extensions because the recorder backend is only `@unchecked Sendable` under +/// the capture path's confinement argument, so neither the `warm` slot nor +/// `takeWarmRecorder`'s return should cross the actor boundary into a test — +/// each helper reduces it to a `Sendable` answer on the actor instead. extension MicCapture { /// Whether a warm recorder is currently held. var hasWarmRecorder: Bool { warm != nil } + /// The held warm recorder's identity, so a test can tell "still the same + /// recorder" from "quietly replaced" without taking it out of the slot. + var warmRecorderIdentity: ObjectIdentifier? { + warm.map { ObjectIdentifier($0.recorder) } + } + /// Opens or closes the bring-up window `canPrepareWarmRecorder` guards on, /// standing in for a `start()` suspended in its liveness wait (which needs /// real hardware to reach). @@ -190,19 +168,18 @@ extension MicCapture { /// Installs a warm recorder bound to a *known* input (and pin) — /// `prepareWarmRecorder` with the resolution read replaced by `input` / /// `pinnedUID`, so the identity tests don't depend on what the test machine's - /// routing happens to answer. + /// routing happens to answer. The recorder itself is a real (idle) session on + /// the default device; only the recorded identity is synthetic. func installWarmRecorder( boundTo input: AudioRoute.InputSnapshot?, pinnedUID: String? = nil ) throws { - preparedGeneration += 1 warm = WarmRecorder( - recorder: try WAVFileRecorder(), input: input, pinnedUID: pinnedUID, - generation: preparedGeneration) + recorder: try CaptureSessionRecorder(pinnedUID: nil), input: input, pinnedUID: pinnedUID) } /// Consumes the warm slot through the production validation, reporting whether - /// the recorder was reusable. A reused recorder's temp file is cleaned up here, - /// the job `start()` would otherwise inherit; the discard path already does so. + /// the recorder was reusable. A reused recorder is torn down here, the job + /// `start()` would otherwise inherit; the discard path already does so. func takeWarm(matching input: AudioRoute.InputSnapshot?, pinnedUID: String? = nil) -> Bool { let resolved = ResolvedInput(input: input, pinnedUID: pinnedUID) guard let recorder = takeWarmRecorder(matching: resolved) else { return false } @@ -210,10 +187,9 @@ extension MicCapture { return true } - /// Test teardown: drops the warm recorder (and its expiry countdown) so a - /// finished test doesn't leave a 60 s expiry task holding the suite's actor. - /// Through `takeWarmRecorder` — which cancels the expiry — rather than a bare - /// `warm = nil`, so teardown can't diverge from how production empties the slot. + /// Test teardown: drops the warm recorder. Through `takeWarmRecorder` rather + /// than a bare `warm = nil`, so teardown can't diverge from how production + /// empties the slot. func discardWarmRecorder() { _ = takeWarmRecorder(matching: ResolvedInput(input: nil, pinnedUID: nil)) } diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index 88d5580a..da1ab044 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -110,7 +110,7 @@ SCOPES=( "$ENGINE $APP" ) ADVICE=( - "MicCapture uses a fresh AVAudioRecorder per session — a long-lived engine goes stale on a device switch" + "MicCapture builds a fresh AVCaptureSession recorder per capture — a long-lived engine goes stale on a device switch" "the dictation API returns the full text in one response; there is no streaming path" "cleanup is the API's server-side rewrite via the llm block on the same /transcribe call" "transcription is a remote AssemblyAI call — no on-device ASR/LLM, no model cache" diff --git a/scripts/check.sh b/scripts/check.sh index 33cc70ee..b3fb2a37 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -600,25 +600,26 @@ else command -v python3 >/dev/null 2>&1 || die_check "python3 not found — needed to read the coverage summary" # Exclusions (so the figure reflects deterministically-testable engine code): # - Tests/ : test files themselves, not shipping code. - # - MicCapture.swift : the AVAudioRecorder capture actor. It needs a real - # audio device, so it can't run in CI (its integration - # test, MicCaptureLevelsTests, is env-gated for the same + # - MicCapture.swift : the capture actor. It needs a real audio device, so + # it can't run in CI (its integration test, + # MicCaptureLevelsTests, is env-gated for the same # reason). Its pure meter math lives in # MicCapture+Meter.swift, which IS covered. Keep this # list tight — exclude only code that genuinely cannot # be exercised without hardware. # - MicCapture+Warm.swift : the same actor's warm-recorder lifecycle, split # out of MicCapture.swift only for the lint file-length - # budget. Every path through it runs makeRecorder(), - # i.e. prepareToRecord() — the route-activation call, the - # one thing here that genuinely needs a device. Unlike - # +Meter (pure math, covered), splitting this out moved - # hardware-bound code onto the counted side by accident: - # the pattern above pins a literal filename. Trying to - # cover it in CI didn't merely fail, it deadlocked the - # whole test run — prepareToRecord blocks its thread - # instead of suspending, so concurrent attempts drained - # the cooperative pool. Its suite is env-gated alongside + # budget. Every path through it builds a real capture + # session with a real device input — the one thing here + # that genuinely needs a device (and mic authorization). + # Unlike +Meter (pure math, covered), splitting this out + # moved hardware-bound code onto the counted side by + # accident: the pattern above pins a literal filename. + # Trying to cover its predecessor in CI didn't merely + # fail, it deadlocked the whole test run — the + # route-activation call blocked its thread instead of + # suspending, so concurrent attempts drained the + # cooperative pool. Its suite is env-gated alongside # MicCaptureLevelsTests. The transport and liveness # *policy* it consults stays covered, in AudioTransport # and MicLiveness. @@ -634,17 +635,15 @@ else # the same justification as AudioRoute.swift. The pure # selection/fallback rules it serves (MicDeviceSelection, # MicDeviceStore) stay covered. - # - CaptureRecorder.swift / PinnedAudioQueueRecorder.swift : the two capture - # backends behind MicCapture's recorder seam — the WAV - # recorder construction (prepareToRecord, the - # route-activation call: see MicCapture+Warm above for - # what covering that in CI did) and the device-pinned - # AudioQueue. Their live suites ride the same env gate - # (MicCaptureLevelsTests, AudioInputDevicesTests); the - # pure decode in CaptureRecorder.swift keeps its - # MicCaptureFormatTests coverage, it just isn't counted. + # - CaptureRecorder.swift / CaptureSessionRecorder.swift : the recorder seam + # (a protocol plus the factory that builds the real + # backend) and the AVCaptureSession recorder behind it — + # constructing a session around a real device input and + # running it is hardware through and through. The live + # suites ride the same env gate (MicCaptureLevelsTests, + # AudioInputDevicesTests). COVERAGE="$(xcrun llvm-cov export -summary-only -instr-profile "$PROFDATA" "$XCTEST_BIN" \ - -ignore-filename-regex='Tests/|Audio/MicCapture(\+Warm)?\.swift|Audio/AudioRoute(Monitor)?\.swift|Audio/AudioInputDevices\.swift|Audio/CaptureRecorder\.swift|Audio/PinnedAudioQueueRecorder\.swift' \ + -ignore-filename-regex='Tests/|Audio/MicCapture(\+Warm)?\.swift|Audio/AudioRoute(Monitor)?\.swift|Audio/AudioInputDevices\.swift|Audio/CaptureRecorder\.swift|Audio/CaptureSessionRecorder\.swift' \ | python3 -c 'import sys,json; print(round(json.load(sys.stdin)["data"][0]["totals"]["lines"]["percent"],2))')" echo "engine line coverage: ${COVERAGE}%" if ! awk -v c="$COVERAGE" -v min="$MIN_COVERAGE" 'BEGIN{ exit (c+0 < min+0) }'; then