From f942e4ae3052b1a709f570f9939bdc2c298be640 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Fri, 23 Oct 2026 23:44:07 +0100 Subject: [PATCH 1/3] =?UTF-8?q?machine:=20FX=20Rack=20master-loud,=20a=20l?= =?UTF-8?q?oudness=20stage=20for=20the=20master=20(docs/21=20=C2=A7The=20m?= =?UTF-8?q?aster)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++++++++ docs/21-production-guide.md | 7 +++++++ machines/fxrack/presets/master-loud.preset | 1 + 3 files changed, 16 insertions(+) create mode 100644 machines/fxrack/presets/master-loud.preset diff --git a/CHANGELOG.md b/CHANGELOG.md index c994e69..a34ee0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ GitHub are its section here (`tools/release.sh` copies them), so add to ## Unreleased +- **A loudness stage for the master.** The FX Rack has a new `master-loud` + preset: glue compression, a diode clip that rounds off the peaks, and a + limiter, behind four macros. LOUD drives the clip and the limiter + together, so the mix gets denser instead of just hotter: on neon_tide it + takes -13 LUFS to -11 at the default, -9 at 0.8 and -8 at full, with the + peak-to-average gap falling from 15 dB to 11. GLUE, CLIP and CEIL + trim it. Put it last on the master. + ## 0.0.15 — 2026-10-22 - **The SM-24 Mono has a patch panel.** Its new PATCH page holds four diff --git a/docs/21-production-guide.md b/docs/21-production-guide.md index 5fd21f6..726b9fd 100644 --- a/docs/21-production-guide.md +++ b/docs/21-production-guide.md @@ -452,6 +452,13 @@ eq2 (hpf 25 Hz, gentle air shelf) → comp2 gentle-bus-glue → limiter2 (ceil knee changed. Compare meters on the same WAV before encoding. - **Limiter gain**: raise it until the integrated loudness reaches the target, then stop. +- **Or use the FX Rack `master-loud`** as the last master effect: glue, + a diode clip and a limiter behind four macros (LOUD, GLUE, CLIP, CEIL). + LOUD drives clip and limiter together, so it gets denser, not only + hotter. A mix with a 15 dB peak-to-average gap, as an open mix with + only a gentle limiter leaves, reads about -11 LUFS at 0.5 and -9 at 0.8. + The float mix never clips on its own, so nothing makes a mix loud unless + something on the master does. | Target | Integrated | |---|---| diff --git a/machines/fxrack/presets/master-loud.preset b/machines/fxrack/presets/master-loud.preset new file mode 100644 index 0000000..ae1132a --- /dev/null +++ b/machines/fxrack/presets/master-loud.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "fxrack", "note": "A loudness stage for the master: GLUE compresses, CLIP rounds off the peaks, then a limiter at CEIL. LOUD drives the clip and the limiter together, so it gets denser, not just louder.", "rack": 2, "kind": "effect", "lanes": [{"name": "MASTER", "effects": [{"machine": "comp2", "params": {"comp-thresh": -12, "comp-ratio": 2, "comp-knee": 8, "comp-atk": 0.01, "comp-rel": 0.15, "comp-hpf": 80, "comp-det": 1, "comp-makeup": 0, "comp-mix": 1}}, {"machine": "sat2", "params": {"sat-drive": 4, "sat-mode": 3, "sat-sag": 0, "sat-color": 1, "sat-tone": 18000, "sat-mix": 0.5, "sat-out": -2}}, {"machine": "limiter2", "params": {"lim-gain": 8, "lim-ceil": -1.0, "lim-look": 2, "lim-rel": 0.12}}]}], "macros": [{"name": "LOUD", "value": 0.5}, {"name": "GLUE", "value": 0.4}, {"name": "CLIP", "value": 0.5}, {"name": "CEIL", "value": 0.6}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": [{"macro": 1, "lane": 1, "insert": 3, "control": "lim-gain", "min": 0, "max": 16}, {"macro": 1, "lane": 1, "insert": 2, "control": "sat-drive", "min": 0, "max": 9}, {"macro": 1, "lane": 1, "insert": 2, "control": "sat-out", "min": 0, "max": -5}, {"macro": 2, "lane": 1, "insert": 1, "control": "comp-thresh", "min": -3, "max": -22}, {"macro": 2, "lane": 1, "insert": 1, "control": "comp-makeup", "min": 0, "max": 3}, {"macro": 3, "lane": 1, "insert": 2, "control": "sat-mix", "min": 0, "max": 1}, {"macro": 4, "lane": 1, "insert": 3, "control": "lim-ceil", "min": -3, "max": -0.3}]} \ No newline at end of file From cc8083cc1b7039c9738318deb587766c31326146 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Fri, 23 Oct 2026 23:59:57 +0100 Subject: [PATCH 2/3] ui: machine menus list every machine; browser drops on the master bay reach the master (CHANGELOG) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++++ src/main.zig | 17 +++++++++++++---- src/ui/machine_bay.zig | 2 +- src/ui/menu.zig | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a34ee0d..b7d01e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ GitHub are its section here (`tools/release.sh` copies them), so add to takes -13 LUFS to -11 at the default, -9 at 0.8 and -8 at full, with the peak-to-average gap falling from 15 dB to 11. GLUE, CLIP and CEIL trim it. Put it last on the master. +- **The machine menu shows every machine again.** The "+" and replace + menus stopped at 40 rows, so FX Rack, Rack and the newest effects (the + harsh pack) were cut off the end. The cap is 72, and "+" on the master + lists effects only. Dropping an effect or rack preset from the browser + onto the machine bay while the master is selected now lands on the + master chain, not the first track. ## 0.0.15 — 2026-10-22 diff --git a/src/main.zig b/src/main.zig index 2b495e9..fae40a1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1471,7 +1471,7 @@ pub fn main(init: std.process.Init) !void { .preset, .table => if (sel_t) |t| .{ .header = t } else .new_track, else => if (sel_t) |t| .{ .lane = .{ .track = t, .beat = trackEnd(&tracks[t]) } } else .new_track, }; - const app = App{ .alloc = alloc, .history = &history, .tracks_buf = &tracks_buf, .track_count = &track_count, .tracks = &tracks, .transport = &transport, .engine = &engine, .audio = &audio, .reg = ®, .pool = &audio_pool, .selected_track = &selected_track, .selected_clip = &selected_clip, .prev_selected_clip = &prev_selected_clip, .project_path = &project_path, .project_path_chosen = &project_path_chosen, .dirty = &dirty, .status = &status, .edit_snap = edit_snap }; + const app = App{ .alloc = alloc, .history = &history, .tracks_buf = &tracks_buf, .track_count = &track_count, .tracks = &tracks, .transport = &transport, .engine = &engine, .audio = &audio, .reg = ®, .pool = &audio_pool, .selected_track = &selected_track, .selected_clip = &selected_clip, .prev_selected_clip = &prev_selected_clip, .project_path = &project_path, .project_path_chosen = &project_path_chosen, .dirty = &dirty, .status = &status, .edit_snap = edit_snap, .master = &master }; if (browserDrop(app, &lib, br.selection(), cur, target)) lib_stale = true; rects = layout.compute(sw, sh); }; @@ -2013,8 +2013,8 @@ pub fn main(init: std.process.Init) !void { .new_track => browser.drawNewTrack(ui, hit.lane, drop_ok), else => {}, } - } else if (bay.contains(@intFromFloat(mx), @intFromFloat(my)) and selected_track != null) { - target = .{ .header = selected_track.? }; + } else if (bay.contains(@intFromFloat(mx), @intFromFloat(my)) and (device_sel == .master or selected_track != null)) { + target = if (device_sel == .master) .master else .{ .header = selected_track.? }; drop_ok = dropAccepts(kind, target, tracks); browser.drawTarget(ui, bay, drop_ok, if (kind == .table) "LOAD TABLE" else if (kind == .preset) "LOAD PRESET" else ""); } @@ -2024,7 +2024,7 @@ pub fn main(init: std.process.Init) !void { } else if (!ui.raw_in.down) { browser.endDrag(ui, &br); if (drop_ok) { - const app = App{ .alloc = alloc, .history = &history, .tracks_buf = &tracks_buf, .track_count = &track_count, .tracks = &tracks, .transport = &transport, .engine = &engine, .audio = &audio, .reg = ®, .pool = &audio_pool, .selected_track = &selected_track, .selected_clip = &selected_clip, .prev_selected_clip = &prev_selected_clip, .project_path = &project_path, .project_path_chosen = &project_path_chosen, .dirty = &dirty, .status = &status, .edit_snap = edit_snap }; + const app = App{ .alloc = alloc, .history = &history, .tracks_buf = &tracks_buf, .track_count = &track_count, .tracks = &tracks, .transport = &transport, .engine = &engine, .audio = &audio, .reg = ®, .pool = &audio_pool, .selected_track = &selected_track, .selected_clip = &selected_clip, .prev_selected_clip = &prev_selected_clip, .project_path = &project_path, .project_path_chosen = &project_path_chosen, .dirty = &dirty, .status = &status, .edit_snap = edit_snap, .master = &master }; if (browserDrop(app, &lib, br.selection(), cur, target)) lib_stale = true; rects = layout.compute(sw, sh); } @@ -5337,6 +5337,9 @@ const BrowserDrop = union(enum) { header: usize, /// Below the tracks: a new track with the item on it. new_track, + /// The master chain (the machine bay while the master is selected): + /// an effect preset joins it. + master, open_song, }; @@ -5360,6 +5363,7 @@ const App = struct { dirty: *bool, status: *StatusMessage, edit_snap: snap_mod.Setting, + master: *track_mod.Track, }; /// The beat after a track's last clip. @@ -5380,6 +5384,7 @@ fn dropAccepts(kind: library_mod.Kind, d: BrowserDrop, tracks: []const track_mod else => false, }, .header => kind == .preset or kind == .table, + .master => kind == .preset, }; } @@ -5408,6 +5413,10 @@ fn browserDrop(app: App, lib: *library_mod.Library, sel: []const u32, first: u32 queueOpen(app.alloc, it.path); return true; } + if (target == .master) { + if (it.kind == .preset and dropPreset(app, app.master, it, false)) app.dirty.* = true; + return false; + } // One undo step for the whole drop, a new track included. const before = document_mod.serialize(app.alloc, app.tracks.*, app.transport) catch return false; var ti: usize = 0; diff --git a/src/ui/machine_bay.zig b/src/ui/machine_bay.zig index cc4c5f4..4cd6dd4 100644 --- a/src/ui/machine_bay.zig +++ b/src/ui/machine_bay.zig @@ -343,7 +343,7 @@ pub fn draw(ui: *Ui, r_legacy: c.rl.Rectangle, device: ?*Track, track_idx: ?usiz _ = ui.plate(Rect.xywh(x + TITLE_H, area.y, area.right() - x - TITLE_H, TITLE_H), .{}); _ = ui.plate(Rect.xywh(x, area.y + TITLE_H, area.right() - x, area.h - TITLE_H), .{}); } - if (machinePickerMenu(ADD_MENU_KEY, reg)) |pick| { + if (machinePickerMenuFor(ADD_MENU_KEY, reg, if (is_bus) .effects else .all)) |pick| { result.add_machine = pick.reg_idx; result.add_preset = pick.preset; } diff --git a/src/ui/menu.zig b/src/ui/menu.zig index 9174bb9..1a567cf 100644 --- a/src/ui/menu.zig +++ b/src/ui/menu.zig @@ -97,7 +97,7 @@ fn commandShortcut(cmd: EditCommand) ?[]const u8 { // ── Metrics ────────────────────────────────────────────────────────── pub const MAX_DEPTH = 4; -const MAX_ITEMS = 40; +const MAX_ITEMS = 72; const LABEL_MAX = 64; const HINT_MAX = 16; const ROW_H: i32 = 18; From d2b4100fb352b3747c732c3cdaef93ee690841ff Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Sat, 24 Oct 2026 00:19:47 +0100 Subject: [PATCH 3/3] ui: browser presets drop onto mixer strips, buses and the master Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 ++- src/main.zig | 5 +++++ src/ui/mixer.zig | 33 +++++++++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d01e8..9a32493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ GitHub are its section here (`tools/release.sh` copies them), so add to harsh pack) were cut off the end. The cap is 72, and "+" on the master lists effects only. Dropping an effect or rack preset from the browser onto the machine bay while the master is selected now lands on the - master chain, not the first track. + master chain, not the first track. In the mixer, dropping a preset on a + strip (a track, a bus or the master) loads it there. ## 0.0.15 — 2026-10-22 diff --git a/src/main.zig b/src/main.zig index fae40a1..e812e3a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1995,6 +1995,11 @@ pub fn main(init: std.process.Init) !void { if (kind == .song and pane.contains(rects.arrangement, mx, my)) { target = .open_song; browser.drawTarget(ui, uiRect(rects.arrangement), true, "OPEN THIS SONG"); + } else if (layout.mixer_visible and mixer.dropHit(mx, my) != null) { + const hit = mixer.dropHit(mx, my).?; + target = if (hit.master) .master else .{ .header = hit.track }; + drop_ok = dropAccepts(kind, target, tracks); + browser.drawTarget(ui, hit.rect, drop_ok, if (kind == .table) "LOAD TABLE" else if (kind == .preset) "LOAD PRESET" else ""); } else if (!layout.mixer_visible and arrangement.dropHit(tracks, mx, my) != null) { const hit = arrangement.dropHit(tracks, mx, my).?; if (hit.track) |ti| { diff --git a/src/ui/mixer.zig b/src/ui/mixer.zig index ff45d1e..1a2033d 100644 --- a/src/ui/mixer.zig +++ b/src/ui/mixer.zig @@ -64,6 +64,19 @@ pub const Result = struct { color_pick: ?arrangement.ColorPick = null, }; +/// Where a browser drop lands in the last drawn mixer: a strip, or the +/// master (`master`). Rebuilt every frame by `draw`. +pub const StripHit = struct { track: usize = 0, master: bool = false, rect: Rect = .{} }; +var hits: [routing.MAX_TRACKS + 1]StripHit = undefined; +var hit_n: usize = 0; + +pub fn dropHit(x: f32, y: f32) ?StripHit { + const ix: i32 = @intFromFloat(x); + const iy: i32 = @intFromFloat(y); + for (hits[0..hit_n]) |h| if (h.rect.contains(ix, iy)) return h; + return null; +} + pub fn draw( ui: *Ui, r_legacy: c.rl.Rectangle, @@ -74,6 +87,7 @@ pub fn draw( beat: f64, ) Result { var res: Result = .{}; + hit_n = 0; const r = bridge.fromRl(r_legacy); ui.rect(r, ui_style.chassis); var area = r; @@ -136,26 +150,41 @@ pub fn draw( ui.clip(strips_r); var x = strips_r.x - scroll_x; for (o.main()) |row| { - drawStrip(ui, Rect.xywh(x, strips_r.y, STRIP_W, strips_r.h), tracks, &o, row.ti, send_rows, device_sel, selected_track, beat, &res); + const sr = Rect.xywh(x, strips_r.y, STRIP_W, strips_r.h); + drawStrip(ui, sr, tracks, &o, row.ti, send_rows, device_sel, selected_track, beat, &res); + addHit(row.ti, false, sr, strips_r); x += STRIP_W; } // Nothing shows bare chassis (docs/06 §Packing). if (x < strips_r.right()) _ = ui.plate(Rect.xywh(x, strips_r.y, strips_r.right() - x, strips_r.h), .{ .fill = ui_style.face.shade(-4) }); ui.unclip(); + const bus_area_full = bus_area; ui.clip(bus_area); for (o.returns()) |row| { - drawStrip(ui, bus_area.cutLeft(STRIP_W), tracks, &o, row.ti, send_rows, device_sel, selected_track, beat, &res); + const sr = bus_area.cutLeft(STRIP_W); + drawStrip(ui, sr, tracks, &o, row.ti, send_rows, device_sel, selected_track, beat, &res); + addHit(row.ti, false, sr, bus_area_full); } ui.unclip(); drawMasterStrip(ui, master_r, master, send_rows, device_sel); + addHit(0, true, master_r, master_r); dragStrips(ui, tracks, &o, selected_track, band, strips_r, bus_x0, &res); if (route_menu.tick(tracks, selected_track.*)) |e| res.route = e; input_menu.tick(tracks); return res; } +fn addHit(ti: usize, is_master: bool, r: Rect, clip: Rect) void { + if (hit_n >= hits.len) return; + const x0 = @max(r.x, clip.x); + const x1 = @min(r.right(), clip.right()); + if (x1 <= x0) return; + hits[hit_n] = .{ .track = ti, .master = is_master, .rect = Rect.xywh(x0, r.y, x1 - x0, r.h) }; + hit_n += 1; +} + /// The rows every strip shares, so sections line up across the page. const Rows = struct { title: Rect,