From ffc035ba29a15da7dfc510a2a30fac95252fcce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:23:18 +0000 Subject: [PATCH 01/26] Delete markers on Alt-click Fixes #1257 Co-Authored-By: GPT-5.6-Luna --- crates/ui-egui/src/edit_window/rulers.rs | 3 ++- crates/ui-egui/src/edit_window/tests.rs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window/rulers.rs b/crates/ui-egui/src/edit_window/rulers.rs index c665f907..5d47fca7 100644 --- a/crates/ui-egui/src/edit_window/rulers.rs +++ b/crates/ui-egui/src/edit_window/rulers.rs @@ -222,7 +222,8 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, } else if resp.clicked() && let Some(m) = hit { - let _ = app.run("markers.recall", json!({"number": m.number})); + let command = if ui.input(|i| i.modifiers.alt) { "markers.delete" } else { "markers.recall" }; + let _ = app.run(command, json!({"number": m.number})); } } } diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index 99d7dc45..3e6705ef 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -1508,3 +1508,20 @@ fn alt_click_on_a_breakpoint_deletes_it() { assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}"); } } + +#[test] +fn alt_click_on_a_marker_deletes_it() { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.session_mut().edit.rulers = vec!["markers".into()]; + engine.execute("markers.add", &json!({"at": 48_000, "name": "Alt-delete me"})).unwrap(); + let mut h = harness_with(engine); + let s = h.state().engine.session_arc(); + let marker = s.markers.iter().find(|m| m.name == "Alt-delete me").unwrap(); + let at = pos2(x_of(&s, timeline(&h), marker.start), timeline(&h).min.y - 11.5); + let before = s.markers.len(); + + click_with(&mut h, at, egui::Modifiers::ALT); + + assert_eq!(h.state().engine.session().markers.len(), before - 1); + assert!(!h.state().engine.session().markers.iter().any(|m| m.name == "Alt-delete me")); +} From 8f7eb4d777c895447c73f296eae2ffca2796f533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:23:34 +0000 Subject: [PATCH 02/26] Show stereo input pairs for stereo tracks Fixes #1255 Co-Authored-By: GPT-5.6-Luna --- crates/ui-egui/src/mix_window/routing.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/mix_window/routing.rs b/crates/ui-egui/src/mix_window/routing.rs index 176a8cba..a343a021 100644 --- a/crates/ui-egui/src/mix_window/routing.rs +++ b/crates/ui-egui/src/mix_window/routing.rs @@ -11,7 +11,12 @@ pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String { } pub(crate) fn input_name(app: &SoundApp, track: &Track) -> String { - if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) } + if track.kind.has_playlist() && track.mixer.input == Route::None { + let channels = track.format.channels(); + if channels == 1 { "In 1".to_string() } else { format!("In 1-{channels}") } + } else { + route_name(app, &track.mixer.input) + } } /// The input or output menu under `resp`. It closes only on a click outside, so its bus search @@ -59,3 +64,19 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { ui.close(); } } + +#[cfg(test)] +mod tests { + use super::input_name; + use crate::{Services, SoundApp}; + use serde_json::json; + + #[test] + fn stereo_track_defaults_to_a_stereo_input_pair() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"format": "stereo"})).unwrap(); + let track = &app.engine.session().tracks[0]; + + assert_eq!(input_name(&app, track), "In 1-2"); + } +} From 7dfa7e470fd3da51f1716ab8607a9622893891a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:25:43 +0000 Subject: [PATCH 03/26] Indent tracks nested in folders Fixes #1252 Co-Authored-By: GPT-5.6-Luna --- crates/ui-egui/src/panels/tracks.rs | 27 +++++++++++++++--- crates/ui-egui/tests/it/track_list.rs | 40 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/tracks.rs b/crates/ui-egui/src/panels/tracks.rs index b4fd9bb8..2c746b1c 100644 --- a/crates/ui-egui/src/panels/tracks.rs +++ b/crates/ui-egui/src/panels/tracks.rs @@ -46,9 +46,28 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { if crate::widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(plus)), plus.size(), "plus", false, &tip).clicked() { let _ = app.dialogs.open_for_command(&app.engine, "track.new"); } - let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = { + let tracks: Vec<(u64, String, bool, [u8; 3], bool, usize)> = { let s = app.engine.session(); - s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect() + s.tracks + .iter() + .map(|x| { + let mut current = x.folder; + let mut depth = 0; + let mut seen = Vec::new(); + while let Some(folder_id) = current { + if seen.contains(&folder_id) { + break; + } + seen.push(folder_id); + let Some(folder) = s.track(folder_id) else { + break; + }; + depth += 1; + current = folder.folder; + } + (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id), depth) + }) + .collect() }; let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden; let lists = with_pending_offset(egui::ScrollArea::vertical().id_salt("tracks_scroll"), ui, "tracks_scroll") @@ -56,7 +75,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { .auto_shrink([false, false]) .scroll_bar_visibility(hidden); let out = lists.show(ui, |ui| { - for (id, name, shown, color, sel) in tracks { + for (id, name, shown, color, sel, depth) in tracks { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, &name)); if sel { @@ -70,7 +89,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { } ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color)); ui.painter().with_clip_rect(r).text( - pos2(r.min.x + 30.0, r.center().y), + pos2(r.min.x + 30.0 + depth as f32 * 10.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), diff --git a/crates/ui-egui/tests/it/track_list.rs b/crates/ui-egui/tests/it/track_list.rs index cd8f758d..c2579962 100644 --- a/crates/ui-egui/tests/it/track_list.rs +++ b/crates/ui-egui/tests/it/track_list.rs @@ -50,6 +50,16 @@ impl Rig { .expect("row not in the Tracks list") } + fn text_x(&self, name: &str) -> f32 { + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == name && t.pos.x < 150.0 => Some(t.pos.x), + _ => None, + }) + .expect("text not in the Tracks list") + } + fn press(&mut self, at: Pos2, pressed: bool) { self.frame(vec![ Event::PointerMoved(at), @@ -90,3 +100,33 @@ fn clicking_a_track_list_row_still_selects_the_track() { r.press(at, false); assert_eq!(r.app.engine.session().edit.selected_tracks, vec![r.order()[2]]); } + +#[test] +fn a_track_inside_a_folder_is_indented_in_the_track_list() { + let mut r = Rig::new(); + let child = r.order()[1]; + let child_name = r.app.engine.session().track(child).unwrap().name.clone(); + r.app.run("track.move_to_new_folder", json!({"tracks": [child.0], "name": "Folder"})).unwrap(); + r.frame(vec![]); + let folder = r.app.engine.session().tracks.iter().find(|track| track.is_folder()).unwrap(); + + assert!(r.text_x(&child_name) > r.text_x(&folder.name), "child is not indented under the folder"); +} + +#[test] +fn deeply_nested_tracks_keep_indenting_past_six_folders() { + let mut r = Rig::new(); + let child = r.order()[1]; + let child_name = r.app.engine.session().track(child).unwrap().name.clone(); + r.app.run("track.move_to_new_folder", json!({"tracks": [child.0], "name": "Folder 1"})).unwrap(); + + let mut parent = r.app.engine.session().tracks.iter().find(|track| track.name == "Folder 1").unwrap().id; + for depth in 2..=7 { + r.app.run("track.move_to_new_folder", json!({"tracks": [parent.0], "name": format!("Folder {depth}")})).unwrap(); + parent = r.app.engine.session().tracks.iter().find(|track| track.name == format!("Folder {depth}")).unwrap().id; + } + r.frame(vec![]); + + let parent_name = r.app.engine.session().track(parent).unwrap().name.clone(); + assert!(r.text_x(&child_name) > r.text_x(&parent_name), "deeply nested child is not indented under its parent"); +} From 1a95692f5002357d30b19464256c14d2352d0d3b Mon Sep 17 00:00:00 2001 From: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:15:55 +0000 Subject: [PATCH 04/26] Toolbar: distinct link icons, an Insertion Follows Playback toggle, and its N shortcut in the menu The two link toggles shared the "link" icon (#1262), and Insertion Follows Playback, although it works with N, had no toolbar button of its own and no shortcut in the Options menu (#1263). - icons: "link_track" draws the chain under two track rows so the track link reads apart from the timeline link; "insertion_follows" draws the insertion line and the playhead arrow it follows. - toolbar: the option toggles move into option_toggles, which gains the insertion-follows entry between the links and Mirrored MIDI, as in Pro Tools. - options: the Insertion Follows Playback menu item shows N, the key it uses. - test: option_toggles_have_distinct_icons fails when any two option toggles share an icon (it fails on the old "link" pair). Fixes #1262, fixes #1263 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/engine/src/cmd/options.rs | 2 +- crates/ui-egui/src/icons.rs | 13 ++++++++++ crates/ui-egui/src/toolbar.rs | 41 +++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 45c4b63a..1596a8ad 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback), toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit), toggle!("options.link_track_edit", "Link Track and Edit Selection", ["Options"], None, link_track_edit), - toggle!("options.insertion_follows_playback", "Insertion Follows Playback", ["Options"], None, insertion_follows_playback), + toggle!("options.insertion_follows_playback", "Insertion Follows Playback", ["Options"], Some("N"), insertion_follows_playback), toggle!("options.tab_to_transient", "Tab to Transient", ["Options"], Some("Cmd+Alt+Tab"), tab_to_transient), toggle!("options.mirrored_midi", "Mirrored MIDI Editing", ["Options"], None, mirrored_midi), toggle!("options.automation_follows_edit", "Automation Follows Edit", ["Options"], None, automation_follows_edit), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1571e8f8..4dc06c59 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -176,6 +176,19 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], s); } } + "link_track" => { + // A link chain below two track rows, so the track link reads apart from the timeline link's plain chain. + painter.line_segment([p(r, 0.12, 0.18), p(r, 0.88, 0.18)], s); + painter.line_segment([p(r, 0.12, 0.32), p(r, 0.88, 0.32)], s); + painter.circle_stroke(p(r, 0.38, 0.72), r.width() * 0.16, s); + painter.circle_stroke(p(r, 0.62, 0.72), r.width() * 0.16, s); + } + "insertion_follows" => { + // The insertion line, a lead, and the playhead arrow it follows. + painter.line_segment([p(r, 0.3, 0.14), p(r, 0.3, 0.86)], s); + painter.line_segment([p(r, 0.42, 0.5), p(r, 0.62, 0.5)], s); + painter.add(Shape::convex_polygon(vec![p(r, 0.84, 0.5), p(r, 0.64, 0.34), p(r, 0.64, 0.66)], c, Stroke::NONE)); + } _ => { painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index cc063c68..0aef3a52 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -6,7 +6,7 @@ use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID}; -use soundcraft_model::{EditMode, Tool}; +use soundcraft_model::{EditState, EditMode, Tool}; use soundcraft_time::{TimeFormat, format_length}; pub const HEIGHT: f32 = 78.0; @@ -205,14 +205,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let e = app.engine.session().edit.clone(); - let toggles: [(&str, &str, bool, &str); 5] = [ - ("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"), - ("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"), - ("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"), - ("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"), - ("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"), - ]; - for (id, icon, on, tip) in toggles { + for (id, icon, on, tip) in option_toggles(&e) { if icon_button(ui, vec2(36.0, 18.0), icon, on, tip).clicked() { let _ = app.run(id, json!({})); } @@ -227,6 +220,18 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { }); } +/// The Edit-window toolbar's option toggles: (command id, icon, on, tooltip). +fn option_toggles(e: &EditState) -> [(&'static str, &'static str, bool, &'static str); 6] { + [ + ("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"), + ("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"), + ("options.link_track_edit", "link_track", e.link_track_edit, "Link Track and Edit Selection"), + ("options.insertion_follows_playback", "insertion_follows", e.insertion_follows_playback, "Insertion Follows Playback"), + ("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"), + ("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"), + ] +} + fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String { let s = app.engine.session(); s.format_position(at, f) @@ -457,3 +462,21 @@ fn output_meter(app: &mut SoundApp, ui: &mut Ui) { crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #1262: the two link toggles differ only by their icon, so each option toggle needs its own. + #[test] + fn option_toggles_have_distinct_icons() { + let toggles = option_toggles(&EditState::default()); + let timeline = toggles.iter().find(|t| t.0 == "options.link_timeline_edit").unwrap(); + let track = toggles.iter().find(|t| t.0 == "options.link_track_edit").unwrap(); + assert_ne!(timeline.1, track.1, "the timeline and track links need different icons"); + let mut icons: Vec<&str> = toggles.iter().map(|t| t.1).collect(); + icons.sort_unstable(); + icons.dedup(); + assert_eq!(icons.len(), toggles.len(), "every option toggle needs its own icon"); + } +} From 4b06407daeac87e2f9c69edd9e4f31a1e1dfafcf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:57:40 +0100 Subject: [PATCH 05/26] Keep the edit cursor as the zoom reference when it is off-screen Fixes #1260 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/view.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index b214a81f..869bb7b6 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -96,14 +96,22 @@ pub fn specs() -> Vec { const MIN_SPP: f64 = 0.25; const MAX_SPP: f64 = 1_000_000.0; +/// Pixel column an off-screen edit cursor is brought to when zooming (the engine does not know the viewport width). +const OFFSCREEN_CURSOR_PX: f64 = 500.0; fn zoom(e: &mut Engine, f: f64, x: Option) -> Result { let insertion = e.session().edit.selection.start as f64; let z = e.zoom_mut(); let (old, scroll) = (z.samples_per_px, z.scroll as f64); let new = (old * f).clamp(MIN_SPP, MAX_SPP); - if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) { + // Without a pointer, the edit cursor is the reference: held in place when visible, centred when off-screen. + let cursor_x = (insertion - scroll) / old; + if let Some(x) = x { z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION); + } else if (0.0..4000.0).contains(&cursor_x) { + z.scroll = soundcraft_time::to_samples(scroll + cursor_x * (old - new)).clamp(0, MAX_POSITION); + } else { + z.scroll = soundcraft_time::to_samples(insertion - OFFSCREEN_CURSOR_PX * new).clamp(0, MAX_POSITION); } z.samples_per_px = new; Ok(json!({"samples_per_px": new, "scroll": z.scroll})) @@ -186,6 +194,17 @@ mod tests { assert_eq!(view(&e), (100.0, 48_000)); } + #[test] + fn zoom_in_brings_an_offscreen_edit_cursor_into_view() { + let mut e = Engine::default(); + e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap(); + e.execute("edit.select", &json!({"start": 1_000_000, "end": 1_000_000})).unwrap(); + e.execute("view.zoom_in", &json!({})).unwrap(); + let (spp, scroll) = view(&e); + let x = (1_000_000.0 - scroll as f64) / spp; + assert!((0.0..2000.0).contains(&x), "cursor at {x}px"); + } + #[test] fn zoom_and_scroll_stay_in_range() { let mut e = Engine::default(); From 576ff36f615a30a4e5a920b8af74b2f07c9046c0 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:34:32 -0300 Subject: [PATCH 06/26] Tell hosted plugins when they render offline Bounces ran VST3 plugins with processMode kRealtime and never set clap.render, so plugins that work differently offline never could. Offline renders now create their plugins with Plugin::set_offline(true): VST3 plugins get kOffline in setupProcessing and in every process call, and CLAP plugins with clap.render are set to CLAP_RENDER_OFFLINE. Playback stays realtime. Fixes #1289. --- crates/clap-host/src/ffi.rs | 1 + crates/clap-host/src/ffi/bundle.rs | 3 +++ crates/clap-host/src/ffi/instance.rs | 12 ++++++++++ crates/clap-host/src/plugin.rs | 5 ++++ crates/clap-host/tests/fixture/src/lib.rs | 28 +++++++++++++++++++---- crates/clap-host/tests/it/fixture_e2e.rs | 13 +++++++++++ crates/dsp/src/lib.rs | 5 ++++ crates/mix/src/instances.rs | 16 +++++++++++++ crates/mix/src/lib.rs | 11 ++++++++- crates/mix/src/offline.rs | 3 ++- crates/mix/src/strip.rs | 6 +++-- crates/vst3-host/src/ffi/instance.rs | 18 +++++++++------ crates/vst3-host/src/ffi/module.rs | 1 + crates/vst3-host/src/plugin.rs | 5 ++++ crates/vst3-host/tests/fixture/src/lib.rs | 19 +++++++++------ crates/vst3-host/tests/it/fixture_e2e.rs | 12 ++++++++++ 16 files changed, 135 insertions(+), 23 deletions(-) diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 8ae25c52..1cbbfac7 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -27,6 +27,7 @@ use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_p use clap_sys::ext::params::{ CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params, }; +use clap_sys::ext::render::{CLAP_EXT_RENDER, CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render}; use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state}; use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory}; use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR}; diff --git a/crates/clap-host/src/ffi/bundle.rs b/crates/clap-host/src/ffi/bundle.rs index 503b6391..5f203e49 100644 --- a/crates/clap-host/src/ffi/bundle.rs +++ b/crates/clap-host/src/ffi/bundle.rs @@ -7,6 +7,7 @@ use super::{ clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan, }; +use super::{CLAP_EXT_RENDER, clap_plugin_render}; // ---- loaded binaries ----------------------------------------------------------------------- @@ -178,6 +179,7 @@ impl Bundle { latency: std::ptr::null(), notes: std::ptr::null(), state_ext: std::ptr::null(), + render: std::ptr::null(), gui: Arc::new(GuiLink::default()), raw: Vec::with_capacity(EVENT_CAP), out_params: Vec::with_capacity(EVENT_CAP), @@ -205,6 +207,7 @@ impl Bundle { inst.latency = inst.extension::(CLAP_EXT_LATENCY); inst.notes = inst.extension::(CLAP_EXT_NOTE_PORTS); inst.state_ext = inst.extension::(CLAP_EXT_STATE); + inst.render = inst.extension::(CLAP_EXT_RENDER); let gui = inst.extension::(CLAP_EXT_GUI); inst.gui = Arc::new(GuiLink { inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }), diff --git a/crates/clap-host/src/ffi/instance.rs b/crates/clap-host/src/ffi/instance.rs index 3a537fb6..3ecd3a95 100644 --- a/crates/clap-host/src/ffi/instance.rs +++ b/crates/clap-host/src/ffi/instance.rs @@ -8,6 +8,7 @@ use super::{ clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process, read_name, }; +use super::{CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render}; // ---- audio buffers ------------------------------------------------------------------------- @@ -99,6 +100,7 @@ pub(crate) struct Instance { pub(super) latency: *const clap_plugin_latency, pub(super) notes: *const clap_plugin_note_ports, pub(super) state_ext: *const clap_plugin_state, + pub(super) render: *const clap_plugin_render, pub(super) gui: Arc, pub(super) raw: Vec, /// Parameter values the plugin reported through output events in the current `process`. @@ -158,6 +160,16 @@ impl Instance { unsafe { load(self.plugin, &stream) } } + /// `clap_plugin_render.set`: offline (no realtime pressure) or realtime. `false` without the + /// extension or when the plugin can't switch. + pub fn set_render_mode(&self, offline: bool) -> bool { + // SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin. + let Some(set) = (unsafe { self.render.as_ref() }).and_then(|r| r.set) else { return false }; + let mode = if offline { CLAP_RENDER_OFFLINE } else { CLAP_RENDER_REALTIME }; + // SAFETY: valid instance, called from the thread that owns it. + unsafe { set(self.plugin, mode) } + } + /// Runs `on_main_thread` if the plugin asked for a callback. pub fn service_callback(&mut self) { if self.state.callback.swap(false, Ordering::Relaxed) diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 8e048e2a..31fbc20e 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -178,6 +178,11 @@ impl Plugin for ClapPlugin { } } + fn set_offline(&mut self, offline: bool) { + // `clap.render`: plugins without it render the same way either way. + self.inst.set_render_mode(offline); + } + fn reset(&mut self) { self.inst.reset(); self.queue.retain(|e| matches!(e, Event::Param { .. })); diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs index 10611670..b31028cc 100644 --- a/crates/clap-host/tests/fixture/src/lib.rs +++ b/crates/clap-host/tests/fixture/src/lib.rs @@ -9,7 +9,8 @@ //! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts //! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction). //! `process` writes 1.0 into every output channel it was given when the host's buffers match the -//! advertised layout, -1.0 when they do not. +//! advertised layout (2.0 once the host set the offline render mode through `clap.render`), -1.0 +//! when they do not. //! //! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a //! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI, @@ -27,6 +28,7 @@ use clap_sys::ext::latency::*; use clap_sys::ext::log::*; use clap_sys::ext::note_ports::*; use clap_sys::ext::params::*; +use clap_sys::ext::render::*; use clap_sys::factory::plugin_factory::*; use clap_sys::host::clap_host; use clap_sys::id::clap_id; @@ -102,6 +104,8 @@ struct Fx { gui_created: bool, gui_visible: bool, gui_edit_pending: bool, + /// `clap.render` set the offline mode (wide fixtures only). + offline: bool, } unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx { @@ -189,8 +193,8 @@ fn apply(f: &mut Fx, h: &clap_event_header) { } } -/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0. -unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) { +/// Fill every output channel with `fit` when all ports have the wide layout, else -1.0. +unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize, fit: f32) { let fits = |bufs: *const clap_audio_buffer, n: u32| { n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels) }; @@ -200,7 +204,7 @@ unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) { for c in 0..out.channel_count as usize { let o = unsafe { *out.data32.add(c) }; for i in 0..n { - unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } }; + unsafe { *o.add(i) = if ok { fit } else { -1.0 } }; } } } @@ -252,7 +256,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> let ev = unsafe { &*pr.in_events }; let count = unsafe { (ev.size.unwrap())(pr.in_events) }; if let Some((ports, channels)) = f.wide { - unsafe { fill_wide(pr, ports, channels, n) }; + unsafe { fill_wide(pr, ports, channels, n, if f.offline { 2.0 } else { 1.0 }) }; return CLAP_PROCESS_CONTINUE; } if pr.audio_outputs_count != 1 { @@ -543,6 +547,17 @@ static GUI: clap_plugin_gui = clap_plugin_gui { hide: Some(gui_hide), }; +unsafe extern "C" fn render_hard_realtime(_p: *const clap_plugin) -> bool { + false +} + +unsafe extern "C" fn render_set(p: *const clap_plugin, mode: clap_plugin_render_mode) -> bool { + unsafe { fx(p) }.offline = mode == CLAP_RENDER_OFFLINE; + true +} + +static RENDER: clap_plugin_render = clap_plugin_render { has_hard_realtime_requirement: Some(render_hard_realtime), set: Some(render_set) }; + unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> *const c_void { let id = unsafe { CStr::from_ptr(id) }; let synth = unsafe { fx(p) }.synth; @@ -558,6 +573,8 @@ unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> &STATE as *const _ as *const c_void } else if id == CLAP_EXT_GUI && !synth { &GUI as *const _ as *const c_void + } else if id == CLAP_EXT_RENDER && unsafe { fx(p) }.wide.is_some() { + &RENDER as *const _ as *const c_void } else { std::ptr::null() } @@ -623,6 +640,7 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const gui_created: false, gui_visible: false, gui_edit_pending: false, + offline: false, }); let raw = Box::into_raw(b); unsafe { diff --git a/crates/clap-host/tests/it/fixture_e2e.rs b/crates/clap-host/tests/it/fixture_e2e.rs index f531db54..1c2fcfea 100644 --- a/crates/clap-host/tests/it/fixture_e2e.rs +++ b/crates/clap-host/tests/it/fixture_e2e.rs @@ -130,6 +130,19 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() { } } +#[test] +fn offline_renders_set_the_render_mode() { + fixture_dir(); + for (offline, want) in [(false, 1.0), (true, 2.0)] { + let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap(); + p.set_offline(offline); + p.prepare(44_100.0, 64, 2); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]); + } +} + #[test] fn many_instances_create_and_drop_cleanly() { fixture_dir(); diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 5b17a590..3c073ec0 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -195,6 +195,11 @@ pub struct Transport { pub trait Plugin: Send { fn info(&self) -> &'static PluginInfo; fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize); + /// The plugin renders offline (a bounce: faster or slower than realtime, no deadline) rather + /// than in realtime. Hosted plugins pass it on (VST3 `processMode`, CLAP `clap.render`): some + /// use better algorithms then, or wait for what they load instead of playing on without it. + /// Called before `prepare`; plugins are realtime until told otherwise. + fn set_offline(&mut self, _offline: bool) {} fn reset(&mut self); /// Sets a parameter (clamped into range; NaN becomes the default). `false` if `id` is unknown. fn set_param(&mut self, id: &str, value: f32) -> bool; diff --git a/crates/mix/src/instances.rs b/crates/mix/src/instances.rs index 8bb5dbf9..fdf67d86 100644 --- a/crates/mix/src/instances.rs +++ b/crates/mix/src/instances.rs @@ -68,7 +68,23 @@ pub struct NewInstance { /// Creates a plugin (built-in or hosted), restores `state` (if any) and prepares it. Expensive /// for third-party plugins: in realtime use, call it on the control thread. pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option { + create_instance_for(id, state, sample_rate, max_block, ch, false) +} + +/// [`create_instance`] for an engine that renders offline (`offline`) or in realtime: the plugin +/// is told which before it is prepared ([`Plugin::set_offline`]). +pub(crate) fn create_instance_for( + id: &str, + state: Option<&[u8]>, + sample_rate: f32, + max_block: usize, + ch: usize, + offline: bool, +) -> Option { let mut plugin = create_plugin(id)?; + if offline { + plugin.set_offline(true); + } let refused_state = state.is_some_and(|st| !plugin.load_state(st)); if refused_state { log::warn!("{id}: the plugin did not accept its stored state"); diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index a671d0b6..7d6442aa 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -144,6 +144,8 @@ pub struct MixEngine { pub metronome: bool, /// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`. external: bool, + /// An offline render: the plugins `sync` creates are told so ([`Plugin::set_offline`]). + offline: bool, /// Instances handed over by the host, waiting for `sync` to place them. adopt: HashMap<(TrackId, usize), PreparedInstance>, /// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]). @@ -177,6 +179,7 @@ impl MixEngine { recording: false, metronome: false, external: false, + offline: false, adopt: HashMap::new(), retired: Vec::new(), work: Vec::new(), @@ -189,6 +192,11 @@ impl MixEngine { self.external = on; } + /// Offline render (a bounce): plugins created from now on are told they render offline. + pub fn set_offline(&mut self, on: bool) { + self.offline = on; + } + /// Takes instances created off the audio thread; the next render's `sync` places them. /// An earlier instance waiting for the same slot is retired. pub fn adopt(&mut self, instances: Vec) { @@ -271,7 +279,8 @@ impl MixEngine { if self.main.len() != main_ch { self.main = vec![vec![0.0; mb]; main_ch]; } - let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired }; + let mut cx = + SlotCtx { sr: self.sample_rate, mb, external: self.external, offline: self.offline, adopt: &mut self.adopt, retired: &mut self.retired }; let gone: Vec = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect(); for id in gone { self.meters.remove(&id); diff --git a/crates/mix/src/offline.rs b/crates/mix/src/offline.rs index d544b0fc..fd80f097 100644 --- a/crates/mix/src/offline.rs +++ b/crates/mix/src/offline.rs @@ -57,7 +57,8 @@ impl RangeRender { /// Renders the main mix over `range`, or `bus` when one is named. pub fn new(s: &Session, bus: Option, range: Range, block: usize) -> Self { let nch = source_channels(s, bus); - let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + eng.set_offline(true); let tmp = vec![vec![0.0f32; eng.max_block()]; nch]; let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) }; RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 } diff --git a/crates/mix/src/strip.rs b/crates/mix/src/strip.rs index 01f4a11a..71acec45 100644 --- a/crates/mix/src/strip.rs +++ b/crates/mix/src/strip.rs @@ -1,6 +1,6 @@ use super::automation::{GainRamp, transport_at, volume_db_at}; use super::clips::{ClipFx, clip_fx_params, render_clip}; -use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance, is_third_party}; +use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party}; use super::midi::{chase_controllers, schedule_notes}; use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix}; use super::routing::receives_solo; @@ -157,6 +157,8 @@ pub(crate) struct SlotCtx<'a> { pub(crate) sr: f32, pub(crate) mb: usize, pub(crate) external: bool, + /// The engine renders offline: plugins it creates are told so. + pub(crate) offline: bool, pub(crate) adopt: &'a mut HashMap<(TrackId, usize), PreparedInstance>, pub(crate) retired: &'a mut Vec>, } @@ -189,7 +191,7 @@ impl SlotCtx<'_> { } } else { let state = if third { ins.state_bytes() } else { None }; - create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin) + create_instance_for(&ins.plugin, state.as_deref(), self.sr, self.mb, ch, self.offline).map(|n| n.plugin) }?; Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false }) } diff --git a/crates/vst3-host/src/ffi/instance.rs b/crates/vst3-host/src/ffi/instance.rs index c7d783e0..4bbad925 100644 --- a/crates/vst3-host/src/ffi/instance.rs +++ b/crates/vst3-host/src/ffi/instance.rs @@ -75,6 +75,8 @@ pub(crate) struct Instance { pub(super) active: bool, pub(super) processing: bool, pub(super) max_frames: usize, + /// `kRealtime` or `kOffline`, for `setupProcessing` and every `process` call. + pub(super) process_mode: i32, pub(super) in_params: ComWrapper, pub(super) out_params: ComWrapper, pub(super) in_events: ComWrapper, @@ -251,6 +253,12 @@ impl Instance { self.active } + /// Offline (`kOffline`: no realtime pressure, e.g. a bounce) or realtime processing, from the + /// next [`Instance::configure`] on. + pub fn set_offline(&mut self, offline: bool) { + self.process_mode = if offline { sv::ProcessModes_::kOffline } else { sv::ProcessModes_::kRealtime } as i32; + } + fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 { // SAFETY: valid component. unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.max(0) @@ -359,12 +367,8 @@ impl Instance { return Err(Vst3Error::Process("plugin cannot process 32-bit float".into())); } let max = i32::try_from(max_frames).unwrap_or(i32::MAX); - let mut setup = sv::ProcessSetup { - processMode: sv::ProcessModes_::kRealtime as i32, - symbolicSampleSize: sample32, - maxSamplesPerBlock: max, - sampleRate: sample_rate, - }; + let mut setup = + sv::ProcessSetup { processMode: self.process_mode, symbolicSampleSize: sample32, maxSamplesPerBlock: max, sampleRate: sample_rate }; // SAFETY: valid processor, inactive; `setup` is a valid struct for the call. if !ok(unsafe { p.setupProcessing(&mut setup) }) { return Err(Vst3Error::Process("setupProcessing failed".into())); @@ -450,7 +454,7 @@ impl Instance { None => self.context.projectTimeSamples = project_time, } let mut data = sv::ProcessData { - processMode: sv::ProcessModes_::kRealtime as i32, + processMode: self.process_mode, symbolicSampleSize: sv::SymbolicSampleSizes_::kSample32 as i32, numSamples: i32::try_from(frames).unwrap_or(0), numInputs: i32::try_from(inputs.bufs.len()).unwrap_or(0), diff --git a/crates/vst3-host/src/ffi/module.rs b/crates/vst3-host/src/ffi/module.rs index 61e415b4..8ad94235 100644 --- a/crates/vst3-host/src/ffi/module.rs +++ b/crates/vst3-host/src/ffi/module.rs @@ -449,6 +449,7 @@ impl Bundle { active: false, processing: false, max_frames: 0, + process_mode: sv::ProcessModes_::kRealtime as i32, in_params: ComWrapper::new(ParamChanges::new(PARAM_QUEUE_CAP)), out_params: ComWrapper::new(ParamChanges::new(PARAM_QUEUE_CAP)), in_events: ComWrapper::new(EventList::new(EVENT_CAP)), diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 726ad98b..2999c1ab 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -259,6 +259,11 @@ impl Plugin for Vst3Plugin { } } + fn set_offline(&mut self, offline: bool) { + // `processMode`, set up by the next `prepare`. + self.inst.set_offline(offline); + } + fn reset(&mut self) { // Keep pending releases and release whatever is still held, so the processor's voices stop // on the next block instead of sounding on while the host forgets them. diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index d6af5332..9d9323cb 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -14,8 +14,9 @@ //! - "Fixture Wide 16x64", "Fixture Wide 32 buses", "Fixture Wide 65 buses", "Fixture Wide 65 //! channels" (`Fx`): effects advertising wide layouts (16 buses of 64 channels, 32 and 65 buses //! of 2 channels, one bus of 65 channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel -//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not. -//! Report 3 samples of latency. +//! of output bus 0 when the host's buffers match the advertised layout (2.0 when the host set it +//! up and runs it with `kOffline`), -1.0 when they do not or when `process` gets another +//! `processMode` than `setupProcessing` did. Report 3 samples of latency. //! //! The module checks the host's module lifecycle the way real plugins depend on it (see "module //! lifecycle" below) and aborts the process when it is broken. @@ -25,7 +26,7 @@ #![allow(non_snake_case, clippy::missing_safety_doc)] use std::ffi::{c_char, c_int, c_void}; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use vst3::Steinberg::Vst::*; use vst3::Steinberg::*; use vst3::{Class, ComRef, ComWrapper, uid}; @@ -640,6 +641,8 @@ impl IEditControllerTrait for Synth { struct Wide { buses: i32, channels: i32, + /// The `processMode` of the last `setupProcessing`. + mode: AtomicI32, _live: Live, } @@ -704,7 +707,8 @@ impl IAudioProcessorTrait for Wide { unsafe fn getLatencySamples(&self) -> u32 { 3 } - unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult { + unsafe fn setupProcessing(&self, setup: *mut ProcessSetup) -> tresult { + self.mode.store(unsafe { &*setup }.processMode, Ordering::Relaxed); kResultOk } unsafe fn setProcessing(&self, _state: TBool) -> tresult { @@ -714,10 +718,11 @@ impl IAudioProcessorTrait for Wide { let d = unsafe { &*data }; let fits = |bufs: *mut AudioBusBuffers, n: i32| n == self.buses && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.numChannels == self.channels); - let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs); + let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs) && d.processMode == self.mode.load(Ordering::Relaxed); + let fit = if d.processMode == ProcessModes_::kOffline as i32 { 2.0 } else { 1.0 }; if d.numOutputs > 0 { for ch in unsafe { channels(&*d.outputs, d.numSamples.max(0) as usize) } { - ch.fill(if ok { 1.0 } else { -1.0 }); + ch.fill(if ok { fit } else { -1.0 }); } } kResultOk @@ -791,7 +796,7 @@ impl IPluginFactoryTrait for Factory { .into_iter() .find_map(|(c, layout)| (c == cid).then_some(layout)) { - ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::() + ComWrapper::new(Wide { buses, channels, mode: AtomicI32::new(-1), _live: Live::new(&OBJECTS) }).to_com_ptr::() } else { None }; diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs index e24d6001..3be404fe 100644 --- a/crates/vst3-host/tests/it/fixture_e2e.rs +++ b/crates/vst3-host/tests/it/fixture_e2e.rs @@ -331,6 +331,18 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() { } } +#[test] +fn offline_renders_set_up_and_process_in_offline_mode() { + for (offline, want) in [(false, 1.0), (true, 2.0)] { + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 16x64")).unwrap(); + p.set_offline(offline); + p.prepare(44_100.0, 64, 2); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]); + } +} + #[test] fn many_buses_like_multi_out_instruments_are_activated() { // Multi-out instruments declare dozens of buses (SSD Sampler 5: 32); only the main one is From 04dab602c7cd7f1ee1645919a238568e12ea7d02 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:38:53 +0100 Subject: [PATCH 07/26] A new send opens its Send window, so its fader is on screen right away (#1269) Picking a destination for an empty send slot, or making one with new bus..., now opens that send's Send window, as in Pro Tools. Moving an existing send to another bus leaves the window as it was. --- crates/ui-egui/src/mix_window/sends.rs | 86 +++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index 87cac59d..dc338527 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -388,8 +388,15 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac }, ); let Some((picked, footer)) = shown else { return }; + // As in Pro Tools, a new send opens its Send window, so its fader is on screen right away. + let created = |app: &mut SoundApp, sent: bool| { + if sent && !has_send { + app.ui.send_window = Some((id, slot)); + } + }; if let Some(bus) = picked { - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus})); + let sent = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus})).is_ok(); + created(app, sent); } match footer { Some(SendFooter::NewBus) => { @@ -400,7 +407,8 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac n += 1; } if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() { - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + let sent = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})).is_ok(); + created(app, sent); } } Some(SendFooter::RemoveSend) => { @@ -464,4 +472,78 @@ mod tests { assert_eq!(field(&h), search, "the search field stays put"); assert!((footer(&h).top() - full.top()).abs() < 1.0, "cleared, the footer is back under the full list: {:?} vs {full:?}", footer(&h)); } + + /// A send slot drawn in a test harness, for the track named "Kick", with an "Aux" bus to pick. + fn kick_send_harness(send_first: bool) -> (Harness<'static, (SoundApp, TrackId)>, TrackId) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("mix.new_bus", json!({"name": "Aux"})).unwrap(); + app.run("mix.new_bus", json!({"name": "Other"})).unwrap(); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + if send_first { + app.run("mix.send", json!({"track": kick.0, "slot": 0, "bus": "Other"})).unwrap(); + } + let mut ready = false; + let h = Harness::builder().with_size(vec2(600.0, 800.0)).build_ui_state( + move |ui, (app, kick): &mut (SoundApp, TrackId)| { + if !ready { + crate::fonts::install(ui.ctx(), true); + crate::theme::apply(ui.ctx(), false); + ready = true; + return; + } + let track = app.engine.session().track(*kick).cloned().unwrap(); + let r = Rect::from_min_size(ui.max_rect().min + vec2(20.0, 20.0), vec2(120.0, 24.0)); + send_slot(app, ui, &track, 0, r); + }, + (app, kick), + ); + (h, kick) + } + + /// Creating a send opens its Send window, so the new send's fader is on screen right away (#1269). + #[test] + fn creating_a_send_opens_its_send_window() { + let _palette = crate::theme::lock_test_palette(); + let (mut h, kick) = kick_send_harness(false); + h.run(); + h.get_by_label("Send A for Kick").click(); + h.run(); + h.get_by_label("Aux").click(); + h.run(); + let (app, _) = h.state(); + let send = app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten()); + assert!(send.is_some(), "the send was created"); + assert_eq!(app.ui.send_window, Some((kick, 0)), "the new send's window is open"); + } + + /// So does a send made with "new bus...". + #[test] + fn a_send_to_a_new_bus_opens_its_send_window() { + let _palette = crate::theme::lock_test_palette(); + let (mut h, kick) = kick_send_harness(false); + h.run(); + h.get_by_label("Send A for Kick").click(); + h.run(); + h.get_by_label("new bus...").click(); + h.run(); + let (app, _) = h.state(); + assert!(app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten()).is_some(), "the send was created"); + assert_eq!(app.ui.send_window, Some((kick, 0)), "the new send's window is open"); + } + + /// Moving an existing send to another bus keeps the Send window as it was. + #[test] + fn changing_a_sends_destination_leaves_the_send_window_alone() { + let _palette = crate::theme::lock_test_palette(); + let (mut h, kick) = kick_send_harness(true); + h.run(); + h.get_by_label("Send A for Kick").click(); + h.run(); + h.get_by_label("Aux").click(); + h.run(); + let (app, _) = h.state(); + let target = app.engine.session().track(kick).and_then(|t| t.mixer.sends.first().cloned().flatten()).map(|s| route_name(app, &s.target)); + assert_eq!(target.as_deref(), Some("Aux"), "the send moved"); + assert_eq!(app.ui.send_window, None, "no window opened"); + } } From 36d42e01369f427e8c00fe6da771e7d1aa4d4362 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:55:52 +0100 Subject: [PATCH 08/26] Test a new send's Send window end to end, and tidy send_route_menu An integration test creates send C while send A's window is open: the window moves to the new send with its fader on screen, and undoing the send closes it. send_route_menu now opens the window only after the send command succeeds, without the extra bool, and its comment says reassignment leaves the window alone. --- crates/ui-egui/src/mix_window/sends.rs | 28 +++++++++++++----------- crates/ui-egui/tests/it/send_window.rs | 30 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index dc338527..90aca765 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -388,15 +388,17 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac }, ); let Some((picked, footer)) = shown else { return }; - // As in Pro Tools, a new send opens its Send window, so its fader is on screen right away. - let created = |app: &mut SoundApp, sent: bool| { - if sent && !has_send { + // As in Pro Tools, a new send opens its Send window (moving it from any other send), so its + // fader is on screen right away. A send that only changes destination leaves the window alone. + let show_new_send = |app: &mut SoundApp| { + if !has_send { app.ui.send_window = Some((id, slot)); } }; - if let Some(bus) = picked { - let sent = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus})).is_ok(); - created(app, sent); + if let Some(bus) = picked + && app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus})).is_ok() + { + show_new_send(app); } match footer { Some(SendFooter::NewBus) => { @@ -406,9 +408,10 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac { n += 1; } - if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() { - let sent = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})).is_ok(); - created(app, sent); + if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() + && app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})).is_ok() + { + show_new_send(app); } } Some(SendFooter::RemoveSend) => { @@ -473,13 +476,14 @@ mod tests { assert!((footer(&h).top() - full.top()).abs() < 1.0, "cleared, the footer is back under the full list: {:?} vs {full:?}", footer(&h)); } - /// A send slot drawn in a test harness, for the track named "Kick", with an "Aux" bus to pick. - fn kick_send_harness(send_first: bool) -> (Harness<'static, (SoundApp, TrackId)>, TrackId) { + /// Kick's send A slot in a test harness, with buses "Aux" and "Other"; with `existing_send`, the + /// slot already sends to "Other". + fn kick_send_harness(existing_send: bool) -> (Harness<'static, (SoundApp, TrackId)>, TrackId) { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); app.run("mix.new_bus", json!({"name": "Aux"})).unwrap(); app.run("mix.new_bus", json!({"name": "Other"})).unwrap(); let kick = app.engine.session().track_by_name("Kick").unwrap().id; - if send_first { + if existing_send { app.run("mix.send", json!({"track": kick.0, "slot": 0, "bus": "Other"})).unwrap(); } let mut ready = false; diff --git a/crates/ui-egui/tests/it/send_window.rs b/crates/ui-egui/tests/it/send_window.rs index ac3a6dad..30a2d7bd 100644 --- a/crates/ui-egui/tests/it/send_window.rs +++ b/crates/ui-egui/tests/it/send_window.rs @@ -1,5 +1,6 @@ //! A send's level opens its Send window, as in Pro Tools: a fader with Alt-click for 0 dB, moved to -//! another send by clicking that send's level, closed by clicking the same one again. +//! another send by clicking that send's level, closed by clicking the same one again. Creating a send +//! opens it too. use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::Harness; @@ -75,6 +76,33 @@ fn a_sends_level_opens_its_send_window() { assert!(h.query_by_label_contains("Vox · Send").is_none()); } +/// Creating a send opens its Send window, so the new send's fader is on screen right away (#1269). The +/// window moves there from the send it was showing, and undoing the new send closes it. +#[test] +fn a_new_send_shows_its_fader_at_once() { + let (mut h, vox) = harness(); + h.get_by_label("Send A level for Vox").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("Vox · Send A").is_some()); + + h.get_by_label("Send C for Vox").click(); + h.run_steps(3); + // Send A's own "Verb" label is on screen too, so pick the menu's button. + h.get_by_role_and_label(egui::accesskit::Role::Button, "Verb").click(); + h.run_steps(3); + assert!(h.state().engine.session().tracks[0].mixer.sends[2].is_some(), "send C was created"); + assert_eq!(h.state().ui.send_window, Some((vox, 2))); + assert!(h.query_by_label_contains("Vox · Send C").is_some(), "the window shows the new send"); + assert!(h.query_by_label_contains("Vox · Send A").is_none(), "and no longer send A"); + assert!(h.query_by_label("Send level").is_some(), "the new send's fader is on screen"); + + h.state_mut().engine.execute("edit.undo", &json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().engine.session().tracks[0].mixer.sends[2].is_none(), "the send was undone"); + assert_eq!(h.state().ui.send_window, None, "undoing the send closes its window"); + assert!(h.query_by_label_contains("Vox · Send").is_none()); +} + /// Escape first closes a send menu or cancels level typing, then a further Escape closes the send window. #[test] fn escape_closes_the_send_window_after_menus_and_typing() { From b0793ca8755aeba086ecfc94b86899f9a795b38b Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 23:36:15 -0300 Subject: [PATCH 09/26] Stamp WAV bounces with their timecode position (BWF bext) Bounces passed no bwf to the encoder, so mix and stem WAVs had no bext chunk and post tools placed them at zero. Bounce::new now stamps them with TimeReference = the session's timecode start plus the bounce start, in samples since midnight, as other DAWs do. A caller's own bwf wins; AIFF and FLAC have no bext and are unchanged. Fixes #1296. --- crates/engine/src/io.rs | 10 ++++++++++ crates/engine/tests/it/bounce.rs | 32 ++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 60d9a323..c4db15a6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -178,6 +178,13 @@ pub struct Bounce { enc: Option>, } +/// The Broadcast Wave stamp of timeline position `at`: the session's timecode start plus `at`, in +/// samples since midnight (`bext` TimeReference; 0 before midnight). +fn time_stamp(s: &Session, at: Samples) -> soundcraft_audio_io::BwfInfo { + let time_reference = u64::try_from(s.timecode_start.saturating_add(at)).unwrap_or(0); + soundcraft_audio_io::BwfInfo { time_reference, ..Default::default() } +} + /// A finished bounce: the size of its file and its levels. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Bounced { @@ -208,6 +215,9 @@ impl Bounce { let part = path.with_file_name(format!("{name}.part")); crate::file_access::check_write(path).map_err(EngineError::Io)?; crate::file_access::check_write(&part).map_err(EngineError::Io)?; + // A WAV bounce says where it starts on the session's timecode, as other DAWs' bounces do, + // so post and broadcast tools can place it (only WAV has a `bext` chunk to say it in). + let opts = &EncodeOptions { bwf: opts.bwf.clone().or_else(|| Some(time_stamp(&s, r.start))), ..opts.clone() }; let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> { let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?; enc.check_room(u64::try_from(r.len()).unwrap_or(0))?; diff --git a/crates/engine/tests/it/bounce.rs b/crates/engine/tests/it/bounce.rs index 36630319..9460cea6 100644 --- a/crates/engine/tests/it/bounce.rs +++ b/crates/engine/tests/it/bounce.rs @@ -123,6 +123,38 @@ fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() { let _ = std::fs::remove_dir_all(&dir); } +/// A WAV bounce is a Broadcast Wave stamped with where it starts on the session's timecode (`bext` +/// TimeReference, samples since midnight), as other DAWs write it, so post tools can place it. +#[test] +fn wav_bounces_carry_their_timecode_position_as_a_bwf_time_reference() { + let mut e = soundcraft_engine::demo::demo_engine(); + let sr = u64::from(e.session().sample_rate.hz()); + // The session starts at 01:00:00:00; the bounce at 2 s into it. + e.session_mut().timecode_start = (3600 * sr) as i64; + let (start, end) = (2 * sr, 3 * sr); + let dir = scratch("bounce-bext"); + let stamp = |path: &std::path::Path| { + let ext = path.extension().and_then(|x| x.to_str()); + soundcraft_audio_io::probe(&std::fs::read(path).unwrap(), ext).unwrap().bwf.map(|b| b.time_reference) + }; + let mix = dir.join("Mix.wav"); + e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap(); + assert_eq!(stamp(&mix), Some(3600 * sr + start), "mix"); + let stems = dir.join("stems"); + let files = e.execute("file.bounce_stems", &serde_json::json!({"dir": stems, "start": start, "end": end})).unwrap(); + let files = files["files"].as_array().unwrap(); + assert!(!files.is_empty()); + for f in files { + let f = std::path::PathBuf::from(f.as_str().unwrap()); + assert_eq!(stamp(&f), Some(3600 * sr + start), "{}", f.display()); + } + // A session that starts at zero still says where the bounce starts. + e.session_mut().timecode_start = 0; + e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap(); + assert_eq!(stamp(&mix), Some(start)); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() { let e = soundcraft_engine::demo::demo_engine(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4d9009be..6f9a7b9d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-02 · **Change:** trivial (bounces carry a BWF time reference, #1296; previously trivial: Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its @@ -33,7 +33,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Format | Pro Tools | Read | Write | Tests / notes | |---|---|---|---|---| -| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). | +| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). WAV bounces (mix and stems) carry a `bext` TimeReference: the session's timecode start plus the bounce start (#1296). | | RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` | | WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) | | AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native; `fl32` retains finite headroom (#791) | @@ -80,6 +80,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | WAV bounces (mix and stems) write a `bext` TimeReference: session timecode start plus bounce start (#1296) | | 2026-10-19 | trivial | WAV and AIFF-C Float32 encoders retain finite headroom (#791); whole-file, streaming and bounce regression tests | | 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) | | 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged | From 1403bbc4d3f8cb78ac9981231cab9c5e68172c4d Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 23:42:08 -0300 Subject: [PATCH 10/26] audiosuite.process: say it takes built-in plugin ids Its help said ``, but it only runs built-in plugins, and a hosted id (clap:, vst3:, au:) failed as "unknown process". The help now says built-in plugin id, and a hosted id gets an error saying AudioSuite runs built-in plugins only. Fixes #1301. --- crates/engine/src/cmd/audiosuite.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 0f7ba25f..2d6c446e 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -14,7 +14,7 @@ pub fn specs() -> Vec { "AudioSuite Process", [], None, - "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)", + "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)", has_selection, process )] @@ -55,6 +55,9 @@ fn process(e: &mut Engine, p: &Value) -> Result { | "speed_ramp" | "dc_offset_removal" ); + if !known && soundcraft_mix::is_third_party(&name) { + return Err(bad("audiosuite.process", format!("`{name}` is a hosted plugin; AudioSuite runs built-in plugins only (engine.plugins)"))); + } if !known { return Err(bad("audiosuite.process", format!("unknown process `{name}`"))); } @@ -228,6 +231,17 @@ mod tests { (crate::Engine::new(s), id) } + #[test] + fn hosted_plugin_ids_are_refused_with_the_reason() { + let (mut e, id) = gained_clip(); + for plugin in ["clap:com.example.reverb", "vst3:00112233445566778899AABBCCDDEEFF", "au:aufx:dely:appl"] { + let err = e.execute("audiosuite.process", &json!({"process": plugin, "clips": [id.0]})).unwrap_err().to_string(); + assert!(err.contains("built-in"), "{plugin}: {err}"); + } + let spec = specs().into_iter().find(|s| s.id == "audiosuite.process").unwrap(); + assert!(spec.params.contains(" Date: Mon, 2 Nov 2026 23:48:01 -0300 Subject: [PATCH 11/26] soundcraft-cli: print logged warnings and errors to stderr The CLI installed no logger, so every log::warn! and log::error! was dropped: a plugin that failed to load just vanished from engine.clap_plugins with no reason. A small logger now writes warnings and errors to standard error as `warning: ` and `error: `, like the CLI's own errors. The level is fixed: RUST_LOG stays a desktop app variable. Fixes #1304. --- Cargo.lock | 1 + apps/soundcraft-cli/Cargo.toml | 1 + apps/soundcraft-cli/src/lib.rs | 1 + apps/soundcraft-cli/src/logger.rs | 49 +++++++++++++++++++++++ apps/soundcraft-cli/src/main.rs | 1 + apps/soundcraft-cli/tests/it/behaviour.rs | 19 +++++++++ docs/cli.md | 2 +- 7 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 apps/soundcraft-cli/src/logger.rs diff --git a/Cargo.lock b/Cargo.lock index 45f00211..144046af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4374,6 +4374,7 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "log", "same-file", "serde", "serde_json", diff --git a/apps/soundcraft-cli/Cargo.toml b/apps/soundcraft-cli/Cargo.toml index 9316b5b8..37ba7649 100644 --- a/apps/soundcraft-cli/Cargo.toml +++ b/apps/soundcraft-cli/Cargo.toml @@ -25,6 +25,7 @@ soundcraft-dsp.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +log.workspace = true clap.workspace = true same-file.workspace = true diff --git a/apps/soundcraft-cli/src/lib.rs b/apps/soundcraft-cli/src/lib.rs index fe351e2e..f72c42a8 100644 --- a/apps/soundcraft-cli/src/lib.rs +++ b/apps/soundcraft-cli/src/lib.rs @@ -11,6 +11,7 @@ mod catalog; mod convert; mod error; mod info; +pub mod logger; mod mcp; mod session; diff --git a/apps/soundcraft-cli/src/logger.rs b/apps/soundcraft-cli/src/logger.rs new file mode 100644 index 00000000..317b44a0 --- /dev/null +++ b/apps/soundcraft-cli/src/logger.rs @@ -0,0 +1,49 @@ +//! The command line's logger. Without one, every `log::warn!` and `log::error!` in the workspace +//! was dropped: a plugin that failed to load just vanished from `engine.clap_plugins`, and nothing +//! said why. Warnings and errors now go to standard error as `warning: ` and +//! `error: `, like the CLI's own errors; standard output keeps only the results. +//! `RUST_LOG` stays a desktop app variable (docs/cli.md), so the level is fixed. + +use std::io::Write; + +struct StderrLogger; + +impl log::Log for StderrLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Warn + } + + fn log(&self, record: &log::Record<'_>) { + if self.enabled(record.metadata()) { + // A closed standard error is not worth failing a command over. + let _ = std::io::stderr().write_all(line(record.level(), &record.args().to_string()).as_bytes()); + } + } + + fn flush(&self) {} +} + +/// One record as the CLI prints it. +fn line(level: log::Level, message: &str) -> String { + let label = if level == log::Level::Error { "error" } else { "warning" }; + format!("{label}: {}\n", message.strip_suffix('\n').unwrap_or(message)) +} + +/// Installs the logger. Once per process: `main` calls it, not `run`, which tests call many times. +pub fn install() { + static LOGGER: StderrLogger = StderrLogger; + if log::set_logger(&LOGGER).is_ok() { + log::set_max_level(log::LevelFilter::Warn); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_print_like_the_cli_errors() { + assert_eq!(line(log::Level::Warn, "cannot load CLAP plugin x.clap: bad file\n"), "warning: cannot load CLAP plugin x.clap: bad file\n"); + assert_eq!(line(log::Level::Error, "panic: boom"), "error: panic: boom\n"); + } +} diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 7d381be7..e29c38f4 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -3,6 +3,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] fn main() { + soundcraft_cli::logger::install(); // `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed. let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr()); // The subcommand's engine and its plugin instances are gone by now. This is process teardown, diff --git a/apps/soundcraft-cli/tests/it/behaviour.rs b/apps/soundcraft-cli/tests/it/behaviour.rs index 69bdffa9..52c5eaeb 100644 --- a/apps/soundcraft-cli/tests/it/behaviour.rs +++ b/apps/soundcraft-cli/tests/it/behaviour.rs @@ -88,6 +88,25 @@ fn assert_refused(got: &Invoke, port: u16) { assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr); } +// ---- logging ---- + +/// Warnings SoundCraft logs (a plugin that fails to load, a backup it can't write) go to standard +/// error. Without a logger they were dropped. +#[test] +fn logged_warnings_reach_standard_error() { + let dir = sandbox("log"); + let first = invoke(&dir, &["run", "--save", "s.scraft"]); + assert_eq!(first.code, 0, "{}", first.stderr); + // A file where the backups folder should go: the next save that changes the session can't + // back the old one up. + std::fs::write(dir.join("Backups"), b"not a folder").unwrap(); + let again = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", r#"track.new={"count":1}"#, "--save", "s.scraft", "--in-place"]); + assert_eq!(again.code, 0, "{}", again.stderr); + assert!(again.stderr.contains("warning: no backup of"), "{}", again.stderr); + assert!(!again.stdout.contains("no backup"), "results only on standard output: {}", again.stdout); + let _ = std::fs::remove_dir_all(&dir); +} + // ---- unknown flags ---- #[test] diff --git a/docs/cli.md b/docs/cli.md index eb94ab74..28d36fcd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -38,7 +38,7 @@ Results go to standard output, as JSON where a program is meant to read them. `i file), `describe`, `app` and the `--inspect` output of `run` and `script` pretty-print it; `--compact` prints the same JSON on one line. The result lines of `run` and `script` (`ID: JSON`) and the `--bounce` and `--save` results are always one line each. `commands`, `plugins`, `parity` and the -`.scraft` summary of `info` are text. Errors go to standard error as `error: `. `mcp` writes only the protocol +`.scraft` summary of `info` are text. Errors go to standard error as `error: `, and so do the warnings and errors SoundCraft and the plugin hosts log (`warning: `, `error: `), such as a plugin that fails to load. `mcp` writes only the protocol to standard output. | Exit code | Meaning | From e4f0599a2a08ccdb81c821a5b33a8b7c51d3c7d4 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:19:13 -0300 Subject: [PATCH 12/26] Plugin hosts: keep the system's reason when a plugin binary fails to load libloading 0.9 prints only "dlopen failed" ("dlsym failed", "LoadLibraryExW failed") and keeps the dlerror text or the Windows error as the error's source. The CLAP and VST3 loaders formatted the error alone, so a plugin built for a newer glibc was reported as "dlopen failed" with no reason. Append the source. --- crates/clap-host/src/ffi/bundle.rs | 14 ++++++++++++-- crates/clap-host/src/lib.rs | 13 +++++++++++++ crates/vst3-host/src/ffi/module.rs | 16 +++++++++++++--- crates/vst3-host/src/lib.rs | 16 ++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/clap-host/src/ffi/bundle.rs b/crates/clap-host/src/ffi/bundle.rs index 503b6391..d16d6abf 100644 --- a/crates/clap-host/src/ffi/bundle.rs +++ b/crates/clap-host/src/ffi/bundle.rs @@ -42,6 +42,16 @@ fn loaded() -> &'static Mutex>> { L.get_or_init(|| Mutex::new(HashMap::new())) } +/// A libloading error with the system's reason. libloading's own text is only "dlopen failed" +/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its +/// source. +fn with_reason(e: &libloading::Error) -> String { + match std::error::Error::source(e) { + Some(why) => format!("{e}: {why}"), + None => e.to_string(), + } +} + impl Bundle { /// Loads (once per path, then cached for the life of the process) and initialises a `.clap`. pub fn load(path: &Path) -> Result, ClapError> { @@ -59,12 +69,12 @@ impl Bundle { let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?; // SAFETY: loading a shared library runs its static initialisers. That is inherent to // hosting plugins; we only load files found in CLAP plugin folders or named explicitly. - let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?; + let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?; // SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type // `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`. let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } { Ok(sym) => *sym, - Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))), + Err(e) => return Err(err(format!("not a CLAP plugin: {}", with_reason(&e)))), }; if entry.is_null() { return Err(err("null clap_entry".into())); diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index f6b93d13..5e99eb04 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -262,4 +262,17 @@ mod tests { assert!(instantiate("clap:does.not.exist").is_err()); let _ = std::fs::remove_dir_all(&d); } + + /// A binary the system refuses to load reports the system's reason (missing library, wrong + /// glibc, not an ELF file), not just "dlopen failed". + #[test] + fn a_binary_that_does_not_load_says_why() { + let d = fresh_private_dir("soundcraft-clap-reason"); + let f = d.join("garbage.clap"); + std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap(); + let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default(); + let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim()); + assert!(!reason.is_empty(), "no reason in: {msg}"); + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/crates/vst3-host/src/ffi/module.rs b/crates/vst3-host/src/ffi/module.rs index 61e415b4..fefe681c 100644 --- a/crates/vst3-host/src/ffi/module.rs +++ b/crates/vst3-host/src/ffi/module.rs @@ -137,6 +137,16 @@ mod cf { } } +/// A libloading error with the system's reason. libloading's own text is only "dlopen failed" +/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its +/// source. +fn with_reason(e: &libloading::Error) -> String { + match std::error::Error::source(e) { + Some(why) => format!("{e}: {why}"), + None => e.to_string(), + } +} + /// Opens the shared library and runs the platform module entry (`bundleEntry` with a /// `CFBundleRef` on macOS, `InitDll` on Windows, `ModuleEntry` with the `dlopen` handle on Linux). /// Also returns the matching module exit, when the entry ran and the module exports one. @@ -146,7 +156,7 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<(libloading::Library, Optio use libloading::os::unix::{Library as UnixLib, RTLD_LOCAL, RTLD_NOW}; // SAFETY: loading a shared library runs its static initialisers. That is inherent to // hosting plugins; we only load files found in VST3 plugin folders or named explicitly. - let lib = unsafe { UnixLib::open(Some(exe), RTLD_NOW | RTLD_LOCAL) }.map_err(|e| e.to_string())?; + let lib = unsafe { UnixLib::open(Some(exe), RTLD_NOW | RTLD_LOCAL) }.map_err(|e| with_reason(&e))?; type ModuleEntry = unsafe extern "C" fn(*mut c_void) -> bool; // SAFETY: `ModuleEntry` is the VST3-mandated Linux entry with this signature. let entry = unsafe { lib.get::(b"ModuleEntry\0") }.ok().map(|s| *s); @@ -170,7 +180,7 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<(libloading::Library, Optio { // SAFETY: loading a shared library runs its static initialisers. That is inherent to // hosting plugins; we only load files found in VST3 plugin folders or named explicitly. - let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| e.to_string())?; + let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| with_reason(&e))?; let mut exit: Option = None; #[cfg(windows)] { @@ -335,7 +345,7 @@ impl Bundle { // SAFETY: `GetPluginFactory` is the VST3-mandated export with this signature. let get = match unsafe { lib.get::(b"GetPluginFactory\0") } { Ok(sym) => *sym, - Err(e) => return Err(fail(format!("not a VST3 plugin: {e}"))), + Err(e) => return Err(fail(format!("not a VST3 plugin: {}", with_reason(&e)))), }; // SAFETY: the module is loaded and its entry function (if any) has run. let raw = unsafe { get() }; diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index a0324f88..a320dedf 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -307,6 +307,21 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// A binary the system refuses to load reports the system's reason (missing library, wrong + /// glibc, not an ELF file), not just "dlopen failed". + #[test] + fn a_binary_that_does_not_load_says_why() { + let d = std::env::temp_dir().join(format!("soundcraft-vst3-reason-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let f = d.join("garbage.vst3"); + std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap(); + let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default(); + let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim()); + assert!(!reason.is_empty(), "no reason in: {msg}"); + let _ = std::fs::remove_dir_all(&d); + } + // Unix only: the test points a ".vst3" at a system library through a symlink. #[cfg(unix)] #[test] @@ -326,6 +341,7 @@ mod tests { std::os::unix::fs::symlink(&lib, d.join("z.vst3")).unwrap(); let e = load_bundle(&d.join("z.vst3")).unwrap_err(); assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); + assert!(e.to_string().contains("GetPluginFactory"), "the system's reason names the missing export: {e}"); let _ = std::fs::remove_dir_all(&d); } } From 7587130d59df9cb4ee5ad9b2df547ebaa6a07b9a Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 01:19:13 -0300 Subject: [PATCH 13/26] convert: keep a BWF source's bext chunk convert wrote every file with bwf: None, so converting a Broadcast Wave file dropped its bext: description, originator, dates and the time reference that places it on a timeline. Pass the decoded bext on. The time reference counts samples, so with --rate it moves to the output rate. --- apps/soundcraft-cli/src/convert.rs | 18 ++++++++++++++++- apps/soundcraft-cli/tests/convert.rs | 30 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/soundcraft-cli/src/convert.rs b/apps/soundcraft-cli/src/convert.rs index 91c606b8..2cd35baa 100644 --- a/apps/soundcraft-cli/src/convert.rs +++ b/apps/soundcraft-cli/src/convert.rs @@ -15,6 +15,7 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?; let ext = input.extension().and_then(std::ffi::OsStr::to_str); let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?; + let source_rate = buf.sample_rate; if let Some(rate) = rate { buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate); buf.sample_rate = rate; @@ -30,13 +31,28 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else { return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into()); }; - let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None }; + // A BWF source keeps its `bext` (written for WAV output only). The time reference counts + // samples, so it moves to the output rate. + let bwf = info.bwf.map(|mut b| { + b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate); + b + }); + let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf }; let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?; write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?; outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs()); Ok(()) } +/// The sample position `pos` at `from` Hz as the nearest sample at `to` Hz. +fn at_rate(pos: u64, from: u32, to: u32) -> u64 { + if from == 0 || from == to { + return pos; + } + let scaled = (u128::from(pos) * u128::from(to) + u128::from(from) / 2) / u128::from(from); + u64::try_from(scaled).unwrap_or(u64::MAX) +} + /// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target` /// (the input, for --in-place) holds either its old or its new contents. The new file is removed when /// writing or renaming fails. diff --git a/apps/soundcraft-cli/tests/convert.rs b/apps/soundcraft-cli/tests/convert.rs index eb62ac77..7d962691 100644 --- a/apps/soundcraft-cli/tests/convert.rs +++ b/apps/soundcraft-cli/tests/convert.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat}; +use soundcraft_audio_io::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat}; fn tmpdir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id())); @@ -93,3 +93,31 @@ fn convert_dithers_when_reducing_depth_unless_told_not_to() { assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn convert_keeps_the_bwf_time_stamp() { + let dir = tmpdir("bext"); + let src = dir.join("take.wav"); + // 01:00:00:00 at 48 kHz. + let bwf = BwfInfo { + description: "take 3".into(), + originator: "recorder".into(), + origination_date: "2026-11-03".into(), + origination_time: "10:00:00".into(), + time_reference: 172_800_000, + ..Default::default() + }; + let buf = AudioBuffer::new(48_000, 1, 4800); + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: Some(bwf.clone()) }; + std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap(); + let bext = |p: &Path| soundcraft_audio_io::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf; + + let out = dir.join("16.wav"); + convert(&src, &out, &["--bit-depth", "16"]); + assert_eq!(bext(&out), Some(bwf.clone())); + // Resampled, the stamp still points at 01:00:00:00: 158,760,000 samples at 44.1 kHz. + let out = dir.join("44k.wav"); + convert(&src, &out, &["--rate", "44100"]); + assert_eq!(bext(&out), Some(BwfInfo { time_reference: 158_760_000, ..bwf })); + let _ = std::fs::remove_dir_all(&dir); +} From 0af1ad909ec17edf6cad92d500fef1b38abdb562 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:25:34 -0500 Subject: [PATCH 14/26] Mixer: match automation lanes to insert parameters in one merge (#1299) apply_params searched all of the track's automation lanes for every stored insert parameter on every block, so a session with many parameters and many lanes multiplied the two on the audio thread. Collect the slot's lanes once per block, order them by parameter name (keeping the first lane per name, the one the old search found) and walk them alongside the sorted parameter map. The lane list reuses a scratch buffer kept on the plugin slot. --- crates/mix/src/strip.rs | 130 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/crates/mix/src/strip.rs b/crates/mix/src/strip.rs index 01f4a11a..1e29653c 100644 --- a/crates/mix/src/strip.rs +++ b/crates/mix/src/strip.rs @@ -24,6 +24,9 @@ pub(crate) struct PluginSlot { pub(crate) plugin: Box, /// Last values pushed to the plugin, in the insert's parameter-map order. applied: Vec, + /// Scratch for `apply_params`: indexes of the track's automation lanes for this slot, kept + /// so the audio thread reuses its allocation. + lanes: Vec, pub(crate) failed: bool, } @@ -191,7 +194,7 @@ impl SlotCtx<'_> { let state = if third { ins.state_bytes() } else { None }; create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin) }?; - Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false }) + Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }) } } @@ -491,21 +494,40 @@ pub(crate) fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert let Ok(slot_u8) = u8::try_from(i) else { return apply_values(slot, ins, |_, v| v); }; - let automated = t.mixer.automation_mode.reads() - && t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty()); + let lanes = &t.automation; + let name = |j: usize| match lanes.get(j).map(|l| &l.param) { + Some(AutoParam::Plugin { param, .. }) => param.as_str(), + _ => "", + }; + // This slot's lanes, ordered by parameter name (the parameter map's order) and keeping only + // the first lane per name (the one a lookup by name finds). Matching them to the parameters + // in one merge costs O(lanes log lanes + params) per block, not O(lanes x params). + let mut order = std::mem::take(&mut slot.lanes); + order.clear(); + if t.mixer.automation_mode.reads() { + order + .extend(lanes.iter().enumerate().filter(|(_, l)| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8)).map(|(j, _)| j)); + if !order.iter().any(|&j| lanes.get(j).is_some_and(|l| !l.points.is_empty())) { + order.clear(); + } + order.sort_unstable_by(|&a, &b| name(a).cmp(name(b)).then(a.cmp(&b))); + order.dedup_by(|later, kept| name(*later) == name(*kept)); + } + let mut next = order.iter().copied().peekable(); apply_values(slot, ins, |k, val| { - if automated - && let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k)) + while next.next_if(|&j| name(j) < k).is_some() {} + if let Some(l) = next.peek().filter(|&&j| name(j) == k).and_then(|&j| lanes.get(j)) && !l.points.is_empty() { return l.value_at(pos, val); } val }); + slot.lanes = order; } /// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time. -fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) { +fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, mut value: impl FnMut(&str, f32) -> f32) { if slot.applied.len() != ins.params.len() { slot.applied = vec![f32::NAN; ins.params.len()]; } @@ -517,3 +539,99 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im } } } + +#[cfg(test)] +mod apply_params_tests { + use super::*; + use soundcraft_model::{AutomationLane, Insert}; + use std::sync::Mutex; + + /// A built-in gain that logs every parameter set. + struct Recorder { + inner: Box, + sets: Arc>>, + } + + impl Plugin for Recorder { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.inner.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.inner.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.inner.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.sets.lock().unwrap().push((id.to_string(), v)); + true + } + fn param(&self, id: &str) -> Option { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.inner.process(io, frames); + } + } + + fn recorder() -> (PluginSlot, Arc>>) { + let sets = Arc::new(Mutex::new(Vec::new())); + let plugin = Box::new(Recorder { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) }); + (PluginSlot { id: "gain".into(), ch: 2, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }, sets) + } + + fn lane(slot: u8, param: &str, point: Option) -> AutomationLane { + let mut l = AutomationLane::new(AutoParam::Plugin { slot, param: param.into() }); + if let Some(v) = point { + l.set_point(0, v); + } + l + } + + #[test] + fn first_lane_per_param_of_this_slot_drives_it() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + let tr = s.track_mut(t).unwrap(); + tr.automation = vec![ + lane(0, "c", None), + lane(1, "a", Some(5.0)), + lane(0, "b", Some(9.0)), + lane(0, "c", Some(7.0)), + lane(0, "zz", Some(1.0)), + lane(0, "b", Some(8.0)), + ]; + let mut ins = Insert::new("gain"); + for (k, v) in [("a", 1.0), ("b", 2.0), ("c", 3.0), ("d", 4.0)] { + ins.params.insert(k.into(), v); + } + let (mut slot, sets) = recorder(); + apply_params(&mut slot, &ins, tr, 0, 0); + let got = sets.lock().unwrap().clone(); + let want: Vec<(String, f32)> = [("a", 1.0), ("b", 9.0), ("c", 3.0), ("d", 4.0)].iter().map(|(k, v)| (k.to_string(), *v)).collect(); + assert_eq!(got, want); + // Unchanged values are not sent again. + apply_params(&mut slot, &ins, tr, 0, 0); + assert_eq!(sets.lock().unwrap().len(), 4); + } + + #[test] + fn many_params_and_lanes_apply_without_a_quadratic_search() { + let n = 40_000; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + let tr = s.track_mut(t).unwrap(); + tr.automation = (0..n).rev().map(|i| lane(0, &format!("p{i:06}"), Some(i as f32))).collect(); + let mut ins = Insert::new("gain"); + for i in 0..n { + ins.params.insert(format!("p{i:06}"), -1.0); + } + let (mut slot, sets) = recorder(); + let start = std::time::Instant::now(); + apply_params(&mut slot, &ins, tr, 0, 0); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + let got = sets.lock().unwrap(); + assert_eq!(got.len(), n); + assert!(got.iter().all(|(k, v)| k == &format!("p{:06}", *v as usize))); + } +} From 64540f109b4e2947ecb832fd15e876a4dac08a94 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:27:09 -0500 Subject: [PATCH 15/26] Mixer: compute cyclic aux depths in one pass per hop (#1306) When the aux/folder routing has a cycle, processing_order sorted the auxes by a bounded feeder depth recomputed inside the sort key: each evaluation re-searched every aux's output and sends against a growing list of inputs, for up to eight hops, on every comparison. A session with many auxes sharing buses made that cubic during audio sync. Compute the same capped depth for every track at once: per hop, collect the buses written by auxes reached so far, then mark the tracks reading them. The result matches the old search (tested against it on a mixed graph with cycles) and the sort uses it by lookup. --- crates/mix/src/routing.rs | 132 +++++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 24 deletions(-) diff --git a/crates/mix/src/routing.rs b/crates/mix/src/routing.rs index fcebca85..2f4788fa 100644 --- a/crates/mix/src/routing.rs +++ b/crates/mix/src/routing.rs @@ -46,7 +46,8 @@ pub(crate) fn processing_order(s: &Session) -> Vec { } else { // A cycle keeps the existing bounded ordering for the entire graph, including any // independent acyclic branches. Feedback does not have a finite longest-path depth. - auxes.sort_by_key(|&i| bounded_aux_depth(s, i)); + let depths = bounded_aux_depths(s); + auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0)); } order.extend(auxes); order @@ -115,32 +116,38 @@ fn aux_output_busses(t: &Track) -> impl Iterator + '_ { .filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None }) } -/// The previous bounded depth calculation, retained unchanged for any cyclic graph. -fn bounded_aux_depth(s: &Session, i: usize) -> usize { - let mut d = 0; - let mut cur = vec![i]; - for _ in 0..8 { - let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect(); - let feeders: Vec = auxes_feeding(s, &inputs); - if feeders.is_empty() { +/// Hop cap for the bounded depth used when the aux graph has a cycle. +const MAX_BOUNDED_DEPTH: usize = 8; + +/// For each track, the bounded depth used for a cyclic graph: the length of the longest chain of +/// Aux/Folder feeders into it (through a bus its input reads and the feeder's output or a send +/// writes), capped at [`MAX_BOUNDED_DEPTH`] hops. One pass per hop over the tracks and their bus +/// writes, so the cost is linear in the routing, not a fresh feeder search per track per +/// sort comparison. +fn bounded_aux_depths(s: &Session) -> Vec { + let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); + // Tracks with a feeder chain of the current length ending at them (length 0: every aux). + let mut reached: Vec = s.tracks.iter().map(is_aux).collect(); + let mut depths = vec![0usize; s.tracks.len()]; + let mut fed = std::collections::HashSet::::new(); + for hop in 1..=MAX_BOUNDED_DEPTH { + fed.clear(); + for (t, _) in s.tracks.iter().zip(&reached).filter(|(t, r)| **r && is_aux(t)) { + fed.extend(aux_output_busses(t)); + } + let mut any = false; + for ((t, r), d) in s.tracks.iter().zip(reached.iter_mut()).zip(depths.iter_mut()) { + *r = matches!(&t.mixer.input, Route::Bus(b) if fed.contains(b)); + if *r { + *d = hop; + any = true; + } + } + if !any { break; } - d += 1; - cur = feeders; } - d -} - -fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec { - s.tracks - .iter() - .enumerate() - .filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder)) - .filter(|(_, t)| { - inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r))) - }) - .map(|(i, _)| i) - .collect() + depths } /// Implicit solo: an aux is not muted when a soloed track feeds its input bus. @@ -204,4 +211,81 @@ mod processing_order_tests { // Both deepest independent returns tie with the cycle at the legacy eight-hop cap. assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]); } + + /// The per-track feeder search `bounded_aux_depths` replaced, kept as its reference. + fn reference_depth(s: &Session, i: usize) -> usize { + let mut d = 0; + let mut cur = vec![i]; + for _ in 0..MAX_BOUNDED_DEPTH { + let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect(); + let feeders: Vec = s + .tracks + .iter() + .enumerate() + .filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder)) + .filter(|(_, t)| { + inputs + .iter() + .any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r))) + }) + .map(|(i, _)| i) + .collect(); + if feeders.is_empty() { + break; + } + d += 1; + cur = feeders; + } + d + } + + #[test] + fn bounded_depths_match_the_per_track_feeder_search() { + // A small pseudo-random graph of auxes, folders and audio tracks with cycles, joins, + // sends and unrouted inputs. + let mut s = Session::default(); + let busses: Vec<_> = (0..6).map(|i| s.add_bus(&format!("B{i}"), ChannelFormat::Stereo)).collect(); + let mut x = 12345u32; + let mut next = |m: usize| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + (x >> 16) as usize % m + }; + for i in 0..30 { + let kind = [TrackKind::Aux, TrackKind::Folder, TrackKind::Audio][next(3)]; + let id = s.add_track(kind, ChannelFormat::Stereo, None); + let pick = |n: usize| if n < busses.len() { Route::Bus(busses[n]) } else { Route::Main }; + let (input, output, send) = (pick(next(8)), pick(next(8)), pick(next(8))); + let t = s.track_mut(id).unwrap(); + t.mixer.input = input; + t.mixer.output = output; + if i % 2 == 0 { + t.mixer.sends[0] = Some(SendSlot::new(send)); + } + } + let depths = bounded_aux_depths(&s); + for (i, (t, depth)) in s.tracks.iter().zip(&depths).enumerate() { + if matches!(t.kind, TrackKind::Aux | TrackKind::Folder) { + assert_eq!(*depth, reference_depth(&s, i), "track {i}"); + } + } + assert!(depths.contains(&MAX_BOUNDED_DEPTH)); + } + + #[test] + fn a_large_cyclic_graph_orders_quickly() { + // Thousands of auxes all reading and writing one bus: every aux feeds every other. + let mut s = Session::default(); + let bus = s.add_bus("Loop", ChannelFormat::Stereo); + for _ in 0..3000 { + let id = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None); + let t = s.track_mut(id).unwrap(); + t.mixer.input = Route::Bus(bus); + t.mixer.output = Route::Bus(bus); + t.mixer.sends[0] = Some(SendSlot::new(Route::Bus(bus))); + } + let start = std::time::Instant::now(); + let order = processing_order(&s); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + assert_eq!(order, (0..3000).collect::>()); + } } From da02c19fcca8fbcc49d66c957e5efcc67a63befe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 23:33:19 -0500 Subject: [PATCH 16/26] Bundles: refuse entries that share their data (#1303) Bundle entries are stored, so the files read out of one can only outgrow the bundle when several entries point at the same bytes. A small bundle naming one payload thousands of times was copied out once per name, up to the 1 GiB total limit, in the browser. Check the archive for overlapping entry data before reading any entry. --- crates/engine/src/io.rs | 6 ++++++ crates/engine/src/io/bundle.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 60d9a323..ba456f6c 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -734,6 +734,12 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle if z.len() > limits.entries { return Err(EngineError::Io("bundle: too many files".into())); } + // Entries are stored, so the files we keep can only outgrow the bundle when several entries + // point at the same bytes: a small bundle naming one payload thousands of times would be + // copied out once per name. + if z.has_overlapping_files().map_err(zerr)? { + return Err(EngineError::Io("bundle: files share data".into())); + } let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64); for i in 0..z.len() { let mut f = z.by_index(i).map_err(zerr)?; diff --git a/crates/engine/src/io/bundle.rs b/crates/engine/src/io/bundle.rs index cfe6e0af..b906333d 100644 --- a/crates/engine/src/io/bundle.rs +++ b/crates/engine/src/io/bundle.rs @@ -194,6 +194,23 @@ mod bundle_tests { } } + #[test] + fn entries_sharing_one_payload_are_refused() { + use std::io::Write as _; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("Song.scraft", opts).unwrap(); + w.write_all(Session::default().to_json().unwrap().as_bytes()).unwrap(); + w.start_file("Audio Files/a.wav", opts).unwrap(); + w.write_all(&[7u8; 4096]).unwrap(); + for i in 0..100 { + w.shallow_copy_file("Audio Files/a.wav", &format!("Audio Files/copy{i}.wav")).unwrap(); + } + let bundle = w.finish().unwrap().into_inner(); + let err = read_bundle(&bundle, BUNDLE_LIMITS).unwrap_err(); + assert!(err.to_string().contains("share data"), "{err}"); + } + #[test] fn a_bundle_is_saved_only_if_it_opens_again() { // #433: the writer keeps to the reader's limits. From 351b9d3a55c08a2f7ca03a7fec317678bb72c2cd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:18:44 -0500 Subject: [PATCH 17/26] Tempo map: binary-search tempo segments in tick/sample conversions (#1297) * Tempo map: index tempo segments so tick/sample conversions are O(log n) Every tick<->sample conversion walked all earlier tempo changes, so code that converts many positions (MIDI import/export, the tempo ruler, score layout, MIDI scheduling and controller chasing on the audio thread) cost positions x tempo changes. A session or MIDI file with many tempo changes and many notes or tracks could stall the engine or the audio callback. TempoMap now keeps the elapsed time at each tempo change (rebuilt on every edit and on deserialize, never serialized) and binary-searches the segment. MIDI scheduling also skips notes whose on and off both fall outside the block before converting them. * Tempo map: index a file's ticks with saturating subtraction --- crates/mix/src/midi.rs | 10 ++- crates/time/src/tempo.rs | 179 +++++++++++++++++++++++++++++++-------- 2 files changed, 152 insertions(+), 37 deletions(-) diff --git a/crates/mix/src/midi.rs b/crates/mix/src/midi.rs index 3b182956..e6efcd51 100644 --- a/crates/mix/src/midi.rs +++ b/crates/mix/src/midi.rs @@ -46,12 +46,20 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize rtp("delay", 0.0).clamp(-1e7, 1e7) as i64, ) }; + // The block in clip ticks, one tick wider on each side so rounding never drops an event. + let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1) + ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1); for n0 in &sequence.notes { let mut n = *n0; n.velocity = (i64::from(n.velocity) + dv).clamp(1, 127) as u8; n.pitch = (i64::from(n.pitch) + dp).clamp(0, 127) as u8; n.length = ((n.length as f64 * dur) as i64).max(1); n.start = n.start.saturating_add(delay).max(0); + // A note whose on and off both fall outside the block sends nothing: skip it before + // converting its positions, so a long sequence costs a comparison per note per block. + if n.start > *near.end() || n.start.saturating_add(n.length) < *near.start() { + continue; + } let on = s.tempo.tick_to_samples(base.saturating_add(n.start), sr); let off = s.tempo.tick_to_samples(base.saturating_add(n.start).saturating_add(n.length), sr).min(clip.end()); if on >= clip.end() { @@ -68,8 +76,6 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize held.retain(|p| *p != n.pitch); } } - let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1) - ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1); // Events before tick 0 are what a start trim hid: they don't play. for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) { let tick = c.tick.saturating_add(delay).max(0); diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 23bb2be0..873034bb 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -40,14 +40,34 @@ pub struct BarBeat { /// Tempo and meter changes for a session. Always has at least one of each, at tick 0. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(from = "TempoMapData")] pub struct TempoMap { tempos: Vec, meters: Vec, + /// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm, + /// summed over the earlier segments). Lets tick/sample conversions binary-search the segment + /// instead of walking every earlier tempo change, so converting many positions against a large + /// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes. + #[serde(skip)] + elapsed: Vec, +} + +/// The serialized form of [`TempoMap`], without the derived lookup table. +#[derive(serde::Deserialize)] +struct TempoMapData { + tempos: Vec, + meters: Vec, +} + +impl From for TempoMap { + fn from(d: TempoMapData) -> Self { + TempoMap::from_parts(d.tempos, d.meters) + } } impl Default for TempoMap { fn default() -> Self { - TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] } + TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]) } } @@ -60,6 +80,43 @@ fn valid_meter(numerator: u32, denominator: u32) -> bool { } impl TempoMap { + fn from_parts(tempos: Vec, meters: Vec) -> Self { + let mut m = TempoMap { tempos, meters, elapsed: Vec::new() }; + m.reindex(); + m + } + + /// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo. + fn reindex(&mut self) { + let mut elapsed = Vec::with_capacity(self.tempos.len()); + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for (i, e) in self.tempos.iter().enumerate() { + if i > 0 { + // Saturating: a file's ticks are indexed before `sanitize` orders them. + acc += e.tick.saturating_sub(seg_tick) as f64 / bpm; + seg_tick = e.tick; + bpm = e.bpm; + } + elapsed.push(acc); + } + self.elapsed = elapsed; + } + + /// Samples per quarter-note minute (the unit of `elapsed`) at `sr`. + fn samples_per_elapsed(sr: SampleRate) -> f64 { + sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64 + } + + /// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for + /// the first), bpm and elapsed time at its start. + fn segment(&self, i: usize) -> (i64, f64, f64) { + let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm); + let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) }; + (tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0)) + } + pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result { let mut m = TempoMap::default(); m.set_tempo(0, bpm)?; @@ -91,6 +148,7 @@ impl TempoMap { let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator)); self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator }); } + self.reindex(); } /// Insert or replace the tempo at `tick`. @@ -105,6 +163,7 @@ impl TempoMap { } Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }), } + self.reindex(); Ok(()) } @@ -115,6 +174,7 @@ impl TempoMap { } let before = self.tempos.len(); self.tempos.retain(|e| e.tick != tick); + self.reindex(); before != self.tempos.len() } @@ -204,36 +264,20 @@ impl TempoMap { } fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 { - let mut acc = 0.0; - let mut seg_tick = 0i64; - let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); - for e in self.tempos.iter().skip(1) { - if e.tick >= tick { - break; - } - acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr); - seg_tick = e.tick; - bpm = e.bpm; - } - acc + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr) + // Tempo events after the first that start before `tick` (the segments it has passed). + let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr) } /// Samples → ticks (fractional). pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 { let s = s as f64; - let mut acc = 0.0; - let mut seg_tick = 0i64; - let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); - for e in self.tempos.iter().skip(1) { - let seg = (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr); - if acc + seg > s { - break; - } - acc += seg; - seg_tick = e.tick; - bpm = e.bpm; - } - seg_tick as f64 + (s - acc) / Self::samples_per_tick(bpm, sr) + let scale = Self::samples_per_elapsed(sr); + // Tempo events after the first that start at or before `s`. + let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr) } pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 { @@ -319,6 +363,7 @@ impl TempoMap { e.bpm = valid_bpm(e.bpm * factor)?; } self.tempos = out; + self.reindex(); Ok(()) } } @@ -407,24 +452,24 @@ mod tests { fn sanitize_keeps_what_the_setters_accept() { let tempo = |tick, bpm| TempoEvent { tick, bpm }; let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator }; - let mut m = TempoMap { - tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)], - meters: vec![meter(0, 0, 4), meter(3840, 3, 4)], - }; + let mut m = TempoMap::from_parts( + vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)], + vec![meter(0, 0, 4), meter(3840, 3, 4)], + ); m.sanitize(); assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]); assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]); - let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() }; + let mut empty = TempoMap::from_parts(Vec::new(), Vec::new()); empty.sanitize(); assert_eq!(empty, TempoMap::default()); } #[test] fn sanitize_drops_events_beyond_the_position_range() { - let mut m = TempoMap { - tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }], - meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }], - }; + let mut m = TempoMap::from_parts( + vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }], + vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }], + ); m.sanitize(); assert_eq!(m, TempoMap::default()); assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4); @@ -446,4 +491,68 @@ mod tests { assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 }); assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 }); } + + /// The segment walk the indexed conversions replaced, kept as the reference they must match. + fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 { + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = m.tempos[0].bpm; + for e in m.tempos.iter().skip(1) { + if e.tick >= tick { + break; + } + acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr); + seg_tick = e.tick; + bpm = e.bpm; + } + to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr)) + } + + #[test] + fn indexed_conversions_match_the_segment_walk() { + let sr = SampleRate::HZ_48000; + let mut m = TempoMap::default(); + for i in 1..200i64 { + m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap(); + } + assert!(m.remove_tempo(487 * 50)); + for tick in (-1000..200 * 487 + 5000).step_by(113) { + let s = m.tick_to_samples(tick, sr); + assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}"); + assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}"); + } + let json = serde_json::to_string(&m).unwrap(); + assert!(!json.contains("elapsed")); + let back: TempoMap = serde_json::from_str(&json).unwrap(); + assert_eq!(back, m); + assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr)); + } + + #[test] + fn a_file_with_wild_ticks_is_indexed_without_overflow() { + let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#; + let mut m: TempoMap = serde_json::from_str(json).unwrap(); + m.sanitize(); + assert_eq!(m.tempos().first().map(|e| e.tick), Some(0)); + assert_eq!(m.elapsed.len(), m.tempos().len()); + } + + #[test] + fn conversions_against_a_large_tempo_map_stay_fast() { + // 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks + // the earlier segments, well under a second when it binary-searches them. + let sr = SampleRate::HZ_48000; + let n = 200_000i64; + let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect(); + let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]); + m.sanitize(); + let start = std::time::Instant::now(); + let mut sum = 0i64; + for i in 0..n { + let s = m.tick_to_samples(i * 10 + 5, sr); + sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr)); + } + assert!(sum != 0); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + } } From aa250cc1d740013cf26cc449e53a20653ed67e2e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 07:44:31 -0500 Subject: [PATCH 18/26] Clip gain to volume: index envelope lookups (#1309) Converting clip gain to volume (and baking volume into clip gain) evaluated the clip's gain envelope by walking it from the start for every breakpoint being converted, so a large envelope in an opened or imported session cost envelope points squared and blocked the engine. Build a running-maximum index over the envelope once per clip and binary-search it. It returns exactly what the walk returned, including for envelopes stored out of order. --- crates/engine/src/cmd/mix.rs | 66 +++++++++++++++++++++++------- crates/engine/src/cmd/mix/tests.rs | 33 +++++++++++++++ 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index dbd52102..1dc67d12 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -598,6 +598,7 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result { } /// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds. +#[cfg(test)] fn env_db(env: &[(i64, f32)], at: i64) -> f32 { let mut prev: Option<(i64, f32)> = None; for &(t, v) in env { @@ -612,6 +613,38 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 { prev.map_or(0.0, |(_, v)| v) } +/// Repeated lookups in one clip-gain envelope, each O(log n) instead of a walk from its start. +/// Gives exactly what walking the points in stored order gives, sorted or not: the first point +/// at or after `at` is the first whose running maximum position reaches `at`. +struct EnvLookup<'a> { + env: &'a [(i64, f32)], + /// Largest position among `env[..=i]`. + reach: Vec, +} + +impl<'a> EnvLookup<'a> { + fn new(env: &'a [(i64, f32)]) -> Self { + let reach = env + .iter() + .scan(i64::MIN, |m, &(t, _)| { + *m = (*m).max(t); + Some(*m) + }) + .collect(); + EnvLookup { env, reach } + } + + /// Value in dB at `at` (see `env_db`). + fn db(&self, at: i64) -> f32 { + let i = self.reach.partition_point(|&m| m < at); + let Some(&(t, v)) = self.env.get(i) else { return self.env.last().map_or(0.0, |&(_, v)| v) }; + match i.checked_sub(1).and_then(|p| self.env.get(p)) { + Some(&(pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + _ => v, + } + } +} + /// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`. fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) { let delta = |at: i64| lane.value_at(at, base) - base; @@ -621,39 +654,41 @@ fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model return; } let rel = |at: i64| at - c.start; + let old_env = std::mem::take(&mut c.gain_env); + let env = EnvLookup::new(&old_env); + let env_db = |at: i64| env.db(at); let rel0 = rel(sel0); let rel1 = rel(sel1); // Later writes win, so the selection anchors replace any point they land on. - let mut pts: Vec<(i64, f32)> = - c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); + let mut pts: Vec<(i64, f32)> = old_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); if rel0 > 0 { - pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1))); + pts.push((rel0 - 1, env_db(rel0 - 1))); } - pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); + pts.push((rel0, env_db(rel0) + delta(sel0))); for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) { let at = rel(pt.at); - pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); + pts.push((at, env_db(at) + delta(pt.at))); } // The clip's own gain points inside the selection keep their shape, offset by the lane. - for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { - pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at)))); + for &(at, _) in old_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { + pts.push((at, env_db(at) + delta(c.start.saturating_add(at)))); } if sel1 - 1 > sel0 { let at = rel(sel1 - 1); - pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); + pts.push((at, env_db(at) + delta(sel1 - 1))); } if sel1 < c.end() { - pts.push((rel1, env_db(&c.gain_env, rel1))); + pts.push((rel1, env_db(rel1))); } pts.sort_by_key(|p| p.0); - let mut env = Vec::new(); + let mut baked = Vec::new(); for p in pts { - if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { - env.pop(); + if baked.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { + baked.pop(); } - env.push(p); + baked.push(p); } - c.gain_env = env; + c.gain_env = baked; } /// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there. @@ -726,8 +761,9 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result { if let Some(l) = &old { ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1)); } + let env = EnvLookup::new(&c.gain_env); for at in ats { - pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start))); + pts.push((at, level(at) + c.gain_db + env.db(at - c.start))); } c.gain_db = 0.0; c.gain_env.clear(); diff --git a/crates/engine/src/cmd/mix/tests.rs b/crates/engine/src/cmd/mix/tests.rs index d134e4da..77087a4e 100644 --- a/crates/engine/src/cmd/mix/tests.rs +++ b/crates/engine/src/cmd/mix/tests.rs @@ -345,3 +345,36 @@ fn insert_and_send_reject_invalid_slot() { let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap(); assert_eq!(r["slot"], 0); } + +#[test] +fn indexed_envelope_lookup_matches_the_walk() { + let envs: [Vec<(i64, f32)>; 5] = [ + Vec::new(), + vec![(100, -3.0)], + vec![(0, 0.0), (100, -6.0), (100, 3.0), (250, -12.0), (900, 1.0)], + // Out of order, as a hand-edited session file may hold. + vec![(500, -1.0), (100, -6.0), (700, 4.0), (300, 2.0), (700, -9.0), (50, 0.5)], + (0..200).map(|i| ((i * 37) % 1000, (i as f32 * 0.7).sin() * 12.0)).collect(), + ]; + for env in &envs { + let lookup = EnvLookup::new(env); + for at in -20..1100 { + assert_eq!(lookup.db(at).to_bits(), env_db(env, at).to_bits(), "{env:?} at {at}"); + } + } +} + +#[test] +fn clip_gain_to_volume_with_a_large_envelope_stays_fast() { + let (mut e, t) = tone_session(&[]); + let clip = e.session().track(t).unwrap().clips()[0].id; + let n = 200_000i64; + let c = e.session_mut().find_clip_mut(clip).unwrap(); + c.length = n * 5 + 10; + c.gain_env = (0..n).map(|i| (i * 5, if i % 2 == 0 { -6.0 } else { 0.0 })).collect(); + let start = std::time::Instant::now(); + e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": n * 5 + 10})).unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap(); + assert!(lane.points.len() as i64 >= n); +} From f86b077cdfd0321d96c2cc5319f1f695bfba2d56 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:53:19 -0500 Subject: [PATCH 19/26] Test that an off-screen edit cursor lands on its fixed column Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 869bb7b6..84f01c72 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -202,7 +202,7 @@ mod tests { e.execute("view.zoom_in", &json!({})).unwrap(); let (spp, scroll) = view(&e); let x = (1_000_000.0 - scroll as f64) / spp; - assert!((0.0..2000.0).contains(&x), "cursor at {x}px"); + assert!((x - OFFSCREEN_CURSOR_PX).abs() < 1.0, "cursor at {x}px"); } #[test] From 7546ab992b9b33ef22cedd64be706c29e545ea2c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:56:05 -0500 Subject: [PATCH 20/26] Cap the folder indent at eight levels and test against the track's own folder Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/tracks.rs | 3 ++- crates/ui-egui/tests/it/track_list.rs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/tracks.rs b/crates/ui-egui/src/panels/tracks.rs index 2c746b1c..1be81dd0 100644 --- a/crates/ui-egui/src/panels/tracks.rs +++ b/crates/ui-egui/src/panels/tracks.rs @@ -89,7 +89,8 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { } ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color)); ui.painter().with_clip_rect(r).text( - pos2(r.min.x + 30.0 + depth as f32 * 10.0, r.center().y), + // Indent stops growing at eight levels so a deep name keeps room in the column. + pos2(r.min.x + 30.0 + depth.min(8) as f32 * 10.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), diff --git a/crates/ui-egui/tests/it/track_list.rs b/crates/ui-egui/tests/it/track_list.rs index c2579962..b0407b29 100644 --- a/crates/ui-egui/tests/it/track_list.rs +++ b/crates/ui-egui/tests/it/track_list.rs @@ -127,6 +127,8 @@ fn deeply_nested_tracks_keep_indenting_past_six_folders() { } r.frame(vec![]); - let parent_name = r.app.engine.session().track(parent).unwrap().name.clone(); - assert!(r.text_x(&child_name) > r.text_x(&parent_name), "deeply nested child is not indented under its parent"); + // "Folder 1" is the child's own folder and the seventh level; "Folder 7" is the outermost. + assert_eq!(r.app.engine.session().track(parent).unwrap().name, "Folder 7"); + assert!(r.text_x(&child_name) > r.text_x("Folder 1"), "deeply nested child is not indented under its own folder"); + assert!(r.text_x("Folder 1") > r.text_x("Folder 2"), "each level indents further"); } From 636454e6393138cb451b6905a38562ac8df16b2c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:56:35 -0500 Subject: [PATCH 21/26] Name the missing entry point in the not-a-plugin errors on every platform Co-Authored-By: Claude Opus 5.5 --- crates/clap-host/src/ffi/bundle.rs | 2 +- crates/vst3-host/src/ffi/module.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/clap-host/src/ffi/bundle.rs b/crates/clap-host/src/ffi/bundle.rs index d16d6abf..6f5d2d28 100644 --- a/crates/clap-host/src/ffi/bundle.rs +++ b/crates/clap-host/src/ffi/bundle.rs @@ -74,7 +74,7 @@ impl Bundle { // `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`. let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } { Ok(sym) => *sym, - Err(e) => return Err(err(format!("not a CLAP plugin: {}", with_reason(&e)))), + Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))), }; if entry.is_null() { return Err(err("null clap_entry".into())); diff --git a/crates/vst3-host/src/ffi/module.rs b/crates/vst3-host/src/ffi/module.rs index fefe681c..5c2cafe0 100644 --- a/crates/vst3-host/src/ffi/module.rs +++ b/crates/vst3-host/src/ffi/module.rs @@ -345,7 +345,7 @@ impl Bundle { // SAFETY: `GetPluginFactory` is the VST3-mandated export with this signature. let get = match unsafe { lib.get::(b"GetPluginFactory\0") } { Ok(sym) => *sym, - Err(e) => return Err(fail(format!("not a VST3 plugin: {}", with_reason(&e)))), + Err(e) => return Err(fail(format!("not a VST3 plugin (no GetPluginFactory): {}", with_reason(&e)))), }; // SAFETY: the module is loaded and its entry function (if any) has run. let raw = unsafe { get() }; From f99407fb1f980e544c9916ee9ecf3366dc810cbc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:56:42 -0500 Subject: [PATCH 22/26] Leave Insertion Follows Playback's N to the Edit window's shortcut handler Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/options.rs | 2 +- crates/ui-egui/src/toolbar.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 1596a8ad..45c4b63a 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback), toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit), toggle!("options.link_track_edit", "Link Track and Edit Selection", ["Options"], None, link_track_edit), - toggle!("options.insertion_follows_playback", "Insertion Follows Playback", ["Options"], Some("N"), insertion_follows_playback), + toggle!("options.insertion_follows_playback", "Insertion Follows Playback", ["Options"], None, insertion_follows_playback), toggle!("options.tab_to_transient", "Tab to Transient", ["Options"], Some("Cmd+Alt+Tab"), tab_to_transient), toggle!("options.mirrored_midi", "Mirrored MIDI Editing", ["Options"], None, mirrored_midi), toggle!("options.automation_follows_edit", "Automation Follows Edit", ["Options"], None, automation_follows_edit), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0aef3a52..3b01aa83 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -6,7 +6,7 @@ use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID}; -use soundcraft_model::{EditState, EditMode, Tool}; +use soundcraft_model::{EditMode, EditState, Tool}; use soundcraft_time::{TimeFormat, format_length}; pub const HEIGHT: f32 = 78.0; From 87ad11cccc67a1e5f362e1fc4ef57490acbc2f10 Mon Sep 17 00:00:00 2001 From: CodyG_NeuralDSP Date: Wed, 14 Oct 2026 14:37:08 -0400 Subject: [PATCH 23/26] fix(ui): center track names in edit window track headers --- crates/ui-egui/src/edit_window/header.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index f8a002a2..ab6da146 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -115,7 +115,7 @@ fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, select track.name.clone() }; let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) }; - ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color); + ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &shown_name, bold(12.5), name_color); let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag()); if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); From d5564bce05197eb85756443aef7b4d0931816562 Mon Sep 17 00:00:00 2001 From: CodyG_NeuralDSP Date: Wed, 14 Oct 2026 15:01:47 -0400 Subject: [PATCH 24/26] fix(ui): position master track view selector directly under track name --- crates/ui-egui/src/edit_window/header.rs | 34 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index ab6da146..60b33fde 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -190,8 +190,9 @@ fn toggle_keys(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { let h = head.height(); let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); let m = &track.mixer; - if h >= 37.0 { - let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master); + let is_master = track.kind == TrackKind::Master; + if h >= 37.0 && !is_master { + let (records, mixes) = (track.kind.has_playlist(), true); let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo); let keys = [ (HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records), @@ -213,10 +214,13 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { let id = track.id; let h = head.height(); let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); - if h >= 58.0 { + let is_master = track.kind == TrackKind::Master; + let view_y = if is_master { 20.0 } else { 39.0 }; + let view_min_h = if is_master { 37.0 } else { 58.0 }; + if h >= view_min_h { let vresp = header_selector( ui, - at(17.0, 39.0, 105.0, 17.0), + at(17.0, view_y, 105.0, 17.0), ui.scope_id().with(("view", id.0)), &view_label(&track.view), t.text, @@ -225,10 +229,32 @@ fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { ); egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); } + if is_master && h >= 58.0 { + let mode = track.mixer.automation_mode; + let col = match mode { + soundcraft_model::AutomationMode::Read => t.auto_read, + soundcraft_model::AutomationMode::Write => t.auto_write, + soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch, + soundcraft_model::AutomationMode::Latch => t.auto_latch, + _ => t.text_dim, + }; + let mode_name = format!("Automation mode for {}", track.name); + let aresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.scope_id().with(("auto_mode", id.0)), mode.label(), col, true, &mode_name); + egui::Popup::menu(&aresp).show(|ui| { + for m in soundcraft_model::AutomationMode::ALL { + if ui.selectable_label(m == mode, m.label()).clicked() { + let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()})); + } + } + }); + } } /// Freeze, voice and automation mode. fn voice_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + if track.kind == TrackKind::Master { + return; + } let t = Tokens::current(); let id = track.id; let h = head.height(); From 6a4c055094baba12996daf44b89e82fab54d4f93 Mon Sep 17 00:00:00 2001 From: Cody Gilchrist Date: Tue, 3 Nov 2026 11:47:04 -0500 Subject: [PATCH 25/26] fix(ui): preserve start of long track names in edit headers --- crates/ui-egui/src/edit_window/header.rs | 7 +++++- crates/ui-egui/src/edit_window/tests.rs | 29 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index 60b33fde..f2e78243 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -115,7 +115,12 @@ fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, select track.name.clone() }; let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) }; - ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &shown_name, bold(12.5), name_color); + let font = bold(12.5); + let galley = ui.painter().layout_no_wrap(shown_name.clone(), font.clone(), name_color); + let overflows = galley.size().x > name_r.width() - 3.0; + let (text_pos, align) = + if overflows { (pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER) } else { (name_r.center(), Align2::CENTER_CENTER) }; + ui.painter().with_clip_rect(name_r).text(text_pos, align, &shown_name, font, name_color); let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag()); if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index 3e6705ef..139123c0 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -1525,3 +1525,32 @@ fn alt_click_on_a_marker_deletes_it() { assert_eq!(h.state().engine.session().markers.len(), before - 1); assert!(!h.state().engine.session().markers.iter().any(|m| m.name == "Alt-delete me")); } + +#[test] +fn short_track_name_is_centered_and_long_name_is_left_aligned() { + let mut h = harness(2); + let s = h.state().engine.session_arc(); + let first = s.tracks[0].id.0; + let second = s.tracks[1].id.0; + h.state_mut().run("track.rename", json!({"track": first, "name": "Vocal"})).unwrap(); + h.state_mut().run("track.rename", json!({"track": second, "name": "A Very Long Track Name That Exceeds Header Width"})).unwrap(); + h.step(); + + let r0 = row(&h, 0); + let r1 = row(&h, 1); + + let first_text = h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.text() == "Vocal" && s.clip_rect.intersects(r0) => Some(t.pos), + _ => None, + }); + let long_text = h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.text() == "A Very Long Track Name That Exceeds Header Width" && s.clip_rect.intersects(r1) => Some(t.pos), + _ => None, + }); + + let first_pos = first_text.expect("short track name should be rendered in edit header"); + let long_pos = long_text.expect("long track name should be rendered in edit header"); + + assert_eq!(long_pos.x, r1.min.x + 17.5); + assert!(first_pos.x > r0.min.x + 25.0, "short name should be centered (was at {})", first_pos.x); +} From 76f58fcb811388d0a556156e2936881284b12e59 Mon Sep 17 00:00:00 2001 From: Cody Gilchrist Date: Tue, 3 Nov 2026 12:29:37 -0500 Subject: [PATCH 26/26] fix(ui): add hover tooltip for overflowing track names and assert exact center position in test --- crates/ui-egui/src/edit_window/header.rs | 7 ++++++- crates/ui-egui/src/edit_window/tests.rs | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index f2e78243..447671d8 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -121,7 +121,12 @@ fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, select let (text_pos, align) = if overflows { (pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER) } else { (name_r.center(), Align2::CENTER_CENTER) }; ui.painter().with_clip_rect(name_r).text(text_pos, align, &shown_name, font, name_color); - let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag()); + let mut nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag()); + if overflows { + nresp = nresp.on_hover_ui(|ui| { + ui.label(&shown_name); + }); + } if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } else if nresp.clicked() { diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index 139123c0..48e63773 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -1540,7 +1540,7 @@ fn short_track_name_is_centered_and_long_name_is_left_aligned() { let r1 = row(&h, 1); let first_text = h.output().shapes.iter().find_map(|s| match &s.shape { - Shape::Text(t) if t.galley.text() == "Vocal" && s.clip_rect.intersects(r0) => Some(t.pos), + Shape::Text(t) if t.galley.text() == "Vocal" && s.clip_rect.intersects(r0) => Some((t.pos, t.galley.size())), _ => None, }); let long_text = h.output().shapes.iter().find_map(|s| match &s.shape { @@ -1548,9 +1548,10 @@ fn short_track_name_is_centered_and_long_name_is_left_aligned() { _ => None, }); - let first_pos = first_text.expect("short track name should be rendered in edit header"); + let (first_pos, first_size) = first_text.expect("short track name should be rendered in edit header"); let long_pos = long_text.expect("long track name should be rendered in edit header"); assert_eq!(long_pos.x, r1.min.x + 17.5); - assert!(first_pos.x > r0.min.x + 25.0, "short name should be centered (was at {})", first_pos.x); + let first_center_x = first_pos.x + first_size.x * 0.5; + assert!((first_center_x - (r0.min.x + 64.5)).abs() <= 0.1, "short name should be centered (center was at {first_center_x})"); }