From 6e8c039097492ee5be50dd8393b3ed3948697f87 Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Wed, 14 Oct 2026 14:50:25 +0000 Subject: [PATCH 001/113] fix(ui): improve New Tracks keyboard navigation and field sizing --- crates/ui-egui/src/dialogs.rs | 199 +++++++++++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d752c16e..ec0bd9a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -33,6 +33,7 @@ pub enum Dialog { #[derive(Default)] pub struct Dialogs { pub open: Option, + new_tracks_focus: bool, /// Runs once the session has been saved from `SaveChanges`. after_save: Option<(String, Value)>, /// A session the browser read while the open one had changes, waiting for `SaveChanges`. @@ -103,6 +104,7 @@ impl Dialogs { return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); } "track.new" => { + self.new_tracks_focus = true; Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } } "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, @@ -343,9 +345,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); match &mut d { Dialog::NewTracks { count, format, kind, timebase, name } => { + new_tracks_keys(ctx, format, kind, timebase, name); + ui.set_min_width(840.0); ui.horizontal(|ui| { ui.label("Create"); - ui.add(egui::DragValue::new(count).range(1..=128)); + // A window's invisible sizing pass cannot retain keyboard focus. + if ui.is_enabled() && !ui.is_sizing_pass() && std::mem::take(&mut app.dialogs.new_tracks_focus) { + ui.memory_mut(|m| m.request_focus(ui.next_auto_id())); + } + ui.add(egui::DragValue::new(count).speed(1.0).range(1..=128)); + *count = (*count).clamp(1, 128); ui.label("new"); egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { for f in soundcraft_model::ChannelFormat::ALL { @@ -357,8 +366,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .show_ui(ui, |ui| { for k in soundcraft_model::TrackKind::ALL { if ui.selectable_label(kind == k.id(), k.label()).clicked() { - *kind = k.id().to_string(); - *name = k.default_name().to_string(); + set_new_track_kind(kind, name, k); } } }); @@ -371,7 +379,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { }, ); ui.label("Name:"); - ui.add(egui::TextEdit::singleline(name).desired_width(140.0)); + ui.add(egui::TextEdit::singleline(name).desired_width(260.0)); }); if buttons(ui, "Create", enter) { action = @@ -695,6 +703,57 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result { + let index = ChannelFormat::ALL.iter().position(|f| f.label() == format).unwrap_or(0); + if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) { + *format = next.label().to_string(); + } + } + Key::ArrowUp | Key::ArrowDown if m.alt => { + *timebase = if timebase == "ticks" { "samples" } else { "ticks" }.to_string(); + } + Key::ArrowUp | Key::ArrowDown => { + let index = TrackKind::ALL.iter().position(|k| k.id() == kind).unwrap_or(0); + if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) { + set_new_track_kind(kind, name, *next); + } + } + _ => return true, + } + false + }); + }); +} + +fn cycle_index(index: usize, len: usize, forward: bool) -> usize { + if forward { + if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 } + } else { + index.checked_sub(1).unwrap_or(len.saturating_sub(1)) + } +} + /// Cancel/OK row; returns true when OK was pressed (or Enter). fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { buttons_enabled(ui, ok, enter, true) @@ -788,6 +847,138 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { mod tests { use super::*; + fn tracks_dialog(os: egui::os::OperatingSystem) -> (egui::Context, SoundApp) { + let ctx = egui::Context::default(); + ctx.set_os(os); + crate::fonts::install(&ctx); + let mut app = SoundApp::new(Engine::default(), None, crate::Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + for _ in 0..3 { + dialog_frame(&ctx, &mut app, vec![]); + } + (ctx, app) + } + + fn dialog_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui.ctx()); + }); + output.textures_delta.clear(); + } + + fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + fn track_fields(app: &SoundApp) -> (u32, &str, &str, &str, &str) { + match app.dialogs.open.as_ref().unwrap() { + Dialog::NewTracks { count, format, kind, timebase, name } => (*count, format, kind, timebase, name), + other => panic!("unexpected dialog: {other:?}"), + } + } + + #[test] + fn new_tracks_focus_selects_count_and_arrows_clamp_it() { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + dialog_frame(&ctx, &mut app, vec![egui::Event::Text("7".into())]); + assert_eq!(track_fields(&app).0, 7, "typing must replace the selected initial value"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 8); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 7); + if let Some(Dialog::NewTracks { count, .. }) = &mut app.dialogs.open { + *count = 128; + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 128); + if let Some(Dialog::NewTracks { count, .. }) = &mut app.dialogs.open { + *count = 1; + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]); + assert_eq!(track_fields(&app).0, 1); + } + + #[test] + fn new_tracks_mac_chords_change_settings_without_changing_count() { + for ctrl in [false, true] { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + let cmd = egui::Modifiers { command: true, mac_cmd: true, ctrl, ..Default::default() }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]); + assert_eq!(track_fields(&app), (1, "Stereo", "audio", "samples", "Audio")); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]); + assert_eq!(track_fields(&app).1, "Mono"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]); + assert_eq!(track_fields(&app).1, soundcraft_model::ChannelFormat::ALL.last().unwrap().label()); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]); + assert_eq!(track_fields(&app), (1, "Mono", "aux", "samples", "Aux")); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]); + assert_eq!(track_fields(&app).2, "audio"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]); + assert_eq!(track_fields(&app).2, "video"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]); + assert_eq!(track_fields(&app).4, "Audio"); + assert!(app.engine.session().tracks.is_empty()); + } + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + let cmd_alt = egui::Modifiers { command: true, mac_cmd: true, alt: true, ..Default::default() }; + for (arrow, expected) in [(egui::Key::ArrowDown, "ticks"), (egui::Key::ArrowUp, "samples")] { + dialog_frame(&ctx, &mut app, vec![key(arrow, cmd_alt)]); + assert_eq!(track_fields(&app).3, expected); + assert_eq!(track_fields(&app).0, 1); + assert_eq!(track_fields(&app).2, "audio"); + } + } + + #[test] + fn new_tracks_type_changes_preserve_custom_names() { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + if let Some(Dialog::NewTracks { name, .. }) = &mut app.dialogs.open { + *name = "Dialogue cinema".into(); + } + let cmd = egui::Modifiers { command: true, mac_cmd: true, ..Default::default() }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]); + assert_eq!(track_fields(&app).4, "Dialogue cinema"); + let mut kind = "audio".to_string(); + let mut name = "Audio".to_string(); + set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Instrument); + assert_eq!(name, "Inst"); + name = "Foley".to_string(); + set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Aux); + assert_eq!(name, "Foley"); + } + + #[test] + fn new_tracks_enter_creates_and_escape_cancels_on_each_platform() { + for os in [egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { + let (ctx, mut app) = tracks_dialog(os); + dialog_frame(&ctx, &mut app, vec![egui::Event::Text("3".into())]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert_eq!(app.engine.session().tracks.len(), 3); + assert!(app.engine.session().markers.is_empty(), "Enter must not add a background marker"); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + dialog_frame(&ctx, &mut app, vec![]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert_eq!(app.engine.session().tracks.len(), 3); + } + } + + #[test] + fn new_tracks_ctrl_chords_work_off_mac_and_reserved_shift_chords_do_nothing() { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Nix); + let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]); + assert_eq!(track_fields(&app).2, "aux"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers { shift: true, ..ctrl })]); + assert_eq!(track_fields(&app).2, "aux"); + assert_eq!(track_fields(&app).0, 1); + } + #[test] fn file_commands_get_path_prompts_with_sensible_names() { let e = soundcraft_engine::demo::demo_engine(); From 3ea1f34a1dcabc4c5f9791be9a766c50d664ffa0 Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Wed, 14 Oct 2026 16:04:00 +0000 Subject: [PATCH 002/113] feat(ui): add multiple New Tracks rows with fixed field widths --- README.md | 10 + crates/engine/src/cmd/track.rs | 34 +- crates/engine/tests/new_track_rows.rs | 78 +++++ crates/ui-egui/src/dialogs.rs | 133 ++------ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/new_tracks.rs | 469 ++++++++++++++++++++++++++ 6 files changed, 613 insertions(+), 112 deletions(-) create mode 100644 crates/engine/tests/new_track_rows.rs create mode 100644 crates/ui-egui/src/new_tracks.rs diff --git a/README.md b/README.md index c683c34b..7e895c80 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,16 @@ Useful shortcuts: Space play/stop, ⌘= Mix/Edi separate, ⌘D duplicate, Enter new marker, ⌘⇧N new tracks. **Setup › Keyboard Shortcuts** lists them all. +In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name. +Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one +Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths +fit their longest option and remain fixed when the selection changes. + +On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**), +and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**; +**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the +field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels. + ### Logs The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 4929b468..e0f83bae 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "New...", ["Track"], Some("Cmd+Shift+N"), - "{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}", + "{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id, rows?: [objects with the same fields] (1–128 tracks total)}", always, new_track ), @@ -230,6 +230,35 @@ pub fn specs() -> Vec { } fn new_track(e: &mut Engine, p: &Value) -> Result { + let ids = if let Some(rows) = p.get("rows") { + let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?; + if rows.is_empty() || rows.len() > 128 { + return Err(bad("track.new", "`rows` must contain 1–128 rows")); + } + let mut total = 0_i64; + for row in rows { + if !row.is_object() || row.get("rows").is_some() { + return Err(bad("track.new", "each row must be an object without nested rows")); + } + total = total.saturating_add(i64_or(row, "count", 1).clamp(1, 128)); + } + if total > 128 { + return Err(bad("track.new", "a single creation is limited to 128 tracks")); + } + let mut ids = Vec::new(); + for row in rows { + ids.extend(new_track_row(e, row)?); + } + ids + } else { + new_track_row(e, p)? + }; + e.session_mut().edit.selected_tracks = ids.clone(); + Ok(json!({"tracks": ids})) +} + +/// All rows run inside one command, so failures roll back and Undo covers the whole creation. +fn new_track_row(e: &mut Engine, p: &Value) -> Result> { let count = i64_or(p, "count", 1).clamp(1, 128); let kind = match str_param(p, "kind") { Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?, @@ -269,8 +298,7 @@ fn new_track(e: &mut Engine, p: &Value) -> Result { } ids.push(id); } - s.edit.selected_tracks = ids.clone(); - Ok(json!({"tracks": ids})) + Ok(ids) } fn group(e: &mut Engine, p: &Value) -> Result { diff --git a/crates/engine/tests/new_track_rows.rs b/crates/engine/tests/new_track_rows.rs new file mode 100644 index 00000000..d00a281e --- /dev/null +++ b/crates/engine/tests/new_track_rows.rs @@ -0,0 +1,78 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, TrackKind}; + +#[test] +fn mixed_rows_create_in_order_select_every_track_and_undo_in_one_step() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"name": "Existing"})).unwrap(); + let before = e.session().to_json().unwrap(); + let result = e + .execute( + "track.new", + &json!({"rows": [ + {"count": 2, "kind": "audio", "format": "mono", "name": "Dialogue", "timebase": "samples"}, + {"kind": "aux", "format": "stereo", "name": "Reverb", "timebase": "samples"}, + {"kind": "instrument", "format": "stereo", "name": "Keys", "timebase": "ticks", "instrument": "subtractive_synth"}, + {"kind": "folder", "format": "stereo", "name": "Music"} + ]}), + ) + .unwrap(); + let s = e.session(); + let created = &s.tracks[1..]; + assert_eq!(created.len(), 5); + assert_eq!( + created.iter().map(|t| t.kind).collect::>(), + [TrackKind::Audio, TrackKind::Audio, TrackKind::Aux, TrackKind::Instrument, TrackKind::Folder] + ); + assert_eq!( + created.iter().map(|t| t.format).collect::>(), + [ChannelFormat::Mono, ChannelFormat::Mono, ChannelFormat::Stereo, ChannelFormat::Stereo, ChannelFormat::Stereo] + ); + assert!(!created[0].ticks_timebase); + assert!(created[3].ticks_timebase && created[3].instrument.is_some()); + assert_eq!(s.busses.len(), 1); + let ids = created.iter().map(|t| t.id).collect::>(); + assert_eq!(s.edit.selected_tracks, ids); + assert_eq!(result["tracks"], json!(ids)); + let after = s.to_json().unwrap(); + assert!(e.undo()); + assert_eq!(e.session().to_json().unwrap(), before); + assert!(e.redo()); + assert_eq!(e.session().to_json().unwrap(), after); +} + +#[test] +fn invalid_later_row_leaves_no_partial_creation_or_undo_step() { + for field in [json!({"kind": "unknown"}), json!({"format": "unknown"})] { + let mut e = Engine::default(); + let before = e.session().to_json().unwrap(); + assert!(e.execute("track.new", &json!({"rows": [{"kind": "folder"}, field]})).is_err()); + assert_eq!(e.session().to_json().unwrap(), before); + assert!(!e.can_undo()); + assert!(e.journal.is_empty()); + } +} + +#[test] +fn row_shape_and_total_size_are_bounded() { + for rows in [json!(null), json!({}), json!([]), json!([true]), json!([{"rows": []}]), json!([{"count": 128}, {}]), json!(vec![json!({}); 129])] { + let mut e = Engine::default(); + assert!(e.execute("track.new", &json!({"rows": rows})).is_err(), "{rows}"); + assert!(e.session().tracks.is_empty()); + assert!(!e.can_undo()); + } + let mut e = Engine::default(); + e.execute("track.new", &json!({"rows": [{"count": 64}, {"count": 64}]})).unwrap(); + assert_eq!(e.session().tracks.len(), 128); +} + +#[test] +fn legacy_single_row_parameters_keep_their_defaults_and_count_clamp() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"kind": "aux", "count": -1})).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Stereo); + e.execute("track.new", &json!({"count": 200})).unwrap(); + assert_eq!(e.session().tracks.len(), 129); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ec0bd9a6..6e434bc3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,7 +10,7 @@ use soundcraft_time::Samples; #[derive(Debug, Clone)] pub enum Dialog { - NewTracks { count: u32, format: String, kind: String, timebase: String, name: String }, + NewTracks(crate::new_tracks::NewTracks), NewSession { name: String, sample_rate: u32, demo: bool }, Bounce { path: String, format: String, bit_depth: String, normalize: bool }, RenameTrack { id: TrackId, name: String }, @@ -33,7 +33,6 @@ pub enum Dialog { #[derive(Default)] pub struct Dialogs { pub open: Option, - new_tracks_focus: bool, /// Runs once the session has been saved from `SaveChanges`. after_save: Option<(String, Value)>, /// A session the browser read while the open one had changes, waiting for `SaveChanges`. @@ -43,7 +42,7 @@ pub struct Dialogs { impl Dialogs { pub fn open_name(&self) -> Option<&'static str> { self.open.as_ref().map(|d| match d { - Dialog::NewTracks { .. } => "new_tracks", + Dialog::NewTracks(_) => "new_tracks", Dialog::NewSession { .. } => "new_session", Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", @@ -103,10 +102,7 @@ impl Dialogs { let ed = &e.session().edit; return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); } - "track.new" => { - self.new_tracks_focus = true; - Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } - } + "track.new" => Dialog::NewTracks(crate::new_tracks::NewTracks::default()), "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, "file.bounce_mix" => { let dir = e @@ -315,7 +311,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let mut save_first: Option<(String, Value)> = None; let confirmed = matches!(d, Dialog::SaveChanges { .. }); let title = match &d { - Dialog::NewTracks { .. } => "New Tracks", + Dialog::NewTracks(_) => "New Tracks", Dialog::NewSession { .. } => "New Session", Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", @@ -344,46 +340,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .show(ctx, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); match &mut d { - Dialog::NewTracks { count, format, kind, timebase, name } => { - new_tracks_keys(ctx, format, kind, timebase, name); - ui.set_min_width(840.0); - ui.horizontal(|ui| { - ui.label("Create"); - // A window's invisible sizing pass cannot retain keyboard focus. - if ui.is_enabled() && !ui.is_sizing_pass() && std::mem::take(&mut app.dialogs.new_tracks_focus) { - ui.memory_mut(|m| m.request_focus(ui.next_auto_id())); - } - ui.add(egui::DragValue::new(count).speed(1.0).range(1..=128)); - *count = (*count).clamp(1, 128); - ui.label("new"); - egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { - for f in soundcraft_model::ChannelFormat::ALL { - ui.selectable_value(format, f.label().to_string(), f.label()); - } - }); - egui::ComboBox::from_id_salt("nt_kind") - .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label())) - .show_ui(ui, |ui| { - for k in soundcraft_model::TrackKind::ALL { - if ui.selectable_label(kind == k.id(), k.label()).clicked() { - set_new_track_kind(kind, name, k); - } - } - }); - ui.label("in"); - egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui( - ui, - |ui| { - ui.selectable_value(timebase, "samples".to_string(), "Samples"); - ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); - }, - ); - ui.label("Name:"); - ui.add(egui::TextEdit::singleline(name).desired_width(260.0)); - }); - if buttons(ui, "Create", enter) { - action = - Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name}))); + Dialog::NewTracks(tracks) => { + if let Some(params) = tracks.show(ui, enter) { + action = Some(("track.new".into(), params)); } } Dialog::NewSession { name, sample_rate, demo } => { @@ -703,64 +662,13 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result { - let index = ChannelFormat::ALL.iter().position(|f| f.label() == format).unwrap_or(0); - if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) { - *format = next.label().to_string(); - } - } - Key::ArrowUp | Key::ArrowDown if m.alt => { - *timebase = if timebase == "ticks" { "samples" } else { "ticks" }.to_string(); - } - Key::ArrowUp | Key::ArrowDown => { - let index = TrackKind::ALL.iter().position(|k| k.id() == kind).unwrap_or(0); - if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) { - set_new_track_kind(kind, name, *next); - } - } - _ => return true, - } - false - }); - }); -} - -fn cycle_index(index: usize, len: usize, forward: bool) -> usize { - if forward { - if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 } - } else { - index.checked_sub(1).unwrap_or(len.saturating_sub(1)) - } -} - /// Cancel/OK row; returns true when OK was pressed (or Enter). fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { buttons_enabled(ui, ok, enter, true) } /// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works. -fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool { +pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool { let mut pressed = enter && ready; ui.separator(); ui.horizontal(|ui| { @@ -846,6 +754,7 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { #[cfg(test)] mod tests { use super::*; + use crate::new_tracks::set_new_track_kind; fn tracks_dialog(os: egui::os::OperatingSystem) -> (egui::Context, SoundApp) { let ctx = egui::Context::default(); @@ -875,7 +784,10 @@ mod tests { fn track_fields(app: &SoundApp) -> (u32, &str, &str, &str, &str) { match app.dialogs.open.as_ref().unwrap() { - Dialog::NewTracks { count, format, kind, timebase, name } => (*count, format, kind, timebase, name), + Dialog::NewTracks(tracks) => { + let row = tracks.active_row().unwrap(); + (row.count, &row.format, &row.kind, &row.timebase, &row.name) + } other => panic!("unexpected dialog: {other:?}"), } } @@ -889,13 +801,13 @@ mod tests { assert_eq!(track_fields(&app).0, 8); dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]); assert_eq!(track_fields(&app).0, 7); - if let Some(Dialog::NewTracks { count, .. }) = &mut app.dialogs.open { - *count = 128; + if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open { + tracks.rows[0].count = 128; } dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]); assert_eq!(track_fields(&app).0, 128); - if let Some(Dialog::NewTracks { count, .. }) = &mut app.dialogs.open { - *count = 1; + if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open { + tracks.rows[0].count = 1; } dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]); assert_eq!(track_fields(&app).0, 1); @@ -936,8 +848,8 @@ mod tests { #[test] fn new_tracks_type_changes_preserve_custom_names() { let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); - if let Some(Dialog::NewTracks { name, .. }) = &mut app.dialogs.open { - *name = "Dialogue cinema".into(); + if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open { + tracks.rows[0].name = "Dialogue cinema".into(); } let cmd = egui::Modifiers { command: true, mac_cmd: true, ..Default::default() }; dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]); @@ -969,13 +881,16 @@ mod tests { } #[test] - fn new_tracks_ctrl_chords_work_off_mac_and_reserved_shift_chords_do_nothing() { + fn new_tracks_ctrl_chords_work_off_mac_and_shift_chords_add_rows() { let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Nix); let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]); assert_eq!(track_fields(&app).2, "aux"); dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers { shift: true, ..ctrl })]); - assert_eq!(track_fields(&app).2, "aux"); + assert_eq!(track_fields(&app).2, "audio"); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") }; + assert_eq!(tracks.rows.len(), 2); + assert_eq!(tracks.rows[0].kind, "aux"); assert_eq!(track_fields(&app).0, 1); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3fdd9892..9f920efb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod icons; pub mod menus; pub mod midi_editor; pub mod mix_window; +pub mod new_tracks; pub mod ops_windows; pub mod palette; pub mod panels; diff --git a/crates/ui-egui/src/new_tracks.rs b/crates/ui-egui/src/new_tracks.rs new file mode 100644 index 00000000..6c0dc50b --- /dev/null +++ b/crates/ui-egui/src/new_tracks.rs @@ -0,0 +1,469 @@ +//! One or more independent New Tracks rows, submitted as a single engine command. + +use egui::{Event, Key, TextStyle, Ui, vec2}; +use serde_json::{Value, json}; +use soundcraft_model::{ChannelFormat, TrackKind}; + +const MAX_TRACKS: usize = 128; +const NAME_WIDTH: f32 = 260.0; +const ROW_BUTTON_WIDTH: f32 = 22.0; + +#[derive(Debug, Clone)] +pub struct NewTrackRow { + key: u64, + pub count: u32, + pub format: String, + pub kind: String, + pub timebase: String, + pub name: String, +} + +impl Default for NewTrackRow { + fn default() -> Self { + Self { key: 0, count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } + } +} + +impl NewTrackRow { + fn params(&self) -> Value { + json!({"count": self.count, "format": self.format, "kind": self.kind, "timebase": self.timebase, "name": self.name}) + } +} + +#[derive(Debug, Clone)] +pub struct NewTracks { + pub rows: Vec, + active: usize, + next_key: u64, + focus_count: bool, +} + +impl Default for NewTracks { + fn default() -> Self { + Self { rows: vec![NewTrackRow::default()], active: 0, next_key: 1, focus_count: true } + } +} + +impl NewTracks { + pub fn active_row(&self) -> Option<&NewTrackRow> { + self.rows.get(self.active) + } + + fn insert_after(&mut self, index: usize) { + if self.rows.len() >= MAX_TRACKS || self.rows.get(index).is_none() { + return; + } + let Some(next) = self.next_key.checked_add(1) else { return }; + self.rows.insert(index + 1, NewTrackRow { key: self.next_key, ..NewTrackRow::default() }); + self.next_key = next; + self.active = index + 1; + self.focus_count = true; + } + + fn remove(&mut self, index: usize) { + if self.rows.len() <= 1 || self.rows.get(index).is_none() { + return; + } + self.rows.remove(index); + if index == self.active { + self.active = index.min(self.rows.len().saturating_sub(1)); + self.focus_count = true; + } else if index < self.active { + self.active = self.active.saturating_sub(1); + } + } + + fn keys(&mut self, ctx: &egui::Context) { + let mac = ctx.os() == egui::os::OperatingSystem::Mac; + ctx.input_mut(|i| { + i.events.retain(|event| { + let Event::Key { key, pressed: true, modifiers: m, .. } = event else { return true }; + if !m.command { + return true; + } + if m.shift { + match key { + Key::ArrowDown => self.insert_after(self.active), + Key::ArrowUp => self.remove(self.active), + _ => return true, + } + return false; + } + if m.alt && m.ctrl && mac { + return true; + } + let Some(row) = self.rows.get_mut(self.active) else { return true }; + let forward = matches!(key, Key::ArrowRight | Key::ArrowDown); + match key { + Key::ArrowLeft | Key::ArrowRight if !m.alt => { + let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0); + if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) { + row.format = next.label().to_string(); + } + } + Key::ArrowUp | Key::ArrowDown if m.alt => { + row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string(); + } + Key::ArrowUp | Key::ArrowDown => { + let index = TrackKind::ALL.iter().position(|k| k.id() == row.kind).unwrap_or(0); + if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) { + set_new_track_kind(&mut row.kind, &mut row.name, *next); + } + } + _ => return true, + } + false + }); + }); + } + + pub(crate) fn show(&mut self, ui: &mut Ui, enter: bool) -> Option { + self.keys(ui.ctx()); + let widths = RowWidths::new(ui); + ui.set_min_width(widths.total(ui)); + // The old row must not consume text on the first frame after adding a new one. + if self.focus_count && ui.is_enabled() && !ui.is_sizing_pass() { + ui.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); + } + }); + } + let mut insert = None; + let mut remove = None; + let len = self.rows.len(); + egui::ScrollArea::vertical().id_salt("new_tracks_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { + for (index, row) in self.rows.iter_mut().enumerate() { + // Explicit row IDs keep editing state with the row when rows above it are removed. + let id = egui::Id::new(("new_tracks_row", row.key)); + let rect = egui::Rect::from_min_size(ui.available_rect_before_wrap().min, vec2(ui.available_width(), ui.spacing().interact_size.y)); + ui.scope_builder(egui::UiBuilder::new().id(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + let label = ui.label("Create"); + let counter = ui + .scope_builder(egui::UiBuilder::new().id(id.with("count")), |ui| { + ui.spacing_mut().interact_size.x = widths.count; + if self.focus_count && index == self.active && ui.is_enabled() && !ui.is_sizing_pass() { + ui.memory_mut(|m| m.request_focus(ui.next_auto_id())); + self.focus_count = false; + } + ui.add(egui::DragValue::new(&mut row.count).speed(1.0).range(1..=128)).labelled_by(label.id) + }) + .inner; + row.count = row.count.clamp(1, 128); + if counter.has_focus() || counter.clicked() { + self.active = index; + } + if counter.gained_focus() { + counter.scroll_to_me(Some(egui::Align::Center)); + } + ui.label("new"); + let format = egui::ComboBox::from_id_salt("nt_fmt") + .width(widths.format) + .selected_text(&row.format) + .show_ui(ui, |ui| { + for f in ChannelFormat::ALL { + ui.selectable_value(&mut row.format, f.label().to_string(), f.label()); + } + }) + .response; + let kind = egui::ComboBox::from_id_salt("nt_kind") + .width(widths.kind) + .selected_text(TrackKind::from_id(&row.kind).map_or("Audio Track", |k| k.label())) + .show_ui(ui, |ui| { + for k in TrackKind::ALL { + if ui.selectable_label(row.kind == k.id(), k.label()).clicked() { + set_new_track_kind(&mut row.kind, &mut row.name, k); + } + } + }) + .response; + ui.label("in"); + let timebase = egui::ComboBox::from_id_salt("nt_tb") + .width(widths.timebase) + .selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples"); + ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks"); + }) + .response; + let label = ui.label("Name:"); + let name = ui.add(egui::TextEdit::singleline(&mut row.name).id(id.with("name")).desired_width(NAME_WIDTH)).labelled_by(label.id); + if [&format, &kind, &timebase, &name].iter().any(|r| r.clicked() || r.has_focus()) { + self.active = index; + } + let size = vec2(ROW_BUTTON_WIDTH, ui.spacing().interact_size.y); + if ui.add_enabled(len > 1, egui::Button::new("−").min_size(size)).on_hover_text("Remove this row").clicked() { + remove = Some(index); + } + if ui.add_enabled(len < MAX_TRACKS, egui::Button::new("+").min_size(size)).on_hover_text("Add a row below").clicked() { + insert = Some(index); + } + }); + } + }); + if let Some(index) = remove { + self.active = index; + self.remove(index); + ui.ctx().request_repaint(); + } else if let Some(index) = insert { + self.insert_after(index); + ui.ctx().request_repaint(); + } + let total = self.rows.iter().fold(0_u32, |total, row| total.saturating_add(row.count)); + let can_create = !self.rows.is_empty() && total <= MAX_TRACKS as u32; + if !can_create { + ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time."); + } + if crate::dialogs::buttons_enabled(ui, "Create", enter, can_create) { + Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::>()})) + } else { + None + } + } +} + +/// Measure all choices with the current UI font, rather than the selected label. +struct RowWidths { + count: f32, + format: f32, + kind: f32, + timebase: f32, +} + +impl RowWidths { + fn new(ui: &Ui) -> Self { + Self { + count: (styled_text_width(ui, "128", &ui.style().drag_value_text_style) + 2.0 * ui.spacing().button_padding.x) + .ceil() + .max(ui.spacing().interact_size.x), + format: combo_width(ui, ChannelFormat::ALL.iter().map(|f| f.label())), + kind: combo_width(ui, TrackKind::ALL.iter().map(|k| k.label())), + timebase: combo_width(ui, ["Samples", "Ticks"].into_iter()), + } + } + + fn total(&self, ui: &Ui) -> f32 { + let labels: f32 = ["Create", "new", "in", "Name:"].iter().map(|s| text_width(ui, s)).sum(); + labels + self.count + self.format + self.kind + self.timebase + NAME_WIDTH + 2.0 * ROW_BUTTON_WIDTH + 10.0 * ui.spacing().item_spacing.x + 8.0 + } +} + +fn text_width(ui: &Ui, text: &str) -> f32 { + styled_text_width(ui, text, &TextStyle::Button) +} + +fn styled_text_width(ui: &Ui, text: &str, style: &TextStyle) -> f32 { + let font = style.resolve(ui.style()); + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, ui.visuals().text_color()).size().x) +} + +fn combo_width<'a>(ui: &Ui, labels: impl Iterator) -> f32 { + let longest = labels.map(|s| text_width(ui, s)).fold(0.0, f32::max); + (longest + ui.spacing().icon_width + ui.spacing().icon_spacing + 2.0 * ui.spacing().button_padding.x + 2.0).ceil() +} + +pub(crate) fn set_new_track_kind(kind: &mut String, name: &mut String, next: TrackKind) { + if TrackKind::from_id(kind).is_some_and(|old| name == old.default_name()) { + *name = next.default_name().to_string(); + } + *kind = next.id().to_string(); +} + +fn cycle_index(index: usize, len: usize, forward: bool) -> usize { + if forward { + if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 } + } else { + index.checked_sub(1).unwrap_or(len.saturating_sub(1)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Services, SoundApp, dialogs::Dialog}; + use egui::{Modifiers, accesskit::Role}; + use egui_kittest::{Harness, kittest::Queryable}; + + fn harness() -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(4); + h + } + + fn tracks<'a>(h: &'a Harness<'_, SoundApp>) -> &'a NewTracks { + let Some(Dialog::NewTracks(tracks)) = &h.state().dialogs.open else { panic!("missing New Tracks dialog") }; + tracks + } + + fn tracks_mut<'a>(h: &'a mut Harness<'_, SoundApp>) -> &'a mut NewTracks { + let Some(Dialog::NewTracks(tracks)) = &mut h.state_mut().dialogs.open else { panic!("missing New Tracks dialog") }; + tracks + } + + fn chord(h: &mut Harness, m: Modifiers, key: Key) { + h.key_press_modifiers(m, key); + // A centered window needs another frame to settle after its row count changes. + h.run_steps(4); + } + + #[test] + fn every_choice_keeps_the_same_field_widths_and_name_position() { + let mut h = harness(); + let format = h.get_by_value("Mono").rect(); + let kind = h.get_by_value("Audio Track").rect(); + let timebase = h.get_by_value("Samples").rect(); + let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert!(name.width() >= NAME_WIDTH); + for f in ChannelFormat::ALL { + tracks_mut(&mut h).rows[0].format = f.label().to_string(); + h.run_steps(2); + assert_eq!(h.get_by_value(f.label()).rect().width(), format.width(), "{}", f.label()); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name); + } + for k in TrackKind::ALL { + tracks_mut(&mut h).rows[0].kind = k.id().to_string(); + h.run_steps(2); + assert_eq!(h.get_by_value(k.label()).rect().width(), kind.width(), "{}", k.label()); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name); + } + tracks_mut(&mut h).rows[0].timebase = "ticks".to_string(); + tracks_mut(&mut h).rows[0].count = 128; + h.run_steps(2); + assert_eq!(h.get_by_value("Ticks").rect().width(), timebase.width()); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name); + h.ctx.global_style_mut(|s| s.drag_value_text_style = TextStyle::Heading); + tracks_mut(&mut h).rows[0].count = 1; + h.run_steps(4); + let larger_font_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + tracks_mut(&mut h).rows[0].count = 128; + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), larger_font_name); + } + + #[test] + fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() { + for alt in [false, true] { + let mut h = harness(); + let row_chord = Modifiers { command: true, mac_cmd: true, ctrl: true, shift: true, alt }; + tracks_mut(&mut h).rows[0].name = "Dialogue".into(); + chord(&mut h, row_chord, Key::ArrowDown); + assert_eq!(tracks(&h).rows.len(), 2); + assert_eq!(tracks(&h).active, 1); + h.event(Event::Text("7".into())); + h.run_steps(2); + assert_eq!(tracks(&h).rows[1].count, 7); + assert_eq!(tracks(&h).rows[0].count, 1); + assert_eq!(tracks(&h).rows[0].name, "Dialogue"); + chord(&mut h, Modifiers { command: true, mac_cmd: true, ..Default::default() }, Key::ArrowDown); + assert_eq!(tracks(&h).rows[1].kind, "aux"); + assert_eq!(tracks(&h).rows[0].kind, "audio"); + chord(&mut h, row_chord, Key::ArrowUp); + assert_eq!(tracks(&h).rows.len(), 1); + assert_eq!(tracks(&h).rows[0].name, "Dialogue"); + chord(&mut h, row_chord, Key::ArrowUp); + assert_eq!(tracks(&h).rows.len(), 1, "the last row cannot be removed"); + assert!(h.state().engine.session().tracks.is_empty()); + } + } + + #[test] + fn plus_and_minus_buttons_operate_on_their_own_row() { + let mut h = harness(); + h.get_by_role_and_label(Role::Button, "+").click(); + h.run_steps(4); + let names: Vec<_> = h.get_all_by_role_and_label(Role::TextInput, "Name:").map(|n| n.rect()).collect(); + assert_eq!(names.len(), 2); + assert_eq!(names[0].min.x, names[1].min.x); + assert_eq!(names[0].width(), names[1].width()); + assert!(names[1].min.y - names[0].min.y <= 32.0, "rows must stay compact: {names:?}"); + tracks_mut(&mut h).rows[0].name = "Dialogue".into(); + tracks_mut(&mut h).rows[1].name = "Effects".into(); + h.get_all_by_role_and_label(Role::Button, "+").next().unwrap().click(); + h.run_steps(4); + assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::>(), ["Dialogue", "Audio", "Effects"]); + h.get_all_by_role_and_label(Role::Button, "−").nth(1).unwrap().click(); + h.run_steps(4); + assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::>(), ["Dialogue", "Effects"]); + } + + #[test] + fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() { + let mut h = harness(); + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + h.event(Event::Text("42".into())); + h.run_steps(2); + let focused = h.ctx.memory(|m| m.focused()); + let key = tracks(&h).rows[1].key; + tracks_mut(&mut h).remove(0); + h.run_steps(2); + assert_eq!(tracks(&h).rows[0].key, key); + assert_eq!(tracks(&h).rows[0].count, 42); + assert_eq!(h.ctx.memory(|m| m.focused()), focused); + chord(&mut h, Modifiers::NONE, Key::ArrowUp); + assert_eq!(tracks(&h).rows[0].count, 43); + } + + #[test] + fn clicking_a_different_row_targets_its_chords_and_keeps_its_custom_name() { + let mut h = harness(); + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + tracks_mut(&mut h).rows[0].name = "Dialogue".into(); + h.get_all_by_role_and_label(Role::TextInput, "Name:").next().unwrap().click(); + h.run_steps(2); + let cmd = Modifiers { command: true, mac_cmd: true, ..Default::default() }; + chord(&mut h, cmd, Key::ArrowDown); + chord(&mut h, cmd, Key::ArrowRight); + chord(&mut h, Modifiers { alt: true, ..cmd }, Key::ArrowUp); + let rows = &tracks(&h).rows; + assert_eq!( + (&rows[0].kind, &rows[0].format, &rows[0].timebase, &rows[0].name), + (&"aux".to_string(), &"Stereo".to_string(), &"ticks".to_string(), &"Dialogue".to_string()) + ); + assert_eq!((&rows[1].kind, &rows[1].format, &rows[1].timebase), (&"audio".to_string(), &"Mono".to_string(), &"samples".to_string())); + assert_eq!((rows[0].count, rows[1].count), (1, 1)); + } + + #[test] + fn create_submits_every_row_and_undo_removes_the_whole_creation() { + let mut h = harness(); + tracks_mut(&mut h).rows[0].count = 2; + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + let row = &mut tracks_mut(&mut h).rows[1]; + row.kind = "aux".into(); + row.format = "Stereo".into(); + row.name = "Reverb".into(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().dialogs.open.is_none()); + assert_eq!( + h.state().engine.session().tracks.iter().map(|t| t.kind).collect::>(), + [TrackKind::Audio, TrackKind::Audio, TrackKind::Aux] + ); + assert_eq!(h.state().engine.session().tracks[2].name, "Reverb"); + assert!(h.state().engine.session().markers.is_empty()); + assert!(h.state_mut().engine.undo()); + assert!(h.state().engine.session().tracks.is_empty()); + assert!(!h.state().engine.can_undo()); + } + + #[test] + fn too_many_tracks_disable_create_but_leave_cancel_available() { + let mut h = harness(); + tracks_mut(&mut h).rows[0].count = 128; + chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().dialogs.open.is_some()); + assert!(h.state().engine.session().tracks.is_empty()); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.run_steps(2); + assert!(h.state().dialogs.open.is_none()); + } +} From 9e9177f1af1f8f2982efdc8e945ed1bd3f8ca798 Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Wed, 14 Oct 2026 16:17:28 +0000 Subject: [PATCH 003/113] fix(ui): hide inapplicable New Tracks fields --- crates/ui-egui/src/dialogs.rs | 48 ++++++++++++---- crates/ui-egui/src/new_tracks.rs | 98 ++++++++++++++++++++++++-------- 2 files changed, 111 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6e434bc3..331e1524 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -881,17 +881,43 @@ mod tests { } #[test] - fn new_tracks_ctrl_chords_work_off_mac_and_shift_chords_add_rows() { - let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Nix); - let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; - dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]); - assert_eq!(track_fields(&app).2, "aux"); - dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers { shift: true, ..ctrl })]); - assert_eq!(track_fields(&app).2, "audio"); - let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") }; - assert_eq!(tracks.rows.len(), 2); - assert_eq!(tracks.rows[0].kind, "aux"); - assert_eq!(track_fields(&app).0, 1); + fn new_tracks_windows_and_linux_chords_cover_every_field_and_row_action() { + for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { + let (ctx, mut app) = tracks_dialog(os); + let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl)]); + assert_eq!(track_fields(&app).1, "Stereo"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, ctrl)]); + assert_eq!(track_fields(&app).1, "Mono"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]); + assert_eq!(track_fields(&app).2, "aux"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl)]); + assert_eq!(track_fields(&app).2, "audio"); + + let ctrl_alt = egui::Modifiers { alt: true, ..ctrl }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl_alt)]); + assert_eq!(track_fields(&app).3, "ticks"); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl_alt)]); + assert_eq!(track_fields(&app).3, "samples"); + + for alt in [false, true] { + let rows = egui::Modifiers { shift: true, alt, ..ctrl }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") }; + assert_eq!(tracks.rows.len(), 2); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") }; + assert_eq!(tracks.rows.len(), 1); + } + + let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open else { panic!("missing dialog") }; + tracks.rows[0].kind = "video".into(); + tracks.rows[0].timebase = "ticks".into(); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl), key(egui::Key::ArrowUp, ctrl_alt)]); + assert_eq!(track_fields(&app).1, "Mono", "Video has no format control"); + assert_eq!(track_fields(&app).3, "ticks", "Video has no timebase control"); + } } #[test] diff --git a/crates/ui-egui/src/new_tracks.rs b/crates/ui-egui/src/new_tracks.rs index 6c0dc50b..b9b9d197 100644 --- a/crates/ui-egui/src/new_tracks.rs +++ b/crates/ui-egui/src/new_tracks.rs @@ -96,13 +96,17 @@ impl NewTracks { let forward = matches!(key, Key::ArrowRight | Key::ArrowDown); match key { Key::ArrowLeft | Key::ArrowRight if !m.alt => { - let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0); - if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) { - row.format = next.label().to_string(); + if has_format(&row.kind) { + let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0); + if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) { + row.format = next.label().to_string(); + } } } Key::ArrowUp | Key::ArrowDown if m.alt => { - row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string(); + if has_timebase(&row.kind) { + row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string(); + } } Key::ArrowUp | Key::ArrowDown => { let index = TrackKind::ALL.iter().position(|k| k.id() == row.kind).unwrap_or(0); @@ -157,15 +161,22 @@ impl NewTracks { counter.scroll_to_me(Some(egui::Align::Center)); } ui.label("new"); - let format = egui::ComboBox::from_id_salt("nt_fmt") - .width(widths.format) - .selected_text(&row.format) - .show_ui(ui, |ui| { - for f in ChannelFormat::ALL { - ui.selectable_value(&mut row.format, f.label().to_string(), f.label()); - } - }) - .response; + let format = if has_format(&row.kind) { + Some( + egui::ComboBox::from_id_salt("nt_fmt") + .width(widths.format) + .selected_text(&row.format) + .show_ui(ui, |ui| { + for f in ChannelFormat::ALL { + ui.selectable_value(&mut row.format, f.label().to_string(), f.label()); + } + }) + .response, + ) + } else { + ui.allocate_space(vec2(widths.format, ui.spacing().interact_size.y)); + None + }; let kind = egui::ComboBox::from_id_salt("nt_kind") .width(widths.kind) .selected_text(TrackKind::from_id(&row.kind).map_or("Audio Track", |k| k.label())) @@ -177,18 +188,26 @@ impl NewTracks { } }) .response; - ui.label("in"); - let timebase = egui::ComboBox::from_id_salt("nt_tb") - .width(widths.timebase) - .selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples"); - ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks"); - }) - .response; + let timebase = if has_timebase(&row.kind) { + ui.label("in"); + Some( + egui::ComboBox::from_id_salt("nt_tb") + .width(widths.timebase) + .selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples"); + ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks"); + }) + .response, + ) + } else { + ui.allocate_space(vec2(text_width(ui, "in"), ui.spacing().interact_size.y)); + ui.allocate_space(vec2(widths.timebase, ui.spacing().interact_size.y)); + None + }; let label = ui.label("Name:"); let name = ui.add(egui::TextEdit::singleline(&mut row.name).id(id.with("name")).desired_width(NAME_WIDTH)).labelled_by(label.id); - if [&format, &kind, &timebase, &name].iter().any(|r| r.clicked() || r.has_focus()) { + if format.iter().chain(timebase.iter()).chain([&kind, &name]).any(|r| r.clicked() || r.has_focus()) { self.active = index; } let size = vec2(ROW_BUTTON_WIDTH, ui.spacing().interact_size.y); @@ -269,6 +288,14 @@ pub(crate) fn set_new_track_kind(kind: &mut String, name: &mut String, next: Tra *kind = next.id().to_string(); } +fn has_format(kind: &str) -> bool { + !matches!(TrackKind::from_id(kind), Some(TrackKind::Midi | TrackKind::Vca | TrackKind::Video)) +} + +fn has_timebase(kind: &str) -> bool { + !matches!(TrackKind::from_id(kind), Some(TrackKind::Video)) +} + fn cycle_index(index: usize, len: usize, forward: bool) -> usize { if forward { if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 } @@ -333,8 +360,10 @@ mod tests { tracks_mut(&mut h).rows[0].kind = k.id().to_string(); h.run_steps(2); assert_eq!(h.get_by_value(k.label()).rect().width(), kind.width(), "{}", k.label()); - assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name); + let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width())); } + tracks_mut(&mut h).rows[0].kind = "audio".to_string(); tracks_mut(&mut h).rows[0].timebase = "ticks".to_string(); tracks_mut(&mut h).rows[0].count = 128; h.run_steps(2); @@ -349,6 +378,27 @@ mod tests { assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), larger_font_name); } + #[test] + fn inapplicable_fields_are_hidden_without_moving_the_other_columns() { + let mut h = harness(); + let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + let all_fields = h.get_all_by_role(Role::ComboBox).count(); + + for kind in [TrackKind::Midi, TrackKind::Vca] { + tracks_mut(&mut h).rows[0].kind = kind.id().to_string(); + h.run_steps(2); + assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 1, "{}", kind.label()); + let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width())); + } + + tracks_mut(&mut h).rows[0].kind = TrackKind::Video.id().to_string(); + h.run_steps(2); + assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 2); + let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect(); + assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width())); + } + #[test] fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() { for alt in [false, true] { From 2e057309b90c1c153831550b75cacf9487ba8afc Mon Sep 17 00:00:00 2001 From: Samuel Bacaro Date: Fri, 23 Oct 2026 18:59:00 -0300 Subject: [PATCH 004/113] Bound the command journal The command journal was a Vec that was never trimmed, so large params (clips/indices arrays, junk fields from MCP or the control channel) were kept for the life of the process. - Store the journal as a VecDeque ring buffer capped at 10,000 entries (oldest are dropped). - Cap a single entry's params at 64 KiB (serialized): a larger value is replaced by a {"__truncated__": true} marker before journaling. Fixes #926 --- crates/engine/src/lib.rs | 60 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03d173dd..9bbf75fd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,6 +26,12 @@ use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use soundcraft_model as model; +/// Upper bound on journaled commands, so a long session cannot grow memory without limit. +const MAX_JOURNAL_ENTRIES: usize = 10_000; +/// Upper bound on a single journaled entry's params (serialized), so junk fields from MCP or the +/// control channel cannot keep a huge blob alive for the life of the process. +const MAX_JOURNAL_ENTRY_BYTES: usize = 64 * 1024; // 64 KiB + /// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they /// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a /// plugin that has been used can crash when its module exit only runs from an exit handler. @@ -119,8 +125,9 @@ pub struct Engine { pub clipboard: Clipboard, pub transport_requests: Vec, pub transport: TransportStatus, - /// Command journal (id, params) for macro recording / agents. - pub journal: Vec<(String, Value)>, + /// Command journal (id, params) for macro recording / agents. Ring buffer: capped at + /// [`MAX_JOURNAL_ENTRIES`] entries of up to [`MAX_JOURNAL_ENTRY_BYTES`] params each. + pub journal: std::collections::VecDeque<(String, Value)>, /// Path of the session file, if saved. pub path: Option, /// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it). @@ -160,7 +167,7 @@ impl Engine { clipboard: Clipboard::default(), transport_requests: Vec::new(), transport: TransportStatus::default(), - journal: Vec::new(), + journal: std::collections::VecDeque::new(), path: None, revision: 1, dirty: false, @@ -337,7 +344,19 @@ impl Engine { self.push_undo(spec.label, before); } if spec.journal { - self.journal.push((id.to_string(), params.clone())); + // Ring buffer with a per-entry cap: a hostile/large params value (junk fields, + // huge arrays from MCP or the control channel) must not be kept whole for the + // life of the process, and the journal must not grow without bound. + let entry = (id.to_string(), params.clone()); + let clamped: (String, Value) = if serde_json::to_vec(&entry.1).map(|b| b.len() > MAX_JOURNAL_ENTRY_BYTES).unwrap_or(true) { + (entry.0, json!({"__truncated__": true})) + } else { + entry + }; + self.journal.push_back(clamped); + if self.journal.len() > MAX_JOURNAL_ENTRIES { + self.journal.pop_front(); + } } Ok(v) } @@ -452,6 +471,39 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String { p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } +#[cfg(test)] +mod journal_tests { + use super::*; + + fn journaled_command(e: &mut Engine) { + // `track.new` is journaled (see its CommandSpec). + e.execute("track.new", &json!({})).unwrap(); + } + + #[test] + fn journal_is_bounded_to_max_entries() { + let mut e = Engine::default(); + for _ in 0..MAX_JOURNAL_ENTRIES + 500 { + journaled_command(&mut e); + } + assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES); + // The oldest entries were dropped: the first remaining id is not the first pushed. + assert!(e.journal.iter().all(|(id, _)| id == "track.new")); + } + + #[test] + fn oversized_params_are_truncated_in_the_journal() { + let mut e = Engine::default(); + let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4); + e.execute("track.new", &json!({"name": big})).unwrap(); + let (id, params) = e.journal.back().expect("a journaled entry"); + assert_eq!(id, "track.new"); + let sz = serde_json::to_vec(params).map(|b| b.len()).unwrap_or(usize::MAX); + assert!(sz <= 1024, "params are truncated, got {sz} bytes"); + assert_eq!(params, &json!({"__truncated__": true})); + } +} + #[cfg(test)] mod merge_tests { use super::*; From 9d5f5784d5b07a47927e96006439234de8f4563c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:02:39 +0000 Subject: [PATCH 005/113] Fix clip group import rejecting audio names containing ".." Found while testing crates/engine/src/clip_group_file.rs:101 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/clip_group_file.rs | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 9a44cca5..9655f7a0 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -89,7 +89,7 @@ fn decode_group_audio( let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); for (old, rel) in audio_entries { let full = dir.join(rel); - if !full.starts_with(dir) || rel.contains("..") { + if !full.starts_with(dir) || Path::new(rel).components().any(|c| matches!(c, std::path::Component::ParentDir)) { return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"))); } let key = crate::io::file_key(&full); @@ -231,6 +231,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() { + let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("drums.scgrp").to_string_lossy().to_string(); + let mut e = crate::demo::demo_engine(); + let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap(); + let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect(); + e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap(); + let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() { + let old = dir.join(rel.as_str().unwrap()); + let new = format!("Wait... what_{i}.wav"); + std::fs::rename(&old, dir.join(&new)).unwrap(); + *rel = json!(new); + } + std::fs::write(&path, file.to_string()).unwrap(); + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"})); + assert!(r.is_ok(), "{r:?}"); + for rel in file["audio"].as_object_mut().unwrap().values_mut() { + *rel = json!("../escape.wav"); + } + std::fs::write(&path, file.to_string()).unwrap(); + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"})); + assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "{r:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn positions_in_a_group_file_are_kept_within_reach() { let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id())); From 6d86a523109a75225f94c7e05c9c91ef1f7bda78 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:57:29 +0200 Subject: [PATCH 006/113] Reject out-of-range timecode fields so Min:Secs strings fall through Found while testing crates/time/src/timecode.rs:189-211 (Timecode::parse), reached via crates/engine/src/cmd/mod.rs:275-287 (position_param_raw) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/tests/it/main.rs | 1 + .../engine/tests/it/min_secs_with_timecode_counter.rs | 11 +++++++++++ crates/time/src/format.rs | 8 +++++++- 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/it/min_secs_with_timecode_counter.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 611c7266..3f85bb50 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -11,6 +11,7 @@ mod hostile_params; mod hostile_sessions; mod long_renders; mod midi_offset; +mod min_secs_with_timecode_counter; mod mix_required_params; mod random_edits; mod recording; diff --git a/crates/engine/tests/it/min_secs_with_timecode_counter.rs b/crates/engine/tests/it/min_secs_with_timecode_counter.rs new file mode 100644 index 00000000..a66d2f6b --- /dev/null +++ b/crates/engine/tests/it/min_secs_with_timecode_counter.rs @@ -0,0 +1,11 @@ +use serde_json::json; +use soundcraft_engine::Engine; + +#[test] +fn min_secs_string_is_not_misread_as_timecode_when_the_counter_is_timecode() { + let mut e = Engine::default(); + e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap(); + e.execute("edit.select", &json!({"start": "0:12.500", "end": "0:20.000"})).unwrap(); + let sel = e.session().edit.selection; + assert_eq!((sel.start, sel.end), (600_000, 960_000)); +} diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index f59611b0..65a338ee 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -115,7 +115,13 @@ pub fn parse_position( let v = sr.samples(m * 60.0 + s); Ok(if neg { -v } else { v }) } - TimeFormat::Timecode => Ok(Timecode::parse(t)?.to_samples(rate, sr).saturating_sub(tc_start)), + TimeFormat::Timecode => { + let tc = Timecode::parse(t)?; + if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) { + return Err(err()); + } + Ok(tc.to_samples(rate, sr).saturating_sub(tc_start)) + } TimeFormat::FeetFrames => { let (ft, fr) = t.split_once('+').ok_or_else(err)?; let ft: i64 = ft.trim().parse().map_err(|_| err())?; From 8f23afe4629e025d27333b0fc3faf6cd5568ebd0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:00:54 +0200 Subject: [PATCH 007/113] Count only audio-track clips in session text export Found while testing crates/engine/src/inspect.rs:116-120 (session_text) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/inspect.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9084a25b..6869c1e8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -115,7 +115,7 @@ pub fn session_text(e: &Engine) -> String { out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n", s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(), s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).count(), - s.tracks.iter().map(|t| t.clips().len()).sum::(), s.sources.len())); + s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum::(), s.sources.len())); out.push_str("F I L E S I N S E S S I O N\nFilename\tLocation\n"); for src in &s.sources { out.push_str(&format!("{}\t{}\n", src.name, src.path)); @@ -156,6 +156,19 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn session_text_counts_only_audio_track_clips() { + let mut e = Engine::default(); + let t = e.execute("track.new", &json!({"kind": "midi"})).unwrap()["tracks"][0].clone(); + e.execute("midi.new_clip", &json!({"track": t, "start": 0, "length": 48000})).unwrap(); + let text = session_text(&e); + assert!(text.contains("# OF AUDIO CLIPS:\t0\n"), "{text}"); + + let demo = crate::demo::demo_engine(); + let n: usize = demo.session().tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum(); + assert!(session_text(&demo).contains(&format!("# OF AUDIO CLIPS:\t{n}\n"))); + } + #[test] fn sources_report_whether_audio_is_loaded() { let mut e = crate::demo::demo_engine(); From 8385ed94c641387ed14f232e98edf1ab68996907 Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:05:38 +0000 Subject: [PATCH 008/113] track.delete: keep a folder's input bus when surviving tracks route through it owned_folder_bus treats any bus a folder takes as input as the folder's own, but track.input or a session file can point a folder at any bus. Deleting such a folder removed the bus and repaired every route to it: inputs set to none, outputs rerouted, sends cleared. Point a folder at the demo session's shared Verb bus and deleting it wiped every reverb send in the session. Remove the bus only when nothing that survives the delete routes through it: an input, a send target, or an output from outside the folder's own subtree, whose outputs the delete already reroutes to the parent folder's bus. Fixes #914 Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> --- crates/engine/src/cmd/track.rs | 40 +++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 7eae3f54..3c22c60c 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -590,6 +590,20 @@ fn owned_folder_bus(s: &Session, folder: TrackId) -> Option bool { + let route = Route::Bus(bus); + s.tracks.iter().filter(|track| !deleted.contains(&track.id)).any(|track| { + track.mixer.input == route + || track.mixer.sends.iter().flatten().any(|send| send.target == route) + || (track.mixer.output == route && !in_folder(s, track.id, folder)) + }) +} + /// Keep only the selected roots so selecting a folder and one of its children preserves the subtree. fn root_tracks(s: &Session, tracks: Vec) -> Vec { tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect() @@ -685,7 +699,11 @@ fn delete(e: &mut Engine, p: &Value) -> Result { _ => break, } } - Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main))) + let replacement = parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main); + // A bus that surviving tracks route through is shared, not the + // folder's own: keep it, and every route to it, when the folder + // goes (#914). + (!bus_used_beyond_folder(s, bus, &tracks, *folder)).then_some((bus, replacement)) }) .collect(); // Promote children of deleted folders and repair only routes that used the deleted @@ -1124,6 +1142,26 @@ mod tests { assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain"); } + #[test] + fn deleting_a_folder_keeps_a_bus_other_tracks_still_use() { + let mut e = crate::demo::demo_engine(); + // #914: the demo session's Verb bus is shared: an aux takes it as its input + // and several tracks send to it. Point a new folder's input at it, the way + // `track.input` or a session file can, then delete the folder. + let verb = e.session().bus_by_name("Verb").unwrap().id; + let folder = e.execute("track.new", &json!({"kind": "folder", "name": "Shared"})).unwrap(); + let folder = TrackId(folder["tracks"][0].as_u64().unwrap()); + e.execute("track.input", &json!({"tracks": [folder.0], "input": "Verb"})).unwrap(); + assert_eq!(e.session().track(folder).unwrap().mixer.input, Route::Bus(verb)); + e.execute("track.delete", &json!({"tracks": [folder.0]})).unwrap(); + assert!(e.session().track(folder).is_none(), "the folder itself is deleted"); + assert!(e.session().bus(verb).is_some(), "a bus other tracks route through must survive"); + let aux = e.session().track_by_name("Verb").unwrap(); + assert_eq!(aux.mixer.input, Route::Bus(verb), "the aux input must keep the bus"); + let snare = e.session().track_by_name("Snare").unwrap(); + assert!(snare.mixer.sends.iter().flatten().any(|send| send.target == Route::Bus(verb)), "sends to the shared bus must survive"); + } + #[test] fn heights_take_points_and_step_through_the_presets() { let mut e = crate::demo::demo_engine(); From 4f771268b0841ef9dec29d8fb32a0e498272d4fe Mon Sep 17 00:00:00 2001 From: Samuel Bacaro Date: Sat, 24 Oct 2026 17:25:53 -0300 Subject: [PATCH 009/113] journal: measure params size via a bounded writer before cloning Addresses the review on #995: the previous fix serialized the full params clone (up to a multi-MiB line from MCP or the control channel) before applying the 64 KiB cap, so one oversized request still made several large allocations. - journal_params serializes through a bounded std::io::Write sink that fails with WriteZero the moment the cap is passed, so an oversized value costs at most ~1x the cap and is never fully serialized. - Only when serialization fits is the value cloned for the journal. - Tests exercise the journal helper directly (no 10,500 track.new calls), check ring-buffer eviction (oldest dropped, newest kept) and the cap boundary. Refines the fix for #926. --- crates/engine/src/lib.rs | 109 ++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9bbf75fd..0db9e1cf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -344,19 +344,7 @@ impl Engine { self.push_undo(spec.label, before); } if spec.journal { - // Ring buffer with a per-entry cap: a hostile/large params value (junk fields, - // huge arrays from MCP or the control channel) must not be kept whole for the - // life of the process, and the journal must not grow without bound. - let entry = (id.to_string(), params.clone()); - let clamped: (String, Value) = if serde_json::to_vec(&entry.1).map(|b| b.len() > MAX_JOURNAL_ENTRY_BYTES).unwrap_or(true) { - (entry.0, json!({"__truncated__": true})) - } else { - entry - }; - self.journal.push_back(clamped); - if self.journal.len() > MAX_JOURNAL_ENTRIES { - self.journal.pop_front(); - } + self.journal_append(id, params); } Ok(v) } @@ -418,6 +406,16 @@ impl Engine { self.merge_key = None; } + /// Append a journaled command, clamping its params to [`MAX_JOURNAL_ENTRY_BYTES`] and the + /// buffer to [`MAX_JOURNAL_ENTRIES`] entries (oldest are dropped). + fn journal_append(&mut self, id: &str, params: &Value) { + let clamped = (id.to_string(), journal_params(params)); + self.journal.push_back(clamped); + if self.journal.len() > MAX_JOURNAL_ENTRIES { + self.journal.pop_front(); + } + } + /// Convenience: execute with `{}` params. pub fn run(&mut self, id: &str) -> Result { self.execute(id, &json!({})) @@ -471,36 +469,89 @@ fn panic_message(p: &(dyn std::any::Any + Send)) -> String { p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } +/// The params to keep in the journal for a command: the value itself when it serializes within +/// [`MAX_JOURNAL_ENTRY_BYTES`], or a truncation marker when it is too large or unparseable. +/// +/// Serializes into a [`std::io::Write`] sink that fails as soon as the cap is exceeded, so an +/// oversized `params` (a multi-MiB line from MCP or the control channel) is never fully +/// serialized, cloned, or allocated just to be discarded. +fn journal_params(params: &Value) -> Value { + // Write through a bounded sink: serialization stops (WriteZero) the moment the cap is passed, + // so an oversized value costs at most ~1× the cap, not its full serialized size. + struct Bounded(Vec); + impl std::io::Write for Bounded { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let room = MAX_JOURNAL_ENTRY_BYTES.saturating_sub(self.0.len()); + if room == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap")); + } + let take = buf.len().min(room); + self.0.extend_from_slice(&buf[..take]); + if take < buf.len() { + return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "journal entry cap")); + } + Ok(take) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut sink = Bounded(Vec::new()); + // Fails with WriteZero as soon as the cap is exceeded, so the allocation stays bounded even + // for a multi-MiB params. + let ok = serde_json::to_writer(&mut sink, params).is_ok(); + if !ok { + return json!({"__truncated__": true}); + } + params.clone() +} + #[cfg(test)] mod journal_tests { use super::*; - fn journaled_command(e: &mut Engine) { - // `track.new` is journaled (see its CommandSpec). - e.execute("track.new", &json!({})).unwrap(); - } - #[test] fn journal_is_bounded_to_max_entries() { let mut e = Engine::default(); - for _ in 0..MAX_JOURNAL_ENTRIES + 500 { - journaled_command(&mut e); + // Calling the private append helper directly exercises the ring buffer without the cost + // of cloning the whole document on 10,000+ `track.new` calls. + for i in 0..MAX_JOURNAL_ENTRIES + 500 { + let n = format!("t{i}"); + e.journal_append("track.new", &json!({"name": n})); } assert_eq!(e.journal.len(), MAX_JOURNAL_ENTRIES); - // The oldest entries were dropped: the first remaining id is not the first pushed. - assert!(e.journal.iter().all(|(id, _)| id == "track.new")); + // The oldest entries were dropped, the newest kept: the first remaining is the 501st pushed, + // and the newest still there. + let first = e.journal.front().map(|(_, p)| p["name"].as_str().unwrap_or_default()); + assert_eq!(first, Some("t500")); + let last = e.journal.back().map(|(_, p)| p["name"].as_str().unwrap_or_default()); + let last_name = format!("t{}", MAX_JOURNAL_ENTRIES + 499); + assert_eq!(last, Some(last_name.as_str())); + assert_eq!(e.journal.back().map(|(id, _)| id.as_str()), Some("track.new")); } #[test] fn oversized_params_are_truncated_in_the_journal() { - let mut e = Engine::default(); let big = "x".repeat(MAX_JOURNAL_ENTRY_BYTES * 4); - e.execute("track.new", &json!({"name": big})).unwrap(); - let (id, params) = e.journal.back().expect("a journaled entry"); - assert_eq!(id, "track.new"); - let sz = serde_json::to_vec(params).map(|b| b.len()).unwrap_or(usize::MAX); - assert!(sz <= 1024, "params are truncated, got {sz} bytes"); - assert_eq!(params, &json!({"__truncated__": true})); + assert_eq!(journal_params(&json!({"name": big})), json!({"__truncated__": true})); + } + + #[test] + fn small_params_are_kept_whole() { + let p = json!({"track": "Kick", "db": -6.0}); + assert_eq!(journal_params(&p), p); + } + + #[test] + fn cap_boundary_is_exact() { + // A value just under the cap is kept whole; one just over is truncated. "Just" means the + // bounded writer made at most ~two allocations of cap size, not a full multi-MiB buffer. + // `{"n":"…"}` adds 9 bytes around the string. + let small_str = MAX_JOURNAL_ENTRY_BYTES - 64; + let small = json!({"n": "x".repeat(small_str)}); + assert_eq!(journal_params(&small), small); + let big = json!({"n": "x".repeat(MAX_JOURNAL_ENTRY_BYTES)}); + assert_eq!(journal_params(&big), json!({"__truncated__": true})); } } From ee63b09cbea91dbf2d160eebab7a5f7b606fa4dd Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Sat, 24 Oct 2026 21:45:19 +0000 Subject: [PATCH 010/113] fix(ui): omit hidden New Tracks row fields --- crates/ui-egui/src/new_tracks.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/new_tracks.rs b/crates/ui-egui/src/new_tracks.rs index b9b9d197..6be96c99 100644 --- a/crates/ui-egui/src/new_tracks.rs +++ b/crates/ui-egui/src/new_tracks.rs @@ -25,8 +25,16 @@ impl Default for NewTrackRow { } impl NewTrackRow { + /// Builds one `track.new` row without values belonging to hidden controls. fn params(&self) -> Value { - json!({"count": self.count, "format": self.format, "kind": self.kind, "timebase": self.timebase, "name": self.name}) + let mut params = json!({"count": self.count, "kind": self.kind, "name": self.name}); + if has_format(&self.kind) { + params["format"] = json!(self.format); + } + if has_timebase(&self.kind) { + params["timebase"] = json!(self.timebase); + } + params } } @@ -399,6 +407,26 @@ mod tests { assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width())); } + /// Submitted rows omit controls that are hidden for their selected track kind. + #[test] + fn inapplicable_fields_are_omitted_from_row_parameters() { + let row = NewTrackRow { + count: 2, + format: "Stereo".into(), + kind: TrackKind::Midi.id().into(), + timebase: "ticks".into(), + name: "MIDI".into(), + ..NewTrackRow::default() + }; + assert_eq!(row.params(), json!({"count": 2, "kind": "midi", "timebase": "ticks", "name": "MIDI"})); + + let row = NewTrackRow { kind: TrackKind::Vca.id().into(), name: "VCA".into(), ..row }; + assert_eq!(row.params(), json!({"count": 2, "kind": "vca", "timebase": "ticks", "name": "VCA"})); + + let row = NewTrackRow { kind: TrackKind::Video.id().into(), name: "Video".into(), ..row }; + assert_eq!(row.params(), json!({"count": 2, "kind": "video", "name": "Video"})); + } + #[test] fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() { for alt in [false, true] { From 95f25979eb8bb79ae0674cb4f07c459ebc3f8854 Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Sat, 24 Oct 2026 21:57:11 +0000 Subject: [PATCH 011/113] docs(ui): document New Tracks changes --- crates/engine/src/cmd/track.rs | 2 ++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/new_tracks.rs | 2 ++ 3 files changed, 5 insertions(+) diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index aa252d7f..e07a29ac 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -7,6 +7,7 @@ use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind}; +/// Returns the track command specifications exposed to UI and automation clients. pub fn specs() -> Vec { vec![ cmd!( @@ -231,6 +232,7 @@ pub fn specs() -> Vec { ] } +/// Creates either one legacy row or several validated rows as one command. fn new_track(e: &mut Engine, p: &Value) -> Result { let ids = if let Some(rows) = p.get("rows") { let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e5cf3896..e7d6216f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -40,6 +40,7 @@ pub struct Dialogs { } impl Dialogs { + /// Returns the stable UI identifier of the currently open dialog. pub fn open_name(&self) -> Option<&'static str> { self.open.as_ref().map(|d| match d { Dialog::NewTracks(_) => "new_tracks", diff --git a/crates/ui-egui/src/new_tracks.rs b/crates/ui-egui/src/new_tracks.rs index 3a402882..d3f6c198 100644 --- a/crates/ui-egui/src/new_tracks.rs +++ b/crates/ui-egui/src/new_tracks.rs @@ -9,6 +9,7 @@ const NAME_WIDTH: f32 = 260.0; const ROW_BUTTON_WIDTH: f32 = 22.0; #[derive(Debug, Clone)] +/// One independently configured row in the New Tracks dialog. pub struct NewTrackRow { key: u64, pub count: u32, @@ -40,6 +41,7 @@ impl NewTrackRow { } #[derive(Debug, Clone)] +/// State for the multi-row New Tracks dialog. pub struct NewTracks { pub rows: Vec, active: usize, From dab64379cbb2925a8d9a73df98568d8255d1c723 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:58:32 +0000 Subject: [PATCH 012/113] Scale trim_clip file bounds by clip stretch Found while testing crates/engine/src/edit.rs:756-758,770 (trim_clip, file closure) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/edit.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 698a6624..365cb976 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -754,8 +754,11 @@ pub fn trim_clip(s: &mut Session, id: ClipId, start: Option, end: Optio lo = lo.max(0); } let file = |c: &Clip| { - let start = c.start.saturating_sub(c.source_offset()); - material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end()))) + // The mixer plays from `offset + rel / stretch`, so file samples span `stretch` times as many timeline samples. + let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; + let scale = |n: Samples| if (stretch - 1.0).abs() >= 1e-9 { (n as f64 * stretch).round() as Samples } else { n }; + let start = c.start.saturating_sub(scale(c.source_offset())); + material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(scale(n)).max(c.end()))) }; let t = TimeCtx::of(s); let mut c = old.clone(); @@ -906,6 +909,25 @@ mod tests { s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect() } + #[test] + fn trim_clip_bounds_stretched_clip_to_its_file() { + for (stretch, length, want) in [(0.5, 5000, 5000), (2.0, 1000, 20_000)] { + let (mut s, t) = session_with_clip(); + let id = { + let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap(); + c.stretch = stretch; + c.start = 0; + c.length = length; + if let ClipContent::Audio { offset, .. } = &mut c.content { + *offset = 0; + } + c.id + }; + let c = trim_clip(&mut s, id, None, Some(99_999), false).unwrap(); + assert_eq!(c.end(), want, "stretch {stretch}"); + } + } + #[test] fn copy_range_resets_fades_at_cut_edges() { let (mut s, t) = session_with_clip(); From 7509b64c5cedb371775ca1cb74cf18c4a9dacd84 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:39:26 +0100 Subject: [PATCH 013/113] Strip XML-forbidden characters from MusicXML/SVG score export Found while testing crates/engine/src/score.rs:216-218 (xml_escape), used by musicxml() and svg() Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/score.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index 847be67e..8f40fe5f 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -214,7 +214,13 @@ fn spell(pitch: u8) -> (char, i32, i32) { } fn xml_escape(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + s.chars() + .filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')) + .collect::() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } fn note_type(units: u32) -> (&'static str, bool) { @@ -558,4 +564,19 @@ mod tests { let s = svg(&parts, &setup); assert!(s.starts_with(" = + out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect(); + assert!(bad.is_empty(), "forbidden characters: {bad:?}"); + } + } } From 9f049ac7d0ff33fe7b5f18c421569e32c5f7c35b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:51:21 +0000 Subject: [PATCH 014/113] Keep clip spacing when moving a group left past the session start Found while testing crates/engine/src/edit.rs:486-489 (move_clips), reached via edit.move_clips in crates/engine/src/cmd/edit.rs:935 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/edit.rs | 4 +++- crates/engine/tests/it/edit_modes.rs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 4017aa00..a5e63155 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -523,8 +523,10 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac } } let n = moving.len(); + // Limit the shift once, so the group stops at zero together and keeps its spacing. + let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0)); let placed = moving.into_iter().map(|(tid, mut c)| { - c.start = c.start.saturating_add(delta).max(0); + c.start = c.start.saturating_add(delta); (lands(dest, tid), c) }); put_clips(s, placed.collect()); diff --git a/crates/engine/tests/it/edit_modes.rs b/crates/engine/tests/it/edit_modes.rs index 27d4a649..2c726614 100644 --- a/crates/engine/tests/it/edit_modes.rs +++ b/crates/engine/tests/it/edit_modes.rs @@ -413,3 +413,10 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() { run(&mut e, "edit.mode", json!({"mode": "grid"})); assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err()); } + +#[test] +fn moving_a_group_left_past_zero_keeps_its_spacing() { + let (mut e, t, ids) = long("slip", &[(500, 100, 0), (2000, 100, 0)]); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000})); + assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]); +} From ccd3fe7ca214de8515c7761ac34a30e110b5574d Mon Sep 17 00:00:00 2001 From: Samuel Bacaro Date: Fri, 23 Oct 2026 18:27:21 -0300 Subject: [PATCH 015/113] Bound Print Score and MusicXML output size MAX_MEASURES limited the number of measures but not the items per measure, pitches per chord, or total output bytes, so a hostile session could produce an unbounded document (a 10M-note session became a ~20 GB SVG and aborted). - Limit items per measure and pitches per chord in layout/fill. - Give svg() and musicxml() a 256 MiB byte budget; over it they return an error instead of growing without bound. - write_score surfaces that error to the caller. Fixes #921 --- crates/engine/src/cmd/file.rs | 6 ++- crates/engine/src/score.rs | 78 ++++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 40e3d3db..7b308b8c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -327,7 +327,11 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result return Err(bad(id, "no MIDI or instrument tracks to notate")); } let setup = crate::score::ScoreSetup::from_session(e.session()); - let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") }; + let (text, ext) = if svg { + (crate::score::svg(&parts, &setup).map_err(|m| bad(id, m))?, "svg") + } else { + (crate::score::musicxml(&parts, &setup).map_err(|m| bad(id, m))?, "musicxml") + }; let path = match str_param(p, "path") { Some(p) => p.to_string(), None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(), diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index 847be67e..ea264377 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -13,9 +13,23 @@ use std::fmt::Write as _; const UNIT: i64 = TICKS_PER_QUARTER / 4; /// Upper bound on engraved measures, so a hostile session cannot make a huge document. const MAX_MEASURES: usize = 2000; +/// Upper bound on output size, so a hostile session cannot make a huge document. +const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB +/// Upper bound on items per measure, so a hostile session cannot make a huge document. +const MAX_ITEMS_PER_MEASURE: usize = 128; +/// Upper bound on pitches per chord, so a hostile session cannot make a huge document. +const MAX_PITCHES_PER_CHORD: usize = 16; /// Note values a single notehead can show, in sixteenths (longest first). const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1]; +/// The output has grown past the byte budget: refuse to grow it further. +fn over_budget(buf: &str) -> Result<(), String> { + if buf.len() > MAX_OUTPUT_BYTES { + return Err(format!("score too large: over {} MiB", MAX_OUTPUT_BYTES / (1024 * 1024))); + } + Ok(()) +} + /// Score Setup (File › Score Setup…). Stored in the session's edit flags/values. #[derive(Debug, Clone, PartialEq)] pub struct ScoreSetup { @@ -159,6 +173,10 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec if !e.1.contains(p) { e.1.push(*p); } + // Cap chord size to avoid pathological memory use. + if e.1.len() > MAX_PITCHES_PER_CHORD { + e.1.truncate(MAX_PITCHES_PER_CHORD); + } } let starts: Vec = onsets.keys().copied().collect(); let mut spans: Vec<(i64, i64, Vec)> = Vec::new(); @@ -196,10 +214,18 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec } emit(from, to, ps, *a < *ms, *b > me, &mut items); cursor = to; + // Cap items per measure to avoid pathological memory use. + if items.len() > MAX_ITEMS_PER_MEASURE { + break; + } } if cursor < me { emit(cursor, me, &[], false, false, &mut items); } + // Truncate if we hit the cap. + if items.len() > MAX_ITEMS_PER_MEASURE { + items.truncate(MAX_ITEMS_PER_MEASURE); + } measures.push(Measure { numerator: *num, denominator: *den, items }); } measures @@ -231,7 +257,7 @@ fn note_type(units: u32) -> (&'static str, bool) { } /// MusicXML 4.0 (partwise) for `parts`. -pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String { +pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { let mut x = String::new(); x.push_str("\n"); x.push_str("\n"); @@ -312,11 +338,12 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String { } } x.push_str(" \n"); + over_budget(&x)?; } x.push_str(" \n"); } x.push_str("\n"); - x + Ok(x) } /// Staff position of `pitch` in half-spaces above the bottom line (treble E4 / bass G2). @@ -328,7 +355,7 @@ fn staff_step(pitch: u8, bass: bool) -> i32 { } /// An engraved score as a printable SVG page (A4 portrait width, as tall as needed). -pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { +pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { const W: f64 = 794.0; const MARGIN: f64 = 48.0; const SP: f64 = 7.0; // staff space @@ -462,6 +489,7 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { let _ = writeln!(o, "", x + 3.0); } } + over_budget(&o)?; } } // System bracket. @@ -474,7 +502,7 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { ); } o.push_str("\n"); - o + Ok(o) } fn rest(o: &mut String, x: f64, y0: f64, sp: f64, units: u32) { @@ -552,10 +580,48 @@ mod tests { let parts = layout(e.session(), &[]); assert!(!parts.is_empty(), "demo has MIDI tracks"); let setup = ScoreSetup::from_session(e.session()); - let x = musicxml(&parts, &setup); + let x = musicxml(&parts, &setup).expect("demo musicxml fits"); assert!(x.contains("") && x.trim_end().ends_with("")); assert_eq!(x.matches("").count()); - let s = svg(&parts, &setup); + let s = svg(&parts, &setup).expect("demo svg fits"); assert!(s.starts_with(" = (0..100).map(|i| (i * 16, 16, 4, 4)).collect(); + let mut notes = Vec::new(); + for i in 0..2000 { + notes.push((i * 2, i * 2 + 1, 60 + (i % 24) as u8)); + } + let measures = fill(&bars, ¬es); + let part = Part { name: "dense".into(), bass: false, measures }; + let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 4, show_track_names: false }; + let s = svg(&[part], &setup); + assert!(s.is_ok(), "dense but bounded score fits"); + let s = s.unwrap(); + assert!(s.len() <= MAX_OUTPUT_BYTES); + } + + #[test] + fn oversize_score_is_refused_not_aborted() { + // Many dense parts drive the output past the byte budget: the call must + // return an error, not grow a multi-GB string or abort. + let bars: Vec<_> = (0..MAX_MEASURES as i64).map(|i| (i * 16, 16, 4, 4)).collect(); + let mut notes = Vec::new(); + for i in 0..(MAX_MEASURES * 8) as i64 { + let onset = i * 2; + notes.push((onset, onset + 1, 60 + (i % 24) as u8)); + } + let measures = fill(&bars, ¬es); + let part = Part { name: "dense".into(), bass: false, measures }; + let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true }; + // Enough copies of the dense part to exceed the byte budget. + let parts: Vec = (0..256).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect(); + let err = svg(&parts, &setup).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + let err = musicxml(&parts, &setup).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + } } From fa77e03e16ffdf8e2a521ee405edbb56b607c411 Mon Sep 17 00:00:00 2001 From: Samuel Bacaro Date: Sat, 24 Oct 2026 17:59:47 -0300 Subject: [PATCH 016/113] score: apply every byte-budget and per-part cap in the same place Addresses the review threads on #993: - MAX_NOTES_PER_PART: the layout loop stops collecting past 100k notes per part, so the raw note vector cannot grow unboundedly regardless of clip count. - fill: a pitch the chord cap rejects no longer extends the onset end (e.0), so dropped pitches do not inflate chord duration; a retained pitch that reappears with a longer duration still does. - musicxml now checks the budget before the per-measure loop (covers an oversized title/composer, escaped, even with zero measures), after the part-list, and at the end, so no growth path is unchecked. - svg now checks every measure (rest-only or missing measures cannot grow the output untouched past the budget), every staff, every system and the final footer, so staff/clef/bracket growth is capped too. - over_budget takes the budget as a parameter and the budgeted entry points stay separate so tests pass a tiny budget instead of building 256 MiB. The oversize test shrank from ~256 MiB to ~64 KiB and the new header test checks the title/composer escape path directly. Regression tests. --- crates/engine/src/score.rs | 93 +++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index ea264377..5f333831 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -19,13 +19,16 @@ const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB const MAX_ITEMS_PER_MEASURE: usize = 128; /// Upper bound on pitches per chord, so a hostile session cannot make a huge document. const MAX_PITCHES_PER_CHORD: usize = 16; +/// Upper bound on notes collected per part, before `fill` builds onsets and spans, so a hostile +/// session cannot make `layout` accumulate an unbounded note vector. +const MAX_NOTES_PER_PART: usize = 100_000; /// Note values a single notehead can show, in sixteenths (longest first). const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1]; /// The output has grown past the byte budget: refuse to grow it further. -fn over_budget(buf: &str) -> Result<(), String> { - if buf.len() > MAX_OUTPUT_BYTES { - return Err(format!("score too large: over {} MiB", MAX_OUTPUT_BYTES / (1024 * 1024))); +fn over_budget(buf: &str, budget: usize) -> Result<(), String> { + if buf.len() > budget { + return Err(format!("score too large: over {} bytes", budget)); } Ok(()) } @@ -107,6 +110,9 @@ pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec { let base = s.tempo.samples_to_ticks(c.start, sr); let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr); for n in &sequence.notes { + if notes.len() >= MAX_NOTES_PER_PART { + break; + } let st = base.saturating_add(n.start); if st < base || st >= end || n.length <= 0 { continue; @@ -165,17 +171,21 @@ fn split_units(mut units: i64) -> Vec { } fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec { - // Onsets → chords, each held until its longest note ends or the next onset. + // Onsets → chords, each held until its longest *retained* note ends or the next onset. A pitch + // the cap rejects must not extend the onset end. let mut onsets: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); for (a, b, p) in notes { - let e = onsets.entry(*a).or_insert((*b, Vec::new())); - e.0 = e.0.max(*b); - if !e.1.contains(p) { - e.1.push(*p); + let (ends, pitches) = onsets.entry(*a).or_insert((*b, Vec::new())); + let new_pitch = !pitches.contains(p); + if new_pitch && pitches.len() >= MAX_PITCHES_PER_CHORD { + // Chord full and this pitch is absent: it is dropped and must not extend the onset end. + continue; } - // Cap chord size to avoid pathological memory use. - if e.1.len() > MAX_PITCHES_PER_CHORD { - e.1.truncate(MAX_PITCHES_PER_CHORD); + // A retained pitch (already in the chord, or one the cap accepts) may extend the onset + // end, even when it reappears with a longer duration. + *ends = (*ends).max(*b); + if new_pitch { + pitches.push(*p); } } let starts: Vec = onsets.keys().copied().collect(); @@ -258,10 +268,19 @@ fn note_type(units: u32) -> (&'static str, bool) { /// MusicXML 4.0 (partwise) for `parts`. pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { + musicxml_budgeted(parts, setup, MAX_OUTPUT_BYTES) +} + +/// [`musicxml`] with an explicit byte budget (kept separate so tests can pass a tiny budget +/// instead of building a 256 MiB score). +fn musicxml_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result { let mut x = String::new(); x.push_str("\n"); x.push_str("\n"); x.push_str("\n"); + // The header (and an oversized title/composer, escaped) can itself exceed the budget even with + // no measures, so check before the per-measure loop too. + over_budget(&x, budget)?; let _ = writeln!(x, " {}", xml_escape(&setup.title)); x.push_str(" \n"); if !setup.composer.is_empty() { @@ -272,6 +291,7 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { let _ = writeln!(x, " {}", i + 1, xml_escape(&p.name)); } x.push_str(" \n"); + over_budget(&x, budget)?; for (i, p) in parts.iter().enumerate() { let _ = writeln!(x, " ", i + 1); let mut meter = (0, 0); @@ -338,11 +358,12 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { } } x.push_str(" \n"); - over_budget(&x)?; + over_budget(&x, budget)?; } x.push_str(" \n"); } x.push_str("\n"); + over_budget(&x, budget)?; Ok(x) } @@ -356,6 +377,12 @@ fn staff_step(pitch: u8, bass: bool) -> i32 { /// An engraved score as a printable SVG page (A4 portrait width, as tall as needed). pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { + svg_budgeted(parts, setup, MAX_OUTPUT_BYTES) +} + +/// [`svg`] with an explicit byte budget (kept separate so tests can pass a tiny budget instead of +/// building a 256 MiB score). +fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result { const W: f64 = 794.0; const MARGIN: f64 = 48.0; const SP: f64 = 7.0; // staff space @@ -377,7 +404,9 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { let _ = writeln!(o, "{}", W / 2.0, xml_escape(&setup.title)); if !setup.composer.is_empty() { let _ = writeln!(o, "{}", W - MARGIN, xml_escape(&setup.composer)); + over_budget(&o, budget)?; } + over_budget(&o, budget)?; let bar_w = (staff_x1 - staff_x0) / per as f64; for sys in 0..systems { let y_sys = top + sys as f64 * sys_h; @@ -489,8 +518,13 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { let _ = writeln!(o, "", x + 3.0); } } - over_budget(&o)?; + // Check each measure, so a run of rest-only or missing measures that only draws + // bar lines cannot grow the output past the budget untouched. + over_budget(&o, budget)?; } + // The staff markup (lines, clef, track names) itself grows with the part count even + // without any items, so check once per staff too. + over_budget(&o, budget)?; } // System bracket. let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP; @@ -500,8 +534,10 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { staff_x0 - 30.0, staff_x0 - 30.0 ); + over_budget(&o, budget)?; } o.push_str("\n"); + over_budget(&o, budget)?; Ok(o) } @@ -606,22 +642,37 @@ mod tests { #[test] fn oversize_score_is_refused_not_aborted() { - // Many dense parts drive the output past the byte budget: the call must - // return an error, not grow a multi-GB string or abort. - let bars: Vec<_> = (0..MAX_MEASURES as i64).map(|i| (i * 16, 16, 4, 4)).collect(); + // Many dense parts drive the output past the byte budget: the call must return an error, + // not grow a multi-GB string or abort. Use the budgeted entry points with a tiny budget so + // the test exercises the over-budget path without allocating 256 MiB of score, and covers + // both the per-measure checks and the header/staff-only growth. + let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect(); let mut notes = Vec::new(); - for i in 0..(MAX_MEASURES * 8) as i64 { + for i in 0..2000 { let onset = i * 2; notes.push((onset, onset + 1, 60 + (i % 24) as u8)); } let measures = fill(&bars, ¬es); let part = Part { name: "dense".into(), bass: false, measures }; let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true }; - // Enough copies of the dense part to exceed the byte budget. - let parts: Vec = (0..256).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect(); - let err = svg(&parts, &setup).unwrap_err(); + // Enough copies of the dense part to exceed a tiny budget. + let parts: Vec = (0..64).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect(); + let tiny = 1024; + let err = svg_budgeted(&parts, &setup, tiny).unwrap_err(); assert!(err.contains("score too large"), "{err}"); - let err = musicxml(&parts, &setup).unwrap_err(); + let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + } + + #[test] + fn oversized_title_is_refused_by_the_header_check() { + // An oversized part name/title must be caught by the header check even with no measures: + // the escaped copy alone exceeds the budget. (A 54 MiB title blows past 64 KiB of escaped + // text without touching the per-measure loop.) + let setup = ScoreSetup { title: "&".repeat(64 * 1024), composer: "c".repeat(64 * 1024), bars_per_system: 4, show_track_names: false }; + let err = musicxml_budgeted(&[], &setup, 1024).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + let err = svg_budgeted(&[], &setup, 1024).unwrap_err(); assert!(err.contains("score too large"), "{err}"); } } From 021caab499b910b8370b74c48254918088309bc1 Mon Sep 17 00:00:00 2001 From: Samuel Bacaro Date: Sun, 25 Oct 2026 09:40:30 -0300 Subject: [PATCH 017/113] score: make the MusicXML oversize test reach the measure check The 1 KiB budget failed on the part-list check, so nothing exercised the MusicXML per-measure budget. Measure the header (parts with no measures) and the full document, pick a budget between them, and assert that the header fits while the completed document is refused, so the test proves the refusal comes from the measures. The 1 KiB run stays as the header-refusal case, and the SVG case is unchanged. --- crates/engine/src/score.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index 5f333831..d6bb9f10 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -660,6 +660,27 @@ mod tests { let tiny = 1024; let err = svg_budgeted(&parts, &setup, tiny).unwrap_err(); assert!(err.contains("score too large"), "{err}"); + + // MusicXML needs a budget that clears the header/part-list but trips in the measures, so + // the per-measure check is what refuses the document (this test used to fail on the + // part-list check alone). + let boundless = usize::MAX; + // The header plus part-list exactly as `parse` writes it, but with no measures: that is + // the size a budget must clear before the per-measure check is reached. + let header_parts: Vec = parts.iter().map(|p| Part { name: p.name.clone(), bass: p.bass, measures: Vec::new() }).collect(); + let header = musicxml_budgeted(&header_parts, &setup, boundless).map_or(0, |x| x.len()); + let full = musicxml_budgeted(&parts, &setup, boundless).map_or(0, |x| x.len()); + assert!(header > 0, "the bare header fits any budget"); + assert!(full > header, "the measures add output ({full} vs {header})"); + // A budget above the bare header but below the completed document must be refused by the + // per-measure check. + let between = header.saturating_add((full.saturating_sub(header)) / 2); + assert!(between > header && between < full, "budget sits between header and document"); + // The header alone fits that budget, so the refusal below comes from the measures. + assert!(musicxml_budgeted(&header_parts, &setup, between).is_ok(), "header fits the mid budget"); + let err = musicxml_budgeted(&parts, &setup, between).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + // And a header that does not fit is refused before any measure is built. let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err(); assert!(err.contains("score too large"), "{err}"); } From e294aa48c0434a98fcaa9440d2c9384e0e1460e0 Mon Sep 17 00:00:00 2001 From: Samuel Bacaro Date: Sun, 25 Oct 2026 09:55:14 -0300 Subject: [PATCH 018/113] score: keep the bar duration when the item cap drops notes A 64/1 bar can hold 256 sixteenths, more than MAX_ITEMS_PER_MEASURE (128). The cap used to truncate the item list and drop the remaining duration, so MusicXML wrote an underfull measure and SVG divided positions by the shortened total, moving the kept notes across the bar. The discarded suffix is now replaced with representable rests. The cap shrinks the retained prefix further when the rests would not fit, and a final truncate still bounds a hostile meter whose whole-bar rests exceed the cap. Regression test: item_cap_keeps_the_measure_duration (fails on the old truncate-only code, passes with rests). --- crates/engine/src/score.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index d6bb9f10..a682ab03 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -232,8 +232,23 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec if cursor < me { emit(cursor, me, &[], false, false, &mut items); } - // Truncate if we hit the cap. + // Cap the item count without shortening the measure: whatever suffix the cap discards is + // replaced with representable rests, so the bar's duration (and the SVG layout denominator) + // stays right. Shrinking the item list further makes room for those rests. if items.len() > MAX_ITEMS_PER_MEASURE { + let bar_len = me.saturating_sub(*ms); + let mut keep = MAX_ITEMS_PER_MEASURE; + loop { + let kept: i64 = items.iter().take(keep).map(|i| i64::from(i.units)).sum(); + let rests = split_units(bar_len.saturating_sub(kept)); + if keep.saturating_add(rests.len()) <= MAX_ITEMS_PER_MEASURE || keep == 0 { + items.truncate(keep); + items.extend(rests.into_iter().map(|units| Item { pitches: Vec::new(), units, tie_start: false, tie_stop: false })); + break; + } + keep -= 1; + } + // Safety net for a hostile meter so wide that even whole-bar rests exceed the cap. items.truncate(MAX_ITEMS_PER_MEASURE); } measures.push(Measure { numerator: *num, denominator: *den, items }); @@ -685,6 +700,22 @@ mod tests { assert!(err.contains("score too large"), "{err}"); } + #[test] + fn item_cap_keeps_the_measure_duration() { + // A 64/1 bar (256 sixteenths) with one note per sixteenth needs 256 items: over the cap. + // What the cap discards must come back as rests, so the bar still fills completely and the + // SVG layout denominator stays right. + let bars = vec![(0i64, 256i64, 64u32, 4u32)]; + let notes: Vec<(i64, i64, u8)> = (0..256).map(|i| (i, i + 1, 60)).collect(); + let measures = fill(&bars, ¬es); + assert_eq!(measures.len(), 1); + let m = &measures[0]; + assert!(m.items.len() <= MAX_ITEMS_PER_MEASURE, "capped at {MAX_ITEMS_PER_MEASURE}"); + let total: u32 = m.items.iter().map(|i| i.units).sum(); + assert_eq!(total, 256, "the bar still lasts 256 sixteenths"); + assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests"); + } + #[test] fn oversized_title_is_refused_by_the_header_check() { // An oversized part name/title must be caught by the header check even with no measures: From ee55acbc93e76a424f54658e84e828a7965d527c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 14:13:49 -0700 Subject: [PATCH 019/113] Strip Silence: closing the window while Mix shows stops the rectangle scan Only the Edit window dropped the Strip Silence rectangles and their scan when Strip Silence closed. With the Mix window showing, a scan for old values ran to the end and strip_scan_running() stayed true. dialogs::show, which runs every frame, now drops them whenever neither Strip Silence nor its Rename Selected Clips dialog is open. edit.strip_silence_preview, strip_silence_scan and strip_settings move from cmd/edit.rs to a new cmd/strip_silence.rs with its own specs(), as AGENTS.md asks for new commands. edit.strip_silence reads its settings through the same strip_settings. --- crates/engine/src/cmd/edit.rs | 45 ++--------------- crates/engine/src/cmd/mod.rs | 4 +- crates/engine/src/cmd/strip_silence.rs | 49 +++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 9 ++-- .../tests/it/strip_silence_rectangles.rs | 15 ++++++ 5 files changed, 75 insertions(+), 47 deletions(-) create mode 100644 crates/engine/src/cmd/strip_silence.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c11d3cbb..ab791760 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,7 +1,7 @@ //! Edit menu commands. use super::{ - CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection, - has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required, + CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, has_clipboard, has_range, has_selection, has_tracks, + i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required, }; use soundcraft_time::Samples; @@ -125,15 +125,6 @@ pub fn specs() -> Vec { has_range, strip_silence ), - cmd!( - query "edit.strip_silence_preview", - "Strip Silence Rectangles", - [], - None, - "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)", - has_range, - strip_silence_preview - ), cmd!( "edit.fades_create", "Create...", @@ -812,38 +803,8 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tracks": tracks.len()})) } -/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads. -type StripSettings = (Vec, soundcraft_time::Range, f32, Samples, (Samples, Samples)); - -/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the -/// command in errors. -fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result { - let tracks = tracks_required(e, cmd, p)?; - let r = range_param(e, cmd, p)?; - let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0); - let sr = e.session().sample_rate; - let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0); - Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)))) -} - -/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit -/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a -/// worker thread outside the web build, and on the frame thread in the web build or when no thread -/// can be started. -pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result { - let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?; - Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() }) -} - -fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result { - let found = strip_silence_scan(e, p)?.run()?; - let tracks: Vec = - found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::>()})).collect(); - Ok(json!({"tracks": tracks})) -} - fn strip_silence(e: &mut Engine, p: &Value) -> Result { - let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?; + let (tracks, r, thr, min_len, (pre, post)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?; let mode = match p.get("mode") { None => crate::io::StripMode::Strip, Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b943c5c1..9b9d7954 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -28,6 +28,7 @@ mod plugin_state; mod query; pub(crate) mod sampler; mod setup_more; +mod strip_silence; mod surround; mod tab; mod track; @@ -63,7 +64,7 @@ mod panic_probe { pub use file::bounce_job; pub(crate) use edit::remember_selection; -pub use edit::strip_silence_scan; +pub use strip_silence::strip_silence_scan; pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. @@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(sampler::specs()); v.extend(video::specs()); v.extend(zoom_toggle::specs()); + v.extend(strip_silence::specs()); #[cfg(test)] v.extend(panic_probe::specs()); v diff --git a/crates/engine/src/cmd/strip_silence.rs b/crates/engine/src/cmd/strip_silence.rs new file mode 100644 index 00000000..4fbc51c1 --- /dev/null +++ b/crates/engine/src/cmd/strip_silence.rs @@ -0,0 +1,49 @@ +//! Strip Silence Rectangles: the ranges Strip Silence keeps, which the Strip Silence window draws. +use super::{CommandSpec, f32_or, f64_or, has_range, range_param, tracks_required}; +use soundcraft_model::TrackId; +use soundcraft_time::Samples; + +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; + +pub fn specs() -> Vec { + vec![cmd!( + query "edit.strip_silence_preview", + "Strip Silence Rectangles", + [], + None, + "{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)", + has_range, + strip_silence_preview + )] +} + +/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads. +pub(super) type StripSettings = (Vec, soundcraft_time::Range, f32, Samples, (Samples, Samples)); + +/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the +/// command in errors. +pub(super) fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result { + let tracks = tracks_required(e, cmd, p)?; + let r = range_param(e, cmd, p)?; + let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0); + let sr = e.session().sample_rate; + let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0); + Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0)))) +} + +/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit +/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a +/// worker thread outside the web build, and on the frame thread in the web build or when no thread +/// can be started. +pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result { + let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?; + Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() }) +} + +fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result { + let found = strip_silence_scan(e, p)?.run()?; + let tracks: Vec = + found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::>()})).collect(); + Ok(json!({"tracks": tracks})) +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a2f11adb..1c01e730 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -406,6 +406,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); crate::bounce::show(app, ctx); crate::import::show(app, ctx); + // Whichever window shows, closing Strip Silence drops its rectangles and stops their scan. + if !matches!(app.dialogs.open, Some(Dialog::StripSilence { .. } | Dialog::StripSilenceRename { .. })) { + app.dialogs.strip_rectangles = StripRectangles::default(); + } let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; @@ -914,10 +918,7 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri let values = match &app.dialogs.open { Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms], Some(Dialog::StripSilenceRename { strip, .. }) => *strip, - _ => { - app.dialogs.strip_rectangles = StripRectangles::default(); - return Arc::default(); - } + _ => return Arc::default(), }; let key: StripKey = (app.engine.revision, values.map(f64::to_bits)); let r = &mut app.dialogs.strip_rectangles; diff --git a/crates/ui-egui/tests/it/strip_silence_rectangles.rs b/crates/ui-egui/tests/it/strip_silence_rectangles.rs index 4ee12174..a76445ec 100644 --- a/crates/ui-egui/tests/it/strip_silence_rectangles.rs +++ b/crates/ui-egui/tests/it/strip_silence_rectangles.rs @@ -109,3 +109,18 @@ fn the_rectangles_stay_while_rename_is_open_and_go_when_the_window_closes() { assert_eq!(rectangles(&h), 0); assert_eq!(clips(&h, track), before); } + +#[test] +fn closing_the_window_while_the_mix_window_shows_stops_the_scan() { + let (mut h, _) = strip_silence_dialog(); + if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open { + *threshold = -30.0; + } + h.run_steps(1); + assert!(h.state().dialogs.strip_scan_running(), "a scan for the new threshold started"); + h.state_mut().run("window.mix", json!({})).unwrap(); + h.run_steps(2); + h.state_mut().dialogs.open = None; + h.run_steps(2); + assert!(!h.state().dialogs.strip_scan_running(), "the scan still runs after the window closed"); +} From 84e591e1ca56eb24ea5219739f5416c53c3f5b6b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 17:05:28 -0700 Subject: [PATCH 020/113] Zoom Toggle: Alt+Shift+E and Alt-click cancel it and keep the current view Cancel Zoom Toggle (view.zoom_toggle_cancel, Alt+Shift+E, Option+Shift+E on a Mac) turns Zoom Toggle off without going back to the view it stored: the zoom, the horizontal and vertical scroll and the track heights stay as they are. An Alt-click (Option-click) on the Zoom Toggle button runs the same command. The next Zoom Toggle zooms in from the current view and goes back to it. When the command runs, from a key, the button or the control channel, the Edit window clears the vertical scroll position that Zoom Toggle would restore. --- ROADMAP.md | 4 ++- crates/engine/src/cmd/zoom_toggle.rs | 32 ++++++++++++++++-- crates/ui-egui/src/edit_window.rs | 12 ++++++- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/tests/it/zoom_toggle.rs | 45 +++++++++++++++++++++++++- docs/ui-parity.md | 4 ++- 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cc80cc78..8eac794f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change. - **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change. - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change. - **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change. @@ -175,6 +176,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged | | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged | diff --git a/crates/engine/src/cmd/zoom_toggle.rs b/crates/engine/src/cmd/zoom_toggle.rs index 24781759..2735133d 100644 --- a/crates/engine/src/cmd/zoom_toggle.rs +++ b/crates/engine/src/cmd/zoom_toggle.rs @@ -1,4 +1,5 @@ -//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. +//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. Cancel Zoom Toggle turns it off +//! and keeps the current view. use super::{CommandSpec, always, f64_or}; use soundcraft_model::{TrackHeight, ZoomToggle}; use soundcraft_time::MAX_POSITION; @@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; pub fn specs() -> Vec { - vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)] + vec![ + cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle), + cmd!(noundo "view.zoom_toggle_cancel", "Cancel Zoom Toggle", [], Some("Alt+Shift+E"), "{}", always, cancel), + ] +} + +/// Forget the view Zoom Toggle would go back to, leaving the zoom, scroll and track heights as they are. +fn cancel(e: &mut Engine, _: &Value) -> Result { + let was_on = e.session_mut().edit.zoom_toggle.take().is_some(); + Ok(json!({"on": false, "canceled": was_on})) } fn toggle(e: &mut Engine, p: &Value) -> Result { @@ -73,6 +83,24 @@ mod tests { assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium])); } + #[test] + fn cancel_keeps_the_zoomed_view_and_the_next_toggle_zooms_in_again() { + let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000})); + e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(); + let zoomed = view(&e); + assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": true})); + assert_eq!(e.session().edit.zoom_toggle, None); + assert_eq!(view(&e), zoomed, "cancel keeps the view Zoom Toggle zoomed in to"); + assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": false})); + assert_eq!(view(&e), zoomed); + e.execute("view.zoom_set", &json!({"samples_per_px": 200.0})).unwrap(); + assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true})); + e.execute("view.zoom_toggle", &json!({})).unwrap(); + assert_eq!(view(&e), (200.0, 48_000, vec![Fit(249), Medium, Fit(249)]), "the toggle goes back to the view it saw after the cancel"); + let spec = crate::cmd::command_specs().iter().find(|c| c.id == "view.zoom_toggle_cancel").unwrap(); + assert_eq!(spec.shortcut, Some("Alt+Shift+E")); + } + #[test] fn hostile_sizes_and_stored_views_stay_in_range() { let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION})); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 1f25fb6f..749bfada 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -78,6 +78,11 @@ impl EditLayout { pub fn scroll_by(&mut self, dy: f32, view_h: f32) { self.scroll_y = (self.scroll_y + dy).clamp(0.0, (self.content_h - view_h + 40.0).max(0.0)); } + + /// Clears the vertical scroll position Zoom Toggle would restore, so Cancel Zoom Toggle keeps the current scroll. + pub(crate) fn forget_zoom_back(&mut self) { + self.zoom_back = None; + } } pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect { @@ -469,8 +474,13 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { } } -/// Zoom Toggle sized to the track area on screen. +/// Zoom Toggle sized to the track area on screen. With Alt held (an Alt-click on the button) it cancels Zoom Toggle +/// instead and keeps the current view. pub fn zoom_toggle(app: &mut SoundApp) { + if app.docking_context.as_ref().is_some_and(|c| c.input(|i| i.modifiers.alt)) { + let _ = app.run("view.zoom_toggle_cancel", json!({})); + return; + } let [x0, y0, x1, y1] = app.edit_layout.timeline; let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0})); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b564f92..91694a07 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -597,6 +597,9 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } + if id == "view.zoom_toggle_cancel" { + self.edit_layout.forget_zoom_back(); + } if id == "track.clear_clip_indicators" { self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); } diff --git a/crates/ui-egui/tests/it/zoom_toggle.rs b/crates/ui-egui/tests/it/zoom_toggle.rs index d3badcff..2011c812 100644 --- a/crates/ui-egui/tests/it/zoom_toggle.rs +++ b/crates/ui-egui/tests/it/zoom_toggle.rs @@ -1,5 +1,5 @@ use egui::os::OperatingSystem; -use egui::{Key, vec2}; +use egui::{Key, Modifiers, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use serde_json::json; use soundcraft_ui_egui::{Services, SoundApp}; @@ -41,3 +41,46 @@ fn e_fits_the_selection_to_the_edit_window_and_the_button_goes_back() { assert_eq!(view(h.state()), before, "{os:?}"); } } + +#[test] +fn alt_shift_e_and_an_alt_click_cancel_zoom_toggle_and_keep_the_zoomed_view() { + for os in [OperatingSystem::Mac, OperatingSystem::Windows, OperatingSystem::Nix] { + for alt_click in [false, true] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let bass = app.engine.session().track_by_name("Bass").unwrap().id.0; + app.engine.execute("edit.select", &json!({"tracks": [bass], "start": 96_000, "end": 192_000})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_os(os).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + let view = |a: &SoundApp| (a.engine.session().edit.zoom, a.edit_layout.rows.clone(), a.edit_layout.scroll_y); + let before = view(h.state()); + h.get_by_label("Zoom Toggle (E)").click(); + h.run_steps(1); + let zoomed = view(h.state()); + assert_ne!(zoomed, before, "{os:?}"); + + if alt_click { + h.get_by_label("Zoom Toggle (E)").click_modifiers(Modifiers::ALT); + } else { + h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::E); + } + h.run_steps(2); + let how = if alt_click { "Alt-click" } else { "Alt+Shift+E" }; + assert_eq!(h.state().engine.session().edit.zoom_toggle, None, "{os:?} {how}"); + assert_eq!(view(h.state()), zoomed, "{os:?} {how} keeps the zoomed view"); + + // The next toggle zooms in from here and goes back here, not to the view before the cancel. + h.get_by_label("Zoom Toggle (E)").click(); + h.run_steps(1); + assert!(h.state().engine.session().edit.zoom_toggle.is_some(), "{os:?} {how}"); + h.get_by_label("Zoom Toggle (E)").click(); + h.run_steps(1); + assert_eq!(view(h.state()), zoomed, "{os:?} {how}"); + } + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f7d06d57..0778d0b1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-25 · **Change:** minor (Cancel Zoom Toggle) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -21,6 +21,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | +| Zoom Toggle | 🟡 | E (Commands focus) and the button zoom to the edit selection with Fit To Window heights and back; Alt+Shift+E and Alt-click cancel it and keep the view. The Zoom Toggle preferences (Vertical Zoom, Horizontal Zoom, Track Height, Track View, Separate Grid Settings When Zoomed In, Zoom Toggle Follows Edit Selection, Remove Range Selection After Zooming In) are not present | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | | Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | | | Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured | @@ -69,6 +70,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) | From 0c7212854a5fa200c63ca394dd8bf3163717a7fe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 17:36:40 -0700 Subject: [PATCH 021/113] Web: Check for Updates opens its window, with no dead_code warnings in xtask wasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SoundApp::logic and SoundApp::ui called updates_ui::tick and updates_ui::show only off wasm32, so `cargo xtask wasm` reported both functions as never used. On the web, SoundCraft > Check for Updates… sets the window's message ("Update check is not supported in the web browser.") but nothing drew the window. Both functions already have wasm32 branches, so the calls now run on every target, as effectcraft's lib.rs calls them. --- crates/ui-egui/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b564f92..683b4579 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1404,7 +1404,6 @@ impl SoundApp { if ctx.current_pass_index() == 0 { bounce::step(self, ctx); import::step(self, ctx); - #[cfg(not(target_arch = "wasm32"))] updates_ui::tick(self, ctx); } if !self.pointer_down { @@ -1770,7 +1769,6 @@ impl SoundApp { score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); - #[cfg(not(target_arch = "wasm32"))] updates_ui::show(self, &ctx); // The sampler's drop rects are current now, so route any captured drop in this pass. self.route_dropped_files(); From 9a99c82d2c54db4a16248bf9041c365fd131d5aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 21:03:25 -0700 Subject: [PATCH 022/113] Engine: wasm32 clippy reports no drop_non_drop in autosave and backups On wasm32-unknown-unknown, std::fs::File has no Drop, so clippy with -D warnings reports the three explicit drops of a File or Option in soundcraft-engine as drop_non_drop errors: Autosave::discard, autosave::leftovers and io::copy_new. Autosave::discard now releases the lock by setting the field to None, which closes the file on every target. The drops in leftovers and copy_new carry #[cfg(not(target_arch = "wasm32"))], as the DirBuilder mode call in Autosave::create carries #[cfg(unix)]. The desktop builds close the same files at the same points as before. --- crates/engine/src/autosave.rs | 3 ++- crates/engine/src/io.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 7c6c7c54..212d3036 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -255,7 +255,7 @@ impl Autosave { /// Delete the folder: the session closed with nothing left to recover. fn discard(mut self) { - drop(self.lock.take()); + self.lock = None; let _ = std::fs::remove_dir_all(&self.dir); } } @@ -286,6 +286,7 @@ pub fn leftovers(root: &Path) -> Vec { let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok()); let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified()); let recording = dir.join(RECORDING).is_file(); + #[cfg(not(target_arch = "wasm32"))] drop(lock); match saved { Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 265d87cf..d9c64d9b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1277,6 +1277,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> { let mut src = std::fs::File::open(from)?; let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?; if let Err(err) = std::io::copy(&mut src, &mut dst) { + #[cfg(not(target_arch = "wasm32"))] drop(dst); let _ = std::fs::remove_file(to); return Err(err); From 75e5b90a869d2c3e13f54af708dbb3fb69a1116d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:37:43 +0000 Subject: [PATCH 023/113] Clamp clip fades after AudioSuite processes that shorten a clip Found while testing crates/engine/src/cmd/audiosuite.rs:132-137 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/audiosuite.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 5d727119..a4e107ed 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -137,6 +137,7 @@ fn process(e: &mut Engine, p: &Value) -> Result { nc.name = new_name; nc.content = ClipContent::Audio { source: src, offset: 0 }; nc.length = i64::try_from(frames).unwrap_or(nc.length); + nc.clamp_fades(); // The render already has the clip's stretch applied; keeping it would stretch the new file again. nc.stretch = 1.0; // Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain, @@ -246,6 +247,21 @@ mod tests { assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}"); } + #[test] + fn shortening_process_clamps_fades_to_the_new_length() { + let (mut e, id) = gained_clip(); + { + let c = e.session_mut().find_clip_mut(id).unwrap(); + c.fade_in.len = 60; + c.fade_out.len = 10; + } + e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!(c.length < 64, "{}", c.length); + assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length); + assert_eq!(c.fade_out.len, 10.min(c.length)); + } + #[test] fn processed_clip_does_not_keep_the_stretch_it_baked() { for stretch in [0.5, 2.0] { From 9dc26fa1cafdbe9a76cd8e35c99c61b6415f50db Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:50:47 +0100 Subject: [PATCH 024/113] Cut BWF bext text fields at UTF-8 character boundaries Found while testing crates/audio-io/src/wav.rs:343-348 (put_fixed_str, used by bext_chunk) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/audio-io/src/wav.rs | 5 ++++- crates/audio-io/tests/it/roundtrip.rs | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index ee93cd46..532c2460 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -342,7 +342,10 @@ pub(crate) fn read_channel_mask(b: &[u8]) -> Option { fn put_fixed_str(out: &mut Vec, s: &str, len: usize) { let bytes = s.as_bytes(); - let n = bytes.len().min(len); + let mut n = bytes.len().min(len); + while !s.is_char_boundary(n) { + n -= 1; + } out.extend_from_slice(bytes.get(..n).unwrap_or_default()); out.resize(out.len() + (len - n), 0); } diff --git a/crates/audio-io/tests/it/roundtrip.rs b/crates/audio-io/tests/it/roundtrip.rs index 70042966..77cd2b69 100644 --- a/crates/audio-io/tests/it/roundtrip.rs +++ b/crates/audio-io/tests/it/roundtrip.rs @@ -316,3 +316,13 @@ fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() { let frames = u64::MAX / 4; assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_)))); } + +#[test] +fn bwf_truncation_keeps_utf8_boundaries() { + let src = sine(48_000, 1, 10); + let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() }; + let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap(); + let out = probe(&bytes, None).unwrap().bwf.unwrap(); + assert_eq!(out.originator, "a".repeat(31)); + assert_eq!(out.description, "d".repeat(255)); +} From 0d0d0abc27540f51271b7ad092c70f4c645ff155 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:44:02 +0000 Subject: [PATCH 025/113] Clamp clip fades after elastic warp and remove warp Found while testing crates/engine/src/cmd/clip.rs:217-231 (elastic), also :249 (remove_warp) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/clip.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 89340187..8cbbf797 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -228,6 +228,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result { nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 }; nc.length = out_len; nc.stretch = 1.0; + nc.clamp_fades(); super::more_util::replace_clip(s, t, *id, nc); rendered += 1; } @@ -246,7 +247,7 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result { if let Some((src, off, len)) = orig { c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off }; c.length = len.max(1); - c.fade_out.len = c.fade_out.len.min(c.length); + c.clamp_fades(); n += 1; } } @@ -496,6 +497,27 @@ mod tests { } } + #[test] + fn warping_and_unwarping_keep_fades_within_the_clip() { + let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000); + if let Some(c) = e.session_mut().find_clip_mut(cid) { + c.fade_in.len = 20_000; + c.fade_out.len = 20_000; + } + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap(); + let c = e.session().find_clip(cid).unwrap().1.clone(); + assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len); + + let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap(); + if let Some(c) = e.session_mut().find_clip_mut(cid) { + c.fade_in.len = 40_000; + } + e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap(); + let c = e.session().find_clip(cid).unwrap().1.clone(); + assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len); + } + #[test] fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() { let mut e = crate::demo::demo_engine(); From afa1532aba213624faffd6e75de3e0d255eca3c6 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:50:39 +0100 Subject: [PATCH 026/113] Mix: clamp clip-FX gain and send-level automation (fixes #884) A shared session or an MCP clip.effects_set could store clip-FX gain or a send-level automation point far above any fader (up to the +200 dB cap in db_to_gain). Clamp clip-FX gain to the slider's +24 dB, send-level lanes to +12 dB, and every loaded automation point to its AutoParam range on sanitize. Co-Authored-By: opencode beam-free Co-Authored-By: MiniMax M3 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/clip_more.rs | 17 +++++++++++++++-- crates/mix/src/lib.rs | 9 +++++++-- crates/model/src/session.rs | 2 ++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index a2ebd8b3..7a2564c8 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result { fn effects_set(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?; - let vals: Vec<(String, f64)> = - params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect(); + let vals: Vec<(String, f64)> = params + .iter() + .filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))) + .map(|(k, v)| { + let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v }; + (k, v) + }) + .collect(); let s = e.session_mut(); for c in &ids { for (k, v) in &vals { @@ -316,6 +322,13 @@ mod tests { (Engine::new(s), t, ids) } + #[test] + fn effects_set_caps_gain() { + let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]); + e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap(); + assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0)); + } + #[test] fn unloop_removes_copies() { let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]); diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 8276220e..9051f062 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -76,6 +76,11 @@ pub fn main_channels(s: &Session) -> usize { /// (insert slots use their index). pub const INSTRUMENT_SLOT: usize = usize::MAX; +/// Top of the clip effects gain slider; a stored or wired gain is clamped to it (#884). +pub const CLIP_FX_MAX_GAIN_DB: f32 = 24.0; +/// Top of a send fader, the same +12 dB `AutoParam::SendLevel` ranges up to (#884). +pub const SEND_LEVEL_MAX_DB: f32 = 12.0; + /// Hosted third-party plugin ids (CLAP or VST3): created off the audio thread in realtime use. pub fn is_third_party(id: &str) -> bool { id.starts_with(soundcraft_clap_host::ID_PREFIX) @@ -1279,7 +1284,7 @@ impl ClipFx { c.set_param(p, v); } } else if k == "gain" { - self.gain_db = v; + self.gain_db = if v.is_nan() { 0.0 } else { v.clamp(soundcraft_dsp::MIN_DB, CLIP_FX_MAX_GAIN_DB) }; } } } @@ -1967,7 +1972,7 @@ fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 { && let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0))) && !l.points.is_empty() { - return l.value_at(pos, base); + return l.value_at(pos, base).clamp(soundcraft_dsp::MIN_DB, SEND_LEVEL_MAX_DB); } base } diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 5283ff83..9f5221ab 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -626,6 +626,8 @@ impl Session { } for l in &mut t.automation { l.points.retain(|p| p.value.is_finite()); + let (lo, hi, _) = l.param.range(); + l.points.iter_mut().for_each(|p| p.value = p.value.clamp(lo, hi)); l.points.iter_mut().for_each(|p| bound(&mut p.at)); l.points.sort_by_key(|p| p.at); } From 45498c826890810f46a0145004aff060a08b3b62 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:34:20 +0000 Subject: [PATCH 027/113] Shift selection memory location ends on Cut Time and Insert Time Found while testing crates/engine/src/cmd/event.rs:162-167 (insert_time) and :188-194 (cut_time) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/event.rs | 6 ++++++ crates/engine/tests/it/edit_modes.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 018209b6..de9a056d 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -163,6 +163,8 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result { if m.start >= r.start { m.start += r.len(); m.end += r.len(); + } else if m.end > r.start { + m.end += r.len(); } } Ok(json!({"inserted": r.len()})) @@ -190,6 +192,10 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result { if m.start >= r.end { m.start -= r.len(); m.end -= r.len(); + } else if m.end >= r.end { + m.end -= r.len(); + } else if m.end > r.start { + m.end = r.start; } } Ok(json!({"removed": r.len()})) diff --git a/crates/engine/tests/it/edit_modes.rs b/crates/engine/tests/it/edit_modes.rs index 27d4a649..dc93d503 100644 --- a/crates/engine/tests/it/edit_modes.rs +++ b/crates/engine/tests/it/edit_modes.rs @@ -413,3 +413,23 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() { run(&mut e, "edit.mode", json!({"mode": "grid"})); assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err()); } + +fn selection_marker(e: &mut Engine, start: i64, end: i64) -> usize { + run(e, "markers.add", json!({"kind": "selection", "start": start, "end": end})); + e.session().markers.len() - 1 +} + +#[test] +fn cut_and_insert_time_move_the_end_of_a_spanning_selection_memory_location() { + let (mut e, _, _) = engine("slip"); + let a = selection_marker(&mut e, 1000, 5000); + let b = selection_marker(&mut e, 1000, 2500); + run(&mut e, "event.cut_time", json!({"start": 2000, "end": 3000})); + let m = &e.session().markers; + assert_eq!((m[a].start, m[a].end), (1000, 4000)); + assert_eq!((m[b].start, m[b].end), (1000, 2000)); + run(&mut e, "event.insert_time", json!({"start": 2000, "length": 1000})); + let m = &e.session().markers; + assert_eq!((m[a].start, m[a].end), (1000, 5000)); + assert_eq!((m[b].start, m[b].end), (1000, 2000)); +} From e631966cf74a39ed0ba65abc976be1a9ac8b64c2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:21:59 +0000 Subject: [PATCH 028/113] Keep crossfade overlaps when pasting clips Found while testing crates/engine/src/edit.rs:235-248 (paste, via place_clip at :252-258) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/edit.rs | 5 ++++- crates/engine/src/edit/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index beed1e8f..25192ce3 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -238,13 +238,16 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt make_room(s, track, at, length)?; } let mut ids = Vec::new(); + let mut placed = Vec::new(); for c in clips { let mut nc = c.clone(); nc.id = s.new_clip_id(); nc.start += at; ids.push(nc.id); - place_clip(s, track, nc); + placed.push((track, nc)); } + // Clear every destination first, so pasted clips that overlap (crossfades) keep each other. + put_clips(s, placed); Ok(ids) } diff --git a/crates/engine/src/edit/tests.rs b/crates/engine/src/edit/tests.rs index 95866b93..873e97b0 100644 --- a/crates/engine/src/edit/tests.rs +++ b/crates/engine/src/edit/tests.rs @@ -343,3 +343,24 @@ fn delete_fades_leaves_unrelated_overlaps_alone() { let c = s.track(t).unwrap().clips(); assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100)); } + +#[test] +fn paste_keeps_crossfade_overlap() { + let (mut s, t) = session_with_clip(); + let (a, b) = (s.new_clip_id(), s.new_clip_id()); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.clear(); + let mut ca = Clip::audio(a, "a", SourceId(500), 0, 1000, 2000); + ca.fade_out = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 }; + let mut cb = Clip::audio(b, "b", SourceId(500), 0, 2500, 2500); + cb.fade_in = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 }; + pl.clips.push(ca); + pl.clips.push(cb); + pl.sort(); + } + let copied = copy_range(&s, t, Range::new(0, 6000)); + paste(&mut s, t, 20_000, &copied, 6000, false).unwrap(); + let pasted: Vec<_> = + s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect(); + assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]); +} From 795f1cb6ef9e4ce836b71d701d261f43786904b6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:34:36 +0000 Subject: [PATCH 029/113] Keep MIDI note spacing when moving notes left past tick 0 Found while testing crates/engine/src/cmd/midi.rs:128-131 (notes_move) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/midi.rs | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 0eb61cbc..b0a2faa0 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result { let dt = i64_or(p, "ticks", 0); let ds = i64_or(p, "semitones", 0); let seq = seq_mut(e, id, "midi.notes_move")?; + // Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing. + let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min(); + let dt = match earliest { + Some(m) => dt.max(0i64.saturating_sub(m).min(0)), + None => dt, + }; for (i, n) in seq.notes.iter_mut().enumerate() { if idx.contains(&i) { n.start = n.start.saturating_add(dt).max(0); @@ -168,6 +174,36 @@ mod tests { assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err()); } + #[test] + fn notes_move_left_past_zero_keeps_spacing() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + for s in [100, 200, 300] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap(); + } + e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap(); + let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + let starts: Vec = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect(); + assert_eq!(starts, [0, 100, 300]); + } + + #[test] + fn notes_move_without_left_overflow_does_not_shift_selection() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap(); + for s in [0, 100] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap(); + } + seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50; + e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap(); + let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + let starts: Vec = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect(); + assert_eq!(starts, [0, 100]); + } + #[test] fn notes_move_huge_params_do_not_overflow() { let mut e = Engine::default(); From 9adcc1f38990c370469ed3a38d74d12aa3a75484 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:29:22 +0000 Subject: [PATCH 030/113] Report the decoded length for AAC in MP4 in audio-io probe Fixes #1113 Co-Authored-By: Claude Opus 5.5 --- crates/audio-io/src/symph.rs | 37 +++++++++++++++- crates/audio-io/tests/it/main.rs | 1 + crates/audio-io/tests/it/mp4_length.rs | 61 ++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 crates/audio-io/tests/it/mp4_length.rs diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index ddf3d719..ca96b964 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf Ok((info, AudioBuffer { sample_rate, channels: planar })) } +/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length. +const AAC_FRAMES_PER_PACKET: u64 = 1024; + +/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length, +/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last +/// sample whose duration differs from a frame makes the two disagree. +fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool { + reader.format_info().short_name == "isomp4" + && reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC) +} + +/// The decoded length of AAC in MP4: count the track's packets without decoding them. +fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result { + let mut frames: u64 = 0; + loop { + match reader.next_packet() { + Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET), + Ok(Some(_)) => {} + Ok(None) => break, + Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(SymError::ResetRequired) => break, + Err(e) => { + demux_failure(frames, e)?; + break; + } + } + } + Ok(frames) +} + pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { guarded(|| { - let reader = match open(bytes, detected)? { + let mut reader = match open(bytes, detected)? { Opened::Reader(reader) => reader, Opened::Empty(info) => return Ok(info), }; - let (_, info) = info_of(reader.as_ref(), detected)?; + let (track_id, mut info) = info_of(reader.as_ref(), detected)?; + if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) { + info.frames = mp4_aac_frames(reader.as_mut(), track_id)?; + } if info.frames > 0 && info.channels > 0 { return Ok(info); } diff --git a/crates/audio-io/tests/it/main.rs b/crates/audio-io/tests/it/main.rs index 84b90aa1..52812387 100644 --- a/crates/audio-io/tests/it/main.rs +++ b/crates/audio-io/tests/it/main.rs @@ -3,6 +3,7 @@ mod encode_format; mod float_headroom; mod malformed; +mod mp4_length; mod roundtrip; mod stream; mod wav_empty_data; diff --git a/crates/audio-io/tests/it/mp4_length.rs b/crates/audio-io/tests/it/mp4_length.rs new file mode 100644 index 00000000..c24bc7ce --- /dev/null +++ b/crates/audio-io/tests/it/mp4_length.rs @@ -0,0 +1,61 @@ +//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113). + +use soundcraft_audio_io::{decode, probe}; + +fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { + [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat() +} + +/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s. +fn full(kind: &[u8; 4], fields: &[u32]) -> Vec { + let body: Vec = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect(); + atom(kind, &body) +} + +/// An MPEG-4 descriptor with a one-byte length. +fn descriptor(tag: u8, body: &[u8]) -> Vec { + [&[tag, body.len() as u8][..], body].concat() +} + +/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END. +const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07]; + +/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts. +fn aac_mp4(packets: u32, last_dur: u32) -> Vec { + // AudioSpecificConfig: AAC-LC, 48 kHz, one channel. + let dsi = descriptor(5, &[0x11, 0x88]); + let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat()); + let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat()); + let esds = atom(b"esds", &[&[0; 4][..], &es].concat()); + let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat()); + let total = (packets - 1) * 1024 + last_dur; + let moov = |offset: u32| { + let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat()); + let stbl = [ + stsd, + full(b"stts", &[2, packets - 1, 1024, 1, last_dur]), + full(b"stsc", &[1, 1, packets, 1]), + full(b"stsz", &[SILENT_SCE.len() as u32, packets]), + full(b"stco", &[1, offset]), + ]; + let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat(); + let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)]; + let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat(); + atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat()) + }; + let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom"); + let offset = (ftyp.len() + moov(0).len() + 8) as u32; + [ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat() +} + +#[test] +fn aac_in_mp4_probe_length_matches_the_decoded_length() { + // A last sample lasting until the track end (longer than a packet), and a control with a short one. + for last_dur in [1145, 546] { + let file = aac_mp4(10, last_dur); + let (_, buf) = decode(&file, Some("m4a")).unwrap(); + assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}"); + assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}"); + } +} From 79c45a154ddee1968e7f24778ee3599167956596 Mon Sep 17 00:00:00 2001 From: Dominic Anaeto Date: Tue, 27 Oct 2026 13:26:04 -0400 Subject: [PATCH 031/113] fix(cli): report kept-going script errors --- apps/soundcraft-cli/src/main.rs | 11 ++- apps/soundcraft-cli/tests/it/main.rs | 2 + apps/soundcraft-cli/tests/it/script_status.rs | 89 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 apps/soundcraft-cli/tests/it/script_status.rs diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 899ba71e..f5fd26bc 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -5,8 +5,10 @@ //! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither] //! dithers only when the output loses resolution, unless told //! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect] -//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line; -//! without --start/--end, --bounce covers the whole session) +//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] [--keep-going] +//! one `ID JSON` per line; --bounce defaults to the whole session +//! --keep-going continues after command errors, finishes requested +//! output, and exits nonzero; malformed JSON stops immediately //! soundcraft-cli commands [FILTER] list commands //! soundcraft-cli describe ID one command's details //! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel @@ -391,6 +393,7 @@ fn script(args: &[String]) -> ExitCode { Err(err) => return fail(err), }; let keep = args.iter().any(|a| a == "--keep-going"); + let mut failed = false; for (n, line) in text.lines().enumerate() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { @@ -408,10 +411,12 @@ fn script(args: &[String]) -> ExitCode { if !keep { return ExitCode::FAILURE; } + failed = true; } } } - finish(&mut e, args) + let result = finish(&mut e, args); + if failed { ExitCode::FAILURE } else { result } } fn commands(args: &[String]) -> ExitCode { diff --git a/apps/soundcraft-cli/tests/it/main.rs b/apps/soundcraft-cli/tests/it/main.rs index c573712b..d07d396a 100644 --- a/apps/soundcraft-cli/tests/it/main.rs +++ b/apps/soundcraft-cli/tests/it/main.rs @@ -3,3 +3,5 @@ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod cli; mod closed_stdout; + +mod script_status; diff --git a/apps/soundcraft-cli/tests/it/script_status.rs b/apps/soundcraft-cli/tests/it/script_status.rs new file mode 100644 index 00000000..6ac85aad --- /dev/null +++ b/apps/soundcraft-cli/tests/it/script_status.rs @@ -0,0 +1,89 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT.fetch_add(1, Ordering::Relaxed); + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn invoke(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap() +} + +#[test] +fn keep_going_runs_later_commands_and_finishes_but_reports_failure() { + let dir = Scratch::new(); + std::fs::write( + dir.0.join("audit.txt"), + "audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n", + ) + .unwrap(); + let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]); + assert_eq!(stop.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid")); + assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:")); + assert!(!dir.0.join("stopped.scraft").exists()); + + let keep = invoke( + &dir.0, + &["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"], + ); + assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr)); + assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid")); + let stdout = String::from_utf8_lossy(&keep.stdout); + assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}"); + assert!(dir.0.join("after.wav").is_file()); + let info = invoke(&dir.0, &["info", "after.scraft"]); + assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr)); + assert!(String::from_utf8_lossy(&info.stdout).contains("After failure")); +} + +#[test] +fn successful_script_and_parsing_or_finish_failures_keep_their_status() { + let dir = Scratch::new(); + std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap(); + let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]); + assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr)); + + std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap(); + let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]); + assert_eq!(malformed.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON")); + assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:")); + assert!(!dir.0.join("malformed.scraft").exists()); + + std::fs::write(dir.0.join("parent"), "not a directory").unwrap(); + for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] { + let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]); + assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr)); + assert!(String::from_utf8_lossy(&fail.stderr).contains("parent")); + } + + std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap(); + let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]); + assert_eq!(fail.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save")); + assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:")); + assert!(dir.0.join("engine.scraft").is_file()); + + let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]); + assert_eq!(both_fail.status.code(), Some(1)); + let errors = String::from_utf8_lossy(&both_fail.stderr); + assert!(errors.contains("session.save") && errors.contains("soundcraft-cli:"), "{errors}"); +} From c7a7cfa09b6f21308d5897bcfa6d0bcf1c5d5116 Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:48:57 +0000 Subject: [PATCH 032/113] Bound the source span of stretch renders (#895) render_stretched bounded only the output, at an hour. The source span it reads in is the clip length over its stretch, which the 0.1..10 ratio check lets reach ten hours, and it is held for every channel of the source. A session's clip length and stretch are not trusted, so a one-frame stereo file with a one-hour clip at stretch 0.1 made Remove Pitch Shift allocate about 14 GB and abort, losing unsaved work. Elastic Properties and TCE to Timeline go through the same code. Both the source span and the output are now bounded by render_limit for the source's channel count, like other offline renders, and the command is refused before anything is allocated. --- crates/engine/src/cmd/more_util.rs | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs index 3d68b6c0..7b63a838 100644 --- a/crates/engine/src/cmd/more_util.rs +++ b/crates/engine/src/cmd/more_util.rs @@ -155,6 +155,14 @@ pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) if !(0.1..=10.0).contains(&ratio) { return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10"))); } + // The source span read in can be 10× the output, and both are held for every channel: bound + // each like any other offline render (a session's clip length and stretch are not trusted). + let channels = audio.buffer.channels.len().max(1); + let limit = crate::io::render_limit(channels); + if src_len > limit as f64 || usize::try_from(out_len).map_or(true, |n| n > limit) { + let secs = limit as f64 / s.sample_rate.as_f64(); + return Err(bad(cmd, format!("the clip is too long to stretch: one render holds at most {secs:.0} s of {channels}-channel audio"))); + } let a = usize::try_from(offset.max(0)).unwrap_or(0); let n = src_len as usize; let ch: Vec> = audio @@ -213,4 +221,38 @@ mod tests { s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap(); assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples); } + + /// A stereo session with a one-frame source and one clip on it. + fn stretched_clip(length: Samples, stretch: f64) -> (Session, Clip) { + use soundcraft_model::{ChannelFormat, TrackKind}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long")); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] }; + let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav); + let cid = s.new_clip_id(); + let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length); + clip.stretch = stretch; + crate::edit::place_clip(&mut s, t, clip.clone()); + (s, clip) + } + + /// #895: a session's clip length and stretch made the source span read in 10× the one-hour + /// output limit, for every channel (about 14 GB here), and the allocation aborted the app. + #[test] + fn a_stretch_over_the_render_limit_is_refused_before_allocating() { + let hour = Session::default().sample_rate.samples(3600.0); + let (mut s, clip) = stretched_clip(hour, 0.1); + let err = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap_err(); + assert!(err.to_string().contains("too long to stretch"), "{err}"); + + let mut e = crate::Engine::new(s); + assert!(e.execute("clip.remove_pitch_shift", &serde_json::json!({"clips": [clip.id.0]})).is_err()); + let (_, kept) = e.session().find_clip(clip.id).unwrap(); + assert_eq!((kept.stretch, kept.length), (0.1, hour), "the clip is left as it was"); + + // An ordinary stretch still renders, at the length asked for. + let (mut s, clip) = stretched_clip(1_000, 0.5); + let src = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap().unwrap(); + assert!(s.pool.get(src).unwrap().buffer.channels.iter().all(|c| c.len() == 1_000)); + } } From 1aab8e449dffefebd329e91ec92181f7e82079ec Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:52:22 +0000 Subject: [PATCH 033/113] Audio import: regression test for oversized Matroska element sizes (#878) With symphonia 0.5, the Matroska reader allocated whatever size an element claimed before reading it, so a 30-byte file whose EBML header claims 2^48 bytes aborted the process on import, drag and drop or session open. Since the bump to symphonia 0.6 such reads grow with the data actually read (safe_read_into_boxed_slice), so the abort is gone without a SoundCraft-side check. Lock that in: the reported file, a track whose CodecPrivate claims 2^46 bytes and a SimpleBlock that claims 2^47 bytes must give an error (or, for the block, a best-effort read of what is there) under every hint, never an abort. The test passes with memory capped at 1 GB. --- crates/audio-io/tests/it/malformed.rs | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index 6d00bcdc..4b2889b3 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -208,6 +208,62 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() { assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets"))); } +/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and +/// `u64::MAX` writes the reserved "unknown size"), then `body`. +fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec { + let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size }; + [id, &size.to_be_bytes(), body].concat() +} + +/// The EBML header of a Matroska file. +fn ebml_header() -> Vec { + let doc_type = ebml(&[0x42, 0x82], 8, b"matroska"); + ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type) +} + +/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size +/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch +/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way +/// for the header, for codec data and for block frames. +#[test] +fn matroska_sizes_beyond_the_file_are_errors_not_aborts() { + let huge = 1u64 << 47; + // The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47. + let reported = [ + 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D, + 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61, + ]; + let segment = |body: Vec| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat(); + // A track whose CodecPrivate claims 2^46 bytes, inside parents that claim even more. + let codec_private = ebml(&[0x63, 0xA2], huge >> 1, &[0; 16]); + let track = ebml(&[0xAE], huge, &[ebml(&[0xD7], 1, &[1]), ebml(&[0x86], 6, b"A_OPUS"), codec_private].concat()); + let tracks = segment(ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)); + // A valid 16-bit PCM track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data. + let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat(); + let pcm = [ + ebml(&[0xD7], 1, &[1]), + ebml(&[0x73, 0xC5], 1, &[1]), + ebml(&[0x83], 1, &[2]), + ebml(&[0x86], 13, b"A_PCM/INT/LIT"), + ebml(&[0xE1], audio.len() as u64, &audio), + ] + .concat(); + let pcm = ebml(&[0xAE], pcm.len() as u64, &pcm); + let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat()); + let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat(); + let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info); + let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], pcm.len() as u64, &pcm), block].concat()); + for hint in [None, Some("mkv"), Some("wav")] { + for (name, f) in [("reported", &reported[..]), ("codec data", &tracks)] { + assert!(probe(f, hint).is_err(), "{name}, {hint:?}"); + assert!(decode(f, hint).is_err(), "{name}, {hint:?}"); + } + // The block reader stops at the end of the file: a best-effort read of what is there. + let _ = probe(&cluster, hint); + assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}"); + } +} + #[test] fn well_formed_caf_still_reads() { let pcm: Vec = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect(); From ed0aed5ccbb9ebc4ff7dd85f3a49309fde6b6239 Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:34:25 +0000 Subject: [PATCH 034/113] Matroska regression test: assert each case reaches the read it targets The codec-data file now has a valid Info and track up to its oversized CodecPrivate, and must fail with the end-of-file error from that read; the block file must probe successfully, so decoding reaches the cluster. An earlier rejection by anything else would now fail the test instead of passing it without exercising the oversized read. --- crates/audio-io/tests/it/malformed.rs | 54 +++++++++++++++------------ 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index 4b2889b3..0351725d 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -234,32 +234,40 @@ fn matroska_sizes_beyond_the_file_are_errors_not_aborts() { 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61, ]; let segment = |body: Vec| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat(); - // A track whose CodecPrivate claims 2^46 bytes, inside parents that claim even more. - let codec_private = ebml(&[0x63, 0xA2], huge >> 1, &[0; 16]); - let track = ebml(&[0xAE], huge, &[ebml(&[0xD7], 1, &[1]), ebml(&[0x86], 6, b"A_OPUS"), codec_private].concat()); - let tracks = segment(ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)); - // A valid 16-bit PCM track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data. - let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat(); - let pcm = [ - ebml(&[0xD7], 1, &[1]), - ebml(&[0x73, 0xC5], 1, &[1]), - ebml(&[0x83], 1, &[2]), - ebml(&[0x86], 13, b"A_PCM/INT/LIT"), - ebml(&[0xE1], audio.len() as u64, &audio), - ] - .concat(); - let pcm = ebml(&[0xAE], pcm.len() as u64, &pcm); - let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat()); let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat(); let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info); - let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], pcm.len() as u64, &pcm), block].concat()); + // A valid 16-bit PCM track entry's children, followed by `extra`. + let pcm = |extra: &[u8]| { + let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat(); + [ + ebml(&[0xD7], 1, &[1]), + ebml(&[0x73, 0xC5], 1, &[1]), + ebml(&[0x83], 1, &[2]), + ebml(&[0x86], 13, b"A_PCM/INT/LIT"), + ebml(&[0xE1], audio.len() as u64, &audio), + extra.to_vec(), + ] + .concat() + }; + // That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the + // segment is valid up to that read, which runs out of file. + let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16]))); + let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat()); + // The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data. + let track = pcm(&[]); + let track = ebml(&[0xAE], track.len() as u64, &track); + let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat()); + let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat()); + let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file")); for hint in [None, Some("mkv"), Some("wav")] { - for (name, f) in [("reported", &reported[..]), ("codec data", &tracks)] { - assert!(probe(f, hint).is_err(), "{name}, {hint:?}"); - assert!(decode(f, hint).is_err(), "{name}, {hint:?}"); - } - // The block reader stops at the end of the file: a best-effort read of what is there. - let _ = probe(&cluster, hint); + assert!(probe(&reported, hint).is_err(), "{hint:?}"); + assert!(decode(&reported, hint).is_err(), "{hint:?}"); + // Refused at the oversized read itself, not by anything before it. + assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err()); + assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}"); + // The file and its track open, so decoding reaches the block; the block reader stops at the + // end of the file, a best-effort read of what is there. + assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err()); assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}"); } } From 7c029c40cebdd7054e4e4d49bf61ace4c040c950 Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:14:20 +0000 Subject: [PATCH 035/113] Write send-level automation from the send faders (#1146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a send's level with automation enabled wrote nothing: the live pass in write_automation only recorded each track's main fader (`volume`). Send levels have their own lanes (`send_a_level` …), which playback reads, but nothing wrote them while playing. The send's level control in the strip slot and its floating send window now start a SendFader gesture, released like the main fader's. The pass writes the send's level with the track's mode, the same rules as the volume: Write throughout, Touch while the send is dragged, Latch and Touch/Latch from the first drag until stop (touched sends are kept per track and slot). --- crates/ui-egui/src/app.rs | 7 +-- crates/ui-egui/src/app/playback.rs | 45 +++++++++++++++++-- crates/ui-egui/src/edit_window/drag.rs | 2 +- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/mix_window/sends.rs | 2 + crates/ui-egui/src/tests.rs | 61 ++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c3289263..613370dc 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -94,6 +94,7 @@ impl SoundApp { quit_requested: false, arrange_request: false, touched: std::collections::HashSet::new(), + touched_sends: std::collections::HashSet::new(), last_write_at: i64::MIN, editor_states: Vec::new(), editor_poll_due: true, @@ -148,7 +149,7 @@ impl SoundApp { if hidden { self.pointer_down = false; self.wants_keyboard = false; - if matches!(self.gesture, Some(Gesture::Fader { .. })) { + if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { self.gesture = None; } } @@ -169,11 +170,11 @@ impl SoundApp { updates_ui::tick(self, ctx); } if !self.pointer_down { - if matches!(self.gesture, Some(Gesture::Fader { .. })) { + if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { self.gesture = None; } // Keep a running automation pass merged until playback stops. - if !self.wants_keyboard && self.touched.is_empty() { + if !self.wants_keyboard && self.touched.is_empty() && self.touched_sends.is_empty() { self.engine.end_merge(); } } diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index fd17adc4..eb508f0a 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -204,7 +204,8 @@ impl SoundApp { } /// While playing, record volume automation from fader positions for tracks in Write mode, and - /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the + /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Send levels follow the + /// same rules, per send, from its slot or its floating send window. Each point replaces the /// automation since the previous one, and Stop writes a last point. A pass and whatever is /// dragged during it, its fader included, are one undo step. pub(crate) fn write_automation(&mut self) { @@ -214,7 +215,7 @@ impl SoundApp { let may_write = playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch)); self.engine.hold_merge(may_write.then_some(PASS)); - if !playing && self.touched.is_empty() { + if !playing && self.touched.is_empty() && self.touched_sends.is_empty() { self.last_write_at = i64::MIN; return; } @@ -230,6 +231,10 @@ impl SoundApp { Some(Gesture::Fader { track, .. }) => Some(*track), _ => None, }; + let send_fader = match &self.gesture { + Some(Gesture::SendFader { track, slot }) => Some((*track, *slot)), + _ => None, + }; let writes: Vec<(TrackId, f32)> = self .engine .session() @@ -247,9 +252,39 @@ impl SoundApp { write.then_some((t.id, t.mixer.volume_db)) }) .collect(); - for (t, v) in writes { + let send_writes: Vec<(TrackId, usize, f32)> = self + .engine + .session() + .tracks + .iter() + .flat_map(|t| { + let m = t.mixer.automation_mode; + t.mixer.sends.iter().enumerate().filter_map(move |(slot, s)| s.as_ref().map(|s| (t.id, m, slot, s.level_db))) + }) + .filter(|&(t, m, slot, _)| { + let touched_now = dragging && send_fader == Some((t, slot)); + match m { + M::Write => true, + M::Touch => touched_now, + M::Latch | M::TouchLatch => touched_now || self.touched_sends.contains(&(t, slot)), + _ => false, + } + }) + .map(|(t, _, slot, db)| (t, slot, db)) + .collect(); + let points: Vec<(TrackId, String, f32)> = writes + .iter() + .map(|&(t, v)| (t, "volume".to_string(), v)) + .chain(send_writes.iter().map(|&(t, slot, v)| (t, format!("send_{}_level", (b'a' + slot as u8) as char), v))) + .collect(); + for &(t, _) in &writes { self.touched.insert(t); - let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}); + } + for &(t, slot, _) in &send_writes { + self.touched_sends.insert((t, slot)); + } + for (t, param, v) in points { + let mut point = serde_json::json!({"track": t.0, "param": param, "at": pos, "value": v}); if from != i64::MIN { point["from"] = from.into(); } @@ -257,6 +292,7 @@ impl SoundApp { } if !playing { self.touched.clear(); + self.touched_sends.clear(); self.engine.end_merge(); } } @@ -296,6 +332,7 @@ impl SoundApp { self.punch = None; self.gesture = None; self.touched.clear(); + self.touched_sends.clear(); self.last_write_at = i64::MIN; self.edit_layout.follow_hold = false; self.meters.clear(); diff --git a/crates/ui-egui/src/edit_window/drag.rs b/crates/ui-egui/src/edit_window/drag.rs index 09c6b326..d2d8eae3 100644 --- a/crates/ui-egui/src/edit_window/drag.rs +++ b/crates/ui-egui/src/edit_window/drag.rs @@ -32,7 +32,7 @@ pub(super) fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32, /// Runs before anything is painted, against the last frame's layout. pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { - if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { + if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { return; } let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 368c10bf..9c8c1be2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -235,6 +235,7 @@ pub enum Gesture { FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 }, Scrub { last: Samples }, Fader { track: TrackId, start_db: f32 }, + SendFader { track: TrackId, slot: usize }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples }, @@ -382,6 +383,8 @@ pub struct SoundApp { pub arrange_request: bool, /// Tracks whose automation was touched during this pass (Touch/Latch writing). touched: std::collections::HashSet, + /// Sends whose level was touched during this pass (Touch/Latch writing), as (track, slot). + touched_sends: std::collections::HashSet<(TrackId, usize)>, /// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped. last_write_at: Samples, /// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`). diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index b1486087..e0ab578b 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -43,6 +43,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us // applying it to the already-updated value made trackpad adjustment accelerate. let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); let db = dragged_send_db(s.level_db, dy, fine); + app.gesture = Some(crate::Gesture::SendFader { track: id, slot }); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } @@ -117,6 +118,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { // Distinct from the strip's "Send A level for " icon and from the slot's "Send level", so a // label query while the window is open still picks one node. if let Some(db) = fader(ui, fr, send.level_db, ui.scope_id().with("send_fader"), "Send level fader") { + app.gesture = Some(crate::Gesture::SendFader { track: id, slot }); let _ = app.engine.execute_merged( "mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index bbbba444..6ebfb68f 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -244,6 +244,67 @@ fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() { assert_eq!(app.engine.undo_history().len(), steps + 1); } +/// #1146: a send's level writes its own automation lane, by the track's mode like its fader: Touch +/// while the send is dragged, Latch from the first drag until stop, Write throughout. The track's +/// volume lane is left alone. +#[test] +fn a_dragged_send_level_writes_send_automation() { + use soundcraft_model::AutoParam; + let points = |app: &SoundApp, param: &AutoParam| app.engine.session().track_by_name("Lead").unwrap().lane(param).map_or(0, |l| l.points.len()); + let level = |app: &SoundApp| app.engine.session().track_by_name("Lead").unwrap().lane(&AutoParam::SendLevel(0)).map(|l| l.points.clone()); + let pass = |app: &mut SoundApp| { + app.handle_transport(0.1); + app.write_automation(); + }; + for mode in ["touch", "latch", "write"] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let lead = app.engine.session().track_by_name("Lead").unwrap().id; + assert!(app.engine.session().track(lead).unwrap().mixer.sends[0].is_some(), "the demo's Lead sends to the reverb"); + let volume = points(&app, &AutoParam::Volume); + app.run("mix.automation_mode", json!({"tracks": ["Lead"], "mode": mode})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + for _ in 0..3 { + pass(&mut app); + } + let before_drag = points(&app, &AutoParam::SendLevel(0)); + if mode == "write" { + assert!(before_drag > 0, "Write writes the send without a drag"); + } else { + assert_eq!(before_drag, 0, "{mode}: nothing written before the send is touched"); + } + // What the send's slot and its floating window do while the level is dragged. + app.pointer_down = true; + app.gesture = Some(super::Gesture::SendFader { track: lead, slot: 0 }); + for frame in 0..4 { + pass(&mut app); + let db = -12.0 + frame as f32; + app.engine.execute_merged("mix.send_level", &json!({"track": lead.0, "slot": 0, "db": db}), &format!("send:{}:0", lead.0)).unwrap(); + } + let dragged = points(&app, &AutoParam::SendLevel(0)); + assert!(dragged >= before_drag + 3, "{mode}: the drag is written ({before_drag} -> {dragged})"); + assert!(level(&app).unwrap().iter().any(|p| (p.value - (-10.0)).abs() < 1e-6), "{mode}: the points follow the send level"); + // Released. + app.pointer_down = false; + app.gesture = None; + for _ in 0..3 { + pass(&mut app); + } + let released = points(&app, &AutoParam::SendLevel(0)); + if mode == "touch" { + assert_eq!(released, dragged, "Touch stops writing when the send is let go"); + } else { + assert!(released > dragged, "{mode}: keeps writing after the release"); + } + app.run("transport.stop", json!({})).unwrap(); + app.handle_transport(0.0); + app.write_automation(); + if mode != "write" { + assert_eq!(points(&app, &AutoParam::Volume), volume, "{mode}: the track's volume lane is left alone"); + } + assert!(app.touched_sends.is_empty(), "{mode}: stop ends the pass"); + } +} + #[test] fn plugin_states_read_during_a_drag_keep_it_one_undo_step() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); From ceb3404c31a4b5a50fd827d181c16d8ea0c93b8a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:58:58 +0100 Subject: [PATCH 036/113] Check the rewritten DSP paths against the algorithms they replaced True peak, the Maximizer window minimum, flush and GainRamp are compared bit for bit with their previous implementations computed in the test, so the checks hold whatever the platform libm. Co-Authored-By: Claude Opus 5.5 --- crates/dsp/src/meter.rs | 60 ++++++++++++++++++++++++++++++ crates/dsp/src/plugins/dynamics.rs | 41 ++++++++++++++++++++ crates/dsp/src/util.rs | 14 +++++++ crates/mix/src/tests.rs | 12 ++++++ 4 files changed, 127 insertions(+) diff --git a/crates/dsp/src/meter.rs b/crates/dsp/src/meter.rs index 442985d5..f5d15d53 100644 --- a/crates/dsp/src/meter.rs +++ b/crates/dsp/src/meter.rs @@ -395,3 +395,63 @@ impl LoudnessMeter { gain_to_db(self.true_peak) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn noise(len: usize, seed: u64, amp: f32) -> Vec { + let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + ((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp + }) + .collect() + } + + /// True peak as first written: the history shifted every sample, each phase summed on its own. + fn true_peak_reference(coefs: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 { + let mut h = [0.0f32; TP_TAPS]; + let mut tp = 0.0f32; + for v in x.iter().map(|v| if v.is_finite() { *v } else { 0.0 }) { + h.copy_within(0..TP_TAPS - 1, 1); + h[0] = v; + let mut peak = v.abs(); + for p in 0..TP_PHASES { + let y: f32 = coefs.iter().zip(h.iter()).map(|(c, b)| c[p] * b).sum(); + peak = peak.max(y.abs()); + } + if peak > tp { + tp = peak; + } + } + tp + } + + #[test] + fn true_peak_matches_the_shifted_history() { + let sr = 48_000.0; + let len = 20_000; + let mut l: Vec = + (0..len).map(|i| 0.6 * (std::f32::consts::TAU * 0.25 * i as f32 + 0.7).sin()).zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect(); + let r = noise(len, 2, 0.9); + l[123] = f32::NAN; + l[4_567] = f32::INFINITY; + for block in [1, 7, 480, len] { + let mut m = LoudnessMeter::new(sr, 2); + let want = true_peak_reference(&m.tp_coefs, &l).max(true_peak_reference(&m.tp_coefs, &r)); + for at in (0..len).step_by(block) { + let n = block.min(len - at); + m.process(&[l[at..at + n].to_vec(), r[at..at + n].to_vec()], n); + } + assert_eq!(m.true_peak.to_bits(), want.to_bits(), "block {block}"); + m.reset(); + let quiet = noise(500, 3, 0.1); + m.process(&[quiet.clone(), quiet.clone()], 500); + assert_eq!(m.true_peak.to_bits(), true_peak_reference(&m.tp_coefs, &quiet).to_bits(), "after reset, block {block}"); + } + } +} diff --git a/crates/dsp/src/plugins/dynamics.rs b/crates/dsp/src/plugins/dynamics.rs index 9d998e4c..1b8751dc 100644 --- a/crates/dsp/src/plugins/dynamics.rs +++ b/crates/dsp/src/plugins/dynamics.rs @@ -671,3 +671,44 @@ impl Plugin for Maximizer { self.gr_meter } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The window minimum as first written: the last `la` requests in a ring, scanned whole. + #[test] + fn window_min_matches_a_scan_of_the_last_requests() { + let mut x = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x + }; + for la in [1usize, 2, 3, 7, 64, 480] { + let mut m = Maximizer::new(); + m.win = vec![(0, 1.0); la]; + let mut ring = vec![1.0f32; la]; + let mut at = 0; + for i in 0..6_000 { + if i == 3_000 { + m.reset(); + ring.iter_mut().for_each(|v| *v = 1.0); + at = 0; + } + let r = next(); + let req = match r % 16 { + 0 => f32::NAN, + 1 => 1.0, + 2 => 0.25, + _ => (r >> 40) as f32 / (1u64 << 24) as f32 * 1.5, + }; + ring[at] = req; + at = (at + 1) % la; + let want = ring.iter().copied().fold(1.0f32, f32::min); + assert_eq!(m.window_min(req, la).to_bits(), want.to_bits(), "la {la}, sample {i}"); + } + } + } +} diff --git a/crates/dsp/src/util.rs b/crates/dsp/src/util.rs index 3ec2f705..d20b1afb 100644 --- a/crates/dsp/src/util.rs +++ b/crates/dsp/src/util.rs @@ -310,3 +310,17 @@ pub(crate) fn sort_events(ev: &mut [T], key: impl Fn(&T) -> usize) { pub(crate) fn note_hz(note: f32) -> f32 { 440.0 * 2f32.powf((note - 69.0) / 12.0) } + +#[cfg(test)] +mod tests { + #[test] + fn flush_matches_the_finite_and_magnitude_test() { + let edge = 1.0e-25f32.to_bits(); + let special = [0, 0x8000_0000, 1, edge - 1, edge, edge + 1, 0x7f7f_ffff, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0xffc0_0001]; + for b in (0..=u32::MAX).step_by(997).chain(special).chain(special.map(|b| b | 0x8000_0000)) { + let x = f32::from_bits(b); + let want = if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 }; + assert_eq!(super::flush(x).to_bits(), want.to_bits(), "{b:#x}"); + } + } +} diff --git a/crates/mix/src/tests.rs b/crates/mix/src/tests.rs index 5ebc6db1..49ff980d 100644 --- a/crates/mix/src/tests.rs +++ b/crates/mix/src/tests.rs @@ -1187,3 +1187,15 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() { } } } + +#[test] +fn gain_ramp_matches_converting_both_ends_every_sample() { + for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] { + let ramp = crate::automation::GainRamp::new(db0, db1, n); + let (g0, g1) = (db_to_gain(db0), db_to_gain(db1)); + for i in 0..=n { + let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) }; + assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}"); + } + } +} From dcdcca47049238c467ba995d0adb99ec0cc70003 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 03:31:06 -0700 Subject: [PATCH 037/113] Zoom Toggle: a cancel with Zoom Toggle off keeps the scroll to restore Cancel Zoom Toggle clears the saved vertical scroll only when it turns Zoom Toggle off, so a cancel run before the next frame after turning it off no longer drops the scroll position the toggle-off restores. --- crates/ui-egui/src/app/commands.rs | 2 +- crates/ui-egui/src/edit_window/tests.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index b569f0f2..49b2da00 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -30,7 +30,7 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } - if id == "view.zoom_toggle_cancel" { + if id == "view.zoom_toggle_cancel" && v["canceled"] == true { self.edit_layout.forget_zoom_back(); } if id == "track.clear_clip_indicators" { diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index f7f94bbe..3de95f6c 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -246,6 +246,16 @@ fn hold_clears_when_playhead_returns_or_transport_stops() { ); } +/// Cancel Zoom Toggle with Zoom Toggle already off keeps the scroll position a pending toggle-off restores. +#[test] +fn a_cancel_with_zoom_toggle_off_keeps_the_scroll_to_restore() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default()); + app.edit_layout.zoom_back = Some(120.0); + let v = app.run("view.zoom_toggle_cancel", json!({})).unwrap(); + assert_eq!(v["canceled"], json!(false)); + assert_eq!(app.edit_layout.zoom_back, Some(120.0)); +} + fn harness(n: usize) -> Harness<'static, SoundApp> { let mut engine = soundcraft_engine::Engine::default(); engine.execute("track.new", &json!({"count": n})).unwrap(); From 50d0028e07f1f8bf5eee8d0facfe51879e8a23d8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:43:00 +0000 Subject: [PATCH 038/113] Move key signatures with Insert Time and Cut Time Found while testing crates/engine/src/cmd/event.rs:147-196 (insert_time, cut_time); key_signatures in crates/model/src/session.rs:344 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/event.rs | 11 +++++ crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/time_key_signatures.rs | 41 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 crates/engine/tests/it/time_key_signatures.rs diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 018209b6..5aeedd42 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -165,6 +165,11 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result { m.end += r.len(); } } + for (at, _) in &mut s.key_signatures { + if *at >= r.start { + *at = at.saturating_add(r.len()); + } + } Ok(json!({"inserted": r.len()})) } @@ -192,6 +197,12 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result { m.end -= r.len(); } } + s.key_signatures.retain(|(at, _)| !r.contains(*at)); + for (at, _) in &mut s.key_signatures { + if *at >= r.end { + *at -= r.len(); + } + } Ok(json!({"removed": r.len()})) } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 611c7266..9ff28eef 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -16,5 +16,6 @@ mod random_edits; mod recording; mod session_transport; mod split_into_mono; +mod time_key_signatures; mod track_name_collisions; mod trim_to_selection; diff --git a/crates/engine/tests/it/time_key_signatures.rs b/crates/engine/tests/it/time_key_signatures.rs new file mode 100644 index 00000000..8b1d5860 --- /dev/null +++ b/crates/engine/tests/it/time_key_signatures.rs @@ -0,0 +1,41 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, Session, TrackKind}; + +fn engine_with_keys() -> Engine { + let mut session = Session::default(); + session.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut engine = Engine::new(session); + for (at, key) in [(10, "C major"), (48000, "D major"), (100000, "E major")] { + engine.execute("event.add_key_change", &json!({"key": key, "at": at})).unwrap(); + } + engine +} + +fn keys(engine: &Engine) -> Vec<(u64, String)> { + engine.session().key_signatures.iter().map(|(at, k)| (*at as u64, k.clone())).collect() +} + +fn k(at: u64, key: &str) -> (u64, String) { + (at, key.to_string()) +} + +#[test] +fn insert_time_moves_key_signatures() { + let mut engine = engine_with_keys(); + let original = keys(&engine); + engine.execute("event.insert_time", &json!({"start": 48000, "length": 1000})).unwrap(); + assert_eq!(keys(&engine), vec![k(10, "C major"), k(49000, "D major"), k(101000, "E major")]); + engine.undo(); + assert_eq!(keys(&engine), original); +} + +#[test] +fn cut_time_removes_and_moves_key_signatures() { + let mut engine = engine_with_keys(); + let original = keys(&engine); + engine.execute("event.cut_time", &json!({"start": 20, "end": 60000})).unwrap(); + assert_eq!(keys(&engine), vec![k(10, "C major"), k(40020, "E major")]); + engine.undo(); + assert_eq!(keys(&engine), original); +} From 90932afed01ec70ed009b2bedfb0075535b86034 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:16:19 +0000 Subject: [PATCH 039/113] Reply with Invalid Request to empty batches and non-object messages Found while testing crates/automation/src/mcp.rs:201-205 (Server::handle) and :258-261 (Server::serve) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/mcp.rs | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index e4ac191a..5b582dfc 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -199,6 +199,9 @@ impl Server { /// Handle one JSON-RPC message; returns the reply line (None for notifications). pub fn handle(&mut self, msg: &Value) -> Option { + if !msg.is_object() { + return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}})); + } let id = msg.get("id").cloned(); let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); let params = msg.get("params").cloned().unwrap_or(json!({})); @@ -255,6 +258,9 @@ impl Server { continue; } let reply = match serde_json::from_str::(&line) { + Ok(Value::Array(batch)) if batch.is_empty() => { + Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}})) + } Ok(Value::Array(batch)) => { let replies: Vec = batch.iter().filter_map(|m| self.handle(m)).collect(); if replies.is_empty() { None } else { Some(Value::Array(replies)) } @@ -270,3 +276,39 @@ impl Server { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Headless; + use soundcraft_engine::Engine; + + fn reply(line: &str) -> String { + let mut server = Server::new(Headless::new(Engine::default())); + let mut out = Vec::new(); + server.serve(line.as_bytes(), &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn invalid_requests_get_an_invalid_request_error() { + for input in ["[]", "42", "[1]", "\"x\""] { + let text = reply(input); + assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}"); + let v: Value = serde_json::from_str(text.trim()).unwrap(); + let e = match &v { + Value::Array(a) => a[0].clone(), + other => other.clone(), + }; + assert_eq!(e["error"]["code"], -32600, "input {input}"); + assert_eq!(e["id"], Value::Null, "input {input}"); + } + } + + #[test] + fn notifications_still_get_no_reply() { + assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), ""); + let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap(); + assert_eq!(v["id"], 1); + } +} From e600e6bf6451c88e7eba8a3af63e3087ea5acaf1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:40 +0100 Subject: [PATCH 040/113] Fix stale end on a Memory Location added during playback Found while testing crates/engine/src/cmd/event.rs:305 (add_marker) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/event.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 018209b6..1d905325 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -302,7 +302,10 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result { (None, Some(a), Some(b)) => (a, b), (None, Some(p), None) | (None, None, Some(p)) => (p, p), _ if kind == MarkerKind::Selection => (sel.start, sel.end), - _ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start), + _ => { + let point = if e.transport.playing { e.transport.position } else { sel.start }; + (point, point) + } }; let s = e.session_mut(); let n = s.markers.len() + 1; @@ -473,6 +476,19 @@ mod tests { assert_eq!(find(&c), Some((1000, 2000))); } + /// A plain marker added during playback is a point at the playhead, whatever the edit selection is. + #[test] + fn markers_add_while_playing_is_a_point_at_the_playhead() { + let mut e = demo_engine(); + e.session_mut().edit.selection.start = 48_000; + e.session_mut().edit.selection.end = 48_000; + e.transport.playing = true; + e.transport.position = 4800; + let r = e.execute("markers.add", &json!({"name": "M"})).unwrap(); + let m = e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end)); + assert_eq!(m, Some((4800, 4800))); + } + /// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it. #[test] fn edit_marker_sets_documented_color() { From 9f2be37e3627a49b0ff91e817aa294f526cbd17a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 11:48:06 -0400 Subject: [PATCH 041/113] Enforce Rust coding standards via Clippy (#1129) * chore(lints): apply strict workspace Clippy rules * refactor(h264): split decoding work by operation * refactor(h264): clarify prediction and filter inputs * docs: require workspace lint inheritance * refactor(audio): clarify host FFI imports and inputs * docs: clarify crate lint placement * refactor(audio): replace wildcard imports in I/O modules * refactor(mix): extract strip and routing stages * refactor(ui): split edit window event handlers * refactor(ui): clarify app and dialog actions * refactor(ui): simplify mixer and panel workflows * fix(ui): apply track resize only once --- CLAUDE.md | 4 + Cargo.toml | 4 +- clippy.toml | 1 + crates/au-host/Cargo.toml | 4 +- crates/clap-host/Cargo.toml | 4 +- crates/clap-host/src/ffi.rs | 2 +- crates/clap-host/src/ffi/bundle.rs | 7 +- crates/clap-host/src/ffi/events.rs | 8 +- crates/clap-host/src/ffi/gui.rs | 5 +- crates/clap-host/src/ffi/host.rs | 6 +- crates/clap-host/src/ffi/instance.rs | 29 +- crates/clap-host/src/plugin.rs | 12 +- crates/engine/src/io/bundle.rs | 2 +- crates/engine/src/io/import.rs | 5 +- crates/engine/src/io/midi.rs | 2 +- crates/engine/src/io/record.rs | 3 +- crates/engine/src/io/render.rs | 2 +- crates/engine/src/io/session.rs | 10 +- crates/engine/src/io/strip.rs | 4 +- crates/mix/src/automation.rs | 2 +- crates/mix/src/clips.rs | 2 +- crates/mix/src/instances.rs | 2 +- crates/mix/src/lib.rs | 112 +++-- crates/mix/src/midi.rs | 3 +- crates/mix/src/offline.rs | 2 +- crates/mix/src/pan.rs | 289 +++++++------ crates/mix/src/routing.rs | 5 +- crates/mix/src/strip.rs | 305 ++++++++------ crates/mix/src/threads.rs | 2 +- crates/playback/src/callback.rs | 6 +- crates/playback/src/stream.rs | 2 +- crates/ui-egui/src/app.rs | 6 +- crates/ui-egui/src/app/audio.rs | 2 +- crates/ui-egui/src/app/commands.rs | 2 +- crates/ui-egui/src/app/control.rs | 2 +- crates/ui-egui/src/app/files.rs | 2 +- crates/ui-egui/src/app/input.rs | 2 +- crates/ui-egui/src/app/playback.rs | 2 +- crates/ui-egui/src/app/plugins.rs | 2 +- crates/ui-egui/src/dialogs.rs | 315 ++++++++------ crates/ui-egui/src/dialogs/audiosuite.rs | 2 +- crates/ui-egui/src/dialogs/files.rs | 2 +- crates/ui-egui/src/dialogs/strip_silence.rs | 2 +- crates/ui-egui/src/edit_window/automation.rs | 2 +- crates/ui-egui/src/edit_window/clips.rs | 126 ++++-- crates/ui-egui/src/edit_window/drag.rs | 4 +- crates/ui-egui/src/edit_window/header.rs | 89 +++- crates/ui-egui/src/edit_window/input.rs | 230 ++++++---- crates/ui-egui/src/edit_window/layout.rs | 2 +- crates/ui-egui/src/edit_window/overlay.rs | 2 +- crates/ui-egui/src/edit_window/rulers.rs | 23 +- crates/ui-egui/src/edit_window/scroll.rs | 2 +- crates/ui-egui/src/edit_window/tests.rs | 396 +++++++++--------- crates/ui-egui/src/edit_window/tracks.rs | 11 +- crates/ui-egui/src/edit_window/universe.rs | 2 +- crates/ui-egui/src/mix_window.rs | 140 ++++++- crates/ui-egui/src/mix_window/plugin_menu.rs | 2 +- crates/ui-egui/src/mix_window/routing.rs | 2 +- crates/ui-egui/src/mix_window/sends.rs | 5 +- crates/ui-egui/src/mix_window/slots.rs | 2 +- crates/ui-egui/src/panels/about.rs | 2 +- crates/ui-egui/src/panels/clips.rs | 2 +- crates/ui-egui/src/panels/history.rs | 2 +- crates/ui-egui/src/panels/memory.rs | 2 +- crates/ui-egui/src/panels/midi_editor.rs | 242 +++++++---- crates/ui-egui/src/panels/plugins.rs | 2 +- crates/ui-egui/src/panels/tracks.rs | 5 +- crates/ui-egui/src/panels/transport.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 8 + crates/ui-egui/src/tests.rs | 92 ++-- .../ui-egui/tests/mix_instrument_contrast.rs | 23 +- crates/video/src/h264/deblock.rs | 53 ++- crates/video/src/h264/intra.rs | 241 ++++++----- crates/video/src/h264/slice.rs | 226 +++++----- crates/vst3-host/Cargo.toml | 4 +- crates/vst3-host/src/ffi.rs | 2 +- crates/vst3-host/src/ffi/editor.rs | 5 +- crates/vst3-host/src/ffi/host.rs | 5 +- crates/vst3-host/src/ffi/instance.rs | 36 +- crates/vst3-host/src/ffi/module.rs | 6 +- crates/vst3-host/src/ffi/process.rs | 5 +- crates/vst3-host/src/plugin.rs | 20 +- 82 files changed, 2016 insertions(+), 1196 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b5c917a3..9559f795 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,5 @@ Read `AGENTS.md`: it is the entry point for agents in this repo. + +New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit +`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes +before modules or other crate items; crate-level `//!` documentation may precede them. diff --git a/Cargo.toml b/Cargo.toml index d2a9c4fc..6f14fa4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,9 +70,11 @@ image = { version = "0.25", default-features = false, features = ["png"] } unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" [profile.dev.package."*"] diff --git a/clippy.toml b/clippy.toml index 6244ea8c..2a016f24 100644 --- a/clippy.toml +++ b/clippy.toml @@ -6,3 +6,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/crates/au-host/Cargo.toml b/crates/au-host/Cargo.toml index af793163..122c4919 100644 --- a/crates/au-host/Cargo.toml +++ b/crates/au-host/Cargo.toml @@ -20,7 +20,9 @@ log.workspace = true unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml index 96c7de7c..27eaf561 100644 --- a/crates/clap-host/Cargo.toml +++ b/crates/clap-host/Cargo.toml @@ -32,7 +32,9 @@ libloading = "0.9" unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index ab589957..8ae25c52 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -54,7 +54,7 @@ pub(crate) use events::Event; #[cfg(test)] use events::transport_event; pub(crate) use gui::GuiLink; -pub(crate) use instance::{Instance, NotePort, PortBuffers, PortInfo, RawParam}; +pub(crate) use instance::{Instance, NotePort, PortBuffers, PortInfo, ProcessInput, RawParam}; /// Most plugins one factory may report (hostile-input cap). const MAX_PLUGINS_PER_FACTORY: u32 = 1024; diff --git a/crates/clap-host/src/ffi/bundle.rs b/crates/clap-host/src/ffi/bundle.rs index e3cdeddc..503b6391 100644 --- a/crates/clap-host/src/ffi/bundle.rs +++ b/crates/clap-host/src/ffi/bundle.rs @@ -1,7 +1,12 @@ use super::gui::{GuiInner, GuiLink}; use super::host::{HostState, host_get_extension, host_request_callback, host_request_process, host_request_restart}; use super::instance::Instance; -use super::*; +use super::{ + Arc, CLAP_EXT_AUDIO_PORTS, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_NOTE_PORTS, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_PLUGIN_FACTORY_ID, + CLAP_VERSION, CString, ClapError, EVENT_CAP, HashMap, MAX_FEATURES, MAX_PLUGINS_PER_FACTORY, Mutex, OnceLock, Path, PathBuf, PoisonError, c_void, + 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, +}; // ---- loaded binaries ----------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/events.rs b/crates/clap-host/src/ffi/events.rs index 1bc2f3f8..d3bb04fe 100644 --- a/crates/clap-host/src/ffi/events.rs +++ b/crates/clap-host/src/ffi/events.rs @@ -1,4 +1,10 @@ -use super::*; +use super::{ + CLAP_BEATTIME_FACTOR, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, + CLAP_EVENT_TRANSPORT, CLAP_SECTIME_FACTOR, CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO, + CLAP_TRANSPORT_HAS_TIME_SIGNATURE, CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, MAX_STATE_BYTES, Transport, c_void, clap_event_header, + clap_event_midi, clap_event_note, clap_event_param_value, clap_event_transport, clap_input_events, clap_istream, clap_ostream, + clap_output_events, +}; // ---- events -------------------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/gui.rs b/crates/clap-host/src/ffi/gui.rs index 5a911a95..92619742 100644 --- a/crates/clap-host/src/ffi/gui.rs +++ b/crates/clap-host/src/ffi/gui.rs @@ -1,6 +1,9 @@ use super::events::ostream_write; use super::host::HostState; -use super::*; +use super::{ + Arc, CStr, CString, Mutex, Ordering, PoisonError, c_void, clap_ostream, clap_plugin, clap_plugin_gui, clap_plugin_state, clap_window, + clap_window_handle, hostwin, +}; // ---- editor (clap.gui) --------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/host.rs b/crates/clap-host/src/ffi/host.rs index 882c0377..408546dc 100644 --- a/crates/clap-host/src/ffi/host.rs +++ b/crates/clap-host/src/ffi/host.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + AtomicBool, AtomicU64, CLAP_EXT_GUI, CLAP_EXT_LATENCY, CLAP_EXT_LOG, CLAP_EXT_PARAMS, CLAP_EXT_STATE, CLAP_LOG_ERROR, CLAP_LOG_FATAL, + CLAP_LOG_WARNING, CStr, EDIT_CAP, Mutex, Ordering, c_char, c_void, clap_host, clap_host_gui, clap_host_latency, clap_host_log, clap_host_params, + clap_host_state, clap_id, clap_log_severity, clap_param_clear_flags, clap_param_rescan_flags, read_cstr, +}; // ---- host side ----------------------------------------------------------------------------- diff --git a/crates/clap-host/src/ffi/instance.rs b/crates/clap-host/src/ffi/instance.rs index fec2bd44..3a537fb6 100644 --- a/crates/clap-host/src/ffi/instance.rs +++ b/crates/clap-host/src/ffi/instance.rs @@ -2,7 +2,12 @@ use super::bundle::Bundle; use super::events::{Event, RawEvent, ReadCursor, in_events_get, in_events_size, istream_read, out_events_try_push, transport_event}; use super::gui::GuiLink; use super::host::HostState; -use super::*; +use super::{ + Arc, CLAP_AUDIO_PORT_IS_MAIN, CLAP_PROCESS_ERROR, CStr, ClapError, MAX_PARAMS, MAX_PORT_CHANNELS, MAX_PORTS, Ordering, Transport, c_char, c_void, + clap_audio_buffer, clap_audio_port_info, clap_host, clap_input_events, clap_istream, clap_note_port_info, clap_output_events, clap_param_info, + clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process, + read_name, +}; // ---- audio buffers ------------------------------------------------------------------------- @@ -312,16 +317,8 @@ impl Instance { /// Runs one `clap_process` call over `frames` frames. Events must be sorted by time; their /// times are made relative to `offset`. Returns the CLAP process status. - pub fn process( - &mut self, - frames: usize, - steady_time: i64, - inputs: &mut PortBuffers, - outputs: &mut PortBuffers, - events: &[Event], - offset: u32, - transport: Option<&Transport>, - ) -> Result { + pub fn process(&mut self, input: ProcessInput<'_>) -> Result { + let ProcessInput { frames, steady_time, inputs, outputs, events, offset, transport } = input; if !self.active { return Err(ClapError::Process("not active".into())); } @@ -386,6 +383,16 @@ impl Instance { } } +pub(crate) struct ProcessInput<'a> { + pub frames: usize, + pub steady_time: i64, + pub inputs: &'a mut PortBuffers, + pub outputs: &'a mut PortBuffers, + pub events: &'a [Event], + pub offset: u32, + pub transport: Option<&'a Transport>, +} + impl Drop for Instance { fn drop(&mut self) { // Editors first (CLAP: destroy the GUI before the plugin), and no handle may call in after. diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 4b685cf8..8e048e2a 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -1,6 +1,6 @@ //! `ClapPlugin`: a loaded CLAP instance behind the safe `soundcraft_dsp::Plugin` trait. -use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers}; +use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers, ProcessInput}; use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI}; use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport}; use std::sync::Arc; @@ -231,7 +231,15 @@ impl Plugin for ClapPlugin { let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let events = self.queue.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref()); + let r = self.inst.process(ProcessInput { + frames: n, + steady_time: self.steady, + inputs: &mut self.inputs, + outputs: &mut self.outputs, + events, + offset, + transport: self.transport.as_ref(), + }); self.queue.drain(..k); self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0)); match (r, self.out_main) { diff --git a/crates/engine/src/io/bundle.rs b/crates/engine/src/io/bundle.rs index 27bad519..cfe6e0af 100644 --- a/crates/engine/src/io/bundle.rs +++ b/crates/engine/src/io/bundle.rs @@ -1,6 +1,6 @@ use super::render::sanitize_name; use super::session::decode_source; -use super::*; +use super::{Arc, EncodeOptions, Engine, EngineError, FileFormat, OsStr, Path, Result, Session, SourceAudio, read_bundle, write_file}; // ─── Hosts without a filesystem (the browser) ───────────────────────────────────────────────── diff --git a/crates/engine/src/io/import.rs b/crates/engine/src/io/import.rs index f34b5f29..a2011def 100644 --- a/crates/engine/src/io/import.rs +++ b/crates/engine/src/io/import.rs @@ -1,5 +1,8 @@ use super::render::sanitize_name; -use super::*; +use super::{ + Arc, AudioBuffer, ChannelFormat, Clip, Engine, EngineError, FileFormat, OsStr, Path, PathBuf, Result, Samples, Session, Source, SourceAudio, + SourceId, TrackId, TrackKind, Value, file_found, file_key, json, read_file, stored_path, +}; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30; diff --git a/crates/engine/src/io/midi.rs b/crates/engine/src/io/midi.rs index 405f1282..f97b5709 100644 --- a/crates/engine/src/io/midi.rs +++ b/crates/engine/src/io/midi.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Samples, TrackId, TrackKind}; /// Most meter changes a MIDI file may bring into the tempo map; scores have a handful. pub(super) const MAX_MIDI_METERS: usize = 10_000; diff --git a/crates/engine/src/io/record.rs b/crates/engine/src/io/record.rs index ece38abf..32e20420 100644 --- a/crates/engine/src/io/record.rs +++ b/crates/engine/src/io/record.rs @@ -1,7 +1,7 @@ use super::import::add_source; #[cfg(test)] use super::session::{save_copy, save_session}; -use super::*; +use super::{Clip, Engine, EngineError, FileFormat, OsStr, PathBuf, Range, Samples, TrackId, read_file}; /// A recording's plan, made when it starts: each record-enabled track's take file, where the /// takes start on the timeline, the loop length they split into when loop recording, and the @@ -117,6 +117,7 @@ pub fn live_span(start: Samples, pass: Option, punch: Option, le #[cfg(test)] mod record_tests { + use super::super::{AudioBuffer, Path, plan_takes}; use super::*; use serde_json::json; diff --git a/crates/engine/src/io/render.rs b/crates/engine/src/io/render.rs index cb8b42c5..5d0f0f94 100644 --- a/crates/engine/src/io/render.rs +++ b/crates/engine/src/io/render.rs @@ -1,7 +1,7 @@ use super::import::add_source; #[cfg(test)] use super::midi::{MAX_MIDI_METERS, import_midi_bytes}; -use super::*; +use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind}; /// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the /// whole range, silence included; a range too long to render is refused rather than shortened. diff --git a/crates/engine/src/io/session.rs b/crates/engine/src/io/session.rs index 0c934f58..8fcd47f8 100644 --- a/crates/engine/src/io/session.rs +++ b/crates/engine/src/io/session.rs @@ -2,7 +2,9 @@ use super::bundle::save_session_bundle; #[cfg(test)] use super::import::add_source; use super::import::{MAX_IMPORT_SAMPLES, at_rate, relink_videos}; -use super::*; +use super::{ + Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_text, stored_path, write_session, +}; /// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written /// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written. @@ -92,7 +94,9 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std: #[cfg(test)] mod save_tests { + use super::super::{AudioBuffer, ChannelFormat, Clip, Engine, FileFormat, Range, TrackKind, back_up, copy_new}; use super::*; + use serde_json::json; #[test] fn autosave_copy_leaves_document_untouched_and_reuses_audio() { @@ -315,7 +319,9 @@ mod save_tests { #[cfg(test)] mod open_tests { + use super::super::{AudioBuffer, ChannelFormat, Clip, EncodeOptions, Engine, FileFormat, SourceId, TrackKind}; use super::*; + use serde_json::json; #[test] fn a_file_named_by_many_sources_is_decoded_once() { @@ -428,7 +434,9 @@ mod open_tests { #[cfg(all(test, unix))] mod pipe_tests { + use super::super::{AudioBuffer, FileFormat}; use super::*; + use serde_json::json; #[test] fn a_pipe_named_as_media_is_refused_not_waited_on() { diff --git a/crates/engine/src/io/strip.rs b/crates/engine/src/io/strip.rs index 6591b873..ec808241 100644 --- a/crates/engine/src/io/strip.rs +++ b/crates/engine/src/io/strip.rs @@ -1,6 +1,6 @@ #[cfg(test)] use super::import::add_source; -use super::*; +use super::{Arc, AtomicBool, Clip, EngineError, Ordering, Range, Result, Samples, Session, TrackId}; /// Limit a range used for rendering to the track's material (plus a second), so a huge /// selection never renders hours of silence. @@ -244,7 +244,9 @@ fn non_silent( #[cfg(test)] mod strip_tests { + use super::super::{AudioBuffer, Engine, FileFormat, Value}; use super::*; + use serde_json::json; /// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone. fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) { diff --git a/crates/mix/src/automation.rs b/crates/mix/src/automation.rs index 20b20225..12a17f9e 100644 --- a/crates/mix/src/automation.rs +++ b/crates/mix/src/automation.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain}; pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport { let sr = s.sample_rate; diff --git a/crates/mix/src/clips.rs b/crates/mix/src/clips.rs index 03449de8..807f64a7 100644 --- a/crates/mix/src/clips.rs +++ b/crates/mix/src/clips.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Bound, Clip, ClipContent, Plugin, Range, Samples, Session, db_to_gain}; /// Clip Effects: an EQ and a compressor per clip, configured from `edit.values` /// (`clip_fx..eq.`, `clip_fx..comp.`, `clip_fx..gain`). diff --git a/crates/mix/src/instances.rs b/crates/mix/src/instances.rs index 5050007e..8bb5dbf9 100644 --- a/crates/mix/src/instances.rs +++ b/crates/mix/src/instances.rs @@ -1,5 +1,5 @@ use super::strip::strip_channels; -use super::*; +use super::{Plugin, Session, TrackId, main_channels}; /// Slot index used for a track's instrument plugin in [`InstanceSpec`] and [`PreparedInstance`] /// (insert slots use their index). diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index b32202a0..3dbd9d0c 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -59,7 +59,6 @@ use soundcraft_model::{ use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER}; use std::collections::HashMap; use std::ops::Bound; -use std::sync::Arc; mod automation; mod clips; @@ -82,7 +81,9 @@ pub use offline::{ pub use pan::itu_stereo_fold; use pan::{PanSpec, apply_matrix, apply_matrix_span, build_matrix}; use routing::{processing_order, structure_fingerprint}; -use strip::{BusBuf, LiveInput, SlotCtx, Strip, apply_params, contain_failure, note_insert_latency, process_strip, strip_channels, strip_format}; +use strip::{ + Block, BusBuf, LiveInput, SlotCtx, Strip, apply_params, contain_failure, note_insert_latency, process_strip, strip_channels, strip_format, +}; #[cfg(test)] use strip::{DelayLine, MAX_LATENCY}; use threads::run_strips; @@ -349,6 +350,44 @@ impl MixEngine { /// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels). pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec]) { let frames = frames.min(self.max_block); + self.begin_block(s, pos, frames); + let blk = Block { pos, frames, any_solo: s.tracks.iter().any(|t| t.mixer.solo && !t.inactive) }; + let order = std::mem::take(&mut self.order); + // Phase 1: independent strips (audio, instrument, MIDI) in parallel. + let mut work = std::mem::take(&mut self.work); + work.clear(); + for &i in &order { + let Some(t) = s.tracks.get(i).filter(|t| is_live(t) && !is_aux(t)) else { continue }; + if let Some(st) = self.strips.remove(&t.id) { + work.push((i, st)); + } + } + for (_, st) in work.iter_mut() { + if st.scratch.len() < self.max_block { + st.scratch.resize(self.max_block, 0.0); + } + } + let busses = std::mem::take(&mut self.busses); + let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; + run_strips(&mut work, |(i, st)| { + if let Some(t) = s.tracks.get(*i) { + process_strip(s, t, st, &busses, &live_in, blk); + } + }); + self.busses = busses; + // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait + // for the slowest aux return. Aux latency has to be read before those outputs are routed, + // or the first block (and a one-block bounce probe) still sees zero. + self.compensate_delay(s, &mut work, blk); + self.work = work; + self.render_auxes(s, &order, blk); + self.order = order; + self.render_masters(s, blk); + self.write_output(s, blk, out); + } + + /// Silence what a seek leaves ringing, sync to the session and clear the block's accumulators. + fn begin_block(&mut self, s: &Session, pos: Samples, frames: usize) { if pos != self.last_end { // Seek: silence held notes and ringing plugins. for st in self.strips.values_mut() { @@ -369,35 +408,11 @@ impl MixEngine { for c in &mut self.main { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive); - let order = std::mem::take(&mut self.order); - let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); - let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); - // Phase 1: independent strips (audio, instrument, MIDI) in parallel. - let mut work = std::mem::take(&mut self.work); - work.clear(); - for &i in &order { - let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue }; - if let Some(st) = self.strips.remove(&t.id) { - work.push((i, st)); - } - } - for (_, st) in work.iter_mut() { - if st.scratch.len() < self.max_block { - st.scratch.resize(self.max_block, 0.0); - } - } - let busses = std::mem::take(&mut self.busses); - let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - run_strips(&mut work, |(i, st)| { - if let Some(t) = s.tracks.get(*i) { - process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo); - } - }); - self.busses = busses; - // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait - // for the slowest aux return. Aux latency has to be read before those outputs are routed, - // or the first block (and a one-block bounce probe) still sees zero. + } + + /// Delay compensation for the independent strips (`work`), then route them back into the engine. + fn compensate_delay(&mut self, s: &Session, work: &mut Vec<(usize, Strip)>, blk: Block) { + let Block { pos, frames, .. } = blk; let pdc = s.edit.delay_compensation; if pdc { for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) { @@ -427,7 +442,7 @@ impl MixEngine { self.aux_latency = lmax_a; self.aux_fed.clear(); if pdc { - for t in s.tracks.iter().filter(|t| is_aux(t) && live(t)) { + for t in s.tracks.iter().filter(|t| is_aux(t) && is_live(t)) { if let Route::Bus(b) = &t.mixer.output && !self.aux_fed.contains(b) { @@ -461,16 +476,20 @@ impl MixEngine { self.strips.insert(t.id, st); } } - self.work = work; - let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); - // Phase 2: auxes in dependency order (they read busses). - for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) { + } + + /// Phase 2: auxes in dependency order (they read busses). + fn render_auxes(&mut self, s: &Session, order: &[usize], blk: Block) { + let Block { pos, frames, .. } = blk; + let pdc = s.edit.delay_compensation; + let lmax_a = self.aux_latency; + for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| is_live(t)).filter(|t| is_aux(t)) { let Some(mut st) = self.strips.remove(&t.id) else { continue }; if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); } let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo); + process_strip(s, t, &mut st, &self.busses, &live_in, blk); let ch = st.buf.len(); st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch); st.align.process(&mut st.buf, frames); @@ -478,7 +497,11 @@ impl MixEngine { self.route_strip(t, &mut st, pos, frames); self.strips.insert(t.id, st); } - self.order = order; + } + + /// Master faders process the main mix. + fn render_masters(&mut self, s: &Session, blk: Block) { + let Block { pos, frames, .. } = blk; // Master faders process the main mix. let transport = transport_at(s, pos, !self.monitor_only, self.recording); for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) { @@ -522,6 +545,11 @@ impl MixEngine { m.plugin_failures = strip.failures; } } + } + + /// Add the metronome and copy the main mix to `out`. + fn write_output(&mut self, s: &Session, blk: Block, out: &mut [Vec]) { + let Block { pos, frames, .. } = blk; // Metronome: summed after the master fader, so it reaches the speakers without passing // through any track, send or insert. Only the playback engine sets `metronome`. // Not while monitoring only (transport stopped): the position does not advance there, so the @@ -629,6 +657,14 @@ std::thread_local! { static AUDIO_THREAD: std::cell::Cell = const { std::cell::Cell::new(false) }; } +fn is_live(t: &Track) -> bool { + !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca) +} + +fn is_aux(t: &Track) -> bool { + matches!(t.kind, TrackKind::Aux | TrackKind::Folder) +} + /// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of /// each bar. Settings come from `setup.click_countoff` (`click.*` values). mod click; diff --git a/crates/mix/src/midi.rs b/crates/mix/src/midi.rs index 2e49790c..3b182956 100644 --- a/crates/mix/src/midi.rs +++ b/crates/mix/src/midi.rs @@ -1,6 +1,7 @@ use super::clips::Key; use super::strip::{PluginSlot, Strip}; -use super::*; +use super::{ClipContent, CtrlKind, Range, SampleSource, Samples, Session, SourceAudio, Track}; +use std::sync::Arc; /// The track's MIDI Track Offset (`midi.offset.` in the edit values), in samples. Looked up /// without formatting the key, since this runs on the audio thread every block. diff --git a/crates/mix/src/offline.rs b/crates/mix/src/offline.rs index ad70b918..d544b0fc 100644 --- a/crates/mix/src/offline.rs +++ b/crates/mix/src/offline.rs @@ -1,6 +1,6 @@ use super::clips::{ClipFx, clip_fx_params, render_clip}; use super::pan::itu_stereo_fold; -use super::*; +use super::{AutoParam, BusId, ChannelFormat, MixEngine, Range, Route, Samples, Session, TrackId, main_channels}; /// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about /// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory; diff --git a/crates/mix/src/pan.rs b/crates/mix/src/pan.rs index 6221ae4f..cb8047a7 100644 --- a/crates/mix/src/pan.rs +++ b/crates/mix/src/pan.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ChannelFormat, MAX_CHANNELS, PanLaw, Speaker, SpeakerPos, SurroundPan, SurroundParams, db_to_gain, gains}; /// Per-block routing gains: `m[src][dst]`. type Matrix = [[f32; MAX_CHANNELS]; MAX_CHANNELS]; @@ -48,6 +48,22 @@ fn surround_params(sp: &SurroundPan, dx: f32) -> SurroundParams { SurroundParams { x: (sp.x + dx).clamp(-1.0, 1.0), y: sp.y, z: sp.z, divergence: sp.divergence, center: sp.center / 100.0 } } +/// One source → destination routing: channel counts and formats, plus how the source is panned. +struct Link<'a> { + src_fmt: ChannelFormat, + ns: usize, + dst_fmt: ChannelFormat, + nd: usize, + pan: &'a PanSpec<'a>, + law: PanLaw, +} + +fn add_gain(m: &mut Matrix, s: usize, d: usize, g: f32) { + if let Some(x) = m.get_mut(s).and_then(|r| r.get_mut(d)) { + *x += g; + } +} + /// Routing gains from a source (`ns` channels of `src_fmt`) into a destination (`nd` channels of /// `dst_fmt`). See the crate docs for the rules. pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: usize, pan: &PanSpec<'_>, law: PanLaw) -> Matrix { @@ -56,11 +72,6 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo if ns == 0 || nd == 0 { return m; } - let set = |m: &mut Matrix, s: usize, d: usize, g: f32| { - if let Some(x) = m.get_mut(s).and_then(|r| r.get_mut(d)) { - *x += g; - } - }; if nd == 1 { // Mono destination: the stereo routing summed at -6 dB. let st = build_matrix(src_fmt, ns, ChannelFormat::Stereo, 2, pan, law); @@ -69,131 +80,168 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo } return m; } - let src_sp = src_fmt.speakers(); + let link = Link { src_fmt, ns, dst_fmt, nd, pan, law }; let src_amb = src_fmt.is_ambisonic() && ns >= 4; if nd == 2 && !dst_fmt.is_ambisonic() { - match ns { - 1 => { - let (gl, gr) = gains(pan.stereo[0], law); - m[0][0] = gl; - m[0][1] = gr; - } - 2 if !src_amb => { - let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero); - let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero); - m[0] = [0.0; MAX_CHANNELS]; - m[0][0] = a_l; - m[0][1] = a_r; - m[1][0] = b_l; - m[1][1] = b_r; - } - _ if src_amb => { - for (d, az) in [(0usize, -30.0f32), (1, 30.0)] { - let g = soundcraft_dsp::pan::ambisonic_decode(az, 0.0, 2); - for (c, gc) in g.iter().enumerate() { - set(&mut m, c, d, *gc); - } - } - } - _ if src_sp.len() == ns => { - for (s, sp) in src_sp.iter().enumerate() { - let (l, r) = itu_stereo_fold(*sp); - set(&mut m, s, 0, l); - set(&mut m, s, 1, r); - } - } - _ => { - // Unknown layout: first two channels to L/R, the rest to the centre. - let h = std::f32::consts::FRAC_1_SQRT_2; - m[0][0] = 1.0; - m[1][1] = 1.0; - for row in m.iter_mut().take(ns).skip(2) { - row[0] = h; - row[1] = h; - } - } - } - return m; + return stereo_dest(&link, src_amb); } if ns > 2 && src_fmt == dst_fmt && ns == nd { return identity_matrix(ns); } - // Multichannel destination. - let mut layout = [SpeakerPos::default(); MAX_CHANNELS]; - let nl = layout_of(dst_fmt, &mut layout); - let layout = layout.get(..nl).unwrap_or(&[]); - let dst_sp = dst_fmt.speakers(); - let dst_amb = dst_fmt.is_ambisonic(); - let find = |sp: Speaker| dst_sp.iter().position(|x| *x == sp).filter(|i| *i < nd); - let mut g = [0.0f32; MAX_CHANNELS]; - if dst_amb { - if src_amb || (src_fmt.is_ambisonic() && ns > 2) { - return identity_matrix(ns.min(nd)); - } - let mut enc = |m: &mut Matrix, s: usize, az: f32, el: f32, spread: f32| { - soundcraft_dsp::pan::ambisonic_encode(az, el, spread, &mut g); - for (d, gd) in g.iter().enumerate().take(nd) { - set(m, s, d, *gd); - } - }; - match (ns, pan.surround) { - (1 | 2, Some(sp)) => { - for s in 0..ns { - let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 }; - let p = surround_params(sp, dx); - enc(&mut m, s, soundcraft_dsp::pan::puck_azimuth(&[], p.x, p.y), p.z * 90.0, p.divergence); - } - } - (1, None) => enc(&mut m, 0, pan.stereo[0].clamp(-1.0, 1.0) * 30.0, 0.0, 0.0), - (2, None) => { - enc(&mut m, 0, -30.0, 0.0, 0.0); - enc(&mut m, 1, 30.0, 0.0, 0.0); - } - _ => { - for (s, sp) in src_sp.iter().enumerate().take(ns) { - if !sp.is_lfe() { - enc(&mut m, s, sp.azimuth(), sp.elevation(), 0.0); - } - } - } - } - return m; + if dst_fmt.is_ambisonic() { + return ambisonic_dest(&link, src_amb); } + layout_dest(&link, src_amb) +} + +/// Routing into a stereo destination. +fn stereo_dest(l: &Link<'_>, src_amb: bool) -> Matrix { + let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS]; + let src_sp = l.src_fmt.speakers(); + match l.ns { + 1 => { + let (gl, gr) = gains(l.pan.stereo[0], l.law); + m[0][0] = gl; + m[0][1] = gr; + } + 2 if !src_amb => { + let (a_l, a_r) = gains(l.pan.stereo[0], PanLaw::Zero); + let (b_l, b_r) = gains(l.pan.stereo[1], PanLaw::Zero); + m[0] = [0.0; MAX_CHANNELS]; + m[0][0] = a_l; + m[0][1] = a_r; + m[1][0] = b_l; + m[1][1] = b_r; + } + _ if src_amb => { + for (d, az) in [(0usize, -30.0f32), (1, 30.0)] { + let g = soundcraft_dsp::pan::ambisonic_decode(az, 0.0, 2); + for (c, gc) in g.iter().enumerate() { + add_gain(&mut m, c, d, *gc); + } + } + } + ns if src_sp.len() == ns => { + for (s, sp) in src_sp.iter().enumerate() { + let (left, right) = itu_stereo_fold(*sp); + add_gain(&mut m, s, 0, left); + add_gain(&mut m, s, 1, right); + } + } + ns => { + // Unknown layout: first two channels to L/R, the rest to the centre. + let h = std::f32::consts::FRAC_1_SQRT_2; + m[0][0] = 1.0; + m[1][1] = 1.0; + for row in m.iter_mut().take(ns).skip(2) { + row[0] = h; + row[1] = h; + } + } + } + m +} + +/// `±1` offset spreading a stereo source's two channels left and right of the surround pan. +fn spread_dx(ns: usize, s: usize) -> f32 { + if ns != 2 { + 0.0 + } else if s == 0 { + -1.0 + } else { + 1.0 + } +} + +/// Routing into an ambisonic destination. +fn ambisonic_dest(l: &Link<'_>, src_amb: bool) -> Matrix { + let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS]; + let (ns, nd) = (l.ns, l.nd); + if src_amb || (l.src_fmt.is_ambisonic() && ns > 2) { + return identity_matrix(ns.min(nd)); + } + let src_sp = l.src_fmt.speakers(); + let mut g = [0.0f32; MAX_CHANNELS]; + let mut enc = |m: &mut Matrix, s: usize, az: f32, el: f32, spread: f32| { + soundcraft_dsp::pan::ambisonic_encode(az, el, spread, &mut g); + for (d, gd) in g.iter().enumerate().take(nd) { + add_gain(m, s, d, *gd); + } + }; + match (ns, l.pan.surround) { + (1 | 2, Some(sp)) => { + for s in 0..ns { + let p = surround_params(sp, spread_dx(ns, s)); + enc(&mut m, s, soundcraft_dsp::pan::puck_azimuth(&[], p.x, p.y), p.z * 90.0, p.divergence); + } + } + (1, None) => enc(&mut m, 0, l.pan.stereo[0].clamp(-1.0, 1.0) * 30.0, 0.0, 0.0), + (2, None) => { + enc(&mut m, 0, -30.0, 0.0, 0.0); + enc(&mut m, 1, 30.0, 0.0, 0.0); + } + _ => { + for (s, sp) in src_sp.iter().enumerate().take(ns) { + if !sp.is_lfe() { + enc(&mut m, s, sp.azimuth(), sp.elevation(), 0.0); + } + } + } + } + m +} + +/// A surround-panned mono or stereo source into a speaker layout, plus its LFE send. +fn surround_into_layout(m: &mut Matrix, l: &Link<'_>, layout: &[SpeakerPos], sp: &SurroundPan) { + let (ns, nd) = (l.ns, l.nd); + let dst_sp = l.dst_fmt.speakers(); + let mut g = [0.0f32; MAX_CHANNELS]; + for s in 0..ns { + soundcraft_dsp::pan::surround_gains(layout, &surround_params(sp, spread_dx(ns, s)), &mut g); + for (d, gd) in g.iter().enumerate().take(nd) { + add_gain(m, s, d, *gd); + } + if let Some(lfe) = dst_sp.iter().position(|x| *x == Speaker::Lfe).filter(|i| *i < nd) + && sp.lfe_db > -143.9 + { + add_gain(m, s, lfe, db_to_gain(sp.lfe_db.min(12.0)) / ns as f32); + } + } +} + +/// Routing into a speaker layout other than stereo or ambisonic. +fn layout_dest(l: &Link<'_>, src_amb: bool) -> Matrix { + let mut m = [[0.0f32; MAX_CHANNELS]; MAX_CHANNELS]; + let (ns, nd) = (l.ns, l.nd); + let src_sp = l.src_fmt.speakers(); + let mut layout = [SpeakerPos::default(); MAX_CHANNELS]; + let nl = layout_of(l.dst_fmt, &mut layout); + let layout = layout.get(..nl).unwrap_or(&[]); + let dst_sp = l.dst_fmt.speakers(); if dst_sp.len() != nd { // No layout to pan on: channel for channel. return identity_matrix(ns.min(nd)); } + let find = |sp: Speaker| dst_sp.iter().position(|x| *x == sp).filter(|i| *i < nd); let (li, ri) = (find(Speaker::L), find(Speaker::R)); - match (ns, pan.surround) { - (1 | 2, Some(sp)) if !src_amb => { - for s in 0..ns { - let dx = if ns == 2 { if s == 0 { -1.0 } else { 1.0 } } else { 0.0 }; - soundcraft_dsp::pan::surround_gains(layout, &surround_params(sp, dx), &mut g); - for (d, gd) in g.iter().enumerate().take(nd) { - set(&mut m, s, d, *gd); - } - if let Some(lfe) = find(Speaker::Lfe) - && sp.lfe_db > -143.9 - { - set(&mut m, s, lfe, db_to_gain(sp.lfe_db.min(12.0)) / ns as f32); - } - } - } + let mut g = [0.0f32; MAX_CHANNELS]; + match (ns, l.pan.surround) { + (1 | 2, Some(sp)) if !src_amb => surround_into_layout(&mut m, l, layout, sp), (1, None) => { - let (gl, gr) = gains(pan.stereo[0], law); - if let (Some(l), Some(r)) = (li, ri) { - set(&mut m, 0, l, gl); - set(&mut m, 0, r, gr); + let (gl, gr) = gains(l.pan.stereo[0], l.law); + if let (Some(left), Some(right)) = (li, ri) { + add_gain(&mut m, 0, left, gl); + add_gain(&mut m, 0, right, gr); } } (2, None) if !src_amb => { - let (a_l, a_r) = gains(pan.stereo[0], PanLaw::Zero); - let (b_l, b_r) = gains(pan.stereo[1], PanLaw::Zero); - if let (Some(l), Some(r)) = (li, ri) { - set(&mut m, 0, l, a_l); - set(&mut m, 0, r, a_r); - set(&mut m, 1, l, b_l); - set(&mut m, 1, r, b_r); + let (a_l, a_r) = gains(l.pan.stereo[0], PanLaw::Zero); + let (b_l, b_r) = gains(l.pan.stereo[1], PanLaw::Zero); + if let (Some(left), Some(right)) = (li, ri) { + add_gain(&mut m, 0, left, a_l); + add_gain(&mut m, 0, right, a_r); + add_gain(&mut m, 1, left, b_l); + add_gain(&mut m, 1, right, b_r); } } _ if src_amb => { @@ -204,20 +252,19 @@ pub(crate) fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFo } let dec = soundcraft_dsp::pan::ambisonic_decode(sp.azimuth(), sp.elevation(), n_spk); for (c, gc) in dec.iter().enumerate() { - set(&mut m, c, d, *gc); + add_gain(&mut m, c, d, *gc); } } } _ if src_sp.len() == ns => { for (s, sp) in src_sp.iter().enumerate() { if let Some(d) = find(*sp) { - set(&mut m, s, d, 1.0); - } else if sp.is_lfe() { - // No LFE in the destination: dropped. - } else { + add_gain(&mut m, s, d, 1.0); + } else if !sp.is_lfe() { + // (An LFE with no LFE in the destination is dropped.) soundcraft_dsp::pan::direction_gains(layout, sp.azimuth(), sp.elevation(), &mut g); for (d, gd) in g.iter().enumerate().take(nd) { - set(&mut m, s, d, *gd); + add_gain(&mut m, s, d, *gd); } } } diff --git a/crates/mix/src/routing.rs b/crates/mix/src/routing.rs index 5b924d50..fcebca85 100644 --- a/crates/mix/src/routing.rs +++ b/crates/mix/src/routing.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{BusId, Route, Session, Track, TrackKind}; +use std::collections::HashMap; +use std::sync::Arc; /// Hash of everything `sync`/`processing_order` depend on (tracks, kinds, routing, plugins, sends). pub(crate) fn structure_fingerprint(s: &Session) -> usize { @@ -153,6 +155,7 @@ pub(crate) fn receives_solo(s: &Session, t: &Track) -> bool { #[allow(clippy::unwrap_used)] mod processing_order_tests { use super::*; + use soundcraft_model::ChannelFormat; use soundcraft_model::SendSlot; #[test] diff --git a/crates/mix/src/strip.rs b/crates/mix/src/strip.rs index 45565feb..01f4a11a 100644 --- a/crates/mix/src/strip.rs +++ b/crates/mix/src/strip.rs @@ -4,7 +4,12 @@ use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create use super::midi::{chase_controllers, schedule_notes}; use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix}; use super::routing::receives_solo; -use super::*; +use super::{ + AutoParam, BusId, ChannelFormat, MAX_CHANNELS, PanLaw, Plugin, Range, Route, Samples, Session, SourceAudio, Track, TrackId, TrackKind, Transport, + db_to_gain, +}; +use std::collections::HashMap; +use std::sync::Arc; /// A bus: its format and one buffer per channel. pub(crate) struct BusBuf { @@ -199,131 +204,130 @@ pub(crate) struct LiveInput<'a> { pub(crate) recording: bool, } -pub(crate) fn process_strip( - s: &Session, - t: &Track, - strip: &mut Strip, - busses: &HashMap, - live: &LiveInput<'_>, - pos: Samples, - frames: usize, - any_solo: bool, -) { - for c in strip.buf.iter_mut() { +/// The block being rendered: where it starts, its length, and whether any track is soloed. +#[derive(Clone, Copy)] +pub(crate) struct Block { + pub(crate) pos: Samples, + pub(crate) frames: usize, + pub(crate) any_solo: bool, +} + +fn zero_buf(buf: &mut [Vec], frames: usize) { + for c in buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let transport = transport_at(s, pos, !live.monitor_only, live.recording); - match t.kind { - TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => { - // Input monitoring: the track hears its hardware input instead of its clips. - let first = match &t.mixer.input { - Route::Hardware(h) => { - h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::().ok()).map_or(0, |n| n.saturating_sub(1)) - } - _ => 0, - }; - let n_in = live.input.len().max(1); - for (k, dst) in strip.buf.iter_mut().enumerate() { - if let Some(src) = live.input.get((first + k) % n_in) { - for (d, x) in dst.iter_mut().zip(src.iter()).take(frames) { - *d = *x; - } - } - } +} + +/// Input monitoring: the track hears its hardware input instead of its clips. +fn monitor_input(t: &Track, strip: &mut Strip, live: &LiveInput<'_>, frames: usize) { + let first = match &t.mixer.input { + Route::Hardware(h) => { + h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::().ok()).map_or(0, |n| n.saturating_sub(1)) } - TrackKind::Audio if live.monitor_only => {} - TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {} - TrackKind::Audio => { - let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; - for clip in t.clips() { - if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) { - continue; - } - match clip_fx_params(s, clip.id.0) { - None => render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch), - Some(params) => { - let nch = strip.buf.len(); - if strip.clipbuf.len() != nch || strip.clipbuf.first().map_or(0, Vec::len) < frames { - strip.clipbuf = vec![vec![0.0; frames.max(strip.scratch.len())]; nch]; - } - for c in strip.clipbuf.iter_mut() { - c.iter_mut().take(frames).for_each(|x| *x = 0.0); - } - render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch); - let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch)); - fx.configure(params); - fx.process(&mut strip.clipbuf, frames); - for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) { - for (x, y) in d.iter_mut().zip(c.iter()).take(frames) { - *x += *y; - } - } - } - } + _ => 0, + }; + let n_in = live.input.len().max(1); + for (k, dst) in strip.buf.iter_mut().enumerate() { + if let Some(src) = live.input.get((first + k) % n_in) { + for (d, x) in dst.iter_mut().zip(src.iter()).take(frames) { + *d = *x; } } - TrackKind::Aux | TrackKind::Folder => { - if let Route::Bus(b) = &t.mixer.input - && let Some(src) = busses.get(b) - { - let (ns, nd) = (src.buf.len(), strip.buf.len()); - let dst_fmt = strip_format(t, nd); - let m = if src.fmt == dst_fmt && ns == nd { - identity_matrix(ns) - } else if ns == 1 && nd == 2 { - // A mono bus feeds both sides of a stereo aux. - let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS]; - m[0][0] = 1.0; - m[0][1] = 1.0; - m - } else { - let stereo = if ns == 1 { [0.0, 0.0] } else { [-1.0, 1.0] }; - build_matrix(src.fmt, ns, dst_fmt, nd, &PanSpec { stereo, surround: None }, PanLaw::Minus3) - }; - apply_matrix(&src.buf, frames, &mut strip.buf, &m, 1.0); - } - } - TrackKind::Instrument | TrackKind::Midi => { - if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) { - // #152: push the instrument's stored parameter values into the live plugin - // before processing, the same way inserts do below. Without this, the values - // loaded from a session (or set via `mix.insert_state`) never reach the plugin - // instance and the rendered audio matches the plugin's defaults — e.g. a saved - // `level = -12 dB` sounds the same as `level = -6 dB`. - if let Some(ins) = &t.instrument { - apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); - } - inst.plugin.set_transport(&transport); - if std::mem::take(&mut strip.chase) { - chase_controllers(s, t, pos, inst); - } - schedule_notes(s, t, pos, frames, inst, &mut strip.held); - if strip.buf.len() >= 2 { - inst.plugin.process(&mut strip.buf, frames); - } else if let Some(c0) = strip.buf.first_mut() { - // Mono MIDI track: render the instrument in stereo and fold. - let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)]; - inst.plugin.process(&mut st, frames); - let [a, b] = st; - *c0 = a; - strip.scratch = b; - for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) { - *x = 0.5 * (*x + *y); - } - } - if contain_failure(inst, &mut strip.buf, frames) { - strip.failures.record(INSTRUMENT_SLOT); - } - } - } - _ => {} - } - let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 }; - if (trim - 1.0).abs() > f32::EPSILON { - for c in strip.buf.iter_mut() { - c.iter_mut().take(frames).for_each(|x| *x *= trim); - } } +} + +/// Render the audio clips overlapping the block into the strip, through their clip effects. +fn render_audio_clips(s: &Session, t: &Track, strip: &mut Strip, blk: Block) { + let Block { pos, frames, .. } = blk; + let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; + for clip in t.clips() { + if clip.muted || !clip.is_audio() || !clip.range().overlaps(&block) { + continue; + } + match clip_fx_params(s, clip.id.0) { + None => render_clip(s, clip, pos, frames, &mut strip.buf, &mut strip.scratch), + Some(params) => { + let nch = strip.buf.len(); + if strip.clipbuf.len() != nch || strip.clipbuf.first().map_or(0, Vec::len) < frames { + strip.clipbuf = vec![vec![0.0; frames.max(strip.scratch.len())]; nch]; + } + zero_buf(&mut strip.clipbuf, frames); + render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch); + let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch)); + fx.configure(params); + fx.process(&mut strip.clipbuf, frames); + for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) { + for (x, y) in d.iter_mut().zip(c.iter()).take(frames) { + *x += *y; + } + } + } + } + } +} + +/// An aux or folder reads the bus its input is routed from. +fn read_aux_bus(t: &Track, strip: &mut Strip, busses: &HashMap, frames: usize) { + if let Route::Bus(b) = &t.mixer.input + && let Some(src) = busses.get(b) + { + let (ns, nd) = (src.buf.len(), strip.buf.len()); + let dst_fmt = strip_format(t, nd); + let m = if src.fmt == dst_fmt && ns == nd { + identity_matrix(ns) + } else if ns == 1 && nd == 2 { + // A mono bus feeds both sides of a stereo aux. + let mut m = [[0.0; MAX_CHANNELS]; MAX_CHANNELS]; + m[0][0] = 1.0; + m[0][1] = 1.0; + m + } else { + let stereo = if ns == 1 { [0.0, 0.0] } else { [-1.0, 1.0] }; + build_matrix(src.fmt, ns, dst_fmt, nd, &PanSpec { stereo, surround: None }, PanLaw::Minus3) + }; + apply_matrix(&src.buf, frames, &mut strip.buf, &m, 1.0); + } +} + +/// Render the track's instrument into the strip. +fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) { + let Block { pos, frames, .. } = blk; + if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) { + // #152: push the instrument's stored parameter values into the live plugin + // before processing, the same way inserts do below. Without this, the values + // loaded from a session (or set via `mix.insert_state`) never reach the plugin + // instance and the rendered audio matches the plugin's defaults — e.g. a saved + // `level = -12 dB` sounds the same as `level = -6 dB`. + if let Some(ins) = &t.instrument { + apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); + } + inst.plugin.set_transport(transport); + if std::mem::take(&mut strip.chase) { + chase_controllers(s, t, pos, inst); + } + schedule_notes(s, t, pos, frames, inst, &mut strip.held); + if strip.buf.len() >= 2 { + inst.plugin.process(&mut strip.buf, frames); + } else if let Some(c0) = strip.buf.first_mut() { + // Mono MIDI track: render the instrument in stereo and fold. + let mut st = [std::mem::take(c0), std::mem::take(&mut strip.scratch)]; + inst.plugin.process(&mut st, frames); + let [a, b] = st; + *c0 = a; + strip.scratch = b; + for (x, y) in c0.iter_mut().zip(strip.scratch.iter()).take(frames) { + *x = 0.5 * (*x + *y); + } + } + if contain_failure(inst, &mut strip.buf, frames) { + strip.failures.record(INSTRUMENT_SLOT); + } + } +} + +/// Run the active inserts in order; records their latency and the first gain reduction. +fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) { + let Block { pos, frames, .. } = blk; let mut gr = 0.0f32; let mut latency = 0usize; for (i, ins) in t.mixer.inserts.iter().enumerate() { @@ -332,7 +336,7 @@ pub(crate) fn process_strip( continue; } apply_params(slot, ins, t, i, pos); - slot.plugin.set_transport(&transport); + slot.plugin.set_transport(transport); slot.plugin.process(&mut strip.buf, frames); if contain_failure(slot, &mut strip.buf, frames) { strip.failures.record(i); @@ -345,15 +349,11 @@ pub(crate) fn process_strip( } strip.gr = gr; strip.latency = latency.min(MAX_LATENCY); - let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); - strip.muted = t.mixer.mute || soloed_out; - if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) { - strip.pre.resize_with(strip.buf.len(), Vec::new); - for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) { - d.clear(); - d.extend_from_slice(src.get(..frames).unwrap_or(&[])); - } - } +} + +/// Fader gain (volume, trim and VCA automation), ramped across the block. +fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) { + let Block { pos, frames, .. } = blk; // Endpoints are the first and last sample of this block. The next block starts on its own // first sample, so a breakpoint there does not ramp the block before it. let last = pos.saturating_add(frames.saturating_sub(1) as i64); @@ -386,9 +386,13 @@ pub(crate) fn process_strip( *x *= ramp.at(i); } } - // Track mute gates the audio and its pre-fader tap without splitting processor blocks. - // Keep the existing curve interpolation, first-lane and mode policy, and render-position - // clock. Processor/bus age and Master/VCA automation policy are separate concerns. +} + +/// Track mute gates the audio and its pre-fader tap without splitting processor blocks. +/// Keep the existing curve interpolation, first-lane and mode policy, and render-position +/// clock. Processor/bus age and Master/VCA automation policy are separate concerns. +fn gate_mute_automation(t: &Track, strip: &mut Strip, blk: Block) { + let Block { pos, frames, .. } = blk; if t.mixer.automation_mode.reads() && let Some(lane) = t.lane(&AutoParam::Mute).filter(|lane| !lane.points.is_empty()) { @@ -405,6 +409,41 @@ pub(crate) fn process_strip( } } +pub(crate) fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap, live: &LiveInput<'_>, blk: Block) { + let Block { pos, frames, any_solo } = blk; + zero_buf(&mut strip.buf, frames); + let transport = transport_at(s, pos, !live.monitor_only, live.recording); + match t.kind { + TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => { + monitor_input(t, strip, live, frames); + } + TrackKind::Audio if live.monitor_only => {} + TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {} + TrackKind::Audio => render_audio_clips(s, t, strip, blk), + TrackKind::Aux | TrackKind::Folder => read_aux_bus(t, strip, busses, frames), + TrackKind::Instrument | TrackKind::Midi => render_instrument(s, t, strip, blk, &transport), + _ => {} + } + let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 }; + if (trim - 1.0).abs() > f32::EPSILON { + for c in strip.buf.iter_mut() { + c.iter_mut().take(frames).for_each(|x| *x *= trim); + } + } + run_inserts(t, strip, blk, &transport); + let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); + strip.muted = t.mixer.mute || soloed_out; + if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) { + strip.pre.resize_with(strip.buf.len(), Vec::new); + for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) { + d.clear(); + d.extend_from_slice(src.get(..frames).unwrap_or(&[])); + } + } + apply_fader(s, t, strip, blk); + gate_mute_automation(t, strip, blk); +} + /// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the /// next [`MixEngine::reset`]. True then. pub(crate) fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec], frames: usize) -> bool { diff --git a/crates/mix/src/threads.rs b/crates/mix/src/threads.rs index cfb2dd98..c71fb3fc 100644 --- a/crates/mix/src/threads.rs +++ b/crates/mix/src/threads.rs @@ -1,4 +1,4 @@ -use super::*; +use super::AUDIO_THREAD; /// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks /// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads diff --git a/crates/playback/src/callback.rs b/crates/playback/src/callback.rs index fc89c16e..5e931fe0 100644 --- a/crates/playback/src/callback.rs +++ b/crates/playback/src/callback.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, AtomicBool, BLOCK, BTreeMap, Cmd, HashMap, HashSet, MAX_CHANNELS, MeterSnapshot, MixEngine, OLD_SESSIONS, Ordering, OutputTrouble, Plugin, + PluginEditor, PluginStateReader, PluginStates, PreparedInstance, Range, Receiver, Samples, Sender, Session, Shared, SyncSender, TrackId, + itu_stereo_fold, main_channels, record, sync_channel, +}; /// How the audio output is doing, for the UI. #[derive(Default)] diff --git a/crates/playback/src/stream.rs b/crates/playback/src/stream.rs index af90a912..a1f78530 100644 --- a/crates/playback/src/stream.rs +++ b/crates/playback/src/stream.rs @@ -1,5 +1,5 @@ use super::callback::{AudioState, Fault, guarded_block}; -use super::*; +use super::{Arc, AudioConfig, Cmd, Ordering, Plugin, Receiver, Sender, Shared, devices, mark_audio_thread}; /// Start a freshly built output stream. `false` when the device refuses to start it, so the caller /// treats it like a stream that failed to build and tries the next configuration. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b0807213..35ca5974 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -1,6 +1,10 @@ use super::services::Services; use super::state::MainWindow; -use super::*; +use super::{ + ControlRequest, Engine, Gesture, HashMap, MeterDisplay, OutputTrouble, Player, Range, Receiver, Samples, SoundApp, TrackId, TransportRequest, + Trouble, UiState, Value, VecDeque, bounce, dialogs, edit_window, extra_windows, fonts, import, json, menus, midi_editor, mix_window, ops_windows, + palette, panels, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows, +}; mod audio; mod commands; diff --git a/crates/ui-egui/src/app/audio.rs b/crates/ui-egui/src/app/audio.rs index 7a9f9bf1..3ec92849 100644 --- a/crates/ui-egui/src/app/audio.rs +++ b/crates/ui-egui/src/app/audio.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{MeterDisplay, OutputTrouble, Player, SoundApp, Trouble}; impl SoundApp { /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard. diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 2ae62f9b..422ff784 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Value, bounce, menus}; impl SoundApp { /// Run a command; errors go to the status line. Returns the result. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index ab5f91a6..1ba17caf 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Value, json}; use crate::control; impl SoundApp { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 22ce03dd..6b230d34 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, midi_editor}; impl SoundApp { /// The open document was replaced outside [`Self::run`] (Recover, a session the browser read diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index cafbed86..c6167d5a 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, json, sampler_window}; impl SoundApp { /// Feed queued synthetic input (from the control channel) into egui: one pointer event per diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index fd17adc4..65c6c194 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -1,6 +1,6 @@ use super::audio::feed_meter; use super::plugins::failed_plugin_status; -use super::*; +use super::{Gesture, MainWindow, MeterDisplay, Range, Samples, SoundApp, TrackId, TransportRequest, json}; /// Whether another automation-write pass is due: the transport moved at least /// `step` since `last` in either direction. `last == i64::MIN` marks "never diff --git a/crates/ui-egui/src/app/plugins.rs b/crates/ui-egui/src/app/plugins.rs index da316f9b..19feaee7 100644 --- a/crates/ui-egui/src/app/plugins.rs +++ b/crates/ui-egui/src/app/plugins.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, TrackId, json, mix_window}; /// The plugins whose state the session takes this frame, out of those changed in their editors /// (`pending`, kept across frames). A plugin editor doesn't say when a gesture ends: a plugin's diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 17543691..fc18e173 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -281,6 +281,175 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option< Some(text) } +/// The New Tracks form; the command to run once Create is pressed. +fn new_tracks_form( + ui: &mut egui::Ui, + count: &mut u32, + format: &mut String, + kind: &mut String, + timebase: &mut String, + name: &mut String, + enter: bool, +) -> Option<(String, Value)> { + ui.horizontal(|ui| { + let create = ui.label("Create"); + ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id); + ui.label("new"); + let fmt = craft_ui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { + for f in soundcraft_model::ChannelFormat::ALL { + ui.selectable_value(format, f.label().to_string(), f.label()); + } + }); + fmt.response.accessible_name("Channel format"); + let kind_combo = craft_ui::ComboBox::from_id_salt("nt_kind") + .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label())) + .show_ui(ui, |ui| { + for k in soundcraft_model::TrackKind::ALL { + if ui.selectable_label(kind == k.id(), k.label()).clicked() { + *kind = k.id().to_string(); + *name = k.default_name().to_string(); + } + } + }); + kind_combo.response.accessible_name("Track kind"); + ui.label("in"); + let tb = craft_ui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(ui, |ui| { + ui.selectable_value(timebase, "samples".to_string(), "Samples"); + ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); + }); + tb.response.accessible_name("Timebase"); + }); + ui.horizontal(|ui| { + ui.label("Name:"); + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); + }); + if buttons(ui, "Create", enter) { + return Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name}))); + } + None +} + +/// The Bounce form's file row: a path field and a Browse button that also picks the format. +fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, format: &mut FileFormat) { + ui.horizontal(|ui| { + ui.label("File"); + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "File"); + if let Some(pick) = &app.services.pick_save + && ui.button("Browse…").clicked() + && let Some(p) = pick("file.bounce_mix", std::path::Path::new(path.as_str())) + { + match unicode(&p) { + Ok(text) => { + *path = text.to_owned(); + if let Some(picked) = p.extension().and_then(std::ffi::OsStr::to_str).and_then(FileFormat::from_write_extension) { + *format = picked; + } + } + Err(e) => app.ui.status = e.to_string(), + } + } + }); +} + +/// The path prompt's form; the command to run once it is confirmed. +fn path_prompt_form(app: &mut SoundApp, ui: &mut egui::Ui, cmd: &str, path: &mut String, key: &str, enter: bool) -> Option<(String, Value)> { + crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path"); + let mut picked = None; + if let Some(pick) = &app.services.pick_open + && cmd != "session.save_as" + && cmd != "session.save_copy" + && key != "dir" + && ui.button("Browse…").clicked() + && let Some(p) = pick(cmd, &[]) + { + picked = Some(p); + } + if let Some(pick) = &app.services.pick_folder + && key == "dir" + && ui.button("Browse…").clicked() + && let Some(p) = pick(std::path::Path::new(path.as_str())) + { + picked = Some(p); + } + if let Some(pick) = &app.services.pick_save + && (cmd == "session.save_as" || cmd == "session.save_copy") + && ui.button("Browse…").clicked() + && let Some(p) = pick(cmd, std::path::Path::new(path.as_str())) + { + picked = Some(p); + } + match picked.as_deref().map(unicode) { + Some(Ok(text)) => *path = text.to_owned(), + Some(Err(e)) => app.ui.status = e.to_string(), + None => {} + } + let file = soundcraft_engine::io::session_file(path); + let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file); + if replaces { + ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy())); + } + if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) { + let mut v = json!({}); + v[key] = json!(path); + if replaces { + v["replace"] = json!(true); + } + return Some((cmd.to_string(), v)); + } + None +} + +/// The Strip Silence form: the Strip Silence command to run, or the values to carry into Rename. +fn strip_silence_form( + ui: &mut egui::Ui, + values: &mut [f64; 4], + strip_names: &StripNames, + enter: bool, +) -> (Option<(String, Value)>, Option<[f64; 4]>) { + let mut action = None; + let mut rename_from = None; + let [threshold, min_ms, pre_ms, post_ms] = values; + let before = [*threshold, *min_ms, *pre_ms, *post_ms]; + ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); + ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms")); + ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); + ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); + let values = [*threshold, *min_ms, *pre_ms, *post_ms]; + // The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame. + if values != before { + ui.ctx().request_repaint(); + } + if !strip_names.name.trim().is_empty() { + let names = format!("New clips are named {}, {} and so on.", strip_names.clip_name(0), strip_names.clip_name(1)); + ui.label(egui::RichText::new(names).small().color(Tokens::current().text_dim)); + } + ui.separator(); + ui.horizontal(|ui| { + let mut mode = None; + let mut rename = false; + if ui.button("Extract").clicked() { + mode = Some("extract"); + } + if ui.button("Rename…").clicked() { + rename = true; + } + if ui.button("Separate").clicked() { + mode = Some("separate"); + } + // Enter on a focused Extract or Separate clicks that button, so Enter presses Strip only when neither was clicked. + if buttons(ui, "Strip", enter) && mode.is_none() { + mode = Some("strip"); + } + // Enter on a focused Rename… also presses Strip, so Rename… goes first. + if rename { + rename_from = Some(values); + } else if let Some(mode) = mode { + action = Some(strip_silence_action(mode, values, strip_names)); + } + }); + (action, rename_from) +} + /// Show the open dialog, if any. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); @@ -328,44 +497,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); match &mut d { Dialog::NewTracks { count, format, kind, timebase, name } => { - ui.horizontal(|ui| { - let create = ui.label("Create"); - ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id); - ui.label("new"); - let fmt = craft_ui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { - for f in soundcraft_model::ChannelFormat::ALL { - ui.selectable_value(format, f.label().to_string(), f.label()); - } - }); - fmt.response.accessible_name("Channel format"); - let kind_combo = craft_ui::ComboBox::from_id_salt("nt_kind") - .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label())) - .show_ui(ui, |ui| { - for k in soundcraft_model::TrackKind::ALL { - if ui.selectable_label(kind == k.id(), k.label()).clicked() { - *kind = k.id().to_string(); - *name = k.default_name().to_string(); - } - } - }); - kind_combo.response.accessible_name("Track kind"); - ui.label("in"); - let tb = craft_ui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui( - ui, - |ui| { - ui.selectable_value(timebase, "samples".to_string(), "Samples"); - ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); - }, - ); - tb.response.accessible_name("Timebase"); - }); - ui.horizontal(|ui| { - ui.label("Name:"); - crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); - }); - if buttons(ui, "Create", enter) { - action = - Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name}))); + if let Some(cmd) = new_tracks_form(ui, count, format, kind, timebase, name, enter) { + action = Some(cmd); } } Dialog::NewSession { name, sample_rate, demo } => { @@ -387,24 +520,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::Bounce { path, format, bit_depth, normalize, dither } => { - ui.horizontal(|ui| { - ui.label("File"); - crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "File"); - if let Some(pick) = &app.services.pick_save - && ui.button("Browse…").clicked() - && let Some(p) = pick("file.bounce_mix", std::path::Path::new(path.as_str())) - { - match unicode(&p) { - Ok(text) => { - *path = text.to_owned(); - if let Some(picked) = p.extension().and_then(std::ffi::OsStr::to_str).and_then(FileFormat::from_write_extension) { - *format = picked; - } - } - Err(e) => app.ui.status = e.to_string(), - } - } - }); + bounce_file_row(app, ui, path, format); ui.horizontal(|ui| { craft_ui::ComboBox::from_label("Format").selected_text(format.name()).show_ui(ui, |ui| { for &f in FileFormat::WRITE { @@ -463,48 +579,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::PathPrompt { cmd, path, key, .. } => { - crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path"); - let mut picked = None; - if let Some(pick) = &app.services.pick_open - && cmd != "session.save_as" - && cmd != "session.save_copy" - && key != "dir" - && ui.button("Browse…").clicked() - && let Some(p) = pick(cmd, &[]) - { - picked = Some(p); - } - if let Some(pick) = &app.services.pick_folder - && key == "dir" - && ui.button("Browse…").clicked() - && let Some(p) = pick(std::path::Path::new(path.as_str())) - { - picked = Some(p); - } - if let Some(pick) = &app.services.pick_save - && (cmd == "session.save_as" || cmd == "session.save_copy") - && ui.button("Browse…").clicked() - && let Some(p) = pick(cmd, std::path::Path::new(path.as_str())) - { - picked = Some(p); - } - match picked.as_deref().map(unicode) { - Some(Ok(text)) => *path = text.to_owned(), - Some(Err(e)) => app.ui.status = e.to_string(), - None => {} - } - let file = soundcraft_engine::io::session_file(path); - let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file); - if replaces { - ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy())); - } - if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) { - let mut v = json!({}); - v[key.as_str()] = json!(path); - if replaces { - v["replace"] = json!(true); - } - action = Some((cmd.clone(), v)); + if let Some(c) = path_prompt_form(app, ui, cmd, path, key, enter) { + action = Some(c); } } Dialog::Number { cmd, key, value, suffix, .. } => { @@ -543,44 +619,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => { - let before = [*threshold, *min_ms, *pre_ms, *post_ms]; - ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); - ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms")); - ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); - ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); - let values = [*threshold, *min_ms, *pre_ms, *post_ms]; - // The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame. - if values != before { - ui.ctx().request_repaint(); - } - if !strip_names.name.trim().is_empty() { - let names = format!("New clips are named {}, {} and so on.", strip_names.clip_name(0), strip_names.clip_name(1)); - ui.label(egui::RichText::new(names).small().color(Tokens::current().text_dim)); - } - ui.separator(); - ui.horizontal(|ui| { - let mut mode = None; - let mut rename = false; - if ui.button("Extract").clicked() { - mode = Some("extract"); - } - if ui.button("Rename…").clicked() { - rename = true; - } - if ui.button("Separate").clicked() { - mode = Some("separate"); - } - // Enter on a focused Extract or Separate clicks that button, so Enter presses Strip only when neither was clicked. - if buttons(ui, "Strip", enter) && mode.is_none() { - mode = Some("strip"); - } - // Enter on a focused Rename… also presses Strip, so Rename… goes first. - if rename { - rename_from = Some(values); - } else if let Some(mode) = mode { - action = Some(strip_silence_action(mode, values, &strip_names)); - } - }); + let mut values = [*threshold, *min_ms, *pre_ms, *post_ms]; + let (a, r) = strip_silence_form(ui, &mut values, &strip_names, enter); + [*threshold, *min_ms, *pre_ms, *post_ms] = values; + action = a.or(action.take()); + rename_from = r.or(rename_from); } Dialog::StripSilenceRename { names, .. } => { strip_name_fields(ui, names, STRIP_NAME_LABELS); diff --git a/crates/ui-egui/src/dialogs/audiosuite.rs b/crates/ui-egui/src/dialogs/audiosuite.rs index fbc650e6..759565a8 100644 --- a/crates/ui-egui/src/dialogs/audiosuite.rs +++ b/crates/ui-egui/src/dialogs/audiosuite.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Value, json}; /// The speed ramp's choices in the AudioSuite window: param id, label, default, and (value, name) pairs. pub(super) const SPEED_RAMP_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [ diff --git a/crates/ui-egui/src/dialogs/files.rs b/crates/ui-egui/src/dialogs/files.rs index a2d8d0a0..4c8e4281 100644 --- a/crates/ui-egui/src/dialogs/files.rs +++ b/crates/ui-egui/src/dialogs/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SoundApp, Value, json}; /// When `id` would close a session with unsaved changes, ask first; true when the question is /// open instead. A take in progress is stopped first, so it counts as a change. diff --git a/crates/ui-egui/src/dialogs/strip_silence.rs b/crates/ui-egui/src/dialogs/strip_silence.rs index a128436c..2d5b98e9 100644 --- a/crates/ui-egui/src/dialogs/strip_silence.rs +++ b/crates/ui-egui/src/dialogs/strip_silence.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, ClipId, Dialog, SoundApp, StripNames, Tokens, TrackId, Value, json, vec2}; /// What Strip keeps on each track: the Strip Silence rectangles. pub type StripRects = Arc)>>; diff --git a/crates/ui-egui/src/edit_window/automation.rs b/crates/ui-egui/src/edit_window/automation.rs index 61d2b3a5..d3127982 100644 --- a/crates/ui-egui/src/edit_window/automation.rs +++ b/crates/ui-egui/src/edit_window/automation.rs @@ -1,5 +1,5 @@ use super::layout::{sample_at, x_of}; -use super::*; +use super::{Align2, AutoParam, Color32, Pos2, Rect, Samples, Session, Shape, Stroke, Tokens, Track, TrackKind, pos2, regular}; pub(super) fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) { let t = Tokens::current(); diff --git a/crates/ui-egui/src/edit_window/clips.rs b/crates/ui-egui/src/edit_window/clips.rs index 6445c95c..5887132d 100644 --- a/crates/ui-egui/src/edit_window/clips.rs +++ b/crates/ui-egui/src/edit_window/clips.rs @@ -1,7 +1,10 @@ use super::drag::fade_drag_preview; use super::input::{clip_gain_icon, near_fade_midpoint}; use super::layout::{sample_at, x_of}; -use super::*; +use super::{ + Align2, AutoParam, Clip, ClipContent, Color32, CornerRadius, Gesture, GridValue, NoteValue, Pos2, Range, Rect, Session, Shape, SoundApp, Stroke, + StrokeKind, TimeFormat, Tokens, Tool, Track, bold, clip_colors, format_position, pos2, regular, vec2, +}; /// The outline of the Strip Silence rectangles. pub fn strip_silence_stroke() -> Stroke { @@ -138,18 +141,39 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 { } } -/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame). -pub(super) fn draw_clip( - app: &SoundApp, - painter: &egui::Painter, - s: &Session, - track: &Track, - sorted: bool, - clip: &Clip, - tl: Rect, - lane: Rect, +/// What `draw_clip` draws into: the app, painter and session, and the track lane the clips sit on. +#[derive(Clone, Copy)] +pub(super) struct ClipLane<'a> { + pub(super) app: &'a SoundApp, + pub(super) painter: &'a egui::Painter, + pub(super) s: &'a Session, + pub(super) track: &'a Track, + /// Whether `track.clips()` is sorted by start (worked out once per track and frame). + pub(super) sorted: bool, + pub(super) tl: Rect, + pub(super) lane: Rect, +} + +/// Where and in what colours one clip is drawn, shared by the parts of `draw_clip`. +#[derive(Clone, Copy)] +struct ClipGeom<'a> { + r: Rect, + body_r: Rect, + vis: Rect, + x0: f32, + x1: f32, + name_h: f32, + body: Color32, + wave: Color32, + selected: bool, dim: bool, -) { + fade_in: soundcraft_model::Fade, + fade_out: soundcraft_model::Fade, + prev: Option<&'a Clip>, +} + +pub(super) fn draw_clip(cl: &ClipLane<'_>, clip: &Clip, dim: bool) { + let ClipLane { app, painter, s, track, tl, lane, .. } = *cl; let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0)); @@ -186,6 +210,19 @@ pub(super) fn draw_clip( let vis = body_r.intersect(lane); // The waveform and fade curve share the same temporary envelope every frame. let (fade_in, fade_out) = clip_preview_fades(clip, app.gesture.as_ref().filter(|_| !dim)); + let prev = crossfade_partner(track, clip); + let g = ClipGeom { r, body_r, vis, x0, x1, name_h, body, wave, selected, dim, fade_in, fade_out, prev }; + draw_clip_content(cl, clip, &g); + draw_crossfade_tail(cl, &g); + draw_gain_line(cl, clip, &g); + draw_fades(cl, clip, &g); + draw_clip_overlays(cl, clip, &g); +} + +/// The clip body: waveform, MIDI notes or the offline notice. +fn draw_clip_content(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, tl, .. } = *cl; + let ClipGeom { body_r, vis, body, wave, selected, fade_in, fade_out, .. } = *g; match &clip.content { _ if track.view == "blocks" => {} ClipContent::Audio { source, .. } => { @@ -247,7 +284,12 @@ pub(super) fn draw_clip( } } } - let prev = crossfade_partner(track, clip); +} + +/// The waveform of the clip this one crossfades with, drawn over the overlap. +fn draw_crossfade_tail(cl: &ClipLane<'_>, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, tl, .. } = *cl; + let ClipGeom { body_r, vis, x0, x1, dim, prev, .. } = *g; if s.edit.flag("waveform.overlapped_crossfades") && track.view != "blocks" && let Some(prev) = prev @@ -262,6 +304,12 @@ pub(super) fn draw_clip( }; crate::waveform::draw(crate::waveform::Canvas { painter: &painter.with_clip_rect(xf), s, tl }, prev, audio, track.channels(), body_r, tail); } +} + +/// The clip gain line and its icon. +fn draw_gain_line(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, tl, lane, .. } = *cl; + let ClipGeom { body_r, x0, x1, dim, .. } = *g; // Clip gain line. let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain"); if show_gain && body_r.height() > 12.0 { @@ -286,6 +334,12 @@ pub(super) fn draw_clip( painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col); painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); } +} + +/// The fade curves, their midpoint handles and the crossfade partner's fade-out. +fn draw_fades(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { app, painter, s, track, tl, .. } = *cl; + let ClipGeom { body_r, vis, x0, x1, body, dim, fade_in, fade_out, prev, .. } = *g; let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); for (side, fade) in [(true, fade_in), (false, fade_out)] { if fade.len <= 0 { @@ -309,6 +363,12 @@ pub(super) fn draw_clip( let pts = fade_curve_points(c.fade_out, false, a, b, body_r, painter.ctx().pixels_per_point()); painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); } +} + +/// Optional clip overlays (View › Clip). +fn draw_clip_overlays(cl: &ClipLane<'_>, clip: &Clip, g: &ClipGeom<'_>) { + let ClipLane { painter, s, track, sorted, tl, lane, .. } = *cl; + let ClipGeom { r, name_h, .. } = *g; // Optional clip overlays (View › Clip). let ov = Color32::from_rgb(16, 16, 16); if s.edit.flag("view.clip.rating") && clip.rating > 0 && name_h > 0.0 { @@ -323,23 +383,7 @@ pub(super) fn draw_clip( )); painter.line_segment([pos2(x, r.min.y + name_h), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90))); } - let time_text = if s.edit.flag("view.clip.time.current") { - Some(s.format_position(clip.start, s.edit.main_counter)) - } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { - clip.source().and_then(|src| s.source(src)).map(|src| { - format_position( - i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start), - TimeFormat::Timecode, - s.sample_rate, - &s.tempo, - s.frame_rate, - 0, - 0, - ) - }) - } else { - None - }; + let time_text = clip_time_text(s, clip); if let Some(tt) = time_text && r.height() > 30.0 { @@ -369,7 +413,27 @@ pub(super) fn draw_clip( if clip.edit_locked || clip.time_locked { painter.text(pos2(r.max.x - 10.0, r.min.y + 7.0), Align2::CENTER_CENTER, "🔒", regular(9.0), Color32::WHITE); } - let _ = app; +} + +/// The clip's start (or original) time label for the overlays, when one is shown. +fn clip_time_text(s: &Session, clip: &Clip) -> Option { + if s.edit.flag("view.clip.time.current") { + Some(s.format_position(clip.start, s.edit.main_counter)) + } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { + clip.source().and_then(|src| s.source(src)).map(|src| { + format_position( + i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start), + TimeFormat::Timecode, + s.sample_rate, + &s.tempo, + s.frame_rate, + 0, + 0, + ) + }) + } else { + None + } } /// The earlier clip of a crossfade pair: the one before `clip` in its playlist, overlapping its start. diff --git a/crates/ui-egui/src/edit_window/drag.rs b/crates/ui-egui/src/edit_window/drag.rs index 09c6b326..9ec78710 100644 --- a/crates/ui-egui/src/edit_window/drag.rs +++ b/crates/ui-egui/src/edit_window/drag.rs @@ -1,7 +1,9 @@ use super::automation::{auto_value, automation_id, lane_range, plugin_range}; use super::input::{new_midi_clip, snap}; use super::layout::{lane_rect, rect, sample_at}; -use super::*; +use super::{ + Arc, AutoParam, Clip, ClipId, Dialog, EditMode, Gesture, HashSet, Pos2, Range, Rect, Samples, Session, SoundApp, Spot, TrackId, json, ops, pos2, +}; /// The same bounded range drives the preview and the committed fade. pub(super) fn fade_drag_range(clip: &Clip, fade_in: bool, to: Samples) -> Option { diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index df0d1905..f8a002a2 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -1,12 +1,34 @@ use super::tracks::{click_track, folder_depth, height_targets, in_edit_window, track_context_menu, view_label, view_menu}; -use super::*; +use super::{ + Align2, AutoParam, Color32, Pos2, Rect, Sense, Shape, SoundApp, Stroke, Tokens, Track, TrackHeight, TrackId, TrackKind, Ui, bold, groove, json, + level_pos, meter_pos, pos2, raised_key, regular, rgb, shade, vec2, +}; /// Draw one track header: resize edge, selection and context menu, folder/options control, automation-lane toggle, /// name and the remaining track controls. pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32) { - let t = Tokens::current(); let id = track.id; - // Drag the bottom edge to resize (snaps to the height presets). + // Click on header background selects the track. + let bg = ui.interact(head, ui.scope_id().with(("hdr", id.0)), Sense::click()); + if bg.clicked() { + click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); + } + bg.context_menu(|ui| track_context_menu(app, ui, id)); + resize_edge(app, ui, track, head); + strip_buttons(app, ui, track, head); + name_plate(app, ui, track, head, selected); + playlist_menu(app, ui, track, head, head_bg); + toggle_keys(app, ui, track, head); + view_selector(app, ui, track, head); + voice_row(app, ui, track, head); + field_row(app, ui, track, head, head_bg); + let md = app.meters.get(&id).copied().unwrap_or_default(); + header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2); +} + +/// Drag the bottom edge of the header to resize the track (snaps to the height presets). +fn resize_edge(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let id = track.id; let edge = Rect::from_min_max(pos2(head.min.x, head.max.y - 4.0), pos2(head.max.x, head.max.y + 2.0)); let eresp = ui.interact(edge, ui.scope_id().with(("hedge", id.0)), Sense::drag()); if eresp.hovered() || eresp.dragged() { @@ -34,17 +56,15 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: ui.ctx().request_repaint(); } } +} + +/// The strip's round options/folder button and the automation-lane toggle. +fn strip_buttons(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let id = track.id; let h = head.height(); - // Click on header background selects the track. - let bg = ui.interact(head, ui.scope_id().with(("hdr", id.0)), Sense::click()); - if bg.clicked() { - click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); - } - bg.context_menu(|ui| track_context_menu(app, ui, id)); 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 strip = rgb(track.color); let ink = shade(strip, -0.8); - // The strip's round button opens a folder, or any other track's options. let round = at(2.0, 2.0, 10.0, 10.0); let rresp = ui.interact(round, ui.scope_id().with(("options", id.0)), Sense::click()); ui.painter().circle_filled(round.center(), 5.0, shade(strip, if rresp.hovered() { 0.6 } else { 0.45 })); @@ -76,7 +96,13 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: let _ = app.run("track.view", json!({"track": id.0, "view": view})); } } - // Name, highlighted when the track is selected, and the playlist menu beside it. +} + +/// The track name, highlighted when selected, with click, rename and drag-to-reorder. +fn name_plate(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) { + let t = Tokens::current(); + let id = track.id; + 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 indent = folder_depth(app.engine.session(), track) as f32 * 10.0; let name_r = at(16.0 + indent, 1.5, 97.0 - indent, 15.0); if selected { @@ -128,6 +154,13 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: let _ = app.run("track.move", json!({"track": id.0, "to": to})); } } +} + +/// The playlist menu button beside the name. +fn playlist_menu(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, head_bg: Color32) { + let t = Tokens::current(); + let id = track.id; + 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 pl_r = at(114.0, 1.0, 11.0, 14.5); let presp = ui.interact(pl_r, ui.scope_id().with(("pl", id.0)), Sense::click()); ui.painter().rect_filled(pl_r, 1.5, shade(head_bg, if presp.hovered() { -0.2 } else { -0.35 })); @@ -149,6 +182,13 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } } }); +} + +/// Record, input, solo and mute keys. +fn toggle_keys(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)); let m = &track.mixer; if h >= 37.0 { let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master); @@ -165,6 +205,14 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } } } +} + +/// The track view selector. +fn view_selector(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let t = Tokens::current(); + 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 vresp = header_selector( ui, @@ -177,6 +225,15 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: ); egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); } +} + +/// Freeze, voice and automation mode. +fn voice_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) { + let t = Tokens::current(); + 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)); + let m = &track.mixer; if h >= 76.0 { if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Aux) { let r = at(17.0, 57.0, 13.0, 17.0); @@ -220,6 +277,14 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } }); } +} + +/// Timebase key and the instrument or elastic field. +fn field_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, head_bg: Color32) { + let t = Tokens::current(); + 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 >= 94.0 { if track.kind.has_playlist() { let r = at(17.0, 75.0, 13.0, 17.0); @@ -277,8 +342,6 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: } } } - let md = app.meters.get(&id).copied().unwrap_or_default(); - header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2); } /// A small menu arrow, pointing down, or right for a closed folder. diff --git a/crates/ui-egui/src/edit_window/input.rs b/crates/ui-egui/src/edit_window/input.rs index 9875a1e6..88538b99 100644 --- a/crates/ui-egui/src/edit_window/input.rs +++ b/crates/ui-egui/src/edit_window/input.rs @@ -2,7 +2,10 @@ use super::automation::{automation_id, breakpoint_at, plugin_range, shown_automa use super::clips::{clip_name_height, fade_control_position}; use super::drag::{drag_snap, fade_curve_drag_value, fade_drag_range, spot_or_run}; use super::layout::{sample_at, x_of}; -use super::*; +use super::{ + Align2, AutoParam, Clip, ClipContent, ClipId, Color32, Dialog, EditMode, Gesture, GridValue, NoteValue, Pos2, Range, Rect, Samples, Sense, + Session, SoundApp, Spot, Stroke, StrokeKind, Tokens, Tool, Track, TrackId, Ui, json, ops, pos2, regular, vec2, +}; // ---- interaction ----------------------------------------------------------------------------- @@ -86,9 +89,40 @@ fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) { p.text(pos2(panel.center().x, panel.min.y + 12.0), Align2::CENTER_CENTER, format!("{db:+.1}"), regular(10.5), t.counter_text); } -/// Handle one track lane: clip context menus, drag-and-drop sources, sampler drops, and the active tool's selection, -/// trim, fade, automation and clip editing. -pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { +/// What a lane interaction knows before it hit-tests: the lane, the session and the press point. +struct LaneInput<'a> { + track: &'a Track, + lane: Rect, + tl: Rect, + s: &'a Session, + tool: Tool, + origin: Pos2, + at_origin: Samples, + auto_view: Option, +} + +/// What is under the pointer: a breakpoint, fade handles and the clip body. +struct LaneHits<'a> { + point_hit: Option<(AutoParam, Samples)>, + fade_handle: Option<(&'a Clip, bool)>, + curve_handle: Option<(&'a Clip, bool)>, + hit: Option, +} + +/// Everything the lane's gesture, click and cursor handling reads. +struct LaneCtx<'a> { + inp: LaneInput<'a>, + hits: LaneHits<'a>, + mods: egui::Modifiers, + p: Pos2, + at: Samples, + eff: Tool, + fade_corner: Option, + selector_press: bool, +} + +/// The lane's response, with its clip context menu and the drops it accepts. +fn lane_menu_and_drops(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) -> egui::Response { let id = ui.scope_id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); // The clip menu is attached before the early return below: once the menu is open the pointer @@ -121,16 +155,15 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l if track.kind == soundcraft_model::TrackKind::Instrument { crate::sampler_window::drop_target(app, ui.ctx(), lane, track.id); } - let s = app.engine.session_arc(); - let tool = s.edit.tool; - let mods = ui.input(|i| i.modifiers); - let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; - let at = sample_at(&s, tl, p.x).max(0); - let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; - let at_origin = sample_at(&s, tl, origin.x).max(0); - let auto_view = AutoParam::parse(&track.view); + resp +} + +/// Hit-test the press point against breakpoints, fade handles and clip bodies. +fn hit_test<'a>(inp: &LaneInput<'a>) -> LaneHits<'a> { + let (track, lane, tl, s, tool, origin, at_origin) = (inp.track, inp.lane, inp.tl, inp.s, inp.tool, inp.origin, inp.at_origin); + let auto_view = &inp.auto_view; let point_hit = if matches!(tool, Tool::Smart | Tool::Grabber | Tool::Pencil) { - shown_automation(track).and_then(|param| breakpoint_at(&s, tl, lane, track, ¶m, origin).map(|at| (param, at))) + shown_automation(track).and_then(|param| breakpoint_at(s, tl, lane, track, ¶m, origin).map(|at| (param, at))) } else { None }; @@ -143,34 +176,40 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l .iter() .rev() .filter(|c| body_hit.is_none_or(|front| front.id == c.id || front.start == c.end())) - .find_map(|c| fade_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + .find_map(|c| fade_handle_at(s, tl, lane, c, origin).map(|side| (c, side))) } else { None }; let curve_handle = if tool == Tool::Smart && auto_view.is_none() && fade_handle.is_none() { - body_hit.and_then(|c| fade_curve_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + body_hit.and_then(|c| fade_curve_handle_at(s, tl, lane, c, origin).map(|side| (c, side))) } else { None }; let hit = fade_handle.or(curve_handle).map(|(c, _)| c).or(body_hit).cloned(); - let upper = origin.y < lane.center().y; - let near_edge = |c: &Clip| -> Option { - let xs = x_of(&s, tl, c.start); - let xe = x_of(&s, tl, c.end()); - if (origin.x - xs).abs() < 6.0 { - Some(true) - } else if (origin.x - xe).abs() < 6.0 { - Some(false) - } else { - None - } - }; - // Effective tool under the Smart tool; on a breakpoint every tool that edits one grabs it. - let eff = match tool { - _ if point_hit.is_some() => Tool::Grabber, + LaneHits { point_hit, fade_handle, curve_handle, hit } +} + +/// Which edge of `c` the press point is within a few points of: `Some(true)` the start, `Some(false)` the end. +fn near_edge(inp: &LaneInput<'_>, c: &Clip) -> Option { + let xs = x_of(inp.s, inp.tl, c.start); + let xe = x_of(inp.s, inp.tl, c.end()); + if (inp.origin.x - xs).abs() < 6.0 { + Some(true) + } else if (inp.origin.x - xe).abs() < 6.0 { + Some(false) + } else { + None + } +} + +/// Effective tool under the Smart tool; on a breakpoint every tool that edits one grabs it. +fn effective_tool(inp: &LaneInput<'_>, hits: &LaneHits<'_>) -> Tool { + let upper = inp.origin.y < inp.lane.center().y; + match inp.tool { + _ if hits.point_hit.is_some() => Tool::Grabber, Tool::Smart => { - if let Some(c) = &hit { - if near_edge(c).is_some() { + if let Some(c) = &hits.hit { + if near_edge(inp, c).is_some() { Tool::Trim } else if upper { Tool::Selector @@ -182,23 +221,18 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l } } other => other, - }; - if resp.hovered() { - let icon = match eff { - Tool::Trim => egui::CursorIcon::ResizeHorizontal, - Tool::Grabber => egui::CursorIcon::Grab, - Tool::Selector => egui::CursorIcon::Text, - Tool::Zoom => egui::CursorIcon::ZoomIn, - Tool::Pencil => egui::CursorIcon::Crosshair, - _ => egui::CursorIcon::Default, - }; - ui.ctx().set_cursor_icon(icon); } - let fade_corner = if tool == Tool::Smart && point_hit.is_none() { +} + +/// The fade corner (`true` for the fade-in) the Smart tool is pressing, if any. +fn fade_corner_at(inp: &LaneInput<'_>, hits: &LaneHits<'_>) -> Option { + let (s, tl, lane, origin) = (inp.s, inp.tl, inp.lane, inp.origin); + let (tool, point_hit, fade_handle, hit) = (inp.tool, &hits.point_hit, hits.fade_handle, &hits.hit); + if tool == Tool::Smart && point_hit.is_none() { fade_handle.map(|(_, side)| side).or_else(|| { hit.as_ref().and_then(|c| { - let xs = x_of(&s, tl, c.start); - let xe = x_of(&s, tl, c.end()); + let xs = x_of(s, tl, c.start); + let xe = x_of(s, tl, c.end()); let top = origin.y < lane.min.y + lane.height() * 0.28; if !top { None @@ -213,25 +247,15 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l }) } else { None - }; - if fade_corner.is_some() && resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); } - if curve_handle.is_some() && resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); - } - let gain_icon_at = |q: Pos2| { - let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| auto_view.is_none())?; - clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) - }; - if resp.hovered() && gain_icon_at(p).is_some() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); - } - let selector_press = eff == Tool::Selector && fade_corner.is_none() && curve_handle.is_none() && gain_icon_at(origin).is_none(); - if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { - place_insertion(app, ui, &s, track.id, at, mods.shift); - } - let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None }; +} + +/// Start the gesture a press begins: a breakpoint, clip gain, a fade or the tool's own. +fn start_gesture(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Response, gain_drag: Option) { + let LaneCtx { inp, hits, mods, p, at, eff, fade_corner, .. } = c; + let (track, lane, s, origin, at_origin, auto_view) = (inp.track, inp.lane, inp.s, inp.origin, inp.at_origin, &inp.auto_view); + let (point_hit, curve_handle, hit) = (&hits.point_hit, hits.curve_handle, &hits.hit); + let (mods, p, at, eff, fade_corner) = (*mods, *p, *at, *eff, *fade_corner); if resp.drag_started() && let Some((param, at)) = &point_hit { @@ -252,7 +276,7 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l { let fade = if fade_in { c.fade_in } else { c.fade_out }; let anchor = (origin.y, fade.gain(0.5)); - let height = (lane.height() - 2.0 - clip_name_height(&s, lane)).max(1.0); + let height = (lane.height() - 2.0 - clip_name_height(s, lane)).max(1.0); app.gesture = Some(Gesture::FadeCurve { clip: c.id, fade_in, curvature: fade_curve_drag_value(fade, anchor, height, p.y), anchor, height }); ui.ctx().request_repaint(); } else if resp.drag_started() @@ -267,12 +291,12 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l ui.ctx().request_repaint(); } else if resp.drag_started() { app.gesture = match eff { - Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at_origin), tracks: vec![track.id] }), + Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(s, at_origin), tracks: vec![track.id] }), Tool::Grabber => hit.as_ref().map(|c| { let clips = if s.edit.selected_clips.contains(&c.id) { s.edit.selected_clips.clone() } else { vec![c.id] }; Gesture::MoveClips { clips, grab_at: at_origin, delta: 0, from_track: track.id, to_track: track.id } }), - Tool::Trim => hit.as_ref().map(|c| match near_edge(c) { + Tool::Trim => hit.as_ref().map(|c| match near_edge(inp, c) { Some(false) => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, _ if at_origin - c.start < c.end() - at_origin => Gesture::TrimStart { clip: c.id, track: track.id, to: c.start }, _ => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, @@ -285,6 +309,11 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; } +} + +/// While a clip-gain gesture is dragged, the fader follows the pointer. +fn drag_clip_gain(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Response) { + let (track, s, mods, p) = (c.inp.track, c.inp.s, c.mods, c.p); if let Some(Gesture::ClipGain { clip, track: t, pos, anchor, fine, fader_bottom }) = &mut app.gesture && *t == track.id && resp.dragged() @@ -303,6 +332,13 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } +} + +/// Click handling for the lane: breakpoints, clip selection, spot dialogs, zoom and pencil points. +fn lane_click(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Response) { + let (track, lane, s, auto_view) = (c.inp.track, c.inp.lane, c.inp.s, &c.inp.auto_view); + let (point_hit, curve_handle, hit) = (&c.hits.point_hit, c.hits.curve_handle, &c.hits.hit); + let (mods, p, at, eff, selector_press) = (c.mods, c.p, c.at, c.eff, c.selector_press); if resp.clicked() && let Some((param, at)) = &point_hit { @@ -319,14 +355,14 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l ids.push(c.id.0); let _ = app.run("edit.select", json!({"clips": ids})); if s.edit.edit_mode == EditMode::Spot { - app.dialogs.open = Spot::clips(&s, vec![c.id], None, 0).map(Dialog::Spot); + app.dialogs.open = Spot::clips(s, vec![c.id], None, 0).map(Dialog::Spot); } } } Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => { if let Some(c) = &hit { let start = at - c.start < c.end() - at; - app.dialogs.open = Spot::trim(&s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot); + app.dialogs.open = Spot::trim(s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot); } } Tool::Zoom => { @@ -352,11 +388,11 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l Some(c) => { let _ = app.run("edit.select", json!({"clips": [c.id.0]})); } - None => new_midi_clip(app, &s, track.id, at, at), + None => new_midi_clip(app, s, track.id, at, at), } } } - _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), + _ if !selector_press => place_insertion(app, ui, s, track.id, at, mods.shift), _ => {} } } @@ -373,6 +409,58 @@ pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, l } } +/// Handle one track lane: clip context menus, drag-and-drop sources, sampler drops, and the active tool's selection, +/// trim, fade, automation and clip editing. +pub(super) fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { + let resp = lane_menu_and_drops(app, ui, track, lane, tl); + let s = app.engine.session_arc(); + let tool = s.edit.tool; + let mods = ui.input(|i| i.modifiers); + let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; + let at = sample_at(&s, tl, p.x).max(0); + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; + let at_origin = sample_at(&s, tl, origin.x).max(0); + let auto_view = AutoParam::parse(&track.view); + let inp = LaneInput { track, lane, tl, s: &s, tool, origin, at_origin, auto_view }; + let hits = hit_test(&inp); + let eff = effective_tool(&inp, &hits); + if resp.hovered() { + let icon = match eff { + Tool::Trim => egui::CursorIcon::ResizeHorizontal, + Tool::Grabber => egui::CursorIcon::Grab, + Tool::Selector => egui::CursorIcon::Text, + Tool::Zoom => egui::CursorIcon::ZoomIn, + Tool::Pencil => egui::CursorIcon::Crosshair, + _ => egui::CursorIcon::Default, + }; + ui.ctx().set_cursor_icon(icon); + } + let fade_corner = fade_corner_at(&inp, &hits); + let curve_handle = hits.curve_handle; + if fade_corner.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + if curve_handle.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let gain_icon_at = |q: Pos2| { + let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| inp.auto_view.is_none())?; + clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) + }; + if resp.hovered() && gain_icon_at(p).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let selector_press = eff == Tool::Selector && fade_corner.is_none() && curve_handle.is_none() && gain_icon_at(origin).is_none(); + if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { + place_insertion(app, ui, &s, track.id, at, mods.shift); + } + let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None }; + let cx = LaneCtx { inp, hits, mods, p, at, eff, fade_corner, selector_press }; + start_gesture(app, ui, &cx, &resp, gain_drag); + drag_clip_gain(app, ui, &cx, &resp); + lane_click(app, ui, &cx, &resp); +} + fn place_insertion(app: &mut SoundApp, ui: &Ui, s: &Session, track: TrackId, at: Samples, extend: bool) { let at = snap(s, at); let sel = s.edit.selection; diff --git a/crates/ui-egui/src/edit_window/layout.rs b/crates/ui-egui/src/edit_window/layout.rs index afc114bb..83452fcc 100644 --- a/crates/ui-egui/src/edit_window/layout.rs +++ b/crates/ui-egui/src/edit_window/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, Samples, Session, Track, pos2}; pub const HEADER_W: f32 = 136.0; diff --git a/crates/ui-egui/src/edit_window/overlay.rs b/crates/ui-egui/src/edit_window/overlay.rs index 985e392c..d5a3934a 100644 --- a/crates/ui-egui/src/edit_window/overlay.rs +++ b/crates/ui-egui/src/edit_window/overlay.rs @@ -2,7 +2,7 @@ use super::drag::drag_plan; use super::input::midi_clip_span; use super::layout::x_of; use super::scroll::{FollowFrame, follow_update}; -use super::*; +use super::{Color32, Gesture, Rect, Samples, Shape, SoundApp, Stroke, StrokeKind, Tokens, TrackId, Ui, json, ops, pos2, rgb}; /// Selection overlay, insertion point and playhead across rulers and tracks. /// While playing, auto-scroll keeps the playhead in view for the follow diff --git a/crates/ui-egui/src/edit_window/rulers.rs b/crates/ui-egui/src/edit_window/rulers.rs index 250549cc..c665f907 100644 --- a/crates/ui-egui/src/edit_window/rulers.rs +++ b/crates/ui-egui/src/edit_window/rulers.rs @@ -1,6 +1,9 @@ use super::input::snap; use super::layout::{COLUMN_TITLES_H, COLUMNS, Column, HEADER_W, RULER_H, edit_columns, header_width, sample_at, x_of}; -use super::*; +use super::{ + Align2, Color32, GridValue, NoteValue, Range, Rect, Samples, Sense, Session, Shape, SoundApp, Stroke, TimeFormat, Tokens, Ui, bold, + format_position, json, pos2, regular, rgb, vec2, +}; // ---- rulers ---------------------------------------------------------------------------------- @@ -170,7 +173,7 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, } else { soundcraft_time::FrameRate::Fps25 }; - timebase_ruler(ui, &painter, &s, tl, row, view, TimeFormat::Timecode, alt, i + 1 == rulers.len()); + timebase_ruler(&painter, &s, &RulerRow { tl, row, view, bottom: i + 1 == rulers.len() }, TimeFormat::Timecode, alt); } "markers" | "markers2" | "markers3" | "markers4" | "markers5" | "chords" => { let lane: u8 = match id.as_str() { @@ -225,7 +228,7 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, } _ => { if let Some(f) = fmt { - timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len()); + timebase_ruler(&painter, &s, &RulerRow { tl, row, view, bottom: i + 1 == rulers.len() }, f, s.frame_rate); // Clicking a timebase ruler locates. let resp = ui.interact(row, ui.scope_id().with(("ruler", id.as_str())), Sense::click_and_drag()); if (resp.clicked() || resp.dragged()) @@ -247,17 +250,17 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, ui.painter().line_segment([pos2(area.min.x, area.max.y - 1.0), pos2(area.max.x, area.max.y - 1.0)], Stroke::new(1.0, t.border)); } -fn timebase_ruler( - _ui: &Ui, - painter: &egui::Painter, - s: &Session, +/// Where a timebase ruler sits: the timeline, its row and the visible range, and whether it is the bottom ruler. +#[derive(Clone, Copy)] +struct RulerRow { tl: Rect, row: Rect, view: Range, - f: TimeFormat, - rate: soundcraft_time::FrameRate, bottom: bool, -) { +} + +fn timebase_ruler(painter: &egui::Painter, s: &Session, place: &RulerRow, f: TimeFormat, rate: soundcraft_time::FrameRate) { + let RulerRow { tl, row, view, bottom } = *place; let t = Tokens::current(); let sr = s.sample_rate; let spp = s.edit.zoom.samples_per_px; diff --git a/crates/ui-egui/src/edit_window/scroll.rs b/crates/ui-egui/src/edit_window/scroll.rs index c54b34b7..0b78e262 100644 --- a/crates/ui-egui/src/edit_window/scroll.rs +++ b/crates/ui-egui/src/edit_window/scroll.rs @@ -1,7 +1,7 @@ use super::layout::track_height; use super::tracks::in_edit_window; use super::universe::content_end; -use super::*; +use super::{Rect, Samples, Sense, SoundApp, Tokens, TrackId, Ui, json, pos2, vec2}; /// Scroll Into View: track `id` to the top, as far as the scroll bar reaches. A track the Edit /// window doesn't show (hidden, or in a closed folder) leaves the view where it is. diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index f7f94bbe..8b6fff5f 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -591,128 +591,129 @@ fn waveform_geometry(h: &Harness, clip_rect: Rect) -> Vec> { .collect() } +/// Replace the clip's mono source with a stereo one whose right channel is half the left, and make the track stereo. +fn make_stereo(h: &mut Harness<'static, SoundApp>, tid: soundcraft_model::TrackId, source: soundcraft_model::SourceId) { + let audio = h.state().engine.session().pool.get(source).unwrap().clone(); + let left = audio.buffer.channels[0].clone(); + let right = left.iter().map(|sample| sample * 0.5).collect(); + let audio = + soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: audio.buffer.sample_rate, channels: vec![left, right] }); + let s = h.state_mut().engine.session_mut(); + s.pool.insert(source, std::sync::Arc::new(audio)); + s.track_mut(tid).unwrap().format = soundcraft_model::ChannelFormat::Stereo; +} + +/// Where a fade drag presses and where it releases: the curve midpoint (bend) or the fade's end (length). +fn fade_drag_points( + session: &soundcraft_model::Session, + tl: Rect, + lane: Rect, + clip: soundcraft_model::ClipId, + fade_in: bool, + bend: bool, +) -> (Pos2, Pos2) { + let c = session.find_clip(clip).unwrap().1; + let (xs, xe) = (x_of(session, tl, c.start), x_of(session, tl, c.end())); + if bend { + let midpoint = fade_midpoint(session, tl, lane, c, fade_in).unwrap(); + (midpoint, midpoint - vec2(0.0, 16.0)) + } else { + (pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0), pos2(xs + (xe - xs) * if fade_in { 0.7 } else { 0.3 }, lane.min.y + 4.0)) + } +} + +fn fade_waveform_case(channels: usize, fade_in: bool, bend: bool, mode: &str) { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + let neighbour = h.state().engine.session().track(tid).unwrap().clips().iter().find(|c| c.id != clip.id && c.start >= clip.end()).unwrap().clone(); + let source = clip.source().unwrap(); + if channels == 2 { + make_stereo(&mut h, tid, source); + } + if mode == "power" { + h.state_mut().engine.execute("view.waveform_power", &json!({})).unwrap(); + } + h.state_mut().engine.execute("view.waveform_rectified", &json!({"value": mode == "rectified"})).unwrap(); + h.state_mut().engine.execute("view.waveform_outlines", &json!({"value": mode == "outlines"})).unwrap(); + h.state_mut().engine.execute("view.waveform_zoom", &json!({"value": 1.4})).unwrap(); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.gain_db = -3.0; + c.gain_env = vec![(0, -3.0), (c.length, 3.0)]; + let target = soundcraft_model::Fade { + len: if bend { c.length * 2 / 3 } else { c.length / 4 }, + shape: soundcraft_model::FadeShape::SCurve, + curvature: 0.0, + }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let undo_len = h.state().engine.undo_history().len(); + let audio = before.pool.get(source).unwrap().clone(); + let (xs, xe) = (x_of(&before, tl, clip.start), x_of(&before, tl, clip.end())); + let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)).intersect(tl); + let neighbour_rect = + Rect::from_min_max(pos2(x_of(&before, tl, neighbour.start), lane.min.y), pos2(x_of(&before, tl, neighbour.end()), lane.max.y)).intersect(tl); + let original = waveform_geometry(&h, clip_rect); + let neighbour_wave = waveform_geometry(&h, neighbour_rect); + assert_eq!(original.len(), channels); + let side = if fade_in { "in" } else { "out" }; + let operation = if bend { "bend" } else { "length" }; + let screenshot = channels == 1 && mode == "peak"; + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-before")); + } + let (press, to) = fade_drag_points(&before, tl, lane, clip.id, fade_in, bend); + h.hover_at(press); + h.drag_at(press); + h.hover_at(to); + h.step(); + h.step(); + assert!(if bend { + matches!(h.state().gesture, Some(Gesture::FadeCurve { .. })) + } else { + matches!(h.state().gesture, Some(Gesture::Fade { .. })) + }); + let preview = waveform_geometry(&h, clip_rect); + assert_eq!(preview.len(), channels); + for channel in 0..channels { + assert_ne!( + preview[channel], original[channel], + "the waveform must respond while the button is held: {side}, {operation}, {mode}, channel={channel}" + ); + } + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.session(), &before); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio), "preview must retain cached media and peaks"); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-preview")); + } + h.drop_at(to); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(waveform_geometry(&h, clip_rect), preview, "release must not cause a waveform jump"); + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio)); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + h.step(); + assert_eq!(waveform_geometry(&h, clip_rect), original); +} + #[test] fn waveforms_follow_fade_drags_before_release_and_match_the_committed_waveform() { for channels in [1, 2] { for fade_in in [true, false] { for bend in [false, true] { for mode in ["peak", "power", "rectified", "outlines"] { - let mut h = smart_demo(); - let tl = timeline(&h); - let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); - let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); - let neighbour = h - .state() - .engine - .session() - .track(tid) - .unwrap() - .clips() - .iter() - .find(|c| c.id != clip.id && c.start >= clip.end()) - .unwrap() - .clone(); - let source = clip.source().unwrap(); - if channels == 2 { - let audio = h.state().engine.session().pool.get(source).unwrap().clone(); - let left = audio.buffer.channels[0].clone(); - let right = left.iter().map(|sample| sample * 0.5).collect(); - let audio = soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { - sample_rate: audio.buffer.sample_rate, - channels: vec![left, right], - }); - let s = h.state_mut().engine.session_mut(); - s.pool.insert(source, std::sync::Arc::new(audio)); - s.track_mut(tid).unwrap().format = soundcraft_model::ChannelFormat::Stereo; - } - if mode == "power" { - h.state_mut().engine.execute("view.waveform_power", &json!({})).unwrap(); - } - h.state_mut().engine.execute("view.waveform_rectified", &json!({"value": mode == "rectified"})).unwrap(); - h.state_mut().engine.execute("view.waveform_outlines", &json!({"value": mode == "outlines"})).unwrap(); - h.state_mut().engine.execute("view.waveform_zoom", &json!({"value": 1.4})).unwrap(); - let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - c.gain_db = -3.0; - c.gain_env = vec![(0, -3.0), (c.length, 3.0)]; - let target = soundcraft_model::Fade { - len: if bend { c.length * 2 / 3 } else { c.length / 4 }, - shape: soundcraft_model::FadeShape::SCurve, - curvature: 0.0, - }; - let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.2 }; - (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; - h.step(); - let before = h.state().engine.session().clone(); - let undo_len = h.state().engine.undo_history().len(); - let audio = before.pool.get(source).unwrap().clone(); - let (xs, xe) = (x_of(&before, tl, clip.start), x_of(&before, tl, clip.end())); - let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)).intersect(tl); - let neighbour_rect = Rect::from_min_max( - pos2(x_of(&before, tl, neighbour.start), lane.min.y), - pos2(x_of(&before, tl, neighbour.end()), lane.max.y), - ) - .intersect(tl); - let original = waveform_geometry(&h, clip_rect); - let neighbour_wave = waveform_geometry(&h, neighbour_rect); - assert_eq!(original.len(), channels); - let side = if fade_in { "in" } else { "out" }; - let operation = if bend { "bend" } else { "length" }; - let screenshot = channels == 1 && mode == "peak"; - if screenshot { - fade_shot(&mut h, &format!("waveform-{side}-{operation}-before")); - } - let (press, to) = if bend { - let c = before.find_clip(clip.id).unwrap().1; - let midpoint = fade_midpoint(&before, tl, lane, c, fade_in).unwrap(); - (midpoint, midpoint - vec2(0.0, 16.0)) - } else { - ( - pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0), - pos2(xs + (xe - xs) * if fade_in { 0.7 } else { 0.3 }, lane.min.y + 4.0), - ) - }; - h.hover_at(press); - h.drag_at(press); - h.hover_at(to); - h.step(); - h.step(); - assert!(if bend { - matches!(h.state().gesture, Some(Gesture::FadeCurve { .. })) - } else { - matches!(h.state().gesture, Some(Gesture::Fade { .. })) - }); - let preview = waveform_geometry(&h, clip_rect); - assert_eq!(preview.len(), channels); - for channel in 0..channels { - assert_ne!( - preview[channel], original[channel], - "the waveform must respond while the button is held: {side}, {operation}, {mode}, channel={channel}" - ); - } - assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); - assert_eq!(h.state().engine.session(), &before); - assert_eq!(h.state().engine.undo_history().len(), undo_len); - assert!( - std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio), - "preview must retain cached media and peaks" - ); - if screenshot { - fade_shot(&mut h, &format!("waveform-{side}-{operation}-preview")); - } - h.drop_at(to); - h.step(); - assert!(h.state().gesture.is_none()); - assert_eq!(waveform_geometry(&h, clip_rect), preview, "release must not cause a waveform jump"); - assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); - assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); - assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio)); - if screenshot { - fade_shot(&mut h, &format!("waveform-{side}-{operation}-released")); - } - assert!(h.state_mut().engine.undo()); - assert_eq!(h.state().engine.session(), &before); - h.step(); - assert_eq!(waveform_geometry(&h, clip_rect), original); + fade_waveform_case(channels, fade_in, bend, mode); } } } @@ -998,85 +999,100 @@ fn fade_midpoint_handles_remain_visible_and_can_be_grabbed_again() { } } +/// `session` with the clip's fade-in (or fade-out) curvature set. +fn with_curvature(session: &soundcraft_model::Session, clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32) -> soundcraft_model::Session { + let mut expected = session.clone(); + let c = expected.find_clip_mut(clip).unwrap(); + if fade_in { + c.fade_in.curvature = curvature; + } else { + c.fade_out.curvature = curvature; + } + expected +} + +/// The screenshot name for a fade bend drag. +fn bend_shot_name(fade_in: bool, dy: f32, stage: &str) -> String { + let side = if fade_in { "in" } else { "out" }; + let direction = if dy < 0.0 { "up" } else { "down" }; + format!("fade-{side}-bend-{direction}-{stage}") +} + +fn fade_midpoint_bend_case(fade_in: bool, show_name: bool, dy: f32) { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let target = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: -0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let clip_rect = Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); + let original = fade_curves(&h, clip_rect); + let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; + let middle = original[edited].0.points.len() / 2; + let midpoint = original[edited].0.points[middle]; + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(midpoint); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + click(&mut h, midpoint); + assert_eq!(h.state().engine.session(), &before, "clicking the curve control should not change the selection"); + h.drag_at(midpoint); + let to = midpoint + vec2(0.0, dy); + h.hover_at(to); + h.step(); + h.step(); // Paint the frame requested when the drag gesture starts. + let curvature = match h.state().gesture { + Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if id == clip.id && side == fade_in => curvature, + _ => panic!("the midpoint must start a curve gesture"), + }; + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + assert!(if dy < 0.0 { curvature > 0.0 } else { curvature < 0.0 }); + let preview = fade_curves(&h, clip_rect); + assert_eq!(preview.len(), 2); + assert_eq!(preview[opposite], original[opposite]); + assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); + assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); + assert_eq!(preview[edited].0.points[middle].x, midpoint.x); + assert!( + (preview[edited].0.points[middle].y - (midpoint.y + dy)).abs() < 0.001, + "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", + preview[edited].0.points[middle], + h.state().gesture + ); + assert_eq!(h.state().engine.session(), &before); + if show_name { + fade_shot(&mut h, &bend_shot_name(fade_in, dy, "preview")); + } + h.drop_at(to); + h.step(); + let expected = with_curvature(&before, clip.id, fade_in, curvature); + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(fade_curves(&h, clip_rect), preview, "the released envelope must match the preview"); + let rel = if fade_in { target.len / 2 } else { clip.length - target.len / 2 }; + let previous_gain = before.find_clip(clip.id).unwrap().1.gain_at(rel); + let new_gain = expected.find_clip(clip.id).unwrap().1.gain_at(rel); + assert!(if dy < 0.0 { new_gain > previous_gain } else { new_gain < previous_gain }); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + if show_name { + fade_shot(&mut h, &bend_shot_name(fade_in, dy, "released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &expected); +} + #[test] fn fade_midpoints_drag_vertically_without_moving_the_endpoints() { for fade_in in [true, false] { for show_name in [true, false] { for dy in [-16.0, 16.0] { - let mut h = smart_demo(); - h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); - h.step(); - let tl = timeline(&h); - let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); - let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - let target = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; - let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: -0.2 }; - (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; - h.step(); - let before = h.state().engine.session().clone(); - let clip_rect = Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); - let original = fade_curves(&h, clip_rect); - let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; - let middle = original[edited].0.points.len() / 2; - let midpoint = original[edited].0.points[middle]; - let undo_len = h.state().engine.undo_history().len(); - h.hover_at(midpoint); - h.step(); - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); - click(&mut h, midpoint); - assert_eq!(h.state().engine.session(), &before, "clicking the curve control should not change the selection"); - h.drag_at(midpoint); - let to = midpoint + vec2(0.0, dy); - h.hover_at(to); - h.step(); - h.step(); // Paint the frame requested when the drag gesture starts. - let curvature = match h.state().gesture { - Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if id == clip.id && side == fade_in => curvature, - _ => panic!("the midpoint must start a curve gesture"), - }; - assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); - assert!(if dy < 0.0 { curvature > 0.0 } else { curvature < 0.0 }); - let preview = fade_curves(&h, clip_rect); - assert_eq!(preview.len(), 2); - assert_eq!(preview[opposite], original[opposite]); - assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); - assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); - assert_eq!(preview[edited].0.points[middle].x, midpoint.x); - assert!( - (preview[edited].0.points[middle].y - (midpoint.y + dy)).abs() < 0.001, - "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", - preview[edited].0.points[middle], - h.state().gesture - ); - assert_eq!(h.state().engine.session(), &before); - let side = if fade_in { "in" } else { "out" }; - let direction = if dy < 0.0 { "up" } else { "down" }; - if show_name { - fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-preview")); - } - h.drop_at(to); - h.step(); - let mut expected = before.clone(); - let c = expected.find_clip_mut(clip.id).unwrap(); - if fade_in { - c.fade_in.curvature = curvature; - } else { - c.fade_out.curvature = curvature; - } - assert_eq!(h.state().engine.session(), &expected); - assert_eq!(fade_curves(&h, clip_rect), preview, "the released envelope must match the preview"); - let rel = if fade_in { target.len / 2 } else { clip.length - target.len / 2 }; - let previous_gain = before.find_clip(clip.id).unwrap().1.gain_at(rel); - let new_gain = expected.find_clip(clip.id).unwrap().1.gain_at(rel); - assert!(if dy < 0.0 { new_gain > previous_gain } else { new_gain < previous_gain }); - assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); - if show_name { - fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-released")); - } - assert!(h.state_mut().engine.undo()); - assert_eq!(h.state().engine.session(), &before); - assert!(h.state_mut().engine.redo()); - assert_eq!(h.state().engine.session(), &expected); + fade_midpoint_bend_case(fade_in, show_name, dy); } } } diff --git a/crates/ui-egui/src/edit_window/tracks.rs b/crates/ui-egui/src/edit_window/tracks.rs index 8eb6c593..8da99821 100644 --- a/crates/ui-egui/src/edit_window/tracks.rs +++ b/crates/ui-egui/src/edit_window/tracks.rs @@ -1,9 +1,12 @@ use super::automation::draw_automation; -use super::clips::{draw_clip, draw_live_take, draw_strip_silence, grid_lines}; +use super::clips::{ClipLane, draw_clip, draw_live_take, draw_strip_silence, grid_lines}; use super::header::{header_column, track_header}; use super::input::lane_interaction; use super::layout::{Column, HEADER_W, LANE_H, STRIP_W, lane_rect, sample_at, x_of}; -use super::*; +use super::{ + Align2, AutoParam, Range, Rect, Sense, Session, SoundApp, Stroke, Tokens, Track, TrackHeight, TrackId, TrackKind, Ui, json, ops, pos2, regular, + rgb, vec2, +}; pub(super) fn in_edit_window(s: &Session, t: &Track) -> bool { ops::shown(s, t) @@ -90,7 +93,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, if !clip.range().overlaps(&view) { continue; } - draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some()); + draw_clip(&ClipLane { app, painter: &painter, s: &s, track, sorted, tl, lane }, clip, auto_view.is_some()); } draw_live_take(app, &painter, &s, track, tl, lane); crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); @@ -140,7 +143,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai let sorted = track.clips().is_sorted_by_key(|c| c.start); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) { - draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false); + draw_clip(&ClipLane { app, painter: &painter, s: &s, track, sorted, tl, lane }, c, false); } let resp = ui.interact(lane, ui.scope_id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() diff --git a/crates/ui-egui/src/edit_window/universe.rs b/crates/ui-egui/src/edit_window/universe.rs index 3175caaa..3a9331a8 100644 --- a/crates/ui-egui/src/edit_window/universe.rs +++ b/crates/ui-egui/src/edit_window/universe.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Rect, Samples, Sense, Session, SoundApp, Stroke, StrokeKind, Tokens, Track, Ui, json, pos2, rgb, vec2}; /// The Universe's clip blocks for one document revision and area: each track's clips merged into /// runs of one colour. A drum edit holds tens of thousands of clips a pixel or less wide here; diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 5bf55078..f089499b 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -151,6 +151,30 @@ fn section_label(ui: &Ui, r: Rect, text: &str, narrow: bool) { /// Draw one mixer channel strip: preamp, instrument, object, insert and send sections, EQ curve, routing, fader and /// meters, with its interactions. +/// The height of the strip's footer: volume readout and name plate. +const STRIP_FOOT_H: f32 = 64.0; + +/// One mixer strip's geometry and settings, shared by its sections. +struct StripCtx<'a> { + track: &'a Track, + id: TrackId, + r: Rect, + w: f32, + x0: f32, + inner_w: f32, + narrow: bool, + selected: bool, + views: Vec, + all: bool, +} + +impl StripCtx<'_> { + /// Whether the mixer shows the section named `view`. + fn shows(&self, view: &str) -> bool { + self.all || self.views.iter().any(|x| x == view) + } +} + fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>, scroll_here: bool) { let t = Tokens::current(); let session = app.engine.session_arc(); @@ -173,12 +197,30 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let inner_w = w - 8.0; let x0 = r.min.x + 4.0; let views = app.ui.mix_views.clone(); - let has = |v: &str| views.iter().any(|x| x == v); - let all = has("all"); + let all = views.iter().any(|x| x == "all"); + let cx = StripCtx { track, id, r, w, x0, inner_w, narrow, selected, views, all }; // Preamp, instrument and object rows keep their height on every strip so sections line up; // their controls only appear on tracks with an input. + y = preamp_section(app, ui, &cx, y); + y = instrument_section(app, ui, &cx, y); + y = object_section(app, ui, &cx, y); + y = slot_sections(app, ui, &cx, y); + y = eq_section(ui, &cx, y); + y = comments_section(app, ui, &cx, y); + y = io_section(app, ui, &cx, y); + y = group_selector(app, ui, &cx, y); + y = pan_section(app, ui, &cx, y); + y = toggle_rows(app, ui, &cx, y); + fader_and_meter(app, ui, &cx, y, snap); + name_and_readout(app, ui, &cx); +} + +/// Input gain stage ahead of the inserts (the track's trim). +fn preamp_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); let has_input = matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi); - if all || has("mic_preamps") { + if cx.shows("mic_preamps") { // Input gain stage ahead of the inserts (the track's trim). let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0)); inset(ui.painter(), sec, t.strip_section); @@ -199,7 +241,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("instruments") { + y +} + +/// The instrument slot. +fn instrument_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); + if cx.shows("instruments") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0)); inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "INSTRUMENT", narrow); @@ -215,7 +264,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("object") { + y +} + +/// The object/bed switch. +fn object_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, x0, inner_w, .. } = *cx; + let has_input = matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi); + if cx.shows("object") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 20.0)); if has_input { let on = app.engine.session().edit.flag(&format!("object.{}", id.0)); @@ -227,13 +283,20 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } + y +} + +/// The insert and send sections. +fn slot_sections(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); for (key, title, off, is_send) in [ ("inserts_ae", "INSERTS A-E", 0usize, false), ("inserts_fj", "INSERTS F-J", 5, false), ("sends_ae", "SENDS A-E", 0, true), ("sends_fj", "SENDS F-J", 5, true), ] { - if !(all || has(key)) { + if !cx.shows(key) { continue; } let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0)); @@ -249,7 +312,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("eq_curve") { + y +} + +/// The EQ curve. +fn eq_section(ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, x0, inner_w, .. } = *cx; + let t = Tokens::current(); + if cx.shows("eq_curve") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 40.0)); ui.painter().rect_filled(sec, 2.0, Color32::from_rgb(14, 18, 22)); let eq = track.mixer.inserts.iter().flatten().find(|i| i.plugin == "eq_7band" || i.plugin == "eq_1band"); @@ -278,7 +348,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("comments") { + y +} + +/// The comments field. +fn comments_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + if cx.shows("comments") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 44.0)); let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec)); @@ -292,7 +368,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y = sec.max.y + 4.0; } - if all || has("io") { + y +} + +/// Input, output and automation mode selectors. +fn io_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); + if cx.shows("io") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0)); inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "I / O", narrow); @@ -357,6 +440,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh }); y = sec.max.y + 4.0; } + y +} + +/// The group selector. +fn group_selector(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx; + let t = Tokens::current(); // Group selector. let no_group = if narrow { "nogrp" } else { "no group" }; let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or(no_group.to_string(), |g| g.name.clone()); @@ -371,6 +461,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh SelectorLabels { name: &format!("Group for {}", track.name), value: &gname, tooltip: "Group" }, ); y += 22.0; + y +} + +/// Pan: a surround panner when the output is multichannel, else the stereo knobs. +fn pan_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, x0, inner_w, .. } = *cx; + let t = Tokens::current(); // Pan: a surround panner when the output is multichannel, else the stereo knobs. let out_fmt = output_format(app, track); let surround_out = @@ -429,6 +526,13 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } y += 54.0; } + y +} + +/// Rec / Input / Solo / Mute. +fn toggle_rows(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 { + let StripCtx { track, id, x0, inner_w, .. } = *cx; + let t = Tokens::current(); // Rec / Input / Solo / Mute. let bw = (inner_w - 6.0) / 2.0; let mut row = ui.new_child( @@ -461,9 +565,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } } y += 26.0; + y +} + +/// The fader and its meter. +fn fader_and_meter(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, y: f32, snap: Option<&MeterSnapshot>) { + let StripCtx { track, id, narrow, x0, inner_w, r, .. } = *cx; + let t = Tokens::current(); // Fader + meter. - let bottom_h = 64.0; - let fh = (r.max.y - bottom_h - y).max(120.0); + let fh = (r.max.y - STRIP_FOOT_H - y).max(120.0); let wide_meter = (if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }).channels() > 2; let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * if wide_meter { 0.45 } else { 0.6 }, fh)); if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.scope_id().with(("fader", id.0)), &format!("{} level", track.name)) { @@ -495,8 +605,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh if md.gr > 0.1 { ui.painter().text(pos2(mr.center().x, mr.min.y - 3.0), Align2::CENTER_BOTTOM, format!("-{:.0}", md.gr), regular(8.0), t.meter_yellow); } +} + +/// Volume readout, kind label and name plate. +fn name_and_readout(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>) { + let StripCtx { track, id, x0, inner_w, r, w, selected, .. } = *cx; + let t = Tokens::current(); // Volume readout and name. - let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0)); + let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - STRIP_FOOT_H + 4.0), vec2(inner_w - 8.0, 15.0)); inset(ui.painter(), vr, t.counter_bg); let (_, entered) = editable_readout( ui, diff --git a/crates/ui-egui/src/mix_window/plugin_menu.rs b/crates/ui-egui/src/mix_window/plugin_menu.rs index c9923dc4..ea077887 100644 --- a/crates/ui-egui/src/mix_window/plugin_menu.rs +++ b/crates/ui-egui/src/mix_window/plugin_menu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Rect, SoundApp, TrackId, Ui, json, pos2, vec2}; /// An insert's menu: a search field that takes typing at once, then (until something is typed) /// Bypass and removal and the plugins by category and format. diff --git a/crates/ui-egui/src/mix_window/routing.rs b/crates/ui-egui/src/mix_window/routing.rs index 63e21f5b..176a8cba 100644 --- a/crates/ui-egui/src/mix_window/routing.rs +++ b/crates/ui-egui/src/mix_window/routing.rs @@ -1,5 +1,5 @@ use super::plugin_menu::{MenuSearch, menu_row}; -use super::*; +use super::{Route, SoundApp, Track, TrackId, Ui, json}; pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String { match r { diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index 1a9dabdc..e0c3da4a 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -1,7 +1,10 @@ use super::plugin_menu::{MenuSearch, menu_row}; use super::routing::route_name; use super::slots::{COMPACT_SLOT, Mark, empty_slot, level_icon, used_slot}; -use super::*; +use super::{ + Align2, Rect, Route, Sense, SoundApp, Stroke, StrokeKind, Tokens, Track, TrackId, TrackKind, Ui, db_text, fader, json, pan_knob, pan_text, pos2, + regular, text_toggle, vec2, +}; use craft_ui::combo::Menu; /// Draw one send slot: the route area picks a destination, while the level area opens or edits the send level. diff --git a/crates/ui-egui/src/mix_window/slots.rs b/crates/ui-egui/src/mix_window/slots.rs index 0c7e45cd..866b60eb 100644 --- a/crates/ui-egui/src/mix_window/slots.rs +++ b/crates/ui-egui/src/mix_window/slots.rs @@ -1,5 +1,5 @@ use super::plugin_menu::{instrument_menu, plugin_info, plugin_menu}; -use super::*; +use super::{Align2, Color32, Rect, Sense, SoundApp, Stroke, Tokens, Track, Ui, bold, groove, json, pos2, raised_key, shade, vec2}; /// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window, /// right-click (or click, when none is set) picks another instrument. A built-in's parameters diff --git a/crates/ui-egui/src/panels/about.rs b/crates/ui-egui/src/panels/about.rs index 36074c25..504bf9f7 100644 --- a/crates/ui-egui/src/panels/about.rs +++ b/crates/ui-egui/src/panels/about.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, bold, mono, vec2}; /// About window tabs for version information, contributors and models. pub(super) fn about(app: &mut SoundApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/panels/clips.rs b/crates/ui-egui/src/panels/clips.rs index 4c127b97..cbdd28d5 100644 --- a/crates/ui-egui/src/panels/clips.rs +++ b/crates/ui-egui/src/panels/clips.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, ClipId, Rect, Sense, SoundApp, SourceId, Tokens, Ui, bold, json, panel_header, pos2, regular, rgb, vec2, with_pending_offset}; /// One row of the clip list: a whole file, or a clip on a track (positions in the session). #[derive(Clone, Copy)] diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index da145ca9..3d284f75 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Tokens, Ui, json}; pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) { let hist = app.engine.undo_history(); diff --git a/crates/ui-egui/src/panels/memory.rs b/crates/ui-egui/src/panels/memory.rs index 313713aa..d849e382 100644 --- a/crates/ui-egui/src/panels/memory.rs +++ b/crates/ui-egui/src/panels/memory.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SoundApp, Ui, json, mono}; /// One recallable memory-location row: number button, editable name, time, length, kind and delete. fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: &str, len: &str, kind: &str) { diff --git a/crates/ui-egui/src/panels/midi_editor.rs b/crates/ui-egui/src/panels/midi_editor.rs index 2515dbbb..2e48be01 100644 --- a/crates/ui-egui/src/panels/midi_editor.rs +++ b/crates/ui-egui/src/panels/midi_editor.rs @@ -69,6 +69,50 @@ fn is_black(p: i32) -> bool { } /// Draw the MIDI editor for the target clip, with an empty-state prompt that adds a clip on the selected MIDI track. +/// The piano roll's geometry: where it is drawn and how ticks and pitches map onto it. +#[derive(Clone, Copy)] +struct Roll<'a> { + sequence: &'a soundcraft_midi::Sequence, + roll: Rect, + keys: Rect, + vel: Rect, + px_per_tick: f32, + len_ticks: i64, + scroll: f32, + grid_ticks: i64, + rows: i32, +} + +impl Roll<'_> { + fn x_of(&self, tick: i64) -> f32 { + self.roll.min.x + tick as f32 * self.px_per_tick + } + + fn y_of(&self, pitch: i32) -> f32 { + self.roll.min.y + (127 - pitch) as f32 * ROW_H - self.scroll + } + + fn pitch_at(&self, y: f32) -> i32 { + 127 - ((y - self.roll.min.y + self.scroll) / ROW_H).floor() as i32 + } + + /// The note under `pos` and whether `pos` is on its resize edge. + fn hit(&self, pos: egui::Pos2) -> Option<(usize, bool)> { + self.sequence.notes.iter().enumerate().rev().find_map(|(i, n)| { + let r = Rect::from_min_max( + pos2(self.x_of(n.start), self.y_of(i32::from(n.pitch))), + pos2(self.x_of(n.start + n.length).max(self.x_of(n.start) + 3.0), self.y_of(i32::from(n.pitch)) + ROW_H), + ); + r.contains(pos).then_some((i, pos.x > r.max.x - 5.0)) + }) + } + + /// `tk` ticks, rounded to the grid. + fn snap(&self, tk: f32) -> i64 { + ((tk / self.grid_ticks as f32).round() as i64) * self.grid_ticks + } +} + pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Painter-only contents do not advance the layout. Claim the resized // space, including the empty state, so the dock keeps its chosen height. @@ -80,24 +124,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(header, 0.0, t.panel_bg2); ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, "MIDI EDITOR", bold(11.5), t.header_text); let Some(cid) = target_clip(app) else { - let track = { - let s = app.engine.session(); - s.tracks.iter().find(|t| t.kind.is_midi() && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.name.clone())) - }; - let msg = match &track { - Some((_, name)) => format!("Click to add a MIDI clip to {name} at the insertion point."), - None => "Select a MIDI clip (or a MIDI track) to edit its notes.".into(), - }; - ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); - let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); - if let Some((id, _)) = track - && ui.interact(body, ui.scope_id().with("new_clip"), Sense::click()).clicked() - { - let sel = app.engine.session().edit.selection; - if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { - let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); - } - } + empty_state(app, ui, full, header); return; }; let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return }; @@ -115,40 +142,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { regular(11.0), t.text_dim, ); - let grid_ticks = match app.engine.session().edit.grid { - soundcraft_time::GridValue::Note { value, dotted, triplet } => { - let mut g = value.ticks(); - if dotted { - g = g * 3 / 2; - } - if triplet { - g = g * 2 / 3; - } - g.max(1) - } - _ => TICKS_PER_QUARTER / 4, - }; - // Header toolbar: whole-clip MIDI operations. - let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0)); - let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); - tb.spacing_mut().item_spacing.x = 4.0; - let mut op: Option<(&str, serde_json::Value)> = None; - if tb.small_button("Legato").clicked() { - op = Some(("event.change_duration", json!({"legato": 0}))); - } - if tb.small_button("Vel −10").clicked() { - op = Some(("event.change_velocity", json!({"add": -10}))); - } - if tb.small_button("Vel +10").clicked() { - op = Some(("event.change_velocity", json!({"add": 10}))); - } - if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() { - op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100}))); - } - if let Some((id, mut p)) = op { - p["clips"] = json!([cid.0]); - let _ = app.run(id, p); - } + let grid_ticks = grid_ticks_of(app); + midi_toolbar(app, ui, header, cid, grid_ticks); let s = app.engine.session_arc(); let sr = s.sample_rate; let base_tick = s.tempo.samples_to_ticks(clip.start, sr); @@ -170,15 +165,90 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0)); let scroll = app.midi.scroll; let px_per_tick = roll.width() / len_ticks as f32; - let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick; - let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll; - let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32; + let g = Roll { sequence, roll, keys, vel, px_per_tick, len_ticks, scroll, grid_ticks, rows }; let painter = ui.painter().with_clip_rect(roll.union(keys)); + draw_roll_background(&painter, &g); + // Notes. + let base = egui::Color32::from_rgb(track_color[0], track_color[1], track_color[2]); + draw_notes(app, &painter, &g, base); + note_pointer(app, ui, &g, cid, s.edit.tool == Tool::Pencil); + note_keys(app, ui, &g, cid); + velocity_lane(app, ui, &g, cid, base, full.min.x + 6.0); +} + +/// The empty state: a prompt that adds a clip on the selected MIDI track when clicked. +fn empty_state(app: &mut SoundApp, ui: &mut Ui, full: Rect, header: Rect) { + let t = Tokens::current(); + let track = { + let s = app.engine.session(); + s.tracks.iter().find(|t| t.kind.is_midi() && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.name.clone())) + }; + let msg = match &track { + Some((_, name)) => format!("Click to add a MIDI clip to {name} at the insertion point."), + None => "Select a MIDI clip (or a MIDI track) to edit its notes.".into(), + }; + ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); + let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); + if let Some((id, _)) = track + && ui.interact(body, ui.scope_id().with("new_clip"), Sense::click()).clicked() + { + let sel = app.engine.session().edit.selection; + if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); + } + } +} + +/// The grid step in ticks, from the Edit window's grid value. +fn grid_ticks_of(app: &SoundApp) -> i64 { + match app.engine.session().edit.grid { + soundcraft_time::GridValue::Note { value, dotted, triplet } => { + let mut g = value.ticks(); + if dotted { + g = g * 3 / 2; + } + if triplet { + g = g * 2 / 3; + } + g.max(1) + } + _ => TICKS_PER_QUARTER / 4, + } +} + +/// The header toolbar: whole-clip MIDI operations. +fn midi_toolbar(app: &mut SoundApp, ui: &mut Ui, header: Rect, cid: ClipId, grid_ticks: i64) { + // Header toolbar: whole-clip MIDI operations. + let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0)); + let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + tb.spacing_mut().item_spacing.x = 4.0; + let mut op: Option<(&str, serde_json::Value)> = None; + if tb.small_button("Legato").clicked() { + op = Some(("event.change_duration", json!({"legato": 0}))); + } + if tb.small_button("Vel −10").clicked() { + op = Some(("event.change_velocity", json!({"add": -10}))); + } + if tb.small_button("Vel +10").clicked() { + op = Some(("event.change_velocity", json!({"add": 10}))); + } + if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() { + op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100}))); + } + if let Some((id, mut p)) = op { + p["clips"] = json!([cid.0]); + let _ = app.run(id, p); + } +} + +/// Background rows, piano keys and the grid. +fn draw_roll_background(painter: &egui::Painter, g: &Roll<'_>) { + let Roll { roll, keys, px_per_tick, len_ticks, grid_ticks, rows, .. } = *g; // Background rows and keys. - let top = pitch_at(roll.min.y); + let top = g.pitch_at(roll.min.y); for r in 0..=rows + 1 { let pitch = top - r; - let y = y_of(pitch); + let y = g.y_of(pitch); let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H)); painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) }); let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0)); @@ -198,7 +268,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let mut tick = 0; while tick <= len_ticks { let bar = tick % (TICKS_PER_QUARTER * 4) == 0; - let x = x_of(tick); + let x = g.x_of(tick); if grid_ticks as f32 * px_per_tick > 4.0 || bar { painter.line_segment( [pos2(x, roll.min.y), pos2(x, roll.max.y)], @@ -207,8 +277,12 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } tick += grid_ticks; } +} + +/// The notes, with a drag in progress applied. +fn draw_notes(app: &SoundApp, painter: &egui::Painter, g: &Roll<'_>, base: Color32) { + let Roll { sequence, grid_ticks, .. } = *g; // Notes. - let base = egui::Color32::from_rgb(track_color[0], track_color[1], track_color[2]); let drag = app.midi.drag.clone(); for (i, n) in sequence.notes.iter().enumerate() { let (mut st, mut ln, mut p) = (n.start, n.length, i32::from(n.pitch)); @@ -224,7 +298,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { p = (p + d.dy_semi as i32).clamp(0, 127); } } - let r = Rect::from_min_max(pos2(x_of(st), y_of(p) + 1.0), pos2(x_of(st + ln).max(x_of(st) + 3.0), y_of(p) + ROW_H - 1.0)); + let r = Rect::from_min_max(pos2(g.x_of(st), g.y_of(p) + 1.0), pos2(g.x_of(st + ln).max(g.x_of(st) + 3.0), g.y_of(p) + ROW_H - 1.0)); let sel = app.midi.selected.contains(&i); let k = 0.45 + 0.55 * f32::from(n.velocity) / 127.0; let fill = if sel { @@ -234,22 +308,17 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { }; painter.rect(r, 2.0, fill, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); } +} + +/// Pointer interaction on the roll: note and band selection, drags and clicks. +fn note_pointer(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId, pencil: bool) { + let Roll { sequence, roll, px_per_tick, grid_ticks, .. } = *g; // Interaction. let resp = ui.interact(roll, ui.scope_id().with(("roll", cid.0)), Sense::click_and_drag()); - let hit = |pos: egui::Pos2| -> Option<(usize, bool)> { - sequence.notes.iter().enumerate().rev().find_map(|(i, n)| { - let r = Rect::from_min_max( - pos2(x_of(n.start), y_of(i32::from(n.pitch))), - pos2(x_of(n.start + n.length).max(x_of(n.start) + 3.0), y_of(i32::from(n.pitch)) + ROW_H), - ); - r.contains(pos).then_some((i, pos.x > r.max.x - 5.0)) - }) - }; - let snap = |tk: f32| -> i64 { ((tk / grid_ticks as f32).round() as i64) * grid_ticks }; if resp.drag_started() && let Some(p) = resp.interact_pointer_pos() { - if let Some((i, edge)) = hit(p) { + if let Some((i, edge)) = g.hit(p) { if !app.midi.selected.contains(&i) { app.midi.selected = vec![i]; } @@ -275,8 +344,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { .enumerate() .filter(|(_, n)| { let r = Rect::from_min_max( - pos2(x_of(n.start), y_of(i32::from(n.pitch))), - pos2(x_of(n.start + n.length), y_of(i32::from(n.pitch)) + ROW_H), + pos2(g.x_of(n.start), g.y_of(i32::from(n.pitch))), + pos2(g.x_of(n.start + n.length), g.y_of(i32::from(n.pitch)) + ROW_H), ); r.intersects(band) }) @@ -288,7 +357,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { if resp.dragged() && let (Some(d), Some(p)) = (&mut app.midi.drag, resp.interact_pointer_pos()) { - d.dx_ticks = snap((p.x - d.start.x) / px_per_tick); + d.dx_ticks = g.snap((p.x - d.start.x) / px_per_tick); d.dy_semi = -((p.y - d.start.y) / ROW_H).round() as i64; } if resp.drag_stopped() @@ -306,7 +375,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { if resp.clicked() && let Some(p) = resp.interact_pointer_pos() { - match hit(p) { + match g.hit(p) { Some((i, _)) => { if ui.input(|x| x.modifiers.shift) { if !app.midi.selected.contains(&i) { @@ -317,8 +386,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } // Audition on the instrument would go here (needs a live MIDI input path). } - None if s.edit.tool == Tool::Pencil => { - let pitch = pitch_at(p.y).clamp(0, 127); + None if pencil => { + let pitch = g.pitch_at(p.y).clamp(0, 127); let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks; let _ = app.run( "midi.note_add", @@ -329,6 +398,11 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { None => app.midi.selected.clear(), } } +} + +/// Keyboard: select all, move and delete notes. +fn note_keys(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId) { + let Roll { sequence, roll, grid_ticks, .. } = *g; // Keyboard: Cmd+A select all; arrows transpose (Shift = octave) and move by the grid. if ui.rect_contains_pointer(roll) && !ui.ctx().egui_wants_keyboard_input() { let (all, up, down, left, right, shift) = ui.input(|i| { @@ -373,12 +447,18 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); app.midi.selected.clear(); } +} + +/// The velocity lane under the roll. +fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId, base: Color32, label_x: f32) { + let Roll { sequence, vel, .. } = *g; + let t = Tokens::current(); // Velocity lane. ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30)); - ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); + ui.painter().text(pos2(label_x, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); let vresp = ui.interact(vel, ui.scope_id().with(("vel", cid.0)), Sense::click_and_drag()); for (i, n) in sequence.notes.iter().enumerate() { - let x = x_of(n.start); + let x = g.x_of(n.start); let h = (vel.height() - 4.0) * f32::from(n.velocity) / 127.0; let col = if app.midi.selected.contains(&i) { Color32::WHITE } else { base }; ui.painter().line_segment([pos2(x + 1.0, vel.max.y - 2.0), pos2(x + 1.0, vel.max.y - 2.0 - h)], Stroke::new(3.0, col)); @@ -386,8 +466,8 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } if (vresp.clicked() || vresp.dragged()) && let Some(p) = vresp.interact_pointer_pos() - && let Some((i, n)) = sequence.notes.iter().enumerate().min_by_key(|(_, n)| ((x_of(n.start) - p.x).abs() * 10.0) as i64) - && (x_of(n.start) - p.x).abs() < 8.0 + && let Some((i, n)) = sequence.notes.iter().enumerate().min_by_key(|(_, n)| ((g.x_of(n.start) - p.x).abs() * 10.0) as i64) + && (g.x_of(n.start) - p.x).abs() < 8.0 { let v = (((vel.max.y - 2.0 - p.y) / (vel.height() - 4.0)) * 127.0).round().clamp(1.0, 127.0) as i64; let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0)); diff --git a/crates/ui-egui/src/panels/plugins.rs b/crates/ui-egui/src/panels/plugins.rs index 7a7e575c..29125432 100644 --- a/crates/ui-egui/src/panels/plugins.rs +++ b/crates/ui-egui/src/panels/plugins.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Sense, SoundApp, Stroke, Tokens, Ui, json, mono, pos2, vec2}; /// Draw plugin windows for instrument and insert slots that still exist, removing closed ones from the list. pub(super) fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/panels/tracks.rs b/crates/ui-egui/src/panels/tracks.rs index 41b82794..b4fd9bb8 100644 --- a/crates/ui-egui/src/panels/tracks.rs +++ b/crates/ui-egui/src/panels/tracks.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, Color32, Rect, Sense, SoundApp, Stroke, Tokens, Ui, bold, gutter_scroll, json, panel_header, pos2, regular, rgb, vec2, + with_pending_offset, +}; /// Drag a Tracks list row to reorder the tracks: a line marks the gap it will land in. fn drag_track(app: &mut SoundApp, ui: &Ui, row: Rect, id: u64, dropped: bool) { diff --git a/crates/ui-egui/src/panels/transport.rs b/crates/ui-egui/src/panels/transport.rs index 511ccb51..9451c1d6 100644 --- a/crates/ui-egui/src/panels/transport.rs +++ b/crates/ui-egui/src/panels/transport.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Rect, Sense, SoundApp, Tokens, json, mono, pos2, vec2}; pub(super) fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_transport; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 35319f1c..e2c9a979 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -177,6 +177,14 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("edit.tool", json!({"tool": tool})); true } + _ => fixed_more(app, key, m, mac), + } +} + +/// The rest of [`fixed`]'s keys. +fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { + let plain = !m.command && !m.alt && !m.ctrl && !m.shift; + match key { // `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it // can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a // numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut. diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index bbbba444..892b5393 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -1387,6 +1387,56 @@ fn transport_simulation_advances_by_monotonic_elapsed_time_only() { app.engine.transport.playing = false; app.sim = None; } +/// One host iteration: the raw-input hook, then logic and UI in a single pass. +fn run_host_frame(app: &mut SoundApp, ctx: &egui::Context, mut input: egui::RawInput) { + app.raw_input_hook(ctx, &mut input); + let mut output = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + output.textures_delta.clear(); +} + +/// Run one frame that discards its first layout pass; returns how many passes ran. +fn run_host_frame_discarding_first_pass(app: &mut SoundApp, ctx: &egui::Context, input: egui::RawInput) -> usize { + let mut passes = 0; + let mut output = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + passes += 1; + if passes == 1 { + ui.ctx().request_discard("plugin gesture must survive a second layout pass"); + } + app.ui(ui); + }); + output.textures_delta.clear(); + passes +} + +/// The editor-poll fixture: each poll counts itself, takes the queued edits and, for the gestures that +/// ended, captures the instrument state `latest` holds. +fn poll_fixture( + reports: &std::rc::Rc>>, + latest: &std::rc::Rc>, + polls: &std::rc::Rc>, + captures: &std::rc::Rc>>, +) -> super::EditorPollFixture { + let (poll_reports, poll_latest, poll_count, poll_captures) = (reports.clone(), latest.clone(), polls.clone(), captures.clone()); + Box::new(move |pending| { + poll_count.set(poll_count.get() + 1); + let edits = std::mem::take(&mut *poll_reports.borrow_mut()); + super::editor_changes( + pending, + edits, + Vec::new(), + |_| false, + |due| { + poll_captures.borrow_mut().extend_from_slice(due); + due.iter().map(|&(track, slot)| (track, slot, vec![poll_latest.get(), 1])).collect() + }, + ) + }) +} + /// Plugin queues are drained once per host iteration, rather than once per discarded pass. /// The fixture replaces only native polling; the real raw hook, logic/UI, scheduler and /// engine state/undo commands run normally. @@ -1400,13 +1450,7 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); let raw = || egui::RawInput { screen_rect: Some(rect), ..Default::default() }; for _ in 0..4 { - let mut input = raw(); - app.raw_input_hook(&ctx, &mut input); - let mut output = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - output.textures_delta.clear(); + run_host_frame(&mut app, &ctx, raw()); } let keys = app.engine.session().track_by_name("Keys").unwrap().id; let original = instrument_state(&app, keys); @@ -1421,34 +1465,11 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { let latest = Rc::new(Cell::new(1u8)); let polls = Rc::new(Cell::new(0)); let captures = Rc::new(RefCell::new(Vec::new())); - let (poll_reports, poll_latest, poll_count, poll_captures) = (reports.clone(), latest.clone(), polls.clone(), captures.clone()); - app.editor_poll_fixture = Some(Box::new(move |pending| { - poll_count.set(poll_count.get() + 1); - let edits = std::mem::take(&mut *poll_reports.borrow_mut()); - super::editor_changes( - pending, - edits, - Vec::new(), - |_| false, - |due| { - poll_captures.borrow_mut().extend_from_slice(due); - due.iter().map(|&(track, slot)| (track, slot, vec![poll_latest.get(), 1])).collect() - }, - ) - })); + app.editor_poll_fixture = Some(poll_fixture(&reports, &latest, &polls, &captures)); reports.borrow_mut().push((keys, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".into(), 1.0)); let mut input = raw(); app.raw_input_hook(&ctx, &mut input); - let mut passes = 0; - let mut output = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - passes += 1; - if passes == 1 { - ui.ctx().request_discard("plugin gesture must survive a second layout pass"); - } - app.ui(ui); - }); - output.textures_delta.clear(); + let passes = run_host_frame_discarding_first_pass(&mut app, &ctx, input); assert!(passes >= 2, "the regression must actually force a discarded pass"); assert_eq!(polls.get(), 1, "the rerun cannot drain an empty editor queue"); assert!(captures.borrow().is_empty(), "the ongoing gesture has not ended"); @@ -1490,7 +1511,12 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); assert_eq!(polls.get(), 4, "a fresh empty host iteration is still live"); assert_eq!(captures.borrow().len(), 1, "the completed gesture is captured once"); - assert_eq!(app.engine.revision, settled_revision); + assert_history_unchanged(&app, settled_revision, undo, &history, &journal); +} + +/// The engine's revision, undo history and journal are as they were. +fn assert_history_unchanged(app: &SoundApp, revision: u64, undo: usize, history: &[String], journal: &[(String, serde_json::Value)]) { + assert_eq!(app.engine.revision, revision); assert_eq!(app.engine.undo_history().len(), undo); assert_eq!(app.engine.undo_history(), history); assert_eq!(app.engine.journal, journal); diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs index bcdc5c28..2bb6cd92 100644 --- a/crates/ui-egui/tests/mix_instrument_contrast.rs +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -72,6 +72,19 @@ fn contrast(a: Color32, b: Color32) -> f64 { (a.max(b) + 0.05) / (a.min(b) + 0.05) } +/// A primary click at `at`, one frame for the press and one for the release. +fn press_and_release(harness: &mut egui_kittest::Harness<'_, SoundApp>, at: egui::Pos2) { + for pressed in [true, false] { + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }); + harness.step(); + } +} + /// In real frames, populated and empty instrument and insert slots use the active theme's readable palette across /// light, dark and system modes, and a plugin change leaves the rest of the session and audio pool intact. #[test] @@ -169,15 +182,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { // Exercise the same painted control's ordinary click/picker path after palette changes. let at = slot(harness.output(), "SubSynth").rect.center(); - for pressed in [true, false] { - harness.input_mut().events.push(Event::PointerButton { - pos: at, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }); - harness.step(); - } + press_and_release(&mut harness, at); harness.step(); // Let the popup finish its initial sizing pass. harness.get_by_label("Built-in").hover(); harness.step(); diff --git a/crates/video/src/h264/deblock.rs b/crates/video/src/h264/deblock.rs index 12ba8da5..e918476c 100644 --- a/crates/video/src/h264/deblock.rs +++ b/crates/video/src/h264/deblock.rs @@ -279,7 +279,6 @@ fn filter_horizontal(pix: &mut [u8], stride: usize, x: usize, y: } /// Deblock one macroblock (macroblocks must be processed in raster order). -#[allow(clippy::needless_range_loop)] pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { let q = &pic.mbs[addr]; if q.slice_num == u32::MAX { @@ -303,6 +302,23 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { let alpha_off = sq.alpha_offset; let beta_off = sq.beta_offset; let t8 = q.transform_8x8; + let bs = boundary_strengths(pic, q, sq, left, top, t8); + let qp_of = |st: &MbState| if st.kind == MbKind::IPcm { 0 } else { st.qp as i32 }; + let q_qp = qp_of(q); + let q_qpc = q.qpc; + let p_qp = [left.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc)), top.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc))]; + let params = |qpp: i32, qpq: i32, b: [u8; 4]| { + let qpav = (qpp + qpq + 1) >> 1; + let index_a = (qpav + alpha_off).clamp(0, 51) as usize; + let index_b = (qpav + beta_off).clamp(0, 51) as usize; + let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; + EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } + }; + filter_luma(pic, (mx, my), &bs, q_qp, p_qp, ¶ms); + filter_chroma(pic, (mx, my), &bs, q_qpc, p_qp, ¶ms); +} + +fn boundary_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, left: Option, top: Option, t8: bool) -> [[[u8; 4]; 4]; 2] { // bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y) let mut bs = [[[0u8; 4]; 4]; 2]; if q.kind.is_intra() { @@ -356,20 +372,18 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { } } } - let qp_of = |st: &MbState| if st.kind == MbKind::IPcm { 0 } else { st.qp as i32 }; - let q_qp = qp_of(q); - let q_qpc = q.qpc; - let p_qp = [left.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc)), top.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc))]; - let params = |qpp: i32, qpq: i32, b: [u8; 4]| { - let qpav = (qpp + qpq + 1) >> 1; - let index_a = (qpav + alpha_off).clamp(0, 51) as usize; - let index_b = (qpav + beta_off).clamp(0, 51) as usize; - let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; - EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } - }; + bs +} + +fn filter_luma( + pic: &mut PicState, + (mx, my): (usize, usize), + bs: &[[[u8; 4]; 4]; 2], + q_qp: i32, + p_qp: [Option<(i32, [u8; 2])>; 2], + params: &impl Fn(i32, i32, [u8; 4]) -> EdgeParams, +) { let width = pic.planes.width; - let cwidth = pic.planes.cwidth; - // luma for dir in 0..2 { for e in 0..4 { if bs[dir][e] == [0; 4] { @@ -389,6 +403,17 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { } } } +} + +fn filter_chroma( + pic: &mut PicState, + (mx, my): (usize, usize), + bs: &[[[u8; 4]; 4]; 2], + q_qpc: [u8; 2], + p_qp: [Option<(i32, [u8; 2])>; 2], + params: &impl Fn(i32, i32, [u8; 4]) -> EdgeParams, +) { + let cwidth = pic.planes.cwidth; // chroma (4:2:0): edges at chroma samples 0 and 4 use the bS of luma edges 0 and 2 for c in 0..2 { for dir in 0..2 { diff --git a/crates/video/src/h264/intra.rs b/crates/video/src/h264/intra.rs index decf2965..176d9a11 100644 --- a/crates/video/src/h264/intra.rs +++ b/crates/video/src/h264/intra.rs @@ -61,125 +61,146 @@ fn load_edge(plane: &[u8], stride: usize, x: usize, y: usize, n: usize, a: Avail /// Directional / DC prediction shared by Intra_4x4 and Intra_8x8 (8.3.1.2.x, 8.3.2.2.x). fn pred_nxn(mode: u8, e: &Edge, n: i32, a: Avail, out: &mut [u8], stride: usize) { - let mut put = |x: i32, y: i32, v: i32| out[y as usize * stride + x as usize] = v as u8; match mode { - 0 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.t(x)); - } - } + 0 => pred_vertical(e, n, out, stride), + 1 => pred_horizontal(e, n, out, stride), + 2 => pred_dc(e, n, a, out, stride), + 3 => pred_down_left(e, n, out, stride), + 4 => pred_down_right(e, n, out, stride), + 5 => pred_vertical_right(e, n, out, stride), + 6 => pred_horizontal_down(e, n, out, stride), + 7 => pred_vertical_left(e, n, out, stride), + _ => pred_horizontal_up(e, n, out, stride), + } +} + +fn put(out: &mut [u8], stride: usize, x: i32, y: i32, value: i32) { + out[y as usize * stride + x as usize] = value as u8; +} + +fn pred_vertical(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + put(out, stride, x, y, e.t(x)); } - 1 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.l(y)); - } - } + } +} + +fn pred_horizontal(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + put(out, stride, x, y, e.l(y)); } - 2 => { - let shift = if n == 4 { 2 } else { 3 }; - let st: i32 = (0..n).map(|x| e.t(x)).sum(); - let sl: i32 = (0..n).map(|y| e.l(y)).sum(); - let dc = match (a.top, a.left) { - (true, true) => (st + sl + n) >> (shift + 1), - (false, true) => (sl + (n >> 1)) >> shift, - (true, false) => (st + (n >> 1)) >> shift, - _ => 128, + } +} + +fn pred_dc(e: &Edge, n: i32, a: Avail, out: &mut [u8], stride: usize) { + let shift = if n == 4 { 2 } else { 3 }; + let st: i32 = (0..n).map(|x| e.t(x)).sum(); + let sl: i32 = (0..n).map(|y| e.l(y)).sum(); + let dc = match (a.top, a.left) { + (true, true) => (st + sl + n) >> (shift + 1), + (false, true) => (sl + (n >> 1)) >> shift, + (true, false) => (st + (n >> 1)) >> shift, + _ => 128, + }; + for y in 0..n { + for x in 0..n { + put(out, stride, x, y, dc); + } + } +} + +fn pred_down_left(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let value = if x == n - 1 && y == n - 1 { + (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 + } else { + (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 }; - for y in 0..n { - for x in 0..n { - put(x, y, dc); - } - } + put(out, stride, x, y, value); } - 3 => { - for y in 0..n { - for x in 0..n { - let v = if x == n - 1 && y == n - 1 { - (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 - } else { - (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_down_right(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let value = if x > y { + (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 + } else if x < y { + (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 + } else { + (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 + }; + put(out, stride, x, y, value); } - 4 => { - for y in 0..n { - for x in 0..n { - let v = if x > y { - (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 - } else if x < y { - (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 - } else { - (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_vertical_right(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let z = 2 * x - y; + let value = if z >= 0 && z % 2 == 0 { + (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 + } else if z > 0 { + (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 + }; + put(out, stride, x, y, value); } - 5 => { - for y in 0..n { - for x in 0..n { - let z = 2 * x - y; - let v = if z >= 0 && z % 2 == 0 { - (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 - } else if z > 0 { - (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_horizontal_down(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let z = 2 * y - x; + let value = if z >= 0 && z % 2 == 0 { + (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 + } else if z > 0 { + (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 + }; + put(out, stride, x, y, value); } - 6 => { - for y in 0..n { - for x in 0..n { - let z = 2 * y - x; - let v = if z >= 0 && z % 2 == 0 { - (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 - } else if z > 0 { - (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 - }; - put(x, y, v); - } - } + } +} + +fn pred_vertical_left(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + for y in 0..n { + for x in 0..n { + let i = x + (y >> 1); + let value = if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 }; + put(out, stride, x, y, value); } - 7 => { - for y in 0..n { - for x in 0..n { - let i = x + (y >> 1); - let v = if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 }; - put(x, y, v); - } - } - } - _ => { - let zmax = 2 * n - 3; - for y in 0..n { - for x in 0..n { - let z = x + 2 * y; - let i = y + (x >> 1); - let v = if z < zmax && z % 2 == 0 { - (e.l(i) + e.l(i + 1) + 1) >> 1 - } else if z < zmax { - (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 - } else if z == zmax { - (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 - } else { - e.l(n - 1) - }; - put(x, y, v); - } - } + } +} + +fn pred_horizontal_up(e: &Edge, n: i32, out: &mut [u8], stride: usize) { + let zmax = 2 * n - 3; + for y in 0..n { + for x in 0..n { + let z = x + 2 * y; + let i = y + (x >> 1); + let value = if z < zmax && z % 2 == 0 { + (e.l(i) + e.l(i + 1) + 1) >> 1 + } else if z < zmax { + (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 + } else if z == zmax { + (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 + } else { + e.l(n - 1) + }; + put(out, stride, x, y, value); } } } diff --git a/crates/video/src/h264/slice.rs b/crates/video/src/h264/slice.rs index bd641d5a..06a2bc67 100644 --- a/crates/video/src/h264/slice.rs +++ b/crates/video/src/h264/slice.rs @@ -214,101 +214,9 @@ impl SliceHeader { num_ref_idx_active[0] = 0; } ensure!(nal.nal_unit_type != 20 && nal.nal_unit_type != 21, "MVC slices are not supported"); - let mut ref_pic_list_mod = [Vec::new(), Vec::new()]; - for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() { - let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; - if present && r.read_flag()? { - loop { - let idc = r.read_ue()?; - ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); - if idc == 3 { - break; - } - let value = r.read_ue()?; - mods.push(RefPicListMod { idc, value }); - ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); - } - } - } - let mut pred_weight_table = None; - if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) { - let luma_log2_denom = r.read_ue()?; - ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); - let mut chroma_log2_denom = 0; - if sps.chroma_array_type() != 0 { - chroma_log2_denom = r.read_ue()?; - ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); - } - let mut lists = [Vec::new(), Vec::new()]; - let nlists = if slice_type.is_b() { 2 } else { 1 }; - for (l, entries) in lists.iter_mut().enumerate().take(nlists) { - for _ in 0..num_ref_idx_active[l] { - let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() }; - if r.read_flag()? { - e.luma_flag = true; - e.luma_weight = r.read_se()?; - e.luma_offset = r.read_se()?; - ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range"); - } - if sps.chroma_array_type() != 0 && r.read_flag()? { - e.chroma_flag = true; - for j in 0..2 { - e.chroma_weight[j] = r.read_se()?; - e.chroma_offset[j] = r.read_se()?; - ensure!( - (-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]), - "chroma weight out of range" - ); - } - } - entries.push(e); - } - } - let [l0, l1] = lists; - pred_weight_table = Some(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 }); - } - let mut no_output_of_prior_pics = false; - let mut long_term_reference = false; - let mut adaptive_ref_pic_marking = false; - let mut mmcos = Vec::new(); - if nal.nal_ref_idc != 0 { - if idr { - no_output_of_prior_pics = r.read_flag()?; - long_term_reference = r.read_flag()?; - } else { - adaptive_ref_pic_marking = r.read_flag()?; - if adaptive_ref_pic_marking { - loop { - let op = r.read_ue()?; - ensure!(op <= 6, "memory_management_control_operation out of range"); - if op == 0 { - break; - } - let mut m = Mmco { - op, - difference_of_pic_nums_minus1: 0, - long_term_pic_num: 0, - long_term_frame_idx: 0, - max_long_term_frame_idx_plus1: 0, - }; - if op == 1 || op == 3 { - m.difference_of_pic_nums_minus1 = r.read_ue()?; - } - if op == 2 { - m.long_term_pic_num = r.read_ue()?; - } - if op == 3 || op == 6 { - m.long_term_frame_idx = r.read_ue()?; - } - if op == 4 { - m.max_long_term_frame_idx_plus1 = r.read_ue()?; - } - mmcos.push(m); - ensure!(mmcos.len() <= 66, "too many MMCOs"); - } - } - } - } + let ref_fields = parse_reference_fields(&mut r, nal, idr, slice_type, pps, sps, num_ref_idx_active)?; + let RefFields { ref_pic_list_mod, pred_weight_table, no_output_of_prior_pics, long_term_reference, adaptive_ref_pic_marking, mmcos } = + ref_fields; let mut cabac_init_idc = 0; if pps.entropy_coding_mode && !slice_type.is_intra() { cabac_init_idc = r.read_ue()?; @@ -391,6 +299,134 @@ impl SliceHeader { } } +struct RefFields { + ref_pic_list_mod: [Vec; 2], + pred_weight_table: Option, + no_output_of_prior_pics: bool, + long_term_reference: bool, + adaptive_ref_pic_marking: bool, + mmcos: Vec, +} + +fn parse_reference_fields( + r: &mut BitReader<'_>, + nal: NalHeader, + idr: bool, + slice_type: SliceType, + pps: &Pps, + sps: &Sps, + num_ref_idx_active: [u32; 2], +) -> Result { + let ref_pic_list_mod = parse_ref_pic_list_mod(r, slice_type)?; + let pred_weight_table = parse_pred_weight_table(r, slice_type, pps, sps, num_ref_idx_active)?; + let (no_output_of_prior_pics, long_term_reference, adaptive_ref_pic_marking, mmcos) = parse_ref_pic_marking(r, nal, idr)?; + Ok(RefFields { ref_pic_list_mod, pred_weight_table, no_output_of_prior_pics, long_term_reference, adaptive_ref_pic_marking, mmcos }) +} + +fn parse_ref_pic_list_mod(r: &mut BitReader<'_>, slice_type: SliceType) -> Result<[Vec; 2]> { + let mut lists = [Vec::new(), Vec::new()]; + for (list, mods) in lists.iter_mut().enumerate() { + let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; + if present && r.read_flag()? { + loop { + let idc = r.read_ue()?; + ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); + if idc == 3 { + break; + } + mods.push(RefPicListMod { idc, value: r.read_ue()? }); + ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); + } + } + } + Ok(lists) +} + +fn parse_pred_weight_table(r: &mut BitReader<'_>, slice_type: SliceType, pps: &Pps, sps: &Sps, active: [u32; 2]) -> Result> { + let weighted = (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()); + if !weighted { + return Ok(None); + } + let luma_log2_denom = r.read_ue()?; + ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); + let chroma_log2_denom = if sps.chroma_array_type() == 0 { 0 } else { r.read_ue()? }; + ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); + let mut lists = [Vec::new(), Vec::new()]; + let list_count = if slice_type.is_b() { 2 } else { 1 }; + for (list, entries) in lists.iter_mut().enumerate().take(list_count) { + for _ in 0..active[list] { + entries.push(parse_weight_entry(r, luma_log2_denom, chroma_log2_denom, sps.chroma_array_type() != 0)?); + } + } + let [l0, l1] = lists; + Ok(Some(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 })) +} + +fn parse_weight_entry(r: &mut BitReader<'_>, luma_denom: u32, chroma_denom: u32, has_chroma: bool) -> Result { + let mut entry = WeightEntry { luma_weight: 1 << luma_denom, chroma_weight: [1 << chroma_denom; 2], ..Default::default() }; + if r.read_flag()? { + entry.luma_flag = true; + entry.luma_weight = r.read_se()?; + entry.luma_offset = r.read_se()?; + ensure!((-128..=127).contains(&entry.luma_weight) && (-128..=127).contains(&entry.luma_offset), "luma weight out of range"); + } + if has_chroma && r.read_flag()? { + entry.chroma_flag = true; + for index in 0..2 { + entry.chroma_weight[index] = r.read_se()?; + entry.chroma_offset[index] = r.read_se()?; + ensure!( + (-128..=127).contains(&entry.chroma_weight[index]) && (-128..=127).contains(&entry.chroma_offset[index]), + "chroma weight out of range" + ); + } + } + Ok(entry) +} + +fn parse_ref_pic_marking(r: &mut BitReader<'_>, nal: NalHeader, idr: bool) -> Result<(bool, bool, bool, Vec)> { + let (mut no_output, mut long_term, mut adaptive) = (false, false, false); + let mut mmcos = Vec::new(); + if nal.nal_ref_idc == 0 { + return Ok((no_output, long_term, adaptive, mmcos)); + } + if idr { + no_output = r.read_flag()?; + long_term = r.read_flag()?; + } else { + adaptive = r.read_flag()?; + if adaptive { + parse_mmcos(r, &mut mmcos)?; + } + } + Ok((no_output, long_term, adaptive, mmcos)) +} + +fn parse_mmcos(r: &mut BitReader<'_>, mmcos: &mut Vec) -> Result<()> { + loop { + let op = r.read_ue()?; + ensure!(op <= 6, "memory_management_control_operation out of range"); + if op == 0 { + return Ok(()); + } + let mut m = Mmco { op, difference_of_pic_nums_minus1: 0, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }; + if op == 1 || op == 3 { + m.difference_of_pic_nums_minus1 = r.read_ue()?; + } + if op == 2 { + m.long_term_pic_num = r.read_ue()?; + } + if op == 3 || op == 6 { + m.long_term_frame_idx = r.read_ue()?; + } + if op == 4 { + m.max_long_term_frame_idx_plus1 = r.read_ue()?; + } + mmcos.push(m); + ensure!(mmcos.len() <= 66, "too many MMCOs"); + } +} + /// POC decoding state carried between pictures (8.2.1). #[derive(Clone, Debug, Default)] pub struct PocState { diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 00ace628..7a956118 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -54,7 +54,9 @@ harness = false unsafe_code = "deny" [lints.clippy] +cognitive_complexity = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" +too_many_arguments = "deny" type_complexity = "allow" diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index ed42bd6f..dc65b7a8 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -39,7 +39,7 @@ mod process; pub(crate) use editor::EditorLink; pub(crate) use host::HostContext; -pub(crate) use instance::{Instance, PARAM_BYPASS, PARAM_HIDDEN, PARAM_READ_ONLY, RawParam}; +pub(crate) use instance::{Instance, PARAM_BYPASS, PARAM_HIDDEN, PARAM_READ_ONLY, ProcessInput, RawParam}; #[cfg(test)] use instance::{apply_transport, split_state}; pub(crate) use module::{Bundle, exit_modules, hex}; diff --git a/crates/vst3-host/src/ffi/editor.rs b/crates/vst3-host/src/ffi/editor.rs index aba22549..89d344fa 100644 --- a/crates/vst3-host/src/ffi/editor.rs +++ b/crates/vst3-host/src/ffi/editor.rs @@ -1,5 +1,8 @@ use super::instance::Instance; -use super::*; +use super::{ + Class, ComPtr, ComWrapper, IComponentTrait, IEditControllerTrait, IPlugFrameTrait, IPlugViewTrait, Mutex, Ordering, PoisonError, STATE_MAGIC, + c_void, ok, read_c16, sb, sv, window, +}; // ---- editor (IPlugView) -------------------------------------------------------------------- diff --git a/crates/vst3-host/src/ffi/host.rs b/crates/vst3-host/src/ffi/host.rs index a2a2a216..5157e6b2 100644 --- a/crates/vst3-host/src/ffi/host.rs +++ b/crates/vst3-host/src/ffi/host.rs @@ -1,5 +1,8 @@ use super::module::tuid_bytes; -use super::*; +use super::{ + AtomicBool, Class, ComWrapper, EDIT_CAP, FUnknown, IComponentHandlerTrait, IHostApplicationTrait, Interface, Mutex, Ordering, PoisonError, + c_void, sb, sv, write_c16, +}; // ---- host-side COM objects ----------------------------------------------------------------- diff --git a/crates/vst3-host/src/ffi/instance.rs b/crates/vst3-host/src/ffi/instance.rs index 64e9d4e0..7bc23e2a 100644 --- a/crates/vst3-host/src/ffi/instance.rs +++ b/crates/vst3-host/src/ffi/instance.rs @@ -2,7 +2,11 @@ use super::editor::EditorLink; use super::host::HostContext; use super::module::Bundle; use super::process::{BusBuffers, EventList, MemoryStream, NoteEvent, ParamChanges}; -use super::*; +use super::{ + Arc, ComPtr, ComWrapper, EVENT_CAP, IAudioProcessorTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IMidiMappingTrait, + IPluginBaseTrait, MAX_BUS_CHANNELS, MAX_BUSES, MAX_PARAMS, MAX_STATE_BYTES, Mutex, Ordering, PARAM_QUEUE_CAP, PITCH_BEND, STATE_MAGIC, Transport, + Vst3Error, ok, read_c16, sb, sv, +}; // ---- instances ----------------------------------------------------------------------------- @@ -32,7 +36,9 @@ pub(crate) struct Layout { } pub(super) fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) { - use sv::ProcessContext_::StatesAndFlags_::*; + use sv::ProcessContext_::StatesAndFlags_::{ + kBarPositionValid, kContTimeValid, kPlaying, kProjectTimeMusicValid, kRecording, kTempoValid, kTimeSigValid, + }; let mut state = kTempoValid | kTimeSigValid | kProjectTimeMusicValid | kBarPositionValid | kContTimeValid; if t.playing { state |= kPlaying; @@ -375,18 +381,8 @@ impl Instance { /// `controls` (time, id, value) one point at their time when it falls in this call (the last /// one wins per parameter), and `notes` (sorted by time) have their times made relative to /// `offset`. - pub fn process( - &mut self, - frames: usize, - inputs: &mut BusBuffers, - outputs: &mut BusBuffers, - params: &[(u32, f64)], - controls: &[(u32, u32, f64)], - notes: &[NoteEvent], - offset: u32, - project_time: i64, - transport: Option<&Transport>, - ) -> Result<(), Vst3Error> { + pub fn process(&mut self, input: ProcessInput<'_>) -> Result<(), Vst3Error> { + let ProcessInput { frames, inputs, outputs, params, controls, notes, offset, project_time, transport } = input; if !self.active { return Err(Vst3Error::Process("not active".into())); } @@ -450,6 +446,18 @@ impl Instance { } } +pub(crate) struct ProcessInput<'a> { + pub frames: usize, + pub inputs: &'a mut BusBuffers, + pub outputs: &'a mut BusBuffers, + pub params: &'a [(u32, f64)], + pub controls: &'a [(u32, u32, f64)], + pub notes: &'a [NoteEvent], + pub offset: u32, + pub project_time: i64, + pub transport: Option<&'a Transport>, +} + impl Drop for Instance { fn drop(&mut self) { // Close any editor and cut its handles off before the controller goes away. diff --git a/crates/vst3-host/src/ffi/module.rs b/crates/vst3-host/src/ffi/module.rs index 574156b7..61e415b4 100644 --- a/crates/vst3-host/src/ffi/module.rs +++ b/crates/vst3-host/src/ffi/module.rs @@ -2,7 +2,11 @@ use super::editor::{EditorInner, EditorLink}; use super::host::HostContext; use super::instance::Instance; use super::process::{EventList, ParamChanges}; -use super::*; +use super::{ + Arc, AtomicBool, AtomicUsize, ComPtr, ComWrapper, Duration, EVENT_CAP, FUnknown, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, + IPluginBaseTrait, IPluginFactory2Trait, IPluginFactoryTrait, Interface, MAX_CLASSES, ManuallyDrop, Mutex, OnceLock, Ordering, PARAM_QUEUE_CAP, + Path, PathBuf, PoisonError, TryLockError, Vst3Error, c_char, c_int, c_void, mpsc, ok, read_c8, sb, scan, sv, +}; // ---- loaded binaries ----------------------------------------------------------------------- diff --git a/crates/vst3-host/src/ffi/process.rs b/crates/vst3-host/src/ffi/process.rs index 9e978496..bd0e1edd 100644 --- a/crates/vst3-host/src/ffi/process.rs +++ b/crates/vst3-host/src/ffi/process.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AtomicI32, AtomicU32, AtomicU64, Class, ComWrapper, IBStreamTrait, IEventListTrait, IParamValueQueueTrait, IParameterChangesTrait, + MAX_BUS_CHANNELS, MAX_BUSES, MAX_STATE_BYTES, Mutex, Ordering, PoisonError, c_void, sb, sv, +}; /// One parameter's points for one block. SoundCraft sends at most one point per block; a /// plugin writing output changes may add more, of which only the last is kept. diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index b2264a66..726ad98b 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -1,6 +1,6 @@ //! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. -use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND}; +use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND, ProcessInput}; use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; use std::collections::HashMap; use std::sync::Arc; @@ -324,17 +324,17 @@ impl Plugin for Vst3Plugin { let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let notes = self.notes.get(..k).unwrap_or(&[]); - let r = self.inst.process( - n, - &mut self.inputs, - &mut self.outputs, - &self.pending, - &self.controls, + let r = self.inst.process(ProcessInput { + frames: n, + inputs: &mut self.inputs, + outputs: &mut self.outputs, + params: &self.pending, + controls: &self.controls, notes, offset, - self.position, - self.transport.as_ref(), - ); + project_time: self.position, + transport: self.transport.as_ref(), + }); self.pending.clear(); self.notes.drain(..k); self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); From c6befe57afd0dc35fc4b86a01b2a19011ae1df1e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 28 Oct 2026 11:52:03 -0400 Subject: [PATCH 042/113] Release 0.14.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbab1b86..b25f84b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4279,7 +4279,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.13.1" +version = "0.14.0" dependencies = [ "chrono", "clap", @@ -4303,7 +4303,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "serde", @@ -4313,7 +4313,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "proptest", @@ -4325,7 +4325,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.13.1" +version = "0.14.0" dependencies = [ "clap", "log", @@ -4338,7 +4338,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.13.1" +version = "0.14.0" dependencies = [ "clap-sys", "libloading 0.9.0", @@ -4351,7 +4351,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.13.1" +version = "0.14.0" dependencies = [ "clap", "clap_complete", @@ -4368,7 +4368,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "proptest", @@ -4379,7 +4379,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.13.1" +version = "0.14.0" dependencies = [ "craft-docpath", "log", @@ -4402,7 +4402,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.13.1" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -4412,7 +4412,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.13.1" +version = "0.14.0" dependencies = [ "log", "rayon", @@ -4428,7 +4428,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.13.1" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -4441,7 +4441,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.13.1" +version = "0.14.0" dependencies = [ "cpal", "log", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.13.1" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -4466,7 +4466,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.13.1" +version = "0.14.0" dependencies = [ "craft-ui", "craft-update", @@ -4495,7 +4495,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.13.1" +version = "0.14.0" dependencies = [ "craft-media", "image", @@ -4507,7 +4507,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.13.1" +version = "0.14.0" dependencies = [ "libloading 0.9.0", "log", @@ -4523,7 +4523,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.13.1" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -6139,7 +6139,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.1" +version = "0.14.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 6f14fa4b..043ad467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.13.1" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 60b2c07a7036cb6a16294c3c7f10a6583cee4d16 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 13:16:03 -0400 Subject: [PATCH 043/113] Grant network access so update checks can reach GitHub (#1163) --- packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml | 2 ++ packaging/linux/flatpak/ai.storyteller.soundcraft.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml index ff502f02..977ec3ee 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml @@ -23,6 +23,8 @@ finish-args: - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Update checks reach GitHub (Help > Check for Updates and the startup check). + - --share=network # GPU access for the wgpu UI (Vulkan, or GL as a fallback). - --device=dri # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml index b30c9ba2..4af6251f 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml @@ -28,6 +28,8 @@ finish-args: - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Update checks reach GitHub (Help > Check for Updates and the startup check). + - --share=network # GPU access for the wgpu UI (Vulkan, or GL as a fallback). - --device=dri # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its From 8ca6939981a1cf5d4b0f39cbcd2b0c6a3dedadb0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 15:36:07 -0400 Subject: [PATCH 044/113] Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1167) * Document the aarch64 archive name in FreeBSD packaging package.sh already honors CARGO_BUILD_TARGET: cargo builds for the target, the binaries are read from target//release, and the arch comes from the target rather than uname -m. Only the header still described x86_64 alone, so update it. * Include the full header in package.sh --help --- packaging/freebsd/package.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 98d86199..cf19e8d4 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Build and package SoundCraft for FreeBSD: # -# $DIST/soundcraft--freebsd-x86_64.tar.gz a /usr/local-style tree: -# soundcraft--freebsd-x86_64/{bin, man/man1, share/applications, share/icons, +# $DIST/soundcraft--freebsd-.tar.gz a /usr/local-style tree ( is x86_64, or +# aarch64 when CARGO_BUILD_TARGET=aarch64-unknown-freebsd): +# soundcraft--freebsd-/{bin, man/man1, share/applications, share/icons, # share/mime, share/metainfo, share/doc/soundcraft, share/{bash-completion,zsh,fish}} # # Install by copying the tree's contents into /usr/local, e.g. @@ -23,7 +24,7 @@ SKIP_BUILD=0 while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,16p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done From 1c284e37036638957c5510b9ebef62a9dd1830d8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:08:00 +0100 Subject: [PATCH 045/113] Saturate and clamp session start in setup.current_timecode Found while testing crates/engine/src/cmd/setup_more.rs:309-310 (current_timecode) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/setup_more.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 9fe82b08..09ccb4bc 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -307,7 +307,7 @@ fn current_timecode(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let at = s.edit.selection.start.max(0); let abs = tc.to_samples(s.frame_rate, s.sample_rate); - s.timecode_start = abs - at; + s.timecode_start = abs.saturating_sub(at).clamp(-soundcraft_time::MAX_POSITION, soundcraft_time::MAX_POSITION); Ok(json!({"timecode_start": s.timecode_start, "at": at})) } @@ -567,6 +567,20 @@ mod tests { assert_eq!(shown, "01:00:00:00"); } + #[test] + fn current_timecode_out_of_range_is_clamped() { + use soundcraft_time::MAX_POSITION; + for tc in ["-9223372036854775807:00:00:00", "9999999:00:00:00"] { + let mut e = Engine::default(); + e.execute("edit.select", &json!({"start": 48_000})).unwrap(); + e.execute("setup.current_timecode", &json!({"timecode": tc})).unwrap(); + let start = e.session().timecode_start; + assert!((-MAX_POSITION..=MAX_POSITION).contains(&start), "{tc}: start {start}"); + let reloaded = Session::from_json(&e.session().to_json().unwrap()).unwrap(); + assert_eq!(reloaded.timecode_start, start, "{tc}"); + } + } + #[test] fn click_countoff_settings() { let mut e = Engine::default(); From 65e5443975e2144ec0d181ca5dfc9f4492933340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:31:50 +0000 Subject: [PATCH 046/113] Calculate velocity ramps across the full tick range Found while testing crates/midi/src/ops.rs:181 Co-Authored-By: GPT-6-Luna --- crates/midi/src/ops.rs | 5 +++-- crates/midi/tests/it/ops.rs | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/midi/src/ops.rs b/crates/midi/src/ops.rs index 16324462..e6da9959 100644 --- a/crates/midi/src/ops.rs +++ b/crates/midi/src/ops.rs @@ -178,9 +178,10 @@ pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64) VelocityOp::Ramp { from, to } => { let first = notes.iter().map(|n| n.start).min().unwrap_or(0); let last = notes.iter().map(|n| n.start).max().unwrap_or(0); - let span = last.saturating_sub(first) as f64; + let span = (i128::from(last) - i128::from(first)) as f64; for n in notes.iter_mut() { - let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 }; + let offset = (i128::from(n.start) - i128::from(first)) as f64; + let t = if span > 0.0 { offset / span } else { 0.0 }; let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t; n.velocity = clamp_vel(round_i64(v)); } diff --git a/crates/midi/tests/it/ops.rs b/crates/midi/tests/it/ops.rs index 1aed370f..3a367ee0 100644 --- a/crates/midi/tests/it/ops.rs +++ b/crates/midi/tests/it/ops.rs @@ -106,6 +106,13 @@ fn transpose_clamps() { assert!(notes.iter().all(|n| n.pitch == 127)); } +#[test] +fn ramp_full_tick_range() { + let mut notes = vec![n(60, i64::MIN, 1), n(60, 0, 1), n(60, i64::MAX, 1)]; + change_velocity(&mut notes, VelocityOp::Ramp { from: 1, to: 127 }); + assert_eq!(notes.iter().map(|n| n.velocity).collect::>(), vec![1, 64, 127]); +} + #[test] fn velocity_ops() { let base = vec![n(60, 0, 1), n(60, 100, 1), n(60, 200, 1)]; From b609172db61ca47822c2d4f008778a421cd4e75a Mon Sep 17 00:00:00 2001 From: Luigi Pinna Date: Thu, 29 Oct 2026 09:39:46 +0100 Subject: [PATCH 047/113] Route sequential H.264 sample reads through the 256 MiB cap decode_h264 read each sample with Mp4File::read_sample directly, so a sample table entry above 256 MiB was allocated and read in full when the source was long enough. It now goes through Movie::read like the keyframe and intra-frame paths, and an oversized sample is skipped like any other unreadable access unit. Fixes #912 --- crates/video/src/movie.rs | 2 +- crates/video/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/video/src/movie.rs b/crates/video/src/movie.rs index 752f701d..bbed0230 100644 --- a/crates/video/src/movie.rs +++ b/crates/video/src/movie.rs @@ -485,7 +485,7 @@ impl Movie { let s = st.next; st.next += 1; let pts = self.rank.get(s).copied().unwrap_or(0) as i64; - let data = match self.mp4.read_sample(&*self.src, self.track, s) { + let data = match self.read(s) { Ok(d) => d, Err(e) => { last_err = Some(e.to_string()); diff --git a/crates/video/src/tests.rs b/crates/video/src/tests.rs index 695a44ae..741db023 100644 --- a/crates/video/src/tests.rs +++ b/crates/video/src/tests.rs @@ -3,10 +3,12 @@ //! `image` encoder, muxed with the bundled MP4/MOV writer. When `ffmpeg` with libx264 is on PATH, //! real High-profile streams with B-frames are checked too (skipped otherwise). -use crate::isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; +use crate::isobmff::{Brand, ByteSource, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use crate::synth::{frame_color, h264_mp4, mjpeg_mov, prores_mov}; use crate::{Movie, VideoError}; use std::io::Cursor; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; fn expected_rgb(i: usize) -> [u8; 3] { let [y, cb, cr] = frame_color(i); @@ -118,6 +120,47 @@ fn prores_frame_size_is_checked_before_decoding() { assert!(matches!(m.frame(0), Err(VideoError::Decode(e)) if e.contains("56x48 frame in a 64x48 track"))); } +/// In-memory movie bytes, zero-padded up to `len` without storing the padding. Records the +/// largest read asked of it and refuses reads over 1 MiB (the test movies are much smaller). +struct PaddedSource { + bytes: Vec, + len: u64, + largest: AtomicUsize, +} + +impl ByteSource for PaddedSource { + fn len(&self) -> u64 { + self.len + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.largest.fetch_max(buf.len(), Ordering::Relaxed); + if buf.len() > 1 << 20 { + return Err(std::io::Error::other("read too large for the test source")); + } + let start = usize::try_from(offset).unwrap_or(usize::MAX); + for (i, b) in buf.iter_mut().enumerate() { + *b = self.bytes.get(start.saturating_add(i)).copied().unwrap_or(0); + } + Ok(()) + } +} + +#[test] +fn h264_sample_reads_are_capped() { + // One frame whose `stsz` entry claims 300 MiB, in a source long enough to hold it. + let mut bytes = h264_mp4(1, 1).unwrap(); + let at = bytes.windows(4).position(|w| w == b"stsz").unwrap(); + // After the type: version and flags, the constant size (0: one size per sample), the count. + let field = if bytes[at + 8..at + 12] == [0; 4] { at + 16 } else { at + 8 }; + bytes[field..field + 4].copy_from_slice(&(300u32 << 20).to_be_bytes()); + let src = Arc::new(PaddedSource { bytes, len: 1 << 30, largest: AtomicUsize::new(0) }); + let mut m = Movie::open_source(src.clone()).unwrap(); + let r = m.frame(0); + assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("sample larger than 256 MiB")), "{r:?}"); + let largest = src.largest.load(Ordering::Relaxed); + assert!(largest <= 256 << 20, "a {largest}-byte read reached the source"); +} + #[test] fn unsupported_codec_opens_but_does_not_decode() { let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); From 09444326d53b959282f204e7814c7497f11276a6 Mon Sep 17 00:00:00 2001 From: Luigi Pinna Date: Thu, 29 Oct 2026 09:42:56 +0100 Subject: [PATCH 048/113] Reject VST3 bus layouts beyond host limits instead of truncating Fixes #904 --- crates/vst3-host/src/ffi/instance.rs | 58 +++++++---- crates/vst3-host/tests/common/mod.rs | 2 +- crates/vst3-host/tests/fixture/src/lib.rs | 111 +++++++++++++++++++++- crates/vst3-host/tests/it/fixture_e2e.rs | 22 ++++- 4 files changed, 173 insertions(+), 20 deletions(-) diff --git a/crates/vst3-host/src/ffi/instance.rs b/crates/vst3-host/src/ffi/instance.rs index 7bc23e2a..c7d783e0 100644 --- a/crates/vst3-host/src/ffi/instance.rs +++ b/crates/vst3-host/src/ffi/instance.rs @@ -35,6 +35,11 @@ pub(crate) struct Layout { pub event_input: bool, } +/// `"input"` or `"output"`, for error messages. +fn dir_name(dir: sv::BusDirections) -> &'static str { + if dir == sv::BusDirections_::kInput { "input" } else { "output" } +} + pub(super) fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) { use sv::ProcessContext_::StatesAndFlags_::{ kBarPositionValid, kContTimeValid, kPlaying, kProjectTimeMusicValid, kRecording, kTempoValid, kTimeSigValid, @@ -248,7 +253,17 @@ impl Instance { 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) }.clamp(0, MAX_BUSES) + unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.max(0) + } + + /// The plugin's audio buses in one direction. More than `MAX_BUSES` is an error: we never + /// hand a plugin fewer buses than it advertised. + fn audio_bus_count(&self, dir: sv::BusDirections) -> Result { + let n = self.bus_count(sv::MediaTypes_::kAudio, dir); + if n > MAX_BUSES { + return Err(Vst3Error::Process(format!("{n} audio {} buses (at most {MAX_BUSES} supported)", dir_name(dir)))); + } + Ok(n) } fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option { @@ -258,30 +273,41 @@ impl Instance { ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr) } - fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 { - if let Some(arr) = self.arrangement(dir, index) { - return arr.count_ones().min(MAX_BUS_CHANNELS); - } - // SAFETY: plain data; all-zero is valid. - let mut info: sv::BusInfo = unsafe { std::mem::zeroed() }; - // SAFETY: valid component; index below the bus count; `info` is writable. - if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) { - u32::try_from(info.channelCount).unwrap_or(0).min(MAX_BUS_CHANNELS) + /// Channels of one audio bus. More than `MAX_BUS_CHANNELS` is an error, as for the bus count. + fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> Result { + let channels = if let Some(arr) = self.arrangement(dir, index) { + arr.count_ones() } else { - 0 + // SAFETY: plain data; all-zero is valid. + let mut info: sv::BusInfo = unsafe { std::mem::zeroed() }; + // SAFETY: valid component; index below the bus count; `info` is writable. + if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) { + u32::try_from(info.channelCount).unwrap_or(0) + } else { + 0 + } + }; + if channels > MAX_BUS_CHANNELS { + return Err(Vst3Error::Process(format!( + "audio {} bus {index} has {channels} channels (at most {MAX_BUS_CHANNELS} supported)", + dir_name(dir) + ))); } + Ok(channels) } /// Stops processing, deactivates, negotiates the bus layout (main buses mono or stereo as /// asked, falling back to stereo and then to the plugin's own choice), activates the main - /// buses, sets up 32-bit processing and activates the component. + /// buses, sets up 32-bit processing and activates the component. A layout beyond + /// `MAX_BUSES` buses or `MAX_BUS_CHANNELS` channels per bus is an error, and the component + /// stays inactive. pub fn configure(&mut self, sample_rate: f64, max_frames: usize, mono: bool) -> Result { self.shutdown(); let audio = sv::MediaTypes_::kAudio; let event = sv::MediaTypes_::kEvent; let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput); - let nin = self.bus_count(audio, din); - let nout = self.bus_count(audio, dout); + let nin = self.audio_bus_count(din)?; + let nout = self.audio_bus_count(dout)?; let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec { (0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect() }; @@ -311,8 +337,8 @@ impl Instance { log::debug!("vst3: plugin refused mono/stereo bus arrangements; using its own"); } let layout = Layout { - inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect(), - outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect(), + inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect::>()?, + outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect::>()?, event_input: self.bus_count(event, din) > 0, }; // Main buses on, aux buses off (VST3 still gets buffers for every bus). diff --git a/crates/vst3-host/tests/common/mod.rs b/crates/vst3-host/tests/common/mod.rs index e49b5a0e..32a50f36 100644 --- a/crates/vst3-host/tests/common/mod.rs +++ b/crates/vst3-host/tests/common/mod.rs @@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path { let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder); std::fs::create_dir_all(&bin).unwrap(); std::fs::copy(&built, bin.join(exe)).unwrap(); - assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 2); + assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5); dir }) } diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 2b7f73d9..68703d27 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -1,4 +1,4 @@ -//! Test-only VST3 plugin for soundcraft-vst3-host (two audio module classes): +//! Test-only VST3 plugin for soundcraft-vst3-host (five audio module classes): //! //! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller //! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain" @@ -11,6 +11,11 @@ //! its edit controller). No audio input, one stereo output, one event input; every output //! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and //! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too. +//! - "Fixture Wide 16x64", "Fixture Wide 17 buses", "Fixture Wide 65 channels" (`Fx`): effects +//! advertising wide layouts (16 buses of 64 channels, 17 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. //! //! 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. @@ -28,6 +33,9 @@ use vst3::{Class, ComRef, ComWrapper, uid}; const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666); const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666); const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666); +const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666); +const WIDE17_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666); +const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666); fn copy_c8(src: &str, dst: &mut [c_char]) { for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) { @@ -626,6 +634,98 @@ impl IEditControllerTrait for Synth { } } +// ---- wide layouts -------------------------------------------------------------------------- + +struct Wide { + buses: i32, + channels: i32, + _live: Live, +} + +impl Class for Wide { + type Interfaces = (IComponent, IAudioProcessor); +} + +impl IPluginBaseTrait for Wide { + unsafe fn initialize(&self, _context: *mut FUnknown) -> tresult { + kResultOk + } + unsafe fn terminate(&self) -> tresult { + kResultOk + } +} + +impl IComponentTrait for Wide { + unsafe fn getControllerClassId(&self, _class_id: *mut TUID) -> tresult { + kNotImplemented + } + unsafe fn setIoMode(&self, _mode: IoMode) -> tresult { + kResultOk + } + unsafe fn getBusCount(&self, media: MediaType, _dir: BusDirection) -> i32 { + if media == MediaTypes_::kAudio as MediaType { self.buses } else { 0 } + } + unsafe fn getBusInfo(&self, media: MediaType, dir: BusDirection, index: i32, bus: *mut BusInfo) -> tresult { + if media != MediaTypes_::kAudio as MediaType || !(0..self.buses).contains(&index) { + return kInvalidArgument; + } + audio_bus(bus, dir, "Wide"); + unsafe { (*bus).channelCount = self.channels }; + kResultOk + } + unsafe fn getRoutingInfo(&self, _i: *mut RoutingInfo, _o: *mut RoutingInfo) -> tresult { + kNotImplemented + } + unsafe fn activateBus(&self, _m: MediaType, _d: BusDirection, _i: i32, _s: TBool) -> tresult { + kResultOk + } + unsafe fn setActive(&self, _state: TBool) -> tresult { + kResultOk + } + unsafe fn setState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } + unsafe fn getState(&self, _state: *mut IBStream) -> tresult { + kResultOk + } +} + +impl IAudioProcessorTrait for Wide { + unsafe fn setBusArrangements(&self, _i: *mut SpeakerArrangement, _n_in: i32, _o: *mut SpeakerArrangement, _n_out: i32) -> tresult { + kResultFalse + } + unsafe fn getBusArrangement(&self, _dir: BusDirection, _index: i32, _arr: *mut SpeakerArrangement) -> tresult { + kNotImplemented + } + unsafe fn canProcessSampleSize(&self, size: i32) -> tresult { + if size == SymbolicSampleSizes_::kSample32 as i32 { kResultOk } else { kNotImplemented } + } + unsafe fn getLatencySamples(&self) -> u32 { + 3 + } + unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult { + kResultOk + } + unsafe fn setProcessing(&self, _state: TBool) -> tresult { + kResultOk + } + unsafe fn process(&self, data: *mut ProcessData) -> tresult { + 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); + 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 }); + } + } + kResultOk + } + unsafe fn getTailSamples(&self) -> u32 { + 0 + } +} + // ---- factory ------------------------------------------------------------------------------- struct Factory { @@ -636,10 +736,13 @@ impl Class for Factory { type Interfaces = (IPluginFactory2,); } -const CLASSES: [(TUID, &str, &str, &str); 3] = [ +const CLASSES: [(TUID, &str, &str, &str); 6] = [ (GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"), (GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""), (SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"), + (WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"), + (WIDE17_CID, "Audio Module Class", "Fixture Wide 17 buses", "Fx"), + (WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"), ]; impl IPluginFactoryTrait for Factory { @@ -682,6 +785,10 @@ impl IPluginFactoryTrait for Factory { .to_com_ptr::() } else if cid == SYNTH_CID { ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::() + } else if let Some((buses, channels)) = + [(WIDE16_CID, (16, 64)), (WIDE17_CID, (17, 2)), (WIDE65_CID, (1, 65))].into_iter().find_map(|(c, layout)| (c == cid).then_some(layout)) + { + ComWrapper::new(Wide { buses, channels, _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 01b4dadd..ac7d2b77 100644 --- a/crates/vst3-host/tests/it/fixture_e2e.rs +++ b/crates/vst3-host/tests/it/fixture_e2e.rs @@ -15,7 +15,7 @@ fn id_of(name: &str) -> String { fn scan_lists_both_audio_modules_but_not_the_controller_class() { let dir = fixture_dir(); let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]); - assert_eq!(found.len(), 2, "{found:?}"); + assert_eq!(found.len(), 5, "{found:?}"); let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap(); // `uid()` lays out the first 8 bytes differently on Windows (COM GUID order). let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" }; @@ -311,6 +311,26 @@ fn host_set_controller_param_survives_state_round_trip() { assert!((r.param("12").unwrap() - 200.0).abs() < 0.01, "a loaded state replaces earlier edits: {:?}", r.param("12")); } +#[test] +fn layouts_beyond_the_host_limits_are_rejected_not_truncated() { + // Control: exactly at the limits (16 buses of 64 channels) activates and gets its full layout. + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 16x64")).unwrap(); + p.prepare(44_100.0, 64, 2); + assert_eq!(p.latency(), 3); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]); + // 17 buses, or a 65-channel bus: never activated, never processed with a cut-down layout. + for name in ["Fixture Wide 17 buses", "Fixture Wide 65 channels"] { + let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap(); + p.prepare(44_100.0, 64, 2); + assert_eq!(p.latency(), 0, "{name}: not activated"); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == 0.5), "{name}: the plugin never ran: {:?}", &io[0][..4]); + } +} + #[test] fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); From 0ede26f5bcb98b3ef7b28baee3a436cd86483158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:05:48 +0000 Subject: [PATCH 049/113] Clamp the minimum beat before conversion Found while testing crates/time/src/tempo.rs:304 Co-Authored-By: GPT-6-Luna --- crates/time/src/tempo.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 6aaa50b8..46c05064 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -301,7 +301,7 @@ impl TempoMap { pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 { let start = self.bar_start_tick(bb.bar); let m = self.meter_at_tick(start); - start.saturating_add((bb.beat - 1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick) + start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick) } pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat { @@ -344,6 +344,12 @@ mod tests { assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4); } + #[test] + fn minimum_beat_is_clamped_without_overflow() { + let m = TempoMap::default(); + assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0); + } + #[test] fn bar_beat_at_min_tick_does_not_overflow() { let m = TempoMap::default(); From a63b23adf77d6d1c4cdfb054c0d68fabd473df5c Mon Sep 17 00:00:00 2001 From: Sander de Wijs Date: Sun, 25 Oct 2026 15:51:15 +0100 Subject: [PATCH 050/113] Apply the playback engine's device and buffer in the desktop app setup.playback_engine and setup.hardware stored device and buffer_size on the session and reported 1024 when nothing was set. Playback never read those values, so the open device stayed at its old buffer. In the app the commands now reopen the device through ui.audio_device and return the name, buffer and sample rate actually in use, while delay compensation still goes through the engine command. Headless keeps the stored request, returns applied: false, and leaves buffer_size null until one is set. A buffer is any integer from 16 to 8192 and is not rounded to a power of two; values outside that range error. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/setup_more.rs | 122 +++++++++++++++--- crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/windows.rs | 44 +++++++ crates/ui-egui/tests/it/audio_device.rs | 50 +++++++ .../ui-egui/tests/it/command_reachability.rs | 9 +- docs/gaps.md | 10 +- docs/roadmap.md | 5 +- 7 files changed, 224 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 9fe82b08..beb30c11 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -42,13 +42,24 @@ pub fn specs() -> Vec { always, prepare_dpe ), - cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)), - cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| { - if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) { - e.session_mut().edit.delay_compensation = v; - } - engine_settings(e, p) - }), + cmd!( + noundo "setup.hardware", + "Hardware...", + ["Setup"], + None, + "{device?: output name, buffer_size?: 16..=8192 or null} — output device and buffer. Applied only in the desktop app (Playback Engine window / ui.audio_device), which returns the open device. Headless stores the request and returns applied: false; buffer_size is null when unset. Any integer in range, not rounded to the window's power-of-two sizes. Sample rate is the session's", + always, + |e, p| engine_settings(e, p, "setup.hardware") + ), + cmd!( + noundo "setup.playback_engine", + "Playback Engine...", + ["Setup"], + None, + "{device?: output name, buffer_size?: 16..=8192 or null, delay_compensation?: bool} — device and buffer as setup.hardware (applied only in the desktop app, or via ui.audio_device); delay_compensation is applied here too. Headless returns applied: false and does not invent a buffer", + always, + |e, p| engine_settings(e, p, "setup.playback_engine") + ), cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation), cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals), cmd!( @@ -156,18 +167,64 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result { Ok(json!({"prepared": n})) } -fn engine_settings(e: &mut Engine, p: &Value) -> Result { +/// A `buffer_size` argument: absent, cleared (`null`), or an integer in 16..=8192. +enum BufferParam { + Skip, + Clear, + Set(u64), +} + +/// Same range as `ui.audio_device`. Out of range and non-numbers error; there is no clamp and no +/// power-of-two rounding (the Playback Engine window lists those sizes, the command keeps the number). +fn buffer_param(id: &str, p: &Value) -> Result { + match p.get("buffer_size") { + None => Ok(BufferParam::Skip), + Some(Value::Null) => Ok(BufferParam::Clear), + Some(v) => { + let n = v.as_u64().filter(|n| (16..=8192).contains(n)).ok_or_else(|| bad(id, "`buffer_size` must be a number in 16..=8192 or null"))?; + Ok(BufferParam::Set(n)) + } + } +} + +/// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it. +fn device_param(p: &Value) -> Option> { + p.get("device").map(|v| v.as_str().filter(|s| !s.is_empty())) +} + +/// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or +/// `engine.buffer_size` (only this command does); they are kept so a later call can report the +/// request. The result says `applied: false` and leaves `buffer_size` null until one was stored. +fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result { + let buffer = buffer_param(id, p)?; + let device = device_param(p); + if id == "setup.playback_engine" + && let Some(on) = p.get("delay_compensation").and_then(Value::as_bool) + { + e.session_mut().edit.delay_compensation = on; + } let s = e.session_mut(); - if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) { - let b = b.clamp(16, 8192).next_power_of_two().min(8192); - s.edit.values.insert("engine.buffer_size".into(), b as f64); + match buffer { + BufferParam::Skip => {} + BufferParam::Clear => { + s.edit.values.remove("engine.buffer_size"); + } + BufferParam::Set(n) => { + s.edit.values.insert("engine.buffer_size".into(), n as f64); + } } - if let Some(d) = str_param(p, "device") { - set_text(&mut s.edit, "engine.device", Some(d)); + if let Some(name) = device { + set_text(&mut s.edit, "engine.device", name); } + // Exact integers only: a hand-edited fraction is not reported as a buffer size. + let buffer_size = s.edit.values.get("engine.buffer_size").copied().and_then(|v| { + let n = v as u64; + (v.is_finite() && (n as f64) == v).then_some(n) + }); Ok(json!({ + "applied": false, "device": text(&s.edit, "engine.device"), - "buffer_size": s.edit.value("engine.buffer_size", 1024.0), + "buffer_size": buffer_size, "sample_rate": s.sample_rate.hz(), "bit_depth": s.bit_depth, "delay_compensation": s.edit.delay_compensation, @@ -587,7 +644,8 @@ mod tests { fn engine_and_midi_settings() { let mut e = Engine::default(); let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap(); - assert_eq!(r["buffer_size"], 512.0); + assert_eq!(r["applied"], false); + assert_eq!(r["buffer_size"], 300); assert_eq!(r["device"], "Interface"); let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap(); assert_eq!(r["filtered"], json!(["sysex"])); @@ -597,6 +655,40 @@ mod tests { assert_eq!(r["samples"], 3200.0); } + #[test] + fn playback_engine_does_not_pretend_the_device_changed() { + let mut e = Engine::default(); + let r = e.execute("setup.playback_engine", &json!({})).unwrap(); + assert_eq!(r["applied"], false); + assert!(r["buffer_size"].is_null(), "no invented default buffer: {r}"); + assert!(r["device"].is_null()); + assert_eq!(r["delay_compensation"], true, "the session default, not a device claim"); + + let r = e.execute("setup.hardware", &json!({"buffer_size": 300, "device": "Interface"})).unwrap(); + assert_eq!(r["applied"], false); + assert_eq!(r["buffer_size"], 300, "kept, not rounded to a power of two"); + assert_eq!(r["device"], "Interface"); + assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0); + + let r = e.execute("setup.playback_engine", &json!({"delay_compensation": false})).unwrap(); + assert_eq!(r["applied"], false); + assert_eq!(r["delay_compensation"], false); + assert_eq!(r["buffer_size"], 300, "delay compensation does not clear a stored buffer"); + assert!(!e.session().edit.delay_compensation); + + let err = e.execute("setup.playback_engine", &json!({"buffer_size": 8, "delay_compensation": true, "device": "Other"})).unwrap_err(); + assert!(err.to_string().contains("16..=8192"), "{err}"); + assert!(!e.session().edit.delay_compensation, "an invalid buffer changes nothing"); + assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0); + assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface")); + assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err()); + + let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap(); + assert_eq!(r["applied"], false); + assert!(r["buffer_size"].is_null()); + assert!(r["device"].is_null()); + } + #[test] fn import_session_data_brings_tracks_and_audio() { let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id())); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fc821b42..450c3af0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -518,6 +518,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option Result Ok(out) } +/// Programmatic `setup.playback_engine` / `setup.hardware`. `device` is the output name and +/// `buffer_size` is any integer 16..=8192 or null (not rounded to the window's power-of-two list); +/// both go through [`audio_device_command`]. Delay compensation still goes through the engine +/// command, and only after the device change has been accepted. The result is the open device: +/// its name, the buffer actually in use (`device_buffer`, null for the device default), its sample +/// rate, and delay compensation. `applied` is true because those figures are the live device. +pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) -> Result { + use serde_json::Value; + let mut audio = serde_json::Map::new(); + if let Some(device) = p.get("device") { + audio.insert("output".to_string(), device.clone()); + } + if let Some(buffer) = p.get("buffer_size") { + audio.insert("buffer_size".to_string(), buffer.clone()); + } + if !audio.is_empty() { + audio_device_command(app, &Value::Object(audio))?; + } + if id == "setup.playback_engine" + && let Some(on) = p.get("delay_compensation").and_then(Value::as_bool) + { + app.engine.execute("setup.playback_engine", &json!({"delay_compensation": on})).map_err(|e| e.to_string())?; + } + let (device, buffer_size, sample_rate) = match app.player.as_ref() { + Some(player) => { + let buffer = match player.device_buffer { + Some(n) => json!(n), + None => Value::Null, + }; + (json!(player.device_name), buffer, json!(player.device_rate)) + } + None => (Value::Null, Value::Null, json!(app.engine.session().sample_rate.hz())), + }; + Ok(json!({ + "applied": true, + "device": device, + "buffer_size": buffer_size, + "sample_rate": sample_rate, + "delay_compensation": app.engine.session().edit.delay_compensation, + })) +} + /// Setup › I/O: busses and output paths. pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_io_setup; diff --git a/crates/ui-egui/tests/it/audio_device.rs b/crates/ui-egui/tests/it/audio_device.rs index 6095a723..3379208b 100644 --- a/crates/ui-egui/tests/it/audio_device.rs +++ b/crates/ui-egui/tests/it/audio_device.rs @@ -25,3 +25,53 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() { assert_eq!(r["config"]["buffer_size"], 256); assert!(r["device"].is_object(), "{r}"); } + +#[test] +fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let before = app.ui.audio.clone(); + assert!(!app.ui.show_playback_engine); + + assert!(app.engine.session().edit.delay_compensation, "delay compensation starts on"); + let err = app.run("setup.playback_engine", json!({"buffer_size": 8, "delay_compensation": false})).unwrap_err(); + assert!(err.contains("16..=8192"), "{err}"); + assert_eq!(app.ui.audio, before, "an invalid buffer changes nothing"); + assert!(app.engine.session().edit.delay_compensation, "an invalid buffer does not apply delay compensation"); + + app.engine.transport.recording = true; + let err = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap_err(); + assert!(err.contains("stop recording"), "{err}"); + assert_eq!(app.ui.audio, before); + assert!(app.engine.session().edit.delay_compensation, "a refused device change does not apply delay compensation"); + app.engine.transport.recording = false; + + let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap(); + assert_eq!(app.ui.audio.buffer_size, Some(256)); + assert!(!app.engine.session().edit.delay_compensation); + assert!(!app.ui.show_playback_engine, "a programmatic call does not open the window"); + assert_eq!(r["applied"], true); + assert_eq!(r["delay_compensation"], false); + let player = app.player.as_ref().unwrap(); + assert_eq!(r["device"], json!(player.device_name)); + assert_eq!(r["buffer_size"], json!(player.device_buffer)); + assert_eq!(r["sample_rate"], player.device_rate); + + // 300 is in range and is not rounded onto the window's power-of-two list. + let mut via_window_command = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + via_window_command.run("ui.audio_device", json!({"buffer_size": 300, "output": "Elsewhere"})).unwrap(); + app.run("setup.hardware", json!({"buffer_size": 300, "device": "Elsewhere"})).unwrap(); + assert_eq!(app.ui.audio.buffer_size, Some(300)); + assert_eq!(app.ui.audio.buffer_size, via_window_command.ui.audio.buffer_size); + assert_eq!(app.ui.audio.output, via_window_command.ui.audio.output); + assert!(!app.ui.show_playback_engine); + + app.run("setup.playback_engine", json!({"buffer_size": null, "device": null})).unwrap(); + assert_eq!(app.ui.audio.buffer_size, None); + assert_eq!(app.ui.audio.output, None); + assert!(!app.engine.session().edit.delay_compensation, "clearing the device leaves delay compensation"); + + let cfg = app.ui.audio.clone(); + soundcraft_ui_egui::menus::invoke_menu(&mut app, "setup.playback_engine", "Setup > Playback Engine..."); + assert!(app.ui.show_playback_engine, "the menu still opens the Playback Engine window"); + assert_eq!(app.ui.audio, cfg, "opening the window does not change the device"); +} diff --git a/crates/ui-egui/tests/it/command_reachability.rs b/crates/ui-egui/tests/it/command_reachability.rs index 797f1d86..a1c0e81a 100644 --- a/crates/ui-egui/tests/it/command_reachability.rs +++ b/crates/ui-egui/tests/it/command_reachability.rs @@ -25,8 +25,6 @@ const KNOWN_UNREACHED: &[&str] = &[ "mix.solo_safe", "options.post_roll", "options.pre_roll", - "setup.hardware", // menu opens the Playback Engine window, which doesn't run it - "setup.playback_engine", // same "setup.reset_edit_state", "track.load_preset", "track.pin", @@ -93,6 +91,13 @@ fn every_command_has_a_ui_path() { assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}"); } +#[test] +fn playback_engine_menu_opens_the_window_instead_of_running_the_command() { + let extra = menus::ui_aliases(); + assert_eq!(catalog::implemented_by("Setup > Playback Engine...", &extra).as_deref(), Some("window.playback_engine")); + assert_eq!(catalog::implemented_by("Setup > Hardware...", &extra).as_deref(), Some("window.playback_engine")); +} + #[test] fn new_midi_clip_is_in_the_clip_menu() { let extra = menus::ui_aliases(); diff --git a/docs/gaps.md b/docs/gaps.md index cd832000..17b2fe8a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker** @@ -46,11 +46,16 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t device, sample rate and device buffer size (kept with the preferences, per machine; `ui.audio_device` for agents); f32 and 16/32-bit integer devices; ASIO behind the opt-in `asio` build feature (verified on Windows with an SSL 2+ at 128 samples). +- **Done (2026-10-25):** `setup.playback_engine` and `setup.hardware` apply `device` (the output + name) and `buffer_size` in the desktop app on the same path as the Playback Engine window + (`ui.audio_device`) and return the open device. `buffer_size` is any integer 16..=8192 and is + not rounded onto the window's power-of-two list. Headless (CLI, no app) still stores the request + and returns `applied: false`, with `buffer_size` null when unset; playback does not read those + session values. - **Missing:** routing to physical channels (I/O Setup), one duplex stream on one clock (input and output are still separate cpal streams), latency reporting and record-offset compensation, ASIO in official builds (owner decision: Steinberg's SDK is proprietary or GPLv3), JACK, and verification on macOS and Linux. The mix still runs in fixed 512-sample blocks. - `setup.playback_engine` still stores `device`/`buffer_size` in the session, unused. - **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42 (no input device selection, forced monitoring feedback with virtual routing). - **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard. @@ -226,6 +231,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops | +| 2026-10-25 | trivial | G-01: `setup.playback_engine` and `setup.hardware` apply the device and buffer in the desktop app; headless returns `applied: false` | | 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing | | 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained | | 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 035b2f7f..5219a8e5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # SoundCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (built-in plugin count; the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (`setup.playback_engine` applies the device buffer in the desktop app) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md). @@ -23,7 +23,7 @@ main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are th | Workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---:| -| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 | +| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Device, rate and buffer are chosen in the Playback Engine window and, in the desktop app, by `setup.playback_engine` (headless returns `applied: false`). The mix still runs in fixed 512-sample blocks. Separate input and output streams. Takes held in RAM until stop (PR #84). | 35–55 | | Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 | | Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — | | Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 27 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | @@ -89,6 +89,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Record-workflow evidence: `setup.playback_engine` applies the device and buffer in the desktop app; headless returns `applied: false` | | 2026-10-24 | trivial | 27 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials | | 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) | | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | From d97e5e7f167f944368410ba8956d9d227189af9f Mon Sep 17 00:00:00 2001 From: Sander de Wijs Date: Mon, 26 Oct 2026 12:42:40 +0100 Subject: [PATCH 051/113] Validate playback device command responses Reject non-string device values before changing session or audio configuration, include bit depth in desktop responses, and report applied only for an open non-silent device. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/setup_more.rs | 16 +++++++++++++--- crates/ui-egui/src/panels/windows.rs | 9 +++++++-- crates/ui-egui/tests/it/audio_device.rs | 13 ++++++++++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index beb30c11..8a64f921 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -188,8 +188,13 @@ fn buffer_param(id: &str, p: &Value) -> Result { } /// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it. -fn device_param(p: &Value) -> Option> { - p.get("device").map(|v| v.as_str().filter(|s| !s.is_empty())) +fn device_param<'a>(id: &str, p: &'a Value) -> Result>> { + match p.get("device") { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(name)) => Ok(Some((!name.is_empty()).then_some(name.as_str()))), + Some(_) => Err(bad(id, "`device` must be a string or null")), + } } /// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or @@ -197,7 +202,7 @@ fn device_param(p: &Value) -> Option> { /// request. The result says `applied: false` and leaves `buffer_size` null until one was stored. fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result { let buffer = buffer_param(id, p)?; - let device = device_param(p); + let device = device_param(id, p)?; if id == "setup.playback_engine" && let Some(on) = p.get("delay_compensation").and_then(Value::as_bool) { @@ -683,6 +688,11 @@ mod tests { assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface")); assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err()); + let err = e.execute("setup.playback_engine", &json!({"device": 42, "delay_compensation": true})).unwrap_err(); + assert!(err.to_string().contains("`device` must be a string or null"), "{err}"); + assert!(!e.session().edit.delay_compensation, "an invalid device changes nothing"); + assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface")); + let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap(); assert_eq!(r["applied"], false); assert!(r["buffer_size"].is_null()); diff --git a/crates/ui-egui/src/panels/windows.rs b/crates/ui-egui/src/panels/windows.rs index 8906ae58..dd1e06d7 100644 --- a/crates/ui-egui/src/panels/windows.rs +++ b/crates/ui-egui/src/panels/windows.rs @@ -630,11 +630,14 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result /// both go through [`audio_device_command`]. Delay compensation still goes through the engine /// command, and only after the device change has been accepted. The result is the open device: /// its name, the buffer actually in use (`device_buffer`, null for the device default), its sample -/// rate, and delay compensation. `applied` is true because those figures are the live device. +/// rate, and delay compensation. `applied` is true only when an audio device is open. pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) -> Result { use serde_json::Value; let mut audio = serde_json::Map::new(); if let Some(device) = p.get("device") { + if !device.is_null() && !device.is_string() { + return Err("`device` must be a string or null".into()); + } audio.insert("output".to_string(), device.clone()); } if let Some(buffer) = p.get("buffer_size") { @@ -648,6 +651,7 @@ pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) { app.engine.execute("setup.playback_engine", &json!({"delay_compensation": on})).map_err(|e| e.to_string())?; } + let applied = app.player.as_ref().is_some_and(|player| !player.silent); let (device, buffer_size, sample_rate) = match app.player.as_ref() { Some(player) => { let buffer = match player.device_buffer { @@ -659,10 +663,11 @@ pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) None => (Value::Null, Value::Null, json!(app.engine.session().sample_rate.hz())), }; Ok(json!({ - "applied": true, + "applied": applied, "device": device, "buffer_size": buffer_size, "sample_rate": sample_rate, + "bit_depth": app.engine.session().bit_depth, "delay_compensation": app.engine.session().edit.delay_compensation, })) } diff --git a/crates/ui-egui/tests/it/audio_device.rs b/crates/ui-egui/tests/it/audio_device.rs index 3379208b..69735cb6 100644 --- a/crates/ui-egui/tests/it/audio_device.rs +++ b/crates/ui-egui/tests/it/audio_device.rs @@ -30,8 +30,13 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() { fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); let before = app.ui.audio.clone(); + let bit_depth = app.engine.session().bit_depth; assert!(!app.ui.show_playback_engine); + let r = app.run("setup.hardware", json!({})).unwrap(); + assert_eq!(r["applied"], false, "without a player there is no open device"); + assert_eq!(r["bit_depth"], json!(bit_depth)); + assert!(app.engine.session().edit.delay_compensation, "delay compensation starts on"); let err = app.run("setup.playback_engine", json!({"buffer_size": 8, "delay_compensation": false})).unwrap_err(); assert!(err.contains("16..=8192"), "{err}"); @@ -45,16 +50,22 @@ fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { assert!(app.engine.session().edit.delay_compensation, "a refused device change does not apply delay compensation"); app.engine.transport.recording = false; + let err = app.run("setup.playback_engine", json!({"device": 42, "delay_compensation": false})).unwrap_err(); + assert!(err.contains("`device` must be a string or null"), "{err}"); + assert_eq!(app.ui.audio, before, "an invalid device changes nothing"); + assert!(app.engine.session().edit.delay_compensation, "an invalid device does not apply delay compensation"); + let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap(); assert_eq!(app.ui.audio.buffer_size, Some(256)); assert!(!app.engine.session().edit.delay_compensation); assert!(!app.ui.show_playback_engine, "a programmatic call does not open the window"); - assert_eq!(r["applied"], true); assert_eq!(r["delay_compensation"], false); let player = app.player.as_ref().unwrap(); + assert_eq!(r["applied"], !player.silent); assert_eq!(r["device"], json!(player.device_name)); assert_eq!(r["buffer_size"], json!(player.device_buffer)); assert_eq!(r["sample_rate"], player.device_rate); + assert_eq!(r["bit_depth"], json!(bit_depth)); // 300 is in range and is not rounded onto the window's power-of-two list. let mut via_window_command = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); From 5c8654b2d7b766a5a36db442ce591791b3dfd134 Mon Sep 17 00:00:00 2001 From: Sander de Wijs Date: Thu, 29 Oct 2026 10:46:26 +0100 Subject: [PATCH 052/113] Split the playback engine device test under the cognitive complexity limit Upstream now denies clippy::cognitive_complexity (#1129). The test is split into refusals, applying, and parity with ui.audio_device; the assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/audio_device.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/it/audio_device.rs b/crates/ui-egui/tests/it/audio_device.rs index 69735cb6..7294b3e9 100644 --- a/crates/ui-egui/tests/it/audio_device.rs +++ b/crates/ui-egui/tests/it/audio_device.rs @@ -26,9 +26,13 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() { assert!(r["device"].is_object(), "{r}"); } +fn new_app() -> SoundApp { + SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()) +} + #[test] -fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { - let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); +fn playback_engine_refuses_an_invalid_or_untimely_device_choice_without_side_effects() { + let mut app = new_app(); let before = app.ui.audio.clone(); let bit_depth = app.engine.session().bit_depth; assert!(!app.ui.show_playback_engine); @@ -54,7 +58,12 @@ fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { assert!(err.contains("`device` must be a string or null"), "{err}"); assert_eq!(app.ui.audio, before, "an invalid device changes nothing"); assert!(app.engine.session().edit.delay_compensation, "an invalid device does not apply delay compensation"); +} +#[test] +fn playback_engine_applies_the_device_choice_and_reports_the_open_device() { + let mut app = new_app(); + let bit_depth = app.engine.session().bit_depth; let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap(); assert_eq!(app.ui.audio.buffer_size, Some(256)); assert!(!app.engine.session().edit.delay_compensation); @@ -66,9 +75,15 @@ fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { assert_eq!(r["buffer_size"], json!(player.device_buffer)); assert_eq!(r["sample_rate"], player.device_rate); assert_eq!(r["bit_depth"], json!(bit_depth)); +} + +#[test] +fn playback_engine_applies_the_same_device_choice_as_the_audio_command() { + let mut app = new_app(); + app.run("setup.playback_engine", json!({"delay_compensation": false})).unwrap(); // 300 is in range and is not rounded onto the window's power-of-two list. - let mut via_window_command = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let mut via_window_command = new_app(); via_window_command.run("ui.audio_device", json!({"buffer_size": 300, "output": "Elsewhere"})).unwrap(); app.run("setup.hardware", json!({"buffer_size": 300, "device": "Elsewhere"})).unwrap(); assert_eq!(app.ui.audio.buffer_size, Some(300)); From 5dbebaf6170abd2852be04a857e34cddf4a06c0f Mon Sep 17 00:00:00 2001 From: Luigi Pinna Date: Thu, 29 Oct 2026 10:46:43 +0100 Subject: [PATCH 053/113] Contributors: add luigiPinna to people.toml --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index ab7b9de3..8bf75dab 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -61,3 +61,7 @@ display_name = "Matt Smith-Burlage" [people.ProdByBuddha] sync_github_name = true + +[people.luigiPinna] +real_name = "Luigi Pinna" +sync_github_name = true From d9c293a92b16ae08b051a0649466ba52fe459d80 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:05:31 +0000 Subject: [PATCH 054/113] Use saturating arithmetic in H.264 POC type 1/2 computation Found while testing crates/video/src/h264/slice.rs:485-490 (PocState::compute, pic_order_cnt_type 1) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/video/src/h264/params.rs | 59 +++++++++++++++++++++++++++++++++ crates/video/src/h264/slice.rs | 20 +++++------ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/crates/video/src/h264/params.rs b/crates/video/src/h264/params.rs index 0970a106..d902efe3 100644 --- a/crates/video/src/h264/params.rs +++ b/crates/video/src/h264/params.rs @@ -909,4 +909,63 @@ mod tests { fn huge_pic_init_qp_is_an_error() { assert_eq!(invalid_pps(pps(None, 0, i32::MAX)), "pic_init_qp_minus26 out of range"); } + + #[test] + fn poc_extreme_offsets_do_not_overflow() { + use crate::h264::slice::{PocState, SliceHeader, SliceType}; + let mut s = sps(SpsFields::default()).unwrap(); + s.log2_max_frame_num = 16; + s.offset_for_ref_frame = vec![i32::MAX, i32::MAX]; + s.offset_for_non_ref_pic = i32::MAX; + s.offset_for_top_to_bottom_field = i32::MAX; + // Drive FrameNumOffset up to the i32 limit by wrapping frame_num repeatedly. + let mut state = PocState::default(); + for _ in 0..32_767 { + state.update_gap_frame(5, &s); + state.update_gap_frame(1, &s); + } + // prev_frame_num > frame_num below, so the next picture adds max_frame_num once more. + state.update_gap_frame(5, &s); + for (poc_type, nal_ref_idc, st) in [1u32, 2].into_iter().flat_map(|t| [1u8, 0].into_iter().flat_map(move |r| [(t, r, false), (t, r, true)])) { + s.pic_order_cnt_type = poc_type; + let state = if st { state.clone() } else { PocState::default() }; + let sh = SliceHeader { + nal_ref_idc, + nal_unit_type: 1, + idr: false, + first_mb_in_slice: 0, + slice_type_raw: 0, + slice_type: SliceType::P, + pps_id: 0, + colour_plane_id: 0, + frame_num: 2, + field_pic: false, + bottom_field: false, + idr_pic_id: 0, + pic_order_cnt_lsb: 0, + delta_pic_order_cnt_bottom: 0, + delta_pic_order_cnt: [i32::MAX, i32::MAX], + redundant_pic_cnt: 0, + direct_spatial_mv_pred: false, + num_ref_idx_active: [1, 0], + ref_pic_list_mod: [Vec::new(), Vec::new()], + pred_weight_table: None, + no_output_of_prior_pics: false, + long_term_reference: false, + adaptive_ref_pic_marking: false, + mmcos: Vec::new(), + cabac_init_idc: 0, + slice_qp_delta: 0, + sp_for_switch: false, + slice_qs_delta: 0, + disable_deblocking_filter_idc: 0, + slice_alpha_c0_offset_div2: 0, + slice_beta_offset_div2: 0, + slice_group_change_cycle: 0, + header_bits: 0, + }; + // Must not panic; the value itself is meaningless for such a stream. + let _ = state.compute(&sh, &s); + } + } } diff --git a/crates/video/src/h264/slice.rs b/crates/video/src/h264/slice.rs index 06a2bc67..ba936281 100644 --- a/crates/video/src/h264/slice.rs +++ b/crates/video/src/h264/slice.rs @@ -475,7 +475,7 @@ impl PocState { 1 => { let frame_num_offset = self.frame_num_offset(sh.idr, sh.frame_num, sps); let n = sps.offset_for_ref_frame.len() as i32; - let mut abs_frame_num = if n != 0 { frame_num_offset + sh.frame_num as i32 } else { 0 }; + let mut abs_frame_num = if n != 0 { frame_num_offset.saturating_add(sh.frame_num as i32) } else { 0 }; if sh.nal_ref_idc == 0 && abs_frame_num > 0 { abs_frame_num -= 1; } @@ -483,17 +483,17 @@ impl PocState { if abs_frame_num > 0 { let cycle_cnt = (abs_frame_num - 1) / n; let in_cycle = (abs_frame_num - 1) % n; - let delta_cycle: i32 = sps.offset_for_ref_frame.iter().sum(); - expected = cycle_cnt.wrapping_mul(delta_cycle); + let delta_cycle: i32 = sps.offset_for_ref_frame.iter().fold(0i32, |a, &o| a.saturating_add(o)); + expected = cycle_cnt.saturating_mul(delta_cycle); for &o in &sps.offset_for_ref_frame[..=in_cycle as usize] { - expected += o; + expected = expected.saturating_add(o); } } if sh.nal_ref_idc == 0 { - expected += sps.offset_for_non_ref_pic; + expected = expected.saturating_add(sps.offset_for_non_ref_pic); } - let top = expected + sh.delta_pic_order_cnt[0]; - let bottom = top + sps.offset_for_top_to_bottom_field + sh.delta_pic_order_cnt[1]; + let top = expected.saturating_add(sh.delta_pic_order_cnt[0]); + let bottom = top.saturating_add(sps.offset_for_top_to_bottom_field).saturating_add(sh.delta_pic_order_cnt[1]); Poc { top, bottom, msb: 0, frame_num_offset } } _ => { @@ -501,9 +501,9 @@ impl PocState { let temp = if sh.idr { 0 } else if sh.nal_ref_idc == 0 { - 2 * (frame_num_offset + sh.frame_num as i32) - 1 + 2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32)).saturating_sub(1) } else { - 2 * (frame_num_offset + sh.frame_num as i32) + 2i32.saturating_mul(frame_num_offset.saturating_add(sh.frame_num as i32)) }; Poc { top: temp, bottom: temp, msb: 0, frame_num_offset } } @@ -514,7 +514,7 @@ impl PocState { if idr { 0 } else if self.prev_frame_num > frame_num { - self.prev_frame_num_offset + sps.max_frame_num() as i32 + self.prev_frame_num_offset.saturating_add(sps.max_frame_num() as i32) } else { self.prev_frame_num_offset } From 986fb951dff4fbe7a04d292ac6f9f8abd92335a6 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:10:58 +0800 Subject: [PATCH 055/113] Avoid cloning sessions for dirty-state comparisons --- crates/engine/src/dirty.rs | 265 +++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 43 ++++-- 2 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/dirty.rs diff --git a/crates/engine/src/dirty.rs b/crates/engine/src/dirty.rs new file mode 100644 index 00000000..ea98825f --- /dev/null +++ b/crates/engine/src/dirty.rs @@ -0,0 +1,265 @@ +//! Borrowed document equality for the saved-state indicator. + +use soundcraft_model::{EditState, Session}; + +/// Equal apart from the selection, playhead and zoom, without copying document data. +pub(super) fn same_document(saved: &Session, doc: &Session) -> bool { + document_values(saved, &saved.edit) == document_values(doc, &saved.edit) +} + +fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a { + // No `..`: adding a document field must also decide how it affects saved-state equality. + let Session { + format_version, + name, + sample_rate, + bit_depth, + frame_rate, + timecode_start, + tempo, + key_signatures, + tracks, + sources, + videos, + busses, + outputs, + markers, + groups, + auto_names, + edit, + next_id, + comments, + midi_originals, + pool, + folder, + } = s; + // Tuples implement PartialEq up to twelve elements. These contain only borrowed fields. + ( + (format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses), + (outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder), + ) +} + +fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a { + let EditState { + tool, + edit_mode, + selection: _, + selected_tracks: _, + selected_clips: _, + selected_sources, + playhead: _, + grid, + nudge, + grid_lines, + main_counter, + sub_counter, + zoom: _, + zoom_toggle, + rulers, + link_timeline_edit, + link_track_edit, + insertion_follows_playback, + loop_playback, + pre_post_roll, + pre_roll, + post_roll, + tab_to_transient, + automation_follows_edit, + markers_follow_edit, + layered_editing, + mirrored_midi, + click, + countoff, + countoff_bars, + midi_merge, + record_mode, + loop_record, + quickpunch, + scrolling, + solo_mode, + pre_fader_metering, + delay_compensation, + keyboard_focus, + timeline_selection: _, + flags, + values, + } = edit; + // Borrow the saved navigation on both sides instead of overwriting a document clone. + // Keeping these values in the comparison also preserves PartialEq for non-finite zooms. + ( + ( + tool, + edit_mode, + &navigation.selection, + &navigation.selected_tracks, + &navigation.selected_clips, + selected_sources, + &navigation.playhead, + grid, + nudge, + grid_lines, + main_counter, + sub_counter, + ), + ( + &navigation.zoom, + zoom_toggle, + rulers, + link_timeline_edit, + link_track_edit, + insertion_follows_playback, + loop_playback, + pre_post_roll, + pre_roll, + post_roll, + tab_to_transient, + automation_follows_edit, + ), + ( + markers_follow_edit, + layered_editing, + mirrored_midi, + click, + countoff, + countoff_bars, + midi_merge, + record_mode, + loop_record, + quickpunch, + scrolling, + solo_mode, + ), + (pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackId, TrackKind, ZoomToggle}; + use soundcraft_time::{Range, TimeFormat}; + use std::sync::Arc; + + // The previous comparison is an oracle for preserving the saved-state policy. + fn previous_same_document(saved: &Session, doc: &Session) -> bool { + let mut doc = doc.clone(); + let (from, to) = (&saved.edit, &mut doc.edit); + to.selection = from.selection; + to.timeline_selection = from.timeline_selection; + to.selected_tracks.clone_from(&from.selected_tracks); + to.selected_clips.clone_from(&from.selected_clips); + to.playhead = from.playhead; + to.zoom = from.zoom; + *saved == doc + } + + fn check(saved: &Session, doc: &Session, expected: bool, field: &str) { + assert_eq!(same_document(saved, doc), expected, "{field}"); + assert_eq!(same_document(doc, saved), expected, "{field}, reversed"); + assert_eq!(same_document(saved, doc), previous_same_document(saved, doc), "{field}, previous policy"); + } + + #[test] + fn navigation_fields_are_ignored() { + let saved = Session::default(); + let mut doc = saved.clone(); + doc.edit.selection = Range::new(100, 200); + doc.edit.timeline_selection = Range::new(300, 400); + doc.edit.selected_tracks = vec![TrackId(50)]; + doc.edit.selected_clips = vec![ClipId(60)]; + doc.edit.playhead = 500; + doc.edit.zoom.samples_per_px = 64.0; + doc.edit.zoom.scroll = 600; + doc.edit.zoom.waveform_zoom = 2.0; + doc.edit.zoom.presets = [1.0; 5]; + check(&saved, &doc, true, "navigation"); + } + + #[test] + fn persisted_edit_settings_still_change_document_equality() { + let saved = Session::default(); + let edits: &[(&str, fn(&mut EditState))] = &[ + ("tool", |e| e.tool = soundcraft_model::Tool::Grabber), + ("edit_mode", |e| e.edit_mode = soundcraft_model::EditMode::Grid), + ("selected_sources", |e| e.selected_sources.push(SourceId(1))), + ("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)), + ("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)), + ("grid_lines", |e| e.grid_lines = !e.grid_lines), + ("main_counter", |e| e.main_counter = TimeFormat::Samples), + ("sub_counter", |e| e.sub_counter = None), + ("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })), + ("rulers", |e| e.rulers.clear()), + ("link_timeline_edit", |e| e.link_timeline_edit = !e.link_timeline_edit), + ("link_track_edit", |e| e.link_track_edit = !e.link_track_edit), + ("insertion_follows_playback", |e| e.insertion_follows_playback = !e.insertion_follows_playback), + ("loop_playback", |e| e.loop_playback = !e.loop_playback), + ("pre_post_roll", |e| e.pre_post_roll = !e.pre_post_roll), + ("pre_roll", |e| e.pre_roll += 1), + ("post_roll", |e| e.post_roll += 1), + ("tab_to_transient", |e| e.tab_to_transient = !e.tab_to_transient), + ("automation_follows_edit", |e| e.automation_follows_edit = !e.automation_follows_edit), + ("markers_follow_edit", |e| e.markers_follow_edit = !e.markers_follow_edit), + ("layered_editing", |e| e.layered_editing = !e.layered_editing), + ("mirrored_midi", |e| e.mirrored_midi = !e.mirrored_midi), + ("click", |e| e.click = !e.click), + ("countoff", |e| e.countoff = !e.countoff), + ("countoff_bars", |e| e.countoff_bars += 1), + ("midi_merge", |e| e.midi_merge = !e.midi_merge), + ("record_mode", |e| e.record_mode = "destructive".into()), + ("loop_record", |e| e.loop_record = !e.loop_record), + ("quickpunch", |e| e.quickpunch = !e.quickpunch), + ("scrolling", |e| e.scrolling = "continuous".into()), + ("solo_mode", |e| e.solo_mode = "afl".into()), + ("pre_fader_metering", |e| e.pre_fader_metering = !e.pre_fader_metering), + ("delay_compensation", |e| e.delay_compensation = !e.delay_compensation), + ("keyboard_focus", |e| e.keyboard_focus = "midi".into()), + ("flags", |e| { + e.flags.insert("test.flag".into()); + }), + ("values", |e| { + e.values.insert("test.value".into(), 0.5); + }), + ]; + for &(field, edit) in edits { + let mut doc = saved.clone(); + edit(&mut doc.edit); + check(&saved, &doc, false, field); + } + } + + #[test] + fn track_and_media_changes_still_change_document_equality() { + let mut saved = Session::default(); + let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100); + saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip); + let audio = + Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] })); + saved.pool.insert(SourceId(900), audio); + check(&saved, &saved.clone(), true, "shared decoded audio"); + let mut doc = saved.clone(); + doc.track_mut(track).unwrap().playlist_mut().unwrap().clips[0].gain_db = -6.0; + check(&saved, &doc, false, "clip gain"); + let mut doc = saved.clone(); + doc.pool.remove(SourceId(900)); + check(&saved, &doc, false, "decoded audio removed"); + let mut doc = saved.clone(); + doc.folder = Some(std::path::PathBuf::from("session")); + check(&saved, &doc, false, "session folder"); + let mut doc = saved.clone(); + doc.edit.values.insert("test.nan".into(), f64::NAN); + assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior"); + } + + #[test] + fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() { + let mut saved = Session::default(); + let doc = saved.clone(); + saved.edit.zoom.waveform_zoom = f32::NAN; + assert!(!same_document(&saved, &doc)); + assert!(same_document(&doc, &saved)); + assert_eq!(same_document(&saved, &doc), previous_same_document(&saved, &doc)); + assert_eq!(same_document(&doc, &saved), previous_same_document(&doc, &saved)); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b0ec0939..8749e536 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod catalog_more; pub mod clip_group_file; pub mod cmd; pub mod demo; +mod dirty; pub mod edit; pub mod file_access; pub mod inspect; @@ -23,6 +24,8 @@ use soundcraft_time::Samples; use std::cell::Cell; use std::sync::Arc; +use dirty::same_document; + pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use soundcraft_model as model; @@ -511,19 +514,6 @@ impl Engine { } } -/// Equal apart from what changes without editing: the selection, the playhead and the zoom. -fn same_document(saved: &Session, doc: &Session) -> bool { - let mut doc = doc.clone(); - let (from, to) = (&saved.edit, &mut doc.edit); - to.selection = from.selection; - to.timeline_selection = from.timeline_selection; - to.selected_tracks.clone_from(&from.selected_tracks); - to.selected_clips.clone_from(&from.selected_clips); - to.playhead = from.playhead; - to.zoom = from.zoom; - *saved == doc -} - fn install_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { @@ -607,6 +597,33 @@ mod dirty_tests { e.undo(); assert!(!e.is_dirty()); } + + #[test] + fn dirty_state_follows_save_undo_and_redo_after_navigation() { + let mut e = Engine::default(); + let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None); + e.mark_clean(); + e.execute("mix.volume", &json!({"track": track.0, "db": -6.0})).unwrap(); + assert!(e.is_dirty()); + // A repeated check uses the revision cache. + assert!(e.is_dirty()); + e.mark_clean(); + assert!(!e.is_dirty()); + e.execute("edit.select", &json!({"tracks": [track.0], "start": 100, "end": 200})).unwrap(); + e.execute("transport.locate", &json!({"at": 300})).unwrap(); + e.execute("view.zoom_in", &json!({})).unwrap(); + assert!(!e.is_dirty()); + assert!(e.undo()); + assert!(e.is_dirty(), "undoing away from the saved fader value is dirty"); + assert!(e.redo()); + assert!(!e.is_dirty(), "redoing to the saved fader value is clean despite navigation changes"); + e.execute("mix.volume", &json!({"track": track.0, "db": -12.0})).unwrap(); + assert!(e.is_dirty()); + assert!(e.undo()); + assert!(!e.is_dirty()); + assert!(e.redo()); + assert!(e.is_dirty()); + } } #[cfg(test)] From f16e312fbc6f3f28a04c765db17469295c56c8ca Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:54:30 +0100 Subject: [PATCH 056/113] Return Error::Invalid for overflowing H.264 slice header values Found while testing crates/video/src/h264/slice.rs:202-204 (SliceHeader::parse) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/video/src/h264/slice.rs | 98 ++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/video/src/h264/slice.rs b/crates/video/src/h264/slice.rs index 06a2bc67..5aec1207 100644 --- a/crates/video/src/h264/slice.rs +++ b/crates/video/src/h264/slice.rs @@ -1,7 +1,7 @@ //! NAL unit header and slice header parsing (7.3.1, 7.3.3). use crate::bitstream::BitReader; -use crate::h264::error::{Result, ensure}; +use crate::h264::error::{Error, Result, ensure}; use crate::h264::params::{Pps, Sps}; /// NAL unit types used by the decoder. @@ -199,9 +199,9 @@ impl SliceHeader { let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active]; if !slice_type.is_intra() { if r.read_flag()? { - num_ref_idx_active[0] = r.read_ue()? + 1; + num_ref_idx_active[0] = plus_one(r.read_ue()?)?; if slice_type.is_b() { - num_ref_idx_active[1] = r.read_ue()? + 1; + num_ref_idx_active[1] = plus_one(r.read_ue()?)?; } } let max = if field_pic { 32 } else { 16 }; @@ -223,7 +223,7 @@ impl SliceHeader { ensure!(cabac_init_idc <= 2, "cabac_init_idc out of range"); } let slice_qp_delta = r.read_se()?; - let qp = pps.pic_init_qp + slice_qp_delta; + let qp = pps.pic_init_qp.checked_add(slice_qp_delta).ok_or_else(|| Error::Invalid("slice QP out of range".to_string()))?; ensure!((0..=51).contains(&qp), "slice QP {qp} out of range"); let mut sp_for_switch = false; let mut slice_qs_delta = 0; @@ -308,6 +308,11 @@ struct RefFields { mmcos: Vec, } +/// `num_ref_idx_active_minus1 + 1`, or the error its range check gives. +fn plus_one(v: u32) -> Result { + v.checked_add(1).ok_or_else(|| Error::Invalid("num_ref_idx_active out of range".to_string())) +} + fn parse_reference_fields( r: &mut BitReader<'_>, nal: NalHeader, @@ -549,3 +554,88 @@ impl PocState { self.prev_frame_num = frame_num; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitstream::BitWriter; + + fn sps_pps() -> (Sps, Pps) { + let mut w = BitWriter::new(); + w.write_bits(66, 8); + w.write_bits(0xC0, 8); + w.write_bits(30, 8); + w.write_ue(0); + w.write_ue(0); + w.write_ue(2); + w.write_ue(1); + w.write_bit(false); + w.write_ue(10); + w.write_ue(8); + w.write_bit(true); + w.write_bit(true); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + let sps = Sps::parse(&w.finish()).unwrap(); + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bit(false); + w.write_ue(0); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bits(0, 2); + w.write_se(0); + w.write_se(0); + w.write_se(0); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + let pps = Pps::parse(&w.finish(), &[Some(sps.clone())]).unwrap(); + (sps, pps) + } + + /// Parse a non-IDR slice header of `slice_type` whose override flag is set with the given counts. + fn parse_override(slice_type: u32, l0_minus1: u32, l1_minus1: u32, qp_delta: i32) -> Result { + let (sps, pps) = sps_pps(); + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(slice_type); + w.write_ue(0); + w.write_bits(0, 4); + if slice_type == 1 { + w.write_bit(true); // direct_spatial_mv_pred + } + w.write_bit(true); // num_ref_idx_active_override_flag + w.write_ue(l0_minus1); + if slice_type == 1 { + w.write_ue(l1_minus1); + } + w.write_bit(false); // ref_pic_list_modification_flag_l0 + if slice_type == 1 { + w.write_bit(false); + } + w.write_bit(false); // adaptive_ref_pic_marking_mode_flag + w.write_se(qp_delta); + w.rbsp_trailing(); + let nal = NalHeader { nal_ref_idc: 1, nal_unit_type: 1 }; + SliceHeader::parse(&w.finish(), nal, |_| Ok((&pps, &sps))).map(|(h, _, _)| h) + } + + #[test] + fn huge_num_ref_idx_override_is_invalid() { + assert!(matches!(parse_override(0, u32::MAX, 0, 0), Err(crate::h264::Error::Invalid(_)))); + assert!(matches!(parse_override(1, 0, u32::MAX, 0), Err(crate::h264::Error::Invalid(_)))); + assert!(matches!(parse_override(0, 16, 0, 0), Err(crate::h264::Error::Invalid(_)))); + assert_eq!(parse_override(0, 15, 0, 0).unwrap().num_ref_idx_active, [16, 0]); + } + + #[test] + fn huge_slice_qp_delta_is_invalid() { + assert!(matches!(parse_override(0, 0, 0, i32::MAX), Err(crate::h264::Error::Invalid(_)))); + } +} From 17d553a7a72587eb14787817a5110022ee9c60c9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:56:21 -0400 Subject: [PATCH 057/113] Refuse absolute audio paths in clip group files A bare group path leaves the folder empty, so an absolute audio path passed the starts_with check. Reject root and prefix components too, as CodeRabbit noted, and cover it in the regression test. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_group_file.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 93639fa2..c2e0bdb9 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -92,7 +92,7 @@ fn decode_group_audio( if !full.starts_with(dir) || Path::new(rel) .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) + .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_))) { return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"))); } @@ -262,6 +262,12 @@ mod tests { std::fs::write(&path, file.to_string()).unwrap(); let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"})); assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "{r:?}"); + for rel in file["audio"].as_object_mut().unwrap().values_mut() { + *rel = json!(dir.join("Wait... what_0.wav").to_string_lossy()); + } + std::fs::write(&path, file.to_string()).unwrap(); + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"})); + assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}"); let _ = std::fs::remove_dir_all(&dir); } From f39413033eb996010f598051e294fc8190971b25 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:57:36 -0400 Subject: [PATCH 058/113] Cover the start bound of trim_clip on a stretched clip Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit/tests.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/engine/src/edit/tests.rs b/crates/engine/src/edit/tests.rs index f4f2b0a2..716624a3 100644 --- a/crates/engine/src/edit/tests.rs +++ b/crates/engine/src/edit/tests.rs @@ -383,3 +383,20 @@ fn trim_clip_bounds_stretched_clip_to_its_file() { assert_eq!(c.end(), want, "stretch {stretch}"); } } + +#[test] +fn trim_clip_start_bound_scales_the_offset_by_stretch() { + let (mut s, t) = session_with_clip(); + let id = { + let c = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()).unwrap(); + c.stretch = 2.0; + c.start = 10_000; + c.length = 1000; + if let ClipContent::Audio { offset, .. } = &mut c.content { + *offset = 1000; + } + c.id + }; + let c = trim_clip(&mut s, id, Some(0), None, false).unwrap(); + assert_eq!(c.start, 8000); +} From 4e9d77873429f6366c34eb05527c3e48ae91c9ac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:58:10 -0400 Subject: [PATCH 059/113] Make the fade clamp test reach the fade-out bound too A 10-sample fade-out was already shorter than the processed clip, so the fade-out clamp went untested. Use 40, as CodeRabbit suggested. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/audiosuite.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index a4e107ed..0f7ba25f 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -253,13 +253,13 @@ mod tests { { let c = e.session_mut().find_clip_mut(id).unwrap(); c.fade_in.len = 60; - c.fade_out.len = 10; + c.fade_out.len = 40; } e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap(); let c = e.session().tracks[0].clips()[0].clone(); assert!(c.length < 64, "{}", c.length); assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length); - assert_eq!(c.fade_out.len, 10.min(c.length)); + assert_eq!(c.fade_out.len, 40.min(c.length)); } #[test] From af4970e388fd9714e33d200ed5ef35bbdb7f2a85 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:58:53 -0400 Subject: [PATCH 060/113] Saturate the reference POC LSB after a memory management op 5 A saturated top POC with a negative bottom field could still overflow here, as CodeRabbit noted. Co-Authored-By: Claude Opus 5.5 --- crates/video/src/h264/slice.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/video/src/h264/slice.rs b/crates/video/src/h264/slice.rs index 0e9b3465..02bcb0ba 100644 --- a/crates/video/src/h264/slice.rs +++ b/crates/video/src/h264/slice.rs @@ -532,7 +532,7 @@ impl PocState { if sh.nal_ref_idc != 0 { if mmco5 { self.prev_ref_msb = 0; - self.prev_ref_lsb = poc.top - poc.frame(); + self.prev_ref_lsb = poc.top.saturating_sub(poc.frame()); } else { self.prev_ref_msb = poc.msb; self.prev_ref_lsb = sh.pic_order_cnt_lsb as i32; From 81ab012724e0f68b7821a90b345b57cea3fcce3d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 22:00:11 -0400 Subject: [PATCH 061/113] Take the journal as a VecDeque in assert_history_unchanged #995 makes the command journal a bounded VecDeque; the helper #1129 added still took a slice. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 892b5393..a0b9a2b6 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -1515,9 +1515,15 @@ fn editor_gesture_polling_survives_discarded_and_hidden_host_iterations() { } /// The engine's revision, undo history and journal are as they were. -fn assert_history_unchanged(app: &SoundApp, revision: u64, undo: usize, history: &[String], journal: &[(String, serde_json::Value)]) { +fn assert_history_unchanged( + app: &SoundApp, + revision: u64, + undo: usize, + history: &[String], + journal: &std::collections::VecDeque<(String, serde_json::Value)>, +) { assert_eq!(app.engine.revision, revision); assert_eq!(app.engine.undo_history().len(), undo); assert_eq!(app.engine.undo_history(), history); - assert_eq!(app.engine.journal, journal); + assert_eq!(&app.engine.journal, journal); } From 98fae0068863856517164b2869dd030b95595304 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:11:16 -0400 Subject: [PATCH 062/113] CI: workflows only call craft-common-actions at @main (#1190) AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and link or open a craft-common-actions issue (a PR only for a small fix that is safe for every app and does not increase CI time); legacy jobs aren't added to and move only as their own task, staying until a shared workflow runs them. windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on craft-common-actions main and the workflow (and the actions it calls) is identical there, so nothing changes in behaviour. --- .github/workflows/windows-arm64.yml | 4 ++-- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index eae50d1a..6514b657 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: soundcraft app-dir: SoundCraft diff --git a/AGENTS.md b/AGENTS.md index 76338a2b..b48565c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,3 +89,8 @@ Push to the `release` branch to build signed installers for macOS (universal DMG GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 9559f795..ac02bdd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,3 +3,8 @@ Read `AGENTS.md`: it is the entry point for agents in this repo. New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. From 072e548ec8c134d0bc495754affb3bd08f5dd0d2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:21:27 -0400 Subject: [PATCH 063/113] deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#1192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `zip`: 9.1.1 → 9.1.3 Lockfile only. --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b25f84b9..40a1d749 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,7 +272,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1533,7 +1533,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3244,7 +3244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3880,7 +3880,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3937,7 +3937,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4839,10 +4839,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5735,7 +5735,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6359,9 +6359,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.1" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", From 14a081304ba9386b10c50be2ba3fcb70c7fe4e1b Mon Sep 17 00:00:00 2001 From: ydking0911 Date: Fri, 30 Oct 2026 11:35:49 +0900 Subject: [PATCH 064/113] Clip menu: act on the whole edit selection when the right-clicked clip is part of it --- crates/ui-egui/src/edit_window/input.rs | 28 +++++- crates/ui-egui/tests/it/clip_menu.rs | 127 +++++++++++++++++++----- 2 files changed, 127 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/edit_window/input.rs b/crates/ui-egui/src/edit_window/input.rs index 88538b99..ad7a2225 100644 --- a/crates/ui-egui/src/edit_window/input.rs +++ b/crates/ui-egui/src/edit_window/input.rs @@ -511,7 +511,9 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { } return; }; - let ids = json!([c.0]); + // A clip of the edit selection acts on the whole selection (the commands default to it); any other clip acts alone. + let in_selection = clip_in_selection(app.engine.session(), c); + let target = if in_selection { json!({}) } else { json!({"clips": [c.0]}) }; for (label, cmd) in [ ("Mute/Unmute", "edit.mute_clips"), ("Edit Lock/Unlock", "clip.edit_lock"), @@ -522,7 +524,7 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { ("Send to Back", "clip.send_to_back"), ] { if ui.button(label).clicked() { - let _ = app.run(cmd, json!({"clips": ids})); + let _ = app.run(cmd, target.clone()); } } ui.separator(); @@ -531,7 +533,25 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { app.dialogs.open_rename_clip(c, &name); } if ui.button("Delete").clicked() { - let _ = app.run("edit.select", json!({"clips": ids})); - let _ = app.run("edit.clear", json!({})); + let selected: Vec = app.engine.session().edit.selected_clips.iter().map(|id| id.0).collect(); + if !in_selection { + let _ = app.run("edit.select", json!({"clips": [c.0]})); + let _ = app.run("edit.clear", json!({})); + } else if selected.is_empty() { + let _ = app.run("edit.clear", json!({})); + } else { + // Selected clips go by id (an empty range), so clips between them that the span covers stay. + let _ = app.run("edit.clear", json!({"clips": selected, "start": 0})); + } } } + +/// Whether `c` is part of the edit selection: one of the selected clips or, when no clip is selected, a clip on a +/// selected track that the selected range overlaps. An insertion point selects nothing. +fn clip_in_selection(s: &Session, c: ClipId) -> bool { + let ed = &s.edit; + if !ed.selected_clips.is_empty() { + return ed.selected_clips.contains(&c); + } + !ed.selection.is_empty() && s.find_clip(c).is_some_and(|(t, clip)| ed.selected_tracks.contains(&t) && clip.range().overlaps(&ed.selection)) +} diff --git a/crates/ui-egui/tests/it/clip_menu.rs b/crates/ui-egui/tests/it/clip_menu.rs index 8da61fd7..7330e319 100644 --- a/crates/ui-egui/tests/it/clip_menu.rs +++ b/crates/ui-egui/tests/it/clip_menu.rs @@ -1,6 +1,7 @@ //! Regression: the clip context menu must stay open while the pointer moves onto it, so its //! Delete item can be used. It used to vanish once the pointer left the lane, because the lane -//! returned early (no hover position) before the menu was drawn. +//! returned early (no hover position) before the menu was drawn. A right-click on a clip of the +//! edit selection acts on the whole selection (#1191). use egui::{Event, Modifiers, PointerButton, pos2, vec2}; use egui_kittest::Harness; @@ -24,15 +25,16 @@ fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) { s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); } -fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { +fn clip_count(h: &Harness<'_, SoundApp>, track: &str) -> usize { let s = h.state().engine.session(); - s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len()) + s.tracks.iter().find(|t| t.name == track).map_or(0, |t| t.clips().len()) } -/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete -/// removes one Kick clip. -#[test] -fn clip_menu_stays_open_while_moving_onto_delete() { +fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { + clip_count(h, "Kick") +} + +fn demo() -> Harness<'static, SoundApp> { let engine = soundcraft_engine::demo::demo_engine(); let app = SoundApp::new(engine, None, Services::default()); let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state( @@ -46,26 +48,103 @@ fn clip_menu_stays_open_while_moving_onto_delete() { for _ in 0..6 { step(&mut h); } + h +} + +/// Selects the whole session on `tracks`, as a drag across their clips does. +fn select_all_of(h: &mut Harness<'_, SoundApp>, tracks: &[&str]) { + h.state_mut().engine.execute("edit.select", &serde_json::json!({"tracks": tracks, "start": 0, "end": 48_000 * 600})).unwrap(); +} + +/// Right-clicks the Kick clip and clicks `item` in its menu, moving onto the item first as a real pointer would. +fn kick_menu(h: &mut Harness<'_, SoundApp>, item: &str) { + press(h, 880.0, 240.0, PointerButton::Secondary); + for _ in 0..10 { + step(h); + } + // Ask the newly laid-out menu where the item is instead of pinning coordinates that change with egui's item metrics. + let at = h.get_by_label(item).rect().center(); + h.state_mut().synthetic.push(Event::PointerMoved(at)); + for _ in 0..10 { + step(h); + } + press(h, at.x, at.y, PointerButton::Primary); + for _ in 0..15 { + step(h); + } +} + +/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete +/// removes one Kick clip. +#[test] +fn clip_menu_stays_open_while_moving_onto_delete() { + let mut h = demo(); let before = kick_clip_count(&h); assert!(before > 0, "the demo session should have a recorded Kick clip"); - // Right-click the Kick clip, then move the pointer into the menu and down to Delete. - press(&mut h, 880.0, 240.0, PointerButton::Secondary); - for _ in 0..10 { - step(&mut h); - } - // Move (no click) onto the menu's Delete item, as a real pointer would, so the lane loses - // its hover. Ask the newly laid-out menu where the item is instead of pinning coordinates - // that change with egui's item metrics. - let delete = h.get_by_label("Delete").rect().center(); - h.state_mut().synthetic.push(Event::PointerMoved(delete)); - for _ in 0..10 { - step(&mut h); - } - press(&mut h, delete.x, delete.y, PointerButton::Primary); - for _ in 0..15 { - step(&mut h); - } + kick_menu(&mut h, "Delete"); assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip"); } + +/// Delete on a clip of a drag selection clears the whole selection, as Edit > Clear does, in one undo step (#1191). +#[test] +fn delete_on_a_selected_clip_clears_the_whole_selection() { + let mut h = demo(); + let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare")); + assert!(snare > 1, "the demo session should have several Snare clips"); + select_all_of(&mut h, &["Kick", "Snare"]); + + kick_menu(&mut h, "Delete"); + + assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (0, 0), "every selected clip should be cleared"); + h.state_mut().engine.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick, snare), "one undo should bring them all back"); +} + +/// A clip outside the selection is still deleted on its own, and the selection's clips stay. +#[test] +fn delete_on_a_clip_outside_the_selection_removes_only_that_clip() { + let mut h = demo(); + let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare")); + select_all_of(&mut h, &["Snare"]); + + kick_menu(&mut h, "Delete"); + + assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick - 1, snare)); +} + +/// With clips selected one by one (Grabber shift-click), Delete removes exactly those clips, not the other clips +/// their span covers. +#[test] +fn delete_on_selected_clips_keeps_the_unselected_clips_between_them() { + let mut h = demo(); + let ids = |h: &Harness<'_, SoundApp>, track: &str| -> Vec { + let s = h.state().engine.session(); + s.tracks.iter().filter(|t| t.name == track).flat_map(|t| t.clips()).map(|c| c.id.0).collect() + }; + let (kick, snare) = (ids(&h, "Kick"), ids(&h, "Snare")); + let last_snare = *snare.last().unwrap(); + let clips: Vec = kick.iter().copied().chain([last_snare]).collect(); + h.state_mut().engine.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap(); + + kick_menu(&mut h, "Delete"); + + assert!(ids(&h, "Kick").is_empty(), "the selected Kick clips should be removed"); + let left: Vec = snare.iter().copied().filter(|id| *id != last_snare).collect(); + assert_eq!(ids(&h, "Snare"), left, "only the selected Snare clip should be removed"); +} + +/// The menu's other entries act on the whole selection too. +#[test] +fn mute_on_a_selected_clip_mutes_every_selected_clip() { + let mut h = demo(); + select_all_of(&mut h, &["Kick", "Snare"]); + + kick_menu(&mut h, "Mute/Unmute"); + + let s = h.state().engine.session(); + let clips: Vec<_> = s.tracks.iter().filter(|t| t.name == "Kick" || t.name == "Snare").flat_map(|t| t.clips()).collect(); + assert!(clips.len() > 2); + assert!(clips.iter().all(|c| c.muted), "every selected clip should be muted"); +} From 4740e8d4c438070ef8d4b725fa6ab69ea3914e55 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:32:25 +0000 Subject: [PATCH 065/113] Export only the MIDI notes a trimmed clip still plays Found while testing crates/engine/src/io.rs:350-362 (export_midi) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/io/midi.rs | 7 +++++-- crates/engine/tests/it/midi_offset.rs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io/midi.rs b/crates/engine/src/io/midi.rs index f97b5709..44f7be31 100644 --- a/crates/engine/src/io/midi.rs +++ b/crates/engine/src/io/midi.rs @@ -66,12 +66,15 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { for c in t.clips() { if let ClipContent::Midi { sequence } = &c.content { let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); - for n in &sequence.notes { + // Only what plays: notes starting inside the clip, cut at its end. + let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate); + for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) { let mut n = *n; n.start = n.start.saturating_add(base); + n.length = n.length.min(clip_end - n.start).max(1); seq.notes.push(n); } - for ev in &sequence.ctrls { + for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) { let mut ev = *ev; ev.tick = ev.tick.saturating_add(base); seq.ctrls.push(ev); diff --git a/crates/engine/tests/it/midi_offset.rs b/crates/engine/tests/it/midi_offset.rs index e4a1b1ba..8e6fb7ae 100644 --- a/crates/engine/tests/it/midi_offset.rs +++ b/crates/engine/tests/it/midi_offset.rs @@ -28,3 +28,25 @@ fn midi_track_offset_moves_the_notes_like_moving_the_clip() { e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap(); assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples"); } + +#[test] +fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() { + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Trim", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 62, "start_ticks": 800, "length_ticks": 960, "velocity": 100})).unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 2000, "length_ticks": 960, "velocity": 100})).unwrap(); + // 24000 samples is 960 ticks at 120 bpm. + let id = e.session().tracks[0].clips()[0].id; + e.session_mut().find_clip_mut(id).unwrap().trim_end_to(24_000); + + let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap(); + let smf = soundcraft_midi::read_smf(&bytes).unwrap(); + let notes = &smf.tracks[0].sequence.notes; + assert_eq!(notes.len(), 2, "only notes starting inside the clip are exported"); + let late = notes.iter().find(|n| n.pitch == 62).unwrap(); + assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it"); +} From 440999a51f9cd143cbecad20c7a0b4d1b6fc5f09 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:18:02 +0000 Subject: [PATCH 066/113] Play transport.play {from} at the requested position, not the selection Fixes #1069 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/transport.rs | 5 +++-- crates/engine/src/lib.rs | 2 ++ crates/ui-egui/src/app/playback.rs | 14 ++++++++++++++ crates/ui-egui/src/tests.rs | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/transport.rs b/crates/engine/src/cmd/transport.rs index 50827802..7246265e 100644 --- a/crates/engine/src/cmd/transport.rs +++ b/crates/engine/src/cmd/transport.rs @@ -9,9 +9,10 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| { if let Some(at) = position_param(e, "transport.play", p, "from")? { - e.transport_requests.push(TransportRequest::Locate(at.max(0))); + e.transport_requests.push(TransportRequest::PlayFrom(at.max(0))); + } else { + e.transport_requests.push(TransportRequest::Play); } - e.transport_requests.push(TransportRequest::Play); Ok(json!({})) }), cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7d21a704..40c3cee9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -84,6 +84,8 @@ pub enum TransportRequest { /// Stop the old session's transport without applying stop-time edits to the new document. ResetSession, Play, + /// Start playing at this position (or move there if already playing), ignoring the selection. + PlayFrom(Samples), Stop, TogglePlay, Record, diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 48ab859e..818e45e8 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -343,6 +343,20 @@ impl SoundApp { self.play_from_selection(); } } + TransportRequest::PlayFrom(at) => { + if self.is_playing() { + if let Some(p) = &self.player { + p.locate(at); + } + if let Some(sim) = &mut self.sim { + sim.0 = at; + } + self.last_write_at = i64::MIN; + self.engine.transport.position = at; + } else { + self.start_play(at, None, None); + } + } TransportRequest::Stop | TransportRequest::Pause => self.stop_play(), TransportRequest::TogglePlay => { if self.is_playing() { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index eccddcd3..e6ca84e9 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -62,6 +62,21 @@ fn new_session_cancels_play_queued_before_the_next_frame() { assert_eq!(app.engine.transport.position, 0); } +#[test] +fn play_from_starts_at_from_regardless_of_the_selection() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 7_632_000, "end": 7_680_000})).unwrap(); + app.run("transport.play", json!({"from": 4_560_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.engine.transport.position, 4_560_000 + 12_000); + + app.run("transport.play", json!({"from": 96_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.engine.transport.position, 96_000 + 12_000); +} + #[test] fn record_with_nothing_armed_says_what_to_do() { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); From ffb95ebcf3e01116131298015e19f9876cf6f2bb Mon Sep 17 00:00:00 2001 From: ydking0911 Date: Fri, 30 Oct 2026 13:47:53 +0900 Subject: [PATCH 067/113] Clip menu: pass the clicked clip's track when Delete removes selected clips by id --- crates/ui-egui/src/edit_window/input.rs | 9 ++++++--- crates/ui-egui/tests/it/clip_menu.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/edit_window/input.rs b/crates/ui-egui/src/edit_window/input.rs index ad7a2225..8529db9a 100644 --- a/crates/ui-egui/src/edit_window/input.rs +++ b/crates/ui-egui/src/edit_window/input.rs @@ -533,15 +533,18 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { app.dialogs.open_rename_clip(c, &name); } if ui.button("Delete").clicked() { - let selected: Vec = app.engine.session().edit.selected_clips.iter().map(|id| id.0).collect(); + let s = app.engine.session(); + let selected: Vec = s.edit.selected_clips.iter().map(|id| id.0).collect(); + let track: Vec = s.find_clip(c).map(|(t, _)| t.0).into_iter().collect(); if !in_selection { let _ = app.run("edit.select", json!({"clips": [c.0]})); let _ = app.run("edit.clear", json!({})); } else if selected.is_empty() { let _ = app.run("edit.clear", json!({})); } else { - // Selected clips go by id (an empty range), so clips between them that the span covers stay. - let _ = app.run("edit.clear", json!({"clips": selected, "start": 0})); + // Selected clips go by id (an empty range), so clips between them that the span covers stay. The clicked + // clip's track satisfies Clear's track check even when the track selection was emptied meanwhile. + let _ = app.run("edit.clear", json!({"clips": selected, "tracks": track, "start": 0})); } } } diff --git a/crates/ui-egui/tests/it/clip_menu.rs b/crates/ui-egui/tests/it/clip_menu.rs index 7330e319..bfac15b6 100644 --- a/crates/ui-egui/tests/it/clip_menu.rs +++ b/crates/ui-egui/tests/it/clip_menu.rs @@ -135,6 +135,26 @@ fn delete_on_selected_clips_keeps_the_unselected_clips_between_them() { assert_eq!(ids(&h, "Snare"), left, "only the selected Snare clip should be removed"); } +/// Selected clips are deleted even when the track selection was emptied after selecting them (Edit > Selection > +/// Remove Edit from Top keeps the clip selection). +#[test] +fn delete_on_selected_clips_works_without_a_track_selection() { + let mut h = demo(); + let kick = clip_count(&h, "Kick"); + let clips: Vec = { + let s = h.state().engine.session(); + s.tracks.iter().filter(|t| t.name == "Kick").flat_map(|t| t.clips()).map(|c| c.id.0).collect() + }; + let e = &mut h.state_mut().engine; + e.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap(); + e.execute("edit.remove_selection_top", &serde_json::json!({})).unwrap(); + assert!(e.session().edit.selected_tracks.is_empty() && !e.session().edit.selected_clips.is_empty()); + + kick_menu(&mut h, "Delete"); + + assert_eq!(clip_count(&h, "Kick"), kick - clips.len(), "the selected Kick clips should be removed"); +} + /// The menu's other entries act on the whole selection too. #[test] fn mute_on_a_selected_clip_mutes_every_selected_clip() { From 92e74f6e001d706ac7071c107d8578174f1d9974 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 01:28:31 -0400 Subject: [PATCH 068/113] Update prompt shows the app icon and accent color instead of a letter (#1198) UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1fa5c9, its colour in ArtCraft Launcher). The updater, the self-update helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer). craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434). --- Cargo.lock | 3 ++- Cargo.toml | 3 ++- apps/soundcraft/src/main.rs | 2 +- crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/src/updates_ui.rs | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40a1d749..17b6bed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1120,13 +1120,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", diff --git a/Cargo.toml b/Cargo.toml index 043ad467..be35f6f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # Video containers and bitstreams, shared with the other craft apps. craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. +craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 8e54a5bd..ddf57e05 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -185,7 +185,7 @@ fn app_icon() -> Option { fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so every start-up record (and the engine's panic hook, installed with the first diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2cdcec08..1618b315 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,6 +4,15 @@ //! executing commands. It can be replaced without touching anything below it. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// SoundCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png")), + accent: Some([0x1f, 0xa5, 0xc9]), + ..craft_update::suite::SOUNDCRAFT +}; + pub mod appearance; pub mod bounce; pub mod control; diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index fcc52541..98efc506 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -21,7 +21,7 @@ impl Default for Runtime { fn default() -> Self { Self { #[cfg(not(target_arch = "wasm32"))] - updater: Updater::new(&craft_update::suite::SOUNDCRAFT, env!("CARGO_PKG_VERSION")), + updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), #[cfg(not(target_arch = "wasm32"))] startup_check: false, #[cfg(not(target_arch = "wasm32"))] From 9f5f312051f3658586de9cd547675a65fbaa2a4e Mon Sep 17 00:00:00 2001 From: ydking0911 Date: Fri, 30 Oct 2026 14:30:58 +0900 Subject: [PATCH 069/113] Clear: close the gaps of clips removed by id in Shuffle --- crates/engine/src/cmd/edit.rs | 8 +------- crates/engine/src/edit.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/edit/tests.rs | 16 ++++++++++++++++ crates/engine/tests/it/edit_modes.rs | 16 ++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 98797a26..65c88f4a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -376,13 +376,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut n = 0; if range.is_empty() && !ids.is_empty() { - for t in &mut s.tracks { - if let Some(pl) = t.playlist_mut() { - let before = pl.clips.len(); - pl.clips.retain(|c| !ids.contains(&c.id)); - n += before - pl.clips.len(); - } - } + n = ops::remove_clips(s, &ids, sh); s.edit.selected_clips.clear(); } else { for t in &tracks { diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 9966a841..f83d199f 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -177,6 +177,33 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) removed } +/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the gaps they leave, +/// the latest gap first so the earlier ones stay where they were. Returns how many clips went. +pub fn remove_clips(s: &mut Session, ids: &[ClipId], shuffle: bool) -> usize { + let mut gaps: Vec<(TrackId, Range)> = Vec::new(); + for t in &mut s.tracks { + let track = t.id; + if let Some(pl) = t.playlist_mut() { + pl.clips.retain(|c| { + let gone = ids.contains(&c.id); + if gone { + gaps.push((track, c.range())); + } + !gone + }); + } + } + let n = gaps.len(); + if shuffle { + gaps.sort_by_key(|(t, r)| (t.0, std::cmp::Reverse(r.start))); + for (track, r) in gaps { + let moved = ripple(s, track, r.end, -r.len()); + follow_automation(s, track, r.end, moved); + } + } + n +} + /// Copy the material inside `range` (clips trimmed to the range), relative to `range.start`. pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec { let t = TimeCtx::of(s); diff --git a/crates/engine/src/edit/tests.rs b/crates/engine/src/edit/tests.rs index 716624a3..82d40a42 100644 --- a/crates/engine/src/edit/tests.rs +++ b/crates/engine/src/edit/tests.rs @@ -159,6 +159,22 @@ fn clear_slip_and_shuffle() { assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]); } +#[test] +fn remove_clips_closes_each_gap_in_shuffle_only() { + let split = |shuffle| { + let (mut s, t) = session_with_clip(); + separate_at(&mut s, t, 2000); + separate_at(&mut s, t, 3000); + separate_at(&mut s, t, 4000); + let ids: Vec = s.track(t).unwrap().clips().iter().map(|c| c.id).collect(); + // The first and third pieces: [1000, 2000) and [3000, 4000). + assert_eq!(remove_clips(&mut s, &[ids[0], ids[2]], shuffle), 2); + clips(&s, t) + }; + assert_eq!(split(false), vec![(2000, 1000, 2000), (4000, 1000, 4000)]); + assert_eq!(split(true), vec![(1000, 1000, 2000), (2000, 1000, 4000)]); +} + #[test] fn copy_paste_overwrites() { let (mut s, t) = session_with_clip(); diff --git a/crates/engine/tests/it/edit_modes.rs b/crates/engine/tests/it/edit_modes.rs index 09433057..17258d76 100644 --- a/crates/engine/tests/it/edit_modes.rs +++ b/crates/engine/tests/it/edit_modes.rs @@ -299,6 +299,22 @@ fn shuffle_clear_moves_automation_with_the_clips() { assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]); } +/// Clearing selected clips by id closes their gaps in Shuffle, as clearing their ranges does, and moves the +/// automation with the clips. +#[test] +fn shuffle_clear_by_clip_id_closes_each_gap() { + for (mode, want, want_points) in [ + ("shuffle", [(0, 10_000), (20_000, 10_000)], [0, 9_000, 20_000, 29_000]), + ("slip", [(0, 10_000), (40_000, 10_000)], [0, 9_000, 40_000, 49_000]), + ] { + let (mut e, t, ids) = long(mode, &[(0, 10_000, 0), (20_000, 10_000, 0), (30_000, 10_000, 0), (40_000, 10_000, 0)]); + set_points(&mut e, t, &[0, 9_000, 40_000, 49_000]); + run(&mut e, "edit.clear", json!({"clips": [ids[1].0, ids[2].0], "tracks": [t.0], "start": 0})); + assert_eq!(clips(&e, t), want, "{mode}"); + assert_eq!(points(&e, t), want_points, "{mode}"); + } +} + #[test] fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() { let (mut e, t, [a, _]) = engine("relative_grid"); From 7902d59b475c1df203b212ef3436cc80bee2ccf2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 02:31:43 -0400 Subject: [PATCH 070/113] UPD.1: honor app pretend versions in update checks (#1199) --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- contributors/people.toml | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17b6bed3..c5385e0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index be35f6f3..fcdb0331 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,8 +54,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # Video containers and bitstreams, shared with the other craft apps. craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. -craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } diff --git a/contributors/people.toml b/contributors/people.toml index 8bf75dab..1f6e43e8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,6 @@ sync_github_name = true [people.luigiPinna] real_name = "Luigi Pinna" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true From 5a1324b8bc2b0d74dcb969eb0d2e3c8a786f1cf7 Mon Sep 17 00:00:00 2001 From: ydking0911 Date: Fri, 30 Oct 2026 16:13:42 +0900 Subject: [PATCH 071/113] Clear: lift clips removed by id in Shuffle so crossfade partners don't overlap --- crates/engine/src/edit.rs | 33 +++++++++++++-------------------- crates/engine/src/edit/tests.rs | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index f83d199f..9fdb49f0 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -177,28 +177,21 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) removed } -/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the gaps they leave, -/// the latest gap first so the earlier ones stay where they were. Returns how many clips went. +/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the holes the movable +/// ones leave, as a Shuffle move lifts them (see [`lift_plan`]), so a crossfade partner keeps its material. Returns +/// how many clips went. pub fn remove_clips(s: &mut Session, ids: &[ClipId], shuffle: bool) -> usize { - let mut gaps: Vec<(TrackId, Range)> = Vec::new(); - for t in &mut s.tracks { - let track = t.id; - if let Some(pl) = t.playlist_mut() { - pl.clips.retain(|c| { - let gone = ids.contains(&c.id); - if gone { - gaps.push((track, c.range())); - } - !gone - }); + let ids: HashSet = ids.iter().copied().collect(); + let mut n = 0; + for track in s.tracks.iter().map(|t| t.id).collect::>() { + if shuffle { + n += lift_all(s, track, &ids).len(); } - } - let n = gaps.len(); - if shuffle { - gaps.sort_by_key(|(t, r)| (t.0, std::cmp::Reverse(r.start))); - for (track, r) in gaps { - let moved = ripple(s, track, r.end, -r.len()); - follow_automation(s, track, r.end, moved); + // Locked clips don't lift; they go without closing up, as everything does outside Shuffle. + if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { + let before = pl.clips.len(); + pl.clips.retain(|c| !ids.contains(&c.id)); + n += before - pl.clips.len(); } } n diff --git a/crates/engine/src/edit/tests.rs b/crates/engine/src/edit/tests.rs index 82d40a42..cd84d6f0 100644 --- a/crates/engine/src/edit/tests.rs +++ b/crates/engine/src/edit/tests.rs @@ -175,6 +175,24 @@ fn remove_clips_closes_each_gap_in_shuffle_only() { assert_eq!(split(true), vec![(1000, 1000, 2000), (2000, 1000, 4000)]); } +#[test] +fn remove_clips_in_shuffle_keeps_crossfade_partners_apart() { + // `(start, end)` of each clip; the second one goes. + let remove_second = |spans: &[(Samples, Samples)]| { + let (mut s, t) = session_with_clip(); + let ids: Vec = spans.iter().map(|_| s.new_clip_id()).collect(); + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips = ids.iter().zip(spans).map(|(id, (a, b))| Clip::audio(*id, "c", SourceId(500), *a, *a, b - a)).collect(); + } + assert_eq!(remove_clips(&mut s, &[ids[1]], true), 1); + clips(&s, t).into_iter().map(|(start, len, _)| (start, start + len)).collect::>() + }; + // Crossfaded with the clip before: the hole starts where that one ends. + assert_eq!(remove_second(&[(0, 1050), (950, 2000), (2000, 3000)]), vec![(0, 1050), (1050, 2050)]); + // Crossfaded with the clip after: the hole ends where that one starts. + assert_eq!(remove_second(&[(0, 1000), (1000, 2050), (1950, 3000), (4000, 5000)]), vec![(0, 1000), (1000, 2050), (3050, 4050)]); +} + #[test] fn copy_paste_overwrites() { let (mut s, t) = session_with_clip(); From 3bd3965c51e48348e697bd861a580fa2f725387e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:29:26 +0000 Subject: [PATCH 072/113] Reject oversized CAF cookie lengths Found while testing crates/audio-io/src/caf.rs:23 Co-Authored-By: GPT-6-Luna --- crates/audio-io/src/caf.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/audio-io/src/caf.rs b/crates/audio-io/src/caf.rs index 94ae7f04..9d3d9ccb 100644 --- a/crates/audio-io/src/caf.rs +++ b/crates/audio-io/src/caf.rs @@ -20,7 +20,7 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> { // Whether the packet table lists each packet's size and frame count (variable per packet). let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false); while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) { - let (body, size) = (pos + 12, size as i64); + let body = pos + 12; let used = match kind { b"desc" if size == 32 => { lists_sizes = be_u32(b, body + 16) == Some(0); @@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> { described = true; Some(32) } - b"data" if size == -1 => Some(4), + b"data" if size == u64::MAX => Some(4), b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20), b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?, - b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")), + b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")), b"desc" | b"pakt" | b"chan" => None, - _ => u64::try_from(size).ok(), + _ => Some(size), }; match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) { Some(next) if next < b.len() => pos = next, @@ -85,3 +85,14 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> { } None } + +#[cfg(test)] +mod tests { + #[test] + fn rejects_cookie_size_above_signed_range() { + let mut bytes = b"caff\0\x01\0\0kuki".to_vec(); + bytes.extend_from_slice(&(1u64 << 63).to_be_bytes()); + + assert!(super::check(&bytes, 0).is_err()); + } +} From 90e8f60bad69b06f5a0e3c81ce971a46d9c83ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:51:31 +0000 Subject: [PATCH 073/113] Reject skipped drop-frame timecode labels Found while testing crates/time/src/format.rs:120 Co-Authored-By: GPT-6-Luna --- crates/time/src/format.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index 65a338ee..3268ef0f 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -120,6 +120,12 @@ pub fn parse_position( if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) { return Err(err()); } + if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 { + let dropped = if rate.nominal() >= 60 { 4 } else { 2 }; + if tc.frames < dropped { + return Err(err()); + } + } Ok(tc.to_samples(rate, sr).saturating_sub(tc_start)) } TimeFormat::FeetFrames => { @@ -213,6 +219,20 @@ mod tests { assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err()); } + #[test] + fn drop_frame_skipped_labels_are_rejected() { + let (sr, map, _) = ctx(); + for (rate, skipped) in [ + (FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]), + (FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]), + ] { + for text in skipped { + assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}"); + } + assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok()); + } + } + #[test] fn bar_lengths() { let (sr, map, rate) = ctx(); From 8da40bef96db521705db4ba00de640fd985d74c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:58:10 +0000 Subject: [PATCH 074/113] Reject overflowing WAV byte rates Found while testing crates/audio-io/src/wav.rs:514 Co-Authored-By: GPT-6-Luna --- crates/audio-io/src/wav.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index 532c2460..9edbb3d8 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -503,6 +503,7 @@ pub(crate) fn header( ) -> Result> { let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?; let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?; + let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?; let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels)); let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM }; @@ -511,7 +512,7 @@ pub(crate) fn header( fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes()); fmt.extend_from_slice(&(channels as u16).to_le_bytes()); fmt.extend_from_slice(&sample_rate.to_le_bytes()); - fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes()); + fmt.extend_from_slice(&byte_rate.to_le_bytes()); fmt.extend_from_slice(&block_align.to_le_bytes()); fmt.extend_from_slice(&bits.to_le_bytes()); if extensible { @@ -560,3 +561,14 @@ pub(crate) fn header( head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes()); Ok(head) } + +#[cfg(test)] +mod tests { + use super::header; + + #[test] + fn rejects_byte_rate_overflow() { + let result = header(2, 1_100_000_000, (16, false), None, None, 0, false); + assert!(result.is_err(), "overflowing byte rate was accepted"); + } +} From ddfe991a8700bb741967f7effa61dc37c22d7f7c Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Sat, 31 Oct 2026 05:02:20 +0530 Subject: [PATCH 075/113] Reject overflowing FLAC vendor string lengths Vorbis comment pre-flight validation skipped the vendor string unread without checking whether its declared length fit inside the metadata block. An overflowing vendor length caused the comment count lookup to evaluate to None and return false from comments_overrun, allowing malformed buffers to bypass pre-flight rejection and reach Symphonia. Validate the vendor string length against the remaining bytes in the block before advancing, and add a regression test for Vorbis comment blocks with overflowing vendor lengths. --- crates/audio-io/src/flac.rs | 9 ++++++--- crates/audio-io/tests/it/malformed.rs | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 1aef23b9..8f489047 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -511,10 +511,13 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option { Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None }) } -/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds. -/// The vendor string is skipped unread. +/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than +/// the block holds. fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool { - let Some(vendor) = le_u32(b, body) else { return false }; + let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false }; + if vendor as usize > end - (body + 4) { + return true; + } let at = (body + 4).saturating_add(vendor as usize); let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false }; overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize)) diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index 0351725d..5e243b89 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -366,6 +366,7 @@ fn packets_longer_than_any_codec_uses_are_refused() { } } +/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed. #[test] fn flac_metadata_fields_beyond_their_block_are_refused() { let with_block = |kind: u8, body: &[u8]| { @@ -376,7 +377,8 @@ fn flac_metadata_fields_beyond_their_block_are_refused() { let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat(); assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300); let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat(); - for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] { + let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat(); + for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] { assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block"))); } } From cc02045a2d4f305673f30fe8e833d0076055ed01 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Sat, 31 Oct 2026 05:02:24 +0530 Subject: [PATCH 076/113] Contributors: add kk376 to people.toml --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 1f6e43e8..abd1e8d1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -68,3 +68,7 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.kk376] +real_name = "Kushagra Kumar" +sync_github_name = true From f7b0805de65444a6a80e6f1884d151ba0ffe8e3f Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Sat, 31 Oct 2026 05:35:09 +0530 Subject: [PATCH 077/113] fix(engine): bound MIDI import tempo and track floods (#890) import_midi_bytes capped meter changes at MAX_MIDI_METERS (10,000), but left imported tempo events and synth tracks unbounded. Dense MIDI files inserted unlimited tempo markers into the session tempo map, degrading linear tick-to-sample conversions across playback and audio processing, and instantiated up to 4,096 synth tracks for controller-only tracks. Add MAX_MIDI_TEMPOS (10,000) and MAX_MIDI_TRACKS (512) caps checked before session mutation, and collapse consecutive equal tempo events. --- crates/engine/src/io/midi.rs | 20 +++++++++++- crates/engine/src/io/render.rs | 56 +++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io/midi.rs b/crates/engine/src/io/midi.rs index f97b5709..9711eccd 100644 --- a/crates/engine/src/io/midi.rs +++ b/crates/engine/src/io/midi.rs @@ -2,18 +2,36 @@ use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Sampl /// Most meter changes a MIDI file may bring into the tempo map; scores have a handful. pub(super) const MAX_MIDI_METERS: usize = 10_000; +/// Most tempo changes a MIDI file may bring into the tempo map: conversions walk the tempo events. +pub(super) const MAX_MIDI_TEMPOS: usize = 10_000; +/// Most synth tracks a MIDI file may create in the session. +pub(super) const MAX_MIDI_TRACKS: usize = 512; /// Import a Standard MIDI File as MIDI tracks (one per SMF track). pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result> { let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?; - let s = e.session_mut(); if tempo_map { // Each meter is placed by walking the ones before it. if smf.meters.len() > MAX_MIDI_METERS { return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes"))); } + if smf.tempos.len() > MAX_MIDI_TEMPOS { + return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TEMPOS} tempo changes"))); + } + } + let tracks_to_import = smf.tracks.iter().filter(|tr| !tr.sequence.notes.is_empty() || !tr.sequence.ctrls.is_empty()).count(); + if tracks_to_import > MAX_MIDI_TRACKS { + return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks"))); + } + let s = e.session_mut(); + if tempo_map { + let mut last_bpm = None; for (tick, bpm) in &smf.tempos { + if last_bpm == Some(*bpm) { + continue; + } let _ = s.tempo.set_tempo(*tick, *bpm); + last_bpm = Some(*bpm); } for (tick, n, d) in &smf.meters { let _ = s.tempo.set_meter(*tick, u32::from(*n), u32::from(*d)); diff --git a/crates/engine/src/io/render.rs b/crates/engine/src/io/render.rs index 5d0f0f94..a8186da6 100644 --- a/crates/engine/src/io/render.rs +++ b/crates/engine/src/io/render.rs @@ -1,6 +1,6 @@ use super::import::add_source; #[cfg(test)] -use super::midi::{MAX_MIDI_METERS, import_midi_bytes}; +use super::midi::{MAX_MIDI_METERS, MAX_MIDI_TEMPOS, MAX_MIDI_TRACKS, import_midi_bytes}; use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind}; /// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the @@ -196,6 +196,60 @@ mod name_tests { assert!(err.to_string().contains("meter changes"), "{err}"); } + /// A MIDI file with more tempo changes than MAX_MIDI_TEMPOS is refused without mutating the session. + #[test] + fn midi_with_more_tempo_changes_than_performances_have_is_refused() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = |n: usize| soundcraft_midi::Smf { + tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence: sequence.clone() }], + tempos: (0..n as i64).map(|beat| (beat * 960, 100.0 + (beat % 40) as f64)).collect(), + ..Default::default() + }; + let mut e = Engine::default(); + let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS + 1)).unwrap(), 0, true).unwrap_err(); + assert!(err.to_string().contains("tempo changes"), "{err}"); + assert_eq!((e.session().tempo.tempos().len(), e.session().tracks.len()), (1, 0), "nothing is imported"); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS)).unwrap(), 0, true).unwrap(); + assert_eq!(e.session().tempo.tempos().len(), MAX_MIDI_TEMPOS); + } + + /// A MIDI file with more tracks than MAX_MIDI_TRACKS is refused without mutating the session. + #[test] + fn midi_with_more_tracks_than_supported_is_refused() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = |n: usize| soundcraft_midi::Smf { + tracks: (0..n).map(|i| soundcraft_midi::SmfTrack { name: format!("Tr {i}"), sequence: sequence.clone() }).collect(), + ..Default::default() + }; + let mut e = Engine::default(); + let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS + 1)).unwrap(), 0, false).unwrap_err(); + assert!(err.to_string().contains("tracks"), "{err}"); + assert_eq!(e.session().tracks.len(), 0, "no tracks imported"); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS)).unwrap(), 0, false).unwrap(); + assert_eq!(e.session().tracks.len(), MAX_MIDI_TRACKS); + } + + /// Consecutive equal tempo events in a MIDI file are collapsed to actual tempo changes. + #[test] + fn midi_collapses_equal_consecutive_tempos() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = soundcraft_midi::Smf { + tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], + tempos: vec![(0, 120.0), (960, 120.0), (1920, 140.0), (2880, 140.0), (3840, 100.0)], + ..Default::default() + }; + let mut e = Engine::default(); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap(); + let tempos = e.session().tempo.tempos(); + assert_eq!(tempos.len(), 3); + assert!((tempos[0].bpm - 120.0).abs() < 1e-3); + assert!((tempos[1].bpm - 140.0).abs() < 1e-3); + assert!((tempos[2].bpm - 100.0).abs() < 1e-3); + } + #[test] fn new_sources_take_the_first_free_file_name() { let mut s = Session::default(); From af4dd7ce4c798c92421140b99fd927cce1665c67 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Sat, 31 Oct 2026 05:35:13 +0530 Subject: [PATCH 078/113] Contributors: add kk376 to people.toml --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 1f6e43e8..abd1e8d1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -68,3 +68,7 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.kk376] +real_name = "Kushagra Kumar" +sync_github_name = true From e8bc4758ccb4881e6c427fbccd0f0df750d31dd8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 20:12:19 -0400 Subject: [PATCH 079/113] fix(flatpak): allow reading launcher version pins (#1210) * fix(flatpak): allow reading launcher version pins * fix(flatpak): allow reading launcher version pins --- packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml | 1 + packaging/linux/flatpak/ai.storyteller.soundcraft.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml index 977ec3ee..ffce6828 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml @@ -18,6 +18,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: soundcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml index 4af6251f..02bad718 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml @@ -23,6 +23,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: soundcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 From 966e253873734b6e25f11b2009ccb2313ac03a3a Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Sat, 31 Oct 2026 05:50:50 +0530 Subject: [PATCH 080/113] fix(engine): skip duplicate MIDI tempos only when session tempo matches Collapse consecutive duplicate SMF tempos only if the session tempo at the event tick already matches, so existing session tempo markers are overridden as expected. --- crates/engine/src/io/midi.rs | 4 +--- crates/engine/src/io/render.rs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/io/midi.rs b/crates/engine/src/io/midi.rs index 9711eccd..4f2bc153 100644 --- a/crates/engine/src/io/midi.rs +++ b/crates/engine/src/io/midi.rs @@ -25,13 +25,11 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b } let s = e.session_mut(); if tempo_map { - let mut last_bpm = None; for (tick, bpm) in &smf.tempos { - if last_bpm == Some(*bpm) { + if s.tempo.tempo_at_tick(*tick) == *bpm { continue; } let _ = s.tempo.set_tempo(*tick, *bpm); - last_bpm = Some(*bpm); } for (tick, n, d) in &smf.meters { let _ = s.tempo.set_meter(*tick, u32::from(*n), u32::from(*d)); diff --git a/crates/engine/src/io/render.rs b/crates/engine/src/io/render.rs index a8186da6..dafa8897 100644 --- a/crates/engine/src/io/render.rs +++ b/crates/engine/src/io/render.rs @@ -250,6 +250,23 @@ mod name_tests { assert!((tempos[2].bpm - 100.0).abs() < 1e-3); } + /// A MIDI file replaces an existing session tempo event even if the MIDI file has consecutive equal tempos. + #[test] + fn midi_import_replaces_differing_existing_session_tempo() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = soundcraft_midi::Smf { + tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], + tempos: vec![(0, 120.0), (960, 120.0)], + ..Default::default() + }; + let mut e = Engine::default(); + e.session_mut().tempo.set_tempo(960, 80.0).unwrap(); + assert_eq!(e.session().tempo.tempo_at_tick(960), 80.0); + import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap(); + assert_eq!(e.session().tempo.tempo_at_tick(960), 120.0); + } + #[test] fn new_sources_take_the_first_free_file_name() { let mut s = Session::default(); From 331b1820dbde29775d10bf396383cac304829ba4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:29:44 +0000 Subject: [PATCH 081/113] Default range start to the selection start in range_param Found while testing crates/engine/src/cmd.rs:326-336 (range_param), used by crates/engine/src/cmd/event.rs:148 (insert_time) and :172 (cut_time) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd.rs | 8 ++++++-- crates/engine/src/cmd/event.rs | 28 ++++++++++++++++++++++++++++ crates/engine/src/cmd/event_more.rs | 16 +++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 9b9d7954..cbba05c1 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -324,11 +324,15 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result Result { - let st = position_param(e, cmd, p, "start")?; + let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? }; let en = position_param(e, cmd, p, "end")?; let len = position_param(e, cmd, p, "length")?; + if st.is_none() && (en.is_some() || len.is_some()) { + st = Some(e.session().edit.selection.start); + } match (st, en, len) { (Some(a), Some(b), _) => Ok(Range::new(a, b)), (Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))), diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index efe6233a..a1f4ef4e 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -427,6 +427,34 @@ mod tests { use crate::demo::demo_engine; use serde_json::json; + /// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply. + #[test] + fn insert_and_cut_time_without_start_use_selection_start() { + use soundcraft_time::Range; + let mut e = demo_engine(); + let a = e.session().edit.selection.start; + let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| { + e.session_mut().edit.selection = sel; + e.execute(id, ¶ms).unwrap() + }; + // Point selection. + assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000); + assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000); + assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700); + // A non-empty selection must not override the given `end`/`length`. + let sel = Range::new(a, a + 100); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000); + assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000); + // A null `start` takes the same path. + assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900); + // An explicit `start` still wins over the selection. + let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000})); + assert_eq!(r["removed"], 2000); + assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500); + } + /// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position. #[test] fn tempo_change_at_position_is_applied() { diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs index 678cf3aa..6f5b73b3 100644 --- a/crates/engine/src/cmd/event_more.rs +++ b/crates/engine/src/cmd/event_more.rs @@ -274,7 +274,12 @@ fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result Result { let id = "event.tempo_stretch"; - let (r, t0, t1) = selection_ticks(e, p, id)?; + // Here `length` is the new length of the selection, not part of the range. + let mut range_params = p.clone(); + if let Some(m) = range_params.as_object_mut() { + m.remove("length"); + } + let (r, t0, t1) = selection_ticks(e, &range_params, id)?; let factor = match position_param(e, id, p, "length")? { Some(l) if l > 0 => l as f64 / r.len() as f64, _ => f64_or(p, "factor", f64::NAN), @@ -901,6 +906,15 @@ mod tests { } } + /// For `event.tempo_stretch`, `length` is the new length of the selection, so the selection stays the range. + #[test] + fn tempo_stretch_length_keeps_selection_range() { + let mut e = Engine::default(); + e.session_mut().edit.selection = Range::new(0, 96_000); + e.execute("event.tempo_stretch", &json!({"length": 192_000})).unwrap(); + assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0); + } + #[test] fn tempo_curves() { let mut e = Engine::default(); From b2a386bc7ab309b36250ce9b19c7aefca207d8f8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:31:18 +0000 Subject: [PATCH 082/113] Fix integer overflow in AutomationLane::value_at for distant breakpoints Found while testing crates/model/src/automation.rs:161 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/automation.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs index 26bbcae3..650d8add 100644 --- a/crates/model/src/automation.rs +++ b/crates/model/src/automation.rs @@ -158,7 +158,10 @@ impl AutomationLane { if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) { return a.value; } - a.value + (b.value - a.value) * ((at - a.at) as f32 / (b.at - a.at) as f32) + // Widen so far-apart breakpoints cannot overflow i64. + let span = i128::from(b.at) - i128::from(a.at); + let off = i128::from(at) - i128::from(a.at); + a.value + (b.value - a.value) * (off as f64 / span as f64) as f32 } (Some(a), _) => a.value, _ => default, @@ -255,6 +258,17 @@ impl AutomationLane { mod tests { use super::*; + #[test] + fn value_at_far_apart_points_does_not_overflow() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(-1, 0.0); + l.set_point(i64::MAX, -10.0); + let v = l.value_at(5, 0.0); + assert!(v.is_finite() && (-10.0..=0.0).contains(&v)); + let w = l.value_at(i64::MAX - 1, 0.0); + assert!(w.is_finite() && (-10.0..=0.0).contains(&w)); + } + #[test] fn pan_label_max_does_not_overflow() { assert_eq!(AutoParam::Pan(255).label(), "pan 256"); From 13fe1df4b8d973b372cc42070571657713a04c84 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:38:12 +0000 Subject: [PATCH 083/113] Fix overflow in GridValue::step_samples at extreme positions Found while testing crates/time/src/grid.rs:125 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/time/src/grid.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/time/src/grid.rs b/crates/time/src/grid.rs index 13551b52..a78c600e 100644 --- a/crates/time/src/grid.rs +++ b/crates/time/src/grid.rs @@ -122,7 +122,7 @@ impl GridValue { GridValue::Note { value, dotted, triplet } => { let t0 = map.samples_to_ticks(at, sr); let dt = Self::note_ticks(value, dotted, triplet, map, t0); - (map.tick_to_samples(t0 + dt, sr) - map.tick_to_samples(t0, sr)).max(1) + (map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1) } GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1), GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1), @@ -320,4 +320,11 @@ mod tests { assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10)); assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1)); } + + #[test] + fn snap_at_extreme_position_does_not_overflow() { + let sr = SampleRate::new(8000).unwrap(); + let map = TempoMap::new(1000.0, 4, 4).unwrap(); + let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30); + } } From a97ce7556c5362718e8bcab5349477381ff3fb65 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 22:59:18 -0400 Subject: [PATCH 084/113] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5385e0d..7875fdb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4280,7 +4280,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "clap", @@ -4304,7 +4304,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "serde", @@ -4314,7 +4314,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "proptest", @@ -4326,7 +4326,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.14.0" +version = "0.15.0" dependencies = [ "clap", "log", @@ -4339,7 +4339,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.14.0" +version = "0.15.0" dependencies = [ "clap-sys", "libloading 0.9.0", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "clap", "clap_complete", @@ -4369,7 +4369,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "proptest", @@ -4380,7 +4380,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-docpath", "log", @@ -4403,7 +4403,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -4413,7 +4413,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "rayon", @@ -4429,7 +4429,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -4442,7 +4442,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cpal", "log", @@ -4457,7 +4457,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proptest", "serde", @@ -4467,7 +4467,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-ui", "craft-update", @@ -4496,7 +4496,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.14.0" +version = "0.15.0" dependencies = [ "craft-media", "image", @@ -4508,7 +4508,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.14.0" +version = "0.15.0" dependencies = [ "libloading 0.9.0", "log", @@ -4524,7 +4524,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "eframe", "egui", @@ -6140,7 +6140,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index fcdb0331..11f6121b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 5288e42bc9c6fbdca2641b13d8fff6b58d25d190 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:17:26 +0000 Subject: [PATCH 085/113] docs: tidy agent, contributor and release docs --- AGENTS.md | 13 ++++--------- ATTRIBUTION.md | 16 ++++++++-------- assets/app-icon/README.md | 2 +- contributors/people.toml | 7 ++----- crates/au-host/Cargo.toml | 2 +- crates/au-host/src/lib.rs | 2 +- crates/clap-host/Cargo.toml | 2 +- crates/clap-host/src/lib.rs | 2 +- crates/ui-egui/build.rs | 7 +++---- crates/ui-egui/src/credits.rs | 2 +- crates/vst3-host/Cargo.toml | 2 +- crates/vst3-host/src/lib.rs | 2 +- docs/contributors.md | 25 ++++--------------------- docs/release-playbook.md | 9 --------- docs/releasing.md | 5 ++--- docs/roadmap.md | 3 +-- docs/target-app-parity.md | 2 +- 17 files changed, 33 insertions(+), 70 deletions(-) delete mode 100644 docs/release-playbook.md diff --git a/AGENTS.md b/AGENTS.md index b48565c8..065e8313 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,9 @@ SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code. - ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed). -2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. +2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. 3. Work autonomously; don't stop to ask unless a decision is genuinely the user's. ## ⚠️ Assets: the absolute rule @@ -22,7 +20,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l `), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names. ## Never crash -People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** - No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API. - The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects). @@ -67,7 +65,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc `cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit. ## Release -Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. +Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`. ## Roadmap `ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry. @@ -81,14 +79,11 @@ Push to the `release` branch to build signed installers for macOS (universal DMG username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 00927097..dc168fa4 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts | Path | Author | Source | License | |---|---|---|---| -| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | -| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | -| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | +| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | +| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | +| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms | | `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 | | `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 | | `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 | diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 12cea9c2..196473c9 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,7 +1,7 @@ # SoundCraft app icon (placeholder) **A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps' -owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird +owl-template framing: a full-bleed colour field, the bird in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off the bottom and right edges, three song arcs leaving the bill. diff --git a/contributors/people.toml b/contributors/people.toml index 1f6e43e8..e6fab691 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" diff --git a/crates/au-host/Cargo.toml b/crates/au-host/Cargo.toml index 122c4919..4e190104 100644 --- a/crates/au-host/Cargo.toml +++ b/crates/au-host/Cargo.toml @@ -13,7 +13,7 @@ serde.workspace = true thiserror.workspace = true log.workspace = true -# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate +# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate # opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere # and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. [lints.rust] diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs index 7aa3b2eb..cbb569fb 100644 --- a/crates/au-host/src/lib.rs +++ b/crates/au-host/src/lib.rs @@ -9,7 +9,7 @@ //! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed. //! Boolean parameters become toggles, indexed ones starting at 0 become choices. //! -//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash): +//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate: //! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written //! AudioToolbox / CoreFoundation declarations). The public API is safe, returns //! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml index 27eaf561..fa719ac7 100644 --- a/crates/clap-host/Cargo.toml +++ b/crates/clap-host/Cargo.toml @@ -25,7 +25,7 @@ harness = false clap-sys = "0.5" libloading = "0.9" -# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace +# The isolated unsafe helper crate: this crate opts out of the workspace # `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the # FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. [lints.rust] diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index a5e042a3..7142a9ee 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -5,7 +5,7 @@ //! plugins exactly like the built-in ones. Plugin ids are `clap:`; parameter ids //! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`]. //! -//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is +//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is //! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns //! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes //! (segfault) takes the process down with it; we cannot sandbox native code. diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb..3116b01d 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml +//! entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index c22c4720..42804e02 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. use std::cmp::Ordering; diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 7a956118..284e7d54 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -47,7 +47,7 @@ harness = false name = "module_exit_shutdown" harness = false -# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-clap-host: this crate +# An isolated unsafe helper crate, like soundcraft-clap-host: this crate # opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere # and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. [lints.rust] diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 9c76446c..a0324f88 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -8,7 +8,7 @@ //! [`PluginInfo::params`]. Values are plain (display) values; the host converts to and from the //! VST3 normalized 0..1 values. Stepped parameters become toggles or choices. //! -//! Like `soundcraft-clap-host`, this is an isolated `unsafe` crate (craftrules never-crash): +//! Like `soundcraft-clap-host`, this is an isolated `unsafe` crate: //! `unsafe` is denied crate-wide and allowed only in the private `ffi` module. The public API is //! safe, returns `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that //! itself crashes (segfault) takes the process down with it; we cannot sandbox native code. diff --git a/docs/contributors.md b/docs/contributors.md index bf35050d..33312ac4 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to SoundCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. Never hand-edit. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -36,20 +34,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/release-playbook.md b/docs/release-playbook.md deleted file mode 100644 index aafba970..00000000 --- a/docs/release-playbook.md +++ /dev/null @@ -1,9 +0,0 @@ -# Release playbook - -The release playbook shared by every crafting app lives in craftrules: -[`../craftrules/release/playbook.md`](../../craftrules/release/playbook.md) -(`storytold/craftrules`). It is the canonical copy; don't duplicate it here. - -SoundCraft's implementation (`.github/workflows/release.yml`, `packaging/`, -`cargo xtask version`) was copied from DesignCraft/PhotoCraft and renamed. SoundCraft-specific -details are in [releasing.md](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index b877757c..0fdc3502 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,7 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o **draft** GitHub Release named `SoundCraft v`. Nobody sees a draft until a maintainer publishes it. -This is SoundCraft's implementation of the shared [release playbook](release-playbook.md). +This file describes how SoundCraft is released. User-facing names say **SoundCraft**. Files, binaries and ids stay lowercase (`soundcraft---.`, `ai.storyteller.soundcraft`). @@ -208,8 +208,7 @@ are written with `umask 077` and deleted in `always()`/`finally` steps. | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | service principal for Azure Trusted Signing | | `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` | Trusted Signing endpoint, account and profile | -`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`. Provisioning the -environment, ruleset and secrets for a new repo: craftrules `release/signing-setup.md`. +`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`. ## Icons diff --git a/docs/roadmap.md b/docs/roadmap.md index 5219a8e5..2e7eeeeb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,8 +18,7 @@ Hours are Opus 5.5 agent-hours. ## Alpha gate The core workflows a Pro Tools professional does every day, checked end to end on macOS (the -main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist -(craftrules `standards/progress-docs.md`, Stages). +main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist. | Workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---:| diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index db69e3b3..2bc915dd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -96,7 +96,7 @@ every command is reachable from the CLI, the JSON control channel and MCP. ## Mainstream practitioner and essentials user -Both use the method in craftrules `standards/progress-docs.md`. Each multiplies a weighted depth +Both use one method. Each multiplies a weighted depth average by written-down discounts. Depths come from the [Feature areas](#feature-areas) table and the evidence there; issue numbers are `storytold/soundcraft` GitHub issues. From 1964662a71a9b2d48a54b7d163fdda18b5a8c512 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:25:42 +0000 Subject: [PATCH 086/113] docs: tidy wording --- ROADMAP.md | 6 +++--- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8ff79f11..c9b87dcd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,8 +156,8 @@ mutated and truncated movies. Field reports from the first public week are track - **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change. - **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change. - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. -- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard - progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ +- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs + set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours, tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session. - **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media @@ -204,6 +204,6 @@ mutated and truncated movies. Field reports from the first public week are track | 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h | | 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers | | 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added | -| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md | +| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md | | 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list | | 2026-10-06 | minor | Alpha and parity estimates | diff --git a/docs/gaps.md b/docs/gaps.md index 17b2fe8a..a762162c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -77,7 +77,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t - **Missing:** opening or saving `.ptx`/`.ptf`, AAF and OMF import and export (#40), Media Composer–compatible sessions. Our session format is `.scraft` JSON. -- **Impact:** the standard's main-format bar for beta. A Pro Tools user cannot bring a project in +- **Impact:** a main-format requirement for beta. A Pro Tools user cannot bring a project in or hand one back; post-production (picture editors deliver AAF) is blocked. - **Estimate:** AAF/OMF import + export ≈ 50–90 h (FilmCraft already writes AAF/OMF; we can learn from it but not share code). `.ptx` is proprietary and undocumented: owner decision on whether diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2bc915dd..2992d93e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -125,7 +125,7 @@ immersive delivery, video, notation, and languages other than English. | File exchange with Pro Tools users | ×0.85 | Engineers trade sessions, not stems. No `.ptx`, AAF or OMF (#40); WAV/BWF stems and MIDI files do work, so the factor isn't lower | | **Result** | 50.6 × 0.90 × 0.92 × 0.85 = **35.6 % ≈ 36 %** | | -This comes out *below* the full number (~40 %), which the standard says is unusual. The +This comes out *below* the full number (~40 %), which is unusual. The mainstream engineer's weekly core is interface recording and MIDI overdubs, exactly the alpha-gate failures. The full number gets partial credit from areas this user doesn't weight: editing breadth, five platforms built, agent control. From 4c31f2884b96992d2840072910f59d2fe445b22f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:42:39 +0000 Subject: [PATCH 087/113] updates: show check results with craft-update's CheckResultDialog --- Cargo.lock | 28 +++++++-------- Cargo.toml | 8 ++--- crates/ui-egui/src/updates_ui.rs | 61 +++++++++++++++----------------- 3 files changed, 46 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7875fdb1..401b3c60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,7 +272,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -1058,17 +1058,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1091,7 +1091,7 @@ dependencies = [ [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "thiserror 2.0.21", ] @@ -1099,7 +1099,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", @@ -1534,7 +1534,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3245,7 +3245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3881,7 +3881,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3938,7 +3938,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4840,10 +4840,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5736,7 +5736,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 11f6121b..73b3e0b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,15 +50,15 @@ zip = { version = "9", default-features = false } cpal = "0.18" # Which file paths stored in a session may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } # Video containers and bitstreams, shared with the other craft apps. -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] } egui = { version = "0.37" } eframe = { version = "0.37", default-features = false } # Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent). diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 98efc506..8fee933a 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,6 +2,8 @@ use crate::SoundApp; +#[cfg(not(target_arch = "wasm32"))] +use craft_update::dialog::{CheckResultDialog, CheckResultResponse}; #[cfg(not(target_arch = "wasm32"))] use craft_update::updater::{Action, Status, Updater}; @@ -13,8 +15,10 @@ pub(crate) struct Runtime { startup_check: bool, #[cfg(not(target_arch = "wasm32"))] check_requested: bool, - pub(crate) manual_window_open: bool, - pub(crate) status_message: Option, + /// The check-result dialog (native) or the web build's notice is showing. + manual_window_open: bool, + #[cfg(not(target_arch = "wasm32"))] + result_dialog: CheckResultDialog, } impl Default for Runtime { @@ -27,7 +31,8 @@ impl Default for Runtime { #[cfg(not(target_arch = "wasm32"))] check_requested: false, manual_window_open: false, - status_message: None, + #[cfg(not(target_arch = "wasm32"))] + result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")), } } } @@ -48,7 +53,6 @@ pub(crate) fn check(app: &mut SoundApp) { #[cfg(target_arch = "wasm32")] { app.updates_rt.manual_window_open = true; - app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into()); } } @@ -58,6 +62,7 @@ pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) { let rt = &mut app.updates_rt; if std::mem::take(&mut rt.check_requested) { rt.updater.check_now(ctx); + rt.manual_window_open = true; } else if rt.startup_check { rt.updater.check_at_start(ctx); } @@ -77,46 +82,36 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} } - // The window answers Help ▸ Check for Updates, so it follows the updater's status. + // Help ▸ Check for Updates shows its outcome in the shared result dialog until the user closes it. let rt = &mut app.updates_rt; - (rt.manual_window_open, rt.status_message) = match rt.updater.status() { - Status::Idle => (false, None), - Status::Checking => (true, None), - Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), - Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), - }; + if rt.manual_window_open { + match rt.result_dialog.show(ctx, rt.updater.status()) { + CheckResultResponse::Close => rt.manual_window_open = false, + CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::None => {} + } + // A found update goes to the startup prompt, which takes over from the dialog. + if matches!(rt.updater.status(), Status::Idle) { + rt.manual_window_open = false; + } + } + // Closing the dialog dismisses the outcome it showed. + if !rt.manual_window_open { + rt.updater.dismiss_status(); + } } + #[cfg(target_arch = "wasm32")] if app.updates_rt.manual_window_open { let mut open = true; - let mut close = false; egui::Window::new("Check for Updates") .open(&mut open) .resizable(false) .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ctx, |ui| { - if let Some(msg) = &app.updates_rt.status_message { - ui.label(msg); - } else { - ui.label("Checking for updates…"); - } - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Close").clicked() { - close = true; - } - }); + ui.label("Update check is not supported in the web browser."); }); - if !open || close { - app.updates_rt.manual_window_open = false; - app.updates_rt.status_message = None; - } - } - - // Closing the window dismisses the outcome it showed. - #[cfg(not(target_arch = "wasm32"))] - if !app.updates_rt.manual_window_open { - app.updates_rt.updater.dismiss_status(); + app.updates_rt.manual_window_open = open; } } From 948460a95308abaf082d8c700392fd7998fee457 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:53:02 +0100 Subject: [PATCH 088/113] Fix overflow in TempoMap bar math with a meter near i64::MAX Found while testing crates/time/src/tempo.rs:287 (bar_beat_at_tick); same pattern in bar_start_tick Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/time/src/tempo.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 46c05064..23bb2be0 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -251,10 +251,10 @@ impl TempoMap { for (i, m) in self.meters.iter().enumerate() { let next_tick = self.meters.get(i + 1).map(|n| n.tick); let tpb = m.ticks_per_bar(); - let bars_in_seg = next_tick.map(|nt| ((nt - cur_tick) + tpb - 1) / tpb); + let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb)); match bars_in_seg { - Some(n) if bar >= cur_bar + n => { - cur_bar += n; + Some(n) if bar >= cur_bar.saturating_add(n) => { + cur_bar = cur_bar.saturating_add(n); cur_tick = next_tick.unwrap_or(cur_tick); } _ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)), @@ -284,7 +284,7 @@ impl TempoMap { if let Some(n) = self.meters.get(i + 1) && tick >= n.tick { - bar += ((n.tick - seg_tick) + tpb - 1) / tpb; + bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb)); seg_tick = n.tick; continue; } @@ -323,6 +323,11 @@ impl TempoMap { } } +/// Ceiling division for a non-negative `num` and positive `den`, without overflow. +fn ceil_div(num: i64, den: i64) -> i64 { + num / den + i64::from(num % den != 0) +} + #[cfg(test)] mod tests { use super::*; @@ -337,6 +342,16 @@ mod tests { assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 }); } + #[test] + fn meter_near_max_tick_does_not_overflow() { + let mut m = TempoMap::default(); + m.set_meter(i64::MAX, 7, 8).unwrap(); + let bb = m.bar_beat_at_tick(i64::MAX); + assert!(bb.bar > 1); + assert!(m.bar_start_tick(bb.bar) > 0); + assert!(m.bar_start_tick(i64::MAX) > 0); + } + #[test] fn minimum_bar_saturates_instead_of_overflowing() { let m = TempoMap::default(); From b9fee48a7510e7303e0107611be0900d2fa50553 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:07:06 +0000 Subject: [PATCH 089/113] Fix remove_dc mean when a channel has non-finite samples Found while testing crates/dsp/src/offline.rs:125-134 (remove_dc) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/dsp/src/offline.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index bc915375..f8d23784 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec]) { if c.is_empty() { continue; } - let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::() / c.len() as f64; - let m = mean as f32; + let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1)); + if n == 0 { + continue; + } + let m = (sum / n as f64) as f32; c.iter_mut().for_each(|s| *s -= m); } } @@ -889,4 +892,14 @@ mod tests { assert_eq!(ch[0][50], 1.0); fade(&mut ch, 1000, 1000, FadeShape::SCurve); } + + #[test] + fn remove_dc_ignores_non_finite_samples_in_the_mean() { + let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]]; + remove_dc(&mut ch); + assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]); + let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]]; + remove_dc(&mut all_bad); + assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite()); + } } From 53bb43db4639d734d4b363fd0e6e5d0813eb6a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:46:05 +0000 Subject: [PATCH 090/113] Keep RMS finite for large samples Found while testing crates/audio-io/src/peaks.rs:34 Co-Authored-By: GPT-6-Luna --- crates/audio-io/src/peaks.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/audio-io/src/peaks.rs b/crates/audio-io/src/peaks.rs index 9553665c..6cd8f321 100644 --- a/crates/audio-io/src/peaks.rs +++ b/crates/audio-io/src/peaks.rs @@ -24,14 +24,14 @@ pub struct Peaks { struct Block { min: f32, max: f32, - squares: f32, + squares: f64, } impl Block { const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 }; fn point(s: f32) -> Block { - Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } } + Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } } } fn of(samples: &[f32]) -> Block { @@ -146,7 +146,7 @@ impl Peaks { .map(|w| { w.map(|(a, b)| { let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len())); - (self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt() + (self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32 }) }) .collect() @@ -215,6 +215,13 @@ mod tests { assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite)); } + #[test] + fn finite_large_samples_keep_finite_rms() { + let samples = vec![1e20_f32; BLOCK]; + let peaks = Peaks::build(&samples); + assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]); + } + #[test] fn rms_spans_match_the_samples() { let x = signal(20_000); From 73e3478ae367d7f0c4716f29696cf7789b944dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:06:09 +0000 Subject: [PATCH 091/113] Accept unsigned AIFF channel counts Found while testing crates/audio-io/src/aiff.rs:84 Co-Authored-By: GPT-6-Luna --- crates/audio-io/src/aiff.rs | 8 +++--- crates/audio-io/tests/hunter_aiff_channels.rs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 crates/audio-io/tests/hunter_aiff_channels.rs diff --git a/crates/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs index 77ed6f80..3c851d72 100644 --- a/crates/audio-io/src/aiff.rs +++ b/crates/audio-io/src/aiff.rs @@ -63,7 +63,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into())); } let is_aifc = tag(b, 8).is_some_and(|t| &t == b"AIFC"); - let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None; + let mut comm: Option<(u16, u32, i16, f64, [u8; 4])> = None; let mut ssnd: Option<(usize, usize)> = None; let mut pos: usize = 12; @@ -81,7 +81,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { match &id { b"COMM" => { let short = || AudioError::Malformed("COMM chunk too short".into()); - let channels = be_u16(c, 0).ok_or_else(short)? as i16; + let channels = be_u16(c, 0).ok_or_else(short)?; let frames = be_u32(c, 2).ok_or_else(short)?; let bits = be_u16(c, 6).ok_or_else(short)? as i16; let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?; @@ -106,7 +106,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { } let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?; - if channels <= 0 { + if channels == 0 { return Err(AudioError::Malformed("AIFF declares zero or negative channels".into())); } if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) { @@ -137,7 +137,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { let block = kind.bytes().saturating_mul(channels as usize).max(1); let frames = u64::from(frames).min((data_len / block) as u64); Ok(AiffParse::Native(AiffLayout { - info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels: channels as u16, frames, bwf: None }, + info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels, frames, bwf: None }, kind, data_start, data_len, diff --git a/crates/audio-io/tests/hunter_aiff_channels.rs b/crates/audio-io/tests/hunter_aiff_channels.rs new file mode 100644 index 00000000..2fa95f2e --- /dev/null +++ b/crates/audio-io/tests/hunter_aiff_channels.rs @@ -0,0 +1,26 @@ +use soundcraft_audio_io::probe; + +#[test] +fn aiff_accepts_full_unsigned_channel_count() { + let mut comm = Vec::new(); + comm.extend_from_slice(&32_768u16.to_be_bytes()); + comm.extend_from_slice(&0u32.to_be_bytes()); + comm.extend_from_slice(&16u16.to_be_bytes()); + // 44,100 Hz as an 80-bit extended float. + comm.extend_from_slice(&[0x40, 0x0e, 0xac, 0x44, 0, 0, 0, 0, 0, 0]); + + let mut file = b"FORM".to_vec(); + file.extend_from_slice(&0u32.to_be_bytes()); + file.extend_from_slice(b"AIFF"); + file.extend_from_slice(b"COMM"); + file.extend_from_slice(&(comm.len() as u32).to_be_bytes()); + file.extend_from_slice(&comm); + file.extend_from_slice(b"SSND"); + file.extend_from_slice(&8u32.to_be_bytes()); + file.extend_from_slice(&[0; 8]); + let form_size = (file.len() - 8) as u32; + file[4..8].copy_from_slice(&form_size.to_be_bytes()); + + let info = probe(&file, Some("aiff")).expect("valid AIFF"); + assert_eq!(info.channels, 32_768); +} From f28a10a5dd64ff59330a6198b480babeb5ac2867 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:13:22 +0100 Subject: [PATCH 092/113] Saturate H.264 POC type 0 bottom field order count Found while testing crates/video/src/h264/slice.rs:477 (PocState::compute, pic_order_cnt_type 0) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/video/src/h264/slice.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/video/src/h264/slice.rs b/crates/video/src/h264/slice.rs index 02bcb0ba..61c2eccd 100644 --- a/crates/video/src/h264/slice.rs +++ b/crates/video/src/h264/slice.rs @@ -474,7 +474,7 @@ impl PocState { prev_msb }; let top = msb + lsb; - let bottom = top + sh.delta_pic_order_cnt_bottom; + let bottom = top.saturating_add(sh.delta_pic_order_cnt_bottom); Poc { top, bottom, msb, frame_num_offset: 0 } } 1 => { @@ -638,4 +638,17 @@ mod tests { fn huge_slice_qp_delta_is_invalid() { assert!(matches!(parse_override(0, 0, 0, i32::MAX), Err(crate::h264::Error::Invalid(_)))); } + + #[test] + fn poc0_huge_bottom_delta_does_not_overflow() { + let (mut sps, _) = sps_pps(); + sps.pic_order_cnt_type = 0; + sps.log2_max_poc_lsb = 4; + let mut sh = parse_override(0, 0, 0, 0).unwrap(); + sh.pic_order_cnt_lsb = 1; + sh.delta_pic_order_cnt_bottom = i32::MAX; + let poc = PocState::default().compute(&sh, &sps); + assert_eq!(poc.top, 1); + assert_eq!(poc.bottom, i32::MAX); + } } From 2d88f46f02a28c30af2f4d3fd151cd0e7bb7e892 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:16:46 +0000 Subject: [PATCH 093/113] Keep plugin parameter id case in AutoParam::parse Found while testing crates/model/src/automation.rs:86-103 (AutoParam::parse) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/automation.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs index 26bbcae3..8ee9e7fb 100644 --- a/crates/model/src/automation.rs +++ b/crates/model/src/automation.rs @@ -80,7 +80,8 @@ impl AutoParam { } /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`. pub fn parse(s: &str) -> Option { - let s = s.trim().to_ascii_lowercase(); + let orig = s.trim(); + let s = orig.to_ascii_lowercase(); match s.as_str() { "volume" | "vol" => return Some(AutoParam::Volume), "pan" | "pan1" => return Some(AutoParam::Pan(0)), @@ -100,6 +101,8 @@ impl AutoParam { } if let Some(rest) = s.strip_prefix("plugin:") { let (slot, param) = rest.split_once(':')?; + // ASCII lowercasing keeps byte offsets, so take the param from the original. + let param = orig.get(orig.len() - param.len()..)?; return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() }); } None @@ -255,6 +258,15 @@ impl AutomationLane { mod tests { use super::*; + #[test] + fn parse_keeps_plugin_param_case() { + let p = AutoParam::Plugin { slot: 0, param: "Cutoff".into() }; + assert_eq!(AutoParam::parse("plugin:a:Cutoff"), Some(p)); + assert_eq!(AutoParam::parse("Plugin:B:Mix"), Some(AutoParam::Plugin { slot: 1, param: "Mix".into() })); + assert_eq!(AutoParam::parse("Volume"), Some(AutoParam::Volume)); + assert_eq!(AutoParam::parse("SEND_B_LEVEL"), Some(AutoParam::SendLevel(1))); + } + #[test] fn pan_label_max_does_not_overflow() { assert_eq!(AutoParam::Pan(255).label(), "pan 256"); From a13a4f5bd22b64dff645dfff09e4d231b7d7947a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:30:11 +0100 Subject: [PATCH 094/113] Fix H.264 DPB integer overflow on huge pic num differences Found while testing crates/video/src/h264/dpb.rs:265 (Dpb::apply_mmcos, MMCO 1 and 3); same pattern at :280 and :203 (build_ref_lists, `m.value as i32 + 1`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/video/src/h264/dpb.rs | 73 ++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/video/src/h264/dpb.rs b/crates/video/src/h264/dpb.rs index 5afc169b..81044955 100644 --- a/crates/video/src/h264/dpb.rs +++ b/crates/video/src/h264/dpb.rs @@ -198,6 +198,7 @@ impl Dpb { let mut ref_idx = 0usize; for m in &sh.ref_pic_list_mod[l] { ensure!(ref_idx < n, "too many ref_pic_list_modification entries"); + ensure!(m.idc >= 2 || i64::from(m.value) < i64::from(max_pic_num), "abs_diff_pic_num_minus1 out of range"); list.push(None); // temporarily one longer let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc { 0 | 1 => { @@ -262,9 +263,9 @@ impl Dpb { for m in mmcos { match m.op { 1 => { - let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1); + let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1); for e in &mut self.entries { - if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn { + if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn { e.mark = RefMark::Unused; } } @@ -277,14 +278,14 @@ impl Dpb { } } 3 => { - let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1); + let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1); for e in &mut self.entries { if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx { e.mark = RefMark::Unused; } } for e in &mut self.entries { - if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn { + if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn { e.mark = RefMark::Long; e.long_term_frame_idx = m.long_term_frame_idx; } @@ -418,3 +419,67 @@ impl Default for Dpb { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::h264::slice::RefPicListMod; + + fn mmco(op: u32, diff: u32) -> Mmco { + Mmco { op, difference_of_pic_nums_minus1: diff, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 } + } + + #[test] + fn mmco_huge_difference_does_not_panic() { + for op in [1, 3] { + for diff in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] { + let mut dpb = Dpb::new(); + dpb.apply_mmcos(&[mmco(op, diff)], 5, 16, &mut None); + } + } + } + + #[test] + fn ref_list_mod_huge_value_does_not_panic() { + for idc in [0, 1] { + for value in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] { + let sh = SliceHeader { + nal_ref_idc: 1, + nal_unit_type: 1, + idr: false, + first_mb_in_slice: 0, + slice_type_raw: 0, + slice_type: SliceType::P, + pps_id: 0, + colour_plane_id: 0, + frame_num: 5, + field_pic: false, + bottom_field: false, + idr_pic_id: 0, + pic_order_cnt_lsb: 0, + delta_pic_order_cnt_bottom: 0, + delta_pic_order_cnt: [0; 2], + redundant_pic_cnt: 0, + direct_spatial_mv_pred: false, + num_ref_idx_active: [1, 0], + ref_pic_list_mod: [vec![RefPicListMod { idc, value }], Vec::new()], + pred_weight_table: None, + no_output_of_prior_pics: false, + long_term_reference: false, + adaptive_ref_pic_marking: false, + mmcos: Vec::new(), + cabac_init_idc: 0, + slice_qp_delta: 0, + sp_for_switch: false, + slice_qs_delta: 0, + disable_deblocking_filter_idc: 0, + slice_alpha_c0_offset_div2: 0, + slice_beta_offset_div2: 0, + slice_group_change_cycle: 0, + header_bits: 0, + }; + let _ = Dpb::new().build_ref_lists(&sh, 0, 16); + } + } + } +} From ad3147388f69c4c49096b1c7a33d03befff3c440 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 10:40:57 -0400 Subject: [PATCH 095/113] Saturate the POC type 0 MSB and top field sum too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MSB accumulates across reference pictures, so a hostile stream can still overflow prev_msb ± max_lsb and msb + lsb. Co-Authored-By: Claude Opus 5.5 --- crates/video/src/h264/slice.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/video/src/h264/slice.rs b/crates/video/src/h264/slice.rs index 61c2eccd..5fb387aa 100644 --- a/crates/video/src/h264/slice.rs +++ b/crates/video/src/h264/slice.rs @@ -467,13 +467,13 @@ impl PocState { let max_lsb = 1i32 << sps.log2_max_poc_lsb; let lsb = sh.pic_order_cnt_lsb as i32; let msb = if lsb < prev_lsb && prev_lsb - lsb >= max_lsb / 2 { - prev_msb + max_lsb + prev_msb.saturating_add(max_lsb) } else if lsb > prev_lsb && lsb - prev_lsb > max_lsb / 2 { - prev_msb - max_lsb + prev_msb.saturating_sub(max_lsb) } else { prev_msb }; - let top = msb + lsb; + let top = msb.saturating_add(lsb); let bottom = top.saturating_add(sh.delta_pic_order_cnt_bottom); Poc { top, bottom, msb, frame_num_offset: 0 } } From 6ef3300aba87ad89c3903e94d99e83dc3a6716b5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 10:41:20 -0400 Subject: [PATCH 096/113] Assert that out-of-range pic num differences are refused Co-Authored-By: Claude Opus 5.5 --- crates/video/src/h264/dpb.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/video/src/h264/dpb.rs b/crates/video/src/h264/dpb.rs index 81044955..34688e2f 100644 --- a/crates/video/src/h264/dpb.rs +++ b/crates/video/src/h264/dpb.rs @@ -478,7 +478,7 @@ mod tests { slice_group_change_cycle: 0, header_bits: 0, }; - let _ = Dpb::new().build_ref_lists(&sh, 0, 16); + assert!(Dpb::new().build_ref_lists(&sh, 0, 16).is_err()); } } } From 13b2488e95115ee345da6713c53260603330b0b4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 10:42:37 -0400 Subject: [PATCH 097/113] Set an empty-range constant tempo at the range start With start now optional, {end} alone resolves the start from the selection; the empty-range fallback still went through event.tempo, which put the tempo at tick 0. CodeRabbit flagged this. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/event.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index a1f4ef4e..d9271210 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -115,11 +115,13 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result { let r = range_param(e, id, p)?; let s = e.session(); let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate)); - if t1 <= t0 { - return tempo(e, p); - } let bpm = f64_or(p, "bpm", f64::NAN); let s = e.session_mut(); + if t1 <= t0 { + // An empty range sets the tempo at its start, which may come from the selection. + s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?; + return Ok(json!({"tick": t0, "bpm": bpm})); + } // Read before the edit: the end is a musical position, and the new tempo moves it in samples. let after = s.tempo.tempo_at_tick(t1); let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1); @@ -427,6 +429,18 @@ mod tests { use crate::demo::demo_engine; use serde_json::json; + #[test] + fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() { + use soundcraft_time::Range; + let mut e = demo_engine(); + e.session_mut().edit.selection = Range::point(96_000); + let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap(); + let s = e.session(); + let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate); + assert_eq!(r["tick"], tick); + assert_eq!(s.tempo.tempo_at_tick(tick), 90.0); + } + /// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply. #[test] fn insert_and_cut_time_without_start_use_selection_start() { From 6e2da18e9e1cf5ae8b0c615ae37e62565a3970c5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Oct 2026 10:46:57 -0400 Subject: [PATCH 098/113] Drop the old selection's end and loop on transport.play {from} While playing, locate kept the earlier end and loop range, so playing from past a looped selection jumped back into the loop. CodeRabbit flagged this. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/playback.rs | 5 +++-- crates/ui-egui/src/tests.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 818e45e8..ccb9e742 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -345,11 +345,12 @@ impl SoundApp { } TransportRequest::PlayFrom(at) => { if self.is_playing() { + // Playing on from `at` drops the end and loop of the earlier selection. if let Some(p) = &self.player { - p.locate(at); + p.play(at, None, None); } if let Some(sim) = &mut self.sim { - sim.0 = at; + *sim = (at, None, None); } self.last_write_at = i64::MIN; self.engine.transport.position = at; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index e6ca84e9..6d61ca95 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -77,6 +77,21 @@ fn play_from_starts_at_from_regardless_of_the_selection() { assert_eq!(app.engine.transport.position, 96_000 + 12_000); } +#[test] +fn play_from_while_looping_drops_the_selection_loop() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("options.loop_playback", json!({"value": true})).unwrap(); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 96_000, "end": 144_000})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + app.run("transport.play", json!({"from": 480_000})).unwrap(); + app.handle_transport(0.25); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.engine.transport.position, 480_000 + 24_000); +} + #[test] fn record_with_nothing_armed_says_what_to_do() { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); From ef2f70eb35a36118b3448a751788311e66be3745 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:54:06 +0100 Subject: [PATCH 099/113] Return app errors from Remote::call unchanged instead of reconnecting (#1128) Found while testing crates/automation/src/lib.rs:~160 (Remote::call, matching on e.contains("closed") / "Broken pipe" / "reset") Co-authored-by: Claude Haiku 5.5 --- crates/automation/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6955db33..88406158 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -317,4 +317,19 @@ mod tests { assert!(r.call("session.inspect", json!({})).is_ok()); assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2); } + + #[test] + fn remote_returns_an_app_error_unchanged() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() }; + std::thread::spawn(move || { + let Ok((mut sock, _)) = listener.accept() else { return }; + let mut line = String::new(); + let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line); + let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"})); + }); + let mut r = Remote::new(&addr); + let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err(); + assert_eq!(err, "transport.locate: nothing to reset"); + } } From 97e2603683f157b8a02ab2bc4484975771a773b1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:41:28 +0000 Subject: [PATCH 100/113] fix: release a held sustain at the end of a trimmed MIDI clip on export Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/io/midi.rs | 21 ++++++++++++++++++++- crates/engine/tests/it/midi_offset.rs | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/io/midi.rs b/crates/engine/src/io/midi.rs index 44f7be31..a9c715e7 100644 --- a/crates/engine/src/io/midi.rs +++ b/crates/engine/src/io/midi.rs @@ -74,10 +74,29 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { n.length = n.length.min(clip_end - n.start).max(1); seq.notes.push(n); } + let mut kept: Vec = Vec::new(); for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) { let mut ev = *ev; ev.tick = ev.tick.saturating_add(base); - seq.ctrls.push(ev); + kept.push(ev); + } + // A sustain still held at the clip end is released there. + let mut release: Vec = Vec::new(); + for ev in &kept { + if ev.kind != soundcraft_midi::CtrlKind::Cc(64) { + continue; + } + match release.iter_mut().find(|r| r.channel == ev.channel) { + Some(r) if ev.tick >= r.tick => *r = *ev, + Some(_) => {} + None => release.push(*ev), + } + } + seq.ctrls.append(&mut kept); + for mut r in release.into_iter().filter(|r| r.value >= 64) { + r.tick = clip_end; + r.value = 0; + seq.ctrls.push(r); } } } diff --git a/crates/engine/tests/it/midi_offset.rs b/crates/engine/tests/it/midi_offset.rs index 8e6fb7ae..f0f4b2d6 100644 --- a/crates/engine/tests/it/midi_offset.rs +++ b/crates/engine/tests/it/midi_offset.rs @@ -50,3 +50,27 @@ fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() { let late = notes.iter().find(|n| n.pitch == 62).unwrap(); assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it"); } + +#[test] +fn export_midi_releases_a_sustain_held_past_the_trim() { + use soundcraft_midi::{CtrlEvent, CtrlKind}; + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Sus", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap(); + let id = e.session().tracks[0].clips()[0].id; + { + let clip = e.session_mut().find_clip_mut(id).unwrap(); + if let soundcraft_model::ClipContent::Midi { sequence } = &mut clip.content { + let cc = |tick, channel, value| CtrlEvent { tick, channel, kind: CtrlKind::Cc(64), value }; + sequence.ctrls = vec![cc(100, 0, 127), cc(200, 1, 127), cc(300, 1, 0), cc(2000, 0, 0)]; + } + clip.trim_end_to(24_000); + } + let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap(); + let smf = soundcraft_midi::read_smf(&bytes).unwrap(); + let ctrls = &smf.tracks[0].sequence.ctrls; + let sus: Vec<_> = ctrls.iter().filter(|c| c.kind == CtrlKind::Cc(64)).collect(); + assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}"); + assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}"); +} From 85ffa1f53ee6d04c4e99b66afd142bd4345d2009 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 18:52:51 -0400 Subject: [PATCH 101/113] packaging(flatpak): add OCI image build and GHCR publishing helper (#1233) --- packaging/linux/flatpak-bundle.sh | 16 ++++++-- packaging/linux/flatpak-oci.sh | 64 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100755 packaging/linux/flatpak-oci.sh diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 78becd59..92288276 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.soundcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" -while [[ $# -gt 0 ]]; do +while [ $# -gt 0 ]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -38,7 +40,7 @@ case "$ARCH" in esac BASENAME="soundcraft-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" -[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +51,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } +[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -61,8 +63,14 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi -if [[ "$TEST" = 1 ]]; then +if [ "$TEST" = 1 ]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=soundcraft-cli "$APP_ID" --version diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 00000000..45287706 --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build soundcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/soundcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="soundcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/soundcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi From b32065a109a93fbc539810f4e7b3965734623340 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:34:16 +0100 Subject: [PATCH 102/113] Keep score export notes when bars are shorter than a sixteenth Found while testing crates/engine/src/score.rs:~150 (measure_grid, `if ub <= ua { break; }`), used by layout() Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/score.rs | 48 +++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index 0188f923..884e8d80 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -146,12 +146,20 @@ fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> { let a = s.tempo.bar_start_tick(bar); let b = s.tempo.bar_start_tick(bar + 1); let m = s.tempo.meter_at_tick(a); - let (ua, ub) = (a / UNIT, b / UNIT); - if ub <= ua { + if b <= a { break; } - v.push((ua, ub - ua, m.numerator, m.denominator)); + let (ua, ub) = (a / UNIT, b / UNIT); bar += 1; + if ub <= ua { + // A bar shorter than a sixteenth that does not reach the next unit: the following + // bar carries this span, so the grid stays contiguous. + continue; + } + // Bars shorter than a sixteenth are written as one measure of the units they cover, so + // the time signature matches the measure's content. + let (num, den) = if m.ticks_per_bar() < UNIT { (u32::try_from(ub - ua).unwrap_or(1), 16) } else { (m.numerator, m.denominator) }; + v.push((ua, ub - ua, num, den)); if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES { break; } @@ -644,6 +652,40 @@ mod tests { assert!(s.starts_with(" usize { + parts.iter().flat_map(|p| &p.measures).flat_map(|m| &m.items).filter(|i| !i.tie_stop).map(|i| i.pitches.len()).sum() + } + + #[test] + fn bars_shorter_than_a_sixteenth_keep_every_note() { + let base = crate::demo::demo_engine(); + let baseline = note_starts(&layout(base.session(), &[])); + assert!(baseline > 0, "demo has notes"); + for (n, d) in [(1, 32), (1, 64), (2, 64), (3, 64)] { + for mid in [false, true] { + let mut e = crate::demo::demo_engine(); + let tick = if mid { e.session().tempo.bar_start_tick(5) } else { 0 }; + e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter"); + let parts = layout(e.session(), &[]); + assert!(parts.iter().all(|p| !p.measures.is_empty()), "{n}/{d} mid={mid}: empty part"); + assert_eq!(note_starts(&parts), baseline, "{n}/{d} mid={mid}: notes lost"); + for p in &parts { + for m in &p.measures { + let units: u32 = m.items.iter().map(|i| i.units).sum(); + // The written meter matches the measure's content (divisions are sixteenths). + assert_eq!(units * m.denominator, 16 * m.numerator, "{n}/{d} mid={mid}: meter/content mismatch"); + } + } + let setup = ScoreSetup::from_session(e.session()); + let x = musicxml(&parts, &setup).expect("musicxml fits"); + assert!(x.contains(""), "{n}/{d} mid={mid}: musicxml has no notes"); + let s = svg(&parts, &setup).expect("svg fits"); + assert!(s.contains(" Date: Sat, 31 Oct 2026 23:43:12 -0400 Subject: [PATCH 103/113] ci: grant packages write permission to release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52b76762..983c62d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: # The shared workflow creates the draft release, which needs write access to contents. permissions: contents: write + packages: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: From cf4029688805303f7815fcf44ea8f4dc66c18bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:56:59 +0000 Subject: [PATCH 104/113] fix: reject unsigned AIFF channel counts Co-Authored-By: GPT-5.6-Luna Co-Authored-By: GPT-5.6-Terra --- crates/audio-io/src/aiff.rs | 8 +++--- crates/audio-io/tests/hunter_aiff_channels.rs | 26 ------------------- crates/audio-io/tests/it/malformed.rs | 9 +++++++ 3 files changed, 13 insertions(+), 30 deletions(-) delete mode 100644 crates/audio-io/tests/hunter_aiff_channels.rs diff --git a/crates/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs index 3c851d72..77ed6f80 100644 --- a/crates/audio-io/src/aiff.rs +++ b/crates/audio-io/src/aiff.rs @@ -63,7 +63,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into())); } let is_aifc = tag(b, 8).is_some_and(|t| &t == b"AIFC"); - let mut comm: Option<(u16, u32, i16, f64, [u8; 4])> = None; + let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None; let mut ssnd: Option<(usize, usize)> = None; let mut pos: usize = 12; @@ -81,7 +81,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { match &id { b"COMM" => { let short = || AudioError::Malformed("COMM chunk too short".into()); - let channels = be_u16(c, 0).ok_or_else(short)?; + let channels = be_u16(c, 0).ok_or_else(short)? as i16; let frames = be_u32(c, 2).ok_or_else(short)?; let bits = be_u16(c, 6).ok_or_else(short)? as i16; let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?; @@ -106,7 +106,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { } let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?; - if channels == 0 { + if channels <= 0 { return Err(AudioError::Malformed("AIFF declares zero or negative channels".into())); } if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) { @@ -137,7 +137,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { let block = kind.bytes().saturating_mul(channels as usize).max(1); let frames = u64::from(frames).min((data_len / block) as u64); Ok(AiffParse::Native(AiffLayout { - info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels, frames, bwf: None }, + info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels: channels as u16, frames, bwf: None }, kind, data_start, data_len, diff --git a/crates/audio-io/tests/hunter_aiff_channels.rs b/crates/audio-io/tests/hunter_aiff_channels.rs deleted file mode 100644 index 2fa95f2e..00000000 --- a/crates/audio-io/tests/hunter_aiff_channels.rs +++ /dev/null @@ -1,26 +0,0 @@ -use soundcraft_audio_io::probe; - -#[test] -fn aiff_accepts_full_unsigned_channel_count() { - let mut comm = Vec::new(); - comm.extend_from_slice(&32_768u16.to_be_bytes()); - comm.extend_from_slice(&0u32.to_be_bytes()); - comm.extend_from_slice(&16u16.to_be_bytes()); - // 44,100 Hz as an 80-bit extended float. - comm.extend_from_slice(&[0x40, 0x0e, 0xac, 0x44, 0, 0, 0, 0, 0, 0]); - - let mut file = b"FORM".to_vec(); - file.extend_from_slice(&0u32.to_be_bytes()); - file.extend_from_slice(b"AIFF"); - file.extend_from_slice(b"COMM"); - file.extend_from_slice(&(comm.len() as u32).to_be_bytes()); - file.extend_from_slice(&comm); - file.extend_from_slice(b"SSND"); - file.extend_from_slice(&8u32.to_be_bytes()); - file.extend_from_slice(&[0; 8]); - let form_size = (file.len() - 8) as u32; - file[4..8].copy_from_slice(&form_size.to_be_bytes()); - - let info = probe(&file, Some("aiff")).expect("valid AIFF"); - assert_eq!(info.channels, 32_768); -} diff --git a/crates/audio-io/tests/it/malformed.rs b/crates/audio-io/tests/it/malformed.rs index 0351725d..c618c8f0 100644 --- a/crates/audio-io/tests/it/malformed.rs +++ b/crates/audio-io/tests/it/malformed.rs @@ -110,6 +110,15 @@ fn zero_channels_is_malformed() { assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_)))); } +#[test] +fn aiff_negative_channel_count_is_malformed() { + let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1); + let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap(); + aiff[comm + 8] = 0x80; + aiff[comm + 9] = 0; + assert!(probe(&aiff, None).is_err()); +} + #[test] fn zero_sample_rate_is_malformed() { let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]); From 265e526f6a08ae0b2948f15e6b3570d0ce2b5ece Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Sun, 1 Nov 2026 11:46:36 +0000 Subject: [PATCH 105/113] fix(ui): keep row actions from submitting New Tracks --- crates/ui-egui/src/dialogs/new_tracks.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/new_tracks.rs b/crates/ui-egui/src/dialogs/new_tracks.rs index 9da20f6e..19e7352b 100644 --- a/crates/ui-egui/src/dialogs/new_tracks.rs +++ b/crates/ui-egui/src/dialogs/new_tracks.rs @@ -243,6 +243,7 @@ impl NewTracks { }); } }); + let rows_changed = remove.is_some() || insert.is_some(); if let Some(index) = remove { self.active = index; self.remove(index); @@ -256,7 +257,8 @@ impl NewTracks { if !can_create { ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time."); } - if crate::dialogs::buttons_enabled(ui, "Create", enter, can_create) { + // Enter on a focused row button changes that row only; it must not also submit the dialog. + if crate::dialogs::buttons_enabled(ui, "Create", enter && !rows_changed, can_create) && !rows_changed { Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::>()})) } else { None @@ -505,6 +507,21 @@ mod tests { assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::>(), ["Dialogue", "Effects"]); } + /// Enter on a focused row button changes the rows without also creating tracks. + #[test] + fn enter_on_row_buttons_does_not_submit_the_dialog() { + let mut h = harness(); + h.get_by_role_and_label(Role::Button, "+").focus(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert_eq!(tracks(&h).rows.len(), 2); + assert!(h.state().engine.session().tracks.is_empty()); + + h.get_all_by_role_and_label(Role::Button, "−").next().unwrap().focus(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert_eq!(tracks(&h).rows.len(), 1); + assert!(h.state().engine.session().tracks.is_empty()); + } + /// Removing a preceding row does not transfer widget state to another row. #[test] fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() { From 9e902b48fedd5476d638163f50091751ff63200f Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Sun, 1 Nov 2026 12:30:51 +0000 Subject: [PATCH 106/113] fix(ui): let dialog cancellation win over Enter --- crates/ui-egui/src/dialogs.rs | 4 +++- crates/ui-egui/src/dialogs/new_tracks.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 53751f94..cd420ec9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -798,9 +798,11 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { /// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works. pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool { let mut pressed = enter && ready; + let mut canceled = false; ui.separator(); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { + canceled = true; ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true)); } let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent); @@ -808,7 +810,7 @@ pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: b pressed = true; } }); - pressed + pressed && !canceled } /// A name field that takes the focus with its text selected, so typing replaces the old name. diff --git a/crates/ui-egui/src/dialogs/new_tracks.rs b/crates/ui-egui/src/dialogs/new_tracks.rs index 19e7352b..cde6bd80 100644 --- a/crates/ui-egui/src/dialogs/new_tracks.rs +++ b/crates/ui-egui/src/dialogs/new_tracks.rs @@ -522,6 +522,16 @@ mod tests { assert!(h.state().engine.session().tracks.is_empty()); } + /// Enter on the focused Cancel button closes the dialog without creating tracks. + #[test] + fn enter_on_cancel_does_not_submit_the_dialog() { + let mut h = harness(); + h.get_by_role_and_label(Role::Button, "Cancel").focus(); + chord(&mut h, Modifiers::NONE, Key::Enter); + assert!(h.state().dialogs.open.is_none()); + assert!(h.state().engine.session().tracks.is_empty()); + } + /// Removing a preceding row does not transfer widget state to another row. #[test] fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() { From 1a4a88fb0d15e5b0a05c8686fa8d823c865c674a Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Sun, 1 Nov 2026 13:40:45 +0000 Subject: [PATCH 107/113] fix(ui): cancel pending dialog actions on Escape --- crates/ui-egui/src/dialogs.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cd420ec9..3d3bfacb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -711,14 +711,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { }); if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::unique("dlg_cancel")).unwrap_or(false)) { keep = false; + action = None; app.dialogs.after_save = None; app.dialogs.pending_session = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::unique("dlg_cancel"))); } - // Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command. - if !keep && matches!(d, Dialog::AutoRename { .. }) { - action = None; - } if let Some(strip) = rename_from && keep { @@ -985,7 +982,7 @@ mod tests { assert_eq!(name, "Foley"); } - /// Enter creates and Escape cancels without leaking events to the background UI. + /// Enter creates, while Escape cancels even when both keys arrive in one frame. #[test] fn new_tracks_enter_creates_and_escape_cancels_on_each_platform() { for os in [egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { @@ -1000,6 +997,11 @@ mod tests { dialog_frame(&ctx, &mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); assert!(app.dialogs.open.is_none()); assert_eq!(app.engine.session().tracks.len(), 3); + assert!(app.dialogs.open_for_command(&app.engine, "track.new")); + dialog_frame(&ctx, &mut app, vec![]); + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert_eq!(app.engine.session().tracks.len(), 3, "Escape must cancel a simultaneous Enter"); } } From bdb73895a58f878a284abeb691016e13f0cfe749 Mon Sep 17 00:00:00 2001 From: Sly LaMenace Date: Sun, 1 Nov 2026 13:55:09 +0000 Subject: [PATCH 108/113] fix(ui): make dialog cancellation atomic --- crates/ui-egui/src/dialogs.rs | 70 ++++++++++++++++++++++-- crates/ui-egui/src/dialogs/new_tracks.rs | 13 +++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3d3bfacb..f9e76af2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -414,6 +414,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; + let mut bounce_params: Option = None; let mut save_first: Option<(String, Value)> = None; let confirmed = matches!(d, Dialog::SaveChanges { .. }); // Strip Silence's Rename button, and the naming scheme its dialog's OK keeps. @@ -450,6 +451,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) .show(ctx, |ui| { + // Escape owns the whole frame: no focused button or other control may act first. + if esc { + return; + } ui.spacing_mut().item_spacing = vec2(8.0, 8.0); match &mut d { Dialog::NewTracks(tracks) => { @@ -502,11 +507,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(format.default_extension()); - crate::bounce::start( - app, - &json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), + bounce_params = Some( + json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), ); - keep = false; } } Dialog::RenameTrack { id, name } => { @@ -712,6 +715,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::unique("dlg_cancel")).unwrap_or(false)) { keep = false; action = None; + bounce_params = None; + save_first = None; app.dialogs.after_save = None; app.dialogs.pending_session = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::unique("dlg_cancel"))); @@ -743,6 +748,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } app.dialogs.after_save = Some(then); } + if let Some(params) = bounce_params { + keep = false; + crate::bounce::start(app, ¶ms); + } if let Some((cmd, params)) = &action && !confirmed && ask_to_save(app, cmd, params) @@ -1005,6 +1014,59 @@ mod tests { } } + /// Row shortcuts change the form without submitting when Enter arrives in the same frame. + #[test] + fn new_tracks_row_shortcuts_take_precedence_over_simultaneous_enter() { + let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac); + let rows = egui::Modifiers { command: true, mac_cmd: true, shift: true, ..Default::default() }; + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") }; + assert_eq!(tracks.rows.len(), 1, "the last row cannot be removed"); + assert!(app.engine.session().tracks.is_empty()); + + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") }; + assert_eq!(tracks.rows.len(), 2); + assert!(app.engine.session().tracks.is_empty()); + + dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]); + let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") }; + assert_eq!(tracks.rows.len(), 1); + assert!(app.engine.session().tracks.is_empty()); + } + + /// Escape prevents a simultaneous Enter from starting a background bounce. + #[test] + fn simultaneous_enter_and_escape_does_not_start_a_bounce() { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx, true); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + for _ in 0..3 { + dialog_frame(&ctx, &mut app, vec![]); + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert!(app.bounce.is_none()); + } + + /// Escape prevents a simultaneous Save from scheduling the deferred session action. + #[test] + fn simultaneous_save_and_escape_does_not_continue_after_saving() { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx, true); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default()); + let tracks = app.engine.session().tracks.len(); + app.dialogs.open = Some(Dialog::SaveChanges { then: ("session.close".into(), json!({})) }); + for _ in 0..3 { + dialog_frame(&ctx, &mut app, vec![]); + } + dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]); + assert!(app.dialogs.open.is_none()); + assert!(app.dialogs.after_save.is_none()); + assert_eq!(app.engine.session().tracks.len(), tracks); + } + /// Windows and Linux chords cover every editable field and row action. #[test] fn new_tracks_windows_and_linux_chords_cover_every_field_and_row_action() { diff --git a/crates/ui-egui/src/dialogs/new_tracks.rs b/crates/ui-egui/src/dialogs/new_tracks.rs index cde6bd80..f7fc75e1 100644 --- a/crates/ui-egui/src/dialogs/new_tracks.rs +++ b/crates/ui-egui/src/dialogs/new_tracks.rs @@ -88,9 +88,10 @@ impl NewTracks { } } - /// Handles the row, format, type and timebase shortcut families for the active row. - fn keys(&mut self, ctx: &egui::Context) { + /// Handles the row, format, type and timebase shortcuts and reports a row action. + fn keys(&mut self, ctx: &egui::Context) -> bool { let mac = ctx.os() == egui::os::OperatingSystem::Mac; + let mut row_action = false; ctx.input_mut(|i| { i.events.retain(|event| { let Event::Key { key, pressed: true, modifiers: m, .. } = event else { return true }; @@ -103,6 +104,7 @@ impl NewTracks { Key::ArrowUp => self.remove(self.active), _ => return true, } + row_action = true; return false; } if m.alt && m.ctrl && mac { @@ -135,11 +137,12 @@ impl NewTracks { false }); }); + row_action } /// Draws every creation row and returns one atomic `track.new` request when submitted. pub(crate) fn show(&mut self, ui: &mut Ui, enter: bool) -> Option { - self.keys(ui.ctx()); + let shortcut_row_action = self.keys(ui.ctx()); let widths = RowWidths::new(ui); ui.set_min_width(widths.total(ui)); // The old row must not consume text on the first frame after adding a new one. @@ -243,7 +246,7 @@ impl NewTracks { }); } }); - let rows_changed = remove.is_some() || insert.is_some(); + let row_action = shortcut_row_action || remove.is_some() || insert.is_some(); if let Some(index) = remove { self.active = index; self.remove(index); @@ -258,7 +261,7 @@ impl NewTracks { ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time."); } // Enter on a focused row button changes that row only; it must not also submit the dialog. - if crate::dialogs::buttons_enabled(ui, "Create", enter && !rows_changed, can_create) && !rows_changed { + if crate::dialogs::buttons_enabled(ui, "Create", enter && !row_action, can_create) && !row_action { Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::>()})) } else { None From d588e7466782e198abcf75a0ec688a7f395c5e1e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 12:30:07 -0500 Subject: [PATCH 109/113] docs: sync Craft App Suite list with ArtCraft Launcher --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1024eba0..1056d6b4 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,15 @@ stand on its own. | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | -| | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. From db51dd17398852ba70919c5a560d1c8ed9e3e6c6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 13:57:15 -0500 Subject: [PATCH 110/113] Release 0.16.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 401b3c60..a265b063 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4280,7 +4280,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.15.0" +version = "0.16.0" dependencies = [ "chrono", "clap", @@ -4304,7 +4304,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.15.0" +version = "0.16.0" dependencies = [ "log", "serde", @@ -4314,7 +4314,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.15.0" +version = "0.16.0" dependencies = [ "log", "proptest", @@ -4326,7 +4326,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.15.0" +version = "0.16.0" dependencies = [ "clap", "log", @@ -4339,7 +4339,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.15.0" +version = "0.16.0" dependencies = [ "clap-sys", "libloading 0.9.0", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "clap", "clap_complete", @@ -4369,7 +4369,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.15.0" +version = "0.16.0" dependencies = [ "log", "proptest", @@ -4380,7 +4380,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-docpath", "log", @@ -4403,7 +4403,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.15.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -4413,7 +4413,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.15.0" +version = "0.16.0" dependencies = [ "log", "rayon", @@ -4429,7 +4429,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.15.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -4442,7 +4442,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.15.0" +version = "0.16.0" dependencies = [ "cpal", "log", @@ -4457,7 +4457,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.15.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -4467,7 +4467,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-ui", "craft-update", @@ -4496,7 +4496,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.15.0" +version = "0.16.0" dependencies = [ "craft-media", "image", @@ -4508,7 +4508,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.15.0" +version = "0.16.0" dependencies = [ "libloading 0.9.0", "log", @@ -4524,7 +4524,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.15.0" +version = "0.16.0" dependencies = [ "eframe", "egui", @@ -6140,7 +6140,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.15.0" +version = "0.16.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 73b3e0b7..5bb43523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From c426b889bb4fbd882e7d3a497d6bf81c2d698697 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 23:05:08 -0500 Subject: [PATCH 111/113] Test MIDI export of a clip trimmed at both ends Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/it/midi_offset.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/engine/tests/it/midi_offset.rs b/crates/engine/tests/it/midi_offset.rs index f0f4b2d6..9953d08d 100644 --- a/crates/engine/tests/it/midi_offset.rs +++ b/crates/engine/tests/it/midi_offset.rs @@ -74,3 +74,25 @@ fn export_midi_releases_a_sustain_held_past_the_trim() { assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}"); assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}"); } + +#[test] +fn export_midi_keeps_the_notes_of_a_clip_trimmed_at_both_ends_in_place() { + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Both", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + for (pitch, start) in [(60, 0), (62, 1000), (64, 2000)] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": 960, "velocity": 100})).unwrap(); + } + // 24000 samples is 960 ticks and 72000 samples is 2880 ticks at 120 bpm. + e.execute("edit.trim_start_to_insertion", &json!({"tracks": [track], "at": 24_000})).unwrap(); + e.execute("edit.trim_end_to_insertion", &json!({"tracks": [track], "at": 72_000})).unwrap(); + + let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap(); + let smf = soundcraft_midi::read_smf(&bytes).unwrap(); + let mut notes: Vec<_> = smf.tracks[0].sequence.notes.iter().map(|n| (n.pitch, n.start, n.length)).collect(); + notes.sort_unstable(); + // The note cut by the start trim is gone; the others keep their place and the last stops at the end. + assert_eq!(notes, vec![(62, 1000, 960), (64, 2000, 880)]); +} From 5e8898976ef941fd3c56afb1bcbb902328ad35df Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 23:06:02 -0500 Subject: [PATCH 112/113] Test a grid of 1/16, 1/32 and 1/64 bars in a row Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/score.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index 884e8d80..70849156 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -686,6 +686,24 @@ mod tests { } } + #[test] + fn bars_of_a_sixteenth_a_32nd_and_a_64th_in_a_row_keep_a_contiguous_grid() { + let base = crate::demo::demo_engine(); + let baseline = note_starts(&layout(base.session(), &[])); + let mut e = crate::demo::demo_engine(); + // Bars 5 to 8 are 1/16, 1/32, 1/64 and 3/64 long, then 4/4 resumes. + for (bar, (n, d)) in (5..).zip([(1, 16), (1, 32), (1, 64), (3, 64), (4, 4)]) { + let tick = e.session().tempo.bar_start_tick(bar); + e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter"); + } + let grid = measure_grid(e.session(), i64::MAX); + assert_eq!(grid.first().map(|m| m.0), Some(0)); + for w in grid.windows(2) { + assert_eq!(w[0].0 + w[0].1, w[1].0, "measures leave no gap or overlap: {w:?}"); + } + assert_eq!(note_starts(&layout(e.session(), &[])), baseline, "notes lost"); + } + #[test] fn output_is_bounded_by_byte_budget() { // A part with many notes must not produce an unbounded document. From 6c3a0dfc5002dc7393fde58d7870010036b34a51 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:19:39 +0100 Subject: [PATCH 113/113] Accept VST3 multi-out instruments with up to 64 audio buses Fixes #1247 Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/src/ffi.rs | 5 +++-- crates/vst3-host/tests/common/mod.rs | 2 +- crates/vst3-host/tests/fixture/src/lib.rs | 21 ++++++++++++--------- crates/vst3-host/tests/it/fixture_e2e.rs | 18 +++++++++++++++--- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index dc65b7a8..7330cd7a 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -51,8 +51,9 @@ pub(crate) use process::{EventList, MemoryStream, ParamChanges}; const MAX_CLASSES: i32 = 1024; /// Most parameters read from one controller. pub(crate) const MAX_PARAMS: i32 = 4096; -/// Most audio buses per direction we provide buffers for. -const MAX_BUSES: i32 = 16; +/// Most audio buses per direction we provide buffers for. Multi-out instruments declare dozens +/// of (mostly inactive) aux buses, e.g. 32 for a drum sampler; every one still gets buffers. +const MAX_BUSES: i32 = 64; /// Most channels per audio bus. const MAX_BUS_CHANNELS: u32 = 64; /// Capacity of the per-block event lists (preallocated: `process` never allocates). diff --git a/crates/vst3-host/tests/common/mod.rs b/crates/vst3-host/tests/common/mod.rs index 32a50f36..32ce9177 100644 --- a/crates/vst3-host/tests/common/mod.rs +++ b/crates/vst3-host/tests/common/mod.rs @@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path { let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder); std::fs::create_dir_all(&bin).unwrap(); std::fs::copy(&built, bin.join(exe)).unwrap(); - assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5); + assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 6); dir }) } diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 68703d27..d6af5332 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -1,4 +1,4 @@ -//! Test-only VST3 plugin for soundcraft-vst3-host (five audio module classes): +//! Test-only VST3 plugin for soundcraft-vst3-host (six audio module classes): //! //! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller //! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain" @@ -11,9 +11,9 @@ //! its edit controller). No audio input, one stereo output, one event input; every output //! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and //! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too. -//! - "Fixture Wide 16x64", "Fixture Wide 17 buses", "Fixture Wide 65 channels" (`Fx`): effects -//! advertising wide layouts (16 buses of 64 channels, 17 buses of 2 channels, one bus of 65 -//! channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel +//! - "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. //! @@ -34,8 +34,9 @@ const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666); const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666); const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666); const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666); -const WIDE17_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666); +const WIDE65B_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666); const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666); +const WIDE32_CID: TUID = uid(0x5C0A1D06, 0x11112222, 0x33334444, 0x55556666); fn copy_c8(src: &str, dst: &mut [c_char]) { for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) { @@ -736,12 +737,13 @@ impl Class for Factory { type Interfaces = (IPluginFactory2,); } -const CLASSES: [(TUID, &str, &str, &str); 6] = [ +const CLASSES: [(TUID, &str, &str, &str); 7] = [ (GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"), (GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""), (SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"), (WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"), - (WIDE17_CID, "Audio Module Class", "Fixture Wide 17 buses", "Fx"), + (WIDE32_CID, "Audio Module Class", "Fixture Wide 32 buses", "Fx"), + (WIDE65B_CID, "Audio Module Class", "Fixture Wide 65 buses", "Fx"), (WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"), ]; @@ -785,8 +787,9 @@ impl IPluginFactoryTrait for Factory { .to_com_ptr::() } else if cid == SYNTH_CID { ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::() - } else if let Some((buses, channels)) = - [(WIDE16_CID, (16, 64)), (WIDE17_CID, (17, 2)), (WIDE65_CID, (1, 65))].into_iter().find_map(|(c, layout)| (c == cid).then_some(layout)) + } else if let Some((buses, channels)) = [(WIDE16_CID, (16, 64)), (WIDE32_CID, (32, 2)), (WIDE65B_CID, (65, 2)), (WIDE65_CID, (1, 65))] + .into_iter() + .find_map(|(c, layout)| (c == cid).then_some(layout)) { ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::() } else { diff --git a/crates/vst3-host/tests/it/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs index ac7d2b77..e24d6001 100644 --- a/crates/vst3-host/tests/it/fixture_e2e.rs +++ b/crates/vst3-host/tests/it/fixture_e2e.rs @@ -15,7 +15,7 @@ fn id_of(name: &str) -> String { fn scan_lists_both_audio_modules_but_not_the_controller_class() { let dir = fixture_dir(); let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]); - assert_eq!(found.len(), 5, "{found:?}"); + assert_eq!(found.len(), 6, "{found:?}"); let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap(); // `uid()` lays out the first 8 bytes differently on Windows (COM GUID order). let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" }; @@ -320,8 +320,8 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() { let mut io = vec![vec![0.5f32; 64]; 2]; p.process(&mut io, 64); assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]); - // 17 buses, or a 65-channel bus: never activated, never processed with a cut-down layout. - for name in ["Fixture Wide 17 buses", "Fixture Wide 65 channels"] { + // 65 buses, or a 65-channel bus: never activated, never processed with a cut-down layout. + for name in ["Fixture Wide 65 buses", "Fixture Wide 65 channels"] { let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap(); p.prepare(44_100.0, 64, 2); assert_eq!(p.latency(), 0, "{name}: not activated"); @@ -331,6 +331,18 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() { } } +#[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 + // active, but the plugin must still load, get buffers for every bus and play (#1247). + let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 32 buses")).unwrap(); + p.prepare(44_100.0, 64, 2); + assert_eq!(p.latency(), 3, "32-bus plugin activated"); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| x == 1.0), "32-bus layout delivered as advertised: {:?}", &io[0][..4]); +} + #[test] fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain");