Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Utter needs microphone access to capture voice for transcription.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Utter connects to a Xiaomi Bluetooth remote so you can use it as a wireless microphone without a separate app.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Utter uses speech recognition to convert voice to text.</string>
<key>NSAppleEventsUsageDescription</key>
Expand Down
2 changes: 2 additions & 0 deletions Resources/OpenType.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<false/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.personal-information.speech-recognition</key>
<true/>
<key>com.apple.security.screen-recording</key>
Expand Down
97 changes: 97 additions & 0 deletions Sources/App/AppDelegate+RemoteMic.swift
Original file line number Diff line number Diff line change
@@ -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() }
)
}
}
26 changes: 22 additions & 4 deletions Sources/App/OpenTypeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

override init() {
let registry = IntegrationClientRegistry()
Expand All @@ -56,6 +59,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
observeSystemAppearanceForIcon()
observeUILanguageForSettingsWindow()
observeIntegrationSettings()
observeRemoteMicSetting()
configureIntegrationHTTPServer()
configureIntegrationXPCServer()

Expand All @@ -67,6 +71,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {

func applicationWillTerminate(_ notification: Notification) {
stopIntegrationHTTPServer(resetService: true)
RemoteMicCaptureManager.shared.deactivate()
}

private func setupMenuBar() {
Expand Down Expand Up @@ -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<Void, Never>? {
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) }
}

Expand Down
78 changes: 71 additions & 7 deletions Sources/App/VoicePipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,8 +27,22 @@ final class VoicePipeline {
var formattingModelLifecycleTask: Task<EspressoGenerationOutcome?, Never>?
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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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

Expand Down
Loading
Loading