diff --git a/Sources/Fluid/Services/DirectCoreAudioInput.swift b/Sources/Fluid/Services/DirectCoreAudioInput.swift index 58566de48..d6822fc6a 100644 --- a/Sources/Fluid/Services/DirectCoreAudioInput.swift +++ b/Sources/Fluid/Services/DirectCoreAudioInput.swift @@ -385,6 +385,30 @@ nonisolated protocol DirectCoreAudioInputControlling: AnyObject, Sendable { func invalidate() -> OSStatus } +nonisolated enum BuiltInOutputKeepAlivePolicy { + static func start( + inputDeviceID: AudioObjectID, + devices: [AudioDevice.Device], + isAppleSilicon: Bool, + startDevice: (AudioObjectID) -> OSStatus + ) -> AudioObjectID? { + guard isAppleSilicon, + let input = devices.first(where: { $0.id == inputDeviceID }), + input.hasInput, + input.isUnavailableWhenClamshellClosed + else { + return nil + } + + for output in devices where output.hasOutput && output.isBuiltIn { + if startDevice(output.id) == noErr { + return output.id + } + } + return nil + } +} + private final nonisolated class DirectCoreAudioInput: DirectCoreAudioInputControlling, @unchecked Sendable { private struct SendableCaptureHandle: @unchecked Sendable { let rawValue: FVCoreAudioCaptureRef @@ -397,6 +421,7 @@ private final nonisolated class DirectCoreAudioInput: DirectCoreAudioInputContro private var capture: FVCoreAudioCaptureRef? private var poisonedStopStatus: OSStatus? + private var outputKeepAliveDeviceID: AudioObjectID? private let packetHandler: DirectCoreAudioPacketHandler private let workerQueue = DispatchQueue( label: "com.fluidvoice.audio.direct-input-consumer", @@ -465,8 +490,10 @@ private final nonisolated class DirectCoreAudioInput: DirectCoreAudioInputContro guard fv_core_audio_capture_is_running(capture) == false else { return } fv_core_audio_capture_clear(capture) + self.startOutputKeepAlive() let status = fv_core_audio_capture_start(capture) guard status == noErr else { + self.stopOutputKeepAlive() throw Self.error(status: status, operation: "start direct Core Audio input") } @@ -501,6 +528,7 @@ private final nonisolated class DirectCoreAudioInput: DirectCoreAudioInputContro let status = fv_core_audio_capture_stop(capture) fv_core_audio_capture_wake(capture) self.workerGroup.wait() + self.stopOutputKeepAlive() if status != noErr { self.poisonedStopStatus = status } @@ -528,6 +556,24 @@ private final nonisolated class DirectCoreAudioInput: DirectCoreAudioInputContro return noErr } + private func startOutputKeepAlive() { + #if arch(arm64) + guard self.outputKeepAliveDeviceID == nil else { return } + self.outputKeepAliveDeviceID = BuiltInOutputKeepAlivePolicy.start( + inputDeviceID: self.deviceID, + devices: AudioDevice.listAllDevices(), + isAppleSilicon: true, + startDevice: { AudioDeviceStart($0, nil) } + ) + #endif + } + + private func stopOutputKeepAlive() { + guard let deviceID = self.outputKeepAliveDeviceID else { return } + self.outputKeepAliveDeviceID = nil + _ = AudioDeviceStop(deviceID, nil) + } + private nonisolated static func consumePackets( capture: FVCoreAudioCaptureRef, packetHandler: DirectCoreAudioPacketHandler diff --git a/Tests/FluidDictationIntegrationTests/DirectAudioReliabilityTests.swift b/Tests/FluidDictationIntegrationTests/DirectAudioReliabilityTests.swift index f8497f4c3..2b2c7efe2 100644 --- a/Tests/FluidDictationIntegrationTests/DirectAudioReliabilityTests.swift +++ b/Tests/FluidDictationIntegrationTests/DirectAudioReliabilityTests.swift @@ -4,6 +4,71 @@ import Foundation import XCTest final class DirectAudioReliabilityTests: XCTestCase { + func testOutputKeepAliveStartsBuiltInOutputForAppleSiliconInternalInput() { + var startedDevices: [AudioObjectID] = [] + + let deviceID = BuiltInOutputKeepAlivePolicy.start( + inputDeviceID: 100, + devices: [Self.internalMicrophone, Self.builtInSpeaker(id: 200)], + isAppleSilicon: true, + startDevice: { + startedDevices.append($0) + return noErr + } + ) + + XCTAssertEqual(deviceID, 200) + XCTAssertEqual(startedDevices, [200]) + } + + func testOutputKeepAliveLeavesIntelAndOtherInputsUntouched() { + let devices = [ + Self.internalMicrophone, + Self.externalMicrophone, + Self.builtInSpeaker(id: 200), + ] + var startedDevices: [AudioObjectID] = [] + let startDevice: (AudioObjectID) -> OSStatus = { + startedDevices.append($0) + return noErr + } + + XCTAssertNil(BuiltInOutputKeepAlivePolicy.start( + inputDeviceID: 100, + devices: devices, + isAppleSilicon: false, + startDevice: startDevice + )) + XCTAssertNil(BuiltInOutputKeepAlivePolicy.start( + inputDeviceID: 101, + devices: devices, + isAppleSilicon: true, + startDevice: startDevice + )) + XCTAssertTrue(startedDevices.isEmpty) + } + + func testOutputKeepAliveReturnsNilWhenBuiltInOutputsFailToStart() { + var startedDevices: [AudioObjectID] = [] + + let deviceID = BuiltInOutputKeepAlivePolicy.start( + inputDeviceID: 100, + devices: [ + Self.internalMicrophone, + Self.builtInSpeaker(id: 200), + Self.builtInSpeaker(id: 201), + ], + isAppleSilicon: true, + startDevice: { + startedDevices.append($0) + return kAudioHardwareNotReadyError + } + ) + + XCTAssertNil(deviceID) + XCTAssertEqual(startedDevices, [200, 201]) + } + func testReadinessGatePreservesFirstPCMThatArrivesBeforeWait() async { let gate = AudioCaptureReadinessGate() gate.arm(sessionID: 41, attemptID: 1) @@ -422,6 +487,35 @@ final class DirectAudioReliabilityTests: XCTestCase { XCTAssertEqual(recorder.events, ["quarantine", "make:24000"]) await controller.shutdown(reason: "test_complete") } + + private static let internalMicrophone = AudioDevice.Device( + id: 100, + uid: "BuiltInMicrophoneDevice", + name: "MacBook Pro Microphone", + hasInput: true, + hasOutput: false, + transportType: kAudioDeviceTransportTypeBuiltIn + ) + + private static let externalMicrophone = AudioDevice.Device( + id: 101, + uid: "ExternalMicrophoneDevice", + name: "USB Microphone", + hasInput: true, + hasOutput: false, + transportType: kAudioDeviceTransportTypeUSB + ) + + private static func builtInSpeaker(id: AudioObjectID) -> AudioDevice.Device { + AudioDevice.Device( + id: id, + uid: "BuiltInSpeakerDevice", + name: "MacBook Pro Speakers", + hasInput: false, + hasOutput: true, + transportType: kAudioDeviceTransportTypeBuiltIn + ) + } } private nonisolated func makeFingerprint(