feat(audio): lock-free ring buffer, Hermite resampler and drift controller

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
Samuel Zielke 2 days ago
parent c741e470af
commit 3d19a2252a

@ -20,13 +20,15 @@ let package = Package(
.systemLibrary(name: "CNDI"), .systemLibrary(name: "CNDI"),
.target(name: "NDIKit", dependencies: ["CNDI", "MusubiCore"]), .target(name: "NDIKit", dependencies: ["CNDI", "MusubiCore"]),
// Placeholders, filled in M1 (video) and M2 (audio).
.target(name: "VideoOutput", dependencies: ["MusubiCore"]), .target(name: "VideoOutput", dependencies: ["MusubiCore"]),
.target(name: "AudioMatrix", dependencies: ["MusubiCore"]), .target(name: "AudioMatrix", dependencies: ["MusubiCore", "CAudioRing"]),
// Lock-free SPSC ring buffer for the audio render thread (C11 atomics; Swift's Atomic needs macOS 15).
.target(name: "CAudioRing"),
// Theme.swift is a symlink to design/Theme.swift (single source of truth). // Theme.swift is a symlink to design/Theme.swift (single source of truth).
.target(name: "DesignSystem"), .target(name: "DesignSystem"),
.testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]), .testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]),
.testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]),
] ]
) )

@ -1,3 +0,0 @@
// Source → output channel routing via Core Audio / AVAudioEngine.
// Implemented in milestone 2.
import MusubiCore

@ -0,0 +1,70 @@
/// 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)
}
}

@ -0,0 +1,28 @@
import Foundation
/// dB ↔ linear gain helpers. -60 dB and below count as silence.
public enum Level {
public static let floorDB: Float = -60
public static func gain(fromDB db: Float) -> Float {
db <= floorDB ? 0 : powf(10, db / 20)
}
public static func db(fromGain gain: Float) -> Float {
gain <= 0 ? floorDB : max(floorDB, 20 * log10f(gain))
}
}
/// Which part of a stereo source feeds a mono output channel.
public enum ChannelSelection: String, Codable, CaseIterable, Sendable {
case left, right, mono
@inline(__always)
public func sample(left: Float, right: Float) -> Float {
switch self {
case .left: left
case .right: right
case .mono: 0.5 * (left + right)
}
}
}

@ -0,0 +1,60 @@
/// Stereo 4-point Hermite resampler for small rate corrections and 44.1 ↔ 48 kHz.
///
/// `step` is input frames per output frame (1.0 = no change). Allocation-free; safe on the render thread.
public struct Resampler: Sendable {
// History per channel: x[-1], x[0], x[1], x[2]. Output is interpolated between x[0] and x[1].
private var left: (Float, Float, Float, Float) = (0, 0, 0, 0)
private var right: (Float, Float, Float, Float) = (0, 0, 0, 0)
private var fraction: Double = 0
public init() {}
/// Number of input frames `process` will consume for `outputFrames` at `step`.
public func inputFramesNeeded(outputFrames: Int, step: Double) -> Int {
var position = fraction
var consumed = 0
for _ in 0..<outputFrames {
position += step
while position >= 1 {
position -= 1
consumed += 1
}
}
return consumed
}
/// Consumes exactly `inputFramesNeeded(outputFrames:step:)` interleaved stereo frames from `input`
/// and writes `outputFrames` interleaved stereo frames to `output`.
public mutating func process(
input: UnsafePointer<Float>, output: UnsafeMutablePointer<Float>, outputFrames: Int, step: Double
) {
var read = 0
for frame in 0..<outputFrames {
let t = Float(fraction)
output[frame * 2] = Self.hermite(left, t)
output[frame * 2 + 1] = Self.hermite(right, t)
fraction += step
while fraction >= 1 {
fraction -= 1
left = (left.1, left.2, left.3, input[read * 2])
right = (right.1, right.2, right.3, input[read * 2 + 1])
read += 1
}
}
}
public mutating func reset() {
left = (0, 0, 0, 0)
right = (0, 0, 0, 0)
fraction = 0
}
@inline(__always)
private static func hermite(_ x: (Float, Float, Float, Float), _ t: Float) -> Float {
let c0 = x.1
let c1 = 0.5 * (x.2 - x.0)
let c2 = x.0 - 2.5 * x.1 + 2 * x.2 - 0.5 * x.3
let c3 = 0.5 * (x.3 - x.0) + 1.5 * (x.1 - x.2)
return ((c3 * t + c2) * t + c1) * t + c0
}
}

@ -0,0 +1,95 @@
#include "AudioRing.h"
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
struct MusubiRing {
float *data;
uint32_t capacity; // frames, power of two
uint32_t mask;
uint32_t channels;
_Atomic uint64_t writeIndex; // frames written in total
_Atomic uint64_t readIndex; // frames read in total
};
static uint32_t next_power_of_two(uint32_t value) {
uint32_t result = 1;
while (result < value) result <<= 1;
return result;
}
MusubiRing *musubi_ring_create(uint32_t capacityFrames, uint32_t channels) {
if (capacityFrames == 0 || channels == 0) return NULL;
MusubiRing *ring = calloc(1, sizeof(MusubiRing));
if (!ring) return NULL;
ring->capacity = next_power_of_two(capacityFrames);
ring->mask = ring->capacity - 1;
ring->channels = channels;
ring->data = calloc((size_t)ring->capacity * channels, sizeof(float));
if (!ring->data) { free(ring); return NULL; }
atomic_init(&ring->writeIndex, 0);
atomic_init(&ring->readIndex, 0);
return ring;
}
void musubi_ring_destroy(MusubiRing *ring) {
if (!ring) return;
free(ring->data);
free(ring);
}
uint32_t musubi_ring_channels(const MusubiRing *ring) { return ring->channels; }
uint32_t musubi_ring_capacity(const MusubiRing *ring) { return ring->capacity; }
uint32_t musubi_ring_available(const MusubiRing *ring) {
uint64_t w = atomic_load_explicit(&((MusubiRing *)ring)->writeIndex, memory_order_acquire);
uint64_t r = atomic_load_explicit(&((MusubiRing *)ring)->readIndex, memory_order_acquire);
return (uint32_t)(w - r);
}
// Copies `frames` frames between the ring (starting at frame index `start`) and a linear buffer.
static void copy_frames(MusubiRing *ring, uint64_t start, float *linear, uint32_t frames, int toRing) {
uint32_t offset = (uint32_t)(start & ring->mask);
uint32_t first = ring->capacity - offset;
if (first > frames) first = frames;
size_t frameBytes = sizeof(float) * ring->channels;
float *slot = ring->data + (size_t)offset * ring->channels;
if (toRing) {
memcpy(slot, linear, first * frameBytes);
memcpy(ring->data, linear + (size_t)first * ring->channels, (frames - first) * frameBytes);
} else {
memcpy(linear, slot, first * frameBytes);
memcpy(linear + (size_t)first * ring->channels, ring->data, (frames - first) * frameBytes);
}
}
uint32_t musubi_ring_write(MusubiRing *ring, const float *interleaved, uint32_t frames) {
uint64_t w = atomic_load_explicit(&ring->writeIndex, memory_order_relaxed);
uint64_t r = atomic_load_explicit(&ring->readIndex, memory_order_acquire);
uint32_t space = ring->capacity - (uint32_t)(w - r);
if (frames > space) frames = space;
if (frames == 0) return 0;
copy_frames(ring, w, (float *)interleaved, frames, 1);
atomic_store_explicit(&ring->writeIndex, w + frames, memory_order_release);
return frames;
}
uint32_t musubi_ring_read(MusubiRing *ring, float *interleaved, uint32_t frames) {
uint64_t r = atomic_load_explicit(&ring->readIndex, memory_order_relaxed);
uint64_t w = atomic_load_explicit(&ring->writeIndex, memory_order_acquire);
uint32_t available = (uint32_t)(w - r);
if (frames > available) frames = available;
if (frames == 0) return 0;
copy_frames(ring, r, interleaved, frames, 0);
atomic_store_explicit(&ring->readIndex, r + frames, memory_order_release);
return frames;
}
uint32_t musubi_ring_skip(MusubiRing *ring, uint32_t frames) {
uint64_t r = atomic_load_explicit(&ring->readIndex, memory_order_relaxed);
uint64_t w = atomic_load_explicit(&ring->writeIndex, memory_order_acquire);
uint32_t available = (uint32_t)(w - r);
if (frames > available) frames = available;
atomic_store_explicit(&ring->readIndex, r + frames, memory_order_release);
return frames;
}

