Files
KMix_Pro/KJamsClone/DJAudioRouting.swift

174 lines
10 KiB
Swift
Raw Permalink 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 controllerPreset(_ device: DJAudioDevice,mode: String) -> Self {
var routing = Self();routing.mode = mode
routing.main = .init(uid:device.uid,firstChannel:0)
routing.deckA = routing.main
routing.deckB = .init(uid:device.uid,firstChannel:device.outputChannels >= 4 ? 2 : 0)
if mode == "Internal",device.outputChannels >= 4 { routing.cue = .init(uid:device.uid,firstChannel:2) }
return routing
}
func changingMode(_ mode: String,preferred: DJAudioDevice?) -> Self {
var next = self;next.mode = mode
if mode == "External" {
if next.deckA == DJAudioRoute(),next.deckB == DJAudioRoute(),let preferred {
next.deckA = .init(uid:preferred.uid,firstChannel:0);next.deckB = .init(uid:preferred.uid,firstChannel:2)
}
if !next.cue.uid.isEmpty,[next.deckA,next.deckB].contains(next.cue) { next.cue = .init() }
}
return next
}
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()
private var recommended: DJAudioDevice? { system.devices.first { $0.name.localizedCaseInsensitiveContains("MixTrack") && $0.outputChannels >= 4 } ?? system.devices.first{$0.outputChannels >= 4} }
private var validation: String? {
do { try draft.validate(devices:system.devices,defaultID:DJAudioDevice.defaultOutput());return nil }
catch { return error.localizedDescription }
}
var body: some View {
VStack(spacing:16) {
ScrollView {
VStack(alignment:.leading,spacing:22) {
PreferencesSection("Audio Routing") {
VStack(spacing:22) {
HStack { Text("Mixer Mode:").frame(width:130,alignment:.trailing);Picker("Mixer Mode",selection:Binding(get:{draft.mode},set:{draft = draft.changingMode($0,preferred:recommended)})) { 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)
}
if let recommended {
Button("Use \(recommended.name)") { draft = .controllerPreset(recommended,mode:draft.mode) }
.help("Use the controller’s stereo pairs immediately")
}
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) }
if let validation { Text(validation).font(.callout).foregroundStyle(.orange) }
PreferencesSection("Detected Audio Devices") {
ForEach(system.detectedDevices) { device in
HStack(alignment:.top) {
Text(device.name).frame(maxWidth:.infinity,alignment:.leading)
VStack(alignment:.trailing,spacing:3) {
Text("\(device.outputChannels) out · \(device.inputChannels) in")
if device.outputChannels >= 2 {
Text(device.stereoPairs.map { "ch \($0+1)–\($0+2)" }.joined(separator:", ")).foregroundStyle(.secondary)
} else { Text(device.outputChannels == 0 ? "Input only" : "Mono output").foregroundStyle(.secondary) }
}
}.font(.caption).padding(.vertical,3)
}
}
Text("Booth, microphone and recording input are not available. Karaoke has its own output settings.").font(.caption).foregroundStyle(.secondary)
}
}
Divider()
HStack {
Button("Refresh Devices") { system.refreshDevices() }
Spacer()
if validation != nil { Button("Revert invalid changes") { draft = system.routing } }
Text("Changes apply immediately").font(.caption).foregroundStyle(.secondary)
}
}.onAppear {
system.refreshDevices();draft = system.routing
}
.onStudioChange(of:draft) { next in
if next != system.routing,validation == nil { system.applyRouting(next) }
}
.task {
while !Task.isCancelled {
do { try await Task.sleep(nanoseconds:2_000_000_000) } catch { return }
system.refreshDevices()
}
}
}
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 = device?.stereoPairs ?? [0]
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)
DJAudioChannelPicker(value:route.firstChannel,pairs:pairs,label:label+" channels")
.frame(width:110,height:26).disabled(optional && route.wrappedValue.uid.isEmpty)
}
}
}
// Explicit native menus keep every reported channel pair visible and keyboard accessible.
struct DJAudioChannelPicker: NSViewRepresentable {
@Binding var value: Int
let pairs: [Int]
let label: String
func makeCoordinator() -> Coordinator { Coordinator(value:$value) }
func makeNSView(context: Context) -> NSPopUpButton {
let button = NSPopUpButton(frame:.zero,pullsDown:false)
button.target = context.coordinator;button.action = #selector(Coordinator.changed(_:))
button.setAccessibilityLabel(label);button.identifier = NSUserInterfaceItemIdentifier(label);return button
}
func updateNSView(_ button: NSPopUpButton,context: Context) {
context.coordinator.value = $value
if button.itemArray.map(\.tag) != pairs {
button.removeAllItems()
for pair in pairs { button.addItem(withTitle:"ch \(pair+1)–\(pair+2)");button.lastItem?.tag = pair }
}
button.selectItem(withTag:value);button.isEnabled = context.environment.isEnabled
button.setAccessibilityLabel(label)
}
func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSPopUpButton,context: Context) -> CGSize? { .init(width:proposal.width ?? 110,height:26) }
final class Coordinator: NSObject {
var value: Binding<Int>
init(value: Binding<Int>) { self.value = value }
@objc func changed(_ sender: NSPopUpButton) { if let selected = sender.selectedItem { value.wrappedValue = selected.tag } }
}
}