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.
Musubi/Packages/MusubiKit/Sources/AudioMatrix/DriftController.swift

71 lines
2.5 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
} 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
smoothedError += 0.02 * (error - smoothedError)
integral = clamp(integral + smoothedError * 0.00002, maxCorrection)
correction = clamp(smoothedError * 0.0005 + 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)
}
}

Powered by TurnKey Linux.