From 49dd87caa8b64ef2417ed2feb4164bf71a8e9299 Mon Sep 17 00:00:00 2001 From: Umer Hassan Date: Sat, 4 Jul 2026 23:48:45 -0500 Subject: [PATCH 1/2] Fix mic going silent after display or input device changes Restart the AVCaptureSession after reconfiguration and recover the capture path when hardware routes change, including system default input updates. Co-authored-by: Cursor --- .../Services/AudioRecordingService.swift | 116 +++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index d04d5e4..42f197b 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,9 @@ 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? // MARK: - Chunking state private var chunkAssetWriter: AVAssetWriter? @@ -143,14 +147,64 @@ 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) + } + } } - func fetchAvailableDevices() { + private func startObservingDefaultInputDevice() { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let status = AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &address, + DispatchQueue.main + ) { [weak self] _, _ in + self?.handleSystemDefaultInputChanged() + } + + if status != noErr { + print("Failed to observe system default input device: \(status)") + } + } + + func fetchAvailableDevices(completion: (() -> Void)? = nil) { let discoverySession = AVCaptureDevice.DiscoverySession( deviceTypes: [.microphone], mediaType: .audio, @@ -174,10 +228,12 @@ class AudioRecordingService: NSObject, ObservableObject { self.selectedDeviceId = nil } } + completion?() } } func setupSession() { + let shouldRestart = isRecording || (captureSession?.isRunning ?? false) captureSession?.stopRunning() captureSession = AVCaptureSession() @@ -215,6 +271,62 @@ 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() + guard let session = self.captureSession, !session.isRunning else { return } + print("🎤 Restarting capture session after reconfiguration...") + session.startRunning() + } + } + + 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 From 12596356660b37a0666f6e0ed8e3bc8676a6f4b5 Mon Sep 17 00:00:00 2001 From: Karan Singh Date: Tue, 7 Jul 2026 01:15:39 +0530 Subject: [PATCH 2/2] Fix idle mic staying hot and Core Audio listener leak in mic recovery - Re-arm idle auto-stop after restarting a pre-warmed session on a device/route change, so the mic no longer stays hot forever while idle. - Remove the Core Audio default-input listener (and NotificationCenter observers) on deinit. Non-singleton instances (Dashboard/Transcribe views, tests) previously leaked a listener registration per instance. --- .../Services/AudioRecordingService.swift | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index 42f197b..b445e11 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -44,6 +44,9 @@ class AudioRecordingService: NSObject, ObservableObject { /// 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? @@ -191,19 +194,45 @@ class AudioRecordingService: NSObject, ObservableObject { mElement: kAudioObjectPropertyElementMain ) + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.handleSystemDefaultInputChanged() + } + let status = AudioObjectAddPropertyListenerBlock( AudioObjectID(kAudioObjectSystemObject), &address, - DispatchQueue.main - ) { [weak self] _, _ in - self?.handleSystemDefaultInputChanged() - } + DispatchQueue.main, + block + ) - if status != noErr { + 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(completion: (() -> Void)? = nil) { let discoverySession = AVCaptureDevice.DiscoverySession( deviceTypes: [.microphone], @@ -287,9 +316,17 @@ class AudioRecordingService: NSObject, ObservableObject { private func restartCaptureSession() { audioQueue.async { self.cancelIdleSessionStop() - guard let session = self.captureSession, !session.isRunning else { return } - print("🎤 Restarting capture session after reconfiguration...") - session.startRunning() + 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() + } } }