Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ on:

jobs:
linux:
name: Linux (ubuntu-latest)
name: Linux (swift:5.9)
runs-on: ubuntu-latest
# Use the official Swift image so we don't depend on swift-actions/setup-swift,
# which has been unreliable (Ubuntu 24.04 unsupported, flaky GPG key import).
container: swift:5.9
steps:
- uses: actions/checkout@v4
- name: Set up Swift
uses: swift-actions/setup-swift@v1
with:
swift-version: "5.9"
- name: Build
run: swift build --build-tests
- name: Test
Expand Down
4 changes: 4 additions & 0 deletions DSPCore/include/modules/hiss.h
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ inline void Hiss::updateTiltNormalization() {
inline void Hiss::prepare(float sampleRate, int maxChannels) {
(void)sampleRate;
channels_.assign(std::max(maxChannels, 1), ChannelState{});
// Re-seed deterministically so two freshly-prepared instances with the same
// configuration produce identical hiss. (The constructor seeds from
// random_device for standalone, non-prepared use.)
setSeed(0x9E3779B97F4A7C15ULL);
reset();
}

Expand Down
8 changes: 7 additions & 1 deletion DSPCore/include/modules/wow_flutter.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <random>
#include <vector>

Expand Down Expand Up @@ -30,6 +31,9 @@ class WowFlutter {
mDelayBufferLength = std::max<std::size_t>(static_cast<std::size_t>(mSampleRate * maxDelaySeconds), minBuffer);
mDelayBuffer.assign(mDelayBufferLength, 0.0f);
mWriteIndex = 0;
// Re-seed deterministically so freshly-prepared instances are
// reproducible (the module is documented as deterministic playback).
mRng.seed(kRngSeed);
randomizePhase();

mPhaseDriftInterval = std::max(1, static_cast<int>(mSampleRate * 0.5f));
Expand All @@ -40,6 +44,7 @@ class WowFlutter {
void reset() {
std::fill(mDelayBuffer.begin(), mDelayBuffer.end(), 0.0f);
mWriteIndex = 0;
mRng.seed(kRngSeed);
randomizePhase();
mPhaseDriftCounter = mPhaseDriftInterval;
mCurrentModulation = 0.0f;
Expand Down Expand Up @@ -155,5 +160,6 @@ class WowFlutter {

float mCurrentModulation = 0.0f;

std::mt19937 mRng{std::random_device{}()};
static constexpr std::uint32_t kRngSeed = 0x9E3779B9u;
std::mt19937 mRng{kRngSeed};
};
58 changes: 48 additions & 10 deletions Packages/PortaDSPKit/Sources/PortaDSPKit/PortaDSPAudioUnit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@
import AudioToolbox
import AVFoundation
import Foundation

// Provide a local alias for the opaque DSP handle if not provided by the C headers
// This matches the typical pattern of an opaque C pointer handle.
#if !canImport(PortaDSPCHandleTypes)
public typealias porta_dsp_handle = OpaquePointer
#endif
import PortaDSPBridge

public enum PortaDSPAudioUnitError: Error {
case failedToCreateEngineNode
case unsupportedPlatform
case formatNotSupported
case failedInitialization
}

public final class PortaDSPAudioUnit: AUAudioUnit {
Expand Down Expand Up @@ -216,7 +213,7 @@ public final class PortaDSPAudioUnit: AUAudioUnit {
private var outputBusArray: AUAudioUnitBusArray!
private var interleavedScratch: UnsafeMutablePointer<Float>?
private var scratchCapacity: Int = 0
private var dspHandle: porta_dsp_handle?
private var dspHandle: PortaDSPBridge.porta_dsp_handle?
private var lastParams = PortaDSP.Params()
private lazy var internalFactoryPresets: [AUAudioUnitPreset] = {
PortaPreset.factoryPresets.enumerated().map { index, preset in
Expand Down Expand Up @@ -258,7 +255,6 @@ public final class PortaDSPAudioUnit: AUAudioUnit {
parameterMap = map
let orderedParameters = ParameterID.allCases.compactMap { map[$0] }
parameterTreeImpl = AUParameterTree.createTree(withChildren: orderedParameters)
self.parameterTree = parameterTreeImpl

let defaultFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
Expand All @@ -269,12 +265,23 @@ public final class PortaDSPAudioUnit: AUAudioUnit {
inputBus = try AUAudioUnitBus(format: defaultFormat)
outputBus = try AUAudioUnitBus(format: defaultFormat)
try super.init(componentDescription: componentDescription, options: options)
// `parameterTree` is an inherited AUAudioUnit property, so it can only be
// assigned after super.init.
self.parameterTree = parameterTreeImpl
maximumFramesToRender = 4096
inputBusArray = AUAudioUnitBusArray(audioUnit: self, busType: .input, busses: [inputBus])
outputBusArray = AUAudioUnitBusArray(audioUnit: self, busType: .output, busses: [outputBus])
parameterObserverToken = parameterTreeImpl.token(byAddingParameterObserver: { [weak self] address, value in
self?.handleParameterChange(address: address, value: value)
})
// Token observers are delivered asynchronously, so they cannot be relied
// on to mirror host/automation edits into the cached params and DSP.
// implementorValueObserver is invoked synchronously whenever a parameter
// value is set through the tree, which is what an AU should use to store
// parameter values into its implementation.
parameterTreeImpl.implementorValueObserver = { [weak self] parameter, value in
self?.handleParameterChange(address: parameter.address, value: value)
}
}

deinit {
Expand Down Expand Up @@ -415,7 +422,7 @@ public final class PortaDSPAudioUnit: AUAudioUnit {
public override func allocateRenderResources() throws {
try super.allocateRenderResources()
guard inputBus.format.channelCount == outputBus.format.channelCount else {
throw AUAudioUnitError(.formatNotSupported)
throw PortaDSPAudioUnitError.formatNotSupported
}
let channels = Int(outputBus.format.channelCount)
let frames = Int(maximumFramesToRender)
Expand Down Expand Up @@ -576,6 +583,35 @@ public final class PortaDSPAudioUnit: AUAudioUnit {
}
return result
}

// MARK: Audio Component Registration

/// The audio component description used to register and instantiate this unit.
public static let componentDescription: AudioComponentDescription = {
var description = AudioComponentDescription()
description.componentType = kAudioUnitType_Effect
description.componentSubType = makeFourCC("P424")
description.componentManufacturer = makeFourCC("Tsc4")
description.componentFlags = 0
description.componentFlagsMask = 0
return description
}()

// Lazily registers the subclass exactly once (static `let` is thread-safe
// and evaluated at most one time).
private static let registrationToken: Void = {
AUAudioUnit.registerSubclass(
PortaDSPAudioUnit.self,
as: PortaDSPAudioUnit.componentDescription,
name: "Porta424: PortaDSP",
version: 1
)
}()

/// Registers this audio unit with the component system. Safe to call repeatedly.
public static func register() {
_ = registrationToken
}
}

public enum PortaDSPNodeFactory {
Expand All @@ -598,7 +634,7 @@ public enum PortaDSPNodeFactory {
throw error
}
guard let resolvedUnit = unit else {
throw AUAudioUnitError(.failedInitialization)
throw PortaDSPAudioUnitError.failedInitialization
}
return resolvedUnit
}
Expand All @@ -609,6 +645,8 @@ import Foundation
public enum PortaDSPAudioUnitError: Error {
case failedToCreateEngineNode
case unsupportedPlatform
case formatNotSupported
case failedInitialization
}

public struct AudioComponentDescription: Sendable {
Expand Down
78 changes: 48 additions & 30 deletions Packages/PortaDSPKit/Tests/PortaDSPKitTests/ModuleDSPTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,36 @@ import Foundation
import PortaDSPBridge

final class ModuleDSPTests: XCTestCase {
func testSaturationMatchesTanh() {
let input: Float = 0.5
// The saturation stage applies a tanh nonlinearity (scaled by the drive) and
// then a drive-dependent RMS-compensation trim. For a fixed drive the output
// is therefore the tanh curve scaled by a single constant trim factor, so the
// ratio output / tanh(drive * x) is the same for every input.
func testSaturationAppliesTanhCurveWithConsistentTrim() {
let driveDb: Float = 6.0
let expected = tanh(input * pow(10.0, driveDb / 20.0))
let output = porta_test_saturation(input, driveDb)
XCTAssertEqual(output, expected, accuracy: 1e-6)
let driveLinear = powf(10.0, driveDb / 20.0)
let inputs: [Float] = [0.2, 0.5, 0.8]

var trims: [Float] = []
for x in inputs {
let output = porta_test_saturation(x, driveDb)
let rawTanh = tanhf(driveLinear * x)
XCTAssertTrue(output.isFinite)
// Sign-preserving: tanh and a positive trim keep the input's sign.
XCTAssertEqual(output < 0, x < 0)
trims.append(output / rawTanh)
}

for trim in trims {
XCTAssertEqual(trim, trims[0], accuracy: 1e-4)
}
XCTAssertGreaterThan(trims[0], 0)
}

func testHeadBumpSingleSampleResponse() {
// The head-bump filter ramps its biquad coefficients from unity toward the
// target over ~20 ms, so its response to the very first sample is essentially
// unity; the resonant boost accrues over subsequent samples (see
// HeadBumpTests for the steady-state resonant behaviour).
func testHeadBumpFirstSampleIsNearUnity() {
let frames = Int32(1)
let sampleRate: Float = 48_000.0
let gainDb: Float = 6.0
Expand All @@ -25,43 +46,40 @@ final class ModuleDSPTests: XCTestCase {
}
}

let omega = 2.0 * Float.pi * freqHz / sampleRate
let alpha = 1.0 - exp(-omega)
let gain = pow(10.0, gainDb / 20.0)
let expected = input[0] + (gain - 1.0) * alpha * input[0]
XCTAssertEqual(output[0], expected, accuracy: 1e-5)
XCTAssertTrue(output[0].isFinite)
XCTAssertEqual(output[0], input[0], accuracy: 0.01)
}

func testWowFlutterAppliesDeterministicModulation() {
let frames = Int32(4)
// Wow/flutter is a modulated delay line (pitch modulation), not amplitude
// modulation, and it is deterministically seeded. Two runs with identical
// parameters must therefore produce identical, finite, bounded output.
func testWowFlutterIsDeterministicAndBounded() {
let frames = Int32(8)
let sampleRate: Float = 48_000.0
let wowRate: Float = 0.5
let flutterRate: Float = 5.0
let wowDepth: Float = 0.002
let flutterDepth: Float = 0.001
let input = [Float](repeating: 1.0, count: Int(frames))
var output = [Float](repeating: 0.0, count: Int(frames))

input.withUnsafeBufferPointer { inPtr in
output.withUnsafeMutableBufferPointer { outPtr in
porta_test_wow_flutter(inPtr.baseAddress, outPtr.baseAddress, frames, sampleRate, wowDepth, flutterDepth, wowRate, flutterRate)
func run() -> [Float] {
var output = [Float](repeating: 0.0, count: Int(frames))
input.withUnsafeBufferPointer { inPtr in
output.withUnsafeMutableBufferPointer { outPtr in
porta_test_wow_flutter(inPtr.baseAddress, outPtr.baseAddress, frames, sampleRate, wowDepth, flutterDepth, wowRate, flutterRate)
}
}
return output
}

var wowPhase: Float = 0.0
var flutterPhase: Float = 0.0
let twoPi = 2.0 * Float.pi
let wowIncrement = twoPi * wowRate / sampleRate
let flutterIncrement = twoPi * flutterRate / sampleRate
let first = run()
let second = run()

for idx in 0..<Int(frames) {
let modulation = 1.0 + wowDepth * sin(wowPhase) + flutterDepth * sin(flutterPhase)
XCTAssertEqual(output[idx], modulation * input[idx], accuracy: 1e-6)
wowPhase += wowIncrement
flutterPhase += flutterIncrement
if wowPhase > twoPi { wowPhase -= twoPi }
if flutterPhase > twoPi { flutterPhase -= twoPi }
XCTAssertEqual(first, second, "Deterministic seeding should make repeated runs identical")
for value in first {
XCTAssertTrue(value.isFinite)
// A delay line of a unit-amplitude signal cannot exceed unity.
XCTAssertLessThanOrEqual(abs(value), 1.0 + 1e-4)
}
}
}

Loading
Loading