Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
c741e470af
commit
3d19a2252a
@ -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…
Reference in new issue