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.
77 lines
3.0 KiB
77 lines
3.0 KiB
/// Keeps a ring buffer near its target fill level by nudging the resampling step.
|
|
///
|
|
/// Compensates for clock drift between an NDI sender (or a second audio device) and the output device.
|
|
/// Allocation-free; safe on the render thread.
|
|
public struct DriftController: Sendable {
|
|
public enum Action: Equatable, Sendable {
|
|
/// Play with this resampling step.
|
|
case play(step: Double)
|
|
/// Not enough buffered audio: output silence until the buffer is refilled.
|
|
case fill
|
|
/// Far too much buffered audio: drop this many frames first, then play.
|
|
case skip(frames: Int, step: Double)
|
|
}
|
|
|
|
public let nominalStep: Double
|
|
public let targetFill: Double
|
|
public var maxCorrection = 0.001 // ±0.1 %
|
|
|
|
public private(set) var underruns = 0
|
|
public private(set) var overruns = 0
|
|
public private(set) var correction = 0.0
|
|
|
|
private var smoothedError = 0.0
|
|
private var integral = 0.0
|
|
private var priming = true
|
|
|
|
/// - Parameters:
|
|
/// - sourceRate: sample rate of the incoming audio
|
|
/// - outputRate: sample rate of the output device
|
|
/// - targetLatency: buffered audio to aim for, in seconds
|
|
public init(sourceRate: Double, outputRate: Double, targetLatency: Double = 0.02) {
|
|
nominalStep = sourceRate / outputRate
|
|
targetFill = sourceRate * targetLatency
|
|
}
|
|
|
|
/// Call once per render cycle with the current fill level (input frames) and the frames needed.
|
|
public mutating func update(available: Int, needed: Int) -> Action {
|
|
if priming {
|
|
guard Double(available) >= targetFill, available >= needed else { return .fill }
|
|
priming = false
|
|
// NDI often delivers a backlog on connect: start at the target latency instead of draining slowly.
|
|
if Double(available) > targetFill * 1.5 {
|
|
return .skip(frames: available - Int(targetFill), step: nominalStep)
|
|
}
|
|
} else if available < needed {
|
|
underruns += 1
|
|
priming = true
|
|
reset()
|
|
return .fill
|
|
}
|
|
|
|
if Double(available) > targetFill * 4 {
|
|
overruns += 1
|
|
reset()
|
|
return .skip(frames: available - Int(targetFill), step: nominalStep)
|
|
}
|
|
|
|
let error = (Double(available) - targetFill) / targetFill
|
|
// Slow loop (seconds): NDI delivers audio in ~20 ms chunks, so the fill level is a sawtooth
|
|
// that must not drive the pitch. Clock drift is tens to hundreds of ppm and changes slowly.
|
|
smoothedError += 0.005 * (error - smoothedError)
|
|
integral = clamp(integral + smoothedError * 0.000002, maxCorrection)
|
|
correction = clamp(smoothedError * 0.0003 + integral, maxCorrection)
|
|
return .play(step: nominalStep * (1 + correction))
|
|
}
|
|
|
|
private mutating func reset() {
|
|
smoothedError = 0
|
|
integral = 0
|
|
correction = 0
|
|
}
|
|
|
|
private func clamp(_ value: Double, _ limit: Double) -> Double {
|
|
min(max(value, -limit), limit)
|
|
}
|
|
}
|