/// 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) } }