Files
KMix_Pro/KJamsClone/DJAudioRouting.swift
T
2026-11-05 16:35:24 +00:00

90 lines
5.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import CoreAudio
struct DJAudioRoute: Codable, Equatable {
var uid = ""
var firstChannel = 0
}
struct DJAudioRouting: Codable, Equatable {
var mode = "Internal"
var main = DJAudioRoute()
var deckA = DJAudioRoute()
var deckB = DJAudioRoute()
var cue = DJAudioRoute()
func route(_ letter: String) -> DJAudioRoute { mode == "External" ? (letter == "A" ? deckA : deckB) : main }
static func channelMap(channels: Int,first: Int) throws -> [Int32] {
guard channels >= 2,first >= 0,first % 2 == 0,first+1 < channels else { throw LibraryError.message("Choose an available stereo channel pair.") }
var map = [Int32](repeating:-1,count:channels);map[first] = 0;map[first+1] = 1;return map
}
func validate(devices: [DJAudioDevice],defaultID: AudioDeviceID?) throws {
func resolved(_ route: DJAudioRoute) throws -> DJAudioDevice {
guard let device = devices.first(where:{route.uid.isEmpty ? $0.id == defaultID : $0.uid == route.uid}) else { throw LibraryError.message("An audio output is unavailable. Reconnect it or choose another device.") }
_ = try Self.channelMap(channels:device.outputChannels,first:route.firstChannel);return device
}
let outputs = mode == "External" ? [deckA,deckB] : [main]
for route in outputs { _ = try resolved(route) }
if mode == "External",try resolved(deckA).id == resolved(deckB).id,deckA.firstChannel == deckB.firstChannel {
throw LibraryError.message("External mixer decks need separate output devices or channel pairs.")
}
if !cue.uid.isEmpty {
let cueDevice = try resolved(cue)
for route in outputs where try resolved(route).id == cueDevice.id && route.firstChannel == cue.firstChannel {
throw LibraryError.message("Pre-cueing overlaps a public output. Choose another channel pair or device.")
}
}
}
}
@MainActor final class DJAudioDeviceMonitor {
private var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
private var block: AudioObjectPropertyListenerBlock?
init(_ changed: @escaping @MainActor () -> Void) {
let listener: AudioObjectPropertyListenerBlock = { _,_ in Task { @MainActor in changed() } }
block = listener;AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject),&address,.main,listener)
}
deinit { if let block { AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject),&address,.main,block) } }
}
struct DJAudioDevicesPane: View {
@ObservedObject var system: DJSystem
@State private var draft = DJAudioRouting()
var body: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Audio Routing") {
VStack(spacing:22) {
HStack { Text("Mixer Mode:").frame(width:130,alignment:.trailing);Picker("Mixer Mode",selection:$draft.mode) { Text("Internal").tag("Internal");Text("External").tag("External") }.pickerStyle(.radioGroup).horizontalRadioGroupLayout().labelsHidden();Spacer() }
if draft.mode == "Internal" { row("Main Output",route:$draft.main,icon:"hifispeaker.fill") }
else { row("Deck A",route:$draft.deckA,icon:"a.circle");row("Deck B",route:$draft.deckB,icon:"b.circle") }
row("Pre-Cueing",route:$draft.cue,icon:"headphones",optional:true)
HStack { Text("Cue Volume:").frame(width:130,alignment:.trailing);Slider(value:$system.cueLevel,in:0...1).frame(maxWidth:320);Spacer() }
}.padding(.vertical,22)
}
Text(draft.mode == "Internal" ? "Internal mixes both decks through the main output. Pre-cueing can use channels 3–4 on the same four-channel sound card." : "External sends each deck to its own stereo pair. The software crossfader is bypassed; mix the outputs on your external mixer.").font(.callout).foregroundStyle(.secondary)
if system.devices.isEmpty { Text("No audio outputs reported by macOS. Reconnect the interface and refresh.").foregroundStyle(.orange) }
HStack {
Button("Refresh Devices") { system.refreshDevices() }
Spacer()
Button("Revert") { draft = system.routing }.disabled(draft == system.routing)
Button("Apply") { system.applyRouting(draft) }.buttonStyle(.borderedProminent).disabled(draft == system.routing)
}
Text("Booth, microphone and recording input are not available. Karaoke has its own output settings.").font(.caption).foregroundStyle(.secondary)
}.onAppear { system.refreshDevices();draft = system.routing }
}
private func row(_ label: String,route: Binding<DJAudioRoute>,icon: String,optional: Bool = false) -> some View {
let device = system.devices.first { route.wrappedValue.uid.isEmpty ? $0.id == DJAudioDevice.defaultOutput() : $0.uid == route.wrappedValue.uid }
let pairs = Array(stride(from:0,to:max(0,(device?.outputChannels ?? 2)-1),by:2))
return HStack(spacing:12) {
Text(label+":").frame(width:130,alignment:.trailing)
Picker(label,selection:Binding(get:{route.wrappedValue.uid},set:{route.wrappedValue = .init(uid:$0,firstChannel:0)})) {
Text(optional ? "None" : "System default").tag("")
ForEach(system.devices) { Text($0.name).tag($0.uid) }
if !route.wrappedValue.uid.isEmpty,device == nil { Text("Disconnected device").tag(route.wrappedValue.uid) }
}.labelsHidden().frame(maxWidth:340)
Image(systemName:icon).foregroundStyle(.secondary).frame(width:24)
Picker("\(label) channels",selection:route.firstChannel) {
ForEach(pairs,id:\.self) { Text("ch \($0+1)–\($0+2)").tag($0) }
}.labelsHidden().frame(width:110).disabled(optional && route.wrappedValue.uid.isEmpty)
}
}
}