@ -0,0 +1,28 @@
#ifndef MUSUBI_AUDIO_RING_H
#define MUSUBI_AUDIO_RING_H
#include <stdint.h>
/// Lock-free single-producer/single-consumer ring of interleaved float frames.
/// Safe to use from a real-time audio thread: no locks, no allocation after creation.
typedef struct MusubiRing MusubiRing;
MusubiRing *_Nullable musubi_ring_create(uint32_t capacityFrames, uint32_t channels);
void musubi_ring_destroy(MusubiRing *_Nullable ring);
uint32_t musubi_ring_channels(const MusubiRing *_Nonnull ring);
uint32_t musubi_ring_capacity(const MusubiRing *_Nonnull ring);
/// Producer side. Writes up to `frames` frames, returns how many fit.
uint32_t musubi_ring_write(MusubiRing *_Nonnull ring, const float *_Nonnull interleaved, uint32_t frames);
/// Consumer side. Reads up to `frames` frames, returns how many were read.
uint32_t musubi_ring_read(MusubiRing *_Nonnull ring, float *_Nonnull interleaved, uint32_t frames);
/// Consumer side. Drops up to `frames` frames, returns how many were dropped.
uint32_t musubi_ring_skip(MusubiRing *_Nonnull ring, uint32_t frames);
/// Frames ready to read (either side).
uint32_t musubi_ring_available(const MusubiRing *_Nonnull ring);
#endif

