diff --git a/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift b/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift index a76e784..7d1ec25 100644 --- a/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift +++ b/Sources/PlaydateKit/Sound/Effect/Classes/Effect.swift @@ -38,10 +38,16 @@ extension Sound { } deinit { - if isOwned { - effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer) + // Subclasses free the C object in their own deinit with the + // subsystem's type-specific free (freeDelayLine, freeOverdrive, + // ...); freeing here as well would double-free. The base class + // owns only the custom-processor effects it creates itself. + if let processorBox { + if isOwned { + effectAPI.pointee.freeEffect.unsafelyUnwrapped(pointer) + } + processorBox.release() } - processorBox?.release() } /// The wet/dry mix: 1 is fully processed, 0 fully dry. diff --git a/Tests/PlaydateKit/MockPlaydate.swift b/Tests/PlaydateKit/MockPlaydate.swift index 50b1685..8d21f1a 100644 --- a/Tests/PlaydateKit/MockPlaydate.swift +++ b/Tests/PlaydateKit/MockPlaydate.swift @@ -20,6 +20,10 @@ enum Mock { nonisolated(unsafe) static let spriteAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let soundAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let channelAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let synthAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let soundEffectAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let lfoAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let delayLineAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let fileAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let jsonAPI = UnsafeMutablePointer.allocate(capacity: 1) nonisolated(unsafe) static let apiStruct = UnsafeMutablePointer.allocate(capacity: 1) @@ -39,6 +43,19 @@ enum Mock { nonisolated(unsafe) static var spriteUpdateCallback: (@convention(c) (OpaquePointer?) -> Void)? nonisolated(unsafe) static var audioCallback: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?, UnsafeMutablePointer?, Int32) -> Int32)? nonisolated(unsafe) static var audioContext: UnsafeMutableRawPointer? + /// Generator registrations per synth. The OS deallocs a generator's + /// userdata when the generator is replaced or its synth is freed; the + /// mock mirrors that contract. + struct SynthGenerator { + var render: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?, UnsafeMutablePointer?, Int32, UInt32, Int32) -> Int32)? + var dealloc: (@convention(c) (UnsafeMutableRawPointer?) -> Void)? + var userdata: UnsafeMutableRawPointer? + } + nonisolated(unsafe) static var synthGenerators: [OpaquePointer: SynthGenerator] = [:] + /// The last custom-effect proc handed to `newEffect`, plus each + /// effect's userdata, for re-invocation by tests. + nonisolated(unsafe) static var effectProc: (@convention(c) (OpaquePointer?, UnsafeMutablePointer?, UnsafeMutablePointer?, Int32, Int32) -> Int32)? + nonisolated(unsafe) static var effectUserdata: [OpaquePointer: UnsafeMutableRawPointer] = [:] static func record(_ event: String) { events.append(event) @@ -72,6 +89,9 @@ enum Mock { spriteUpdateCallback = nil audioCallback = nil audioContext = nil + synthGenerators = [:] + effectProc = nil + effectUserdata = [:] } private static func install() { @@ -282,6 +302,86 @@ enum Mock { Mock.record("channelRemoveSource") return 1 } + channelAPI.pointee.addSource = { _, _ in + Mock.record("channelAddSource") + return 1 + } + + soundAPI.pointee.synth = UnsafePointer(synthAPI) + synthAPI.initialize(to: playdate_sound_synth()) + synthAPI.pointee.newSynth = { + Mock.record("newSynth") + return Mock.fakePointer() + } + synthAPI.pointee.freeSynth = { synth in + Mock.record("freeSynth") + // Freeing a synth deallocs its generator's userdata. + if let synth, let generator = Mock.synthGenerators.removeValue(forKey: synth) { + generator.dealloc?(generator.userdata) + } + } + synthAPI.pointee.setGenerator = { synth, _, render, _, _, _, dealloc, _, userdata in + Mock.record("setGenerator") + guard let synth else { return } + // Replacing a generator deallocs the previous one first. + if let previous = Mock.synthGenerators[synth] { + previous.dealloc?(previous.userdata) + } + Mock.synthGenerators[synth] = SynthGenerator(render: render, dealloc: dealloc, + userdata: userdata) + } + synthAPI.pointee.setFrequencyModulator = { _, _ in + Mock.record("setFrequencyModulator") + } + + soundAPI.pointee.lfo = UnsafePointer(lfoAPI) + lfoAPI.initialize(to: playdate_sound_lfo()) + lfoAPI.pointee.newLFO = { _ in + Mock.record("newLFO") + return Mock.fakePointer() + } + lfoAPI.pointee.freeLFO = { _ in + Mock.record("freeLFO") + } + + soundAPI.pointee.effect = UnsafePointer(soundEffectAPI) + soundEffectAPI.initialize(to: playdate_sound_effect()) + soundEffectAPI.pointee.newEffect = { proc, userdata in + Mock.record("newEffect") + let pointer = Mock.fakePointer() + Mock.effectProc = proc + if let userdata { + Mock.effectUserdata[pointer] = userdata + } + return pointer + } + soundEffectAPI.pointee.freeEffect = { effect in + Mock.record("freeEffect") + if let effect { + Mock.effectUserdata[effect] = nil + } + } + soundEffectAPI.pointee.getUserdata = { effect in + guard let effect else { return nil } + return Mock.effectUserdata[effect] + } + + soundEffectAPI.pointee.delayline = UnsafePointer(delayLineAPI) + delayLineAPI.initialize(to: playdate_sound_effect_delayline()) + delayLineAPI.pointee.newDelayLine = { _, _ in + Mock.record("newDelayLine") + return Mock.fakePointer() + } + delayLineAPI.pointee.freeDelayLine = { _ in + Mock.record("freeDelayLine") + } + delayLineAPI.pointee.addTap = { _, _ in + Mock.record("addTap") + return Mock.fakePointer() + } + delayLineAPI.pointee.freeTap = { _ in + Mock.record("freeTap") + } } // MARK: - File diff --git a/Tests/PlaydateKit/WrapperTests.swift b/Tests/PlaydateKit/WrapperTests.swift index 6c15e71..793772a 100644 --- a/Tests/PlaydateKit/WrapperTests.swift +++ b/Tests/PlaydateKit/WrapperTests.swift @@ -182,6 +182,148 @@ struct WrapperTests { #expect(Sound.CallbackSource.live.count == baseline) } + // MARK: Sound ownership graph + + @Test func channelRetainsAddedSourceUntilRemoved() { + let channel = Sound.Channel() + weak var weakSynth: Sound.Synth? + do { + let synth = Sound.Synth() + weakSynth = synth + channel.addSource(synth) + } + #expect(weakSynth != nil) // the channel keeps the source alive + #expect(Mock.eventCount("freeSynth") == 0) + + if let synth = weakSynth { + channel.removeSource(synth) + } + #expect(weakSynth == nil) + #expect(Mock.eventCount("freeSynth") == 1) + } + + @Test func synthRetainsItsModulatorWhileAlive() { + weak var weakLFO: Sound.LFO? + do { + let synth = Sound.Synth() + do { + let lfo = Sound.LFO() + weakLFO = lfo + synth.frequencyModulator = lfo + } + #expect(weakLFO != nil) // the synth retains the modulator + #expect(Mock.eventCount("freeLFO") == 0) + } + #expect(weakLFO == nil) + #expect(Mock.eventCount("freeLFO") == 1) + } + + @Test func synthGeneratorDispatchesAndIsReleasedWithTheSynth() { + final class Token {} + weak var weakToken: Token? + var rendered = 0 + + do { + let synth = Sound.Synth() + let token = Token() + weakToken = token + synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in + _ = token + rendered += 1 + return left.count + })) + + // Simulate the audio engine rendering through the trampoline. + let generator = try! #require(Mock.synthGenerators[synth.pointer]) + var samples = [Int32](repeating: 0, count: 8) + let frames = samples.withUnsafeMutableBufferPointer { buffer in + generator.render?(generator.userdata, buffer.baseAddress, nil, + Int32(buffer.count), 0, 0) + } + #expect(frames == 8) + #expect(rendered == 1) + #expect(weakToken != nil) + } + // freeSynth deallocs the generator userdata, releasing the box and + // the closure's captures with it. + #expect(Mock.eventCount("freeSynth") == 1) + #expect(weakToken == nil) + } + + @Test func replacingASynthGeneratorReleasesThePreviousOne() { + final class Token {} + weak var firstToken: Token? + + let synth = Sound.Synth() + do { + let token = Token() + firstToken = token + synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in + _ = token + return left.count + })) + } + #expect(firstToken != nil) + + synth.setGenerator(stereo: false, .init(render: { left, _, _, _ in left.count })) + #expect(firstToken == nil) // the OS deallocs the replaced generator + } + + @Test func effectProcessorDispatchesAndIsReleasedOnDeinit() { + final class Token {} + weak var weakToken: Token? + var processed = 0 + + do { + let token = Token() + weakToken = token + let effect = Sound.Effect(processor: { left, right, _ in + _ = token + processed += left.count + #expect(right == nil) + return true + }) + + // Drive the effect the way the OS would: through the registered + // proc, which recovers the box from the effect's userdata. + var samples = [Int32](repeating: 0, count: 4) + let active = samples.withUnsafeMutableBufferPointer { buffer in + Mock.effectProc?(effect.pointer, buffer.baseAddress, nil, + Int32(buffer.count), 1) + } + #expect(active == 1) + #expect(processed == 4) + } + #expect(Mock.eventCount("freeEffect") == 1) + #expect(weakToken == nil) + } + + @Test func effectSubclassIsFreedExactlyOnceWithItsOwnFree() { + do { + let line = Sound.DelayLine(length: 256) + _ = line + } + #expect(Mock.eventCount("freeDelayLine") == 1) + // The base Effect deinit must not also free the subclass's object. + #expect(Mock.eventCount("freeEffect") == 0) + } + + @Test func delayLineTapKeepsItsDelayLineAlive() { + weak var weakLine: Sound.DelayLine? + var tap: Sound.DelayLineTap? + do { + let line = Sound.DelayLine(length: 256) + weakLine = line + tap = line.addTap(delay: 128) + } + #expect(weakLine != nil) // the tap retains its delay line + #expect(Mock.eventCount("freeDelayLine") == 0) + + tap = nil + #expect(Mock.eventCount("freeTap") == 1) + #expect(Mock.eventCount("freeDelayLine") == 1) + } + // MARK: File @Test func fileHandleReadsWritesAndClosesExactlyOnce() throws {