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 private let output: UnsafeMutablePointer 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, 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..