- 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