diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b0be6b..432f24e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ GitHub are its section here (`tools/release.sh` copies them), so add to ## Unreleased +- **New Project no longer crashes while a reverb rings.** Starting a new + project, or opening one, while the old project's master effects were + still sounding could crash Slab. + - **Juniper-8, a Roland Jupiter-8.** Eight voices of two VCOs with exponential cross mod, VCO-2 synced to VCO-1, and PWM on both pulses. A 6 dB HPF feeds the IR3109 filter, with 24 dB and its thinner 12 dB tap. diff --git a/src/document.zig b/src/document.zig index e5e7d07..3e8d36a 100644 --- a/src/document.zig +++ b/src/document.zig @@ -995,11 +995,10 @@ pub fn apply( if (objGet(mo, "volume")) |x| m.setVolume(@floatCast(asF64(x))); if (objGet(mo, "pan")) |x| m.setPan(@floatCast(asF64(x))); m.subsonic.store(if (objGet(mo, "subsonic")) |x| asBool(x) else false, .monotonic); - for (m.effects.items) |*fx| { - if (fx.mach.deinit) |d| d(fx.mach.state, alloc); - if (fx.shared_io) |io| alloc.destroy(io); - } - m.effects.clearRetainingCapacity(); + // The master outlives the load and the audio thread may still be + // rendering its old chain (a fading switch plays one more chunk): + // retire the effects, freed once audio adopts the new chain. + while (m.effects.items.len > 0) try m.removeEffect(alloc, m.effects.items.len - 1); if (objGet(mo, "effects")) |ev| try applyEffects(alloc, reg, m, ev); m.publishMachines(); }; @@ -2213,3 +2212,55 @@ test "grooves round-trip: the project's own, the song's, a section's and a track try std.testing.expectEqual(@as(f32, 0.5), loaded_buf[0].groove.amount); try std.testing.expectEqual(@as(f32, -8), loaded_buf[0].groove.shift_ms); } + +test "realtime: a project load retires the master's effects until audio adopts the new chain" { + // New Project while the old master chain rang crashed the audio + // thread: the load freed the master's effects in place, and a fading + // adoption keeps rendering the old chain for one more chunk. + const M = struct { + freed: usize = 0, + fn destroy(st: *anyopaque, _: std.mem.Allocator) void { + const x: *@This() = @ptrCast(@alignCast(st)); + x.freed += 1; + } + }; + const alloc = std.testing.allocator; + var reg = registry_mod.Registry.init(alloc); + defer reg.deinit(); + var master = try track_mod.Track.init(alloc, "Master", .{ .r = 0, .g = 0, .b = 0, .a = 255 }, test_machine); + master.kind = .master; + defer master.deinit(alloc); + setMaster(&master); + defer active_master = null; + var transport: transport_mod.Transport = .{}; + transport.sample_rate = 48_000; + const blank = try serialize(alloc, &.{}, &transport); + defer alloc.free(blank); + + var fx = M{}; + var fx_mach = test_machine; + fx_mach.state = &fx; + fx_mach.deinit = M.destroy; + try master.addEffect(alloc, fx_mach, 0); + master.adoptMachines(false); + try std.testing.expectEqual(@as(usize, 1), master.audioEffects().len); + + var loaded_buf: [1]track_mod.Track = undefined; + var loaded_count: usize = 0; + try apply(alloc, blank, ®, loaded_buf[0..], &loaded_count, &transport, test_machine); + try std.testing.expectEqual(@as(usize, 0), master.effects.items.len); + // The audio side still holds the old effect: it must be alive. + master.collectMachines(alloc); + try std.testing.expectEqual(@as(usize, 0), fx.freed); + // Still ringing: the old chain renders its last chunk, then switches. + master.adoptMachines(true); + try std.testing.expectEqual(@as(usize, 1), master.audioEffects().len); + try std.testing.expect(@intFromPtr(master.audioEffects()[0].mach.state) == @intFromPtr(&fx)); + master.collectMachines(alloc); + try std.testing.expectEqual(@as(usize, 0), fx.freed); + master.swap = .out_done; + master.adoptMachines(true); + try std.testing.expectEqual(@as(usize, 0), master.audioEffects().len); + master.collectMachines(alloc); + try std.testing.expectEqual(@as(usize, 1), fx.freed); +} diff --git a/src/main.zig b/src/main.zig index ffe675f..a81b423 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2092,6 +2092,9 @@ pub fn main(init: std.process.Init) !void { } for (tracks) |*t| t.publishSnapshot(&audio_pool); + // The master's retired effects (a load or an edit) free once audio + // has moved past them. + master.collectMachines(alloc); // Bounces go stale as their sources change (docs/27 §Provenance). if (c.rl.GetTime() >= next_recipe_check and !bounce_job.active) { next_recipe_check = c.rl.GetTime() + 0.5;