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.
107 lines
4.0 KiB
107 lines
4.0 KiB
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
|
|
}
|
|
}
|