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
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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`.

Expand All @@ -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

Expand Down
28 changes: 26 additions & 2 deletions Xavucontrol/Xavucontrol/core/preference/AppPreferenceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions Xavucontrol/Xavucontrol/core/routing/RoutingEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
Expand Down Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading