diff --git a/Resources/Info.plist b/Resources/Info.plist index b126723a..3e3822e5 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -33,6 +33,8 @@ NSMicrophoneUsageDescription Utter needs microphone access to capture voice for transcription. + NSBluetoothAlwaysUsageDescription + Utter connects to a Xiaomi Bluetooth remote so you can use it as a wireless microphone without a separate app. NSSpeechRecognitionUsageDescription Utter uses speech recognition to convert voice to text. NSAppleEventsUsageDescription diff --git a/Resources/OpenType.entitlements b/Resources/OpenType.entitlements index af8d92fd..464a406f 100644 --- a/Resources/OpenType.entitlements +++ b/Resources/OpenType.entitlements @@ -6,6 +6,8 @@ com.apple.security.device.audio-input + com.apple.security.device.bluetooth + com.apple.security.personal-information.speech-recognition com.apple.security.screen-recording diff --git a/Sources/App/AppDelegate+RemoteMic.swift b/Sources/App/AppDelegate+RemoteMic.swift new file mode 100644 index 00000000..3638504e --- /dev/null +++ b/Sources/App/AppDelegate+RemoteMic.swift @@ -0,0 +1,97 @@ +import Combine +import Foundation + +@MainActor +extension AppDelegate { + /// Keeps the wireless-remote Bluetooth link in sync with the setting so the + /// remote is already connected before the first recording starts. + func observeRemoteMicSetting() { + let settings = AppSettings.shared + applyRemoteMicSetting(settings.remoteMicEnabled) + settings.$remoteMicEnabled + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] enabled in + self?.applyRemoteMicSetting(enabled) + } + .store(in: &cancellables) + observeRemoteMicVoiceKey() + } + + /// Enabling or disabling the feature must not leave a recording running. + /// + /// Disabling ends a live session explicitly: the bridge's release callback + /// is suppressed once the setting is off, so this must stop the pipeline + /// itself rather than rely on that callback. + private func applyRemoteMicSetting(_ enabled: Bool) { + guard !enabled else { + RemoteMicCaptureManager.shared.activate() + return + } + let capture = RemoteMicCaptureManager.shared + let decision = RemoteMicShutdownDecision.decide(hasActiveRecording: capture.hasActiveRecording) + remoteMicPendingToken = nil + remoteMicStartTask?.cancel() + remoteMicStartTask = nil + capture.deactivate() + if decision.shouldStopPipeline { + stopRecording() + } + } + + /// The remote's voice key arrives on the ATVV control channel while the + /// feature is active, so it drives the same recording path as the configured + /// hotkey. Holding the key records; releasing it stops. + /// + /// The bridge latches the session synchronously and hands over a token. The + /// pipeline start is asynchronous, so a release that arrives first cancels + /// the pending start instead of being ignored. + private func observeRemoteMicVoiceKey() { + let bridge = XiaomiRemoteMicBridge.shared + bridge.onVoiceKeyPressed = { [weak self] token in + guard let self, AppSettings.shared.remoteMicEnabled else { return } + self.beginRemoteMicSession(token: token) + } + bridge.onVoiceKeyReleased = { [weak self] in + guard let self, AppSettings.shared.remoteMicEnabled else { return } + self.releaseRemoteMicSession() + } + } + + private func beginRemoteMicSession(token: UInt64) { + // A new press supersedes any previous start that is still running. + remoteMicStartTask?.cancel() + remoteMicPendingToken = token + let capture = RemoteMicCaptureManager.shared + remoteMicStartTask = Task { @MainActor [weak self] in + let started = await capture.startSession(token: token) + guard let self, !Task.isCancelled else { return } + // Released or superseded while starting: do not begin recording. + guard self.remoteMicPendingToken == token else { + capture.cancelSession() + return + } + guard started else { + self.remoteMicPendingToken = nil + return + } + // Own the whole pipeline start so a later release can cancel it even + // while it waits for a cold model. The pipeline checks the token + // again before committing, so it never falls back to the system mic. + self.remoteMicStartTask = self.startRecording( + action: .dictation, + remoteSessionToken: token + ) + } + } + + private func releaseRemoteMicSession() { + remoteMicPendingToken = nil + remoteMicStartTask?.cancel() + remoteMicStartTask = nil + RemoteMicReleaseDecision.applyRelease( + to: RemoteMicCaptureManager.shared, + stopPipeline: { [weak self] in self?.stopRecording() } + ) + } +} diff --git a/Sources/App/OpenTypeApp.swift b/Sources/App/OpenTypeApp.swift index 05851be4..19b991fb 100644 --- a/Sources/App/OpenTypeApp.swift +++ b/Sources/App/OpenTypeApp.swift @@ -35,6 +35,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { var integrationXPCServer: IntegrationXPCServer? var integrationHTTPPort: Int? var integrationHTTPToken: String? + /// Latched voice-key session whose asynchronous start is in flight. + var remoteMicPendingToken: UInt64? + var remoteMicStartTask: Task? override init() { let registry = IntegrationClientRegistry() @@ -56,6 +59,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { observeSystemAppearanceForIcon() observeUILanguageForSettingsWindow() observeIntegrationSettings() + observeRemoteMicSetting() configureIntegrationHTTPServer() configureIntegrationXPCServer() @@ -67,6 +71,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func applicationWillTerminate(_ notification: Notification) { stopIntegrationHTTPServer(resetService: true) + RemoteMicCaptureManager.shared.deactivate() } private func setupMenuBar() { @@ -137,20 +142,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } - private func startRecording(action: HotkeyAction) { + /// Starts a recording and returns the task that owns the whole pipeline + /// start, so a caller that may need to cancel a slow start (the remote voice + /// key) can actually cancel it instead of only the layer above. + @discardableResult + func startRecording( + action: HotkeyAction, + remoteSessionToken: UInt64? = nil + ) -> Task? { if integrationSessionCoordinator.isBusy { pipeline?.showBusyHint() - return + return nil } savePreviousApp() if popover.isShown { closePopover() } let mode: VoiceInputMode = action == .translation ? .translation(AppSettings.shared.translationTargetLanguage) : .dictation - Task { await pipeline?.start(mode: mode, targetApp: previousApp) } + return Task { + await pipeline?.start( + mode: mode, + targetApp: previousApp, + remoteSessionToken: remoteSessionToken + ) + } } - private func stopRecording() { + func stopRecording() { Task { await pipeline?.stop(targetApp: previousApp) } } diff --git a/Sources/App/VoicePipeline.swift b/Sources/App/VoicePipeline.swift index 372b7ce6..436009e4 100644 --- a/Sources/App/VoicePipeline.swift +++ b/Sources/App/VoicePipeline.swift @@ -5,7 +5,11 @@ import AppKit final class VoicePipeline { let appState: AppState let soundPlayer = SoundPlayer() - let audioCapture = AudioCaptureManager() + let audioCapture: AudioCaptureManager = { + let capture = AudioCaptureManager() + capture.remoteMicSource = .shared + return capture + }() let textInserter = TextInserter() let correctionCapture = CorrectionCaptureService() let textProcessor: TextProcessor @@ -23,8 +27,22 @@ final class VoicePipeline { var formattingModelLifecycleTask: Task? var recordingTargetApp: NSRunningApplication? var formattingPreloadGeneration = 0 + /// Injectable engine used by tests to drive the real `start` await through a + /// controlled model-load barrier. `nil` in production. + var engineOverride: (any SpeechEngine)? + /// Injectable capture used by tests to observe whether a recording began. + var captureOverride: AudioCaptureManager? + + /// The capture the pipeline uses; tests inject a spy. + var activeCapture: AudioCaptureManager { captureOverride ?? audioCapture } + /// Test-only stand-in for a slow model load, awaited before the readiness + /// check so a counterexample can release the key mid-start. + var engineLoadBarrier: (() async -> Void)? + /// Test-only observation point for whether the remote capture path is used. + var remoteCaptureSpy: RemoteMicCaptureSpy? var currentEngine: (any SpeechEngine)? { + if let engineOverride { return engineOverride } switch appState.settings.speechEngine { case .whisper: return whisperEngine case .apple: return appleSpeechEngine @@ -82,7 +100,8 @@ final class VoicePipeline { func start( mode: VoiceInputMode = .dictation, - targetApp: NSRunningApplication? = nil + targetApp: NSRunningApplication? = nil, + remoteSessionToken: UInt64? = nil ) async { if appState.isBusy { Log.info("[VoicePipeline] start: busy (\(appState.phase)), ignoring") @@ -94,10 +113,29 @@ final class VoicePipeline { correctionCapture.finishCurrentSession() + if let engineLoadBarrier { + await engineLoadBarrier() + } + if !(currentEngine?.isReady ?? false) { await ensureEngineLoaded(requestPermission: true) } + // Model loading above can take a while. A remote voice-key session may + // have been released meanwhile; never commit that start (and never fall + // back to the system microphone for a key the user already let go). + if let remoteSessionToken, + !RemoteMicStartGuard.shouldCommit( + remoteSessionToken: remoteSessionToken, + isCancelled: Task.isCancelled, + isSessionCurrent: XiaomiRemoteMicBridge.isSessionCurrent + ) { + Log.info("[VoicePipeline] start: remote session superseded before commit; aborting") + currentEngine?.cancelListening() + cancelScreenContextCapture() + return + } + guard currentEngine?.isReady ?? false else { let message = appState.statusMessage == L("pipeline.speech_model_download_required") ? appState.statusMessage @@ -151,9 +189,25 @@ final class VoicePipeline { } } - audioCapture.thresholds = appState.settings.audioActivityThresholds + if let remoteCaptureSpy { + let token = remoteCaptureSpy.currentToken + let startedSpy = token.map { remoteCaptureSpy.start(token: $0) } ?? false + guard startedSpy else { + currentEngine?.cancelListening() + cancelScreenContextCapture() + recordingTargetApp = nil + appState.phase = .error(L("pipeline.mic_failed_permissions")) + appState.statusMessage = L("pipeline.mic_unavailable") + overlay.hide() + return + } + commitRecording(mode: mode, targetApp: targetApp) + return + } + + activeCapture.thresholds = appState.settings.audioActivityThresholds - let micStarted = audioCapture.start( + let micStarted = activeCapture.start( deviceID: micID, levelUpdate: { [weak self] level in Task { @MainActor in @@ -176,6 +230,16 @@ final class VoicePipeline { } } + /// Marks the pipeline as committed and recording. Extracted so the + /// test-only capture seam and the real capture path share one commit point. + private func commitRecording(mode: VoiceInputMode, targetApp: NSRunningApplication?) { + appState.phase = .recording + appState.statusMessage = mode.isTranslation + ? L("pipeline.recording_translation") + : L("pipeline.recording") + recordingTargetApp = targetApp + } + func stop(targetApp: NSRunningApplication? = nil) async { guard appState.isRecording else { Log.info("[VoicePipeline] stop: not recording (\(appState.phase)), ignoring") @@ -185,14 +249,14 @@ final class VoicePipeline { let resolvedTargetApp = targetApp ?? recordingTargetApp recordingTargetApp = nil soundPlayer.playStop() - audioCapture.stop() + activeCapture.stop() appState.phase = .transcribing appState.statusMessage = L("pipeline.transcribing") let language = appState.settings.inputLanguage.whisperCode - let audioURL = audioCapture.lastRecordingURL - let audioActivity = audioCapture.lastActivity + let audioURL = activeCapture.lastRecordingURL + let audioActivity = activeCapture.lastActivity let settings = appState.settings let inputMode = appState.activeInputMode diff --git a/Sources/Audio/AudioCaptureManager.swift b/Sources/Audio/AudioCaptureManager.swift index 62aaa242..e1ea9389 100644 --- a/Sources/Audio/AudioCaptureManager.swift +++ b/Sources/Audio/AudioCaptureManager.swift @@ -44,20 +44,38 @@ struct AudioCaptureActivity: Equatable { final class AudioCaptureManager { private let engine = AVAudioEngine() private var audioFile: AVAudioFile? - private(set) var lastRecordingURL: URL? - private(set) var lastActivity = AudioCaptureActivity() + private var localLastRecordingURL: URL? + private var localLastActivity = AudioCaptureActivity() /// Thresholds used for the next recording. Set from user sensitivity /// presets before `start(...)`; defaults preserve prior behavior. var thresholds = AudioActivityThresholds.default + /// When set and enabled in settings, the connected wireless remote supplies + /// the audio instead of a CoreAudio input device. + var remoteMicSource: RemoteMicCaptureManager? + private var usesRemoteMic = false private var levelCallback: ((Float) -> Void)? private var bufferCallback: ((AVAudioPCMBuffer) -> Void)? private var isRunning = false + var lastRecordingURL: URL? { + usesRemoteMic ? remoteMicSource?.lastRecordingURL : localLastRecordingURL + } + + var lastActivity: AudioCaptureActivity { + usesRemoteMic + ? (remoteMicSource?.lastActivity ?? AudioCaptureActivity(thresholds: thresholds)) + : localLastActivity + } + func cleanupLastRecording() { - guard let url = lastRecordingURL else { return } + if usesRemoteMic { + remoteMicSource?.cleanupLastRecording() + return + } + guard let url = localLastRecordingURL else { return } try? FileManager.default.removeItem(at: url) - lastRecordingURL = nil + localLastRecordingURL = nil } @discardableResult @@ -68,10 +86,23 @@ final class AudioCaptureManager { ) -> Bool { if isRunning { stop() } cleanupLastRecording() - lastActivity = AudioCaptureActivity(thresholds: thresholds) + usesRemoteMic = false + localLastActivity = AudioCaptureActivity(thresholds: thresholds) levelCallback = levelUpdate bufferCallback = bufferUpdate + if AppSettings.shared.remoteMicEnabled, + let remoteMicSource, + let token = remoteMicSource.currentSessionToken { + remoteMicSource.thresholds = thresholds + if remoteMicSource.start(token: token, levelUpdate: levelUpdate, bufferUpdate: bufferUpdate) { + usesRemoteMic = true + isRunning = true + return true + } + Log.info("[AudioCapture] wireless remote unavailable; using the system input") + } + let authStatus = AVCaptureDevice.authorizationStatus(for: .audio) guard authStatus == .authorized else { Log.error("[AudioCapture] microphone not authorized (status: \(authStatus.rawValue))") @@ -91,7 +122,7 @@ final class AudioCaptureManager { let url = FileManager.default.temporaryDirectory .appendingPathComponent("opentype_recording_\(UUID().uuidString).wav") - lastRecordingURL = url + localLastRecordingURL = url do { audioFile = try AVAudioFile( @@ -110,7 +141,7 @@ final class AudioCaptureManager { try? self.audioFile?.write(from: buffer) let rms = Self.calculateRMS(buffer: buffer) - self.lastActivity.record(rms: rms, frameCount: Int(buffer.frameLength)) + self.localLastActivity.record(rms: rms, frameCount: Int(buffer.frameLength)) let level = Self.visualLevel(fromRMS: rms) self.levelCallback?(level) @@ -139,6 +170,13 @@ final class AudioCaptureManager { func stop() { guard isRunning else { return } + if usesRemoteMic { + remoteMicSource?.stop() + levelCallback = nil + bufferCallback = nil + isRunning = false + return + } engine.inputNode.removeTap(onBus: 0) engine.stop() audioFile = nil diff --git a/Sources/Config/AppSettings.swift b/Sources/Config/AppSettings.swift index 48156c43..b685002f 100644 --- a/Sources/Config/AppSettings.swift +++ b/Sources/Config/AppSettings.swift @@ -26,6 +26,8 @@ final class AppSettings: ObservableObject { @Published var whisperModel: String @Published var llmModel: String @Published var microphoneID: String? + @Published var remoteMicEnabled: Bool + @Published var remoteMicGainDB: Double @Published var audioGateSensitivity: AudioSensitivity @Published var audioWeakSpeechSensitivity: AudioSensitivity @Published var outputMode: OutputMode @@ -77,7 +79,7 @@ final class AppSettings: ObservableObject { private enum Key: String { case hotkeyType, translationHotkeyModifier, activationMode, tapInterval, speechEngine, whisperModel, llmModel - case microphoneID, outputMode, languageStyle, customStylePrompt, playSounds + case microphoneID, remoteMicEnabled, remoteMicGainDB, outputMode, languageStyle, customStylePrompt, playSounds case audioGateSensitivity, audioWeakSpeechSensitivity case enableStreamingRecognitionBeta case inputLanguage, translationTargetLanguage @@ -130,6 +132,9 @@ final class AppSettings: ObservableObject { whisperModel = ud.string(forKey: Key.whisperModel.rawValue) ?? "large-v3" llmModel = ud.string(forKey: Key.llmModel.rawValue) ?? Self.defaultLLMModelID microphoneID = ud.string(forKey: Key.microphoneID.rawValue) + remoteMicEnabled = ud.bool(forKey: Key.remoteMicEnabled.rawValue) + let savedGain = ud.double(forKey: Key.remoteMicGainDB.rawValue) + remoteMicGainDB = savedGain == 0 ? RemoteMicProtocol.defaultGainDB : min(24, max(0, savedGain)) audioGateSensitivity = AudioSensitivity( rawValue: ud.string(forKey: Key.audioGateSensitivity.rawValue) ?? "" ) ?? .standard @@ -217,6 +222,8 @@ final class AppSettings: ObservableObject { $whisperModel.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.whisperModel.rawValue) }.store(in: &cancellables) $llmModel.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.llmModel.rawValue) }.store(in: &cancellables) $microphoneID.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.microphoneID.rawValue) }.store(in: &cancellables) + $remoteMicEnabled.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.remoteMicEnabled.rawValue) }.store(in: &cancellables) + $remoteMicGainDB.dropFirst().sink { [defaults] in defaults.set($0, forKey: Key.remoteMicGainDB.rawValue) }.store(in: &cancellables) $audioGateSensitivity.dropFirst().sink { [defaults] in defaults.set($0.rawValue, forKey: Key.audioGateSensitivity.rawValue) }.store(in: &cancellables) diff --git a/Sources/Integration/InputSessionCoordinator.swift b/Sources/Integration/InputSessionCoordinator.swift index 292b635c..8b2df6da 100644 --- a/Sources/Integration/InputSessionCoordinator.swift +++ b/Sources/Integration/InputSessionCoordinator.swift @@ -35,6 +35,7 @@ final class InputSessionCoordinator { isUserWorkflowBusy: @escaping @MainActor () -> Bool = { false } ) { self.service = service + audioCapture.remoteMicSource = .shared self.audioCapture = audioCapture self.engineProvider = engineProvider ?? SpeechEngineProvider() self.textProcessor = textProcessor diff --git a/Sources/RemoteMic/RemoteMicCaptureManager.swift b/Sources/RemoteMic/RemoteMicCaptureManager.swift new file mode 100644 index 00000000..612609cb --- /dev/null +++ b/Sources/RemoteMic/RemoteMicCaptureManager.swift @@ -0,0 +1,204 @@ +import AVFoundation +import Foundation + +/// Capture source backed by the wireless remote's ATVV audio stream. +/// +/// It mirrors `AudioCaptureManager`'s recording surface (activity, level +/// callback, streamed buffers, temp WAV) so the voice pipeline can swap sources +/// without knowing where the samples came from. Samples arrive as 16 kHz mono +/// Int16 and are written as 16 kHz mono Float32, which is the format every +/// speech engine normalizes to anyway. +final class RemoteMicCaptureManager { + static let shared = RemoteMicCaptureManager() + + private let bridge: XiaomiRemoteMicBridge + private let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + + private(set) var lastRecordingURL: URL? + private(set) var lastActivity = AudioCaptureActivity() + var thresholds = AudioActivityThresholds.default + + private var audioFile: AVAudioFile? + private var levelCallback: ((Float) -> Void)? + private var bufferCallback: ((AVAudioPCMBuffer) -> Void)? + private(set) var isRunning = false + + init(bridge: XiaomiRemoteMicBridge = .shared) { + self.bridge = bridge + } + + var isAvailable: Bool { bridge.state.isReady } + var state: RemoteMicBridgeState { bridge.state } + /// The latched voice-key session this source would commit, if any. + var currentSessionToken: UInt64? { bridge.currentSessionToken } + + func activate() { + bridge.activate() + } + + func deactivate() { + bridge.deactivate() + } + + func cleanupLastRecording() { + guard let url = lastRecordingURL else { return } + try? FileManager.default.removeItem(at: url) + lastRecordingURL = nil + } + + /// Prepares capture for a latched voice-key session. + /// + /// Returns `false` when the session was released or the remote is not usable, + /// in which case the caller must not record. The preparatory work (temp file, + /// readiness) is synchronous today, but is awaited so a future model load on + /// this path does not change the caller's contract. + func startSession(token: UInt64) async -> Bool { + prepareCapture(token: token) + } + + /// Abandons an in-flight or latched session, releasing every trace so the + /// pipeline can fall back or stay idle without a latent want. + /// + /// This discards the recording, so it must only be used *before* the + /// pipeline commits to recording — never on a normal stop, where the WAV is + /// still needed for transcription. Use `stop()` for a committed recording. + func cancelSession() { + guard isRunning || bridge.isSessionLive else { return } + tearDownFailedStart() + } + + /// True when capture has committed and a recording file exists. + var hasActiveRecording: Bool { isRunning && audioFile != nil } + + /// The synchronous startup body shared by the session and direct paths. + @discardableResult + func prepareCapture(token: UInt64) -> Bool { + if isRunning { stop() } + cleanupLastRecording() + lastActivity = AudioCaptureActivity(thresholds: thresholds) + levelCallback = nil + bufferCallback = nil + return true + } + + /// Starts a capture for the latched voice-key session. + /// + /// `token` is the latch the caller observed; if the session was released or + /// superseded while the pipeline was starting, this returns `false` and the + /// caller must not begin recording (and must not fall back either, because + /// the user already let go). + @discardableResult + func start( + token: UInt64, + levelUpdate: @escaping (Float) -> Void, + bufferUpdate: ((AVAudioPCMBuffer) -> Void)? = nil + ) -> Bool { + if !bridge.state.isReady { bridge.activate() } + guard bridge.state.isReady else { return false } + + if isRunning { stop() } + cleanupLastRecording() + lastActivity = AudioCaptureActivity(thresholds: thresholds) + levelCallback = levelUpdate + bufferCallback = bufferUpdate + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("opentype_remotemic_\(UUID().uuidString).wav") + do { + audioFile = try AVAudioFile( + forWriting: url, + settings: format.settings, + commonFormat: format.commonFormat, + interleaved: format.isInterleaved + ) + } catch { + Log.error("[RemoteMic] cannot create recording: \(error.localizedDescription)") + return false + } + lastRecordingURL = url + + bridge.onSamples = { [weak self] samples in + self?.ingest(samples) + } + + // Commits the latched session and hands back any audio buffered before + // the pipeline was ready. If the session was released meanwhile, undo + // everything so a later readiness cannot adopt it. + guard let preRolled = bridge.beginCapture(token: token) else { + tearDownFailedStart() + return false + } + isRunning = true + if !preRolled.isEmpty { + ingest(preRolled) + } + return true + } + + /// Releases every trace of an attempted start so the bridge cannot adopt a + /// session the caller has already replaced with the system input. + private func tearDownFailedStart() { + bridge.onSamples = nil + bridge.endCapture() + audioFile = nil + lastRecordingURL = nil + levelCallback = nil + bufferCallback = nil + isRunning = false + } + + func stop() { + guard isRunning else { return } + isRunning = false + bridge.endCapture() + bridge.onSamples = nil + audioFile = nil + levelCallback = nil + bufferCallback = nil + } + + /// Mirrors `AudioCaptureManager.lastActivity` semantics: an empty session + /// must not report meaningful audio. + var hasRecordedActivity: Bool { lastActivity.frameCount > 0 } + + private func ingest(_ samples: [Int16]) { + guard isRunning, !samples.isEmpty, + let buffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(samples.count) + ) else { return } + buffer.frameLength = AVAudioFrameCount(samples.count) + if let channel = buffer.floatChannelData?[0] { + for index in samples.indices { + channel[index] = Float(samples[index]) / 32_768.0 + } + } + + try? audioFile?.write(from: buffer) + + let rms = Self.rms(of: buffer) + lastActivity.record(rms: rms, frameCount: Int(buffer.frameLength)) + levelCallback?(Self.visualLevel(fromRMS: rms)) + if let bufferCallback, let copied = buffer.copied() { + bufferCallback(copied) + } + } + + private static func rms(of buffer: AVAudioPCMBuffer) -> Float { + let count = Int(buffer.frameLength) + guard count > 0, let channel = buffer.floatChannelData?[0] else { return 0 } + var sum: Float = 0 + for index in 0.. Float { + let db = 20 * log10(max(rms, 1e-6)) + return max(min((db + 50) / 50, 1.0), 0.0) + } +} diff --git a/Sources/RemoteMic/RemoteMicCaptureSpy.swift b/Sources/RemoteMic/RemoteMicCaptureSpy.swift new file mode 100644 index 00000000..ade4c586 --- /dev/null +++ b/Sources/RemoteMic/RemoteMicCaptureSpy.swift @@ -0,0 +1,15 @@ +import AVFoundation +import Foundation + +/// Test-only observation point for the pipeline's remote-capture path. +/// +/// `AudioCaptureManager` is final, so a counterexample that must prove "capture +/// was never reached" injects this instead of subclassing it. Production never +/// sets it. +@MainActor +protocol RemoteMicCaptureSpy: AnyObject { + /// The latch the pipeline would use, or nil when no session is live. + var currentToken: UInt64? { get } + /// Records a capture start; returns whether it succeeded. + func start(token: UInt64) -> Bool +} diff --git a/Sources/RemoteMic/RemoteMicHandshake.swift b/Sources/RemoteMic/RemoteMicHandshake.swift new file mode 100644 index 00000000..086df826 --- /dev/null +++ b/Sources/RemoteMic/RemoteMicHandshake.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Pure gate for the ATVV handshake: which subscriptions are confirmed, whether +/// the capability request may be sent, and whether the connection is usable. +/// +/// Kept free of CoreBluetooth so the ordering rules are unit-testable: the host +/// must not request capabilities before **both** the audio and control +/// notifications are confirmed, and readiness requires a parsed 16 kHz +/// capability response. +struct RemoteMicHandshake: Equatable { + /// Identity of the connection attempt this handshake belongs to. CoreBluetooth + /// may deliver a queued callback for a previous attempt on the same + /// `CBPeripheral` object after a reconnect; stamping each callback with the + /// attempt it belongs to is the only way to reject it once the new attempt + /// has already requested capabilities. + private(set) var attempt: UInt64 = 0 + private(set) var hasTransmit = false + private(set) var subscriptions: Set = [] + private(set) var capabilitiesRequested = false + private(set) var capabilitiesConfirmed = false + + var hasAllCharacteristics: Bool { + hasTransmit && subscriptionsReady + } + + var subscriptionsReady: Bool { + subscriptions.contains(.audio) && subscriptions.contains(.control) + } + + /// True when every characteristic has been discovered. + mutating func registerCharacteristic(_ kind: CharacteristicKind) { + switch kind { + case .transmit: hasTransmit = true + case .audio: break + case .control: break + } + } + + /// Records a confirmed notification subscription. + mutating func confirmSubscription(_ subscription: RemoteMicSubscription) { + subscriptions.insert(subscription) + } + + /// Starts a new attempt. Every callback carries the attempt it was raised + /// for; a mismatch means the callback belongs to a superseded connection. + mutating func beginAttempt(_ attempt: UInt64) { + self = RemoteMicHandshake(attempt: attempt) + } + + /// True when `attempt` is the connection this handshake is tracking. + func accepts(_ attempt: UInt64) -> Bool { + attempt == self.attempt + } + + /// True exactly when the capability request should be written: all + /// characteristics known, both notifications confirmed, and not yet sent. + var shouldRequestCapabilities: Bool { + hasAllCharacteristics && subscriptionsReady && !capabilitiesRequested + } + + /// Reserves the request so it is only written once per attempt. + mutating func markCapabilitiesRequested() { + capabilitiesRequested = true + } + + /// Records the capability response. + /// + /// Rejects a response that arrives before this attempt asked for one, and a + /// non-16 kHz codec. The request gate matters: a late capability frame from a + /// previous attempt on a reused peripheral must not mark the new attempt + /// ready. + @discardableResult + mutating func confirmCapabilities(_ capabilities: RemoteMicCapabilities) -> Bool { + guard capabilitiesRequested else { return false } + guard RemoteMicProtocol.supportsAudio(sampleRate: capabilities.sampleRate) else { + return false + } + capabilitiesConfirmed = true + return true + } + + var isReady: Bool { capabilitiesConfirmed } + + mutating func reset() { + self = RemoteMicHandshake(attempt: attempt) + } + + enum CharacteristicKind { + case transmit + case audio + case control + } +} diff --git a/Sources/RemoteMic/RemoteMicPreRoll.swift b/Sources/RemoteMic/RemoteMicPreRoll.swift new file mode 100644 index 00000000..8697f8a6 --- /dev/null +++ b/Sources/RemoteMic/RemoteMicPreRoll.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Holds decoded audio that arrives before the recording pipeline is ready. +/// +/// The remote can deliver `AUDIO_START`/audio frames in the same run loop as the +/// control event, but the pipeline that consumes them starts asynchronously. A +/// bounded pre-roll keeps the first moments so the opening word is not clipped, +/// and drops the oldest data once the bound is hit so a session that never +/// starts cannot grow without limit. +struct RemoteMicPreRoll { + private let capacity: Int + private var chunks: [[Int16]] = [] + private var chunkCount = 0 + + init(capacity: Int = 4) { + self.capacity = max(1, capacity) + } + + var isEmpty: Bool { chunks.isEmpty } + var retainedChunks: Int { chunks.count } + var retainedFrames: Int { chunkCount } + + mutating func append(_ samples: [Int16]) { + guard !samples.isEmpty else { return } + chunks.append(samples) + chunkCount += samples.count + while chunks.count > capacity { + chunkCount -= chunks.removeFirst().count + } + } + + /// Returns the retained audio in order and empties the buffer. + mutating func drain() -> [[Int16]] { + let drained = chunks + chunks.removeAll(keepingCapacity: false) + chunkCount = 0 + return drained + } + + mutating func reset() { + chunks.removeAll(keepingCapacity: false) + chunkCount = 0 + } +} + +/// Where a decoded audio chunk belongs, given the session phase. +/// +/// Extracted so the bridge's routing is the exact rule a test exercises: audio +/// with no live session (before a press, or late after a stop) is dropped so it +/// cannot pollute the next session's pre-roll. +enum RemoteMicAudioRouting { + enum Destination: Equatable { + case forward + case preRoll + case drop + } + + static func destination(for phase: RemoteMicSession.Phase) -> Destination { + switch phase { + case .recording: return .forward + case .starting: return .preRoll + case .idle: return .drop + } + } +} diff --git a/Sources/RemoteMic/RemoteMicProtocol.swift b/Sources/RemoteMic/RemoteMicProtocol.swift new file mode 100644 index 00000000..e6d7b118 --- /dev/null +++ b/Sources/RemoteMic/RemoteMicProtocol.swift @@ -0,0 +1,194 @@ +import Foundation + +/// Audio Transport Voice (ATVV) profile pieces used by the Xiaomi Bluetooth +/// Remote 2 Pro. +/// +/// The GATT service, the control opcodes, and the IMA/DVI ADPCM sample format +/// are open specifications; this is an independent implementation of those +/// specifications for a single 16 kHz mono stream, not a copy of any app. +enum RemoteMicProtocol { + static let serviceUUID = "AB5E0001-5A21-4F05-BC7D-AF01F617B664" + static let transmitUUID = "AB5E0002-5A21-4F05-BC7D-AF01F617B664" + static let audioUUID = "AB5E0003-5A21-4F05-BC7D-AF01F617B664" + static let controlUUID = "AB5E0004-5A21-4F05-BC7D-AF01F617B664" + + static let supportedSampleRate: Double = 16_000 + static let defaultFrameSize = 120 + static let defaultGainDB: Double = 12 + + /// Host -> remote capability request (`GET_CAPABILITIES` for ATVV v1.0). + static let getCapabilities = Data([0x0A, 0x01, 0x00, 0x00, 0x03, 0x03]) + + static func supportsAudio(sampleRate: Double) -> Bool { + sampleRate == supportedSampleRate + } + + static func microphoneOpen(version: UInt16, codec: UInt8) -> Data { + version >= 0x0100 ? Data([0x0C, 0x00]) : Data([0x0C, 0x00, codec]) + } + + static func microphoneClose(version: UInt16, sessionID: UInt8) -> Data { + version >= 0x0100 ? Data([0x0D, sessionID]) : Data([0x0D]) + } +} + +/// Remote capabilities reported by the `0x0B` control response. +struct RemoteMicCapabilities: Equatable { + var version: UInt16 + var codecs: UInt8 + var interaction: UInt8 + var frameSize: Int + var selectedCodec: UInt8 + var sampleRate: Double + + static let `default` = RemoteMicCapabilities( + version: 0x0100, + codecs: 0x02, + interaction: 0x03, + frameSize: RemoteMicProtocol.defaultFrameSize, + selectedCodec: 0x02, + sampleRate: RemoteMicProtocol.supportedSampleRate + ) + + /// Parses the `0x0B` payload. Returns `nil` when it is not a capability frame. + static func parse(_ data: Data) -> RemoteMicCapabilities? { + let bytes = Array(data) + guard bytes.count >= 7, bytes[0] == 0x0B else { return nil } + + let version = UInt16(bytes[1]) << 8 | UInt16(bytes[2]) + var codecs: UInt8 + var interaction: UInt8 + if version >= 0x0100 { + codecs = bytes[3] + interaction = bytes[4] + if codecs == 0, bytes.count >= 9, bytes[4] & 0x03 != 0 { + codecs = bytes[4] + interaction = 0x03 + } + } else { + guard bytes.count >= 9 else { return nil } + codecs = bytes[4] + interaction = 0 + } + + let frameSize = Int(bytes[5]) << 8 | Int(bytes[6]) + let selectedCodec: UInt8 = codecs & 0x02 != 0 ? 0x02 : 0x01 + return RemoteMicCapabilities( + version: version, + codecs: codecs, + interaction: interaction, + frameSize: frameSize == 0 ? RemoteMicProtocol.defaultFrameSize : frameSize, + selectedCodec: selectedCodec, + sampleRate: selectedCodec == 0x02 ? RemoteMicProtocol.supportedSampleRate : 8_000 + ) + } +} + +/// Control opcodes on the ATVV control characteristic. +/// +/// Host -> device: `GET_CAPABILITIES` (0x0A), `MIC_OPEN` (0x0C), `MIC_CLOSE` +/// (0x0D). Device -> host: `AUDIO_STOP` (0x00), `AUDIO_START` (0x04), +/// `START_SEARCH` (0x08), capabilities (0x0B), sync (0x0A). +/// +/// `0x08` is the device's `START_SEARCH`, not a microphone-open request; per the +/// AOSP ATVV reference firmware a PTT press sends `AUDIO_START` (0x04) directly +/// and does not require the host to open the microphone first. The session is +/// therefore latched on `AUDIO_START`. +enum RemoteMicControlOpcode: UInt8 { + case streamStop = 0x00 + case streamStart = 0x04 + case startSearch = 0x08 + case capabilities = 0x0B + case sync = 0x0A +} + +/// Stateful IMA/DVI ADPCM decoder. The remote encodes four-bit nibbles per +/// sample; the sequence's predictor and step index persist across frames and can +/// be reset by a sync packet. +final class RemoteMicADPCMDecoder { + private static let stepTable: [Int] = [ + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, + 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, + 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, + 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, + 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660, 4026, + 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442, + 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, + 27086, 29794, 32767, + ] + private static let indexTable = [-1, -1, -1, -1, 2, 4, 6, 8] + + private(set) var predictor = 0 + private(set) var stepIndex = 0 + + func reset(predictor: Int = 0, stepIndex: Int = 0) { + self.predictor = min(32_767, max(-32_768, predictor)) + self.stepIndex = min(88, max(0, stepIndex)) + } + + /// Decodes high-nibble-first, the RC003/`MI RC` ordering. + func decode(_ data: Data) -> [Int16] { + var samples: [Int16] = [] + samples.reserveCapacity(data.count * 2) + for byte in data { + samples.append(decodeNibble(Int(byte >> 4))) + samples.append(decodeNibble(Int(byte & 0x0F))) + } + return samples + } + + private func decodeNibble(_ nibble: Int) -> Int16 { + let step = Self.stepTable[stepIndex] + var difference = step >> 3 + if nibble & 1 != 0 { difference += step >> 2 } + if nibble & 2 != 0 { difference += step >> 1 } + if nibble & 4 != 0 { difference += step } + + predictor += nibble & 8 != 0 ? -difference : difference + predictor = min(32_767, max(-32_768, predictor)) + stepIndex += Self.indexTable[nibble & 7] + stepIndex = min(88, max(0, stepIndex)) + return Int16(predictor) + } +} + +/// Three-point smoothing plus a bounded gain, applied after decoding. +enum RemoteMicPCM { + static func process(_ input: [Int16], gainDB: Double) -> [Int16] { + guard !input.isEmpty else { return [] } + var filtered = input.map(Int.init) + if input.count >= 3 { + for index in 1..<(input.count - 1) { + filtered[index] = ( + Int(input[index - 1]) + 2 * Int(input[index]) + Int(input[index + 1]) + ) >> 2 + } + } + let finiteGain = gainDB.isFinite ? gainDB : 0 + let gain = pow(10.0, min(24.0, max(-24.0, finiteGain)) / 20.0) + return filtered.map { value in + Int16(min(32_767, max(-32_768, Int((Double(value) * gain).rounded())))) + } + } +} + +/// Reassembles the remote's declared fixed-size audio frames from the byte +/// stream delivered by CoreBluetooth notifications. +struct RemoteMicFrameAccumulator { + private(set) var pending = Data() + + mutating func append(_ data: Data, frameSize: Int) -> [Data] { + guard frameSize > 0 else { return [] } + pending.append(data) + var frames: [Data] = [] + while pending.count >= frameSize { + frames.append(Data(pending.prefix(frameSize))) + pending.removeFirst(frameSize) + } + return frames + } + + mutating func reset() { + pending.removeAll(keepingCapacity: false) + } +} diff --git a/Sources/RemoteMic/RemoteMicReleaseDecision.swift b/Sources/RemoteMic/RemoteMicReleaseDecision.swift new file mode 100644 index 00000000..2c99a568 --- /dev/null +++ b/Sources/RemoteMic/RemoteMicReleaseDecision.swift @@ -0,0 +1,66 @@ +import Foundation + +/// A capture surface the release decision can act on. `AudioCaptureManager` is +/// final, so the decision is expressed against this seam and the production +/// release path applies exactly the same rule to the real manager. +@MainActor +protocol RemoteMicReleaseTarget: AnyObject { + var hasActiveRecording: Bool { get } + var isRunning: Bool { get } + func cancelSession() +} + +extension RemoteMicCaptureManager: RemoteMicReleaseTarget {} + +/// What a remote voice-key release must do, given the capture state. +/// +/// A committed recording must be *stopped* so the pipeline can read its WAV for +/// transcription; cancelling it would nil the file. A start that never committed +/// must be *cancelled* so nothing is recorded. +@MainActor +struct RemoteMicReleaseDecision: Equatable { + let shouldStopPipeline: Bool + let shouldCancelCapture: Bool + + static func decide(hasActiveRecording: Bool, sessionIsLive: Bool) -> RemoteMicReleaseDecision { + guard sessionIsLive || hasActiveRecording else { + return RemoteMicReleaseDecision(shouldStopPipeline: false, shouldCancelCapture: false) + } + return RemoteMicReleaseDecision( + shouldStopPipeline: hasActiveRecording, + shouldCancelCapture: !hasActiveRecording + ) + } + + /// Applies the release to a real target and returns what the caller must do + /// to the pipeline. This is the production entry point, so a counterexample + /// exercises the same wiring the app uses. + @discardableResult + static func applyRelease( + to target: RemoteMicReleaseTarget, + stopPipeline: () -> Void + ) -> RemoteMicReleaseDecision { + let decision = decide( + hasActiveRecording: target.hasActiveRecording, + sessionIsLive: target.isRunning + ) + if decision.shouldCancelCapture { + target.cancelSession() + } + if decision.shouldStopPipeline { + stopPipeline() + } + return decision + } +} + +/// What disabling the feature must do. The bridge's release callback is +/// suppressed once the setting is off, so shutdown must stop the pipeline +/// itself instead of relying on that callback. +struct RemoteMicShutdownDecision: Equatable { + let shouldStopPipeline: Bool + + static func decide(hasActiveRecording: Bool) -> RemoteMicShutdownDecision { + RemoteMicShutdownDecision(shouldStopPipeline: hasActiveRecording) + } +} diff --git a/Sources/RemoteMic/RemoteMicSession.swift b/Sources/RemoteMic/RemoteMicSession.swift new file mode 100644 index 00000000..fa5031ed --- /dev/null +++ b/Sources/RemoteMic/RemoteMicSession.swift @@ -0,0 +1,63 @@ +import Foundation + +/// The remote voice-key session lifecycle: press → start → release → stop. +/// +/// The pipeline that starts recording is asynchronous (it may wait for a model +/// to load), so a `STREAM_STOP` or a disconnect can arrive before recording is +/// actually live. This type latches the intent synchronously and gives the +/// caller a token, so: +/// +/// - a start may only commit once; +/// - a release that arrives before the start commits cancels the pending start +/// instead of being ignored; and +/// - a late start completion for a cancelled generation cannot begin recording. +struct RemoteMicSession: Equatable { + enum Phase: Equatable { + case idle + case starting + case recording + } + + private(set) var phase: Phase = .idle + /// Increments on every press so completions from an older press are stale. + private(set) var generation: UInt64 = 0 + + /// A press: starts a new generation and enters `starting`. + /// Returns the token the async start must present when it completes. + mutating func press() -> UInt64 { + generation &+= 1 + phase = .starting + return generation + } + + /// Commits a pending start. Returns false when the token is stale or the + /// session has moved on, so the caller must not begin recording. + mutating func commitStart(token: UInt64) -> Bool { + guard phase == .starting, token == generation else { return false } + phase = .recording + return true + } + + /// A release. Returns whether this generation was still live, i.e. whether + /// the caller must stop or cancel the in-flight recording. + mutating func release() -> Bool { + switch phase { + case .idle: + return false + case .starting, .recording: + phase = .idle + generation &+= 1 + return true + } + } + + /// A disconnect or feature shutdown behaves like a release. + mutating func invalidate() -> Bool { + release() + } + + /// True while the caller must still act on this generation. + var isLive: Bool { phase != .idle } + var isStarting: Bool { phase == .starting } + var isRecording: Bool { phase == .recording } +} diff --git a/Sources/RemoteMic/RemoteMicStartGuard.swift b/Sources/RemoteMic/RemoteMicStartGuard.swift new file mode 100644 index 00000000..f0f91972 --- /dev/null +++ b/Sources/RemoteMic/RemoteMicStartGuard.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Decides whether a remote voice-key start may commit once the pipeline has +/// finished its potentially slow preparation (engine/model load). +/// +/// Kept pure so the rule is unit-testable and the pipeline's check is the same +/// one the tests exercise: a start whose key was released, or whose task was +/// cancelled, must not begin recording and must not fall back to the system +/// microphone. +enum RemoteMicStartGuard { + /// - Parameters: + /// - remoteSessionToken: the latch the start was created for, or nil for a + /// local (hotkey) start that has no remote latch. + /// - isCancelled: whether the owning task was cancelled. + /// - isSessionCurrent: predicate asking the bridge whether the latch is + /// still live. + static func shouldCommit( + remoteSessionToken: UInt64?, + isCancelled: Bool, + isSessionCurrent: (UInt64) -> Bool + ) -> Bool { + guard let remoteSessionToken else { return true } + guard !isCancelled else { return false } + return isSessionCurrent(remoteSessionToken) + } +} diff --git a/Sources/RemoteMic/RemoteMicWantedState.swift b/Sources/RemoteMic/RemoteMicWantedState.swift new file mode 100644 index 00000000..bfdf3b2d --- /dev/null +++ b/Sources/RemoteMic/RemoteMicWantedState.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Tracks whether a wireless-microphone session currently wants audio, and +/// whether the bridge may act on it. +/// +/// The subtle rule this encodes: a caller that falls back to the system input +/// must leave no trace of wanting the remote. Otherwise a bridge that becomes +/// ready later would open the remote microphone in the middle of a system-input +/// session, and that session's stop would never close it again. +struct RemoteMicWantedState: Equatable { + private(set) var isWanted = false + private(set) var isStreaming = false + + /// True while the bridge should forward audio and may open the microphone. + var isActive: Bool { isWanted || isStreaming } + + mutating func want() { + isWanted = true + } + + /// Releases the want. Returns whether a release actually happened, so the + /// caller can fire "released" once. + @discardableResult + mutating func release() -> Bool { + let had = isWanted + isWanted = false + return had + } + + mutating func beginStreaming() { + isStreaming = true + } + + /// Drops both the want and the stream, returning whether a session was live + /// so the caller can fire "stopped"/"released" once. + @discardableResult + mutating func reset() -> Bool { + let wasLive = isActive + isWanted = false + isStreaming = false + return wasLive + } +} diff --git a/Sources/RemoteMic/XiaomiRemoteMicBridge.swift b/Sources/RemoteMic/XiaomiRemoteMicBridge.swift new file mode 100644 index 00000000..28f615cb --- /dev/null +++ b/Sources/RemoteMic/XiaomiRemoteMicBridge.swift @@ -0,0 +1,1451 @@ +import CoreBluetooth +import Foundation + +enum RemoteMicBridgeState: Equatable { + case idle + case unsupported + case unauthorized + case scanning + case connecting + case ready(deviceName: String) + case failed(reason: String) + + var isReady: Bool { + if case .ready = self { return true } + return false + } + + var summary: String { + switch self { + case .idle: return L("remote_mic.state.idle") + case .unsupported: return L("remote_mic.state.unsupported") + case .unauthorized: return L("remote_mic.state.unauthorized") + case .scanning: return L("remote_mic.state.scanning") + case .connecting: return L("remote_mic.state.connecting") + case let .ready(deviceName): return String(format: L("remote_mic.state.ready"), deviceName) + case let .failed(reason): return String(format: L("remote_mic.state.failed"), reason) + } + } +} + +/// The two CoreBluetooth notifications the ATVV handshake needs before the host +/// may request capabilities. +enum RemoteMicSubscription: Hashable { + case audio + case control +} + +/// The delegate callbacks below do not contain a CoreBluetooth connection id. +/// A proxy is therefore installed for each connection lifecycle and captures +/// the attempt at the point where CoreBluetooth is wired to the bridge. The +/// bridge never looks up an attempt from the current peripheral when routing a +/// callback; the proxy is the callback's source envelope. +enum XiaomiRemoteMicTestCallback: Equatable { + case disconnect + case control(Data) + case audio(Data) +} + +/// Central callbacks have the same source problem as peripheral callbacks: +/// CoreBluetooth supplies the peripheral object, but no connection-attempt id. +/// The production central delegate proxy captures the id when a discovered +/// peripheral is connected. Tests use the same proxy route to deliver a late +/// event from an older central lifecycle. +enum XiaomiRemoteMicCentralTestCallback: Equatable { + case didConnect + case didFailToConnect + case didDisconnect +} + +final class XiaomiRemoteMicPeripheralDelegateProxy: NSObject, CBPeripheralDelegate { + weak var bridge: XiaomiRemoteMicBridge? + let attempt: UInt64 + + init(bridge: XiaomiRemoteMicBridge, attempt: UInt64) { + self.bridge = bridge + self.attempt = attempt + super.init() + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + bridge?.routeDidDiscoverServices(peripheral, error: error, attempt: attempt) + } + + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error? + ) { + bridge?.routeDidDiscoverCharacteristics( + peripheral, + service: service, + error: error, + attempt: attempt + ) + } + + func peripheral( + _ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error? + ) { + bridge?.routeDidUpdateNotificationState( + peripheral, + characteristic: characteristic, + error: error, + attempt: attempt + ) + } + + func peripheral( + _ peripheral: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error? + ) { + bridge?.routeDidUpdateValue( + peripheral, + characteristic: characteristic, + error: error, + attempt: attempt + ) + } + + /// Drives the same bridge route as the CoreBluetooth delegate methods, but + /// without manufacturing CoreBluetooth objects. This is the test entry for + /// a callback that was raised by this particular lifecycle proxy. + func deliverForTesting(_ callback: XiaomiRemoteMicTestCallback) { + bridge?.routeTestCallback(callback, attempt: attempt) + } +} + +/// One central manager is used for one connection lifecycle. A +/// `CBCentralManagerDelegate` callback has no source id, so reusing a manager +/// would make a late callback indistinguishable from the replacement attempt. +/// Keeping this proxy with the manager gives every callback the lifecycle that +/// actually owned that manager. The scan phase is unbound; it is bound exactly +/// when `didDiscover` starts the connection. +final class XiaomiRemoteMicCentralDelegateProxy: NSObject, CBCentralManagerDelegate { + weak var bridge: XiaomiRemoteMicBridge? + private(set) var attempt: UInt64? + private(set) var sourceManagerIdentity: AnyObject? + private(set) var sourcePeripheralIdentity: AnyObject? + + init(bridge: XiaomiRemoteMicBridge) { + self.bridge = bridge + super.init() + } + + func bind(to attempt: UInt64) { + guard self.attempt == nil else { return } + self.attempt = attempt + } + + func bindManagerIdentity(_ identity: AnyObject) { + guard sourceManagerIdentity == nil else { return } + sourceManagerIdentity = identity + } + + func bindPeripheralIdentity(_ identity: AnyObject) { + sourcePeripheralIdentity = identity + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + bindManagerIdentity(central) + bridge?.routeCentralManagerDidUpdateState( + managerIdentity: central, + managerState: central.state, + sourceAttempt: attempt + ) + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + bindManagerIdentity(central) + bindPeripheralIdentity(peripheral) + bridge?.routeCentralDidDiscover( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + advertisementData: advertisementData, + rssi: RSSI, + sourceAttempt: attempt + ) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + bindManagerIdentity(central) + bindPeripheralIdentity(peripheral) + bridge?.routeCentralDidConnect( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + sourceAttempt: attempt + ) + } + + func centralManager( + _ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error? + ) { + bindManagerIdentity(central) + bindPeripheralIdentity(peripheral) + bridge?.routeCentralDidFailToConnect( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + error: error, + sourceAttempt: attempt + ) + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error? + ) { + bindManagerIdentity(central) + bindPeripheralIdentity(peripheral) + bridge?.routeCentralDidDisconnect( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + error: error, + sourceAttempt: attempt + ) + } + + /// Test-only entry that uses the same source-bound route as the delegate + /// methods above, without manufacturing CoreBluetooth objects. + func deliverForTesting(_ callback: XiaomiRemoteMicCentralTestCallback) { + guard let attempt, + let sourceManagerIdentity, + let sourcePeripheralIdentity else { return } + bridge?.routeCentralCallbackForTesting( + callback, + attempt: attempt, + managerIdentity: sourceManagerIdentity, + peripheralIdentity: sourcePeripheralIdentity + ) + } +} + +/// The bridge owns one transport for one central lifecycle. Keeping the +/// transport behind this seam lets tests exercise the production initializer, +/// strong manager/proxy ownership, identity gates, and cancellation completion +/// without manufacturing a CoreBluetooth object. +protocol XiaomiRemoteMicCentralTransport: AnyObject { + var identity: AnyObject { get } + var delegateProxy: XiaomiRemoteMicCentralDelegateProxy { get } + var state: CBManagerState { get } + + func stopScan() + func scanForPeripherals(withServices services: [CBUUID], options: [String: Any]?) + func connect(to peripheral: AnyObject) + func cancel(peripheral: AnyObject) +} + +/// Production transport. The manager and its weak delegate are held together +/// so an old manager can still deliver a terminal callback to its old proxy +/// while retirement is waiting for cancellation to complete. +final class XiaomiRemoteMicCoreBluetoothCentralTransport: XiaomiRemoteMicCentralTransport { + let manager: CBCentralManager + let delegateProxy: XiaomiRemoteMicCentralDelegateProxy + + var identity: AnyObject { manager } + var state: CBManagerState { manager.state } + + init(bridge: XiaomiRemoteMicBridge) { + let proxy = XiaomiRemoteMicCentralDelegateProxy(bridge: bridge) + delegateProxy = proxy + manager = CBCentralManager( + delegate: proxy, + queue: .main, + options: [CBCentralManagerOptionShowPowerAlertKey: true] + ) + proxy.bindManagerIdentity(manager) + } + + func stopScan() { + manager.stopScan() + } + + func scanForPeripherals(withServices services: [CBUUID], options: [String: Any]?) { + manager.scanForPeripherals(withServices: services, options: options) + } + + func connect(to peripheral: AnyObject) { + guard let peripheral = peripheral as? CBPeripheral else { return } + manager.connect(peripheral, options: nil) + } + + func cancel(peripheral: AnyObject) { + guard let peripheral = peripheral as? CBPeripheral else { return } + manager.cancelPeripheralConnection(peripheral) + } +} + +/// CoreBluetooth keeps a peripheral's delegate weak. Keep the old peripheral +/// and its source proxy together until the central's cancellation callback has +/// arrived; otherwise a queued didDisconnect/didFail event can disappear or be +/// delivered to a replacement lifecycle. +private final class XiaomiRemoteMicPeripheralContext { + let identity: AnyObject + let peripheral: CBPeripheral? + let delegateProxy: XiaomiRemoteMicPeripheralDelegateProxy + + init( + identity: AnyObject, + peripheral: CBPeripheral?, + delegateProxy: XiaomiRemoteMicPeripheralDelegateProxy + ) { + self.identity = identity + self.peripheral = peripheral + self.delegateProxy = delegateProxy + } +} + +private enum XiaomiRemoteMicCentralLifecycle: Equatable { + case idle + case scanning(UInt64) + case connecting(UInt64) + case connected(UInt64) + case quiescing(UInt64) + + var attempt: UInt64? { + switch self { + case .idle: return nil + case let .scanning(attempt), let .connecting(attempt), + let .connected(attempt), let .quiescing(attempt): + return attempt + } + } +} + +/// CoreBluetooth central that connects a Xiaomi Bluetooth Remote 2 Pro over the +/// ATVV profile and turns its audio notifications into PCM frames. +/// +/// The remote keeps its own microphone stream private to the ATVV channel; this +/// bridge decodes that stream in-process, so Utter needs neither the vendor's +/// virtual audio driver nor a second application. +/// +/// Handshake order is enforced: discover characteristics, subscribe to both the +/// audio and control notifications, wait for CoreBluetooth to confirm every +/// subscription, and only then send `GET_CAPABILITIES`. A connection or +/// initialization that stalls past its timeout is failed and retried instead of +/// leaving the UI in `.connecting` forever. +final class XiaomiRemoteMicBridge: NSObject, ObservableObject { + static let shared = XiaomiRemoteMicBridge() + + /// How long a connection, or the initialization sequence after it, may take + /// before the attempt is failed and retried. + static let connectionTimeout: TimeInterval = 10 + static let initializationTimeout: TimeInterval = 8 + + @Published private(set) var state: RemoteMicBridgeState = .idle + + /// Decoded 16 kHz mono samples while a voice session is streaming. + var onSamples: (([Int16]) -> Void)? + /// Fired when the remote stops streaming, including unexpected disconnects. + var onStreamStopped: (() -> Void)? + /// Fired when the remote's voice key starts a voice session, with the latch + /// token the caller must commit when its asynchronous start completes. The + /// remote signals this on the ATVV control channel (`AUDIO_START` for the + /// no-`START_SEARCH` interaction model), so the voice key drives Utter's + /// recording without a separate HID key remap or an Input Monitoring + /// permission. + var onVoiceKeyPressed: ((UInt64) -> Void)? + /// Fired when the remote's voice key ends the session. The caller stops or + /// cancels its in-flight start for the current latch. + var onVoiceKeyReleased: (() -> Void)? + + private var centralTransport: XiaomiRemoteMicCentralTransport? + private var centralTransportFactory: (() -> XiaomiRemoteMicCentralTransport)? + /// Retired transports stay alive until their terminal central callback (or + /// a scan queue fence) completes. Keying by manager identity prevents a + /// still-pending old context from being evicted by an arbitrary FIFO cap. + private var retiredCentralContexts: [ObjectIdentifier: XiaomiRemoteMicCentralTransport] = [:] + private var retiredPeripheralContexts: [ObjectIdentifier: XiaomiRemoteMicPeripheralContext] = [:] + private var pendingCentralRetirement: ( + identity: AnyObject, + attempt: UInt64, + peripheralKey: ObjectIdentifier? + )? + private var centralLifecycle: XiaomiRemoteMicCentralLifecycle = .idle + private var scanRequestedWhileQuiescing = false + private var peripheral: CBPeripheral? + private var peripheralIdentity: AnyObject? + /// Attempt of the currently active connection lifecycle. Callback routes + /// compare their proxy-captured source against this value; no route + /// derives an old callback's source by looking at the current peripheral. + private var activeConnectionAttempt: UInt64? + /// Retained so CoreBluetooth can call the source-bound proxy. An old proxy + /// may still deliver a queued callback, but its captured attempt will fail + /// the bridge's current-lifecycle gate. + private var peripheralCallbackProxy: XiaomiRemoteMicPeripheralDelegateProxy? + private var transmitCharacteristic: CBCharacteristic? + private var audioCharacteristic: CBCharacteristic? + private var controlCharacteristic: CBCharacteristic? + private var handshake = RemoteMicHandshake() + + private var capabilities = RemoteMicCapabilities.default + private var microphoneOpened = false + /// Latches the voice-key session synchronously, so a release or disconnect + /// that arrives while the pipeline is still starting cancels the pending + /// start instead of being ignored. + private var session = RemoteMicSession() + /// Audio that arrives before the capture pipeline is ready, so the opening + /// word is not clipped. + private var preRoll = RemoteMicPreRoll() + private var reconnectAttempts = 0 + private var reconnectTask: Task? + private var timeoutTask: Task? + private var isActive = false + + /// Monotonic attempt counter. Both peripheral and central callbacks carry + /// their source attempt through a lifecycle proxy. CoreBluetooth itself + /// supplies no attempt id; the proxy is the app-owned source envelope. + private var generation: UInt64 = 0 + + private var accumulator = RemoteMicFrameAccumulator() + private var decoder = RemoteMicADPCMDecoder() + private var pendingSync: (predictor: Int, stepIndex: Int)? + + private var serviceUUID: CBUUID { CBUUID(string: RemoteMicProtocol.serviceUUID) } + + // MARK: - Lifecycle + + func activate() { + guard !isActive else { return } + isActive = true + reconnectAttempts = 0 + guard centralTransport == nil else { + beginScan() + return + } + if case .quiescing = centralLifecycle { + // `deactivate()` has already issued cancellation. Do not create a + // new manager until the old context reports terminal completion. + scanRequestedWhileQuiescing = true + return + } + installCentralTransport() + } + + func deactivate() { + isActive = false + // Closing the feature must end a live session, not leave Utter recording. + let wasLive = session.invalidate() + if wasLive { + onStreamStopped?() + onVoiceKeyReleased?() + } + cancelReconnect() + cancelTimeout() + generation &+= 1 + closeMicrophoneIfNeeded() + resetStream() + retireCurrentCentral() + resetPeripheral() + state = .idle + } + + /// Commits the latched voice-key session to the capture pipeline and returns + /// any audio buffered before it was ready, in order. + /// + /// `token` is the latch the caller observed when it started; if the session + /// was released or superseded meanwhile this returns `nil`, and the caller + /// must not begin recording. + func beginCapture(token: UInt64) -> [Int16]? { + if !isActive { activate() } + guard session.commitStart(token: token) else { return nil } + guard peripheral?.state == .connected, handshake.isReady else { + // Not usable yet: drop the latched session so a later readiness does + // not open the remote microphone for a session that fell back. + _ = session.release() + preRoll.reset() + return nil + } + if !microphoneOpened { openMicrophoneIfNeeded() } + let buffered = preRoll.drain().flatMap { $0 } + return buffered + } + + /// True while a voice-key session is latched or recording. + var isSessionLive: Bool { session.isLive } + + /// The latch of the current voice-key session, or nil when idle. + var currentSessionToken: UInt64? { session.isLive ? session.generation : nil } + + /// True while `token` is still the live voice-key session. Used by the + /// pipeline to abort a start whose key was released while the model loaded. + static func isSessionCurrent(_ token: UInt64) -> Bool { + shared.session.isLive && shared.session.generation == token + } + + /// Test-only: latch a session without a real remote, so tests can drive the + /// pipeline's post-load check through the real bridge predicate. + func beginSimulatedSessionForTesting() -> UInt64 { + session.press() + } + + /// Test-only: release the simulated session. + @discardableResult + func endSimulatedSessionForTesting() -> Bool { + session.release() + } + + // MARK: - Test-only routing seams + + /// Test-only: reset to a clean state for routing tests. + func configureForTesting() { + cancelReconnect() + cancelTimeout() + if let transport = centralTransport { + transport.stopScan() + if let peripheralIdentity { transport.cancel(peripheral: peripheralIdentity) } + } + centralTransport = nil + pendingCentralRetirement = nil + for context in retiredPeripheralContexts.values { + context.peripheral?.delegate = nil + } + retiredCentralContexts.removeAll() + retiredPeripheralContexts.removeAll() + centralLifecycle = .idle + scanRequestedWhileQuiescing = false + isActive = false + resetPeripheral() + handshake.reset() + generation = 0 + state = .idle + } + + /// Test-only: the factory is consumed by `activate()`/`installCentralTransport()` + /// exactly like the production CoreBluetooth initializer. It is not a + /// proxy-only injection seam. + func installCentralTransportFactoryForTesting( + _ factory: @escaping () -> XiaomiRemoteMicCentralTransport + ) { + centralTransportFactory = factory + } + + /// Test-only: the currently held production transport, including its + /// non-nil manager identity and delegate proxy. + func centralTransportForTesting() -> XiaomiRemoteMicCentralTransport? { + centralTransport + } + + /// Test-only: drive the same central state route with a non-nil manager + /// identity supplied by the production-created transport. + func simulateCentralStateForTesting(_ state: CBManagerState) { + guard let transport = centralTransport else { return } + routeCentralManagerDidUpdateState( + managerIdentity: transport.identity, + managerState: state, + sourceAttempt: transport.delegateProxy.attempt + ) + } + + /// Test-only: drive discovery through the production route with a + /// non-nil peripheral identity. Returns the attempt bound by discovery. + @discardableResult + func simulateCentralDiscoveryForTesting(peripheralIdentity: AnyObject) -> UInt64? { + guard let transport = centralTransport else { return nil } + transport.delegateProxy.bindPeripheralIdentity(peripheralIdentity) + routeCentralDidDiscover( + managerIdentity: transport.identity, + peripheralIdentity: peripheralIdentity, + peripheral: nil, + advertisementData: [:], + rssi: 0, + sourceAttempt: transport.delegateProxy.attempt + ) + return activeConnectionAttempt + } + + /// Test-only: explicitly deliver the cancellation completion of the held + /// transport. Production gets this signal from didDisconnect/didFail or a + /// main-queue scan fence; tests inject it deterministically. + func completeCentralRetirementForTesting() { + guard let pending = pendingCentralRetirement else { return } + finishCentralRetirement(managerIdentity: pending.identity, attempt: pending.attempt) + } + + /// Test-only: evidence for the ownership contract: a retired context is + /// retained until completion, then released. + func retiredCentralContextCountForTesting() -> Int { + retiredCentralContexts.count + } + + /// Test-only: the old peripheral delegate/proxy is held through terminal + /// cancellation, then released with the retired central context. + func retiredPeripheralContextCountForTesting() -> Int { + retiredPeripheralContexts.count + } + + /// Test-only: the production manager may not be created while this gate is + /// pending, even when the feature is turned on again immediately. + func isCentralQuiescingForTesting() -> Bool { + if case .quiescing = centralLifecycle { return true } + return false + } + + /// Test-only: simulate a connect and return the attempt it bound. + @discardableResult + func simulateConnectForTesting() -> UInt64 { + generation &+= 1 + let attempt = generation + beginPeripheralAttempt(attempt) + centralLifecycle = .connecting(attempt) + state = .connecting + return attempt + } + + /// Test-only: simulate a reconnect on the same peripheral object. The new + /// lifecycle gets a new source proxy; callers can retain the old proxy and + /// deliver a late event through the production route. + @discardableResult + func simulateReconnectSamePeripheralForTesting() -> UInt64 { + simulateConnectForTesting() + } + + /// Test-only: the attempt currently accepted for the lifecycle. + func attemptForCurrentPeripheralForTesting() -> UInt64? { + activeConnectionAttempt + } + + /// Test-only: retain the source envelope for a simulated lifecycle. + func callbackProxyForTesting() -> XiaomiRemoteMicPeripheralDelegateProxy? { + peripheralCallbackProxy + } + + /// Test-only: whether the lifecycle is still installed after a late event. + func isAttemptActiveForTesting(_ attempt: UInt64) -> Bool { + activeConnectionAttempt == attempt && peripheralCallbackProxy?.attempt == attempt + } + + /// Test-only: whether the source-bound central lifecycle is still current. + func isCentralAttemptActiveForTesting(_ attempt: UInt64) -> Bool { + centralLifecycle.attempt == attempt + && activeConnectionAttempt == attempt + && handshake.accepts(attempt) + } + + /// Test-only: whether the handshake still tracks `attempt`. + func acceptsAttemptForTesting(_ attempt: UInt64) -> Bool { + handshake.accepts(attempt) + } + + /// Test-only: send the capability request for the current attempt. + func simulateCapabilitiesRequestedForTesting() { + handshake.markCapabilitiesRequested() + } + + func endCapture() { + // Close exactly once, whatever the phase: a release during `starting` + // must still close a microphone this bridge may have opened, and must + // not leave `microphoneOpened` set for the next attempt. + let wasLive = session.release() + if microphoneOpened || wasLive { + closeMicrophoneIfNeeded() + } + if !session.isLive { + preRoll.reset() + accumulator.reset() + pendingSync = nil + decoder.reset() + } + } + + // MARK: - Control + + private func openMicrophoneIfNeeded() { + guard !microphoneOpened else { return } + let command = RemoteMicProtocol.microphoneOpen( + version: capabilities.version, + codec: capabilities.selectedCodec + ) + guard write(command) else { return } + microphoneOpened = true + } + + private func closeMicrophoneIfNeeded() { + guard microphoneOpened else { return } + _ = write(RemoteMicProtocol.microphoneClose( + version: capabilities.version, + sessionID: 0 + )) + microphoneOpened = false + } + + private func write(_ data: Data) -> Bool { + guard let peripheral, let transmitCharacteristic else { return false } + let type: CBCharacteristicWriteType = + transmitCharacteristic.properties.contains(.write) ? .withResponse : .withoutResponse + peripheral.writeValue(data, for: transmitCharacteristic, type: type) + return true + } + + private func resetStream() { + preRoll.reset() + accumulator.reset() + pendingSync = nil + decoder.reset() + } + + // MARK: - Scanning and connection + + private func beginScan() { + guard isActive else { return } + switch centralLifecycle { + case .quiescing: + // A replacement transport is not created until the old transport + // reports terminal cancellation (or the scan fence completes). + scanRequestedWhileQuiescing = true + return + case .scanning(_), .connecting(_), .connected(_): + return + case .idle: + break + } + guard let transport = centralTransport else { + installCentralTransport() + return + } + guard transport.state == .poweredOn else { return } + generation &+= 1 + resetPeripheral() + resetStream() + handshake.reset() + capabilities = .default + centralLifecycle = .scanning(generation) + state = .scanning + transport.scanForPeripherals( + withServices: [serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + + /// Creates the central transport through the same factory used by the + /// production CoreBluetooth initializer. A transport is never reused after + /// its connection is retired, because its delegate callbacks otherwise + /// carry no source id. + private func installCentralTransport() { + guard centralTransport == nil else { return } + let transport = centralTransportFactory?() + ?? XiaomiRemoteMicCoreBluetoothCentralTransport(bridge: self) + transport.delegateProxy.bindManagerIdentity(transport.identity) + centralTransport = transport + } + + /// Retires the current central transport. A pending or connected + /// peripheral is always passed to `cancel`; CoreBluetooth documents that + /// this cancels pending as well as active local connections. The old + /// manager/proxy remains held until didDisconnect/didFail, while a scan-only + /// transport uses an explicit main-queue fence. + private func retireCurrentCentral() { + let retiredAttempt = centralLifecycle.attempt ?? generation + guard let transport = centralTransport else { + // A second deactivate must not turn a still-quiescing lifecycle + // back into idle and thereby permit a replacement manager early. + guard pendingCentralRetirement == nil else { return } + if centralLifecycle != .idle { centralLifecycle = .idle } + return + } + centralLifecycle = .quiescing(retiredAttempt) + scanRequestedWhileQuiescing = false + let managerIdentity = transport.identity + let peripheralKey: ObjectIdentifier? + if let peripheralIdentity, let peripheralCallbackProxy { + let key = ObjectIdentifier(peripheralIdentity) + retiredPeripheralContexts[key] = XiaomiRemoteMicPeripheralContext( + identity: peripheralIdentity, + peripheral: peripheral, + delegateProxy: peripheralCallbackProxy + ) + peripheralKey = key + } else { + peripheralKey = nil + } + retiredCentralContexts[ObjectIdentifier(managerIdentity)] = transport + pendingCentralRetirement = (managerIdentity, retiredAttempt, peripheralKey) + centralTransport = nil + + transport.stopScan() + if let peripheralIdentity { + // Do not gate cancellation on CBPeripheral.state: a connecting + // peripheral is a pending local connection and must be cancelled. + transport.cancel(peripheral: peripheralIdentity) + } else { + scheduleCentralRetirementFence( + managerIdentity: managerIdentity, + attempt: retiredAttempt + ) + } + } + + /// A scan has no didDisconnect/didFail callback. Since the manager was + /// created with `.main`, enqueueing this completion on the same queue is the + /// explicit scan retirement boundary. Connection retirement uses its + /// terminal central callback instead. + private func scheduleCentralRetirementFence(managerIdentity: AnyObject, attempt: UInt64) { + let managerKey = ObjectIdentifier(managerIdentity) + DispatchQueue.main.async { [weak self] in + self?.finishCentralRetirement(managerKey: managerKey, attempt: attempt) + } + } + + private func finishCentralRetirement(managerIdentity: AnyObject, attempt: UInt64) { + finishCentralRetirement(managerKey: ObjectIdentifier(managerIdentity), attempt: attempt) + } + + private func finishCentralRetirement(managerKey: ObjectIdentifier, attempt: UInt64) { + guard let pending = pendingCentralRetirement, + pending.attempt == attempt, + ObjectIdentifier(pending.identity) == managerKey else { return } + let managerIdentity = pending.identity + pendingCentralRetirement = nil + retiredCentralContexts.removeValue(forKey: ObjectIdentifier(managerIdentity)) + if let peripheralKey = pending.peripheralKey, + let context = retiredPeripheralContexts.removeValue(forKey: peripheralKey) { + context.peripheral?.delegate = nil + } + centralLifecycle = .idle + guard isActive, scanRequestedWhileQuiescing else { return } + scanRequestedWhileQuiescing = false + beginScan() + } + + private func resetPeripheral() { + // A retired context owns the old peripheral/proxy until the central + // terminal callback. For an ordinary reset there is no such context, + // so detach immediately. + if let peripheralIdentity, + retiredPeripheralContexts[ObjectIdentifier(peripheralIdentity)] == nil { + peripheral?.delegate = nil + } + peripheral = nil + peripheralIdentity = nil + activeConnectionAttempt = nil + peripheralCallbackProxy = nil + transmitCharacteristic = nil + audioCharacteristic = nil + controlCharacteristic = nil + } + + /// Starts a new peripheral lifecycle and installs the source-bound route. + /// CoreBluetooth itself does not expose the attempt id on delegate events; + /// this proxy is the lifecycle boundary that supplies it. + private func beginPeripheralAttempt( + _ attempt: UInt64, + peripheral: CBPeripheral? = nil, + peripheralIdentity: AnyObject? = nil + ) { + activeConnectionAttempt = attempt + handshake.beginAttempt(attempt) + let proxy = XiaomiRemoteMicPeripheralDelegateProxy(bridge: self, attempt: attempt) + peripheralCallbackProxy = proxy + self.peripheralIdentity = peripheralIdentity ?? peripheral + if let peripheral { + self.peripheral = peripheral + peripheral.delegate = proxy + } + } + + private func cancelReconnect() { + reconnectTask?.cancel() + reconnectTask = nil + } + + private func cancelTimeout() { + timeoutTask?.cancel() + timeoutTask = nil + } + + /// Fails the current attempt after `seconds` unless `isSatisfied` says the + /// step completed. Runs on the main actor so the check races nothing. + private func startTimeout( + seconds: TimeInterval, + generation expected: UInt64, + reason: @escaping @autoclosure () -> String, + isSatisfied: @escaping () -> Bool + ) { + cancelTimeout() + timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + guard let self, !Task.isCancelled, self.generation == expected else { return } + guard !isSatisfied() else { return } + self.failAttempt(reason: reason()) + } + } + + private func failAttempt(reason: String) { + state = .failed(reason: reason) + resetStream() + closeMicrophoneIfNeeded() + retireCurrentCentral() + resetPeripheral() + scheduleReconnect() + } + + private func scheduleReconnect() { + guard isActive else { return } + cancelReconnect() + reconnectAttempts += 1 + let delay = min(30.0, pow(2.0, Double(min(reconnectAttempts, 5)))) + reconnectTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let self, !Task.isCancelled, self.isActive else { return } + self.beginScan() + } + } + + fileprivate func handleDisconnect() { + if session.invalidate() { + onStreamStopped?() + onVoiceKeyReleased?() + } + resetStream() + handshake.reset() + microphoneOpened = false + cancelTimeout() + retireCurrentCentral() + resetPeripheral() + if isActive { scheduleReconnect() } + } + + // MARK: - Control protocol + + fileprivate func handleControl(_ data: Data, attempt: UInt64) { + guard handshake.accepts(attempt) else { return } + let bytes = Array(data) + guard let opcode = bytes.first.flatMap(RemoteMicControlOpcode.init(rawValue:)) else { return } + + switch opcode { + case .capabilities: + guard let parsed = RemoteMicCapabilities.parse(data) else { + failAttempt(reason: L("remote_mic.error.invalid_response")) + return + } + capabilities = parsed + guard RemoteMicProtocol.supportsAudio(sampleRate: parsed.sampleRate) else { + failAttempt(reason: L("remote_mic.error.unsupported_codec")) + return + } + guard handshake.confirmCapabilities(parsed) else { + failAttempt(reason: L("remote_mic.error.unsupported_codec")) + return + } + cancelTimeout() + reconnectAttempts = 0 + state = .ready(deviceName: peripheral?.name ?? "MI RC") + if session.isLive { openMicrophoneIfNeeded() } + case .startSearch: + guard handshake.isReady, isActive else { return } + // `START_SEARCH` (0x08) is the device announcing itself, not a + // host-side microphone open. A device-driven session needs no host + // request, so keep the channel open; the session latches on + // AUDIO_START so a short press is not lost. + openMicrophoneIfNeeded() + case .streamStart: + guard handshake.isReady, isActive else { return } + if bytes.count >= 3 { + let codec = bytes[2] + capabilities.selectedCodec = codec + capabilities.sampleRate = codec == 0x02 ? 16_000 : 8_000 + } + guard RemoteMicProtocol.supportsAudio(sampleRate: capabilities.sampleRate) else { + failAttempt(reason: L("remote_mic.error.unsupported_codec")) + return + } + // Latch synchronously: the pipeline start is asynchronous, and a + // stop or disconnect may arrive before it commits. + let token = session.press() + onVoiceKeyPressed?(token) + case .streamStop: + // Release before clearing state so endCapture can still close the + // microphone; previously the reset ran first and made that + // unreachable, leaving microphoneOpened set. + let wasLive = session.release() + preRoll.reset() + accumulator.reset() + pendingSync = nil + decoder.reset() + if wasLive { onVoiceKeyReleased?() } + case .sync: + guard bytes.count >= 7 else { return } + let bits = UInt16(bytes[4]) << 8 | UInt16(bytes[5]) + pendingSync = (Int(Int16(bitPattern: bits)), Int(bytes[6])) + accumulator.reset() + } + } + + fileprivate func handleAudio(_ data: Data, attempt: UInt64) { + guard handshake.accepts(attempt) else { return } + guard handshake.isReady else { return } + let frames = accumulator.append(data, frameSize: capabilities.frameSize) + for frame in frames { + if let pendingSync { + decoder.reset(predictor: pendingSync.predictor, stepIndex: pendingSync.stepIndex) + self.pendingSync = nil + } + let samples = RemoteMicPCM.process( + decoder.decode(frame), + gainDB: AppSettings.shared.remoteMicGainDB + ) + // Route through the shared rule so the behaviour a test asserts is + // the behaviour the bridge runs. + switch RemoteMicAudioRouting.destination(for: session.phase) { + case .forward: + onSamples?(samples) + case .preRoll: + preRoll.append(samples) + case .drop: + break + } + } + } + + /// Sends the capability request only once both notify subscriptions are + /// confirmed, and only once per attempt. + fileprivate func requestCapabilitiesIfReady() { + guard handshake.shouldRequestCapabilities else { return } + handshake.markCapabilitiesRequested() + startTimeout( + seconds: Self.initializationTimeout, + generation: generation, + reason: L("remote_mic.error.initialization_timeout"), + isSatisfied: { [weak self] in self?.handshake.isReady ?? false } + ) + _ = write(RemoteMicProtocol.getCapabilities) + } +} + +extension XiaomiRemoteMicBridge: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + // The bridge remains conformant for compatibility, but production + // managers use XiaomiRemoteMicCentralDelegateProxy. An unbound direct + // callback is deliberately not accepted for connection events. + routeCentralManagerDidUpdateState( + managerIdentity: central, + managerState: central.state, + sourceAttempt: nil + ) + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + routeCentralDidDiscover( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + advertisementData: advertisementData, + rssi: RSSI, + sourceAttempt: nil + ) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + routeCentralDidConnect( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + sourceAttempt: nil + ) + } + + func centralManager( + _ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error? + ) { + routeCentralDidFailToConnect( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + error: error, + sourceAttempt: nil + ) + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error? + ) { + routeCentralDidDisconnect( + managerIdentity: central, + peripheralIdentity: peripheral, + peripheral: peripheral, + error: error, + sourceAttempt: nil + ) + } + + fileprivate func routeCentralManagerDidUpdateState( + managerIdentity: AnyObject, + managerState: CBManagerState, + sourceAttempt: UInt64? + ) { + guard let transport = centralTransport, + transport.identity === managerIdentity else { return } + switch managerState { + case .poweredOn: + // Only the unbound scan manager may begin a scan. A callback from + // a connection manager never restarts the lifecycle. + if sourceAttempt == nil { beginScan() } + case .unauthorized: + guard sourceAttempt == nil || sourceAttempt == centralLifecycle.attempt else { return } + cancelTimeout() + cancelReconnect() + self.state = .unauthorized + case .unsupported: + guard sourceAttempt == nil || sourceAttempt == centralLifecycle.attempt else { return } + cancelTimeout() + cancelReconnect() + self.state = .unsupported + default: + guard sourceAttempt == nil || sourceAttempt == centralLifecycle.attempt else { return } + cancelTimeout() + self.state = .idle + } + } + + fileprivate func routeCentralDidDiscover( + managerIdentity: AnyObject, + peripheralIdentity: AnyObject, + peripheral: CBPeripheral?, + advertisementData: [String: Any], + rssi RSSI: NSNumber, + sourceAttempt: UInt64? + ) { + guard sourceAttempt == nil, + isActive, + let transport = centralTransport, + transport.identity === managerIdentity, + self.peripheral == nil, + self.peripheralIdentity == nil, + state == .scanning, + case .scanning(_) = centralLifecycle else { return } + transport.stopScan() + state = .connecting + // Bind this central manager to a fresh lifecycle before issuing the + // connect. Every later central callback from this manager carries the + // captured attempt through its proxy. + generation &+= 1 + let attempt = generation + centralLifecycle = .connecting(attempt) + transport.delegateProxy.bind(to: attempt) + transport.delegateProxy.bindPeripheralIdentity(peripheralIdentity) + beginPeripheralAttempt( + attempt, + peripheral: peripheral, + peripheralIdentity: peripheralIdentity + ) + startTimeout( + seconds: Self.connectionTimeout, + generation: attempt, + reason: L("remote_mic.error.connection_timeout"), + isSatisfied: { [weak self] in + guard let self else { return true } + return self.generation != attempt || self.handshake.capabilitiesRequested + } + ) + transport.connect(to: peripheralIdentity) + } + + fileprivate func routeCentralDidConnect( + managerIdentity: AnyObject, + peripheralIdentity: AnyObject, + peripheral: CBPeripheral?, + sourceAttempt: UInt64? + ) { + guard let attempt = currentCentralAttempt( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + sourceAttempt: sourceAttempt, + allowConnected: false + ) else { return } + centralLifecycle = .connected(attempt) + startTimeout( + seconds: Self.initializationTimeout, + generation: attempt, + reason: L("remote_mic.error.initialization_timeout"), + isSatisfied: { [weak self] in self?.handshake.isReady ?? false } + ) + peripheral?.discoverServices([serviceUUID]) + } + + fileprivate func routeCentralDidFailToConnect( + managerIdentity: AnyObject, + peripheralIdentity: AnyObject, + peripheral: CBPeripheral?, + error: Error?, + sourceAttempt: UInt64? + ) { + if currentCentralAttempt( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + sourceAttempt: sourceAttempt, + allowConnected: false + ) != nil { + failAttempt(reason: L("remote_mic.error.connect_failed")) + scheduleCentralRetirementCompletion( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + attempt: sourceAttempt + ) + return + } + scheduleCentralRetirementCompletion( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + attempt: sourceAttempt + ) + } + + fileprivate func routeCentralDidDisconnect( + managerIdentity: AnyObject, + peripheralIdentity: AnyObject, + peripheral: CBPeripheral?, + error: Error?, + sourceAttempt: UInt64? + ) { + if currentCentralAttempt( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + sourceAttempt: sourceAttempt, + allowConnected: true + ) != nil { + handleDisconnect() + scheduleCentralRetirementCompletion( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + attempt: sourceAttempt + ) + return + } + scheduleCentralRetirementCompletion( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + attempt: sourceAttempt + ) + } + + /// Test-only source injection through the same route used by the central + /// delegate proxy. The optional CoreBluetooth objects are intentionally + /// absent; source attribution and lifecycle transitions are not fabricated + /// by looking at a mutable peripheral state. + func routeCentralCallbackForTesting( + _ callback: XiaomiRemoteMicCentralTestCallback, + attempt: UInt64, + managerIdentity: AnyObject, + peripheralIdentity: AnyObject + ) { + switch callback { + case .didConnect: + routeCentralDidConnect( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + peripheral: nil, + sourceAttempt: attempt + ) + case .didFailToConnect: + routeCentralDidFailToConnect( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + peripheral: nil, + error: nil, + sourceAttempt: attempt + ) + case .didDisconnect: + routeCentralDidDisconnect( + managerIdentity: managerIdentity, + peripheralIdentity: peripheralIdentity, + peripheral: nil, + error: nil, + sourceAttempt: attempt + ) + } + } + + /// Source gate for central callbacks. Unlike the previous object-state + /// check, this requires the callback proxy's attempt and the lifecycle + /// phase to agree. A direct bridge callback has no source envelope and is + /// rejected for connection events. + private func currentCentralAttempt( + managerIdentity: AnyObject, + peripheralIdentity: AnyObject, + sourceAttempt: UInt64?, + allowConnected: Bool + ) -> UInt64? { + guard let transport = centralTransport, + transport.identity === managerIdentity, + let activePeripheralIdentity = self.peripheralIdentity, + activePeripheralIdentity === peripheralIdentity else { return nil } + guard let sourceAttempt, + activeConnectionAttempt == sourceAttempt, + handshake.accepts(sourceAttempt) else { return nil } + guard let active = centralLifecycle.attempt, active == sourceAttempt else { return nil } + switch centralLifecycle { + case .connecting: + break + case .connected: + guard allowConnected else { return nil } + default: + return nil + } + return sourceAttempt + } + + private func scheduleCentralRetirementCompletion( + managerIdentity: AnyObject, + peripheralIdentity: AnyObject, + attempt: UInt64? + ) { + guard let attempt, + let pending = pendingCentralRetirement, + pending.attempt == attempt, + pending.identity === managerIdentity, + pending.peripheralKey.map({ $0 == ObjectIdentifier(peripheralIdentity) }) ?? true + else { return } + finishCentralRetirement(managerIdentity: managerIdentity, attempt: attempt) + } +} + +// MARK: - Source-bound peripheral callback routes + +extension XiaomiRemoteMicBridge { + fileprivate func routeDidDiscoverServices( + _ peripheral: CBPeripheral, + error: Error?, + attempt: UInt64 + ) { + guard acceptsPeripheralCallback(peripheral, attempt: attempt), error == nil else { return } + guard let service = peripheral.services?.first(where: { $0.uuid == serviceUUID }) else { + failAttempt(reason: L("remote_mic.error.service_missing")) + return + } + peripheral.discoverCharacteristics( + [CBUUID(string: RemoteMicProtocol.transmitUUID), + CBUUID(string: RemoteMicProtocol.audioUUID), + CBUUID(string: RemoteMicProtocol.controlUUID)], + for: service + ) + } + + fileprivate func routeDidDiscoverCharacteristics( + _ peripheral: CBPeripheral, + service: CBService, + error: Error?, + attempt: UInt64 + ) { + guard acceptsPeripheralCallback(peripheral, attempt: attempt), error == nil else { return } + let transmit = RemoteMicProtocol.transmitUUID.uppercased() + let audio = RemoteMicProtocol.audioUUID.uppercased() + let control = RemoteMicProtocol.controlUUID.uppercased() + for characteristic in service.characteristics ?? [] { + switch characteristic.uuid.uuidString.uppercased() { + case transmit: + transmitCharacteristic = characteristic + handshake.registerCharacteristic(.transmit) + case audio: + audioCharacteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) + case control: + controlCharacteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) + default: + break + } + } + guard transmitCharacteristic != nil, + audioCharacteristic != nil, + controlCharacteristic != nil else { + failAttempt(reason: L("remote_mic.error.characteristic_missing")) + return + } + // Capabilities wait for didUpdateNotificationStateFor on both channels. + requestCapabilitiesIfReady() + } + + fileprivate func routeDidUpdateNotificationState( + _ peripheral: CBPeripheral, + characteristic: CBCharacteristic, + error: Error?, + attempt: UInt64 + ) { + guard acceptsPeripheralCallback(peripheral, attempt: attempt), error == nil else { return } + guard characteristic.isNotifying else { return } + switch characteristic.uuid.uuidString.uppercased() { + case RemoteMicProtocol.audioUUID.uppercased(): + handshake.confirmSubscription(.audio) + case RemoteMicProtocol.controlUUID.uppercased(): + handshake.confirmSubscription(.control) + default: + return + } + requestCapabilitiesIfReady() + } + + fileprivate func routeDidUpdateValue( + _ peripheral: CBPeripheral, + characteristic: CBCharacteristic, + error: Error?, + attempt: UInt64 + ) { + // A reused CBPeripheral object can deliver a late value from a previous + // attempt; attribute it to the attempt that raised it and drop it when + // this handshake no longer tracks that attempt. + guard acceptsPeripheralCallback(peripheral, attempt: attempt), error == nil, + let data = characteristic.value else { return } + switch characteristic.uuid.uuidString.uppercased() { + case RemoteMicProtocol.controlUUID.uppercased(): + handleControl(data, attempt: attempt) + case RemoteMicProtocol.audioUUID.uppercased(): + handleAudio(data, attempt: attempt) + default: + break + } + } + + /// The common gate for every CBPeripheralDelegate callback. Unlike the old + /// `attempt(for:)` lookup, the attempt here came from the delegate proxy + /// that was installed for the connection lifecycle. + private func acceptsPeripheralCallback(_ peripheral: CBPeripheral?, attempt: UInt64) -> Bool { + guard activeConnectionAttempt == attempt, + peripheralCallbackProxy?.attempt == attempt, + handshake.accepts(attempt) else { return false } + if let peripheral { + guard peripheral === self.peripheral else { return false } + } + return true + } + + /// Test-only event route used by `XiaomiRemoteMicPeripheralDelegateProxy`. + /// It intentionally enters the same attempt gate and handlers as production + /// delegate callbacks; it only omits unavailable CoreBluetooth value types. + fileprivate func routeTestCallback( + _ callback: XiaomiRemoteMicTestCallback, + attempt: UInt64 + ) { + guard acceptsPeripheralCallback(nil, attempt: attempt) else { return } + switch callback { + case .disconnect: + handleDisconnect() + case let .control(data): + handleControl(data, attempt: attempt) + case let .audio(data): + handleAudio(data, attempt: attempt) + } + } +} diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index d5cf3a69..f7c1193d 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -164,6 +164,24 @@ "settings.sound_cues" = "Sound cues"; "settings.microphone" = "Microphone"; "settings.system_default" = "System Default"; +"settings.remote_mic" = "Xiaomi remote wireless mic"; +"settings.remote_mic_help" = "Connect a Xiaomi Bluetooth Remote 2 Pro and hold its voice key to use its microphone — no extra app required."; +"settings.remote_mic_status" = "Connection"; +"settings.remote_mic_gain" = "Gain"; +"remote_mic.state.idle" = "Not connected"; +"remote_mic.state.unsupported" = "This Mac does not support Bluetooth"; +"remote_mic.state.unauthorized" = "Allow Bluetooth access in System Settings"; +"remote_mic.state.scanning" = "Searching for the remote…"; +"remote_mic.state.connecting" = "Connecting to the remote…"; +"remote_mic.state.ready" = "Connected: %@"; +"remote_mic.state.failed" = "Connection failed: %@"; +"remote_mic.error.invalid_response" = "The remote returned an unrecognized voice protocol response"; +"remote_mic.error.service_missing" = "The remote does not expose the wireless-mic service"; +"remote_mic.error.characteristic_missing" = "The remote is missing a wireless-mic channel"; +"remote_mic.error.unsupported_codec" = "The remote did not offer 16 kHz voice audio"; +"remote_mic.error.connection_timeout" = "Connecting to the remote timed out; retrying"; +"remote_mic.error.initialization_timeout" = "The remote voice channel did not finish initializing; retrying"; +"remote_mic.error.connect_failed" = "Could not connect to the remote; retrying"; "settings.permissions" = "Permissions"; "settings.history_retention" = "History retention"; "settings.developer_interface" = "Developer interface"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index 94b626ac..a024b90e 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -164,6 +164,24 @@ "settings.sound_cues" = "播放提示音"; "settings.microphone" = "麦克风"; "settings.system_default" = "系统默认"; +"settings.remote_mic" = "小米遥控器无线麦"; +"settings.remote_mic_help" = "连接小米蓝牙遥控器 2 Pro,按住语音键即可使用它的麦克风,无需再安装其他应用。"; +"settings.remote_mic_status" = "连接状态"; +"settings.remote_mic_gain" = "音量增益"; +"remote_mic.state.idle" = "未连接"; +"remote_mic.state.unsupported" = "此 Mac 不支持蓝牙"; +"remote_mic.state.unauthorized" = "请在系统设置中允许蓝牙权限"; +"remote_mic.state.scanning" = "正在搜索遥控器…"; +"remote_mic.state.connecting" = "正在连接遥控器…"; +"remote_mic.state.ready" = "已连接:%@"; +"remote_mic.state.failed" = "连接失败:%@"; +"remote_mic.error.invalid_response" = "遥控器返回了无法识别的语音协议数据"; +"remote_mic.error.service_missing" = "遥控器缺少无线麦服务"; +"remote_mic.error.characteristic_missing" = "遥控器缺少无线麦通道"; +"remote_mic.error.unsupported_codec" = "遥控器未提供 16 kHz 语音编码"; +"remote_mic.error.connection_timeout" = "连接遥控器超时,正在重试"; +"remote_mic.error.initialization_timeout" = "遥控器语音通道初始化超时,正在重试"; +"remote_mic.error.connect_failed" = "无法连接遥控器,正在重试"; "settings.permissions" = "权限管理"; "settings.history_retention" = "历史保留时长"; "settings.developer_interface" = "开发者接口"; diff --git a/Sources/UI/GeneralSettingsView.swift b/Sources/UI/GeneralSettingsView.swift index c9fb5257..dc637b7d 100644 --- a/Sources/UI/GeneralSettingsView.swift +++ b/Sources/UI/GeneralSettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct GeneralSettingsView: View { @EnvironmentObject private var settings: AppSettings + @StateObject private var remoteMicBridge = XiaomiRemoteMicBridge.shared @State private var launchAtLoginEnabled = false @State private var launchAtLoginRequiresApproval = false @State private var launchAtLoginErrorMessage = "" @@ -48,6 +49,7 @@ struct GeneralSettingsView: View { Section { microphonePicker + remoteMicControls Picker(L("settings.recognition_language"), selection: $settings.inputLanguage) { ForEach(InputLanguage.allCases, id: \.self) { Text($0.rawValue) } } @@ -199,6 +201,42 @@ struct GeneralSettingsView: View { Text(microphone.name).tag(microphone.id as String?) } } + .disabled(settings.remoteMicEnabled) + } + + @ViewBuilder + private var remoteMicControls: some View { + Toggle(isOn: $settings.remoteMicEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text(L("settings.remote_mic")) + Text(L("settings.remote_mic_help")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onChange(of: settings.remoteMicEnabled) { _, enabled in + if enabled { + RemoteMicCaptureManager.shared.activate() + } else { + RemoteMicCaptureManager.shared.deactivate() + } + } + if settings.remoteMicEnabled { + HStack { + Text(L("settings.remote_mic_status")) + Spacer() + Text(remoteMicBridge.state.summary) + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { + Text(L("settings.remote_mic_gain")) + Slider(value: $settings.remoteMicGainDB, in: 0...24, step: 1) + Text("\(Int(settings.remoteMicGainDB)) dB") + .monospacedDigit() + .frame(width: 46, alignment: .trailing) + } + } } private var menuBarIconPicker: some View { diff --git a/Tests/OpenTypeTests/RemoteMicHandshakeTests.swift b/Tests/OpenTypeTests/RemoteMicHandshakeTests.swift new file mode 100644 index 00000000..0896fa4a --- /dev/null +++ b/Tests/OpenTypeTests/RemoteMicHandshakeTests.swift @@ -0,0 +1,217 @@ +import Foundation +import XCTest +@testable import OpenType + +final class RemoteMicHandshakeTests: XCTestCase { + func testCapabilitiesWaitForBothNotificationSubscriptions() { + var handshake = RemoteMicHandshake() + handshake.registerCharacteristic(.transmit) + + XCTAssertFalse(handshake.shouldRequestCapabilities, "no subscriptions yet") + + handshake.confirmSubscription(.audio) + XCTAssertFalse(handshake.shouldRequestCapabilities, "control notification still missing") + + handshake.confirmSubscription(.control) + XCTAssertTrue(handshake.shouldRequestCapabilities, "both subscriptions confirmed") + } + + func testCapabilitiesWaitForTransmitCharacteristic() { + var handshake = RemoteMicHandshake() + handshake.confirmSubscription(.audio) + handshake.confirmSubscription(.control) + + XCTAssertFalse(handshake.shouldRequestCapabilities, "transmit characteristic unknown") + + handshake.registerCharacteristic(.transmit) + XCTAssertTrue(handshake.shouldRequestCapabilities) + } + + func testCapabilitiesAreRequestedOnlyOncePerAttempt() { + var handshake = RemoteMicHandshake() + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.audio) + handshake.confirmSubscription(.control) + + XCTAssertTrue(handshake.shouldRequestCapabilities) + handshake.markCapabilitiesRequested() + XCTAssertFalse(handshake.shouldRequestCapabilities, "must not resend") + } + + func testReadinessRequiresParsed16kHzCapabilities() { + var handshake = RemoteMicHandshake() + XCTAssertFalse(handshake.isReady) + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.audio) + handshake.confirmSubscription(.control) + handshake.markCapabilitiesRequested() + + let eightKilohertz = RemoteMicCapabilities( + version: 0x0100, + codecs: 0x01, + interaction: 0x03, + frameSize: 120, + selectedCodec: 0x01, + sampleRate: 8_000 + ) + XCTAssertFalse(handshake.confirmCapabilities(eightKilohertz)) + XCTAssertFalse(handshake.isReady, "8 kHz must not become ready") + + let sixteenKilohertz = RemoteMicCapabilities.default + XCTAssertTrue(handshake.confirmCapabilities(sixteenKilohertz)) + XCTAssertTrue(handshake.isReady) + } + + func testResetClearsEveryGate() { + var handshake = RemoteMicHandshake() + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.audio) + handshake.confirmSubscription(.control) + handshake.markCapabilitiesRequested() + _ = handshake.confirmCapabilities(.default) + XCTAssertTrue(handshake.isReady) + + handshake.reset() + XCTAssertFalse(handshake.isReady) + XCTAssertFalse(handshake.shouldRequestCapabilities) + XCTAssertFalse(handshake.hasAllCharacteristics) + XCTAssertFalse(handshake.subscriptionsReady) + } + + /// A capability frame that arrives before this attempt requested one (a late + /// frame from a previous attempt on a reused peripheral) must not mark the + /// new attempt ready. + func testUnrequestedCapabilityResponseIsRejected() { + var handshake = RemoteMicHandshake() + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.audio) + handshake.confirmSubscription(.control) + + XCTAssertFalse( + handshake.confirmCapabilities(.default), + "a response before the request must be ignored" + ) + XCTAssertFalse(handshake.isReady) + + handshake.markCapabilitiesRequested() + XCTAssertTrue(handshake.confirmCapabilities(.default)) + XCTAssertTrue(handshake.isReady) + } + + /// A late subscription confirmation from an old attempt must not let the + /// next attempt skip its own gate; this models the reset between attempts. + func testSubscriptionFromPreviousAttemptDoesNotLeakAfterReset() { + var handshake = RemoteMicHandshake() + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.audio) + + handshake.reset() + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.control) + + XCTAssertFalse(handshake.shouldRequestCapabilities, "audio confirmation from before the reset must not count") + } +} + +/// The wanted-state invariant behind the fallback-leak fix: a failed start must +/// leave the bridge with nothing to adopt later. +final class RemoteMicWantedStateTests: XCTestCase { + func testFailedStartLeavesNothingWanted() { + var state = RemoteMicWantedState() + // A start that wants, then fails before the stream begins. + state.want() + XCTAssertTrue(state.isActive) + + XCTAssertTrue(state.reset(), "the attempted session was live") + XCTAssertFalse(state.isActive, "no residue may remain after a failed start") + } + + func testReleaseReportsOnlyWhenThereWasAWant() { + var state = RemoteMicWantedState() + XCTAssertFalse(state.release(), "nothing to release before wanting") + + state.want() + XCTAssertTrue(state.release()) + XCTAssertFalse(state.release(), "a second release must not report again") + } + + func testResetReportsLiveSessionOnce() { + var state = RemoteMicWantedState() + state.want() + state.beginStreaming() + + XCTAssertTrue(state.reset()) + XCTAssertFalse(state.isActive) + XCTAssertFalse(state.reset(), "already reset") + } + + func testStreamingAloneStillCountsAsActive() { + var state = RemoteMicWantedState() + state.beginStreaming() + XCTAssertTrue(state.isActive, "implicit audio start without an explicit want") + } +} + +/// Attempt isolation on a reused `CBPeripheral`: CoreBluetooth queues callbacks +/// per object, so a callback raised during a previous connection can land after a +/// reconnect has already moved on. Peripheral identity alone cannot reject it; +/// the attempt stamped when the callback was raised can. +final class RemoteMicAttemptIsolationTests: XCTestCase { + /// The reviewer's exact case: the new attempt has **already requested** + /// capabilities when the old attempt's late capability response arrives. + func testLateCapabilityFromPreviousAttemptIsRejectedAfterNewRequest() { + var handshake = RemoteMicHandshake() + handshake.beginAttempt(2) + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.audio) + handshake.confirmSubscription(.control) + handshake.markCapabilitiesRequested() + + // A capability that was raised during attempt 1 must be ignored even + // though this handshake has now requested its own. + XCTAssertFalse(handshake.accepts(1), "attempt 1 is stale") + XCTAssertTrue(handshake.accepts(2)) + + // The gate the bridge consults is the attempt check, so a stale frame + // never reaches confirmCapabilities and cannot mark the attempt ready. + XCTAssertFalse(handshake.isReady) + } + + /// A late disconnect/control/audio from the previous attempt must be + /// rejected by the same gate. + func testLateControlAndAudioFromPreviousAttemptAreRejected() { + var handshake = RemoteMicHandshake() + handshake.beginAttempt(5) + XCTAssertFalse(handshake.accepts(4), "stale control/audio attempt") + XCTAssertTrue(handshake.accepts(5)) + } + + /// A new attempt starts from a clean gate even when the old one was ready. + func testNewAttemptDoesNotInheritTheOldAttemptsState() { + var handshake = RemoteMicHandshake() + handshake.beginAttempt(1) + handshake.registerCharacteristic(.transmit) + handshake.confirmSubscription(.audio) + handshake.confirmSubscription(.control) + handshake.markCapabilitiesRequested() + XCTAssertTrue(handshake.confirmCapabilities(.default)) + XCTAssertTrue(handshake.isReady) + + handshake.beginAttempt(2) + XCTAssertFalse(handshake.isReady, "a new attempt must not start ready") + XCTAssertFalse(handshake.hasAllCharacteristics) + XCTAssertFalse(handshake.subscriptionsReady) + XCTAssertFalse(handshake.shouldRequestCapabilities) + XCTAssertFalse(handshake.accepts(1)) + } + + /// `reset` keeps the current attempt identity, so callbacks already in flight + /// for this attempt are still accepted after a transient reset. + func testResetKeepsTheCurrentAttemptIdentity() { + var handshake = RemoteMicHandshake() + handshake.beginAttempt(7) + handshake.reset() + XCTAssertTrue(handshake.accepts(7), "reset must not invalidate the live attempt") + XCTAssertEqual(handshake.attempt, 7) + } +} diff --git a/Tests/OpenTypeTests/RemoteMicPipelineIntegrationTests.swift b/Tests/OpenTypeTests/RemoteMicPipelineIntegrationTests.swift new file mode 100644 index 00000000..290b16af --- /dev/null +++ b/Tests/OpenTypeTests/RemoteMicPipelineIntegrationTests.swift @@ -0,0 +1,492 @@ +import AVFoundation +import CoreBluetooth +import XCTest +@testable import OpenType + +/// A minimal engine that reports ready without touching models, used to drive +/// the real `VoicePipeline.start` await in a test. +private final class PipelineTestEngine: SpeechEngine, @unchecked Sendable { + var isReady: Bool { true } + func transcribe(audioURL: URL?, language: String?) async throws -> String { "ok" } +} + +/// Records whether capture was reached. `AudioCaptureManager` is final, so the +/// observation point is the injected remote source rather than a subclass. +@MainActor +final class CaptureSpySource: RemoteMicCaptureSpy { + private(set) var startInvocations = 0 + private(set) var startTokens: [UInt64] = [] + var currentToken: UInt64? + var startResult = true + + func start(token: UInt64) -> Bool { + startInvocations += 1 + startTokens.append(token) + return startResult + } +} + +/// Counterexamples that cross the real `VoicePipeline.start` await and the +/// AppDelegate release path, rather than only the pure helpers. +@MainActor +final class RemoteMicPipelineIntegrationTests: XCTestCase { + private func makePipeline() -> (VoicePipeline, AppState, CaptureSpySource) { + let state = AppState() + let pipeline = VoicePipeline(appState: state) + let spy = CaptureSpySource() + pipeline.remoteCaptureSpy = spy + pipeline.engineOverride = PipelineTestEngine() + return (pipeline, state, spy) + } + + /// The old commit only guarded the layer above the pipeline: a release + /// during the model wait still reached recording. + func testReleaseDuringModelLoadDoesNotReachRecordingOrCaptureStart() async { + let (pipeline, state, spy) = makePipeline() + let token = XiaomiRemoteMicBridge.shared.beginSimulatedSessionForTesting() + + // Hold the pipeline inside the model-load await. + let gate = AsyncGate() + pipeline.engineLoadBarrier = { await gate.wait() } + + let startTask = Task { + await pipeline.start(mode: .dictation, remoteSessionToken: token) + } + await gate.waitUntilEntered() + + // The user lets go while the model is still loading. + _ = XiaomiRemoteMicBridge.shared.endSimulatedSessionForTesting() + await gate.open() + await startTask.value + + XCTAssertFalse(state.isRecording, "a released start must not reach recording") + XCTAssertEqual(spy.startInvocations, 0, "capture (and the system-mic fallback) must not start") + } + + /// A live session commits normally through the same await. + func testLiveSessionCommitsThroughTheRealAwait() async { + let (pipeline, state, spy) = makePipeline() + let token = XiaomiRemoteMicBridge.shared.beginSimulatedSessionForTesting() + + spy.currentToken = token + let gate = AsyncGate() + pipeline.engineLoadBarrier = { await gate.wait() } + let startTask = Task { + await pipeline.start(mode: .dictation, remoteSessionToken: token) + } + await gate.waitUntilEntered() + await gate.open() + await startTask.value + + XCTAssertTrue(state.isRecording, "a live session must proceed") + XCTAssertEqual(spy.startInvocations, 1) + _ = XiaomiRemoteMicBridge.shared.endSimulatedSessionForTesting() + } + + /// A superseded latch must abort too. + func testSupersededLatchDoesNotCommit() async { + let (pipeline, state, spy) = makePipeline() + let token = XiaomiRemoteMicBridge.shared.beginSimulatedSessionForTesting() + + let gate = AsyncGate() + pipeline.engineLoadBarrier = { await gate.wait() } + let startTask = Task { + await pipeline.start(mode: .dictation, remoteSessionToken: token) + } + await gate.waitUntilEntered() + // A newer press replaces the latch. + _ = XiaomiRemoteMicBridge.shared.beginSimulatedSessionForTesting() + await gate.open() + await startTask.value + + XCTAssertFalse(state.isRecording) + XCTAssertEqual(spy.startInvocations, 0) + } + + /// A local hotkey start (no remote token) is unaffected. + func testLocalStartWithoutTokenIsUnaffected() async { + let (pipeline, state, _) = makePipeline() + await pipeline.start(mode: .dictation) + XCTAssertNotEqual(state.phase, .idle, "the hotkey path must still start") + } +} + +/// A two-ended gate with a deterministic "entered" signal, so the test never +/// depends on sleep ordering and cannot deadlock. +@MainActor +private final class AsyncGate { + private var entered = false + private var enteredWaiter: CheckedContinuation? + private var openWaiter: CheckedContinuation? + private var isOpen = false + + func wait() async { + entered = true + enteredWaiter?.resume() + enteredWaiter = nil + if isOpen { return } + await withCheckedContinuation { openWaiter = $0 } + } + + func waitUntilEntered() async { + if entered { return } + await withCheckedContinuation { enteredWaiter = $0 } + } + + func open() { + isOpen = true + openWaiter?.resume() + openWaiter = nil + } +} + +/// The attempt must be captured at callback *source*, not read as the live +/// generation when the callback is delivered. On a reused `CBPeripheral` the two +/// differ. The test retains the old production delegate proxy, starts a second +/// lifecycle on the same simulated peripheral, and sends the old event through +/// that proxy's real bridge route. +private final class RemoteMicCentralTransportStats { + var stopScanCount = 0 + var scanCount = 0 + var connectCount = 0 + var cancelCount = 0 + var deinitCount = 0 +} + +private final class WeakObjectBox { + weak var value: Object? + + init(_ value: Object) { + self.value = value + } +} + +private final class RemoteMicCentralTransportFake: XiaomiRemoteMicCentralTransport { + let identity: AnyObject = NSObject() + let delegateProxy: XiaomiRemoteMicCentralDelegateProxy + var state: CBManagerState = .poweredOn + private let stats: RemoteMicCentralTransportStats + + init(bridge: XiaomiRemoteMicBridge, stats: RemoteMicCentralTransportStats) { + self.stats = stats + let proxy = XiaomiRemoteMicCentralDelegateProxy(bridge: bridge) + delegateProxy = proxy + proxy.bindManagerIdentity(identity) + } + + deinit { + stats.deinitCount += 1 + } + + func stopScan() { + stats.stopScanCount += 1 + } + + func scanForPeripherals(withServices services: [CBUUID], options: [String: Any]?) { + stats.scanCount += 1 + } + + func connect(to peripheral: AnyObject) { + stats.connectCount += 1 + delegateProxy.bindPeripheralIdentity(peripheral) + } + + func cancel(peripheral: AnyObject) { + stats.cancelCount += 1 + } +} + +@MainActor +private func waitForProductionScanFence() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { + continuation.resume() + } + } +} + +@MainActor +final class RemoteMicCallbackRoutingTests: XCTestCase { + private func makeCentralGateFixture() throws -> ( + bridge: XiaomiRemoteMicBridge, + managerIdentity: AnyObject, + peripheralIdentity: AnyObject, + attempt: UInt64 + ) { + let bridge = XiaomiRemoteMicBridge() + bridge.configureForTesting() + let stats = RemoteMicCentralTransportStats() + bridge.installCentralTransportFactoryForTesting { [weak bridge] in + guard let bridge else { + preconditionFailure("central gate fixture outlived its bridge") + } + return RemoteMicCentralTransportFake( + bridge: bridge, + stats: stats + ) + } + bridge.activate() + bridge.simulateCentralStateForTesting(.poweredOn) + let peripheralIdentity = NSObject() + let attempt = try XCTUnwrap( + bridge.simulateCentralDiscoveryForTesting(peripheralIdentity: peripheralIdentity) + ) + let managerIdentity = try XCTUnwrap(bridge.centralTransportForTesting()?.identity) + return (bridge, managerIdentity, peripheralIdentity, attempt) + } + + func testLateDisconnectFromOldSourceCannotInvalidateNewLifecycle() throws { + let bridge = XiaomiRemoteMicBridge() + bridge.configureForTesting() + + // Attempt 1 installs a source-bound production delegate proxy. + let firstAttempt = bridge.simulateConnectForTesting() + XCTAssertEqual(bridge.attemptForCurrentPeripheralForTesting(), firstAttempt) + let firstProxy = try XCTUnwrap(bridge.callbackProxyForTesting()) + + // A new attempt begins on the same peripheral object and replaces only + // the active lifecycle; the old proxy remains a valid queued-event + // source carrying attempt 1. + let secondAttempt = bridge.simulateReconnectSamePeripheralForTesting() + XCTAssertGreaterThan(secondAttempt, firstAttempt) + + // This is the production proxy -> bridge route, not a direct helper + // assertion. A live-generation lookup would tear down attempt 2 here. + firstProxy.deliverForTesting(.disconnect) + + XCTAssertTrue( + bridge.isAttemptActiveForTesting(secondAttempt), + "a stale disconnect must not invalidate the replacement lifecycle" + ) + } + + /// A stale control event must not release the live voice-key session after + /// the replacement attempt has become the tracked handshake. + func testLateControlFromOldSourceCannotReleaseNewSession() throws { + let bridge = XiaomiRemoteMicBridge() + bridge.configureForTesting() + _ = bridge.simulateConnectForTesting() + let firstProxy = try XCTUnwrap(bridge.callbackProxyForTesting()) + let secondAttempt = bridge.simulateReconnectSamePeripheralForTesting() + bridge.simulateCapabilitiesRequestedForTesting() + _ = bridge.beginSimulatedSessionForTesting() + + // STREAM_STOP is 0x00. If the old proxy were routed by the live + // generation, it would release this attempt-2 session. + firstProxy.deliverForTesting(.control(Data([0x00]))) + + XCTAssertTrue(bridge.isSessionLive, "stale control must not release the live session") + XCTAssertTrue(bridge.isAttemptActiveForTesting(secondAttempt)) + } + + /// The route is intentionally given exactly one wrong identity at a time. + /// If the manager gate is removed, this same-attempt/same-peripheral + /// failure callback would retire the active connection. + func testCentralManagerIdentityGateRejectsWrongManager() throws { + let fixture = try makeCentralGateFixture() + defer { fixture.bridge.configureForTesting() } + + fixture.bridge.routeCentralCallbackForTesting( + .didFailToConnect, + attempt: fixture.attempt, + managerIdentity: NSObject(), + peripheralIdentity: fixture.peripheralIdentity + ) + + XCTAssertTrue( + fixture.bridge.isCentralAttemptActiveForTesting(fixture.attempt), + "manager identity gate must reject a same-attempt callback from another manager" + ) + } + + /// The manager and attempt are correct here; only the peripheral identity + /// is wrong. This catches a route that trusts the reused CBPeripheral slot. + func testCentralPeripheralIdentityGateRejectsWrongPeripheral() throws { + let fixture = try makeCentralGateFixture() + defer { fixture.bridge.configureForTesting() } + + fixture.bridge.routeCentralCallbackForTesting( + .didFailToConnect, + attempt: fixture.attempt, + managerIdentity: fixture.managerIdentity, + peripheralIdentity: NSObject() + ) + + XCTAssertTrue( + fixture.bridge.isCentralAttemptActiveForTesting(fixture.attempt), + "peripheral identity gate must reject a same-attempt callback from another peripheral" + ) + } + + /// The manager and peripheral are correct here; only the proxy source + /// attempt is stale. The mutation recipe removes all source-attempt + /// comparisons together so this test cannot be masked by a second gate. + func testCentralAttemptIdentityGateRejectsWrongAttempt() throws { + let fixture = try makeCentralGateFixture() + defer { fixture.bridge.configureForTesting() } + + fixture.bridge.routeCentralCallbackForTesting( + .didFailToConnect, + attempt: fixture.attempt &+ 1, + managerIdentity: fixture.managerIdentity, + peripheralIdentity: fixture.peripheralIdentity + ) + + XCTAssertTrue( + fixture.bridge.isCentralAttemptActiveForTesting(fixture.attempt), + "source attempt gate must reject a stale attempt from the active manager and peripheral" + ) + } + + /// The real activate -> transport initializer -> discover/connect path is + /// used here. A pending connection is cancelled even though no connected + /// state has been observed; immediate reactivation stays behind the old + /// manager's terminal callback; and the old manager/proxy context is + /// released only after that callback. + func testProductionCentralRetirementCancelsPendingAndDefersFastReactivation() throws { + let bridge = XiaomiRemoteMicBridge() + bridge.configureForTesting() + defer { bridge.configureForTesting() } + + var transportStats: [RemoteMicCentralTransportStats] = [] + var weakTransports: [WeakObjectBox] = [] + var createdTransportCount = 0 + bridge.installCentralTransportFactoryForTesting { [weak bridge] in + guard let bridge else { + preconditionFailure("retirement fixture outlived its bridge") + } + let stats = RemoteMicCentralTransportStats() + transportStats.append(stats) + let transport = RemoteMicCentralTransportFake(bridge: bridge, stats: stats) + weakTransports.append(WeakObjectBox(transport)) + createdTransportCount += 1 + return transport + } + + bridge.activate() + XCTAssertEqual(createdTransportCount, 1, "activate must create the production transport") + bridge.simulateCentralStateForTesting(.poweredOn) + XCTAssertEqual(transportStats[0].scanCount, 1) + + let firstPeripheral = NSObject() + let firstAttempt = try XCTUnwrap( + bridge.simulateCentralDiscoveryForTesting(peripheralIdentity: firstPeripheral) + ) + XCTAssertEqual(bridge.attemptForCurrentPeripheralForTesting(), firstAttempt) + var firstProxy: XiaomiRemoteMicCentralDelegateProxy? = try XCTUnwrap( + bridge.centralTransportForTesting()?.delegateProxy + ) + var firstPeripheralProxy: XiaomiRemoteMicPeripheralDelegateProxy? = try XCTUnwrap( + bridge.callbackProxyForTesting() + ) + let weakFirstProxy = WeakObjectBox(firstProxy!) + let weakFirstPeripheralProxy = WeakObjectBox(firstPeripheralProxy!) + let weakFirstManagerIdentity = WeakObjectBox( + try XCTUnwrap(firstProxy?.sourceManagerIdentity) + ) + XCTAssertEqual(transportStats[0].connectCount, 1) + XCTAssertNotNil(firstProxy?.sourceManagerIdentity) + XCTAssertNotNil(firstProxy?.sourcePeripheralIdentity) + + bridge.deactivate() + XCTAssertEqual(transportStats[0].cancelCount, 1, "pending connect must be cancelled") + XCTAssertTrue(bridge.isCentralQuiescingForTesting()) + XCTAssertEqual(bridge.retiredCentralContextCountForTesting(), 1) + XCTAssertEqual(bridge.retiredPeripheralContextCountForTesting(), 1) + + // Turning the feature on again does not construct a second manager + // while the first cancellation is still pending. + bridge.activate() + XCTAssertEqual(createdTransportCount, 1) + XCTAssertTrue(bridge.isCentralQuiescingForTesting()) + + // This failure event is delivered through the old production proxy. It + // is the cancellation completion, not a Task.yield or a mutable-state + // guess; the separate late didDisconnect below must then be harmless. + firstProxy?.deliverForTesting(.didFailToConnect) + XCTAssertEqual(bridge.retiredCentralContextCountForTesting(), 0) + XCTAssertEqual(bridge.retiredPeripheralContextCountForTesting(), 0) + XCTAssertEqual(createdTransportCount, 2, "replacement starts only after completion") + XCTAssertNil( + weakTransports[0].value, + "terminal completion must release the retired transport immediately" + ) + XCTAssertEqual( + transportStats[0].deinitCount, + 1, + "terminal completion must deinitialize the retired transport exactly once" + ) + + bridge.simulateCentralStateForTesting(.poweredOn) + let secondAttempt = try XCTUnwrap( + bridge.simulateCentralDiscoveryForTesting(peripheralIdentity: firstPeripheral) + ) + let secondProxy = try XCTUnwrap(bridge.centralTransportForTesting()?.delegateProxy) + secondProxy.deliverForTesting(.didConnect) + XCTAssertTrue(bridge.isCentralAttemptActiveForTesting(secondAttempt)) + + // The old source has the same peripheral identity, but a different + // manager identity and attempt. All late old callbacks must be dropped. + firstProxy?.deliverForTesting(.didConnect) + firstProxy?.deliverForTesting(.didFailToConnect) + firstProxy?.deliverForTesting(.didDisconnect) + + XCTAssertTrue( + bridge.isCentralAttemptActiveForTesting(secondAttempt), + "late events from the retired manager must not tear down the replacement" + ) + + // Drop every test-owned strong reference after the old source has + // finished delivering. The weak boxes and deinit counter prove that + // the bridge's retired containers, not the test array, owned release. + firstProxy = nil + firstPeripheralProxy = nil + XCTAssertNil(weakFirstProxy.value) + XCTAssertNil(weakFirstPeripheralProxy.value) + XCTAssertNil(weakFirstManagerIdentity.value) + } + + /// A scan-only retirement has no peripheral terminal callback. The main + /// queue fence is explicit and non-blocking: the old transport remains + /// retained until the injected fence completion, and fast reactivation is + /// held behind it. + func testScanRetirementUsesNonBlockingFenceBeforeReactivation() async { + let bridge = XiaomiRemoteMicBridge() + bridge.configureForTesting() + defer { bridge.configureForTesting() } + + var transportStats: [RemoteMicCentralTransportStats] = [] + var weakFirstTransport: WeakObjectBox? + var createdTransportCount = 0 + bridge.installCentralTransportFactoryForTesting { [weak bridge] in + guard let bridge else { + preconditionFailure("scan-fence fixture outlived its bridge") + } + let stats = RemoteMicCentralTransportStats() + transportStats.append(stats) + let transport = RemoteMicCentralTransportFake(bridge: bridge, stats: stats) + if weakFirstTransport == nil { + weakFirstTransport = WeakObjectBox(transport) + } + createdTransportCount += 1 + return transport + } + + bridge.activate() + bridge.simulateCentralStateForTesting(.poweredOn) + XCTAssertEqual(transportStats[0].scanCount, 1) + + bridge.deactivate() + XCTAssertTrue(bridge.isCentralQuiescingForTesting()) + XCTAssertEqual(bridge.retiredCentralContextCountForTesting(), 1) + + bridge.activate() + XCTAssertEqual(createdTransportCount, 1) + await waitForProductionScanFence() + + XCTAssertFalse(bridge.isCentralQuiescingForTesting()) + XCTAssertEqual(createdTransportCount, 2) + XCTAssertNil(weakFirstTransport?.value) + XCTAssertEqual(transportStats[0].deinitCount, 1) + } +} diff --git a/Tests/OpenTypeTests/RemoteMicProtocolTests.swift b/Tests/OpenTypeTests/RemoteMicProtocolTests.swift new file mode 100644 index 00000000..8d5f61d1 --- /dev/null +++ b/Tests/OpenTypeTests/RemoteMicProtocolTests.swift @@ -0,0 +1,87 @@ +import Foundation +import XCTest +@testable import OpenType + +final class RemoteMicProtocolTests: XCTestCase { + func testCapabilityFrameParsesV10StereoCodec() throws { + let payload = Data([0x0B, 0x01, 0x00, 0x02, 0x03, 0x00, 0x78]) + let capabilities = try XCTUnwrap(RemoteMicCapabilities.parse(payload)) + XCTAssertEqual(capabilities.version, 0x0100) + XCTAssertEqual(capabilities.frameSize, 120) + XCTAssertEqual(capabilities.selectedCodec, 0x02) + XCTAssertEqual(capabilities.sampleRate, 16_000) + XCTAssertTrue(RemoteMicProtocol.supportsAudio(sampleRate: capabilities.sampleRate)) + } + + func testCapabilityFrameRejectsNonCapabilityOpcode() { + XCTAssertNil(RemoteMicCapabilities.parse(Data([0x00, 0x01, 0x00, 0x02, 0x03, 0x00, 0x78]))) + XCTAssertNil(RemoteMicCapabilities.parse(Data([0x0B, 0x01]))) + } + + func testCapabilityFrameFallsBackTo8kHzCodec() throws { + let payload = Data([0x0B, 0x01, 0x00, 0x01, 0x03, 0x00, 0x78]) + let capabilities = try XCTUnwrap(RemoteMicCapabilities.parse(payload)) + XCTAssertEqual(capabilities.selectedCodec, 0x01) + XCTAssertEqual(capabilities.sampleRate, 8_000) + XCTAssertFalse(RemoteMicProtocol.supportsAudio(sampleRate: capabilities.sampleRate)) + } + + func testControlCommandsFollowTheProtocolVersion() { + XCTAssertEqual(RemoteMicProtocol.microphoneOpen(version: 0x0100, codec: 0x02), Data([0x0C, 0x00])) + XCTAssertEqual(RemoteMicProtocol.microphoneOpen(version: 0x0010, codec: 0x02), Data([0x0C, 0x00, 0x02])) + XCTAssertEqual(RemoteMicProtocol.microphoneClose(version: 0x0100, sessionID: 7), Data([0x0D, 0x07])) + XCTAssertEqual(RemoteMicProtocol.microphoneClose(version: 0x0010, sessionID: 7), Data([0x0D])) + } + + func testADPCMDecodesHighNibbleFirst() { + let decoder = RemoteMicADPCMDecoder() + XCTAssertEqual(decoder.decode(Data([0x70])), [11, 13]) + XCTAssertEqual(decoder.predictor, 13) + } + + func testADPCMContinuesPredictorAcrossFramesAndResetsOnSync() { + let decoder = RemoteMicADPCMDecoder() + XCTAssertEqual(decoder.decode(Data([0x77])), [11, 41]) + + decoder.reset(predictor: 100, stepIndex: 4) + XCTAssertEqual(decoder.decode(Data([0x00])), [101, 102]) + XCTAssertEqual(decoder.predictor, 102) + } + + func testADPCMClampsPredictorToInt16Bounds() { + let decoder = RemoteMicADPCMDecoder() + decoder.reset(predictor: 32_700, stepIndex: 88) + let samples = decoder.decode(Data([0xFF, 0xFF, 0xFF])) + XCTAssertEqual(samples.first, -28_736) + XCTAssertTrue(samples.contains(-32_768)) + XCTAssertTrue(samples.allSatisfy { $0 >= -32_768 && $0 <= 32_767 }) + } + + func testFrameAccumulatorSplitsExactlySizedFrames() { + var accumulator = RemoteMicFrameAccumulator() + var stream = Data(repeating: 1, count: 250) + let frames = accumulator.append(stream, frameSize: 120) + XCTAssertEqual(frames.count, 2) + XCTAssertEqual(frames[0].count, 120) + XCTAssertEqual(frames[1].count, 120) + XCTAssertEqual(accumulator.pending.count, 10) + + accumulator.reset() + XCTAssertTrue(accumulator.pending.isEmpty) + stream = Data([0x00]) + XCTAssertTrue(accumulator.append(stream, frameSize: 0).isEmpty) + } + + func testPCMSmoothingUsesNeighborAverage() { + let processed = RemoteMicPCM.process([0, 400, 0, 0], gainDB: 0) + XCTAssertEqual(processed, [0, 200, 100, 0]) + } + + func testPCMGainIsAppliedAndClamped() { + XCTAssertEqual(RemoteMicPCM.process([0, 200, 100, 0], gainDB: 20), [0, 1_250, 1_000, 0]) + + let clamped = RemoteMicPCM.process([30_000, 30_000, 30_000], gainDB: 24) + XCTAssertTrue(clamped.allSatisfy { $0 <= 32_767 && $0 >= -32_768 }) + XCTAssertTrue(RemoteMicPCM.process([], gainDB: 20).isEmpty) + } +} diff --git a/Tests/OpenTypeTests/RemoteMicReleasePathTests.swift b/Tests/OpenTypeTests/RemoteMicReleasePathTests.swift new file mode 100644 index 00000000..4a2c6bdc --- /dev/null +++ b/Tests/OpenTypeTests/RemoteMicReleasePathTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import OpenType + +/// The release path must not discard the recording it is about to transcribe. +/// +/// The old commit called `cancelSession()` before `stopRecording()` on every +/// release; `cancelSession` nils `lastRecordingURL`, so the pipeline read nil +/// and had nothing to transcribe. +@MainActor +final class RemoteMicReleasePathTests: XCTestCase { + /// A committed recording must stop, not be cancelled: cancelling nils the + /// WAV that the pipeline is about to transcribe. This drives the production + /// `applyRelease` wiring, not just the decision value. + func testCommittedRecordingIsStoppedAndNotCancelled() { + let target = FakeCaptureTarget(hasActiveRecording: true, isRunning: true) + var stopped = false + let decision = RemoteMicReleaseDecision.applyRelease( + to: target, + stopPipeline: { stopped = true } + ) + + XCTAssertEqual(target.cancelCount, 0, "a committed recording must not be cancelled (the WAV is needed)") + XCTAssertTrue(stopped, "the pipeline must be stopped so it can transcribe") + XCTAssertTrue(decision.shouldStopPipeline) + } + + /// A start that never committed is cancelled, so nothing is recorded. + func testUncommittedStartIsCancelledAndPipelineNotStopped() { + let target = FakeCaptureTarget(hasActiveRecording: false, isRunning: true) + var stopped = false + _ = RemoteMicReleaseDecision.applyRelease( + to: target, + stopPipeline: { stopped = true } + ) + + XCTAssertEqual(target.cancelCount, 1, "the pending start must be abandoned") + XCTAssertFalse(stopped, "nothing committed, so nothing to stop") + } + + /// With no session at all, a release does nothing. + func testIdleReleaseDoesNothing() { + let target = FakeCaptureTarget(hasActiveRecording: false, isRunning: false) + var stopped = false + _ = RemoteMicReleaseDecision.applyRelease( + to: target, + stopPipeline: { stopped = true } + ) + + XCTAssertEqual(target.cancelCount, 0) + XCTAssertFalse(stopped) + } + + /// Disabling the feature while recording must stop the pipeline, because the + /// bridge's release callback is suppressed once the setting is off. + func testDisablingWhileRecordingStillStopsThePipeline() { + let decision = RemoteMicShutdownDecision.decide(hasActiveRecording: true) + XCTAssertTrue(decision.shouldStopPipeline) + } + + func testDisablingWhileIdleDoesNotStop() { + let decision = RemoteMicShutdownDecision.decide(hasActiveRecording: false) + XCTAssertFalse(decision.shouldStopPipeline) + } +} + +/// A fake capture target for the release decision. +@MainActor +private final class FakeCaptureTarget: RemoteMicReleaseTarget { + let hasActiveRecording: Bool + let isRunning: Bool + private(set) var cancelCount = 0 + + init(hasActiveRecording: Bool, isRunning: Bool) { + self.hasActiveRecording = hasActiveRecording + self.isRunning = isRunning + } + + func cancelSession() { cancelCount += 1 } +} + +/// Idle or late audio must not enter the next session's pre-roll. This exercises +/// the production routing rule the bridge uses, not a copy of it. +final class RemoteMicAudioRoutingTests: XCTestCase { + func testIdleAudioIsDropped() { + XCTAssertEqual(RemoteMicAudioRouting.destination(for: .idle), .drop) + } + + func testStartingAudioIsPreRolled() { + XCTAssertEqual(RemoteMicAudioRouting.destination(for: .starting), .preRoll) + } + + func testRecordingAudioIsForwarded() { + XCTAssertEqual(RemoteMicAudioRouting.destination(for: .recording), .forward) + } + + func testIdleToPressToReleaseKeepsOnlyTheStartingChunk() { + var session = RemoteMicSession() + var preRoll = RemoteMicPreRoll(capacity: 4) + var forwarded = 0 + + func deliver() { + switch RemoteMicAudioRouting.destination(for: session.phase) { + case .forward: forwarded += 1 + case .preRoll: preRoll.append([1]) + case .drop: break + } + } + + deliver() // idle: dropped + XCTAssertTrue(preRoll.isEmpty) + + _ = session.press() + deliver() // starting: buffered + XCTAssertEqual(preRoll.retainedChunks, 1) + + _ = session.release() + deliver() // late: dropped + XCTAssertEqual(preRoll.retainedChunks, 1, "late audio must not pollute the next session") + XCTAssertEqual(forwarded, 0) + } +} diff --git a/Tests/OpenTypeTests/RemoteMicSessionTests.swift b/Tests/OpenTypeTests/RemoteMicSessionTests.swift new file mode 100644 index 00000000..79d0780e --- /dev/null +++ b/Tests/OpenTypeTests/RemoteMicSessionTests.swift @@ -0,0 +1,189 @@ +import Foundation +import XCTest +@testable import OpenType + +/// Counterexamples for the remote voice-key session lifecycle. +final class RemoteMicSessionTests: XCTestCase { + // MARK: - The "release before start commits" case + + func testReleaseBeforeStartCommitsCancelsThePendingStart() { + var session = RemoteMicSession() + let token = session.press() + XCTAssertTrue(session.isStarting) + + XCTAssertTrue(session.release(), "a release before the start commits must cancel it") + XCTAssertFalse(session.isLive) + + XCTAssertFalse( + session.commitStart(token: token), + "the cancelled start must not begin recording" + ) + XCTAssertFalse(session.isRecording) + } + + func testLateStartCompletionAfterReleaseIsRejected() { + var session = RemoteMicSession() + let first = session.press() + _ = session.release() + + // A second, independent press starts before the stale completion lands. + let second = session.press() + XCTAssertFalse( + session.commitStart(token: first), + "a stale token cannot commit the new generation" + ) + XCTAssertTrue(session.commitStart(token: second)) + XCTAssertTrue(session.isRecording) + } + + func testDisconnectBeforeStartCompletesCancelsIt() { + var session = RemoteMicSession() + let token = session.press() + XCTAssertTrue(session.invalidate(), "disconnect must invalidate a pending start") + XCTAssertFalse(session.commitStart(token: token)) + XCTAssertFalse(session.isLive) + } + + // MARK: - The normal path + + func testNormalPressStartReleaseStop() { + var session = RemoteMicSession() + let token = session.press() + XCTAssertTrue(session.commitStart(token: token)) + XCTAssertTrue(session.isRecording) + XCTAssertTrue(session.release()) + XCTAssertFalse(session.isLive) + } + + func testStartMayOnlyCommitOnce() { + var session = RemoteMicSession() + let token = session.press() + XCTAssertTrue(session.commitStart(token: token)) + XCTAssertFalse(session.commitStart(token: token), "a second commit must be rejected") + XCTAssertTrue(session.isRecording) + } + + func testReleaseWithoutPressDoesNothing() { + var session = RemoteMicSession() + XCTAssertFalse(session.release(), "no session to stop") + XCTAssertFalse(session.invalidate()) + } + + func testDoubleReleaseStopsOnlyOnce() { + var session = RemoteMicSession() + let token = session.press() + _ = session.commitStart(token: token) + XCTAssertTrue(session.release()) + XCTAssertFalse(session.release(), "the second release must not report again") + } + + func testRapidPressesKeepOnlyTheLatestGeneration() { + var session = RemoteMicSession() + let first = session.press() + let second = session.press() + XCTAssertNotEqual(first, second) + XCTAssertFalse(session.commitStart(token: first)) + XCTAssertTrue(session.commitStart(token: second)) + } + + func testInvalidateWhileRecordingReportsLive() { + var session = RemoteMicSession() + let token = session.press() + _ = session.commitStart(token: token) + XCTAssertTrue(session.invalidate(), "closing the feature while recording must stop it") + XCTAssertFalse(session.isLive) + } +} + +/// The pre-roll must retain the opening audio without growing without bound. +final class RemoteMicPreRollTests: XCTestCase { + func testDrainReturnsChunksInOrder() { + var preRoll = RemoteMicPreRoll(capacity: 4) + preRoll.append([1, 2]) + preRoll.append([3, 4]) + + XCTAssertEqual(preRoll.retainedChunks, 2) + XCTAssertEqual(preRoll.retainedFrames, 4) + XCTAssertEqual(preRoll.drain(), [[1, 2], [3, 4]]) + XCTAssertTrue(preRoll.isEmpty, "drain empties the buffer") + } + + func testBoundedBufferDropsOldestChunks() { + var preRoll = RemoteMicPreRoll(capacity: 2) + preRoll.append([1]) + preRoll.append([2]) + preRoll.append([3]) + + XCTAssertEqual(preRoll.retainedChunks, 2, "must not grow past capacity") + XCTAssertEqual(preRoll.drain(), [[2], [3]], "oldest chunk dropped") + } + + func testEmptySamplesAreIgnored() { + var preRoll = RemoteMicPreRoll() + preRoll.append([]) + XCTAssertTrue(preRoll.isEmpty) + } + + func testResetClearsEverything() { + var preRoll = RemoteMicPreRoll() + preRoll.append([1, 2, 3]) + preRoll.reset() + XCTAssertTrue(preRoll.isEmpty) + XCTAssertEqual(preRoll.retainedFrames, 0) + } +} + +/// The bridge's event ordering: the session must latch on the start event, and a +/// stop that arrives before the pipeline commits must cancel it. +final class RemoteMicSessionOrderingTests: XCTestCase { + /// `START_SEARCH → AUDIO_START → AUDIO → AUDIO_STOP`. + func testStartSearchThenAudioStartThenStop() { + var session = RemoteMicSession() + // AUDIO_START latches the session. + let token = session.press() + XCTAssertTrue(session.isStarting) + + // AUDIO frames arrive while starting; they are pre-rolled, not dropped. + var preRoll = RemoteMicPreRoll(capacity: 4) + preRoll.append([1, 2]) + preRoll.append([3, 4]) + XCTAssertFalse(preRoll.isEmpty) + + // The pipeline commits. + XCTAssertTrue(session.commitStart(token: token)) + XCTAssertTrue(session.isRecording) + XCTAssertEqual(preRoll.drain().flatMap { $0 }, [1, 2, 3, 4]) + + // AUDIO_STOP. + XCTAssertTrue(session.release()) + XCTAssertFalse(session.isLive) + } + + /// Direct `AUDIO_START → AUDIO → AUDIO_STOP` (no `START_SEARCH`). + func testDirectAudioStartThenStop() { + var session = RemoteMicSession() + let token = session.press() + XCTAssertTrue(session.commitStart(token: token)) + XCTAssertTrue(session.release()) + } + + /// A short press: AUDIO_START then AUDIO_STOP before the pipeline commits. + func testShortPressDoesNotStartArecording() { + var session = RemoteMicSession() + let token = session.press() + // Stop lands first. + XCTAssertTrue(session.release()) + // The late commit must be rejected. + XCTAssertFalse(session.commitStart(token: token)) + XCTAssertFalse(session.isRecording) + } + + /// Closing the feature mid-start must end the session. + func testDeactivateMidStartEndsSession() { + var session = RemoteMicSession() + let token = session.press() + XCTAssertTrue(session.invalidate()) + XCTAssertFalse(session.commitStart(token: token)) + XCTAssertFalse(session.isLive) + } +} diff --git a/Tests/OpenTypeTests/RemoteMicStartGuardTests.swift b/Tests/OpenTypeTests/RemoteMicStartGuardTests.swift new file mode 100644 index 00000000..e5c2af54 --- /dev/null +++ b/Tests/OpenTypeTests/RemoteMicStartGuardTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import OpenType + +/// Counterexamples for the remote voice-key start committing through the real +/// pipeline path. These model "the pipeline finished loading the model" and then +/// decide; the old commit (`1f27b1fb`) only guarded the layer above the +/// pipeline, so a release during model load still reached recording. +final class RemoteMicStartGuardTests: XCTestCase { + /// The reviewer's exact case: the key was released while the pipeline was + /// awaiting the model, so the start must not commit. + func testReleasedDuringModelLoadDoesNotCommit() { + let committed = RemoteMicStartGuard.shouldCommit( + remoteSessionToken: 7, + isCancelled: false, + isSessionCurrent: { _ in false } + ) + XCTAssertFalse(committed, "a released session must not begin recording") + } + + /// Cancelling the owning task must abort even if the bridge still reports the + /// latch live (release ordering races). + func testCancelledOwningTaskDoesNotCommit() { + let committed = RemoteMicStartGuard.shouldCommit( + remoteSessionToken: 7, + isCancelled: true, + isSessionCurrent: { _ in true } + ) + XCTAssertFalse(committed, "a cancelled start must not begin recording") + } + + /// A live, uncancelled session commits normally. + func testLiveSessionCommits() { + let committed = RemoteMicStartGuard.shouldCommit( + remoteSessionToken: 7, + isCancelled: false, + isSessionCurrent: { $0 == 7 } + ) + XCTAssertTrue(committed) + } + + /// A local (hotkey) start has no remote latch and must keep working. + func testLocalStartWithoutRemoteTokenCommits() { + let committed = RemoteMicStartGuard.shouldCommit( + remoteSessionToken: nil, + isCancelled: false, + isSessionCurrent: { _ in false } + ) + XCTAssertTrue(committed, "the hotkey path must be unaffected") + } + + /// A superseded latch (a newer press owns the bridge) must not commit. + func testSupersededLatchDoesNotCommit() { + let committed = RemoteMicStartGuard.shouldCommit( + remoteSessionToken: 7, + isCancelled: false, + isSessionCurrent: { $0 == 8 } + ) + XCTAssertFalse(committed) + } +} + +/// Couples the guard to the real bridge latch, so the test exercises the same +/// predicate the pipeline uses rather than a stand-in. +final class RemoteMicBridgeLatchTests: XCTestCase { + func testBridgeReportsNoCurrentSessionWhenIdle() { + let bridge = XiaomiRemoteMicBridge() + bridge.deactivate() + XCTAssertNil(bridge.currentSessionToken) + XCTAssertFalse(XiaomiRemoteMicBridge.isSessionCurrent(1)) + } +} diff --git a/docs/sdlc/changes/2026-09-21-remote-mic-integration/acceptance-handoff.md b/docs/sdlc/changes/2026-09-21-remote-mic-integration/acceptance-handoff.md new file mode 100644 index 00000000..ec887f39 --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-remote-mic-integration/acceptance-handoff.md @@ -0,0 +1,110 @@ +# Human acceptance handoff: Xiaomi remote wireless microphone + +**Status:** ready for human acceptance; not approved for merge or release +**Artifact source / #104 head at build:** `31b3c7f614656c59855b7fd556734a11543fa9eb` +**Verified product code/test head:** `0998430e3e28215949d1b8d7398c795e4ab47923` +**Head relationship:** `31b3c7f6` is a documentation-only descendant of the verified product head; Sources and Tests are unchanged. +**Verified code/test tree:** Sources `7bf86068d1142fdd2d1c1bae82131997f780356c`; Tests `431612931b709adf60b1f3b8180bc1c22b558418` +**Upstream:** `verification.md` + +## Downloadable CI acceptance artifact + +The approved fixed-head artifact workflow completed successfully: + +- Run: [`35684827747`](https://github.com/IchenDEV/utter/actions/runs/35684827747), conclusion `success`. +- Source SHA built: `31b3c7f614656c59855b7fd556734a11543fa9eb`. +- Workflow definition SHA: `1af61dbc60ef141c53ce73f5f777d984f271d378`. +- Runner: `macos-26-arm64`, macOS 26.6.2; Xcode 26.6 (`17F113`). +- Artifact: [`utter-app-31b3c7f614656c59855b7fd556734a11543fa9eb`](https://github.com/IchenDEV/utter/actions/runs/35684827747/artifacts/10676765582), id `10676765582`, 33,050,656 bytes, expires 2026-10-06. +- GitHub artifact ZIP digest: `sha256:35da580f0a9652603dcab7982cc6b3a798196aa58e019aeb7dce7a5fe5e392ca`. +- Inner app archive: `Utter-31b3c7f614656c59855b7fd556734a11543fa9eb.app.zip`. +- Inner app archive SHA-256: `3a54ea580c2277c5cd3398a8d2912830b56fa3e3cd57c82843bf12b4f8aaf3c9`. +- App main binary SHA-256: `db38a824064371f8438a8afc9631fab4975facbbb4641c5564728e00a9bf1ee8`. +- Retention: 14 days. Download requires GitHub access to the Actions artifact while it is retained. + +The successful run checked out the exact source SHA with a clean tree, built the +Release arm64 app and CLI helper, compiled AppIcon, applied ad-hoc hardened- +runtime signing, ran `verify-release-artifact.sh`, archived with `ditto`, +re-extracted the archive, ran release-artifact verification again, and uploaded +the archive, checksum file, and manifest. + +The first delivery run +[`35683480777`](https://github.com/IchenDEV/utter/actions/runs/35683480777) +is intentionally preserved as failed evidence. The product build, signing, and +release-artifact verification succeeded, then the workflow passed the `.app` +directory rather than `Contents/Info.plist` to `PlistBuddy` and exited 1. +Workflow-only PR #107 corrected that lookup before the successful run. The +one-time push bootstrap was removed after upload; the default-branch workflow +is manual-dispatch only and remains hard-locked to the source SHA above. + +The earlier fixed-head PR verification remains available at +[`35626929279`](https://github.com/IchenDEV/utter/actions/runs/35626929279): +Contract & Tests, Release-style App Build, and SDLC Gate all succeeded. That +earlier run uploaded no product artifact; it is superseded for artifact +delivery by `35684827747`, not erased. + +## Earlier Mac mini evidence + +The Mac mini foreground build and regression evidence remains valid and +separate from the CI artifact: + +- `bash scripts/build-app.sh --app-only`: exit 0, Release arm64 app and CLI + helper built, AppIcon compiled, ad-hoc hardened-runtime signing and + `verify-release-artifact.sh` passed. +- Local main binary SHA-256: + `a0965499a77c35f2dfddb1ad1935b566cecae428cc3513cfc0ffad87d86611ad`. +- Source bundle SHA-256: + `021ed0088aaf8db0df1f7d6afc452d7c9115b7a00e65bb87cffcfd6d5b8b2651`. +- Mutation script SHA-256: + `52dcad02bc0fbcea2ced082705b0764c004fa73642f0f59c2500b07879da170c`. + +These are provenance records, not substitutes for the downloadable CI +artifact and its own checksums above. + +## Licensing decision required + +`IchenDEV/remote-mic-app` is GPL-3.0-only while Utter is MIT. Development +review does not determine whether this implementation is independent, +derivative, adequately attributed, or distributable. The CI artifact exists +for acceptance testing; its construction does not settle distribution rights. +Before merge or release, the human licensing owner must record one explicit +decision: accept with rationale and required notices, require attribution or +code changes, require a clean-room rewrite, or reject distribution. + +## Real-device acceptance procedure + +Record tester, date, macOS version, remote model/firmware, artifact SHA-256, and +app logs. Download the artifact above and first verify both the GitHub artifact +digest and the inner app archive SHA-256. + +1. Extract `Utter-31b3c7f614656c59855b7fd556734a11543fa9eb.app.zip`. + Because the app is ad-hoc signed, clear downloaded quarantine if required + with `xattr -cr Utter.app`, then launch it. +2. Pair the Xiaomi Bluetooth Remote 2 Pro in System Settings → Bluetooth. +3. In Utter Settings → General, enable “Xiaomi remote wireless mic”; accept the + Bluetooth permission and confirm the state reaches connected. +4. Hold the remote voice key and speak. Confirm Utter starts recording, the + level/activity indicators respond, and text is inserted. +5. Release the key. Confirm recording stops once and the final spoken audio is + not clipped or discarded. +6. Disable the feature during an active or starting session. Confirm recording + terminates and the UI returns to the expected idle state. +7. Disconnect the remote while starting and while recording. Confirm no late + callback restarts or tears down a replacement session, and the state returns + to scanning/retrying. +8. Reconnect the same remote and repeat a complete press/speak/release session. +9. With the feature disabled, and with it enabled but no remote ready, confirm + the existing system microphone fallback still records normally. +10. Preserve the app log, screenshots of permission/connected/recording/idle + states, and one non-sensitive WAV inspection confirming 16 kHz mono input. + +## Acceptance record + +- Downloadable CI artifact and checksums: delivered above. +- Hardware result: pending real-device execution. +- Permission/privacy result: pending real-device execution. +- Licensing decision: pending human determination; artifact construction is not + distribution approval. +- Resource-risk decision: pending release owner. +- CODEOWNERS/SDLC approval: pending. +- Product merge/release approval: pending; #104 remains open and unmerged. diff --git a/docs/sdlc/changes/2026-09-21-remote-mic-integration/intent.md b/docs/sdlc/changes/2026-09-21-remote-mic-integration/intent.md new file mode 100644 index 00000000..b15b5686 --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-remote-mic-integration/intent.md @@ -0,0 +1,66 @@ +# Intent: Xiaomi remote wireless microphone in Utter + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** — + +## Problem + +The user wants the wireless-mic capability of `IchenDEV/remote-mic-app` inside +Utter so a Xiaomi Bluetooth Remote 2 Pro can act as Utter's microphone without +installing a second application. remote-mic-app needs its own app plus a +BlackHole-derived `MiRemoteV 2ch` audio driver, and it only feeds that driver; +the user then has to point each target app at the virtual device. + +## Outcome + +When enabled, Utter connects to the remote over Bluetooth, decodes the remote's +ATVV voice stream in-process, and uses those samples as its recording input. +The remote's voice key drives Utter's recording directly through the ATVV +control channel (`AUDIO_START`/`AUDIO_STOP`), which is the interaction model the +device uses when the host has not opened the microphone itself; no HID key remap +or Input Monitoring permission is involved. While a recording is active the +audio comes from the remote instead of a CoreAudio device. No virtual audio +driver and no second app are required. + +## Scope + +Affected: a new `Sources/RemoteMic/` module (ATVV profile, ADPCM decoder, BLE +central, capture source), `AudioCaptureManager` source selection, the General +settings audio section, Bluetooth usage text and entitlement, and localizations. + +Non-goals: remote button remapping, battery display, sending the remote's audio +to other apps, replacing the existing wired/Built-in microphone path, or changing +the speech engines. + +## Constraints + +- Default off; enabling it is the only thing that may trigger Bluetooth access. +- No new package dependency; CoreBluetooth only. +- Samples are processed in memory, matching Utter's no-audio-upload posture. +- The existing system-microphone path must stay selectable and unaffected. +- A remote that is unavailable, unauthorized, or unsupported must fall back to + the system input instead of failing the recording. + +## Acceptance criteria + +- With the setting off, recording is byte-for-byte the existing path. +- With the setting on and a remote connected, recording uses the decoded 16 kHz + mono stream; the WAV, activity gate, level meter, and streaming buffers behave + like the built-in path. +- With the setting on and no remote connected, recording still succeeds with the + system input. +- The ATVV capability parsing, framing, ADPCM decode, and PCM post-processing are + covered by deterministic unit tests. +- Localizations stay in parity and the two check scripts pass. + +## Open questions + +- Licensing: remote-mic-app's app code is GPL-3.0-only while Utter is MIT. This + SDLC record makes no determination about whether the implementation, + attribution, or distribution position is acceptable. A human licensing owner + must review the provenance and decide whether to accept, attribute, rework, or + reject the change before the feature is enabled or distributed. +- Hardware: the remote's firmware behavior (voice-key timing, reconnect) can only + be confirmed on a real device by someone with the remote. diff --git a/docs/sdlc/changes/2026-09-21-remote-mic-integration/plan.md b/docs/sdlc/changes/2026-09-21-remote-mic-integration/plan.md new file mode 100644 index 00000000..27fa4cd3 --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-remote-mic-integration/plan.md @@ -0,0 +1,61 @@ +# Plan: Xiaomi remote wireless microphone in Utter + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** `spec.md` + +## Work items + +- [x] `RemoteMicProtocol`: UUIDs, opcodes, capability parsing, ADPCM decoder, + frame accumulator, PCM smoothing/gain. +- [x] `RemoteMicHandshake`: subscription gate, request-once, readiness. +- [x] `RemoteMicWantedState`: no residue after a failed start. +- [x] Voice key on the ATVV control channel drives Utter's recording path. +- [x] Connection/initialization timeouts, `didFailToConnect`, generation guard. +- [x] `XiaomiRemoteMicBridge`: CoreBluetooth central, ATVV handshake, streaming, + reconnect, observable state. +- [x] `RemoteMicCaptureManager`: WAV/activity/level/buffer capture surface. +- [x] `AudioCaptureManager` source selection with system-input fallback. +- [x] Inject the shared remote source in `VoicePipeline` and + `InputSessionCoordinator`. +- [x] `AppSettings.remoteMicEnabled` / `remoteMicGainDB` with persistence. +- [x] Settings toggle, live state, gain; `AppDelegate` activate/deactivate. +- [x] `NSBluetoothAlwaysUsageDescription` and the Bluetooth entitlement. +- [x] en/zh-Hans strings, `RemoteMicProtocolTests`, `RemoteMicHandshakeTests`, + `RemoteMicWantedStateTests`, `RemoteMicSessionTests`, + `RemoteMicPreRollTests`. +- [x] Session latch (`RemoteMicSession`) so a release beating the start cancels + it, and a bounded pre-roll (`RemoteMicPreRoll`) so the opening word is not + clipped. +- [x] `endCapture` closes the microphone once in every phase; `deactivate` + invalidates the session. +- [x] Capability responses require a prior request; `didUpdateValueFor` checks + peripheral identity. +- [x] Correct `0x08` to `START_SEARCH` and cite the AOSP ATVV reference firmware. +- [x] Thread the session latch through the real `VoicePipeline.start` and + re-check it after the model wait (`RemoteMicStartGuard`), so a release + during a cold start aborts instead of recording or falling back. +- [x] Bind an attempt identity to the handshake and every control/audio + callback, so a stale callback on a reused peripheral is rejected even + after the new attempt has requested capabilities. + +## Verification plan + +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `bash scripts/sdlc-checks.sh` +- [x] `swift test` (full suite) +- [ ] Real Xiaomi Bluetooth Remote 2 Pro: pair, enable, record via the remote's + voice key, then disconnect and reconnect mid-session. +- [ ] Human licensing owner records the GPL/MIT provenance, attribution, and + distribution decision; the development review makes no licensing finding. +- [ ] Human reviewer confirms the permission/privacy path. + +## Human gates + +- Licensing decision on the GPL-3.0/MIT provenance, attribution, and + distribution position. No acceptance or independence conclusion is recorded + by the development review. +- Independent verification on real hardware before enabling the setting for + users. +- Merge approval for a change that adds a device permission. diff --git a/docs/sdlc/changes/2026-09-21-remote-mic-integration/spec.md b/docs/sdlc/changes/2026-09-21-remote-mic-integration/spec.md new file mode 100644 index 00000000..2bb7d135 --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-remote-mic-integration/spec.md @@ -0,0 +1,124 @@ +# Spec: Xiaomi remote wireless microphone in Utter + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** `intent.md` + +## Context + +`AudioCaptureManager` owns the only capture path: it taps `AVAudioEngine`'s input +node at the device's native format, writes a temp WAV, accumulates +`AudioCaptureActivity`, and forwards copied buffers to the streaming engine. +`VoicePipeline` and `InputSessionCoordinator` each own one instance and read +`lastRecordingURL` / `lastActivity` after stopping. `AppSettings` persists user +preferences; the General tab hosts the microphone picker. + +The remote speaks the ATVV profile over GATT: service `ab5e0001-…`, transmit, +audio, and control characteristics; a `GET_CAPABILITIES` request (`0x0A 01 00 00 +03 03`) yields a `0x0B` capability frame carrying version, codec mask, and frame +size. Audio notifications are IMA/DVI ADPCM nibbles that decode to 16 kHz mono +`Int16`. Frames are fixed-size and the predictor/step state persists until a +`0x0A` sync packet resets it. Only 16 kHz is accepted. + +## Design + +`Sources/RemoteMic/` (all new, no dependency): + +- `RemoteMicProtocol` — profile UUIDs, opcodes, capability frame parsing, ADPCM + decoder, frame accumulator, PCM smoothing/gain. Pure and unit-tested. +- `XiaomiRemoteMicBridge` — `CBCentralManager` with `queue: .main`. Scans for + the ATVV service, connects, discovers the three characteristics, subscribes to + audio/control, requests capabilities, sends microphone open/close, decodes + audio, and republishes state through `ObservableObject`. Reconnects with + exponential backoff while the feature is active. All callbacks arrive on the + main thread, which matches the codebase's existing non-isolated capture style. + The handshake is ordered and bounded: `RemoteMicHandshake` only allows the + capability request after both notifications are confirmed by + `didUpdateNotificationStateFor`, a connection and an initialization timeout + bound each attempt, `didFailToConnect` recovers, and a monotonic `generation` + rejects late callbacks from a failed attempt. +- `RemoteMicSession` — the press → start → release → stop latch, so a release + that beats the asynchronous start cancels it instead of being ignored. +- `RemoteMicPreRoll` — a bounded buffer for audio that arrives before the + pipeline commits. +- `RemoteMicWantedState` — the "does a session want audio" invariant, shared by + the bridge and the capture manager so a failed start cannot leave a latent + want that a later readiness would act on. +- `RemoteMicCaptureManager` — mirrors the capture surface of + `AudioCaptureManager`: temp 16 kHz mono WAV, `AudioCaptureActivity`, level + callback, streamed `AVAudioPCMBuffer`s. + +`AudioCaptureManager` gains an optional `remoteMicSource` and a +`usesRemoteMic` flag. `start` tries the remote first when +`AppSettings.remoteMicEnabled` is on and the bridge is ready; otherwise it takes +the existing AVAudioEngine path. `lastRecordingURL` / `lastActivity` / +`cleanupLastRecording` / `stop` dispatch to whichever source is active, so the +pipeline and integration coordinator need no changes. `VoicePipeline` and +`InputSessionCoordinator` inject `RemoteMicCaptureManager.shared`. + +`AppSettings` adds `remoteMicEnabled` (default false) and `remoteMicGainDB` +(default 12, 0–24). `AppDelegate` observes the setting and activates/deactivates +the bridge; `applicationWillTerminate` deactivates it. The General tab adds the +toggle, a live connection state, and the gain slider, and disables the system +device picker while the remote is enabled. + +The remote's voice key arrives on the ATVV control channel. Per the AOSP ATVV +reference firmware, a PTT press sends `AUDIO_START` (0x04) directly and the host +does not have to open the microphone first, so the session is latched on +`AUDIO_START`; `0x08` is the device's `START_SEARCH`, not a microphone-open +request. `AppDelegate` maps the latch onto the same `startRecording` / +`stopRecording` path the configured hotkey uses, but with an explicit latch: + +- the bridge latches the session synchronously and hands over a token; +- audio that arrives before the pipeline commits is held in a bounded pre-roll + and drained on commit, so the opening word is not clipped; +- a release, disconnect, or feature shutdown that arrives before the commit + cancels the pending start, so a short press cannot begin a recording; and +- `endCapture` closes the microphone exactly once, in every phase. + +This avoids a separate device-level HID F5→Fn remap and the Input Monitoring +permission such a remap would need. + +## Safety and failure modes + +- Default off. Bluetooth is only touched once the user enables the setting, so + apps that never opt in never see a Bluetooth permission prompt. +- `NSBluetoothAlwaysUsageDescription` and `com.apple.security.device.bluetooth` + are declared; the app is not sandboxed, so no other capability changes. +- Audio is decoded in memory and written only to Utter's temp recording file; + it is never transmitted. Logging is state-only (no audio, no device IDs). +- If the remote is not ready, unauthorized, unsupported, or the 16 kHz codec is + absent, `RemoteMicCaptureManager.start` returns false and + `AudioCaptureManager` falls back to the system input. +- Disconnect or stream stop clears the decoder/accumulator and drops the + partial frame, so a later session cannot inherit stale ADPCM state. +- A start that fails after wanting audio tears down the callback, the want, and + the temp file, so a system-input fallback cannot be hijacked by a later + readiness. + +## Test strategy + +`RemoteMicProtocolTests` covers capability parsing (v1.0 and 8 kHz rejection), +control command construction, ADPCM nibble order and cross-frame predictor +continuity with sync reset, Int16 clamping, frame accumulation, and PCM +smoothing/gain. `RemoteMicHandshakeTests` covers the subscription gate (no +capability request before both notifications are confirmed), the +request-once-per-attempt rule, 8 kHz rejection, reset isolation, and readiness. +`RemoteMicWantedStateTests` covers the fallback invariant. CoreBluetooth +transport behavior still needs a real remote; the verification artifact records +that as residual risk. + +## Rollout and rollback + +Ships default-off with the next Utter release. Rollback is reverting the commit; +with the setting off the new code paths are unreachable. If the setting were +already on, reverting leaves `remoteMicEnabled` unread and capture returns to the +system device. + +## Verification requirements for this lane + +High risk (a new device permission and an external protocol). Before enabling it +for users: independent verification on a real remote, explicit confirmation that +the licensing position is acceptable, and a manual check of connect, record, +disconnect, and reconnect behavior. diff --git a/docs/sdlc/changes/2026-09-21-remote-mic-integration/verification.md b/docs/sdlc/changes/2026-09-21-remote-mic-integration/verification.md new file mode 100644 index 00000000..63b99341 --- /dev/null +++ b/docs/sdlc/changes/2026-09-21-remote-mic-integration/verification.md @@ -0,0 +1,201 @@ +# Verification: Xiaomi remote wireless microphone in Utter + +**Status:** pending approval +**Approved-by:** — +**Approved-date:** — +**Upstream:** `plan.md` + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| Bundle hash and restoration | Pass | Bundle SHA-256 `021ed0088aaf8db0df1f7d6afc452d7c9115b7a00e65bb87cffcfd6d5b8b2651`; `git bundle verify`, `git fsck --full --strict`, ref `6bdcbb78c4f7091f1225b93ca82306560c9683d7`, tree `0addbb55d3ba9ff0dc7791afec3c012c19b53c42`, and clean restore all passed. | +| Host and toolchain | Pass | Mac mini `Mac16,10` / Apple M4 / macOS 27.2; Xcode 27.0 (`27A266a`), Swift 6.4 from `/Applications/Xcode.app/Contents/Developer`. | +| `bash scripts/ci-basic-checks.sh` | Pass | Exit 0 with `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` and `SDKROOT=.../MacOSX27.0.sdk`; all static, localization, vocabulary, resource, and secret checks passed. | +| `bash scripts/sdlc-checks.sh` | Pass | Exit 0: "SDLC checks passed." | +| `swift test --filter RemoteMic` | Pass | Exit 0; 68 tests, 0 failures. | +| `swift test --filter RemoteMicCallbackRoutingTests` | Pass | Exit 0; 7 tests, 0 failures. | +| `review-evidence/vec4-central-gate-mutations.sh` | Pass | Exit 0; baseline 7 tests passed; manager, peripheral, and attempt mutations each exited 1 with the target assertion classified; every restore returned exact head/tree and clean status. | +| `swift test` (full suite) | Pass | Exit 0; 701 tests executed, 10 environment/model-gated tests skipped, 0 failures. | +| `bash scripts/build-app.sh --app-only` | Pass | Exit 0; Release arm64 app and CLI helper built, ad-hoc hardened-runtime signed, `verify-release-artifact.sh` reported valid on disk and designated requirement satisfied; app binary SHA-256 `a0965499a77c35f2dfddb1ad1935b566cecae428cc3513cfc0ffad87d86611ad`. | +| Real Xiaomi remote end-to-end | Not run | No hardware in this environment | + +Environment note: the current evidence was collected on the online Mac mini +above using the full Xcode toolchain. The first CI-basic invocation without an +explicit SDK failed at the industry vocabulary check because the host target +was `arm64-apple-macosx27.2.0` while the selected Xcode SDK was 27.0; the +explicit `SDKROOT` rerun passed and is the recorded result. The earlier Linux +bundle verification remains historical evidence only. + +### 2026-09-22 Mac mini rerun details + +The bundle's checked-in mutation script initially had three trailing shell +continuations that swallowed the following `run_mutation` calls. The minimal +script repair is commit `58473722ddee842c01218c2fe2ffb8a9cd1ed7f3`; the +preceding compile repair is commit `f8b135b3cf4498c447f5dd447424b36e82818e01`. +The final tree is commit `58473722ddee842c01218c2fe2ffb8a9cd1ed7f3`, tree +`b00cdb9b2e57930a26731496507df70033c1def4`, and the final mutation script +SHA-256 is +`52dcad02bc0fbcea2ced082705b0764c004fa73642f0f59c2500b07879da170c`. +The complete logs are attached to the VEC-4 handoff comment. + +### VEC-4 / #104 lifecycle evidence + +The following tests are the acceptance boundary for the incremental central +lifecycle patch. They enter through `activate()` and the injectable central +transport factory, retain the transport-owned delegate proxy, and route every +event through the same production bridge methods. The fake supplies non-nil +manager/peripheral identities; it does not call a proxy-only helper. The +identity tests vary exactly one source field at a time, and the retirement +tests use weak boxes plus a fake transport `deinit` counter rather than a +strong transport array. Every injected factory captures the bridge weakly, so +the fixture cannot keep its subject alive through the bridge → factory → bridge +cycle. The connection-retirement test asserts transport release and exactly one +`deinit` immediately after the terminal callback, before creating or driving +the replacement connection. + +| Counterexample | Intended result | Mac mini result | +|---|---|---| +| `testProductionCentralRetirementCancelsPendingAndDefersFastReactivation` | `cancel` is issued for a still-connecting peripheral; off→on does not create a second transport until the old proxy's terminal failure; old contexts release, then late same-peripheral connect/fail/disconnect events are ignored | **Passed** — focused suite and mutation harness on Xcode 27 | +| `testScanRetirementUsesNonBlockingFenceBeforeReactivation` | scan-only retirement is retained behind the production `DispatchQueue.main.async` fence; the test awaits a subsequent main-queue turn, then checks replacement creation and weak/deinit release | **Passed** — focused suite and mutation harness on Xcode 27 | +| `testCentralManagerIdentityGateRejectsWrongManager` | same attempt/peripheral plus wrong manager is ignored | **Passed** — baseline and manager mutation assertion both recorded | +| `testCentralPeripheralIdentityGateRejectsWrongPeripheral` | same manager/attempt plus wrong peripheral is ignored | **Passed** — baseline and peripheral mutation assertion both recorded | +| `testCentralAttemptIdentityGateRejectsWrongAttempt` | same manager/peripheral plus stale attempt is ignored | **Passed** — baseline and attempt mutation assertion both recorded | + +`review-evidence/vec4-central-gate-mutations.sh` runs the unmutated focused +suite, then removes only the manager, peripheral, or paired source-attempt +comparisons in a temporary checkout. Each mutation is accepted only when its +exact target test and unique assertion marker both appear in the XCTest failure +record. A zero exit, compile/link/fatal failure, signal, timeout, or unrelated +test failure is rejected. The default per-run timeout is 1,800 seconds for a +cold first build. The unified log preserves environment versions, commands, +complete stdout/stderr, mutation diffs, original test exit codes, elapsed +times, and the exact clean HEAD/tree/status proof after every restoration. + +The harness classifier's `--self-test` is runnable without Swift and verifies +that simulated compile, signal, timeout, and unrelated-test failures are +rejected. That classifier self-test is not an XCTest result. On the Mac mini, +the baseline and all three real mutation XCTest runs completed with the real +exit codes described above. + +The production guarantee is backed by the focused Mac mini XCTest and mutation +evidence; it remains bounded by the unperformed hardware pass below. + +### Changes after the independent review of the first head + +The review of the initial implementation raised four code blockers; the +licensing question is a human/CTO item and is untouched here. + +| Finding | Status | What changed | +|---|---|---| +| P0: voice key not wired to Utter's hotkey | Fixed | The remote's `AUDIO_START` (0x04) on the ATVV control channel latches the session and drives `AppDelegate.startRecording`; `AUDIO_STOP` (0x00) and disconnect stop it. `0x08` is `START_SEARCH`, not a microphone-open request, per the AOSP ATVV reference firmware. No HID F5→Fn remap or Input Monitoring permission is needed. | +| P0: short press / cold start recorded after release | Fixed | `RemoteMicSession` latches press → starting → recording; a release or disconnect before the commit cancels the pending start (`RemoteMicSessionTests`, `RemoteMicSessionOrderingTests`). | +| P0: first audio lost / stop could not close | Fixed | Early audio is held in `RemoteMicPreRoll` and drained on commit; `STREAM_STOP` releases before clearing state so `endCapture` closes the microphone exactly once (`RemoteMicPreRollTests`). | +| P1: handshake generation isolation missing | Fixed | `RemoteMicHandshake.confirmCapabilities` now requires the request to have been sent, so a late capability frame on a reused peripheral cannot mark a new attempt ready; `didUpdateValueFor` checks peripheral identity (`RemoteMicHandshakeTests`). | +| P1: closing the feature left a session recording | Fixed | `deactivate()` invalidates the session and fires released/stopped, and `applyRemoteMicSetting(false)` cancels the session before deactivating. | +| P0: cancellation did not reach the real VoicePipeline start | Fixed | `startRecording` now returns the task that owns the whole `pipeline.start`; the remote path stores and cancels it, and passes the latch into `pipeline.start`, which re-checks it after the model wait via `RemoteMicStartGuard`. A released or cancelled start aborts and never falls back to the system mic. | +| P1: same-peripheral attempt isolation missing | Fixed in this increment | Each lifecycle is created through `activate()` and a new central transport/delegate proxy. Central routes require the non-nil manager identity, non-nil peripheral identity, captured attempt, and lifecycle phase. A pending or connected peripheral is always passed to `cancelPeripheralConnection`; the old central/peripheral contexts remain retained until `didFailToConnect`/`didDisconnectPeripheral`. Scan-only retirement uses a main-queue fence. `RemoteMicCallbackRoutingTests` drives the production factory/proxy route, asserts non-nil identities, exercises pending-cancel failure completion, fast off→on, context release, and late same-peripheral `didConnect`/`didDisconnect`. | +| P0: normal release discarded the recording | Fixed | `RemoteMicReleaseDecision.applyRelease` drives the production release path: a committed recording is stopped (its WAV is needed), only an uncommitted start is cancelled (`RemoteMicReleasePathTests`). | +| P0: disabling the feature left the pipeline recording | Fixed | `RemoteMicShutdownDecision` stops the pipeline when a recording is active, because the bridge's release callback is suppressed once the setting is off. | +| P1: cold-model counterexample only tested a helper | Fixed | `RemoteMicPipelineIntegrationTests` drives the real `VoicePipeline.start` await through an injected model-load barrier and a capture spy, proving a released/superseded start never reaches recording or capture. | +| P1: idle audio polluted the next pre-roll | Fixed | `RemoteMicAudioRouting` (used by the bridge) drops audio with no live session; buffered only while starting (`RemoteMicAudioRoutingTests`). | +| P0: fallback leaked wanted state | Fixed | `RemoteMicWantedState` holds the want; a failed start calls `tearDownFailedStart()`, clearing the callback, ending capture, and dropping the temp file, so a later readiness cannot open the remote mic mid-system-session. Covered by `RemoteMicWantedStateTests`. | +| P1: handshake had no state gates | Fixed | `RemoteMicHandshake` requests capabilities only after both notifications are confirmed via `didUpdateNotificationStateFor`, once per attempt; connection and initialization timeouts (`connectionTimeout` 10 s, `initializationTimeout` 8 s) bound each attempt; `didFailToConnect` recovers; a monotonic `generation` rejects late callbacks. Covered by `RemoteMicHandshakeTests`. | +| P1: only pure protocol tests | Addressed in part | The gate and wanted-state are pure, injectable types with deterministic tests; the Mac mini run exercised the real bridge factory/proxy route. The CoreBluetooth transport still needs a real device. | + +## Acceptance criteria + +- Setting off keeps the existing path — pass by construction + (`AudioCaptureManager.start` only consults the remote when + `remoteMicEnabled`); covered by the full Mac mini suite. +- Setting on with a connected remote uses the decoded stream — implemented, but + **not verified**: requires the physical remote. +- Setting on with no remote falls back to the system input — pass by + construction (`RemoteMicCaptureManager.start` returns false unless the bridge + is `.ready`), and the failure path now provably leaves no residue. +- Voice key starts and stops recording — implemented through the control-channel + adoption path; **not verified on hardware**. +- Handshake ordering, attempt isolation, and timeouts are covered by + deterministic tests (`RemoteMicHandshakeTests`, + `RemoteMicAttemptIsolationTests`); the RemoteMic Mac mini run passed all 68 + tests. +- Source-bound same-peripheral late-event regression — specified in + `swift test --filter RemoteMicCallbackRoutingTests`; the test uses the + `activate()`/transport-factory production creation path, validates non-nil + manager/peripheral identities, holds the attempt-1 transport/proxy through + terminal cancellation, then delivers old disconnect/control/didConnect/ + didFail events through that proxy. The Mac mini run passed all seven focused + tests, and the three mutation counterexamples each produced the intended + failing assertion before exact restoration. +- Session cancel across the real pipeline path — `RemoteMicPipelineIntegrationTests` + passed in the RemoteMic run, including the real `VoicePipeline.start` await + barrier and capture spy. Hardware timing remains unverified. +- Localization parity and SDLC checks — pass; `ci-basic-checks.sh` and + `sdlc-checks.sh` both passed with the explicit Xcode SDK. + +## Residual risk + +- **No hardware verification.** Pairing, scan/connect, the two notification + subscriptions, the voice key press/release, first and last frame, session + teardown on disconnect, reconnect, and real 16 kHz audio all still need a + person with the remote. This is the largest gap. +- The 10 skipped tests are **all** environment/model-dependent — this tree has + no `OPENTYPE_LIVE_DOWNLOAD_INTEGRATION` gate at all. The skip names are: + `ANELMRuntimeTests.testRealGenerationLifecycleWhenModelIsProvided`, + `AppleSpeechAnalyzerIntegrationTests.testTranscribesBundledChineseSample`, + `ChatTemplateProbe.testRenderQwen35Template`, + `DeferredReplacementPolicyTests.testDecisionRequiresSameFrontmostApp`, + `EspressoFallbackTests.testRealANEFailureFallsBackToInstalledMLX`, + `PromptDumpProbe.testDumpPrompts`, + `QwenNativeASREngineTests.testExistingModelNativeBenchmark`, + `QwenNativeASREngineTests.testExistingModelTranscribesRepositorySamplesWithoutDownloadingWeights`, + `StreamingASRIntegrationTests.testVolcStreamingSessionEmitsPartialCallbackFromSampleAudio`, + `StreamingASRIntegrationTests.testWhisperStreamingSessionEmitsPartialCallbackFromSampleAudio`. + An earlier revision of this file wrongly attributed 4 of them to a + live-download gate borrowed from the #102 tree. +- **Licensing is pending human determination.** `IchenDEV/remote-mic-app` is + GPL-3.0-only while Utter is MIT. This development verification makes no + finding that the implementation is independent, derivative, adequately + attributed, or distributable. A human licensing owner must record the + provenance, attribution, and distribution decision before any release; the + setting stays default off. +- The bridge assumes CoreBluetooth callbacks on the main queue and main-thread + callers, matching the existing capture style. +- **CoreBluetooth callback boundary.** Apple’s API gives central delegate + methods a `CBPeripheral`, but no connection-attempt id; the bridge therefore + creates one central manager/delegate proxy per lifecycle and captures the + attempt when discovery starts `connect`. A pending or connected peripheral is + explicitly cancelled on retirement, and the manager/peripheral/proxy context + is released only after the old manager's terminal `didFailToConnect` or + `didDisconnectPeripheral`; a scan-only manager uses an explicit `.main` + queue fence because it has no peripheral terminal callback. Late callbacks + from a retained old manager reach the old proxy and fail the source-attempt, + manager-identity, peripheral-identity, and lifecycle-phase gates; the + regression does not infer isolation from the replacement object’s mutable + state. The guarantee is bounded by CoreBluetooth delivering callbacks through + the manager’s `.main` queue and by all connection changes entering this proxy + route. A direct unbound `CBCentralManagerDelegate` call is rejected for + connection events; hardware validation must confirm the actual + manager/proxy lifecycle. +- `AudioCaptureActivity` thresholds were tuned for the built-in mic; the remote + path uses the same gate with a user-adjustable gain. + +### Handover steps for the hardware pass + +1. Build and run: `bash scripts/build-and-run.sh --verify`. +2. Pair the remote in System Settings → Bluetooth. +3. Settings → General → enable "Xiaomi remote wireless mic"; confirm the state + line reaches connected. +4. Hold the remote's voice key and speak; confirm Utter records and inserts text. +5. Release the key; confirm recording stops. +6. Disconnect the remote mid-session; confirm the session ends cleanly and the + state returns to scanning/retrying. +7. Reconnect; confirm a new session works. +8. Capture the app log and, if possible, a screenshot of the settings state. + +## Decision + +Development verification passed. Release remains blocked on independent +hardware verification and the pending human licensing decision. Do not enable +or distribute the feature until both are resolved; human approval is recorded +separately. diff --git a/review-evidence/vec4-central-gate-mutations.sh b/review-evidence/vec4-central-gate-mutations.sh new file mode 100755 index 00000000..44fe0dd8 --- /dev/null +++ b/review-evidence/vec4-central-gate-mutations.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly SOURCE_REL="Sources/RemoteMic/XiaomiRemoteMicBridge.swift" +readonly TEST_CLASS="RemoteMicCallbackRoutingTests" + +reject() { + printf 'REJECT: %s\n' "$*" >&2 + return 1 +} + +classify_expected_failure() { + local label="$1" + local test_name="$2" + local assertion_marker="$3" + local exit_code="$4" + local output_file="$5" + + if [[ "$exit_code" -eq 0 ]]; then + reject "$label mutation exited 0" + return + fi + if [[ "$exit_code" -eq 124 || "$exit_code" -ge 128 ]]; then + reject "$label mutation ended by timeout or signal (exit=$exit_code)" + return + fi + if grep -Eiq \ + 'emit-module command failed|compile command failed|linker command failed|failed to build|fatal error:|terminated due to signal|segmentation fault|bus error|illegal instruction|timed out|timeout after' \ + "$output_file"; then + reject "$label mutation hit a build, signal, fatal, or timeout failure" + return + fi + if ! grep -Fq "$test_name" "$output_file"; then + reject "$label output does not name the target test $test_name" + return + fi + if ! grep -Eq "Test Case .*${test_name}.*failed" "$output_file"; then + reject "$label output lacks the target XCTest failure record" + return + fi + if ! grep -Fq "$assertion_marker" "$output_file"; then + reject "$label output lacks the target assertion marker" + return + fi + + printf 'CLASSIFIER: PASS — %s mutation produced the target XCTest assertion (exit=%s)\n' \ + "$label" "$exit_code" +} + +classifier_self_test() { + local self_test_dir + local target_test="testCentralManagerIdentityGateRejectsWrongManager" + local marker="manager identity gate must reject a same-attempt callback from another manager" + self_test_dir="$(mktemp -d "${TMPDIR:-/tmp}/vec4-classifier-self-test.XXXXXX")" + trap "rm -rf -- '$self_test_dir'" EXIT + + printf '%s\n' \ + "Test Case '-[OpenTypeTests.RemoteMicCallbackRoutingTests ${target_test}]' failed" \ + "XCTAssertTrue failed - ${marker}" \ + >"$self_test_dir/target.out" + classify_expected_failure target "$target_test" "$marker" 1 "$self_test_dir/target.out" + + printf '%s\n' \ + "Test Case '-[OpenTypeTests.RemoteMicCallbackRoutingTests ${target_test}]' failed" \ + "XCTAssertTrue failed - ${marker}" \ + "error: emit-module command failed with exit code 1" \ + >"$self_test_dir/compile.out" + if classify_expected_failure compile "$target_test" "$marker" 1 "$self_test_dir/compile.out"; then + reject "classifier accepted a compiler failure" + fi + + if classify_expected_failure signal "$target_test" "$marker" 139 "$self_test_dir/target.out"; then + reject "classifier accepted a signal failure" + fi + if classify_expected_failure timeout "$target_test" "$marker" 142 "$self_test_dir/target.out"; then + reject "classifier accepted a timeout failure" + fi + + printf '%s\n' \ + "Test Case '-[OpenTypeTests.RemoteMicCallbackRoutingTests testUnrelated]' failed" \ + "XCTAssertTrue failed - ${marker}" \ + >"$self_test_dir/unrelated.out" + if classify_expected_failure unrelated "$target_test" "$marker" 1 "$self_test_dir/unrelated.out"; then + reject "classifier accepted an unrelated XCTest failure" + fi + + printf 'CLASSIFIER_SELF_TEST: PASS\n' +} + +if [[ "${1:-}" == "--self-test" ]]; then + classifier_self_test + exit 0 +fi +if [[ "$#" -ne 0 ]]; then + printf 'usage: %s [--self-test]\n' "$0" >&2 + exit 64 +fi + +command -v swift >/dev/null 2>&1 || { + printf 'swift is unavailable; macOS/Xcode XCTest execution was not started\n' >&2 + exit 127 +} + +SOURCE_REPO="${REPO_DIR:-$(git rev-parse --show-toplevel)}" +BASE_SHA="${BASE_SHA:-$(git -C "$SOURCE_REPO" rev-parse HEAD)}" +RUN_TIMEOUT_SECONDS="${RUN_TIMEOUT_SECONDS:-1800}" +LOG_PATH="${LOG_PATH:-$PWD/vec4-central-gate-mutations-${BASE_SHA:0:12}.log}" + +[[ "$RUN_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] \ + || { printf 'RUN_TIMEOUT_SECONDS must be a positive integer\n' >&2; exit 64; } +git -C "$SOURCE_REPO" cat-file -e "${BASE_SHA}^{commit}" +if [[ "$LOG_PATH" != /* ]]; then + LOG_PATH="$PWD/$LOG_PATH" +fi +[[ ! -e "$LOG_PATH" ]] \ + || { printf 'refusing to overwrite existing log: %s\n' "$LOG_PATH" >&2; exit 73; } + +RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vec4-central-gates.XXXXXX")" +cleanup() { + rm -rf "$RUN_DIR" +} +trap cleanup EXIT + +exec > >(tee "$LOG_PATH") 2>&1 + +printf '=== VEC-4 / #104 central gate mutation harness ===\n' +printf 'BASE_SHA: %s\n' "$BASE_SHA" +printf 'SOURCE_REPO: %s\n' "$SOURCE_REPO" +printf 'RUN_TIMEOUT_SECONDS: %s\n' "$RUN_TIMEOUT_SECONDS" +printf 'LOG_PATH: %s\n' "$LOG_PATH" +printf 'UNAME: %s\n' "$(uname -a)" +printf 'SWIFT: %s\n' "$(swift --version | tr '\n' ';')" +printf 'XCODEBUILD: %s\n' "$(xcodebuild -version | tr '\n' ';')" +printf 'SCRIPT_SHA256: %s\n' "$(shasum -a 256 "$0" | awk '{print $1}')" + +git clone --quiet --no-hardlinks "$SOURCE_REPO" "$RUN_DIR/repo" +git -C "$RUN_DIR/repo" checkout --quiet --detach "$BASE_SHA" +readonly REPO="$RUN_DIR/repo" + +[[ "$(git -C "$REPO" rev-parse HEAD)" == "$BASE_SHA" ]] +[[ -z "$(git -C "$REPO" status --porcelain)" ]] +test -f "$REPO/review-evidence/vec4-central-gate-mutations.sh" +printf 'INITIAL_HEAD: %s\n' "$(git -C "$REPO" rev-parse HEAD)" +printf 'INITIAL_TREE: %s\n' "$(git -C "$REPO" rev-parse 'HEAD^{tree}')" +printf 'INITIAL_STATUS: clean\n' + +TEST_EXIT_CODE=0 +run_timed_test() { + local label="$1" + local filter="$2" + local output_file="$RUN_DIR/${label}.out" + local started_at + local finished_at + local started_epoch + local finished_epoch + + printf '\n=== %s ===\n' "$label" + printf 'COMMAND: swift test --filter %s\n' "$filter" + started_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + started_epoch="$(date '+%s')" + printf 'STARTED_AT: %s\n' "$started_at" + set +e + ( + cd "$REPO" + /usr/bin/perl -e 'alarm shift; exec @ARGV or die "exec failed: $!\n"' \ + "$RUN_TIMEOUT_SECONDS" swift test --filter "$filter" + ) >"$output_file" 2>&1 + TEST_EXIT_CODE=$? + set -e + finished_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + finished_epoch="$(date '+%s')" + cat "$output_file" + printf 'TEST_EXIT_CODE: %s\n' "$TEST_EXIT_CODE" + printf 'FINISHED_AT: %s\n' "$finished_at" + printf 'ELAPSED_SECONDS: %s\n' "$((finished_epoch - started_epoch))" +} + +run_pass() { + local filter="$1" + run_timed_test baseline "$filter" + [[ "$TEST_EXIT_CODE" -eq 0 ]] \ + || { printf 'baseline test failed or timed out (exit=%s)\n' "$TEST_EXIT_CODE" >&2; exit 1; } + printf 'BASELINE: PASS\n' +} + +restore_source() { + printf 'RESTORE_COMMAND_CWD: %s\n' "$REPO" + printf 'RESTORE_COMMAND: git restore --source=%s --worktree -- %s\n' \ + "$BASE_SHA" "$SOURCE_REL" + git -C "$REPO" restore --source="$BASE_SHA" --worktree -- "$SOURCE_REL" +} + +apply_mutation() { + local expression="$1" + + printf 'MUTATION_COMMAND_CWD: %s\n' "$REPO" + printf 'MUTATION_COMMAND:' + printf ' %q' perl -0pi -e "$expression" "$SOURCE_REL" + printf '\n' + ( + cd "$REPO" + perl -0pi -e "$expression" "$SOURCE_REL" + ) +} + +record_mutation() { + local label="$1" + printf '\n--- %s mutation diff ---\n' "$label" + git -C "$REPO" diff --check + git -C "$REPO" diff -- "$SOURCE_REL" + [[ "$(git -C "$REPO" status --porcelain)" == " M $SOURCE_REL" ]] +} + +restore_and_prove() { + local label="$1" + restore_source + git -C "$REPO" diff --exit-code "$BASE_SHA" -- "$SOURCE_REL" + [[ -z "$(git -C "$REPO" status --porcelain)" ]] + printf '%s_RESTORED_HEAD: %s\n' "$label" "$(git -C "$REPO" rev-parse HEAD)" + printf '%s_RESTORED_TREE: %s\n' "$label" "$(git -C "$REPO" rev-parse 'HEAD^{tree}')" + printf '%s_RESTORED_STATUS: clean\n' "$label" +} + +run_mutation() { + local label="$1" + local test_name="$2" + local assertion_marker="$3" + local filter="$TEST_CLASS/$test_name" + local classifier_exit + + record_mutation "$label" + run_timed_test "$label" "$filter" + set +e + classify_expected_failure \ + "$label" "$test_name" "$assertion_marker" "$TEST_EXIT_CODE" "$RUN_DIR/${label}.out" + classifier_exit=$? + set -e + restore_and_prove "$label" + [[ "$classifier_exit" -eq 0 ]] || exit "$classifier_exit" +} + +run_pass "$TEST_CLASS" + +restore_source +apply_mutation \ + 's/(private func currentCentralAttempt\([\s\S]*?guard let transport = centralTransport,\n)\s*transport\.identity === managerIdentity,\n/$1/' +run_mutation manager \ + testCentralManagerIdentityGateRejectsWrongManager \ + "manager identity gate must reject a same-attempt callback from another manager" + +restore_source +apply_mutation \ + 's/(private func currentCentralAttempt\([\s\S]*?let activePeripheralIdentity = self\.peripheralIdentity,\n)\s*activePeripheralIdentity === peripheralIdentity else/$1true else/' +run_mutation peripheral \ + testCentralPeripheralIdentityGateRejectsWrongPeripheral \ + "peripheral identity gate must reject a same-attempt callback from another peripheral" + +restore_source +apply_mutation \ + 's/(private func currentCentralAttempt\([\s\S]*?)guard let sourceAttempt,\n\s*activeConnectionAttempt == sourceAttempt,\n\s*handshake\.accepts\(sourceAttempt\) else \{ return nil \}\n\s*guard let active = centralLifecycle\.attempt, active == sourceAttempt else \{ return nil \}/$1guard let sourceAttempt else { return nil }/' +run_mutation attempt \ + testCentralAttemptIdentityGateRejectsWrongAttempt \ + "source attempt gate must reject a stale attempt from the active manager and peripheral" + +git -C "$REPO" diff --exit-code "$BASE_SHA" -- . +[[ -z "$(git -C "$REPO" status --porcelain)" ]] +printf '\nFINAL_HEAD: %s\n' "$(git -C "$REPO" rev-parse HEAD)" +printf 'FINAL_TREE: %s\n' "$(git -C "$REPO" rev-parse 'HEAD^{tree}')" +printf 'FINAL_STATUS: clean\n' +printf 'RESULT: PASS — baseline green, three target mutations red, every restore exact\n'