From a0c19abf2d856640ac0c13dc2eed37da941a5ad2 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Mon, 23 Mar 2026 18:27:08 -0700 Subject: [PATCH 1/3] Add test to reproduce crash --- Tests/AudioKitEXTests/DynamicGraphTests.swift | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 Tests/AudioKitEXTests/DynamicGraphTests.swift diff --git a/Tests/AudioKitEXTests/DynamicGraphTests.swift b/Tests/AudioKitEXTests/DynamicGraphTests.swift new file mode 100644 index 0000000..bc17beb --- /dev/null +++ b/Tests/AudioKitEXTests/DynamicGraphTests.swift @@ -0,0 +1,300 @@ +// +// DynamicGraphTests.swift +// AudioKitEX +// +// Created by Taylor Holliday on 3/23/26. +// + +import Testing +import AudioKit +import AudioKitEX +import AVFoundation +import Accelerate +import CAudioKitEX + +/// Manages local audio file playback using `AudioKit` with `AudioKitEX` fades. +@MainActor +final class AudioKitFilePlayer { + private struct ActivePlaybackNode { + let player: AudioPlayer + let fader: Fader + let panMixer: Mixer + let instrument: Instrument + + var id: ObjectIdentifier { + ObjectIdentifier(player.playerNode) + } + } + + let levelsHolder = LevelsHolder() + + private let audioEngine = AudioEngine() + private let masterMixer = Mixer(name: "AudioKitFilePlayer Master") + private let instrumentMixers = Dictionary( + uniqueKeysWithValues: Instrument.allCases.map { instrument in + (instrument, Mixer(name: "\(instrument.rawValue) Mixer")) + } + ) + private var levelsTap: RawBufferTap? + + private var activePlaybackNodes: [ObjectIdentifier: ActivePlaybackNode] = [:] + private var engineIsRunning = false + + var isRunning: Bool { + engineIsRunning + } + + init() { + setupInstrumentMixers() + audioEngine.output = masterMixer + let levelsTap = RawBufferTap( + masterMixer, + bufferSize: 1024, + callbackQueue: .global(qos: .userInitiated), + handler: Self.makeLevelsTap(holder: levelsHolder) + ) + self.levelsTap = levelsTap + levelsTap.start() + } + + func startPlayback() { + do { + if !engineIsRunning { + try audioEngine.start() + engineIsRunning = true + Self.log("AudioEngine started") + } + } catch { + Self.log("Error starting AudioEngine: \(error)") + } + } + + func stopPlayback() { + stopAllRunningPlayers() + } + + func pausePlayback() { + stopAllRunningPlayers() + audioEngine.pause() + engineIsRunning = false + } + + func reset() { + stopAllRunningPlayers() + audioEngine.stop() + levelsTap?.stop() + engineIsRunning = false + } + + func setOutputVolume(_ outputVolume: Float) { + masterMixer.volume = outputVolume + } + + func updateActiveInstruments(_ activeInstruments: Set) { + for (instrument, mixer) in instrumentMixers { + mixer.volume = activeInstruments.contains(instrument) ? 1.0 : 0.0 + } + } + + func scheduleFilePlayback( + _ audioFile: AVAudioFile, + fileName: String, + instrument: Instrument, + gain: Float, + startTime: Double, + endTime: Double, + fileOffset: Double, + fadeInDuration: Double, + fadeOutDuration: Double, + pan: Float + ) { + guard isRunning else { return } + + guard let instrumentMixer = instrumentMixers[instrument] else { + Self.log("Missing mixer for instrument \(instrument.rawValue)") + return + } + + let player = AudioPlayer() + let fader = Fader(player, gain: gain) + let panMixer = Mixer(fader, name: "\(fileName) Pan") + let activeNode = ActivePlaybackNode( + player: player, + fader: fader, + panMixer: panMixer, + instrument: instrument + ) + + do { + try player.load(file: audioFile, buffered: false) + } catch { + Self.log("Could not load audio file for \(fileName): \(error)") + return + } + + panMixer.pan = AUValue(pan) + instrumentMixer.addInput(panMixer) + activePlaybackNodes[activeNode.id] = activeNode + + let playDuration = max(0, endTime - startTime) + let effectiveGain = AUValue(max(0, gain)) + + if fadeInDuration > 0 { + fader.gain = 0 + } else { + fader.gain = effectiveGain + } + + let automationEvents = makeGainAutomationEvents( + targetGain: effectiveGain, + playDuration: playDuration, + fadeInDuration: fadeInDuration, + fadeOutDuration: fadeOutDuration + ) + + if !automationEvents.isEmpty { + fader.automateGain(events: automationEvents) + } + + player.completionHandler = { [weak self] in + Task { @MainActor [weak self] in + self?.cleanupPlaybackNode(id: activeNode.id, fileName: fileName) + } + } + + player.play(from: fileOffset, to: fileOffset + playDuration) + } + + private func makeGainAutomationEvents( + targetGain: AUValue, + playDuration: Double, + fadeInDuration: Double, + fadeOutDuration: Double + ) -> [AutomationEvent] { + var events: [AutomationEvent] = [] + + if fadeInDuration > 0 { + events.append( + AutomationEvent( + targetValue: targetGain, + startTime: 0, + rampDuration: Float(fadeInDuration) + ) + ) + } + + if fadeOutDuration > 0 { + let fadeOutDelay = playDuration - fadeOutDuration + + if fadeOutDelay > 0 { + events.append( + AutomationEvent( + targetValue: 0, + startTime: Float(fadeOutDelay), + rampDuration: Float(fadeOutDuration) + ) + ) + } + } + + return events + } + + private func setupInstrumentMixers() { + for mixer in instrumentMixers.values { + masterMixer.addInput(mixer) + } + } + + private func stopAllRunningPlayers() { + let activeNodes = Array(activePlaybackNodes.values) + + for activeNode in activeNodes { + cleanupPlaybackNode(id: activeNode.id) + } + } + + private func cleanupPlaybackNode(id: ObjectIdentifier, fileName: String? = nil) { + guard let activeNode = activePlaybackNodes.removeValue(forKey: id) else { + return + } + + activeNode.player.stop() + activeNode.fader.stopAutomation() + activeNode.panMixer.volume = 0 + instrumentMixers[activeNode.instrument]?.removeInput(activeNode.panMixer) + + if let fileName { + Self.log("Detaching AudioKit player node for \(fileName)") + } + } + + nonisolated private static func makeLevelsTap(holder: LevelsHolder) -> AVAudioNodeTapBlock { + return { buffer, _ in + guard let floatData = buffer.floatChannelData else { + log("Tap received nil floatChannelData") + return + } + + let channelCount = Int(buffer.format.channelCount) + let length = UInt(buffer.frameLength) + + for channelIndex in 0.. String) { + print("[AudioKitFilePlayer] \(message())") + } +} + +enum Instrument: String, CaseIterable, Hashable { + case waves +} + +final class LevelsHolder: @unchecked Sendable { + private let lock = NSLock() + private var levels: [Int: Float] = [:] + + func setLevel(_ level: Float, channel: Int) { + lock.lock() + levels[channel] = level + lock.unlock() + } +} + + +@MainActor +@Test +func testAudioKitFilePlayerCanScheduleLocalMP3() throws { + let testFileURL = Bundle.module.url(forResource: "12345", withExtension: "wav", subdirectory: "TestResources")! + let audioFile = try AVAudioFile(forReading: testFileURL) + + let player = AudioKitFilePlayer() + player.startPlayback() + + #expect(player.isRunning) + + player.scheduleFilePlayback( + audioFile, + fileName: testFileURL.lastPathComponent, + instrument: .waves, + gain: 1.0, + startTime: 0, + endTime: min(audioFile.duration, 0.25), + fileOffset: 0, + fadeInDuration: 0.05, + fadeOutDuration: 0.05, + pan: 0 + ) +} + From a21ab4c2f19ae310db3a76d8c17b7560c70be875 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Tue, 24 Mar 2026 01:50:28 +0000 Subject: [PATCH 2/3] Add minimal repro for dynamic graph crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces the "player started when in a disconnected state" crash with fewer layers: engine → master → instrumentMixer → panMixer → fader → player. Co-Authored-By: Claude Opus 4.6 (1M context) --- Tests/AudioKitEXTests/DynamicGraphTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/AudioKitEXTests/DynamicGraphTests.swift b/Tests/AudioKitEXTests/DynamicGraphTests.swift index bc17beb..a01a30e 100644 --- a/Tests/AudioKitEXTests/DynamicGraphTests.swift +++ b/Tests/AudioKitEXTests/DynamicGraphTests.swift @@ -273,6 +273,33 @@ final class LevelsHolder: @unchecked Sendable { } +/// Minimal repro: dynamically adding a player to a running engine and calling play() crashes +/// with "player started when in a disconnected state". +@MainActor +@Test +func testDynamicPlayerCrash() throws { + let engine = AudioEngine() + let masterMixer = Mixer(name: "Master") + engine.output = masterMixer + try engine.start() + + let testFileURL = Bundle.module.url(forResource: "12345", withExtension: "wav", subdirectory: "TestResources")! + let audioFile = try AVAudioFile(forReading: testFileURL) + + // Pre-connect an intermediate mixer (like an instrument bus) + let instrumentMixer = Mixer(name: "Instrument") + masterMixer.addInput(instrumentMixer) + + let player = AudioPlayer() + try player.load(file: audioFile, buffered: false) + + let fader = Fader(player) + let panMixer = Mixer(fader, name: "Pan") + instrumentMixer.addInput(panMixer) + + player.play() +} + @MainActor @Test func testAudioKitFilePlayerCanScheduleLocalMP3() throws { From bbe206a9f33f818265161c4076a8ccaca3da2339 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Mon, 23 Mar 2026 22:18:52 -0700 Subject: [PATCH 3/3] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index b116633..49066c7 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( name: "AudioKitEX", platforms: [.macOS(.v12), .iOS(.v13), .tvOS(.v13)], products: [.library(name: "AudioKitEX", targets: ["AudioKitEX"])], - dependencies: [.package(url: "https://github.com/AudioKit/AudioKit", from: "5.5.0")], + dependencies: [.package(url: "https://github.com/AudioKit/AudioKit", branch: "dynamic_graph_crash")], targets: [ .target(name: "AudioKitEX", dependencies: ["AudioKit", "CAudioKitEX"]), .target(name: "CAudioKitEX", cxxSettings: [.headerSearchPath(".")]),