Skip to content
Open
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
153 changes: 151 additions & 2 deletions speaktype/Services/AudioRecordingService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AVFoundation
import Combine
import CoreAudio
import CoreMedia
import Foundation

Expand Down Expand Up @@ -40,6 +41,12 @@ class AudioRecordingService: NSObject, ObservableObject {
private var setupTask: Task<Void, Never>?
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?
Expand Down Expand Up @@ -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,
Expand All @@ -174,10 +257,12 @@ class AudioRecordingService: NSObject, ObservableObject {
self.selectedDeviceId = nil
}
}
completion?()
}
}

func setupSession() {
let shouldRestart = isRecording || (captureSession?.isRunning ?? false)
captureSession?.stopRunning()
captureSession = AVCaptureSession()

Expand Down Expand Up @@ -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<AudioDeviceID>.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<CFString>.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
Expand Down
Loading