diff --git a/CHANGELOG.md b/CHANGELOG.md index c994e69..9a32493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ 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. +- **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. 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 - **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 diff --git a/src/main.zig b/src/main.zig index 2b495e9..e812e3a 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); }; @@ -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| { @@ -2013,8 +2018,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 +2029,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 +5342,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 +5368,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 +5389,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 +5418,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; 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,