Skip to content
Open
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
46 changes: 46 additions & 0 deletions Sources/Fluid/Services/DirectCoreAudioInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop only FluidVoice's keep-alive IOProc

When another application is playing through the built-in output during dictation, AudioDeviceStop(deviceID, nil) stops the device as a whole rather than a keep-alive operation owned by FluidVoice, so ending direct capture can interrupt or strand that application's playback. Register and retain a dedicated no-op output IOProc and pass its ID to both AudioDeviceStart and AudioDeviceStop instead of using the device-wide nil form.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggests to reintroduce the larger IOProc solution we decided to remove

}

private nonisolated static func consumePackets(
capture: FVCoreAudioCaptureRef,
packetHandler: DirectCoreAudioPacketHandler
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Loading