From 3d19a2252ae5bd83a0505c494c6c2483b9785151 Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Wed, 21 Oct 2026 14:57:11 +0200 Subject: [PATCH] feat(audio): lock-free ring buffer, Hermite resampler and drift controller Co-Authored-By: Claude Opus 5.5 --- Packages/MusubiKit/Package.swift | 6 +- .../Sources/AudioMatrix/AudioMatrix.swift | 3 - .../Sources/AudioMatrix/DriftController.swift | 70 +++++++++ .../MusubiKit/Sources/AudioMatrix/Level.swift | 28 ++++ .../Sources/AudioMatrix/Resampler.swift | 60 ++++++++ .../MusubiKit/Sources/CAudioRing/AudioRing.c | 95 ++++++++++++ .../Sources/CAudioRing/include/AudioRing.h | 28 ++++ .../Tests/AudioMatrixTests/DSPTests.swift | 141 ++++++++++++++++++ 8 files changed, 426 insertions(+), 5 deletions(-) delete mode 100644 Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift create mode 100644 Packages/MusubiKit/Sources/AudioMatrix/DriftController.swift create mode 100644 Packages/MusubiKit/Sources/AudioMatrix/Level.swift create mode 100644 Packages/MusubiKit/Sources/AudioMatrix/Resampler.swift create mode 100644 Packages/MusubiKit/Sources/CAudioRing/AudioRing.c create mode 100644 Packages/MusubiKit/Sources/CAudioRing/include/AudioRing.h create mode 100644 Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift diff --git a/Packages/MusubiKit/Package.swift b/Packages/MusubiKit/Package.swift index 1e6c0eb..313af08 100644 --- a/Packages/MusubiKit/Package.swift +++ b/Packages/MusubiKit/Package.swift @@ -20,13 +20,15 @@ let package = Package( .systemLibrary(name: "CNDI"), .target(name: "NDIKit", dependencies: ["CNDI", "MusubiCore"]), - // Placeholders, filled in M1 (video) and M2 (audio). .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). .target(name: "DesignSystem"), .testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]), + .testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]), ] ) diff --git a/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift b/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift deleted file mode 100644 index f9206b1..0000000 --- a/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Source → output channel routing via Core Audio / AVAudioEngine. -// Implemented in milestone 2. -import MusubiCore diff --git a/Packages/MusubiKit/Sources/AudioMatrix/DriftController.swift b/Packages/MusubiKit/Sources/AudioMatrix/DriftController.swift new file mode 100644 index 0000000..be79429 --- /dev/null +++ b/Packages/MusubiKit/Sources/AudioMatrix/DriftController.swift @@ -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) + } +} diff --git a/Packages/MusubiKit/Sources/AudioMatrix/Level.swift b/Packages/MusubiKit/Sources/AudioMatrix/Level.swift new file mode 100644 index 0000000..185f3e1 --- /dev/null +++ b/Packages/MusubiKit/Sources/AudioMatrix/Level.swift @@ -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) + } + } +} diff --git a/Packages/MusubiKit/Sources/AudioMatrix/Resampler.swift b/Packages/MusubiKit/Sources/AudioMatrix/Resampler.swift new file mode 100644 index 0000000..76d8c4c --- /dev/null +++ b/Packages/MusubiKit/Sources/AudioMatrix/Resampler.swift @@ -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..= 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, output: UnsafeMutablePointer, outputFrames: Int, step: Double + ) { + var read = 0 + for frame in 0..= 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 + } +} diff --git a/Packages/MusubiKit/Sources/CAudioRing/AudioRing.c b/Packages/MusubiKit/Sources/CAudioRing/AudioRing.c new file mode 100644 index 0000000..a4b2f22 --- /dev/null +++ b/Packages/MusubiKit/Sources/CAudioRing/AudioRing.c @@ -0,0 +1,95 @@ +#include "AudioRing.h" +#include +#include +#include + +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; +} diff --git a/Packages/MusubiKit/Sources/CAudioRing/include/AudioRing.h b/Packages/MusubiKit/Sources/CAudioRing/include/AudioRing.h new file mode 100644 index 0000000..039a7d6 --- /dev/null +++ b/Packages/MusubiKit/Sources/CAudioRing/include/AudioRing.h @@ -0,0 +1,28 @@ +#ifndef MUSUBI_AUDIO_RING_H +#define MUSUBI_AUDIO_RING_H + +#include + +/// 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 diff --git a/Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift b/Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift new file mode 100644 index 0000000..66bf459 --- /dev/null +++ b/Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift @@ -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.. [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.. 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) + } +}