- NDIReceiver captures audio (audio-only bandwidth for sound routes) - AUHAL output per device, mic input via AUHAL, fan-out to routes - Drift-compensated, allocation-free render path; backlog trimmed on start - Global talk key (Ctrl-Option-Space, hold or toggle) gates mic routes - Sound tab with channel strips, meters, gain, mute, L/R/L+R - --audio-route launch argument, per-minute audio CSV, 10 s diagnostics log Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
3d19a2252a
commit
2d6d0236d5
@ -0,0 +1,63 @@
|
|||||||
|
import Carbon.HIToolbox
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Global talk key (works while Musubi is in the background). Default: ⌃⌥ Space.
|
||||||
|
///
|
||||||
|
/// Uses Carbon hot keys, which need no Accessibility permission and report press and release.
|
||||||
|
@MainActor @Observable
|
||||||
|
final class TalkKey {
|
||||||
|
enum Mode: String, CaseIterable { case hold, toggle }
|
||||||
|
|
||||||
|
var mode: Mode = Mode(rawValue: UserDefaults.standard.string(forKey: "talkMode") ?? "") ?? .hold {
|
||||||
|
didSet { UserDefaults.standard.set(mode.rawValue, forKey: "talkMode") }
|
||||||
|
}
|
||||||
|
private(set) var isActive = false
|
||||||
|
var onChange: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
let displayName = "⌃⌥ Leertaste"
|
||||||
|
private var hotKey: EventHotKeyRef?
|
||||||
|
private var handler: EventHandlerRef?
|
||||||
|
|
||||||
|
func register() {
|
||||||
|
var types = [
|
||||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
||||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)),
|
||||||
|
]
|
||||||
|
InstallEventHandler(GetApplicationEventTarget(), talkKeyHandler, types.count, &types,
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(), &handler)
|
||||||
|
let id = EventHotKeyID(signature: OSType(0x4D534249), id: 1) // 'MSBI'
|
||||||
|
RegisterEventHotKey(UInt32(kVK_Space), UInt32(controlKey | optionKey), id, GetApplicationEventTarget(), 0, &hotKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Button or hot key pressed.
|
||||||
|
func press() {
|
||||||
|
switch mode {
|
||||||
|
case .hold: set(true)
|
||||||
|
case .toggle: set(!isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Button or hot key released.
|
||||||
|
func release() {
|
||||||
|
if mode == .hold { set(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ active: Bool) {
|
||||||
|
guard active != isActive else { return }
|
||||||
|
isActive = active
|
||||||
|
onChange?(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func handle(kind: UInt32) {
|
||||||
|
if kind == UInt32(kEventHotKeyPressed) { press() } else { release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func talkKeyHandler(_: EventHandlerCallRef?, event: EventRef?, userData: UnsafeMutableRawPointer?) -> OSStatus {
|
||||||
|
guard let event, let userData else { return OSStatus(eventNotHandledErr) }
|
||||||
|
let kind = GetEventKind(event)
|
||||||
|
let key = Unmanaged<TalkKey>.fromOpaque(userData).takeUnretainedValue()
|
||||||
|
MainActor.assumeIsolated { key.handle(kind: kind) }
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A Core Audio device as shown in the UI.
|
||||||
|
public struct AudioDevice: Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String { uid }
|
||||||
|
public let deviceID: AudioDeviceID
|
||||||
|
public let uid: String
|
||||||
|
public let name: String
|
||||||
|
public let inputChannels: Int
|
||||||
|
public let outputChannels: Int
|
||||||
|
public let sampleRate: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AudioDevices {
|
||||||
|
public static func all() -> [AudioDevice] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain
|
||||||
|
)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr else { return [] }
|
||||||
|
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr else { return [] }
|
||||||
|
return ids.compactMap(device(for:))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func device(uid: String) -> AudioDevice? {
|
||||||
|
all().first { $0.uid == uid }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func device(for id: AudioDeviceID) -> AudioDevice? {
|
||||||
|
guard let uid = string(id, kAudioDevicePropertyDeviceUID),
|
||||||
|
let name = string(id, kAudioObjectPropertyName)
|
||||||
|
else { return nil }
|
||||||
|
return AudioDevice(
|
||||||
|
deviceID: id, uid: uid, name: name,
|
||||||
|
inputChannels: channelCount(id, scope: kAudioObjectPropertyScopeInput),
|
||||||
|
outputChannels: channelCount(id, scope: kAudioObjectPropertyScopeOutput),
|
||||||
|
sampleRate: nominalSampleRate(id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func nominalSampleRate(_ id: AudioDeviceID) -> Double {
|
||||||
|
var address = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var rate: Float64 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float64>.size)
|
||||||
|
return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &rate) == noErr ? rate : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls `handler` on the main queue whenever devices are added or removed.
|
||||||
|
/// Keep the returned token alive as long as you want notifications.
|
||||||
|
public static func observeChanges(_ handler: @escaping @MainActor @Sendable () -> Void) -> AnyObject {
|
||||||
|
DeviceListObserver(handler: handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func string(_ id: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var value: Unmanaged<CFString>?
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &value) == noErr, let value else { return nil }
|
||||||
|
return value.takeRetainedValue() as String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func channelCount(_ id: AudioDeviceID, scope: AudioObjectPropertyScope) -> Int {
|
||||||
|
var address = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||||
|
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { raw.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, raw) == noErr else { return 0 }
|
||||||
|
let list = UnsafeMutableAudioBufferListPointer(raw.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return list.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class DeviceListObserver {
|
||||||
|
private var address = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
private let block: AudioObjectPropertyListenerBlock
|
||||||
|
|
||||||
|
init(handler: @escaping @MainActor @Sendable () -> Void) {
|
||||||
|
block = { _, _ in MainActor.assumeIsolated { handler() } }
|
||||||
|
AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
/// Distributes one producer's audio to several feeds (one per route).
|
||||||
|
///
|
||||||
|
/// `push` may run on a real-time thread: it only try-locks and otherwise uses the last known list.
|
||||||
|
public final class AudioFanout: @unchecked Sendable {
|
||||||
|
private let feeds = OSAllocatedUnfairLock<[AudioFeed]>(initialState: [])
|
||||||
|
private var cached: [AudioFeed] = [] // producer thread only
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func add(_ feed: AudioFeed) {
|
||||||
|
feeds.withLock { $0.append(feed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove(_ feed: AudioFeed) {
|
||||||
|
feeds.withLock { $0.removeAll { $0 === feed } }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isEmpty: Bool { feeds.withLock { $0.isEmpty } }
|
||||||
|
|
||||||
|
public func push(planar data: UnsafePointer<Float>, channelStride: Int, channels: Int, frames: Int, sampleRate: Double) {
|
||||||
|
if let current = feeds.withLockIfAvailable({ $0 }) { cached = current }
|
||||||
|
for feed in cached {
|
||||||
|
feed.push(planar: data, channelStride: channelStride, channels: channels, frames: frames, sampleRate: sampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import CAudioRing
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Producer side of one route: takes audio from any thread (NDI, input device) into a stereo ring buffer.
|
||||||
|
///
|
||||||
|
/// One feed has exactly one producer and one consumer (the route on the render thread).
|
||||||
|
public final class AudioFeed: @unchecked Sendable {
|
||||||
|
let ring: OpaquePointer
|
||||||
|
/// Sample rate of the audio in the ring. Written by the producer, read by the render thread.
|
||||||
|
private(set) var sampleRate: Double = 0
|
||||||
|
// Producer-only scratch for interleaving.
|
||||||
|
private var scratch: UnsafeMutablePointer<Float>
|
||||||
|
private var scratchFrames = 0
|
||||||
|
|
||||||
|
public init(capacitySeconds: Double = 1, maxSampleRate: Double = 96_000) {
|
||||||
|
ring = musubi_ring_create(UInt32(capacitySeconds * maxSampleRate), 2)!
|
||||||
|
scratchFrames = 4096
|
||||||
|
scratch = .allocate(capacity: scratchFrames * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
musubi_ring_destroy(ring)
|
||||||
|
scratch.deallocate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes planar audio. Mono is duplicated to both channels; channels beyond two are ignored.
|
||||||
|
public func push(planar data: UnsafePointer<Float>, channelStride: Int, channels: Int, frames: Int, sampleRate: Double) {
|
||||||
|
guard channels > 0, frames > 0 else { return }
|
||||||
|
self.sampleRate = sampleRate
|
||||||
|
ensureScratch(frames)
|
||||||
|
let left = data
|
||||||
|
let right = channels > 1 ? data + channelStride : data
|
||||||
|
for i in 0..<frames {
|
||||||
|
scratch[i * 2] = left[i]
|
||||||
|
scratch[i * 2 + 1] = right[i]
|
||||||
|
}
|
||||||
|
musubi_ring_write(ring, scratch, UInt32(frames))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureScratch(_ frames: Int) {
|
||||||
|
guard frames > scratchFrames else { return }
|
||||||
|
scratch.deallocate()
|
||||||
|
scratchFrames = frames
|
||||||
|
scratch = .allocate(capacity: frames * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
import CAudioRing
|
||||||
|
import Foundation
|
||||||
|
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] = [:]
|
||||||
|
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 }) {
|
||||||
|
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: - 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)
|
||||||
|
}
|
||||||
|
rebuildOutputs()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import AudioToolbox
|
||||||
|
import CoreAudio
|
||||||
|
|
||||||
|
/// Captures up to two channels of an input device (e.g. headset mic) into an `AudioFanout`.
|
||||||
|
final class InputEngine: @unchecked Sendable {
|
||||||
|
let device: AudioDevice
|
||||||
|
let fanout = AudioFanout()
|
||||||
|
private let sampleRate: Double
|
||||||
|
private let channels: Int
|
||||||
|
private var unit: AudioUnit?
|
||||||
|
private let maxFrames = 4096
|
||||||
|
private let samples: UnsafeMutablePointer<Float>
|
||||||
|
private let bufferList: UnsafeMutableAudioBufferListPointer
|
||||||
|
|
||||||
|
init(device: AudioDevice, bufferFrames: UInt32 = 128) throws {
|
||||||
|
self.device = device
|
||||||
|
self.sampleRate = device.sampleRate
|
||||||
|
self.channels = min(max(device.inputChannels, 1), 2)
|
||||||
|
samples = .allocate(capacity: maxFrames * 2)
|
||||||
|
samples.initialize(repeating: 0, count: maxFrames * 2)
|
||||||
|
bufferList = AudioBufferList.allocate(maximumBuffers: channels)
|
||||||
|
|
||||||
|
var description = AudioComponentDescription(
|
||||||
|
componentType: kAudioUnitType_Output, componentSubType: kAudioUnitSubType_HALOutput,
|
||||||
|
componentManufacturer: kAudioUnitManufacturer_Apple, componentFlags: 0, componentFlagsMask: 0)
|
||||||
|
guard let component = AudioComponentFindNext(nil, &description) else { throw AudioError.noHAL }
|
||||||
|
var newUnit: AudioUnit?
|
||||||
|
try check(AudioComponentInstanceNew(component, &newUnit), "instance")
|
||||||
|
guard let newUnit else { throw AudioError.noHAL }
|
||||||
|
unit = newUnit
|
||||||
|
|
||||||
|
var enable: UInt32 = 1, disable: UInt32 = 0
|
||||||
|
let size = UInt32(MemoryLayout<UInt32>.size)
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioOutputUnitProperty_EnableIO, kAudioUnitScope_Input, 1, &enable, size), "enable input")
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioOutputUnitProperty_EnableIO, kAudioUnitScope_Output, 0, &disable, size), "disable output")
|
||||||
|
|
||||||
|
var deviceID = device.deviceID
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
||||||
|
&deviceID, UInt32(MemoryLayout<AudioDeviceID>.size)), "device")
|
||||||
|
|
||||||
|
var format = AudioStreamBasicDescription.floatNonInterleaved(sampleRate: sampleRate, channels: channels)
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 1,
|
||||||
|
&format, UInt32(MemoryLayout<AudioStreamBasicDescription>.size)), "format")
|
||||||
|
|
||||||
|
var frames = bufferFrames
|
||||||
|
AudioUnitSetProperty(newUnit, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Global, 0, &frames, size)
|
||||||
|
|
||||||
|
var callback = AURenderCallbackStruct(inputProc: inputCaptured, inputProcRefCon: Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioOutputUnitProperty_SetInputCallback, kAudioUnitScope_Global, 0,
|
||||||
|
&callback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "callback")
|
||||||
|
try check(AudioUnitInitialize(newUnit), "initialize")
|
||||||
|
try check(AudioOutputUnitStart(newUnit), "start")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
samples.deallocate()
|
||||||
|
free(bufferList.unsafeMutablePointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard let unit else { return }
|
||||||
|
AudioOutputUnitStop(unit)
|
||||||
|
AudioUnitUninitialize(unit)
|
||||||
|
AudioComponentInstanceDispose(unit)
|
||||||
|
self.unit = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func capture(flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>, timestamp: UnsafePointer<AudioTimeStamp>, frames: UInt32) -> OSStatus {
|
||||||
|
guard let unit, Int(frames) <= maxFrames else { return noErr }
|
||||||
|
for channel in 0..<channels {
|
||||||
|
bufferList[channel] = AudioBuffer(mNumberChannels: 1, mDataByteSize: frames * 4,
|
||||||
|
mData: UnsafeMutableRawPointer(samples + channel * maxFrames))
|
||||||
|
}
|
||||||
|
let status = AudioUnitRender(unit, flags, timestamp, 1, frames, bufferList.unsafeMutablePointer)
|
||||||
|
guard status == noErr else { return status }
|
||||||
|
fanout.push(planar: samples, channelStride: maxFrames, channels: channels, frames: Int(frames), sampleRate: sampleRate)
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inputCaptured(
|
||||||
|
refCon: UnsafeMutableRawPointer,
|
||||||
|
flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
|
||||||
|
timestamp: UnsafePointer<AudioTimeStamp>,
|
||||||
|
bus: UInt32,
|
||||||
|
frames: UInt32,
|
||||||
|
data: UnsafeMutablePointer<AudioBufferList>?
|
||||||
|
) -> OSStatus {
|
||||||
|
Unmanaged<InputEngine>.fromOpaque(refCon).takeUnretainedValue().capture(flags: flags, timestamp: timestamp, frames: frames)
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
import AudioToolbox
|
||||||
|
import CoreAudio
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Plays the routes of one output device through an AUHAL unit.
|
||||||
|
final class OutputEngine: @unchecked Sendable {
|
||||||
|
let device: AudioDevice
|
||||||
|
let sampleRate: Double
|
||||||
|
let channels: Int
|
||||||
|
|
||||||
|
private var unit: AudioUnit?
|
||||||
|
private let routes = OSAllocatedUnfairLock<[RouteRenderer]>(initialState: [])
|
||||||
|
private var renderRoutes: [RouteRenderer] = [] // render thread only
|
||||||
|
/// Peak per output channel, written on the render thread, read and reset by the UI.
|
||||||
|
let channelPeaks: UnsafeMutablePointer<Float>
|
||||||
|
|
||||||
|
init(device: AudioDevice, bufferFrames: UInt32 = 256) throws {
|
||||||
|
self.device = device
|
||||||
|
self.sampleRate = device.sampleRate
|
||||||
|
self.channels = device.outputChannels
|
||||||
|
channelPeaks = .allocate(capacity: max(device.outputChannels, 1))
|
||||||
|
channelPeaks.initialize(repeating: 0, count: max(device.outputChannels, 1))
|
||||||
|
|
||||||
|
var description = AudioComponentDescription(
|
||||||
|
componentType: kAudioUnitType_Output, componentSubType: kAudioUnitSubType_HALOutput,
|
||||||
|
componentManufacturer: kAudioUnitManufacturer_Apple, componentFlags: 0, componentFlagsMask: 0)
|
||||||
|
guard let component = AudioComponentFindNext(nil, &description) else { throw AudioError.noHAL }
|
||||||
|
var newUnit: AudioUnit?
|
||||||
|
try check(AudioComponentInstanceNew(component, &newUnit), "instance")
|
||||||
|
guard let newUnit else { throw AudioError.noHAL }
|
||||||
|
unit = newUnit
|
||||||
|
|
||||||
|
var deviceID = device.deviceID
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
||||||
|
&deviceID, UInt32(MemoryLayout<AudioDeviceID>.size)), "device")
|
||||||
|
|
||||||
|
var format = AudioStreamBasicDescription.floatNonInterleaved(sampleRate: sampleRate, channels: channels)
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0,
|
||||||
|
&format, UInt32(MemoryLayout<AudioStreamBasicDescription>.size)), "format")
|
||||||
|
|
||||||
|
var frames = bufferFrames
|
||||||
|
AudioUnitSetProperty(newUnit, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Global, 0,
|
||||||
|
&frames, UInt32(MemoryLayout<UInt32>.size))
|
||||||
|
|
||||||
|
var callback = AURenderCallbackStruct(inputProc: outputRender, inputProcRefCon: Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
try check(AudioUnitSetProperty(newUnit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0,
|
||||||
|
&callback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "callback")
|
||||||
|
try check(AudioUnitInitialize(newUnit), "initialize")
|
||||||
|
try check(AudioOutputUnitStart(newUnit), "start")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
channelPeaks.deallocate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard let unit else { return }
|
||||||
|
AudioOutputUnitStop(unit)
|
||||||
|
AudioUnitUninitialize(unit)
|
||||||
|
AudioComponentInstanceDispose(unit)
|
||||||
|
self.unit = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRoutes(_ newRoutes: [RouteRenderer]) {
|
||||||
|
routes.withLock { $0 = newRoutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func render(frames: Int, buffers: UnsafeMutableAudioBufferListPointer) {
|
||||||
|
if let current = routes.withLockIfAvailable({ $0 }) { renderRoutes = current }
|
||||||
|
|
||||||
|
for buffer in buffers {
|
||||||
|
if let data = buffer.mData { memset(data, 0, Int(buffer.mDataByteSize)) }
|
||||||
|
}
|
||||||
|
for route in renderRoutes where route.outputChannel < buffers.count {
|
||||||
|
guard let data = buffers[route.outputChannel].mData else { continue }
|
||||||
|
route.render(into: data.assumingMemoryBound(to: Float.self), frames: frames, outputRate: sampleRate)
|
||||||
|
}
|
||||||
|
for (index, buffer) in buffers.enumerated() where index < channels {
|
||||||
|
guard let data = buffer.mData?.assumingMemoryBound(to: Float.self) else { continue }
|
||||||
|
var peak: Float = 0
|
||||||
|
for i in 0..<frames { peak = max(peak, abs(data[i])) }
|
||||||
|
channelPeaks[index] = max(channelPeaks[index], peak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func outputRender(
|
||||||
|
refCon: UnsafeMutableRawPointer,
|
||||||
|
flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
|
||||||
|
timestamp: UnsafePointer<AudioTimeStamp>,
|
||||||
|
bus: UInt32,
|
||||||
|
frames: UInt32,
|
||||||
|
data: UnsafeMutablePointer<AudioBufferList>?
|
||||||
|
) -> OSStatus {
|
||||||
|
guard let data else { return noErr }
|
||||||
|
let engine = Unmanaged<OutputEngine>.fromOpaque(refCon).takeUnretainedValue()
|
||||||
|
engine.render(frames: Int(frames), buffers: UnsafeMutableAudioBufferListPointer(data))
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AudioError: Error {
|
||||||
|
case noHAL
|
||||||
|
case coreAudio(String, OSStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func check(_ status: OSStatus, _ step: String) throws {
|
||||||
|
if status != noErr { throw AudioError.coreAudio(step, status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioStreamBasicDescription {
|
||||||
|
static func floatNonInterleaved(sampleRate: Double, channels: Int) -> AudioStreamBasicDescription {
|
||||||
|
AudioStreamBasicDescription(
|
||||||
|
mSampleRate: sampleRate, mFormatID: kAudioFormatLinearPCM,
|
||||||
|
mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved,
|
||||||
|
mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4,
|
||||||
|
mChannelsPerFrame: UInt32(channels), mBitsPerChannel: 32, mReserved: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
import CAudioRing
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Real-time state of one route: feed → resampler → gain → one output channel.
|
||||||
|
///
|
||||||
|
/// `render` runs on the Core Audio render thread and never allocates or locks.
|
||||||
|
/// Settings are plain stored values written from the main thread (benign races on aligned scalars).
|
||||||
|
final class RouteRenderer: @unchecked Sendable {
|
||||||
|
let id: UUID
|
||||||
|
let feed: AudioFeed
|
||||||
|
let outputChannel: Int
|
||||||
|
let targetLatency: Double
|
||||||
|
|
||||||
|
var selection: ChannelSelection
|
||||||
|
var gain: Float = 1
|
||||||
|
var muted = false
|
||||||
|
/// Closed gate = silent (used for talkback).
|
||||||
|
var gateOpen = true
|
||||||
|
|
||||||
|
// Meter, read and reset by the UI.
|
||||||
|
var peak: Float = 0
|
||||||
|
private(set) var underruns = 0
|
||||||
|
private(set) var overruns = 0
|
||||||
|
|
||||||
|
private var drift: DriftController?
|
||||||
|
private var driftRates: (source: Double, output: Double) = (0, 0)
|
||||||
|
private var resampler = Resampler()
|
||||||
|
private var currentGain: Float = 0
|
||||||
|
private let input: UnsafeMutablePointer<Float>
|
||||||
|
private let output: UnsafeMutablePointer<Float>
|
||||||
|
private static let maxFrames = 8192
|
||||||
|
|
||||||
|
init(id: UUID, feed: AudioFeed, outputChannel: Int, selection: ChannelSelection, targetLatency: Double) {
|
||||||
|
self.id = id
|
||||||
|
self.feed = feed
|
||||||
|
self.outputChannel = outputChannel
|
||||||
|
self.selection = selection
|
||||||
|
self.targetLatency = targetLatency
|
||||||
|
input = .allocate(capacity: Self.maxFrames * 2 * 2 + 16)
|
||||||
|
output = .allocate(capacity: Self.maxFrames * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
input.deallocate()
|
||||||
|
output.deallocate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mixes this route into `channel` (non-interleaved float buffer of the output device).
|
||||||
|
func render(into channel: UnsafeMutablePointer<Float>, frames requested: Int, outputRate: Double) {
|
||||||
|
let frames = min(requested, Self.maxFrames)
|
||||||
|
let sourceRate = feed.sampleRate
|
||||||
|
guard sourceRate > 0, outputRate > 0 else { return }
|
||||||
|
|
||||||
|
if drift == nil || driftRates != (sourceRate, outputRate) {
|
||||||
|
if let old = drift { underruns += old.underruns; overruns += old.overruns }
|
||||||
|
drift = DriftController(sourceRate: sourceRate, outputRate: outputRate, targetLatency: targetLatency)
|
||||||
|
driftRates = (sourceRate, outputRate)
|
||||||
|
resampler.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
let available = Int(musubi_ring_available(feed.ring))
|
||||||
|
let estimate = Int(Double(frames) * drift!.nominalStep * 1.002) + 2
|
||||||
|
let step: Double
|
||||||
|
switch drift!.update(available: available, needed: estimate) {
|
||||||
|
case .fill:
|
||||||
|
currentGain = 0
|
||||||
|
decayPeak()
|
||||||
|
return
|
||||||
|
case .play(let s):
|
||||||
|
step = s
|
||||||
|
case .skip(let skipFrames, let s):
|
||||||
|
musubi_ring_skip(feed.ring, UInt32(skipFrames))
|
||||||
|
step = s
|
||||||
|
}
|
||||||
|
|
||||||
|
let needed = resampler.inputFramesNeeded(outputFrames: frames, step: step)
|
||||||
|
let read = Int(musubi_ring_read(feed.ring, input, UInt32(needed)))
|
||||||
|
if read < needed {
|
||||||
|
// Cannot happen after the drift check, but never read garbage.
|
||||||
|
(input + read * 2).update(repeating: 0, count: (needed - read) * 2)
|
||||||
|
}
|
||||||
|
resampler.process(input: input, output: output, outputFrames: frames, step: step)
|
||||||
|
|
||||||
|
// Linear gain ramp over the block avoids zipper noise and clicks on mute/talk.
|
||||||
|
let target: Float = (muted || !gateOpen) ? 0 : gain
|
||||||
|
let start = currentGain
|
||||||
|
let delta = (target - start) / Float(frames)
|
||||||
|
var blockPeak: Float = 0
|
||||||
|
for i in 0..<frames {
|
||||||
|
let g = start + delta * Float(i + 1)
|
||||||
|
let sample = selection.sample(left: output[i * 2], right: output[i * 2 + 1]) * g
|
||||||
|
channel[i] += sample
|
||||||
|
blockPeak = max(blockPeak, abs(sample))
|
||||||
|
}
|
||||||
|
currentGain = target
|
||||||
|
peak = max(peak, blockPeak)
|
||||||
|
}
|
||||||
|
|
||||||
|
var correction: Double { drift?.correction ?? 0 }
|
||||||
|
var totalUnderruns: Int { underruns + (drift?.underruns ?? 0) }
|
||||||
|
var totalOverruns: Int { overruns + (drift?.overruns ?? 0) }
|
||||||
|
|
||||||
|
private func decayPeak() {
|
||||||
|
peak *= 0.9
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
/// Parses `--audio-route "<kind>:<source>=<device>:<channel>"` launch arguments (scripted tests until scenes exist).
|
||||||
|
///
|
||||||
|
/// `kind` is `ndi` (NDI source name) or `input` (input device name, substring match).
|
||||||
|
/// `device` is an output device name (substring match), `channel` is 1-based.
|
||||||
|
/// Example: `--audio-route "ndi:ZOWIEBOX (PGM)=MOTU:2"`
|
||||||
|
public enum AudioRouteArgument {
|
||||||
|
public enum Kind: String, Sendable { case ndi, input }
|
||||||
|
|
||||||
|
public struct Parsed: Equatable, Sendable {
|
||||||
|
public var kind: Kind
|
||||||
|
public var source: String
|
||||||
|
public var device: String
|
||||||
|
public var channel: Int // 1-based
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func parse(_ arguments: [String]) -> [Parsed] {
|
||||||
|
zip(arguments, arguments.dropFirst()).compactMap { flag, value in
|
||||||
|
guard flag == "--audio-route",
|
||||||
|
let colon = value.firstIndex(of: ":"),
|
||||||
|
let kind = Kind(rawValue: String(value[..<colon])),
|
||||||
|
let equals = value.lastIndex(of: "="),
|
||||||
|
equals > colon
|
||||||
|
else { return nil }
|
||||||
|
let source = String(value[value.index(after: colon)..<equals])
|
||||||
|
let target = value[value.index(after: equals)...]
|
||||||
|
guard let lastColon = target.lastIndex(of: ":"),
|
||||||
|
let channel = Int(target[target.index(after: lastColon)...]), channel >= 1
|
||||||
|
else { return nil }
|
||||||
|
let device = String(target[..<lastColon])
|
||||||
|
guard !source.isEmpty, !device.isEmpty else { return nil }
|
||||||
|
return Parsed(kind: kind, source: source, device: device, channel: channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue