From 62c93fb647a9f79b54a8729b534bcfa18ad7b3e9 Mon Sep 17 00:00:00 2001 From: ShiftHackZ Date: Thu, 30 Apr 2026 00:33:34 +0300 Subject: [PATCH] Implement multiple output routing/tap for playback audio streams --- README.md | 36 +- .../core/preference/AppPreferenceStore.swift | 28 +- .../core/routing/RoutingEngine.swift | 40 ++ .../routing/VirtualAudioRoutingBackend.swift | 27 +- .../ProcessTapRoutingService.swift | 349 +++++++++----- .../Xavucontrol/domain/AudioModel.swift | 429 +++++++++++++++--- .../presentation/tabs/PatchbayTab.swift | 12 +- .../presentation/tabs/StreamsTab.swift | 57 ++- 8 files changed, 751 insertions(+), 227 deletions(-) diff --git a/README.md b/README.md index b90ed97..cf3f4e8 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Xavucontrol is a native macOS audio routing and monitoring app inspired by the Linux `pavucontrol` experience. +Project repository: [github.com/ShiftHackZ/Xavucontrol](https://github.com/ShiftHackZ/Xavucontrol) + It is not affiliated with, endorsed by, or connected to the Linux `pavucontrol` project, PulseAudio, PipeWire, Apple, or any third-party audio vendor. The name and UX direction are used to communicate the goal: a practical per-application @@ -19,6 +21,7 @@ system default device. That means simple workflows can become impossible: - play a browser or YouTube stream through MacBook speakers; - send Apple Music or foobar2000 to headphones; +- duplicate one app's playback stream to multiple output devices at once; - monitor a microphone before recording; - combine multiple microphones into one virtual microphone; - mix selected playback audio into a virtual microphone for calls, recording, @@ -46,9 +49,12 @@ output device Xavucontrol is routing them to. When a stream is running through `Xavucontrol Virtual Cable`, the app can manage its route, volume, mute state, and whether that stream should also be mixed into -`Xavucontrol Virtual Mic`. Streams that are not currently on an Xavucontrol -virtual device remain visible, but controls that cannot actually affect them are -disabled. +`Xavucontrol Virtual Mic`. A playback stream can be routed to one output device +or fanned out to several hardware outputs at the same time, for example browser +audio on both MacBook speakers and headphones. + +Streams that are not currently on an Xavucontrol virtual device remain visible, +but controls that cannot actually affect them are disabled. ![Playback](Docs/Screenshots/Playback.png) @@ -126,6 +132,7 @@ is actively seeing process tap routes. - Native macOS SwiftUI app. - Per-application playback discovery. - Per-application playback routing to different hardware output devices. +- Per-application playback fan-out to multiple output devices at once. - Internal default output device preference independent from the macOS system default. - Per-stream playback volume and mute for streams routed through Xavucontrol. @@ -150,8 +157,8 @@ Xavucontrol uses a hybrid approach: - Core Audio device and process APIs discover hardware devices and active apps. - A bundled Core Audio HAL driver exposes virtual devices to macOS. - Apps can output into `Xavucontrol Virtual Cable`. -- Xavucontrol captures/processes those streams and plays them to selected real - outputs. +- Xavucontrol captures/processes those streams and plays each app to one or more + selected real outputs. - Xavucontrol can also mix selected microphones and playback streams into `Xavucontrol Virtual Mic`. @@ -164,15 +171,24 @@ flowchart LR VC --> Router["Xavucontrol routing engine"] - Router --> Out1["Real output device (MacBook Speakers)"] - Router --> Out2["Real output device (Headphones)"] - Router --> Out3["Real output device (USB audio interface)"] + Router --> FanoutA["App A route / fan-out"] + Router --> RouteB["App B route"] + + FanoutA --> Out1["Real output device (MacBook Speakers)"] + FanoutA --> Out2["Real output device (Headphones)"] + FanoutA --> Out3["Real output device (USB audio interface)"] + + RouteB --> Out2 - Preferences["Xavucontrol preferences (per-app route, volume, mute)"] --> Router + Preferences["Xavucontrol preferences (per-app routes, fan-out targets, volume, mute)"] --> Router ``` In this mode, macOS apps send audio to `Xavucontrol Virtual Cable`. Xavucontrol -then routes each detected app stream to the selected real output device. +then routes each detected app stream to the selected real output device or to +multiple real output devices at once. This makes workflows possible that macOS +does not normally expose as a single user-facing control, such as playing one +browser stream through both built-in speakers and headphones while another app +uses a different route. ### Virtual Microphone Mixer diff --git a/Xavucontrol/Xavucontrol/core/preference/AppPreferenceStore.swift b/Xavucontrol/Xavucontrol/core/preference/AppPreferenceStore.swift index daace2f..afb5e80 100644 --- a/Xavucontrol/Xavucontrol/core/preference/AppPreferenceStore.swift +++ b/Xavucontrol/Xavucontrol/core/preference/AppPreferenceStore.swift @@ -7,6 +7,7 @@ struct AppPreferences: Hashable { var defaultOutputDeviceID: AudioDevice.ID? var defaultInputDeviceID: AudioDevice.ID? var streamOutputDeviceIDs: [String: AudioDevice.ID] + var streamOutputTargetDeviceIDs: [String: [AudioDevice.ID]] var streamInputDeviceIDs: [String: AudioDevice.ID] var streamVolumes: [String: Double] var streamMutedStates: [String: Bool] @@ -20,6 +21,7 @@ struct AppPreferenceStore { static let defaultOutputDeviceID = "preferences.defaultOutputDeviceID" static let defaultInputDeviceID = "preferences.defaultInputDeviceID" static let streamOutputDeviceIDs = "preferences.streamOutputDeviceIDs" + static let streamOutputTargetDeviceIDs = "preferences.streamOutputTargetDeviceIDs" static let streamInputDeviceIDs = "preferences.streamInputDeviceIDs" static let streamVolumes = "preferences.streamVolumes" static let streamMutedStates = "preferences.streamMutedStates" @@ -35,10 +37,17 @@ struct AppPreferenceStore { } func load() -> AppPreferences { - AppPreferences( + let legacyOutputRoutes = defaults.dictionary(forKey: Key.streamOutputDeviceIDs) as? [String: String] ?? [:] + var outputTargetRoutes = defaults.dictionary(forKey: Key.streamOutputTargetDeviceIDs) as? [String: [String]] ?? [:] + for (preferenceKey, deviceID) in legacyOutputRoutes where outputTargetRoutes[preferenceKey] == nil { + outputTargetRoutes[preferenceKey] = [deviceID] + } + + return AppPreferences( defaultOutputDeviceID: defaults.string(forKey: Key.defaultOutputDeviceID), defaultInputDeviceID: defaults.string(forKey: Key.defaultInputDeviceID), - streamOutputDeviceIDs: defaults.dictionary(forKey: Key.streamOutputDeviceIDs) as? [String: String] ?? [:], + streamOutputDeviceIDs: legacyOutputRoutes, + streamOutputTargetDeviceIDs: outputTargetRoutes, streamInputDeviceIDs: defaults.dictionary(forKey: Key.streamInputDeviceIDs) as? [String: String] ?? [:], streamVolumes: doubleDictionary(forKey: Key.streamVolumes), streamMutedStates: boolDictionary(forKey: Key.streamMutedStates), @@ -74,6 +83,20 @@ struct AppPreferenceStore { defaults.set(routes, forKey: Key.streamOutputDeviceIDs) } + func setStreamOutputTargetDeviceIDs(_ deviceIDs: [AudioDevice.ID]?, for preferenceKey: String) { + var routes = defaults.dictionary(forKey: Key.streamOutputTargetDeviceIDs) as? [String: [String]] ?? [:] + if let deviceIDs, !deviceIDs.isEmpty { + var uniqueDeviceIDs: [String] = [] + for deviceID in deviceIDs where !uniqueDeviceIDs.contains(deviceID) { + uniqueDeviceIDs.append(deviceID) + } + routes[preferenceKey] = uniqueDeviceIDs + } else { + routes.removeValue(forKey: preferenceKey) + } + defaults.set(routes, forKey: Key.streamOutputTargetDeviceIDs) + } + func setStreamInputDeviceID(_ deviceID: AudioDevice.ID?, for preferenceKey: String) { var routes = defaults.dictionary(forKey: Key.streamInputDeviceIDs) as? [String: String] ?? [:] if let deviceID { @@ -130,6 +153,7 @@ struct AppPreferenceStore { defaults.removeObject(forKey: Key.defaultOutputDeviceID) defaults.removeObject(forKey: Key.defaultInputDeviceID) defaults.removeObject(forKey: Key.streamOutputDeviceIDs) + defaults.removeObject(forKey: Key.streamOutputTargetDeviceIDs) defaults.removeObject(forKey: Key.streamInputDeviceIDs) defaults.removeObject(forKey: Key.streamVolumes) defaults.removeObject(forKey: Key.streamMutedStates) diff --git a/Xavucontrol/Xavucontrol/core/routing/RoutingEngine.swift b/Xavucontrol/Xavucontrol/core/routing/RoutingEngine.swift index b687302..dccbe17 100644 --- a/Xavucontrol/Xavucontrol/core/routing/RoutingEngine.swift +++ b/Xavucontrol/Xavucontrol/core/routing/RoutingEngine.swift @@ -22,11 +22,31 @@ protocol RoutingBackend { targetDevice: AudioDevice ) async -> RoutingBackendResult + func apply( + routeRequests: [AudioRouteRequest], + stream: AppAudioStream, + targetDevices: [AudioDevice] + ) async -> RoutingBackendResult + func setStreamVolume(stream: AppAudioStream, volume: Double) async -> RoutingBackendResult func setStreamMuted(stream: AppAudioStream, isMuted: Bool) async -> RoutingBackendResult } +extension RoutingBackend { + func apply( + routeRequests: [AudioRouteRequest], + stream: AppAudioStream, + targetDevices: [AudioDevice] + ) async -> RoutingBackendResult { + guard let routeRequest = routeRequests.first, + let targetDevice = targetDevices.first else { + return .failed("No output devices selected") + } + return await apply(routeRequest: routeRequest, stream: stream, targetDevice: targetDevice) + } +} + struct RoutingEngine { private let backend: RoutingBackend @@ -46,6 +66,14 @@ struct RoutingEngine { await backend.apply(routeRequest: routeRequest, stream: stream, targetDevice: targetDevice) } + func apply( + routeRequests: [AudioRouteRequest], + stream: AppAudioStream, + targetDevices: [AudioDevice] + ) async -> RoutingBackendResult { + await backend.apply(routeRequests: routeRequests, stream: stream, targetDevices: targetDevices) + } + func setStreamVolume(stream: AppAudioStream, volume: Double) async -> RoutingBackendResult { await backend.setStreamVolume(stream: stream, volume: volume) } @@ -87,6 +115,18 @@ struct DirectCoreAudioRoutingBackend: RoutingBackend { ) } + func apply( + routeRequests: [AudioRouteRequest], + stream: AppAudioStream, + targetDevices: [AudioDevice] + ) async -> RoutingBackendResult { + guard let routeRequest = routeRequests.first, + let targetDevice = targetDevices.first else { + return .failed("No output devices selected") + } + return await apply(routeRequest: routeRequest, stream: stream, targetDevice: targetDevice) + } + func setStreamVolume(stream: AppAudioStream, volume: Double) async -> RoutingBackendResult { .unsupported("Direct Core Audio cannot set another process stream volume") } diff --git a/Xavucontrol/Xavucontrol/core/routing/VirtualAudioRoutingBackend.swift b/Xavucontrol/Xavucontrol/core/routing/VirtualAudioRoutingBackend.swift index a93f8f6..86587ed 100644 --- a/Xavucontrol/Xavucontrol/core/routing/VirtualAudioRoutingBackend.swift +++ b/Xavucontrol/Xavucontrol/core/routing/VirtualAudioRoutingBackend.swift @@ -33,15 +33,27 @@ struct VirtualAudioRoutingBackend: RoutingBackend { routeRequest: AudioRouteRequest, stream: AppAudioStream, targetDevice: AudioDevice + ) async -> RoutingBackendResult { + await apply(routeRequests: [routeRequest], stream: stream, targetDevices: [targetDevice]) + } + + func apply( + routeRequests: [AudioRouteRequest], + stream: AppAudioStream, + targetDevices: [AudioDevice] ) async -> RoutingBackendResult { guard !stream.isVirtualStream else { return .unsupported("Virtual streams are diagnostic Xavucontrol outputs and cannot be routed by the POC backend") } if stream.direction == .playback { + guard !targetDevices.isEmpty else { + return .failed("No output devices selected") + } + do { - await VirtualCableRoutingService.shared.stopAll() - let summary = try await ProcessTapRoutingService.shared.routeConfirmed(stream: stream, to: targetDevice) + await VirtualCableRoutingService.shared.stop(streamID: stream.id) + let summary = try await ProcessTapRoutingService.shared.routeConfirmed(stream: stream, to: targetDevices) return .applied(summary) } catch { let virtualProbeSourceUID = ProcessTapRoutingService.sourceDeviceUID( @@ -60,6 +72,9 @@ struct VirtualAudioRoutingBackend: RoutingBackend { } do { + guard let targetDevice = targetDevices.first else { + return .failed("No output devices selected") + } let fallbackSummary = try await VirtualCableRoutingService.shared.route(stream: stream, to: targetDevice) return .applied("\(fallbackSummary) Per-app process tap is not ready yet: \(error.localizedDescription). \(probeSummary)") } catch { @@ -69,11 +84,11 @@ struct VirtualAudioRoutingBackend: RoutingBackend { } let message = RoutingIPCMessage.setRoute(SetRouteMessage( - streamID: routeRequest.streamID, + streamID: routeRequests.first?.streamID ?? stream.id, processID: stream.processID, - direction: routeRequest.direction.rawValue, - targetDeviceID: targetDevice.id, - targetCoreAudioObjectID: targetDevice.coreAudioObjectID + direction: routeRequests.first?.direction.rawValue ?? stream.direction.rawValue, + targetDeviceID: targetDevices.first?.id ?? "", + targetCoreAudioObjectID: targetDevices.first?.coreAudioObjectID )) do { diff --git a/Xavucontrol/Xavucontrol/core/virtual-cable/ProcessTapRoutingService.swift b/Xavucontrol/Xavucontrol/core/virtual-cable/ProcessTapRoutingService.swift index 54e30a3..29678c5 100644 --- a/Xavucontrol/Xavucontrol/core/virtual-cable/ProcessTapRoutingService.swift +++ b/Xavucontrol/Xavucontrol/core/virtual-cable/ProcessTapRoutingService.swift @@ -18,16 +18,24 @@ actor ProcessTapRoutingService { private var sessions: [String: ProcessTapRouteSession] = [:] func route(stream: AppAudioStream, to targetDevice: AudioDevice) async throws -> String { - try await route(stream: stream, to: targetDevice, requireAudibleSignal: false) + try await route(stream: stream, to: [targetDevice], requireAudibleSignal: false) } func routeConfirmed(stream: AppAudioStream, to targetDevice: AudioDevice, timeout: TimeInterval = 1.5) async throws -> String { - try await route(stream: stream, to: targetDevice, requireAudibleSignal: true, timeout: timeout) + try await route(stream: stream, to: [targetDevice], requireAudibleSignal: true, timeout: timeout) + } + + func route(stream: AppAudioStream, to targetDevices: [AudioDevice]) async throws -> String { + try await route(stream: stream, to: targetDevices, requireAudibleSignal: false) + } + + func routeConfirmed(stream: AppAudioStream, to targetDevices: [AudioDevice], timeout: TimeInterval = 1.5) async throws -> String { + try await route(stream: stream, to: targetDevices, requireAudibleSignal: true, timeout: timeout) } private func route( stream: AppAudioStream, - to targetDevice: AudioDevice, + to targetDevices: [AudioDevice], requireAudibleSignal: Bool, timeout: TimeInterval = 1.5 ) async throws -> String { @@ -40,9 +48,21 @@ actor ProcessTapRoutingService { } let processObjectIDs = ProcessTapRoutingService.relatedOutputProcessObjectIDs(for: processObjectID) - guard let targetDeviceObjectID = targetDevice.coreAudioObjectID else { - throw ProcessTapRouteError(message: "Target device has no Core Audio object ID") + let targets = targetDevices.compactMap { device -> ProcessTapOutputTarget? in + guard let objectID = device.coreAudioObjectID else { + return nil + } + return ProcessTapOutputTarget(deviceID: device.id, objectID: objectID, name: device.name) + } + guard !targets.isEmpty else { + throw ProcessTapRouteError(message: "No selected output device has a Core Audio object ID") } + NSLog( + "Xavucontrol process tap fanout start %@ targets=%d [%@]", + stream.appName, + targets.count, + targets.map(\.name).joined(separator: ", ") + ) let sourceDeviceUID = ProcessTapRoutingService.sourceDeviceUID( processObjectID: processObjectID, @@ -59,8 +79,7 @@ actor ProcessTapRoutingService { processObjectIDs: processObjectIDs, appName: stream.appName, sourceDeviceUID: sourceDeviceUID, - targetDeviceObjectID: targetDeviceObjectID, - targetDeviceName: targetDevice.name, + outputTargets: targets, initialVolume: stream.volume, initiallyMuted: stream.isMuted ) @@ -75,7 +94,8 @@ actor ProcessTapRoutingService { } sessions[stream.id] = session - return "Process tap route active: \(stream.appName) -> \(targetDevice.name) from \(sourceDeviceUID) using \(processObjectIDs.count) process tap candidate(s)" + let targetNames = targets.map(\.name).joined(separator: ", ") + return "Process tap fan-out route active: \(stream.appName) -> \(targetNames) from \(sourceDeviceUID) using \(processObjectIDs.count) process tap candidate(s)" } func stop(streamID: String) { @@ -83,6 +103,10 @@ actor ProcessTapRoutingService { sessions[streamID] = nil } + func stop(streamID: String, targetDeviceID: String) { + stop(streamID: streamID) + } + func stopAll() { sessions.values.forEach { $0.stop() } sessions.removeAll() @@ -562,28 +586,27 @@ private nonisolated final class ProcessTapProbeSession { } } +private struct ProcessTapOutputTarget { + let deviceID: AudioDevice.ID + let objectID: AudioObjectID + let name: String +} + private nonisolated final class ProcessTapRouteSession { private let streamID: String private let processObjectIDs: [AudioObjectID] private let appName: String private let sourceDeviceUID: String - private let targetDeviceObjectID: AudioObjectID - private let targetDeviceName: String - private let ringBuffer = ByteRingBuffer(capacity: 48_000 * 4 * 4) + private let outputTargets: [ProcessTapOutputTarget] + private var outputSinks: [ProcessTapOutputSink] = [] private var tapFormat = AudioStreamBasicDescription() private var outputFormat = ProcessTapRouteSession.outputFormat(sampleRate: 48_000) private var tapID = AudioObjectID(kAudioObjectUnknown) private var aggregateDeviceID = AudioObjectID(kAudioObjectUnknown) private var ioProcID: AudioDeviceIOProcID? - private var queue: AudioQueueRef? - private var queueBuffers: [AudioQueueBufferRef] = [] private var diagnosticsTimer: DispatchSourceTimer? - private var requestedOutputDeviceUID = "" - private var actualOutputDeviceUID = "" private var capturedBytes: UInt64 = 0 - private var playedBytes: UInt64 = 0 - private var underflowCount: UInt64 = 0 private var callbackCount: UInt64 = 0 private var emptyCallbackCount: UInt64 = 0 private var lastInputBufferCount: UInt32 = 0 @@ -600,8 +623,7 @@ private nonisolated final class ProcessTapRouteSession { processObjectIDs: [AudioObjectID], appName: String, sourceDeviceUID: String, - targetDeviceObjectID: AudioObjectID, - targetDeviceName: String, + outputTargets: [ProcessTapOutputTarget], initialVolume: Double, initiallyMuted: Bool ) { @@ -609,8 +631,7 @@ private nonisolated final class ProcessTapRouteSession { self.processObjectIDs = processObjectIDs self.appName = appName self.sourceDeviceUID = sourceDeviceUID - self.targetDeviceObjectID = targetDeviceObjectID - self.targetDeviceName = targetDeviceName + self.outputTargets = outputTargets self.volumeGain = Float32(max(0, min(1, initialVolume))) self.muted = initiallyMuted } @@ -651,7 +672,7 @@ private nonisolated final class ProcessTapRouteSession { } outputFormat = ProcessTapRouteSession.outputFormat(sampleRate: tapFormat.mSampleRate) - try startOutputQueue(format: outputFormat) + try startOutputSinks(format: outputFormat) try startTapInput() startDiagnosticsTimer() didStart = true @@ -687,12 +708,8 @@ private nonisolated final class ProcessTapRouteSession { diagnosticsTimer?.cancel() diagnosticsTimer = nil - if let queue { - AudioQueueStop(queue, true) - AudioQueueDispose(queue, true) - self.queue = nil - } - queueBuffers.removeAll() + outputSinks.forEach { $0.stop() } + outputSinks.removeAll() if aggregateDeviceID != kAudioObjectUnknown { if let ioProcID { @@ -724,43 +741,19 @@ private nonisolated final class ProcessTapRouteSession { try check(AudioDeviceStart(aggregateDeviceID, createdIOProcID), "Start tap IOProc") } - private func startOutputQueue(format: AudioStreamBasicDescription) throws { - var outputQueue: AudioQueueRef? - let context = Unmanaged.passUnretained(self).toOpaque() - var mutableFormat = format - - try check(AudioQueueNewOutput(&mutableFormat, processTapOutputProc, context, nil, nil, 0, &outputQueue), "Create output AudioQueue") - guard let outputQueue else { - throw ProcessTapRouteError(message: "AudioQueue output could not be created") - } - - let targetUID = try readDeviceUID(deviceID: targetDeviceObjectID) - requestedOutputDeviceUID = targetUID - var uid = targetUID as CFString - try check(withUnsafePointer(to: &uid) { pointer in - AudioQueueSetProperty( - outputQueue, - kAudioQueueProperty_CurrentDevice, - pointer, - UInt32(MemoryLayout.size) - ) - }, "Set AudioQueue output device") - actualOutputDeviceUID = readAudioQueueCurrentDevice(outputQueue) ?? "unreadable" - AudioQueueSetParameter(outputQueue, kAudioQueueParam_Volume, 1.0) - - let bytesPerFrame = max(Int(format.mBytesPerFrame), 8) - let bufferByteSize = UInt32(bytesPerFrame * 1024) - for _ in 0..<4 { - var buffer: AudioQueueBufferRef? - try check(AudioQueueAllocateBuffer(outputQueue, bufferByteSize, &buffer), "Allocate AudioQueue buffer") - guard let buffer else { continue } - queueBuffers.append(buffer) - fillOutputBuffer(buffer) - try check(AudioQueueEnqueueBuffer(outputQueue, buffer, 0, nil), "Prime AudioQueue buffer") + private func startOutputSinks(format: AudioStreamBasicDescription) throws { + var startedSinks: [ProcessTapOutputSink] = [] + do { + for target in outputTargets { + let sink = ProcessTapOutputSink(target: target) + try sink.start(format: format) + startedSinks.append(sink) + } + outputSinks = startedSinks + } catch { + startedSinks.forEach { $0.stop() } + throw error } - - queue = outputQueue - try check(AudioQueueStart(outputQueue, nil), "Start output AudioQueue") } fileprivate func handleTapInput(_ inputData: UnsafePointer?) { @@ -815,29 +808,11 @@ private nonisolated final class ProcessTapRouteSession { return } - ringBuffer.write(bytes: baseAddress.assumingMemoryBound(to: UInt8.self), count: rawBuffer.count) + writeToOutputSinks(bytes: baseAddress.assumingMemoryBound(to: UInt8.self), count: rawBuffer.count) capturedBytes += UInt64(rawBuffer.count) } } - private func fillOutputBuffer(_ buffer: AudioQueueBufferRef) { - let capacity = Int(buffer.pointee.mAudioDataBytesCapacity) - let written = ringBuffer.read(into: buffer.pointee.mAudioData.assumingMemoryBound(to: UInt8.self), count: capacity) - playedBytes += UInt64(written) - - if written < capacity { - underflowCount += 1 - memset(buffer.pointee.mAudioData.advanced(by: written), 0, capacity - written) - } - - buffer.pointee.mAudioDataByteSize = UInt32(capacity) - } - - fileprivate func handleOutputBuffer(queue: AudioQueueRef, buffer: AudioQueueBufferRef) { - fillOutputBuffer(buffer) - AudioQueueEnqueueBuffer(queue, buffer, 0, nil) - } - private func shouldTreatInputAsPlanarFloat(buffers: UnsafeMutableAudioBufferListPointer) -> Bool { guard buffers.count >= 2 else { return false @@ -884,11 +859,17 @@ private nonisolated final class ProcessTapRouteSession { return } - ringBuffer.write(bytes: baseAddress.assumingMemoryBound(to: UInt8.self), count: rawBuffer.count) + writeToOutputSinks(bytes: baseAddress.assumingMemoryBound(to: UInt8.self), count: rawBuffer.count) capturedBytes += UInt64(rawBuffer.count) } } + private func writeToOutputSinks(bytes: UnsafePointer, count: Int) { + for sink in outputSinks { + sink.write(bytes: bytes, count: count) + } + } + private func currentGain() -> Float32 { controlLock.lock() let gain = muted ? Float32.zero : volumeGain @@ -904,25 +885,27 @@ private nonisolated final class ProcessTapRouteSession { return } - NSLog( - "Xavucontrol process tap route %@ -> %@ sourceUID=%@ callbacks=%llu empty=%llu lastBuffers=%u lastBytes=%u captured=%llu played=%llu buffered=%d underflows=%llu peak=%.5f rms=%.5f requestedUID=%@ actualUID=%@ tapFormat=%@", - self.appName, - self.targetDeviceName, - self.sourceDeviceUID, - self.callbackCount, - self.emptyCallbackCount, - self.lastInputBufferCount, - self.lastInputByteSize, - self.capturedBytes, - self.playedBytes, - self.ringBuffer.availableBytes, - self.underflowCount, - self.peakLevel, - self.rmsLevel, - self.requestedOutputDeviceUID, - self.actualOutputDeviceUID, - self.formatSummary(self.tapFormat) - ) + for sink in self.outputSinks { + NSLog( + "Xavucontrol process tap fanout route %@ -> %@ sourceUID=%@ callbacks=%llu empty=%llu lastBuffers=%u lastBytes=%u captured=%llu played=%llu buffered=%d underflows=%llu peak=%.5f rms=%.5f requestedUID=%@ actualUID=%@ tapFormat=%@", + self.appName, + sink.targetDeviceName, + self.sourceDeviceUID, + self.callbackCount, + self.emptyCallbackCount, + self.lastInputBufferCount, + self.lastInputByteSize, + self.capturedBytes, + sink.playedBytes, + sink.bufferedBytes, + sink.underflowCount, + self.peakLevel, + self.rmsLevel, + sink.requestedOutputDeviceUID, + sink.actualOutputDeviceUID, + self.formatSummary(self.tapFormat) + ) + } } diagnosticsTimer = timer timer.resume() @@ -930,8 +913,9 @@ private nonisolated final class ProcessTapRouteSession { private func createAggregateDevice(tapUID: String) throws -> AudioObjectID { let aggregateUID = "org.moroz.xavucontrol.tap.\(streamID).\(UUID().uuidString)" + let targetNames = outputTargets.map(\.name).joined(separator: ", ") let description: [String: Any] = [ - kAudioAggregateDeviceNameKey: "Xavucontrol Tap - \(appName) to \(targetDeviceName)", + kAudioAggregateDeviceNameKey: "Xavucontrol Tap - \(appName) to \(targetNames)", kAudioAggregateDeviceUIDKey: aggregateUID, kAudioAggregateDeviceIsPrivateKey: true, kAudioAggregateDeviceTapListKey: [ @@ -976,21 +960,6 @@ private nonisolated final class ProcessTapRouteSession { return format } - private func readDeviceUID(deviceID: AudioObjectID) throws -> String { - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceUID, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - - var value: CFString = "" as CFString - var dataSize = UInt32(MemoryLayout.size) - try check(withUnsafeMutablePointer(to: &value) { pointer in - AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, pointer) - }, "Read target device UID") - return value as String - } - private func updateInterleavedFloatLevels(data: UnsafeMutableRawPointer, byteCount: Int) { let sampleCount = byteCount / MemoryLayout.size guard sampleCount > 0 else { @@ -1054,7 +1023,7 @@ private nonisolated final class ProcessTapRouteSession { } } - private static func describe(status: OSStatus) -> String { + fileprivate static func describe(status: OSStatus) -> String { let code = UInt32(bitPattern: status) let bytes = [ UInt8((code >> 24) & 0xff), @@ -1075,6 +1044,142 @@ private nonisolated final class ProcessTapRouteSession { } } +private nonisolated final class ProcessTapOutputSink { + let targetDeviceID: AudioDevice.ID + let targetDeviceName: String + private let targetDeviceObjectID: AudioObjectID + private let ringBuffer = ByteRingBuffer(capacity: 48_000 * 4 * 4) + private var queue: AudioQueueRef? + private var queueBuffers: [AudioQueueBufferRef] = [] + private(set) var requestedOutputDeviceUID = "" + private(set) var actualOutputDeviceUID = "" + private(set) var playedBytes: UInt64 = 0 + private(set) var underflowCount: UInt64 = 0 + + var bufferedBytes: Int { + ringBuffer.availableBytes + } + + init(target: ProcessTapOutputTarget) { + targetDeviceID = target.deviceID + targetDeviceName = target.name + targetDeviceObjectID = target.objectID + } + + deinit { + stop() + } + + func start(format: AudioStreamBasicDescription) throws { + var outputQueue: AudioQueueRef? + let context = Unmanaged.passUnretained(self).toOpaque() + var mutableFormat = format + + try check(AudioQueueNewOutput(&mutableFormat, processTapOutputProc, context, nil, nil, 0, &outputQueue), "Create output AudioQueue") + guard let outputQueue else { + throw ProcessTapRouteError(message: "AudioQueue output could not be created") + } + + let targetUID = try readDeviceUID(deviceID: targetDeviceObjectID) + requestedOutputDeviceUID = targetUID + var uid = targetUID as CFString + try check(withUnsafePointer(to: &uid) { pointer in + AudioQueueSetProperty( + outputQueue, + kAudioQueueProperty_CurrentDevice, + pointer, + UInt32(MemoryLayout.size) + ) + }, "Set AudioQueue output device") + actualOutputDeviceUID = readAudioQueueCurrentDevice(outputQueue) ?? "unreadable" + AudioQueueSetParameter(outputQueue, kAudioQueueParam_Volume, 1.0) + + let bytesPerFrame = max(Int(format.mBytesPerFrame), 8) + let bufferByteSize = UInt32(bytesPerFrame * 1024) + for _ in 0..<4 { + var buffer: AudioQueueBufferRef? + try check(AudioQueueAllocateBuffer(outputQueue, bufferByteSize, &buffer), "Allocate AudioQueue buffer") + guard let buffer else { continue } + queueBuffers.append(buffer) + fillOutputBuffer(buffer) + try check(AudioQueueEnqueueBuffer(outputQueue, buffer, 0, nil), "Prime AudioQueue buffer") + } + + queue = outputQueue + try check(AudioQueueStart(outputQueue, nil), "Start output AudioQueue") + } + + func stop() { + if let queue { + AudioQueueStop(queue, true) + AudioQueueDispose(queue, true) + self.queue = nil + } + queueBuffers.removeAll() + } + + func write(bytes: UnsafePointer, count: Int) { + ringBuffer.write(bytes: bytes, count: count) + } + + fileprivate func handleOutputBuffer(queue: AudioQueueRef, buffer: AudioQueueBufferRef) { + fillOutputBuffer(buffer) + AudioQueueEnqueueBuffer(queue, buffer, 0, nil) + } + + private func fillOutputBuffer(_ buffer: AudioQueueBufferRef) { + let capacity = Int(buffer.pointee.mAudioDataBytesCapacity) + let written = ringBuffer.read(into: buffer.pointee.mAudioData.assumingMemoryBound(to: UInt8.self), count: capacity) + playedBytes += UInt64(written) + + if written < capacity { + underflowCount += 1 + memset(buffer.pointee.mAudioData.advanced(by: written), 0, capacity - written) + } + + buffer.pointee.mAudioDataByteSize = UInt32(capacity) + } + + private func readDeviceUID(deviceID: AudioObjectID) throws -> String { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var value: CFString = "" as CFString + var dataSize = UInt32(MemoryLayout.size) + try check(withUnsafeMutablePointer(to: &value) { pointer in + AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, pointer) + }, "Read target device UID") + return value as String + } + + private func readAudioQueueCurrentDevice(_ queue: AudioQueueRef) -> String? { + var dataSize: UInt32 = 0 + guard AudioQueueGetPropertySize(queue, kAudioQueueProperty_CurrentDevice, &dataSize) == noErr, + dataSize > 0 else { + return nil + } + + var value: CFString = "" as CFString + let status = withUnsafeMutablePointer(to: &value) { pointer in + AudioQueueGetProperty(queue, kAudioQueueProperty_CurrentDevice, pointer, &dataSize) + } + guard status == noErr else { + return nil + } + + return value as String + } + + private func check(_ status: OSStatus, _ operation: String) throws { + guard status == noErr else { + throw ProcessTapRouteError(message: "\(operation) failed: \(ProcessTapRouteSession.describe(status: status))") + } + } +} + private nonisolated final class ByteRingBuffer { private let lock = NSLock() private var storage: [UInt8] @@ -1155,6 +1260,6 @@ nonisolated private let processTapOutputProc: AudioQueueOutputCallback = { clien return } - let session = Unmanaged.fromOpaque(clientData).takeUnretainedValue() - session.handleOutputBuffer(queue: queue, buffer: buffer) + let sink = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + sink.handleOutputBuffer(queue: queue, buffer: buffer) } diff --git a/Xavucontrol/Xavucontrol/domain/AudioModel.swift b/Xavucontrol/Xavucontrol/domain/AudioModel.swift index 6d7abf9..09291b4 100644 --- a/Xavucontrol/Xavucontrol/domain/AudioModel.swift +++ b/Xavucontrol/Xavucontrol/domain/AudioModel.swift @@ -163,7 +163,9 @@ final class AudioModel: ObservableObject { private var realtimeObserver: CoreAudioRealtimeObserver? private var isRefreshingDevices = false private var isRefreshingStreams = false - private var routeGenerationByStreamID: [AppAudioStream.ID: Int] = [:] + private var routeGenerationByRouteID: [AudioRouteRequest.ID: Int] = [:] + private var activePlaybackRouteTargetIDsByPreferenceKey: [String: Set] = [:] + private var activePlaybackRouteStreamIDByPreferenceKey: [String: AppAudioStream.ID] = [:] init() { outputDevices = [] @@ -279,6 +281,15 @@ final class AudioModel: ObservableObject { updatedStream.requestedDeviceID = routeRequest.targetDeviceID updatedStream.routingStatus = routeRequest.status.displayText updatedStream.routeSelectionID = preferredRouteSelectionID(for: updatedStream) + } else if let activeTargetIDs = activePlaybackRouteTargetIDsByPreferenceKey[stream.preferenceKey], + !activeTargetIDs.isEmpty, + stream.direction == .playback { + updatedStream.requestedDeviceID = activeTargetIDs.first + updatedStream.routeSelectionID = preferredRouteSelectionID(for: updatedStream) + let targetNames = activeTargetIDs + .compactMap { targetID in outputDevices.first(where: { $0.id == targetID })?.name } + .sorted() + updatedStream.routingStatus = "Active route: \(targetNames.joined(separator: ", "))" } else if updatedStream.routeSelectionID == nil { updatedStream.routeSelectionID = preferredRouteSelectionID(for: updatedStream) } @@ -302,52 +313,126 @@ final class AudioModel: ObservableObject { return } - let selectedRouteID = targetDeviceID - let effectiveTargetDeviceID = effectiveOutputDeviceID(forRouteSelectionID: targetDeviceID) + requestPlaybackRouteTargets(streamIndex: streamIndex, routeSelectionIDs: [targetDeviceID]) + } + + func requestPlaybackRouteTargets(streamID: AppAudioStream.ID, routeSelectionIDs: [AudioDevice.ID]) { + guard let streamIndex = streams.firstIndex(where: { $0.id == streamID }) else { + return + } + + requestPlaybackRouteTargets(streamIndex: streamIndex, routeSelectionIDs: routeSelectionIDs) + } + + func togglePlaybackRouteTarget(streamID: AppAudioStream.ID, targetDeviceID: AudioDevice.ID) { + guard let streamIndex = streams.firstIndex(where: { $0.id == streamID }) else { + return + } + + let stream = streams[streamIndex] + togglePlaybackRouteTarget(preferenceKey: stream.preferenceKey, targetDeviceID: targetDeviceID) + } + + func togglePlaybackRouteTarget(preferenceKey: String, targetDeviceID: AudioDevice.ID) { + let activeStreamID = activePlaybackRouteStreamIDByPreferenceKey[preferenceKey] + guard let streamIndex = streams.firstIndex(where: { $0.id == activeStreamID }) + ?? streams.firstIndex(where: { $0.preferenceKey == preferenceKey && $0.direction == .playback && !$0.isVirtualStream }) else { + return + } + + let stream = streams[streamIndex] + guard stream.direction == .playback, + routableOutputDevice(id: targetDeviceID) != nil else { + return + } + + var selectedRouteIDs = preferredOutputRouteSelectionIDs(for: stream) + if selectedRouteIDs.contains(targetDeviceID) { + selectedRouteIDs.removeAll { $0 == targetDeviceID } + } else { + selectedRouteIDs.append(targetDeviceID) + } + + NSLog( + "Xavucontrol playback route toggle %@ target=%@ selected=%@", + preferenceKey, + targetDeviceID, + selectedRouteIDs.joined(separator: ",") + ) + requestPlaybackRouteTargets(streamIndex: streamIndex, routeSelectionIDs: selectedRouteIDs) + } + + private func requestPlaybackRouteTargets(streamIndex: Int, routeSelectionIDs: [AudioDevice.ID]) { + let stream = streams[streamIndex] + guard stream.direction == .playback else { + return + } + + let selectedRouteIDs = normalizedPlaybackRouteSelectionIDs(routeSelectionIDs) if stream.direction == .playback { - if targetDeviceID == AppPreferences.defaultOutputRouteID { + if selectedRouteIDs.count == 1, + selectedRouteIDs.first == applicationDefaultOutputDevice()?.id { preferenceStore.setStreamOutputDeviceID(nil, for: stream.preferenceKey) + preferenceStore.setStreamOutputTargetDeviceIDs(nil, for: stream.preferenceKey) appPreferences.streamOutputDeviceIDs.removeValue(forKey: stream.preferenceKey) + appPreferences.streamOutputTargetDeviceIDs.removeValue(forKey: stream.preferenceKey) } else { - preferenceStore.setStreamOutputDeviceID(targetDeviceID, for: stream.preferenceKey) - appPreferences.streamOutputDeviceIDs[stream.preferenceKey] = targetDeviceID + preferenceStore.setStreamOutputDeviceID(nil, for: stream.preferenceKey) + preferenceStore.setStreamOutputTargetDeviceIDs(selectedRouteIDs, for: stream.preferenceKey) + appPreferences.streamOutputDeviceIDs.removeValue(forKey: stream.preferenceKey) + appPreferences.streamOutputTargetDeviceIDs[stream.preferenceKey] = selectedRouteIDs } } - let devices = stream.direction == .playback ? outputDevices : inputDevices - guard let targetDevice = devices.first(where: { $0.id == effectiveTargetDeviceID }) else { + let effectiveTargetDeviceIDs = effectiveOutputDeviceIDs(forRouteSelectionIDs: selectedRouteIDs) + let targetDevices = effectiveTargetDeviceIDs.compactMap { routableOutputDevice(id: $0) } + guard !targetDevices.isEmpty else { streams[streamIndex].routingStatus = "Target device is no longer available" - streams[streamIndex].routeSelectionID = selectedRouteID + streams[streamIndex].routeSelectionID = selectedRouteIDs.first return } - if let reason = routeReadinessMessage(stream: stream, targetDevice: targetDevice) { - streams[streamIndex].requestedDeviceID = targetDevice.id - streams[streamIndex].routeSelectionID = selectedRouteID + if let reason = targetDevices.compactMap({ routeReadinessMessage(stream: stream, targetDevice: $0) }).first { + streams[streamIndex].requestedDeviceID = targetDevices.first?.id + streams[streamIndex].routeSelectionID = selectedRouteIDs.first streams[streamIndex].routingStatus = reason routingStatus = reason return } - let routeRequest = AudioRouteRequest( - streamID: stream.id, - direction: stream.direction, - targetDeviceID: targetDevice.id, - status: .pending + NSLog( + "Xavucontrol playback route request %@ selected=%@ targets=%d [%@]", + stream.preferenceKey, + selectedRouteIDs.joined(separator: ","), + targetDevices.count, + targetDevices.map(\.name).joined(separator: ", ") ) - let routeGeneration = nextRouteGeneration(streamID: stream.id) - upsertRouteRequest(routeRequest) - - streams[streamIndex].requestedDeviceID = targetDevice.id - streams[streamIndex].routeSelectionID = selectedRouteID + streams[streamIndex].requestedDeviceID = targetDevices.first?.id + streams[streamIndex].routeSelectionID = selectedRouteIDs.first streams[streamIndex].routingStatus = RoutingStatus.pending.displayText routingStatus = RoutingStatus.pending.displayText - Task { - await applyRoute( - routeRequest: routeRequest, + stopDuplicatePlaybackRoutes(preferenceKey: stream.preferenceKey, keepingStreamID: stream.id) + activePlaybackRouteTargetIDsByPreferenceKey[stream.preferenceKey] = Set(targetDevices.map(\.id)) + activePlaybackRouteStreamIDByPreferenceKey[stream.preferenceKey] = stream.id + routeRequests.removeAll { $0.streamID == stream.id } + let requests = targetDevices.map { targetDevice in + AudioRouteRequest( streamID: stream.id, + direction: stream.direction, targetDeviceID: targetDevice.id, + status: .pending + ) + } + requests.forEach(upsertRouteRequest) + let routeGeneration = nextRouteGeneration(routeID: stream.preferenceKey) + + Task { + await applyRoutes( + routeRequests: requests, + streamID: stream.id, + preferenceKey: stream.preferenceKey, + targetDeviceIDs: targetDevices.map(\.id), routeGeneration: routeGeneration ) } @@ -520,6 +605,13 @@ final class AudioModel: ObservableObject { stream.routeSelectionID ?? preferredRouteSelectionID(for: stream) } + func routeSelectionIDs(for stream: AppAudioStream) -> [AudioDevice.ID] { + guard stream.direction == .playback else { + return [routeSelectionID(for: stream)] + } + return preferredOutputRouteSelectionIDs(for: stream) + } + func applicationDefaultOutputDeviceName() -> String { guard let device = applicationDefaultOutputDevice() else { return "No available output device" @@ -548,7 +640,7 @@ final class AudioModel: ObservableObject { targetDeviceID: AudioDevice.ID, routeGeneration: Int ) async { - guard routeGenerationByStreamID[streamID] == routeGeneration else { + guard routeGenerationByRouteID[routeRequest.id] == routeGeneration else { return } @@ -568,15 +660,49 @@ final class AudioModel: ObservableObject { stream: stream, targetDevice: targetDevice ) - guard routeGenerationByStreamID[streamID] == routeGeneration else { + guard routeGenerationByRouteID[routeRequest.id] == routeGeneration else { return } updateRouteStatus(routeRequest: routeRequest, status: RoutingStatus(result: result)) } - private func nextRouteGeneration(streamID: AppAudioStream.ID) -> Int { - let nextGeneration = (routeGenerationByStreamID[streamID] ?? 0) + 1 - routeGenerationByStreamID[streamID] = nextGeneration + private func applyRoutes( + routeRequests: [AudioRouteRequest], + streamID: AppAudioStream.ID, + preferenceKey: String, + targetDeviceIDs: [AudioDevice.ID], + routeGeneration: Int + ) async { + guard routeGenerationByRouteID[preferenceKey] == routeGeneration else { + return + } + + guard let stream = streams.first(where: { $0.id == streamID }) else { + return + } + + let targetDevices = targetDeviceIDs.compactMap { targetDeviceID in + outputDevices.first(where: { $0.id == targetDeviceID }) + } + guard !targetDevices.isEmpty else { + updateRouteStatuses(routeRequests: routeRequests, status: .failed("Target device is no longer available")) + return + } + + let result = await routingEngine.apply( + routeRequests: routeRequests, + stream: stream, + targetDevices: targetDevices + ) + guard routeGenerationByRouteID[preferenceKey] == routeGeneration else { + return + } + updateRouteStatuses(routeRequests: routeRequests, status: RoutingStatus(result: result)) + } + + private func nextRouteGeneration(routeID: AudioRouteRequest.ID) -> Int { + let nextGeneration = (routeGenerationByRouteID[routeID] ?? 0) + 1 + routeGenerationByRouteID[routeID] = nextGeneration return nextGeneration } @@ -672,11 +798,49 @@ final class AudioModel: ObservableObject { return AppPreferences.defaultInputRouteID } - if let preferredDeviceID = appPreferences.streamOutputDeviceIDs[stream.preferenceKey], + return preferredOutputRouteSelectionIDs(for: stream).first ?? applicationDefaultOutputDevice()?.id ?? AppPreferences.defaultOutputRouteID + } + + private func preferredOutputRouteSelectionIDs(for stream: AppAudioStream) -> [AudioDevice.ID] { + if let activeDeviceIDs = activePlaybackRouteTargetIDsByPreferenceKey[stream.preferenceKey], + !activeDeviceIDs.isEmpty { + let availableDeviceIDs = activeDeviceIDs.filter { routableOutputDevice(id: $0) != nil } + if !availableDeviceIDs.isEmpty { + return normalizedPlaybackRouteSelectionIDs(Array(availableDeviceIDs)) + } + } + + if let preferredDeviceIDs = appPreferences.streamOutputTargetDeviceIDs[stream.preferenceKey] { + let availableDeviceIDs = preferredDeviceIDs.filter { routableOutputDevice(id: $0) != nil } + if !availableDeviceIDs.isEmpty { + return normalizedPlaybackRouteSelectionIDs(availableDeviceIDs) + } + } + + if appPreferences.streamOutputTargetDeviceIDs[stream.preferenceKey] == nil, + let preferredDeviceID = appPreferences.streamOutputDeviceIDs[stream.preferenceKey], routableOutputDevice(id: preferredDeviceID) != nil { - return preferredDeviceID + return [preferredDeviceID] + } + + guard let defaultOutputDevice = applicationDefaultOutputDevice() else { + return [] + } + return [defaultOutputDevice.id] + } + + private func normalizedPlaybackRouteSelectionIDs(_ routeSelectionIDs: [AudioDevice.ID]) -> [AudioDevice.ID] { + let nonEmptyIDs = routeSelectionIDs + .filter { !$0.isEmpty && $0 != AppPreferences.defaultOutputRouteID } + guard !nonEmptyIDs.isEmpty else { + return applicationDefaultOutputDevice().map { [$0.id] } ?? [] + } + + var uniqueIDs: [AudioDevice.ID] = [] + for routeSelectionID in nonEmptyIDs where !uniqueIDs.contains(routeSelectionID) { + uniqueIDs.append(routeSelectionID) } - return AppPreferences.defaultOutputRouteID + return uniqueIDs } private func effectiveOutputDeviceID(forRouteSelectionID routeSelectionID: AudioDevice.ID) -> AudioDevice.ID { @@ -686,6 +850,17 @@ final class AudioModel: ObservableObject { return routeSelectionID } + private func effectiveOutputDeviceIDs(forRouteSelectionIDs routeSelectionIDs: [AudioDevice.ID]) -> [AudioDevice.ID] { + var effectiveIDs: [AudioDevice.ID] = [] + for routeSelectionID in normalizedPlaybackRouteSelectionIDs(routeSelectionIDs) { + let effectiveID = effectiveOutputDeviceID(forRouteSelectionID: routeSelectionID) + if !effectiveIDs.contains(effectiveID) { + effectiveIDs.append(effectiveID) + } + } + return effectiveIDs + } + private func effectiveInputDeviceID(forRouteSelectionID routeSelectionID: AudioDevice.ID) -> AudioDevice.ID { if routeSelectionID == AppPreferences.defaultInputRouteID { return applicationDefaultInputDevice()?.id ?? routeSelectionID @@ -730,32 +905,47 @@ final class AudioModel: ObservableObject { } private func applyPreferredRoutesToPlaybackStreams() { - for stream in streams where stream.direction == .playback && !stream.isVirtualStream { - let routeSelectionID = preferredRouteSelectionID(for: stream) - let targetDeviceID = effectiveOutputDeviceID(forRouteSelectionID: routeSelectionID) - guard routableOutputDevice(id: targetDeviceID) != nil else { - markRouteUnavailable(streamID: stream.id, routeSelectionID: routeSelectionID) + let routeablePlaybackGroups = Dictionary( + grouping: streams.filter { $0.direction == .playback && !$0.isVirtualStream }, + by: \.preferenceKey + ) + + for groupedStreams in routeablePlaybackGroups.values { + guard let stream = groupedStreams.first(where: { candidate in + routeRequests.contains { $0.streamID == candidate.id } + }) ?? groupedStreams.first else { + continue + } + + let routeSelectionIDs = preferredOutputRouteSelectionIDs(for: stream) + let targetDeviceIDs = effectiveOutputDeviceIDs(forRouteSelectionIDs: routeSelectionIDs) + guard targetDeviceIDs.contains(where: { routableOutputDevice(id: $0) != nil }) else { + markRouteUnavailable(streamID: stream.id, routeSelectionID: routeSelectionIDs.first ?? AppPreferences.defaultOutputRouteID) continue } - if let existingRequest = routeRequests.first(where: { $0.streamID == stream.id }), - existingRequest.targetDeviceID == targetDeviceID { + let requestedTargetIDs = Set(targetDeviceIDs) + if activePlaybackRouteTargetIDsByPreferenceKey[stream.preferenceKey] == requestedTargetIDs, + activePlaybackRouteStreamIDByPreferenceKey[stream.preferenceKey] != nil { continue } - startRoute(streamID: stream.id, routeSelectionID: routeSelectionID, targetDeviceID: targetDeviceID) + stopDuplicatePlaybackRoutes(preferenceKey: stream.preferenceKey, keepingStreamID: stream.id) + startRoutes(streamID: stream.id, routeSelectionIDs: routeSelectionIDs, targetDeviceIDs: targetDeviceIDs) } } private func rerouteDefaultPlaybackStreams() { for stream in streams where stream.direction == .playback && !stream.isVirtualStream { - guard appPreferences.streamOutputDeviceIDs[stream.preferenceKey] == nil else { + guard appPreferences.streamOutputDeviceIDs[stream.preferenceKey] == nil, + appPreferences.streamOutputTargetDeviceIDs[stream.preferenceKey] == nil else { continue } - startRoute( + let routeSelectionIDs = preferredOutputRouteSelectionIDs(for: stream) + startRoutes( streamID: stream.id, - routeSelectionID: AppPreferences.defaultOutputRouteID, - targetDeviceID: effectiveOutputDeviceID(forRouteSelectionID: AppPreferences.defaultOutputRouteID) + routeSelectionIDs: routeSelectionIDs, + targetDeviceIDs: effectiveOutputDeviceIDs(forRouteSelectionIDs: routeSelectionIDs) ) } } @@ -795,49 +985,112 @@ final class AudioModel: ObservableObject { } private func startRoute(streamID: AppAudioStream.ID, routeSelectionID: AudioDevice.ID, targetDeviceID: AudioDevice.ID) { + startRoutes(streamID: streamID, routeSelectionIDs: [routeSelectionID], targetDeviceIDs: [targetDeviceID]) + } + + private func startRoutes(streamID: AppAudioStream.ID, routeSelectionIDs: [AudioDevice.ID], targetDeviceIDs: [AudioDevice.ID]) { guard let streamIndex = streams.firstIndex(where: { $0.id == streamID }) else { return } let stream = streams[streamIndex] - guard stream.direction == .playback, - let targetDevice = routableOutputDevice(id: targetDeviceID) else { - markRouteUnavailable(streamID: streamID, routeSelectionID: routeSelectionID) + let targetDevices = targetDeviceIDs.compactMap { routableOutputDevice(id: $0) } + guard stream.direction == .playback, !targetDevices.isEmpty else { + markRouteUnavailable(streamID: streamID, routeSelectionID: routeSelectionIDs.first ?? AppPreferences.defaultOutputRouteID) return } - if let reason = routeReadinessMessage(stream: stream, targetDevice: targetDevice) { - streams[streamIndex].requestedDeviceID = targetDevice.id - streams[streamIndex].routeSelectionID = routeSelectionID + if let reason = targetDevices.compactMap({ routeReadinessMessage(stream: stream, targetDevice: $0) }).first { + streams[streamIndex].requestedDeviceID = targetDevices.first?.id + streams[streamIndex].routeSelectionID = routeSelectionIDs.first streams[streamIndex].routingStatus = reason routingStatus = reason return } - let routeRequest = AudioRouteRequest( - streamID: stream.id, - direction: stream.direction, - targetDeviceID: targetDevice.id, - status: .pending - ) - let routeGeneration = nextRouteGeneration(streamID: stream.id) - upsertRouteRequest(routeRequest) - - streams[streamIndex].requestedDeviceID = targetDevice.id - streams[streamIndex].routeSelectionID = routeSelectionID + streams[streamIndex].requestedDeviceID = targetDevices.first?.id + streams[streamIndex].routeSelectionID = routeSelectionIDs.first streams[streamIndex].routingStatus = RoutingStatus.pending.displayText routingStatus = RoutingStatus.pending.displayText - Task { - await applyRoute( - routeRequest: routeRequest, + let requestedTargetIDs = Set(targetDevices.map(\.id)) + activePlaybackRouteTargetIDsByPreferenceKey[stream.preferenceKey] = requestedTargetIDs + activePlaybackRouteStreamIDByPreferenceKey[stream.preferenceKey] = stream.id + routeRequests.removeAll { $0.streamID == stream.id } + let requests = targetDevices.map { targetDevice in + AudioRouteRequest( streamID: stream.id, + direction: stream.direction, targetDeviceID: targetDevice.id, + status: .pending + ) + } + requests.forEach(upsertRouteRequest) + let routeGeneration = nextRouteGeneration(routeID: stream.preferenceKey) + + Task { + await applyRoutes( + routeRequests: requests, + streamID: stream.id, + preferenceKey: stream.preferenceKey, + targetDeviceIDs: targetDevices.map(\.id), routeGeneration: routeGeneration ) } } + private func stopRemovedPlaybackRoutes(stream: AppAudioStream, activeTargetDeviceIDs: Set) { + let staleRequests = routeRequests.filter { + $0.streamID == stream.id && !activeTargetDeviceIDs.contains($0.targetDeviceID) + } + guard !staleRequests.isEmpty else { + return + } + + routeRequests.removeAll { request in + request.streamID == stream.id && !activeTargetDeviceIDs.contains(request.targetDeviceID) + } + + Task { + for request in staleRequests { + await ProcessTapRoutingService.shared.stop(streamID: stream.id, targetDeviceID: request.targetDeviceID) + await VirtualCableRoutingService.shared.stop(streamID: stream.id) + } + } + } + + private func stopDuplicatePlaybackRoutes(preferenceKey: String, keepingStreamID: AppAudioStream.ID) { + if let activeStreamID = activePlaybackRouteStreamIDByPreferenceKey[preferenceKey], + activeStreamID != keepingStreamID { + activePlaybackRouteStreamIDByPreferenceKey[preferenceKey] = keepingStreamID + Task { + await ProcessTapRoutingService.shared.stop(streamID: activeStreamID) + await VirtualCableRoutingService.shared.stop(streamID: activeStreamID) + } + } + + let duplicateStreamIDs = streams + .filter { + $0.direction == .playback + && !$0.isVirtualStream + && $0.preferenceKey == preferenceKey + && $0.id != keepingStreamID + } + .map(\.id) + + guard !duplicateStreamIDs.isEmpty else { + return + } + + routeRequests.removeAll { duplicateStreamIDs.contains($0.streamID) } + Task { + for duplicateStreamID in duplicateStreamIDs { + await ProcessTapRoutingService.shared.stop(streamID: duplicateStreamID) + await VirtualCableRoutingService.shared.stop(streamID: duplicateStreamID) + } + } + } + private func updateRouteStatus(routeRequest: AudioRouteRequest, status: RoutingStatus) { upsertRouteRequest(AudioRouteRequest( streamID: routeRequest.streamID, @@ -847,9 +1100,51 @@ final class AudioModel: ObservableObject { )) if let streamIndex = streams.firstIndex(where: { $0.id == routeRequest.streamID }) { - streams[streamIndex].requestedDeviceID = routeRequest.targetDeviceID + let streamRequests = routeRequests.filter { $0.streamID == routeRequest.streamID } + streams[streamIndex].requestedDeviceID = streamRequests.first?.targetDeviceID ?? routeRequest.targetDeviceID + streams[streamIndex].routeSelectionID = preferredRouteSelectionID(for: streams[streamIndex]) + if streamRequests.count > 1 { + let activeCount = streamRequests.filter { + if case .active = $0.status { return true } + return false + }.count + let targetNames = streamRequests.compactMap { request in + outputDevices.first(where: { $0.id == request.targetDeviceID })?.name + } + streams[streamIndex].routingStatus = "Multi-output route \(activeCount)/\(streamRequests.count): \(targetNames.joined(separator: ", "))" + } else { + streams[streamIndex].routingStatus = status.displayText + } + } + routingStatus = status.displayText + } + + private func updateRouteStatuses(routeRequests: [AudioRouteRequest], status: RoutingStatus) { + for routeRequest in routeRequests { + upsertRouteRequest(AudioRouteRequest( + streamID: routeRequest.streamID, + direction: routeRequest.direction, + targetDeviceID: routeRequest.targetDeviceID, + status: status + )) + } + + guard let firstRequest = routeRequests.first else { + routingStatus = status.displayText + return + } + + if let streamIndex = streams.firstIndex(where: { $0.id == firstRequest.streamID }) { + streams[streamIndex].requestedDeviceID = routeRequests.first?.targetDeviceID streams[streamIndex].routeSelectionID = preferredRouteSelectionID(for: streams[streamIndex]) - streams[streamIndex].routingStatus = status.displayText + if routeRequests.count > 1 { + let targetNames = routeRequests.compactMap { request in + outputDevices.first(where: { $0.id == request.targetDeviceID })?.name + } + streams[streamIndex].routingStatus = "\(status.displayText) Outputs: \(targetNames.joined(separator: ", "))" + } else { + streams[streamIndex].routingStatus = status.displayText + } } routingStatus = status.displayText } diff --git a/Xavucontrol/Xavucontrol/presentation/tabs/PatchbayTab.swift b/Xavucontrol/Xavucontrol/presentation/tabs/PatchbayTab.swift index d55bf0f..1daea32 100644 --- a/Xavucontrol/Xavucontrol/presentation/tabs/PatchbayTab.swift +++ b/Xavucontrol/Xavucontrol/presentation/tabs/PatchbayTab.swift @@ -89,10 +89,10 @@ private struct PatchbayGraphBuilder { for stream in playbackStreams { let appNode = appNode(stream: stream, direction: .playback, side: .left) - let targetNodeID = playbackTargetNodeID(stream: stream) + let targetNodeIDs = playbackTargetNodeIDs(stream: stream) nodesByID[appNode.id] = appNode - if nodesByID[targetNodeID] != nil { + for targetNodeID in targetNodeIDs where nodesByID[targetNodeID] != nil { links.append(contentsOf: stereoLinks( sourceNodeID: appNode.id, sourcePrefix: "out", @@ -190,12 +190,10 @@ private struct PatchbayGraphBuilder { ) } - private func playbackTargetNodeID(stream: AppAudioStream) -> PatchbayNode.ID { - let routeSelectionID = audioModel.routeSelectionID(for: stream) - if routeSelectionID == AppPreferences.defaultOutputRouteID { - return outputNodeID(applicationDefaultOutputDeviceID() ?? routeSelectionID) + private func playbackTargetNodeIDs(stream: AppAudioStream) -> [PatchbayNode.ID] { + audioModel.routeSelectionIDs(for: stream).map { routeSelectionID in + return outputNodeID(routeSelectionID) } - return outputNodeID(stream.requestedDeviceID ?? routeSelectionID) } private func recordingSourceNodeID(stream: AppAudioStream) -> PatchbayNode.ID { diff --git a/Xavucontrol/Xavucontrol/presentation/tabs/StreamsTab.swift b/Xavucontrol/Xavucontrol/presentation/tabs/StreamsTab.swift index 472a9cc..9f099ee 100644 --- a/Xavucontrol/Xavucontrol/presentation/tabs/StreamsTab.swift +++ b/Xavucontrol/Xavucontrol/presentation/tabs/StreamsTab.swift @@ -79,14 +79,15 @@ struct StreamRow: View { showsStreamControls && audioModel.streamUsesXavucontrolVirtualDevice(stream) } - private var routeSelection: Binding { - Binding( - get: { audioModel.routeSelectionID(for: stream) }, - set: { targetDeviceID in - guard !targetDeviceID.isEmpty, !stream.isVirtualStream else { return } - audioModel.requestRoute(streamID: stream.id, targetDeviceID: targetDeviceID) - } - ) + private var routeSelections: [String] { + audioModel.routeSelectionIDs(for: stream) + } + + private var routeSummary: String { + let names = routeSelections.compactMap { selectionID in + devices.first(where: { $0.id == selectionID }).map(deviceTitle) + } + return names.isEmpty ? "No available output" : names.joined(separator: ", ") } private var muteBinding: Binding { @@ -158,14 +159,26 @@ struct StreamRow: View { HStack(spacing: 8) { Text("Route to:") .frame(width: 76, alignment: .trailing) - Picker("", selection: routeSelection) { - Text("Default Output Device (\(audioModel.applicationDefaultOutputDeviceName()))") - .tag(AppPreferences.defaultOutputRouteID) + Menu { ForEach(devices) { device in - Text(device.name).tag(device.id) + Button { + toggleOutputDevice(device.id) + } label: { + let title = deviceTitle(device) + if routeSelections.contains(device.id) { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } } + } label: { + Text(routeSummary) + .lineLimit(1) + .truncationMode(.middle) + .frame(minWidth: 280, alignment: .leading) } - .labelsHidden() + .menuStyle(.borderlessButton) .frame(minWidth: 280) .disabled(stream.isVirtualStream) Text(stream.routingStatus ?? (stream.isVirtualStream ? "Diagnostic stream" : "No routing request")) @@ -222,6 +235,24 @@ struct StreamRow: View { let allDevices = stream.direction == .playback ? audioModel.outputDevices : audioModel.inputDevices return allDevices.first(where: { $0.id == assignedDeviceID })?.name ?? "Unknown device" } + + private func toggleOutputDevice(_ deviceID: AudioDevice.ID) { + guard !stream.isVirtualStream else { + return + } + + audioModel.togglePlaybackRouteTarget( + preferenceKey: stream.preferenceKey, + targetDeviceID: deviceID + ) + } + + private func deviceTitle(_ device: AudioDevice) -> String { + if audioModel.isApplicationDefaultOutputDevice(device) { + return "\(device.name) (Xavucontrol Default Output)" + } + return device.name + } } private struct AppIconTile: View {