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/RouteRenderer.swift

108 lines
4.0 KiB

import CAudioRing
import Foundation
import MusubiCore
/// 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
}
}

Powered by TurnKey Linux.