Skip to content

Commit 1157fe8

Browse files
authored
Merge pull request #35 from AudioKit/dynamic_graph_crash
test to reproduce dynamic graph crash
2 parents eacbf5f + bbe206a commit 1157fe8

File tree

2 files changed

+328
-1
lines changed

2 files changed

+328
-1
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ let package = Package(
77
name: "AudioKitEX",
88
platforms: [.macOS(.v12), .iOS(.v13), .tvOS(.v13)],
99
products: [.library(name: "AudioKitEX", targets: ["AudioKitEX"])],
10-
dependencies: [.package(url: "https://github.com/AudioKit/AudioKit", from: "5.5.0")],
10+
dependencies: [.package(url: "https://github.com/AudioKit/AudioKit", branch: "dynamic_graph_crash")],
1111
targets: [
1212
.target(name: "AudioKitEX", dependencies: ["AudioKit", "CAudioKitEX"]),
1313
.target(name: "CAudioKitEX", cxxSettings: [.headerSearchPath(".")]),
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
//
2+
// DynamicGraphTests.swift
3+
// AudioKitEX
4+
//
5+
// Created by Taylor Holliday on 3/23/26.
6+
//
7+
8+
import Testing
9+
import AudioKit
10+
import AudioKitEX
11+
import AVFoundation
12+
import Accelerate
13+
import CAudioKitEX
14+
15+
/// Manages local audio file playback using `AudioKit` with `AudioKitEX` fades.
16+
@MainActor
17+
final class AudioKitFilePlayer {
18+
private struct ActivePlaybackNode {
19+
let player: AudioPlayer
20+
let fader: Fader
21+
let panMixer: Mixer
22+
let instrument: Instrument
23+
24+
var id: ObjectIdentifier {
25+
ObjectIdentifier(player.playerNode)
26+
}
27+
}
28+
29+
let levelsHolder = LevelsHolder()
30+
31+
private let audioEngine = AudioEngine()
32+
private let masterMixer = Mixer(name: "AudioKitFilePlayer Master")
33+
private let instrumentMixers = Dictionary(
34+
uniqueKeysWithValues: Instrument.allCases.map { instrument in
35+
(instrument, Mixer(name: "\(instrument.rawValue) Mixer"))
36+
}
37+
)
38+
private var levelsTap: RawBufferTap?
39+
40+
private var activePlaybackNodes: [ObjectIdentifier: ActivePlaybackNode] = [:]
41+
private var engineIsRunning = false
42+
43+
var isRunning: Bool {
44+
engineIsRunning
45+
}
46+
47+
init() {
48+
setupInstrumentMixers()
49+
audioEngine.output = masterMixer
50+
let levelsTap = RawBufferTap(
51+
masterMixer,
52+
bufferSize: 1024,
53+
callbackQueue: .global(qos: .userInitiated),
54+
handler: Self.makeLevelsTap(holder: levelsHolder)
55+
)
56+
self.levelsTap = levelsTap
57+
levelsTap.start()
58+
}
59+
60+
func startPlayback() {
61+
do {
62+
if !engineIsRunning {
63+
try audioEngine.start()
64+
engineIsRunning = true
65+
Self.log("AudioEngine started")
66+
}
67+
} catch {
68+
Self.log("Error starting AudioEngine: \(error)")
69+
}
70+
}
71+
72+
func stopPlayback() {
73+
stopAllRunningPlayers()
74+
}
75+
76+
func pausePlayback() {
77+
stopAllRunningPlayers()
78+
audioEngine.pause()
79+
engineIsRunning = false
80+
}
81+
82+
func reset() {
83+
stopAllRunningPlayers()
84+
audioEngine.stop()
85+
levelsTap?.stop()
86+
engineIsRunning = false
87+
}
88+
89+
func setOutputVolume(_ outputVolume: Float) {
90+
masterMixer.volume = outputVolume
91+
}
92+
93+
func updateActiveInstruments(_ activeInstruments: Set<Instrument>) {
94+
for (instrument, mixer) in instrumentMixers {
95+
mixer.volume = activeInstruments.contains(instrument) ? 1.0 : 0.0
96+
}
97+
}
98+
99+
func scheduleFilePlayback(
100+
_ audioFile: AVAudioFile,
101+
fileName: String,
102+
instrument: Instrument,
103+
gain: Float,
104+
startTime: Double,
105+
endTime: Double,
106+
fileOffset: Double,
107+
fadeInDuration: Double,
108+
fadeOutDuration: Double,
109+
pan: Float
110+
) {
111+
guard isRunning else { return }
112+
113+
guard let instrumentMixer = instrumentMixers[instrument] else {
114+
Self.log("Missing mixer for instrument \(instrument.rawValue)")
115+
return
116+
}
117+
118+
let player = AudioPlayer()
119+
let fader = Fader(player, gain: gain)
120+
let panMixer = Mixer(fader, name: "\(fileName) Pan")
121+
let activeNode = ActivePlaybackNode(
122+
player: player,
123+
fader: fader,
124+
panMixer: panMixer,
125+
instrument: instrument
126+
)
127+
128+
do {
129+
try player.load(file: audioFile, buffered: false)
130+
} catch {
131+
Self.log("Could not load audio file for \(fileName): \(error)")
132+
return
133+
}
134+
135+
panMixer.pan = AUValue(pan)
136+
instrumentMixer.addInput(panMixer)
137+
activePlaybackNodes[activeNode.id] = activeNode
138+
139+
let playDuration = max(0, endTime - startTime)
140+
let effectiveGain = AUValue(max(0, gain))
141+
142+
if fadeInDuration > 0 {
143+
fader.gain = 0
144+
} else {
145+
fader.gain = effectiveGain
146+
}
147+
148+
let automationEvents = makeGainAutomationEvents(
149+
targetGain: effectiveGain,
150+
playDuration: playDuration,
151+
fadeInDuration: fadeInDuration,
152+
fadeOutDuration: fadeOutDuration
153+
)
154+
155+
if !automationEvents.isEmpty {
156+
fader.automateGain(events: automationEvents)
157+
}
158+
159+
player.completionHandler = { [weak self] in
160+
Task { @MainActor [weak self] in
161+
self?.cleanupPlaybackNode(id: activeNode.id, fileName: fileName)
162+
}
163+
}
164+
165+
player.play(from: fileOffset, to: fileOffset + playDuration)
166+
}
167+
168+
private func makeGainAutomationEvents(
169+
targetGain: AUValue,
170+
playDuration: Double,
171+
fadeInDuration: Double,
172+
fadeOutDuration: Double
173+
) -> [AutomationEvent] {
174+
var events: [AutomationEvent] = []
175+
176+
if fadeInDuration > 0 {
177+
events.append(
178+
AutomationEvent(
179+
targetValue: targetGain,
180+
startTime: 0,
181+
rampDuration: Float(fadeInDuration)
182+
)
183+
)
184+
}
185+
186+
if fadeOutDuration > 0 {
187+
let fadeOutDelay = playDuration - fadeOutDuration
188+
189+
if fadeOutDelay > 0 {
190+
events.append(
191+
AutomationEvent(
192+
targetValue: 0,
193+
startTime: Float(fadeOutDelay),
194+
rampDuration: Float(fadeOutDuration)
195+
)
196+
)
197+
}
198+
}
199+
200+
return events
201+
}
202+
203+
private func setupInstrumentMixers() {
204+
for mixer in instrumentMixers.values {
205+
masterMixer.addInput(mixer)
206+
}
207+
}
208+
209+
private func stopAllRunningPlayers() {
210+
let activeNodes = Array(activePlaybackNodes.values)
211+
212+
for activeNode in activeNodes {
213+
cleanupPlaybackNode(id: activeNode.id)
214+
}
215+
}
216+
217+
private func cleanupPlaybackNode(id: ObjectIdentifier, fileName: String? = nil) {
218+
guard let activeNode = activePlaybackNodes.removeValue(forKey: id) else {
219+
return
220+
}
221+
222+
activeNode.player.stop()
223+
activeNode.fader.stopAutomation()
224+
activeNode.panMixer.volume = 0
225+
instrumentMixers[activeNode.instrument]?.removeInput(activeNode.panMixer)
226+
227+
if let fileName {
228+
Self.log("Detaching AudioKit player node for \(fileName)")
229+
}
230+
}
231+
232+
nonisolated private static func makeLevelsTap(holder: LevelsHolder) -> AVAudioNodeTapBlock {
233+
return { buffer, _ in
234+
guard let floatData = buffer.floatChannelData else {
235+
log("Tap received nil floatChannelData")
236+
return
237+
}
238+
239+
let channelCount = Int(buffer.format.channelCount)
240+
let length = UInt(buffer.frameLength)
241+
242+
for channelIndex in 0..<channelCount {
243+
let data = floatData[channelIndex]
244+
245+
var rms: Float = 0
246+
vDSP_rmsqv(data, 1, &rms, length)
247+
248+
holder.setLevel(rms, channel: channelIndex)
249+
}
250+
}
251+
}
252+
}
253+
254+
extension AudioKitFilePlayer {
255+
nonisolated private static func log(_ message: @autoclosure () -> String) {
256+
print("[AudioKitFilePlayer] \(message())")
257+
}
258+
}
259+
260+
enum Instrument: String, CaseIterable, Hashable {
261+
case waves
262+
}
263+
264+
final class LevelsHolder: @unchecked Sendable {
265+
private let lock = NSLock()
266+
private var levels: [Int: Float] = [:]
267+
268+
func setLevel(_ level: Float, channel: Int) {
269+
lock.lock()
270+
levels[channel] = level
271+
lock.unlock()
272+
}
273+
}
274+
275+
276+
/// Minimal repro: dynamically adding a player to a running engine and calling play() crashes
277+
/// with "player started when in a disconnected state".
278+
@MainActor
279+
@Test
280+
func testDynamicPlayerCrash() throws {
281+
let engine = AudioEngine()
282+
let masterMixer = Mixer(name: "Master")
283+
engine.output = masterMixer
284+
try engine.start()
285+
286+
let testFileURL = Bundle.module.url(forResource: "12345", withExtension: "wav", subdirectory: "TestResources")!
287+
let audioFile = try AVAudioFile(forReading: testFileURL)
288+
289+
// Pre-connect an intermediate mixer (like an instrument bus)
290+
let instrumentMixer = Mixer(name: "Instrument")
291+
masterMixer.addInput(instrumentMixer)
292+
293+
let player = AudioPlayer()
294+
try player.load(file: audioFile, buffered: false)
295+
296+
let fader = Fader(player)
297+
let panMixer = Mixer(fader, name: "Pan")
298+
instrumentMixer.addInput(panMixer)
299+
300+
player.play()
301+
}
302+
303+
@MainActor
304+
@Test
305+
func testAudioKitFilePlayerCanScheduleLocalMP3() throws {
306+
let testFileURL = Bundle.module.url(forResource: "12345", withExtension: "wav", subdirectory: "TestResources")!
307+
let audioFile = try AVAudioFile(forReading: testFileURL)
308+
309+
let player = AudioKitFilePlayer()
310+
player.startPlayback()
311+
312+
#expect(player.isRunning)
313+
314+
player.scheduleFilePlayback(
315+
audioFile,
316+
fileName: testFileURL.lastPathComponent,
317+
instrument: .waves,
318+
gain: 1.0,
319+
startTime: 0,
320+
endTime: min(audioFile.duration, 0.25),
321+
fileOffset: 0,
322+
fadeInDuration: 0.05,
323+
fadeOutDuration: 0.05,
324+
pan: 0
325+
)
326+
}
327+

0 commit comments

Comments
 (0)