diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index d04d5e4..b445e11 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -1,5 +1,6 @@ import AVFoundation import Combine +import CoreAudio import CoreMedia import Foundation @@ -40,6 +41,12 @@ class AudioRecordingService: NSObject, ObservableObject { private var setupTask: Task? private var isStopping = false // Flag to prevent appending during stop private var idleSessionStopWorkItem: DispatchWorkItem? + /// Tracks the last macOS system default input UID so we can follow default changes + /// when the user has not explicitly picked a different device in SpeakType settings. + private var lastKnownSystemDefaultInputUID: String? + /// Retained so the Core Audio default-input listener can be removed on deinit. + /// AudioObjectRemovePropertyListenerBlock requires the *same* block instance. + private var defaultInputListenerBlock: AudioObjectPropertyListenerBlock? // MARK: - Chunking state private var chunkAssetWriter: AVAssetWriter? @@ -143,14 +150,90 @@ class AudioRecordingService: NSObject, ObservableObject { name: AVCaptureDevice.wasDisconnectedNotification, object: nil ) + + lastKnownSystemDefaultInputUID = Self.defaultInputDeviceUID() + startObservingDefaultInputDevice() } @objc private func handleDeviceChange(_ notification: Notification) { print("Audio device change detected") - fetchAvailableDevices() + fetchAvailableDevices { [weak self] in + // Rebuild even when selectedDeviceId is unchanged — display reconnects and + // route changes can leave the old AVCaptureSession alive but silent. + self?.recoverCaptureSessionIfNeeded(forceReconfigure: true) + } + } + + private func handleSystemDefaultInputChanged() { + let previousDefault = lastKnownSystemDefaultInputUID + guard let newDefaultUID = Self.defaultInputDeviceUID() else { return } + lastKnownSystemDefaultInputUID = newDefaultUID + + fetchAvailableDevices { [weak self] in + guard let self else { return } + + // If the app was effectively using the previous system default, follow the + // new default when the user changes input in System Settings. + if let previousDefault, + self.selectedDeviceId == previousDefault, + newDefaultUID != previousDefault, + self.availableDevices.contains(where: { $0.uniqueID == newDefaultUID }) + { + print("🎤 Following system default input change to UID: \(newDefaultUID)") + self.selectedDeviceId = newDefaultUID + } else { + self.recoverCaptureSessionIfNeeded(forceReconfigure: true) + } + } + } + + private func startObservingDefaultInputDevice() { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.handleSystemDefaultInputChanged() + } + + let status = AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &address, + DispatchQueue.main, + block + ) + + if status == noErr { + defaultInputListenerBlock = block + } else { + print("Failed to observe system default input device: \(status)") + } + } + + private func stopObservingDefaultInputDevice() { + guard let block = defaultInputListenerBlock else { return } + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &address, + DispatchQueue.main, + block + ) + defaultInputListenerBlock = nil + } + + deinit { + stopObservingDefaultInputDevice() + NotificationCenter.default.removeObserver(self) } - func fetchAvailableDevices() { + func fetchAvailableDevices(completion: (() -> Void)? = nil) { let discoverySession = AVCaptureDevice.DiscoverySession( deviceTypes: [.microphone], mediaType: .audio, @@ -174,10 +257,12 @@ class AudioRecordingService: NSObject, ObservableObject { self.selectedDeviceId = nil } } + completion?() } } func setupSession() { + let shouldRestart = isRecording || (captureSession?.isRunning ?? false) captureSession?.stopRunning() captureSession = AVCaptureSession() @@ -215,6 +300,70 @@ class AudioRecordingService: NSObject, ObservableObject { // Don't start session here - only start when recording begins // This prevents continuous CPU usage when idle + if shouldRestart { + restartCaptureSession() + } + } + + /// Rebuild and restart the capture session after hardware/route changes. + private func recoverCaptureSessionIfNeeded(forceReconfigure: Bool = false) { + let shouldRecover = + forceReconfigure || isRecording || (captureSession?.isRunning ?? false) + guard shouldRecover else { return } + setupSession() + } + + private func restartCaptureSession() { + audioQueue.async { + self.cancelIdleSessionStop() + if let session = self.captureSession, !session.isRunning { + print("🎤 Restarting capture session after reconfiguration...") + session.startRunning() + } + // If this restart was for a pre-warmed (idle) session rather than an + // active recording, re-arm the idle auto-stop. Otherwise the mic would + // stay hot indefinitely after a device/route change, regressing the + // "no continuous mic indicator while idle" behavior. + if !self.isRecording { + self.scheduleIdleSessionStop() + } + } + } + + private static func defaultInputDeviceUID() -> String? { + var deviceID = AudioDeviceID(0) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size = UInt32(MemoryLayout.size) + guard + AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &size, + &deviceID + ) == noErr + else { return nil } + return deviceUID(for: deviceID) + } + + private static func deviceUID(for deviceID: AudioDeviceID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var uid = "" as CFString + var size = UInt32(MemoryLayout.size) + let status = withUnsafeMutablePointer(to: &uid) { uidPointer in + AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, uidPointer) + } + guard status == noErr else { return nil } + return uid as String } /// Pre-warm the capture session so first recording starts instantly