From 81ea13f52c3e9bd14cf4bf508a3f59a886e3bd04 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Thu, 15 Oct 2026 13:34:44 +0100 Subject: [PATCH] audio: retire the master's effects on project load instead of freeing them A project load replaced the master's effect chain by freeing the old effects in place. When the old project was still ringing, the engine's fading adoption renders the old chain for one more chunk, and the audio thread read the freed states (segfault in adoptAssets on New Project). The load now removes them through Track.removeEffect, so they retire and free only after audio adopts the new chain, and the UI loop collects the master's retired machines every frame like the tracks'. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ src/document.zig | 61 ++++++++++++++++++++++++++++++++++++++++++++---- src/main.zig | 3 +++ 3 files changed, 63 insertions(+), 5 deletions(-) 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;