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'