@ -0,0 +1,141 @@
import CAudioRing
import Foundation
import Testing
@testable import AudioMatrix
@Suite struct RingTests {
@Test func writesAndReadsAcrossWrap() throws {
let ring = try #require(musubi_ring_create(8, 2))
defer { musubi_ring_destroy(ring) }
var input: [Float] = (0..<12).map(Float.init) // 6 frames
var output = [Float](repeating: 0, count: 12)
#expect(musubi_ring_write(ring, &input, 6) == 6)
#expect(musubi_ring_read(ring, &output, 4) == 4)
#expect(Array(output[0..<8]) == (0..<8).map(Float.init))
// Wraps around the end of the buffer.
#expect(musubi_ring_write(ring, &input, 6) == 6)
#expect(musubi_ring_available(ring) == 8)
#expect(musubi_ring_read(ring, &output, 6) == 6)
#expect(Array(output[0..<4]) == [8, 9, 10, 11])
#expect(Array(output[4..<12]) == (0..<8).map(Float.init))
}
@Test func dropsWhatDoesNotFit() throws {
let ring = try #require(musubi_ring_create(4, 1))
defer { musubi_ring_destroy(ring) }
var input: [Float] = [1, 2, 3, 4, 5, 6]
#expect(musubi_ring_write(ring, &input, 6) == 4)
#expect(musubi_ring_skip(ring, 3) == 3)
#expect(musubi_ring_available(ring) == 1)
}
}
@Suite struct ResamplerTests {
@Test func unitStepPassesSignalThroughWithDelay() {
var resampler = Resampler()
let frames = 64
let input: [Float] = (0..<frames).flatMap { [Float($0), -Float($0)] }
var output = [Float](repeating: 0, count: frames * 2)
#expect(resampler.inputFramesNeeded(outputFrames: frames, step: 1) == frames)
input.withUnsafeBufferPointer { resampler.process(input: $0.baseAddress!, output: &output, outputFrames: frames, step: 1) }
// Three frames of latency from the 4-point interpolation history.
for frame in 3..<frames {
#expect(output[frame * 2] == Float(frame - 3))
#expect(output[frame * 2 + 1] == -Float(frame - 3))
}
}
@Test func convertsRateAndKeepsFrequency() {
// 1 kHz at 44.1 kHz in, 48 kHz out.
let step = 44_100.0 / 48_000.0
var resampler = Resampler()
let outFrames = 4800
let inFrames = resampler.inputFramesNeeded(outputFrames: outFrames, step: step)
#expect(abs(inFrames - 4410) <= 1)
let input: [Float] = (0..<inFrames).flatMap { i -> [Float] in
let v = Float(sin(2 * Double.pi * 1000 * Double(i) / 44_100))
return [v, v]
}
var output = [Float](repeating: 0, count: outFrames * 2)
input.withUnsafeBufferPointer { resampler.process(input: $0.baseAddress!, output: &output, outputFrames: outFrames, step: step) }
// Compare against an ideal 1 kHz sine at 48 kHz (skipping the start-up latency).
let latency = 3.0 / step
var maxError: Float = 0
for i in 100..<outFrames {
let expected = Float(sin(2 * Double.pi * 1000 * (Double(i) - latency) / 48_000))
maxError = max(maxError, abs(output[i * 2] - expected))
}
#expect(maxError < 0.01)
}
}
@Suite struct DriftControllerTests {
@Test func primesBeforePlaying() {
var drift = DriftController(sourceRate: 48_000, outputRate: 48_000, targetLatency: 0.02) // 960 frames
#expect(drift.update(available: 500, needed: 512) == .fill)
#expect(drift.update(available: 960, needed: 512) == .play(step: 1))
}
@Test func countsUnderrunAndRefills() {
var drift = DriftController(sourceRate: 48_000, outputRate: 48_000)
_ = drift.update(available: 1000, needed: 512)
#expect(drift.update(available: 100, needed: 512) == .fill)
#expect(drift.underruns == 1)
#expect(drift.update(available: 600, needed: 512) == .fill) // still priming
}
@Test func skipsOnOverrun() {
var drift = DriftController(sourceRate: 48_000, outputRate: 48_000)
_ = drift.update(available: 1000, needed: 512)
#expect(drift.update(available: 5000, needed: 512) == .skip(frames: 4040, step: 1))
#expect(drift.overruns == 1)
}
/// Sender clock 200 ppm fast: the fill level must stay bounded without under- or overruns.
@Test func followsClockDrift() {
let outputRate = 48_000.0, sourceRate = 48_000.0 * 1.0002
var drift = DriftController(sourceRate: 48_000, outputRate: outputRate)
let block = 512
var fill = 960.0
var produced = 0.0
for _ in 0..<(60 * 94) { // ~60 s of 512-frame cycles
produced += sourceRate * Double(block) / outputRate
let whole = produced.rounded(.down)
fill += whole
produced -= whole
var resampler = Resampler()
switch drift.update(available: Int(fill), needed: block + 4) {
case .play(let step):
fill -= Double(resampler.inputFramesNeeded(outputFrames: block, step: step))
case .fill:
break
case .skip(let frames, let step):
fill -= Double(frames) + Double(resampler.inputFramesNeeded(outputFrames: block, step: step))
}
}
#expect(drift.underruns == 0)
#expect(drift.overruns == 0)
#expect(fill > 400 && fill < 2000)
#expect(drift.correction > 0)
}
}
@Suite struct LevelTests {
@Test func convertsDecibels() {
#expect(Level.gain(fromDB: 0) == 1)
#expect(abs(Level.gain(fromDB: -6) - 0.501) < 0.001)
#expect(Level.gain(fromDB: -60) == 0)
#expect(Level.db(fromGain: 0) == Level.floorDB)
#expect(abs(Level.db(fromGain: 0.5) + 6.02) < 0.01)
}
@Test func selectsChannels() {
#expect(ChannelSelection.left.sample(left: 1, right: 0) == 1)
#expect(ChannelSelection.right.sample(left: 1, right: 0) == 0)
#expect(ChannelSelection.mono.sample(left: 1, right: 0) == 0.5)
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.