You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
252 lines
10 KiB
252 lines
10 KiB
import CAudioRing
|
|
import Foundation
|
|
import MusubiCore
|
|
import Observation
|
|
import OSLog
|
|
|
|
/// Routes audio sources (NDI, input devices) to single channels of output devices.
|
|
///
|
|
/// NDI audio is pushed in by the caller through the `AudioFanout` returned from `fanout(forNDI:)`;
|
|
/// input devices are captured here. Input-device routes are gated by the talk key.
|
|
@MainActor @Observable
|
|
public final class AudioMatrix {
|
|
public enum SourceRef: Hashable, Sendable {
|
|
case ndi(name: String)
|
|
case input(deviceUID: String)
|
|
}
|
|
|
|
public struct Route: Identifiable, Equatable, Sendable {
|
|
public let id: UUID
|
|
public let source: SourceRef
|
|
public let deviceUID: String
|
|
public let channel: Int
|
|
public var selection: ChannelSelection
|
|
public var gainDB: Float
|
|
public var muted: Bool
|
|
}
|
|
|
|
public private(set) var devices: [AudioDevice] = []
|
|
public private(set) var routes: [Route] = []
|
|
public private(set) var errors: [String: String] = [:] // device UID → message
|
|
public var talkActive = false { didSet { applyGates() } }
|
|
|
|
/// Fanouts for NDI sources; the app feeds them from NDI receivers.
|
|
public private(set) var ndiFanouts: [String: AudioFanout] = [:]
|
|
|
|
private var renderers: [UUID: RouteRenderer] = [:]
|
|
/// Removed routes stay alive briefly so the render thread never releases (frees) them.
|
|
private var retired: [RouteRenderer] = []
|
|
private var outputEngines: [String: OutputEngine] = [:]
|
|
private var inputEngines: [String: InputEngine] = [:]
|
|
/// Extra consumers of input devices (intercom talkback), keyed by device UID.
|
|
private var inputTaps: [String: [AudioFeed]] = [:]
|
|
private var deviceObserver: AnyObject?
|
|
private let log = Logger(subsystem: "studio.zielke.musubi", category: "audio")
|
|
|
|
public init() {
|
|
devices = AudioDevices.all()
|
|
deviceObserver = AudioDevices.observeChanges { [weak self] in self?.devicesChanged() }
|
|
}
|
|
|
|
public var outputDevices: [AudioDevice] { devices.filter { $0.outputChannels > 0 } }
|
|
public var inputDevices: [AudioDevice] { devices.filter { $0.inputChannels > 0 } }
|
|
|
|
@discardableResult
|
|
public func addRoute(_ source: SourceRef, deviceUID: String, channel: Int, selection: ChannelSelection = .mono) -> UUID {
|
|
let id = UUID()
|
|
let latency: Double = if case .input = source { 0.012 } else { 0.08 }
|
|
let feed = AudioFeed()
|
|
let renderer = RouteRenderer(id: id, feed: feed, outputChannel: channel, selection: selection, targetLatency: latency)
|
|
renderers[id] = renderer
|
|
routes.append(Route(id: id, source: source, deviceUID: deviceUID, channel: channel, selection: selection, gainDB: 0, muted: false))
|
|
|
|
switch source {
|
|
case .ndi(let name):
|
|
let fanout = ndiFanouts[name] ?? AudioFanout()
|
|
ndiFanouts[name] = fanout
|
|
fanout.add(feed)
|
|
case .input(let uid):
|
|
inputEngine(for: uid)?.fanout.add(feed)
|
|
}
|
|
applyGates()
|
|
rebuildOutputs()
|
|
log.info("Route \(String(describing: source), privacy: .public) → \(deviceUID, privacy: .public) ch \(channel + 1)")
|
|
return id
|
|
}
|
|
|
|
public func removeRoute(_ id: UUID) {
|
|
guard let index = routes.firstIndex(where: { $0.id == id }), let renderer = renderers.removeValue(forKey: id) else { return }
|
|
let route = routes.remove(at: index)
|
|
retire(renderer)
|
|
switch route.source {
|
|
case .ndi(let name):
|
|
ndiFanouts[name]?.remove(renderer.feed)
|
|
if ndiFanouts[name]?.isEmpty == true { ndiFanouts[name] = nil }
|
|
case .input(let uid):
|
|
inputEngines[uid]?.fanout.remove(renderer.feed)
|
|
if !routes.contains(where: { $0.source == route.source }), inputTaps[uid, default: []].isEmpty {
|
|
inputEngines.removeValue(forKey: uid)?.stop()
|
|
}
|
|
}
|
|
rebuildOutputs()
|
|
}
|
|
|
|
public func setGain(_ id: UUID, db: Float) {
|
|
update(id) { $0.gainDB = db }
|
|
renderers[id]?.gain = Level.gain(fromDB: db)
|
|
}
|
|
|
|
public func setMuted(_ id: UUID, _ muted: Bool) {
|
|
update(id) { $0.muted = muted }
|
|
renderers[id]?.muted = muted
|
|
}
|
|
|
|
public func setSelection(_ id: UUID, _ selection: ChannelSelection) {
|
|
update(id) { $0.selection = selection }
|
|
renderers[id]?.selection = selection
|
|
}
|
|
|
|
/// Peak level of a route since the last call, in dBFS.
|
|
public func takePeak(_ id: UUID) -> Float {
|
|
guard let renderer = renderers[id] else { return Level.floorDB }
|
|
let peak = renderer.peak
|
|
renderer.peak = 0
|
|
return Level.db(fromGain: peak)
|
|
}
|
|
|
|
/// Peak level of an output channel since the last call, in dBFS.
|
|
public func takeChannelPeak(deviceUID: String, channel: Int) -> Float {
|
|
guard let engine = outputEngines[deviceUID], channel < engine.channels else { return Level.floorDB }
|
|
let peak = engine.channelPeaks[channel]
|
|
engine.channelPeaks[channel] = 0
|
|
return Level.db(fromGain: peak)
|
|
}
|
|
|
|
/// Under- and overruns of a route since it was created (dropouts = audible clicks).
|
|
public func dropouts(_ id: UUID) -> Int {
|
|
guard let renderer = renderers[id] else { return 0 }
|
|
return renderer.totalUnderruns + renderer.totalOverruns
|
|
}
|
|
|
|
public struct Diagnostics: Sendable {
|
|
public let underruns: Int
|
|
public let overruns: Int
|
|
public let bufferedFrames: Int
|
|
public let correction: Double
|
|
}
|
|
|
|
public func diagnostics(_ id: UUID) -> Diagnostics? {
|
|
guard let renderer = renderers[id] else { return nil }
|
|
return Diagnostics(underruns: renderer.totalUnderruns, overruns: renderer.totalOverruns,
|
|
bufferedFrames: Int(musubi_ring_available(renderer.feed.ring)), correction: renderer.correction)
|
|
}
|
|
|
|
// MARK: - Taps
|
|
|
|
/// Captures an input device for another consumer (e.g. intercom talkback), independent of routes.
|
|
/// The caller reads the feed with `AudioFeed.read`; the talk key does not gate taps.
|
|
public func addInputTap(deviceUID: String) -> AudioFeed {
|
|
let feed = AudioFeed()
|
|
inputTaps[deviceUID, default: []].append(feed)
|
|
inputEngine(for: deviceUID)?.fanout.add(feed)
|
|
return feed
|
|
}
|
|
|
|
public func removeInputTap(_ feed: AudioFeed, deviceUID: String) {
|
|
inputTaps[deviceUID]?.removeAll { $0 === feed }
|
|
if inputTaps[deviceUID]?.isEmpty == true { inputTaps[deviceUID] = nil }
|
|
inputEngines[deviceUID]?.fanout.remove(feed)
|
|
if inputTaps[deviceUID] == nil, !routes.contains(where: { $0.source == .input(deviceUID: deviceUID) }) {
|
|
inputEngines.removeValue(forKey: deviceUID)?.stop()
|
|
}
|
|
}
|
|
|
|
// MARK: - Engines
|
|
|
|
private func retire(_ renderer: RouteRenderer) {
|
|
retired.append(renderer)
|
|
Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(2))
|
|
self?.retired.removeAll { $0 === renderer }
|
|
}
|
|
}
|
|
|
|
private func update(_ id: UUID, _ change: (inout Route) -> Void) {
|
|
guard let index = routes.firstIndex(where: { $0.id == id }) else { return }
|
|
change(&routes[index])
|
|
}
|
|
|
|
private func applyGates() {
|
|
for route in routes {
|
|
guard case .input = route.source else { continue }
|
|
renderers[route.id]?.gateOpen = talkActive
|
|
}
|
|
}
|
|
|
|
private func inputEngine(for uid: String) -> InputEngine? {
|
|
if let engine = inputEngines[uid] { return engine }
|
|
guard let device = devices.first(where: { $0.uid == uid }) else { return nil }
|
|
do {
|
|
let engine = try InputEngine(device: device)
|
|
inputEngines[uid] = engine
|
|
errors[uid] = nil
|
|
return engine
|
|
} catch {
|
|
errors[uid] = "\(device.name) lässt sich nicht öffnen."
|
|
log.error("Input \(device.name, privacy: .public): \(String(describing: error), privacy: .public)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Starts engines for devices with routes, stops the others, and hands each engine its routes.
|
|
private func rebuildOutputs() {
|
|
let wanted = Set(routes.map(\.deviceUID))
|
|
for uid in outputEngines.keys where !wanted.contains(uid) {
|
|
outputEngines.removeValue(forKey: uid)?.stop()
|
|
}
|
|
for uid in wanted {
|
|
if outputEngines[uid] == nil, let device = devices.first(where: { $0.uid == uid }) {
|
|
do {
|
|
outputEngines[uid] = try OutputEngine(device: device)
|
|
errors[uid] = nil
|
|
log.info("Output \(device.name, privacy: .public) started at \(device.sampleRate) Hz")
|
|
} catch {
|
|
errors[uid] = "\(device.name) lässt sich nicht öffnen."
|
|
log.error("Output \(device.name, privacy: .public): \(String(describing: error), privacy: .public)")
|
|
}
|
|
}
|
|
outputEngines[uid]?.setRoutes(routes.filter { $0.deviceUID == uid }.compactMap { renderers[$0.id] })
|
|
}
|
|
}
|
|
|
|
/// A device was plugged in or out: drop engines of vanished devices, restart those that came back.
|
|
private func devicesChanged() {
|
|
devices = AudioDevices.all()
|
|
let present = Set(devices.map(\.uid))
|
|
|
|
for uid in outputEngines.keys where !present.contains(uid) {
|
|
outputEngines.removeValue(forKey: uid)?.stop()
|
|
log.notice("Output device \(uid, privacy: .public) disappeared")
|
|
}
|
|
for uid in inputEngines.keys where !present.contains(uid) {
|
|
inputEngines.removeValue(forKey: uid)?.stop()
|
|
log.notice("Input device \(uid, privacy: .public) disappeared")
|
|
}
|
|
// Reattach input routes of devices that are back (add is idempotent via remove first).
|
|
for route in routes {
|
|
guard case .input(let uid) = route.source, present.contains(uid),
|
|
let renderer = renderers[route.id], let engine = inputEngine(for: uid) else { continue }
|
|
engine.fanout.remove(renderer.feed)
|
|
engine.fanout.add(renderer.feed)
|
|
}
|
|
for (uid, feeds) in inputTaps where present.contains(uid) {
|
|
guard let engine = inputEngine(for: uid) else { continue }
|
|
for feed in feeds {
|
|
engine.fanout.remove(feed)
|
|
engine.fanout.add(feed)
|
|
}
|
|
}
|
|
rebuildOutputs()
|
|
}
|
|
}
|