From 42d08558ab5d4f1089b6fa23816faea952417c5e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:49:23 +0100 Subject: [PATCH 001/137] Clear clips and audio files from the Clip List MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the Clip List could be removed: neither its files nor its clips had a delete action, so everything stayed listed. - New `clip.clear` (Cmd+Shift+B, the Clip List Clear shortcut in Pro Tools), also offered as Clear… on every Clip List row's right-click menu. A clip leaves its track; a file leaves the session with its decoded audio and is never deleted from disk. One undo step brings everything back. - As in Pro Tools, Clear asks first. The dialog names what goes, including the clips on any track or playlist that still play a file being removed: they are removed too. Programmatic calls never open it; they pass `with_clips: true` to remove a file still in use, and fail without changing anything otherwise. - File rows can be selected now (`edit.select {sources}`, kept in `EditState::selected_sources`); with no ids, `clip.clear` acts on the Clip List selection. - Remove Warp and re-warping ignore a stored original whose file has been cleared, so they never point a clip at a missing file. --- crates/engine/src/cmd/clip.rs | 35 +++++-- crates/engine/src/cmd/clip_list.rs | 151 +++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 13 ++- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 2 +- crates/model/src/session.rs | 13 +++ crates/ui-egui/src/dialogs.rs | 90 ++++++++++++++++- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels.rs | 43 ++++---- 9 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 crates/engine/src/cmd/clip_list.rs diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 0c566d7..520e537 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -185,10 +185,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result { let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed"); // Start from the unwarped original if this clip was warped before. let key = |k: &str| format!("elastic.{}.{k}", id.0); - let orig = match (s.edit.values.get(&key("src")), s.edit.values.get(&key("off")), s.edit.values.get(&key("len"))) { - (Some(src), Some(off), Some(len)) => Some((soundcraft_model::SourceId(*src as u64), *off as i64, *len as i64)), - _ => None, - }; + let orig = warp_original(s, *id); let mut base = c.clone(); if let Some((src, off, len)) = orig { base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off }; @@ -229,12 +226,12 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result { let mut n = 0; for id in &ids { let key = |k: &str| format!("elastic.{}.{k}", id.0); - let orig = (s.edit.values.get(&key("src")).copied(), s.edit.values.get(&key("off")).copied(), s.edit.values.get(&key("len")).copied()); + let orig = warp_original(s, *id); if let Some(c) = s.find_clip_mut(*id) { c.stretch = 1.0; - if let (Some(src), Some(off), Some(len)) = orig { - c.content = soundcraft_model::ClipContent::Audio { source: soundcraft_model::SourceId(src as u64), offset: off as i64 }; - c.length = (len as i64).max(1); + 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); n += 1; } @@ -246,6 +243,14 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result { Ok(json!({"restored": n})) } +/// The audio a warped clip was rendered from, while that file is still in the session. +fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> { + let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied(); + let src = soundcraft_model::SourceId(v("src")? as u64); + s.source(src)?; + Some((src, v("off")? as i64, v("len")? as i64)) +} + fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result { let ids = clip_ids_param(e, p); let s = e.session_mut(); @@ -373,6 +378,20 @@ mod tests { assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0); } + #[test] + fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() { + let mut e = crate::demo::demo_engine(); + let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap(); + e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap(); + let clip = e.session().track(pad).unwrap().clips()[0].clone(); + e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap(); + e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap(); + let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap(); + assert_eq!(r["restored"], 0); + let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap(); + assert!(e.session().source(src).is_some()); + } + #[test] fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/clip_list.rs b/crates/engine/src/cmd/clip_list.rs new file mode 100644 index 0000000..4df0d69 --- /dev/null +++ b/crates/engine/src/cmd/clip_list.rs @@ -0,0 +1,151 @@ +//! Clip List commands. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::SourceId; +use std::collections::BTreeSet; + +const CLEAR: &str = "clip.clear"; + +pub fn specs() -> Vec { + vec![cmd!( + "clip.clear", + "Clear Clips", + [], + Some("Cmd+Shift+B"), + "{clips?: [id], sources?: [id], with_clips?: bool} — removes clips and audio files from the session (files stay on disk); default: the Clip List selection. A file still used on the timeline needs `with_clips: true`, which removes those clips too", + has_list_selection, + clear + )] +} + +fn has_list_selection(e: &Engine) -> std::result::Result<(), String> { + let ed = &e.session().edit; + if ed.selected_clips.is_empty() && ed.selected_sources.is_empty() { Err("select clips or files in the Clip List".into()) } else { Ok(()) } +} + +fn ids<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result> { + p.get(key).map(|v| T::deserialize(v).map_err(|_| bad(CLEAR, format!("`{key}` must be an array of ids")))).transpose() +} + +fn clear(e: &mut Engine, p: &Value) -> Result { + let s = e.session(); + let (clips, sources): (BTreeSet, BTreeSet) = match (ids(p, "clips")?, ids(p, "sources")?) { + (None, None) => (s.edit.selected_clips.iter().copied().collect(), s.edit.selected_sources.iter().copied().collect()), + (c, f) => (c.unwrap_or_default(), f.unwrap_or_default()), + }; + let known: BTreeSet = s.all_clips().map(|c| c.id).collect(); + if let Some(c) = clips.iter().find(|c| !known.contains(c)) { + return Err(bad(CLEAR, format!("no clip with id {c}"))); + } + let files = sources.iter().map(|f| s.source(*f).ok_or_else(|| bad(CLEAR, format!("no audio file with id {f}")))).collect::>>()?; + if clips.is_empty() && files.is_empty() { + return Err(bad(CLEAR, "nothing to clear: pass `clips` or `sources`, or select them in the Clip List")); + } + if !bool_or(p, "with_clips", false) + && let Some((f, n)) = files.iter().map(|f| (f, s.clips_using(f.id).filter(|c| !clips.contains(&c.id)).count())).find(|(_, n)| *n > 0) + { + let plural = if n == 1 { "" } else { "s" }; + return Err(bad(CLEAR, format!("`{}` is used by {n} clip{plural} on the timeline; pass `with_clips: true` to remove them too", f.name))); + } + let gone: BTreeSet = clips.iter().copied().chain(sources.iter().flat_map(|f| s.clips_using(*f).map(|c| c.id))).collect(); + let s = e.session_mut(); + for pl in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()) { + pl.clips.retain(|c| !gone.contains(&c.id)); + } + s.sources.retain(|f| !sources.contains(&f.id)); + for f in &sources { + s.pool.remove(*f); + } + s.edit.selected_clips.retain(|c| !gone.contains(c)); + s.edit.selected_sources.retain(|f| !sources.contains(f)); + Ok(json!({"clips": gone.len(), "sources": sources.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::{AudioBuffer, FileFormat}; + + fn kick(e: &Engine) -> (SourceId, Vec) { + let s = e.session(); + let f = s.sources.iter().find(|x| x.name == "Kick").map(|x| x.id).unwrap(); + (f, s.clips_using(f).map(|c| c.id).collect()) + } + + #[test] + fn clearing_a_clip_takes_it_off_the_timeline_and_keeps_its_file() { + let mut e = crate::demo::demo_engine(); + let (f, clips) = kick(&e); + let r = e.execute("clip.clear", &json!({"clips": [clips[0]]})).unwrap(); + assert_eq!(r, json!({"clips": 1, "sources": 0})); + assert!(e.session().find_clip(clips[0]).is_none()); + assert!(e.session().source(f).is_some()); + assert!(e.undo()); + assert!(e.session().find_clip(clips[0]).is_some()); + } + + #[test] + fn a_file_in_use_stays_unless_its_clips_go_too() { + let mut e = crate::demo::demo_engine(); + e.execute("track.playlist_duplicate", &json!({"track": "Kick"})).unwrap(); + let (f, clips) = kick(&e); + assert_eq!(clips.len(), 6); + let err = e.execute("clip.clear", &json!({"sources": [f]})).unwrap_err(); + assert!(err.to_string().contains("used by 6 clips"), "{err}"); + let r = e.execute("clip.clear", &json!({"sources": [f], "with_clips": true})).unwrap(); + assert_eq!(r, json!({"clips": 6, "sources": 1})); + let s = e.session(); + assert!(s.source(f).is_none() && !s.pool.contains(f)); + assert_eq!(s.clips_using(f).count(), 0); + assert_eq!(e.undo_label(), Some("Clear Clips")); + assert!(e.undo()); + assert!(e.session().pool.contains(f)); + assert_eq!(e.session().clips_using(f).count(), 6); + } + + #[test] + fn listing_a_files_clips_too_is_enough() { + let mut e = crate::demo::demo_engine(); + let (f, clips) = kick(&e); + let r = e.execute("clip.clear", &json!({"clips": clips, "sources": [f]})).unwrap(); + assert_eq!(r, json!({"clips": 3, "sources": 1})); + } + + #[test] + fn the_clip_list_selection_is_the_default_target() { + let mut e = crate::demo::demo_engine(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4_800]] }; + let f = crate::io::add_source(e.session_mut(), "Spare", buf, None, FileFormat::Wav); + let (_, clips) = kick(&e); + e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap(); + let r = e.execute("edit.select", &json!({"sources": [f, f, 999_999]})).unwrap(); + assert_eq!(r["sources"], json!([f])); + assert_eq!(r["clips"], json!([])); + let r = e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap(); + assert_eq!(r["sources"], json!([])); + e.execute("clip.clear", &json!({})).unwrap(); + assert!(e.session().find_clip(clips[0]).is_none()); + assert!(e.session().edit.selected_clips.is_empty()); + e.execute("edit.select", &json!({"sources": [f]})).unwrap(); + e.execute("clip.clear", &json!({})).unwrap(); + assert!(e.session().source(f).is_none()); + assert!(e.session().edit.selected_sources.is_empty()); + } + + #[test] + fn bad_targets_are_errors() { + let mut e = crate::demo::demo_engine(); + for p in [ + json!({}), + json!({"clips": []}), + json!({"clips": [999_999]}), + json!({"sources": [999_999]}), + json!({"sources": "Kick"}), + json!({"clips": [-1]}), + ] { + assert!(e.execute("clip.clear", &p).is_err(), "{p}"); + } + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e4f2d5..6082afb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { repeat_to_fill ), cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all), - cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select), + cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select), cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| { let s = e.session_mut(); s.edit.selected_tracks.clear(); @@ -349,6 +349,9 @@ fn select(e: &mut Engine, p: &Value) -> Result { let en = position_param(e, "edit.select", p, "end")?; let clips = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); + let sources = + p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::>()); + let list_changes = clips.is_some() || sources.is_some() || st.is_some(); let exact = bool_or(p, "exact", false); let s = e.session_mut(); if let Some(mut t) = tracks { @@ -388,13 +391,17 @@ fn select(e: &mut Engine, p: &Value) -> Result { s.edit.selected_tracks = trs; } s.edit.selected_clips = c; - } else if st.is_some() { + } else if list_changes { s.edit.selected_clips.clear(); } + if list_changes { + let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect(); + s.edit.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect(); + } if s.edit.link_timeline_edit { s.edit.timeline_selection = s.edit.selection; } - Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips})) + Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips, "sources": s.edit.selected_sources})) } fn shift_selection(e: &mut Engine, dir: i64) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b72971..a11ba71 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -10,6 +10,7 @@ mod audio_midi; mod audiosuite; mod clap; mod clip; +mod clip_list; mod clip_more; mod edit; mod edit_more; @@ -116,6 +117,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(view_more::specs()); v.extend(track_more::specs()); v.extend(clip_more::specs()); + v.extend(clip_list::specs()); v.extend(event_more::specs()); v.extend(setup_more::specs()); v.extend(clap::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 88584e0..3662cde 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -227,7 +227,7 @@ impl Engine { if let Err(reason) = (spec.enabled)(self) { // A call that names its targets (tracks, clips, a range) does not need a selection; // the command itself validates them. - let names_targets = ["track", "tracks", "clip", "clips", "start", "end", "at"].iter().any(|k| params.get(k).is_some()); + let names_targets = ["track", "tracks", "clip", "clips", "sources", "start", "end", "at"].iter().any(|k| params.get(k).is_some()); if !names_targets { return Err(EngineError::Disabled(id.to_string(), reason)); } diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index f3537d7..c475e7c 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -160,6 +160,9 @@ pub struct EditState { /// Clips explicitly selected (Grabber selection). #[serde(default)] pub selected_clips: Vec, + /// Audio files selected in the Clip List. + #[serde(default)] + pub selected_sources: Vec, /// Playhead position when stopped. pub playhead: Samples, pub grid: GridValue, @@ -234,6 +237,7 @@ impl Default for EditState { selection: Range::default(), selected_tracks: Vec::new(), selected_clips: Vec::new(), + selected_sources: Vec::new(), playhead: 0, grid: GridValue::default(), nudge: GridValue::Seconds(1.0), @@ -399,6 +403,15 @@ impl Session { self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id))) } + /// Clips on every playlist, alternates included. + pub fn all_clips(&self) -> impl Iterator { + self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips)) + } + + pub fn clips_using(&self, src: SourceId) -> impl Iterator { + self.all_clips().filter(move |c| c.source() == Some(src)) + } + /// End of the last clip on any track (session length). pub fn content_end(&self) -> Samples { self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..f879050 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold}; use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; -use soundcraft_model::{ClipId, TrackId}; +use soundcraft_model::{ClipId, Session, SourceId, TrackId}; use soundcraft_time::Samples; #[derive(Debug, Clone)] @@ -15,6 +15,7 @@ pub enum Dialog { Bounce { path: String, format: String, bit_depth: String, normalize: bool }, RenameTrack { id: TrackId, name: String }, RenameClip { id: ClipId, name: String }, + ClearClips { clips: Vec, sources: Vec, message: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler @@ -38,6 +39,7 @@ impl Dialogs { Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", Dialog::RenameClip { .. } => "rename_clip", + Dialog::ClearClips { .. } => "clear_clips", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", Dialog::TempoChange { .. } => "tempo_change", @@ -57,11 +59,22 @@ impl Dialogs { self.open = Some(Dialog::RenameClip { id, name: name.to_string() }); } + /// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear. + pub fn open_clear(&mut self, e: &Engine, clips: Vec, sources: Vec) -> bool { + let Some(message) = clear_message(e.session(), &clips, &sources) else { return false }; + self.open = Some(Dialog::ClearClips { clips, sources, message }); + true + } + /// Open the dialog for a command; false when the command has no dialog. pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool { let home = default_dir(); let name = e.session().name.clone(); self.open = Some(match id { + "clip.clear" => { + let ed = &e.session().edit; + return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); + } "track.new" => { Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } } @@ -146,6 +159,41 @@ fn path_dialog(id: &str, home: &str, name: &str) -> Option { Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() }) } +fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option { + let listed: std::collections::BTreeSet = clips.iter().copied().collect(); + let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect(); + let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect(); + let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") }; + let what = match (names.as_slice(), files.as_slice()) { + ([], []) => return None, + ([name], []) => format!("the clip \"{name}\""), + ([], [f]) => format!("the file \"{}\"", f.name), + (c, []) => count(c.len(), "clip"), + ([], f) => count(f.len(), "file"), + (c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")), + }; + let mut text = format!("Remove {what} from the session?"); + let used: Vec<(&str, usize)> = + files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect(); + let n: usize = used.iter().map(|u| u.1).sum(); + let users = if n == 1 { + "a clip on the timeline, which is removed too.".to_string() + } else { + format!("{n} clips on the timeline, which are removed too.") + }; + match used.as_slice() { + [] => {} + [_] if files.len() == 1 => text += &format!(" It is used by {users}"), + u if u.len() == files.len() => text += &format!(" They are used by {users}"), + [(name, _)] => text += &format!(" \"{name}\" is used by {users}"), + many => text += &format!(" {} of the files are used by {users}", many.len()), + } + if !files.is_empty() && files.iter().all(|f| !f.unsaved) { + text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." }; + } + Some(text) +} + fn default_dir() -> String { #[cfg(not(target_arch = "wasm32"))] { @@ -169,6 +217,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", + Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", Dialog::Fades { .. } => "Fades", @@ -286,6 +335,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } } + Dialog::ClearClips { clips, sources, message } => { + ui.set_max_width(340.0); + ui.label(message.as_str()); + if buttons(ui, "Remove", enter) { + action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true}))); + } + } Dialog::PathPrompt { cmd, path, key, .. } => { ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); if let Some(pick) = &app.services.pick_open @@ -522,4 +578,36 @@ mod tests { assert_eq!(params["at"], 96_000); assert_eq!(params["bpm"], 133.5); } + + #[test] + fn clear_always_asks_and_names_the_timeline_clips_that_go_too() { + let mut e = soundcraft_engine::demo::demo_engine(); + let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap(); + let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats")); + let kick_clips: Vec = e.session().clips_using(kick).map(|c| c.id).collect(); + let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id; + let message = |d: &Dialogs| match &d.open { + Some(Dialog::ClearClips { message, .. }) => message.clone(), + other => panic!("{other:?}"), + }; + let mut d = Dialogs::default(); + assert!(!d.open_for_command(&e, "clip.clear")); + e.execute("edit.select", &json!({"clips": [snare]})).unwrap(); + assert!(d.open_for_command(&e, "clip.clear")); + assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?"); + e.execute("edit.select", &json!({"sources": [kick]})).unwrap(); + assert!(d.open_for_command(&e, "clip.clear")); + assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick])); + assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too."); + assert!(d.open_clear(&e, kick_clips.clone(), vec![kick])); + assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?"); + assert!(d.open_clear(&e, kick_clips, vec![kick, hats])); + assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too."); + e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false); + assert!(d.open_clear(&e, vec![], vec![kick, hats])); + assert_eq!( + message(&d), + "Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk." + ); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8c40e7..7f1b9a4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -131,6 +131,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ /// Commands that open a dialog when invoked from a menu (programmatic calls never do). const DIALOG_COMMANDS: &[&str] = &[ + "clip.clear", "track.new", "file.bounce_mix", "session.new", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 33c4324..24eadb7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -4,6 +4,7 @@ use crate::SoundApp; use crate::theme::{Tokens, bold, mono, regular, rgb}; use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; +use soundcraft_model::{ClipId, SourceId}; use soundcraft_time::format_position; fn panel_header(ui: &mut Ui, title: &str) { @@ -100,51 +101,58 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); panel_header(ui, "CLIPS"); - let items: Vec<(Option, String, bool, [u8; 3])> = { + let items: Vec<(u64, String, bool, [u8; 3], bool)> = { let s = app.engine.session(); - let mut v: Vec<(Option, String, bool, [u8; 3])> = Vec::new(); + let mut v = Vec::new(); // Whole files first (bold), then clips on tracks. for src in &s.sources { - v.push((Some(src.id.0), format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150])); + v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id))); } for tr in &s.tracks { for c in tr.clips() { - v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color))); + v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id))); } } v }; - let selected: Vec = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect(); let fkey = egui::Id::new("clip_filter"); let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default(); ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY)); ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone())); let needle = filter.to_lowercase(); - let items: Vec<_> = items.into_iter().filter(|(_, n, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect(); + let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect(); egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| { - for (id, name, whole, color) in items { + for (id, name, whole, color, selected) in items { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() }); - if whole && let Some(src) = id { - resp.context_menu(|ui| { + resp.context_menu(|ui| { + if whole { if ui.button("Place on New Track").clicked() { - let _ = app.run("clip.place_source", json!({"source": src})); + let _ = app.run("clip.place_source", json!({"source": id})); ui.close(); } let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0); if let Some(t) = sel_track && ui.button("Place on Selected Track").clicked() { - let _ = app.run("clip.place_source", json!({"source": src, "track": t})); + let _ = app.run("clip.place_source", json!({"source": id, "track": t})); ui.close(); } - }); + ui.separator(); + } + if ui.button("Clear…").clicked() { + let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) }; + app.dialogs.open_clear(&app.engine, clips, sources); + ui.close(); + } + }); + if whole { // Whole files drag onto tracks. - resp.dnd_set_drag_payload(crate::DragSource(src)); + resp.dnd_set_drag_payload(crate::DragSource(id)); if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } } - if !whole && id.is_some_and(|i| selected.contains(&i)) { + if selected { ui.painter().rect_filled(r, 0.0, t.row_selected); } ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color)); @@ -155,11 +163,8 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { if whole { bold(11.0) } else { regular(11.0) }, t.text, ); - if resp.clicked() - && !whole - && let Some(i) = id - { - let _ = app.run("edit.select", json!({"clips": [i]})); + if resp.clicked() { + let _ = app.run("edit.select", if whole { json!({"sources": [id]}) } else { json!({"clips": [id]}) }); } } }); From cfe6b42d001ea93ffa7291cceb8602b8cf576d5c Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:46:34 +0100 Subject: [PATCH 002/137] Record takes straight to disk The input callback appended every block to one growing Vec: it reallocated on the audio thread as the take grew, kept every input channel in RAM, and the take existed only in memory until Stop, so a crash during a take lost it. - The input callback copies each block into a preallocated lock-free ring (atomic slots, no unsafe) and never locks or allocates. Samples that don't fit are paid back as silence before the next block, so the take keeps its timing; the loss is reported in the status line. - A writer thread drains the ring every 10 ms into one 32-bit float WAV per armed track, holding only that track's input channels. Files go to the session's Audio Files folder, or to its autosave folder while the session has never been saved, named like the incumbent's takes (_.wav). An existing file is never overwritten. - The header is rewritten every second, so a file left by a crash plays up to the last update. Above 4 GiB it switches to RF64 in place. - If a take file can't be created, Record doesn't start and says why; a write error ends that take where it failed. - At Stop the files become the takes' sources. Loop recording keeps one file per track, one clip per pass (at most 1000) on its own playlist, in one undo step. - Saving uses a file already in the target's Audio Files folder in place instead of rewriting it, so neither Save nor autosave copies a take. A take recorded at another rate than the session's is saved once at the session's rate. soundcraft-audio-io gains wav_float_header (its length doesn't depend on the frame count, so it can be rewritten in place); encode shares the header code. Splitting a loop take into passes moved from the UI into soundcraft_engine::io::add_recording, where it is tested. --- crates/audio-io/src/lib.rs | 11 ++ crates/audio-io/src/wav.rs | 123 ++++++------ crates/audio-io/tests/roundtrip.rs | 18 +- crates/engine/src/io.rs | 148 +++++++++++--- crates/playback/Cargo.toml | 4 +- crates/playback/src/record.rs | 300 ++++++++++++++++++++++------- crates/ui-egui/src/lib.rs | 79 ++++---- 7 files changed, 481 insertions(+), 202 deletions(-) diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 782cb9a..87241c6 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -8,6 +8,7 @@ //! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`. //! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in //! encoder), with optional TPDF dither. +//! * [`wav_float_header`] heads a float WAV written while it grows (recording). //! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -289,3 +290,13 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u pub fn wav_channel_mask(bytes: &[u8]) -> Option { wav::read_channel_mask(bytes) } + +/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its +/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it +/// switches to RF64 above 4 GiB. +pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result> { + if channels == 0 || sample_rate == 0 { + return Err(AudioError::Encode("a WAV needs channels and a sample rate".into())); + } + wav::header(channels, sample_rate, (32, true), None, None, frames, false) +} diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index cf14fb7..df56010 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -276,60 +276,8 @@ pub(crate) fn encode( BitDepth::Float32 => (32, true), }; let bytes = usize::from(bits / 8); - let block = bytes.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 data_len = block.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 }; - - let mut fmt = Vec::with_capacity(40); - 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(&buf.sample_rate.to_le_bytes()); - fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes()); - fmt.extend_from_slice(&block_align.to_le_bytes()); - fmt.extend_from_slice(&bits.to_le_bytes()); - if extensible { - fmt.extend_from_slice(&22u16.to_le_bytes()); - fmt.extend_from_slice(&bits.to_le_bytes()); - fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes()); - fmt.extend_from_slice(&fmt_tag.to_le_bytes()); - fmt.extend_from_slice(&GUID_TAIL); - } - - let mut head = Vec::with_capacity(1024); - let rf64_body_len = 28usize; - // Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place). - let mut meta = Vec::new(); - if let Some(bwf) = bwf { - push_chunk(&mut meta, b"bext", &bext_chunk(bwf)); - } - push_chunk(&mut meta, b"fmt ", &fmt); - if is_float { - push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes()); - } - let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1); - let rf64 = force_rf64 || u32::try_from(total).is_err(); - - if rf64 { - head.extend_from_slice(b"RF64"); - head.extend_from_slice(&u32::MAX.to_le_bytes()); - head.extend_from_slice(b"WAVE"); - let mut ds64 = Vec::with_capacity(rf64_body_len); - ds64.extend_from_slice(&(total as u64).to_le_bytes()); - ds64.extend_from_slice(&(data_len as u64).to_le_bytes()); - ds64.extend_from_slice(&(frames as u64).to_le_bytes()); - ds64.extend_from_slice(&0u32.to_le_bytes()); - push_chunk(&mut head, b"ds64", &ds64); - } else { - head.extend_from_slice(b"RIFF"); - head.extend_from_slice(&(total as u32).to_le_bytes()); - head.extend_from_slice(b"WAVE"); - push_chunk(&mut head, b"JUNK", &[0u8; 28]); - } - head.extend_from_slice(&meta); - head.extend_from_slice(b"data"); - head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes()); + let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?; + let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; let mut out = Vec::new(); out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1)) @@ -352,3 +300,70 @@ pub(crate) fn encode( } Ok(out) } + +/// Everything before the sample data of a WAV holding `frames` frames. +pub(crate) fn header( + channels: usize, + sample_rate: u32, + (bits, is_float): (u16, bool), + bwf: Option<&BwfInfo>, + mask: Option, + frames: u64, + force_rf64: bool, +) -> 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 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 }; + + let mut fmt = Vec::with_capacity(40); + 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(&block_align.to_le_bytes()); + fmt.extend_from_slice(&bits.to_le_bytes()); + if extensible { + fmt.extend_from_slice(&22u16.to_le_bytes()); + fmt.extend_from_slice(&bits.to_le_bytes()); + fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes()); + fmt.extend_from_slice(&fmt_tag.to_le_bytes()); + fmt.extend_from_slice(&GUID_TAIL); + } + + let mut head = Vec::with_capacity(1024); + let rf64_body_len = 28usize; + // Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place). + let mut meta = Vec::new(); + if let Some(bwf) = bwf { + push_chunk(&mut meta, b"bext", &bext_chunk(bwf)); + } + push_chunk(&mut meta, b"fmt ", &fmt); + if is_float { + push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes()); + } + let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1); + let rf64 = force_rf64 || u32::try_from(total).is_err(); + + if rf64 { + head.extend_from_slice(b"RF64"); + head.extend_from_slice(&u32::MAX.to_le_bytes()); + head.extend_from_slice(b"WAVE"); + let mut ds64 = Vec::with_capacity(rf64_body_len); + ds64.extend_from_slice(&total.to_le_bytes()); + ds64.extend_from_slice(&data_len.to_le_bytes()); + ds64.extend_from_slice(&frames.to_le_bytes()); + ds64.extend_from_slice(&0u32.to_le_bytes()); + push_chunk(&mut head, b"ds64", &ds64); + } else { + head.extend_from_slice(b"RIFF"); + head.extend_from_slice(&(total as u32).to_le_bytes()); + head.extend_from_slice(b"WAVE"); + push_chunk(&mut head, b"JUNK", &[0u8; 28]); + } + head.extend_from_slice(&meta); + head.extend_from_slice(b"data"); + head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes()); + Ok(head) +} diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/roundtrip.rs index b2291e1..a53c272 100644 --- a/crates/audio-io/tests/roundtrip.rs +++ b/crates/audio-io/tests/roundtrip.rs @@ -1,5 +1,5 @@ use soundcraft_audio_io::{ - AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, + AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header, }; fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer { @@ -268,3 +268,19 @@ fn multichannel_wav_speaker_masks() { let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap(); assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None); } + +#[test] +fn float_wav_header_is_rewritten_in_place_as_the_file_grows() { + let take = sine(48_000, 2, 1_000); + let mut file = wav_float_header(2, 48_000, 0).unwrap(); + let len = file.len(); + file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes())); + assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000); + file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap()); + assert_eq!(decode(&file, None).unwrap().1.frames(), 600); + file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap()); + assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap()); + let big = wav_float_header(2, 48_000, 600_000_000).unwrap(); + assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len)); + assert!(wav_float_header(0, 48_000, 0).is_err()); +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..65aac5a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -477,6 +477,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess if exists_rel && !src.unsaved { continue; } + let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string); + if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) { + src.path = format!("Audio Files/{f}"); + continue; + } let Some(audio) = s.pool.get(src.id) else { continue }; std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; let mut target = rel_target.clone(); @@ -548,17 +553,14 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { Ok(missing) } -/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record"). -/// `channels` is the planar input capture at `rate`; each track takes the input channels its -/// input route names ("In 3" → channel 2), defaulting to the first channels. -pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec>, rate: u32) -> Result> { - let frames = channels.first().map_or(0, Vec::len); - if frames == 0 { - return Ok(Vec::new()); - } - let armed: Vec<(TrackId, usize, usize, String)> = e - .session() - .tracks +/// The record-enabled audio tracks, each with the input channels it records (those its input +/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file +/// `_.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved). +pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<(TrackId, Vec, PathBuf)> { + let dir = e.path.as_deref().and_then(|p| Path::new(p).parent()).unwrap_or(unsaved_dir); + let s = e.session(); + let take_no = s.sources.len() + 1; + s.tracks .iter() .filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive) .map(|t| { @@ -568,28 +570,118 @@ pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec>, ra } _ => 0, }; - (t.id, first, t.channels(), t.name.clone()) + let inputs = (0..t.channels()).map(|k| first.saturating_add(k) % input_channels.max(1)).collect(); + let file = |n: usize| dir.join("Audio Files").join(format!("{}_{n:02}.wav", sanitize_name(&t.name))); + let path = (take_no..take_no + 1_000).map(file).find(|p| !p.exists()).unwrap_or_else(|| file(take_no)); + (t.id, inputs, path) }) - .collect(); - if armed.is_empty() { - return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into())); - } + .collect() +} + +/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the +/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most +/// 1000), each pass after the first on a new playlist. +pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option) -> Vec { let before = e.session_arc(); let mut out = Vec::new(); - let take_no = e.session().sources.len() + 1; - for (tid, first, n, name) in armed { - let chans: Vec> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect(); - let buf = AudioBuffer { sample_rate: rate, channels: chans }; - let clip_name = format!("{name}_{take_no:02}"); + for (tid, path) in takes { + let read = std::fs::read(&path).map_err(|x| x.to_string()).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|x| x.to_string())); + let Ok((_, buf)) = read.inspect_err(|err| e.message(format!("{}: {err}", path.display()))) else { continue }; let s = e.session_mut(); - let src = add_source(s, &clip_name, buf, None, FileFormat::Wav); - let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0)); - let cid = s.new_clip_id(); - crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len)); - out.push(cid); + if s.track(tid).is_none() { + continue; + } + let resampled = buf.sample_rate != s.sample_rate.hz(); + let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take"); + let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav); + let Some(x) = s.sources.last_mut() else { continue }; + x.unsaved = resampled; + let len = i64::try_from(x.frames).unwrap_or(0); + let pass = pass.unwrap_or(len).max(1); + for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) { + let n = pass.min(len - offset); + if offset > 0 && n < pass / 8 { + break; + } + if offset > 0 + && let Some(t) = s.track_mut(tid) + { + t.playlists.push(soundcraft_model::Playlist::new(format!("{}.{:02}", t.name, t.playlists.len() + 1))); + t.active_playlist = t.playlists.len() - 1; + } + let cid = s.new_clip_id(); + crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n)); + out.push(cid); + } + } + if !out.is_empty() { + e.push_undo("Record", before); + } + out +} + +#[cfg(test)] +mod record_tests { + use super::*; + use serde_json::json; + + fn armed(e: &mut Engine, name: &str, format: &str, input: &str) -> TrackId { + let id = e.execute("track.new", &json!({"name": name, "format": format})).unwrap()["tracks"][0].as_u64().unwrap(); + e.execute("track.input", &json!({"tracks": [id], "input": input})).unwrap(); + e.execute("mix.record_arm", &json!({"tracks": [id], "value": true})).unwrap(); + TrackId(id) + } + + fn write_take(path: &Path, frames: usize) { + let mut bytes = soundcraft_audio_io::wav_float_header(1, 48_000, frames as u64).unwrap(); + bytes.extend((0..frames).flat_map(|i| (i as f32 / frames as f32 - 0.5).to_le_bytes())); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn armed_tracks_get_new_take_files_used_in_place() { + let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut e = Engine::default(); + let vox = armed(&mut e, "Vox", "mono", "In 2"); + let gtr = armed(&mut e, "Gtr", "stereo", "In 3-4"); + write_take(&dir.join("Audio Files/Vox_01.wav"), 10); + let plan = plan_takes(&e, &dir, 4); + assert_eq!(plan, vec![(vox, vec![1], dir.join("Audio Files/Vox_02.wav")), (gtr, vec![2, 3], dir.join("Audio Files/Gtr_01.wav"))]); + assert_eq!(plan_takes(&e, &dir, 2)[1].1, vec![0, 1]); + + let session = dir.join("Song.scraft").to_string_lossy().into_owned(); + e.execute("session.save_as", &json!({"path": session})).unwrap(); + write_take(&plan[0].2, 4_800); + let clips = add_recording(&mut e, 96_000, vec![(vox, plan[0].2.clone())], None); + let clip = e.session().find_clip(clips[0]).unwrap().1.clone(); + assert_eq!((clip.start, clip.length), (96_000, 4_800)); + assert_eq!(save_session(&mut e, &session).unwrap(), 0); + let src = e.session().sources.last().unwrap().clone(); + assert_eq!((src.name.as_str(), src.path.as_str(), src.unsaved, src.frames), ("Vox_02", "Audio Files/Vox_02.wav", false, 4_800)); + e.execute("edit.undo", &json!({})).unwrap(); + assert!(e.session().find_clip(clips[0]).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_loop_take_becomes_one_clip_per_pass_and_autosaves_in_place() { + let dir = std::env::temp_dir().join(format!("soundcraft-loop-take-{}", std::process::id())); + let path = dir.join("Audio Files/Vox_01.wav"); + write_take(&path, 1_050); + let mut e = Engine::default(); + let vox = armed(&mut e, "Vox", "mono", "In 1"); + assert_eq!(add_recording(&mut e, 1_000, vec![(vox, path.clone())], Some(400)).len(), 3); + assert_eq!(save_copy(&e, &dir.join("Untitled.scraft").to_string_lossy()).unwrap(), 0); + let t = e.session().track(vox).unwrap(); + let passes: Vec<_> = t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| (c.source_offset(), c.start, c.length))).collect(); + assert_eq!(passes, vec![(0, 1_000, 400), (400, 1_000, 400), (800, 1_000, 250)]); + assert_eq!((t.active_playlist, t.playlists[2].name.as_str()), (2, "Vox.03")); + e.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1); + let _ = std::fs::remove_dir_all(&dir); } - e.push_undo("Record", before); - Ok(out) } #[cfg(test)] diff --git a/crates/playback/Cargo.toml b/crates/playback/Cargo.toml index 6f728a5..5ff9904 100644 --- a/crates/playback/Cargo.toml +++ b/crates/playback/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true [dependencies] soundcraft-time.workspace = true +soundcraft-audio-io.workspace = true soundcraft-model.workspace = true soundcraft-mix.workspace = true soundcraft-dsp.workspace = true @@ -20,8 +21,5 @@ cpal.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] cpal = { workspace = true, features = ["wasm-bindgen"] } -[dev-dependencies] -soundcraft-audio-io.workspace = true - [lints] workspace = true diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index 09eb684..fb1e6c4 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -1,22 +1,78 @@ //! Audio input capture for recording. //! -//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared -//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is -//! kept in a small local backlog and appended next time, so nothing blocks the audio thread. +//! A [`Recorder`] opens the default input device. While armed, the input callback copies each +//! block into a preallocated lock-free ring (it never locks or allocates), and a writer thread +//! streams the ring into one WAV file per take, rewriting the header every second so a crash leaves +//! a playable file. -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::fs::File; +use std::io::{Seek, SeekFrom, Write}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; -/// Captured audio: planar f32 at `sample_rate`. -#[derive(Debug, Clone, Default)] -pub struct Take { - pub sample_rate: u32, - pub channels: Vec>, +use soundcraft_model::TrackId; + +/// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met. +pub type Takes = (Vec<(TrackId, PathBuf)>, Option); + +const RING_SECONDS: usize = 4; + +/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots +/// are atomics, so it needs no lock and no `unsafe`. +struct CaptureRing { + slots: Box<[AtomicU32]>, + mask: usize, + head: AtomicUsize, + tail: AtomicUsize, +} + +impl CaptureRing { + fn new(capacity: usize) -> CaptureRing { + let n = capacity.max(2).next_power_of_two(); + CaptureRing { slots: (0..n).map(|_| AtomicU32::new(0)).collect(), mask: n - 1, head: AtomicUsize::new(0), tail: AtomicUsize::new(0) } + } + + /// Queue up to `n` samples (`sample(i)` is the i-th); returns how many fit. + fn push(&self, n: usize, sample: impl Fn(usize) -> f32) -> usize { + let head = self.head.load(Ordering::Relaxed); + let n = n.min(self.slots.len().saturating_sub(head.wrapping_sub(self.tail.load(Ordering::Acquire)))); + for i in 0..n { + if let Some(slot) = self.slots.get(head.wrapping_add(i) & self.mask) { + slot.store(sample(i).to_bits(), Ordering::Relaxed); + } + } + self.head.store(head.wrapping_add(n), Ordering::Release); + n + } + + fn pop_all(&self, out: &mut Vec) { + let (tail, head) = (self.tail.load(Ordering::Relaxed), self.head.load(Ordering::Acquire)); + out.extend( + (0..head.wrapping_sub(tail)) + .map(|i| self.slots.get(tail.wrapping_add(i) & self.mask).map_or(0.0, |s| f32::from_bits(s.load(Ordering::Relaxed)))), + ); + self.tail.store(head, Ordering::Release); + } } struct Shared { armed: AtomicBool, - data: Mutex>, + ring: CaptureRing, + /// Samples lost while the ring was full. + dropped: AtomicUsize, +} + +impl Shared { + /// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued + /// as silence before the next block, so the take keeps its timing. + fn capture(&self, data: &[f32], owed: &mut usize) { + *owed -= self.ring.push(*owed, |_| 0.0); + let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 }; + *owed = owed.saturating_add(data.len() - kept); + self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed); + } } pub struct Recorder { @@ -28,12 +84,9 @@ pub struct Recorder { pub device_name: String, pub sample_rate: u32, pub channels: usize, + writer: Option>, } -/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets -/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`. -const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 }; - impl Recorder { /// Open the default input device. Errors when there is none (the caller shows a message). #[cfg(not(target_os = "freebsd"))] @@ -48,29 +101,23 @@ impl Recorder { return Err(format!("unsupported input sample format {:?}", cfg.sample_format())); } let config = cfg.config(); - let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) }); + let channels = usize::from(config.channels).max(1); + let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); + let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) }); let sh = Arc::clone(&shared); let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); let mon = Arc::clone(&monitor); - let mut backlog: Vec = Vec::new(); + let mut owed = 0; let stream = device .build_input_stream( &config, move |data: &[f32], _: &cpal::InputCallbackInfo| { crate::mark_audio_thread(); mon.push(data); - if !sh.armed.load(Ordering::Relaxed) { - backlog.clear(); - return; - } - if let Ok(mut d) = sh.data.try_lock() { - if d.len() < MAX_SAMPLES { - d.extend_from_slice(&backlog); - d.extend_from_slice(data); - } - backlog.clear(); - } else if backlog.len() < 1 << 20 { - backlog.extend_from_slice(data); + if sh.armed.load(Ordering::Acquire) { + sh.capture(data, &mut owed); + } else { + owed = 0; } }, |e| { @@ -82,14 +129,7 @@ impl Recorder { ) .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; - Ok(Recorder { - monitor, - shared, - _stream: Some(stream), - device_name: name, - sample_rate: config.sample_rate.0, - channels: usize::from(config.channels).max(1), - }) + Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None }) } #[cfg(target_os = "freebsd")] @@ -97,51 +137,171 @@ impl Recorder { Err("recording is not supported on this platform yet".into()) } - /// Start capturing (clears any previous take). - pub fn arm(&self) { - self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear(); - self.shared.armed.store(true, Ordering::Relaxed); + /// Create each take's file and start recording into them: a track's take records the given + /// input channels into a new file. + pub fn arm(&mut self, takes: Vec<(TrackId, Vec, PathBuf)>) -> Result<(), String> { + if self.writer.is_some() { + return Err("already recording".into()); + } + let takes = + takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::, _>>()?; + let ring = &self.shared.ring; + ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release); + self.shared.armed.store(true, Ordering::Release); + let (shared, channels) = (Arc::clone(&self.shared), self.channels); + match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) { + Ok(w) => self.writer = Some(w), + Err(e) => { + self.shared.armed.store(false, Ordering::Release); + return Err(e.to_string()); + } + } + Ok(()) } - pub fn is_armed(&self) -> bool { - self.shared.armed.load(Ordering::Relaxed) - } - - /// Stop capturing and return the take. - pub fn take(&self) -> Take { - self.shared.armed.store(false, Ordering::Relaxed); - let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner)); - deinterleave(&data, self.channels, self.sample_rate) - } - - /// Samples captured so far (per channel). - pub fn captured_frames(&self) -> usize { - self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.max(1)) + pub fn finish(&mut self) -> Takes { + self.shared.armed.store(false, Ordering::Release); + let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default(); + let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1); + let slow = + (lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize)); + (takes, problem.or(slow)) } } -pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take { - let ch = channels.max(1); - let frames = data.len() / ch; - let mut out = vec![Vec::with_capacity(frames); ch]; - for frame in data.chunks_exact(ch) { - for (c, v) in frame.iter().enumerate() { - if let Some(o) = out.get_mut(c) { - o.push(*v); - } +impl Drop for Recorder { + fn drop(&mut self) { + self.finish(); + } +} + +fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> Takes { + let (mut block, mut header_written) = (Vec::new(), Instant::now()); + loop { + let stopping = !shared.armed.load(Ordering::Acquire); + block.clear(); + shared.ring.pop_all(&mut block); + takes.iter_mut().for_each(|t| t.write(&block, channels)); + if stopping { + break; + } + if header_written.elapsed() >= Duration::from_secs(1) { + header_written = Instant::now(); + takes.iter_mut().for_each(TakeWriter::write_header); + } + std::thread::sleep(Duration::from_millis(10)); + } + let (mut done, mut problem) = (Vec::new(), None); + for mut t in takes { + t.write_header(); + if t.frames == 0 { + let _ = std::fs::remove_file(&t.path); + } else { + done.push((t.id, t.path)); + } + problem = problem.or(t.error); + } + (done, problem) +} + +/// One take's file. After an error it stops writing; its header keeps covering what was written. +struct TakeWriter { + id: TrackId, + inputs: Vec, + path: PathBuf, + file: File, + rate: u32, + frames: u64, + error: Option, +} + +impl TakeWriter { + fn create(id: TrackId, inputs: Vec, path: PathBuf, rate: u32) -> Result { + let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path)); + let file = file.map_err(|e| format!("{}: {e}", path.display()))?; + let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None }; + take.write_header(); + take.error.take().map_or(Ok(take), Err) + } + + /// Append this take's channels of `block` (interleaved, `channels` wide). + fn write(&mut self, block: &[f32], channels: usize) { + let channels = channels.max(1); + if self.error.is_some() { + return; + } + let bytes: Vec = block + .chunks_exact(channels) + .flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes())) + .collect(); + match self.file.write_all(&bytes) { + Ok(()) => self.frames += (block.len() / channels) as u64, + Err(e) => self.fail(e), } } - Take { sample_rate, channels: out } + + fn write_header(&mut self) { + let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) { + Ok(h) => h, + Err(e) => return self.fail(e), + }; + let f = &mut self.file; + if let Err(e) = f.seek(SeekFrom::Start(0)).and_then(|_| f.write_all(&header)).and_then(|()| f.seek(SeekFrom::End(0))) { + self.fail(e); + } + } + + fn fail(&mut self, e: impl std::fmt::Display) { + self.error.get_or_insert_with(|| format!("{}: {e}", self.path.display())); + } } #[cfg(test)] mod tests { + use super::*; + + fn decode(path: &PathBuf) -> Vec> { + soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap().1.channels + } + #[test] - fn deinterleaves() { - let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000); - assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]); - let t = super::deinterleave(&[], 0, 48_000); - assert_eq!(t.channels.len(), 1); + fn a_full_ring_owes_the_lost_samples_as_silence() { + let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) }; + let (mut owed, mut out) = (0, Vec::new()); + shared.capture(&[1.0; 6], &mut owed); + shared.capture(&[2.0; 4], &mut owed); + shared.ring.pop_all(&mut out); + shared.capture(&[3.0; 2], &mut owed); + shared.ring.pop_all(&mut out); + assert_eq!(out, [vec![1.0; 6], vec![2.0; 2], vec![0.0; 2], vec![3.0; 2]].concat()); + assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2)); + } + + #[test] + fn takes_stream_into_their_files() { + let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let gtr = TakeWriter::create(TrackId(1), vec![1, 2], dir.join("Gtr_01.wav"), 48_000).unwrap(); + let mut vox = TakeWriter::create(TrackId(2), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap(); + assert!(TakeWriter::create(TrackId(3), vec![0], dir.join("Vox_01.wav"), 48_000).is_err(), "never overwrites a file"); + vox.write(&[0.5; 300], 3); + vox.write_header(); + vox.write(&[0.25; 150], 3); + assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen"); + + let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) }); + let sh = Arc::clone(&shared); + let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3)); + let input: Vec = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect(); + let mut owed = 0; + input.chunks(3 * 480).for_each(|block| shared.capture(block, &mut owed)); + shared.armed.store(false, Ordering::Release); + let (done, problem) = writer.join().unwrap(); + let channel = |c: usize| input.iter().skip(c).step_by(3).copied().collect::>(); + assert_eq!((done.len(), problem), (2, None)); + assert_eq!(decode(&done[0].1), vec![channel(1), channel(2)]); + assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..26c14e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -402,17 +402,14 @@ impl SoundApp { return; } self.ensure_input(); - if self.recorder.is_none() { - self.ui.status = "Cannot record: no audio input device".into(); + if let Err(err) = self.arm_recorder() { + self.ui.status = format!("Cannot record: {err}"); return; } if let Some(p) = &self.player { p.set_recording(true); } let already_playing = self.is_playing(); - if let Some(r) = &self.recorder { - r.arm(); - } self.engine.transport.recording = true; if already_playing { // Punch in on the fly at the current playhead. @@ -438,6 +435,19 @@ impl SoundApp { } } + /// Arm the input for the record-enabled tracks; a session never saved records into its + /// autosave folder. + fn arm_recorder(&mut self) -> Result<(), String> { + let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); + let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name)); + let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) }; + let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels); + if plan.is_empty() { + return Err("no audio track is record-enabled".into()); + } + r.arm(plan) + } + /// Reads every live third-party plugin's state from the audio engine and stores it on its /// insert (`mix.insert_state`), so saves and bounces include it. pub fn capture_plugin_states(&mut self) { @@ -527,49 +537,26 @@ impl SoundApp { if let Some(p) = &self.player { p.set_recording(false); } - let Some(r) = &self.recorder else { return }; - let take = r.take(); - let rate = take.sample_rate; + let Some(r) = &mut self.recorder else { return }; + let (takes, problem) = r.finish(); + let tracks = takes.len().max(1); let sel = self.engine.session().edit.selection; - if self.engine.session().edit.loop_record && !sel.is_empty() { - // Split the capture into one take per loop pass; each pass gets its own playlist. - let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64())) - .unwrap_or(0) - .max(1); - let total = take.channels.first().map_or(0, Vec::len); - let passes = total.div_ceil(pass).max(1); - let mut made = 0; - for k in 0..passes { - let chunk: Vec> = - take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect(); - if chunk.first().is_none_or(|c| c.len() < pass / 8) { - continue; - } - if made > 0 { - let armed: Vec = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect(); - let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed})); - } - if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() { - made += 1; - } - } - self.ui.status = format!("Loop-recorded {made} take(s)"); - return; + let looped = self.engine.session().edit.loop_record && !sel.is_empty(); + let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) }; + let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass); + if let Some(p) = self.punch.take() { + // Keep only the punch range of each new take (merged into the Record undo step). + let clip_ids: Vec = ids.iter().map(|c| c.0).collect(); + let _ = self.engine.execute_merged( + "edit.trim_to_fill_selection", + &serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}), + "record", + ); } - match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) { - Ok(ids) => { - if let Some(p) = self.punch.take() { - // Keep only the punch range of each new take (merged into the Record undo step). - let clip_ids: Vec = ids.iter().map(|c| c.0).collect(); - let _ = self.engine.execute_merged( - "edit.trim_to_fill_selection", - &serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}), - "record", - ); - } - self.ui.status = format!("Recorded {} clip(s)", ids.len()); - } - Err(e) => self.ui.status = e.to_string(), + self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) }; + if let Some(p) = problem { + log::warn!("recording: {p}"); + self.ui.status = format!("{} ({p})", self.ui.status); } } From fa88fbb942d9538d4973ac091a7a17fdb189c4a9 Mon Sep 17 00:00:00 2001 From: Scott Campbell <251750033+scottcampbelldata@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:54:50 -0400 Subject: [PATCH 003/137] Add a built-in Sampler instrument A third built-in instrument next to Subtractive Synth and Drum Synth. It plays one sample across the keyboard: root note, transpose and fine tune, key tracking, key and velocity ranges, amp ADSR with velocity sensitivity, sample start, forward or ping-pong loops, gate or one-shot playback, 32 voices with cubic interpolation. The sample's own rate is converted as it plays. - dsp: `Plugin::set_sample` and the `SampleSource` trait hand an instrument shared audio without copying; the Adsr from the synth is reused. - model: `Insert::sample` names the session source the sampler plays. - mix: sync hands the pool audio to the instrument when it changes, and built-in instruments now get their parameter values applied (they always ran on defaults). - engine: `mix.sampler_load {track?, path, params?}` decodes WAV, AIFF, FLAC, MP3, OGG (anything file.import_audio reads) into a session source, resampled to the session rate and saved in Audio Files with the session; `mix.sampler_clear`; and `mix.instrument_param {track?, param, value}` for any built-in instrument. Tests generate their audio in code; no bundled assets. --- README.md | 3 +- ROADMAP.md | 2 +- crates/dsp/src/lib.rs | 62 ++- crates/dsp/src/plugins/mod.rs | 3 + crates/dsp/src/plugins/sampler.rs | 817 ++++++++++++++++++++++++++++ crates/dsp/src/plugins/synth.rs | 20 +- crates/engine/src/cmd/instrument.rs | 61 ++- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sampler.rs | 321 +++++++++++ crates/mix/src/lib.rs | 72 ++- crates/model/src/mixer.rs | 15 +- 11 files changed, 1352 insertions(+), 26 deletions(-) create mode 100644 crates/dsp/src/plugins/sampler.rs create mode 100644 crates/engine/src/cmd/sampler.rs diff --git a/README.md b/README.md index 89ec40e..02b59d1 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,8 @@ where things are. - **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer, channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi, rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither, - a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor + a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the + keyboard), all original. **CLAP plugins** load too. Any processor runs offline as AudioSuite, and user presets save per plugin. - **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files, diff --git a/ROADMAP.md b/ROADMAP.md index a89da3f..d3fe2da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,7 @@ the honest status: what works today, what is missing, and how far we are. | Editing | Slip/Shuffle/Spot/Grid, cut/copy/paste/clear/duplicate/repeat/shift, Paste Special (merge MIDI, markers, clip gain/effects), separate/heal/trim/consolidate/strip silence, fades & crossfades, nudge, playlists with comping, every tool gesture, edit groups, clip groups (and `.scgrp` import/export) | 75 % | 20 | | Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, plugin delay compensation, clip effects, preamp/instrument/object views | 75 % | 15 | | Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce. Surround-pan automation is still to do | 60 % | 15 | -| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing for the whole AudioSuite menu | 45 % | 30 | +| Plugins (built-in) | 26 original processors + 3 instruments (subtractive synth, drum synth, sampler), AudioSuite offline processing for the whole AudioSuite menu | 45 % | 30 | | Third-party plugins | CLAP, VST3 and Audio Units (macOS): audio, parameters, latency, notes, state saved in sessions, instances created/destroyed off the audio thread, editors (floating CLAP GUIs, VST3 views on macOS, verified with Melodyne). Missing: AU editors, embedded CLAP GUIs, VST3 editors on Windows/Linux, ARA | 80 % | 10 | | MIDI & notation | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll, velocity, rubber-band select, keyboard transpose/move), event list, step input, quantize/transpose/velocity/duration, real-time properties, Score Editor, MusicXML export (Sibelius) and printable SVG score | 55 % | 20 | | Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 10 | diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 7572ac3..3d5bd33 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -1,7 +1,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] //! SoundCraft DSP. //! -//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two +//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and three //! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline //! AudioSuite-style whole-buffer processing. //! @@ -28,6 +28,8 @@ pub use offline::FadeShape; pub use osc::Waveform; pub use plugins::eq::{eq1_response, eq7_response}; +use std::sync::Arc; + /// Plugin category, used to group the insert menu. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum Category { @@ -197,6 +199,13 @@ pub trait Plugin: Send { fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {} fn note_off(&mut self, _offset: usize, _note: u8) {} fn all_notes_off(&mut self) {} + /// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it. + /// `false` when the plugin does not play samples. Does not allocate; sounding voices stop. + /// The previous sample is dropped here, so hosts keep their own reference to it (the + /// session's source pool does) to keep deallocation off the audio thread. + fn set_sample(&mut self, _sample: Option>) -> bool { + false + } /// The plugin's complete internal state as an opaque blob (third-party plugins), for saving /// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or /// the plugin refused. May allocate: never call it in steady-state audio processing. @@ -240,6 +249,40 @@ pub trait PluginEditor: Send { fn set_param(&mut self, _id: &str, _value: f32) {} } +/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the +/// instrument converts rates as it plays). Implementations must be cheap to read from the audio +/// thread: no locks, no I/O. +pub trait SampleSource: Send + Sync { + /// Sample rate of the audio in Hz. + fn sample_rate(&self) -> f32; + fn num_channels(&self) -> usize; + /// One channel's frames (`None` past the last channel). + fn channel(&self, ch: usize) -> Option<&[f32]>; + /// Frames per channel (the shortest channel, so every index below it is readable). + fn frames(&self) -> usize { + (0..self.num_channels()).filter_map(|c| self.channel(c)).map(<[f32]>::len).min().unwrap_or(0) + } +} + +/// A sample held in memory (tests, generated audio). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SampleBuffer { + pub sample_rate: f32, + pub channels: Vec>, +} + +impl SampleSource for SampleBuffer { + fn sample_rate(&self) -> f32 { + self.sample_rate + } + fn num_channels(&self) -> usize { + self.channels.len() + } + fn channel(&self, ch: usize) -> Option<&[f32]> { + self.channels.get(ch).map(Vec::as_slice) + } +} + /// The plugin registry. pub fn plugins() -> &'static [&'static PluginInfo] { plugins::REGISTRY @@ -298,4 +341,21 @@ mod trait_default_tests { assert!(p.editor().is_none(), "{}", info.id); } } + + #[test] + fn only_the_sampler_takes_samples() { + for info in plugins() { + let mut p = create(info.id).unwrap(); + let s: Arc = Arc::new(SampleBuffer { sample_rate: 48_000.0, channels: vec![vec![0.0; 16]] }); + assert_eq!(p.set_sample(Some(s)), info.id == "sampler", "{}", info.id); + assert_eq!(p.set_sample(None), info.id == "sampler", "{}", info.id); + } + } + + #[test] + fn sample_buffer_frames_is_the_shortest_channel() { + let s = SampleBuffer { sample_rate: 44_100.0, channels: vec![vec![0.0; 10], vec![0.0; 7]] }; + assert_eq!(s.frames(), 7); + assert_eq!(SampleBuffer::default().frames(), 0); + } } diff --git a/crates/dsp/src/plugins/mod.rs b/crates/dsp/src/plugins/mod.rs index a190835..aef3e39 100644 --- a/crates/dsp/src/plugins/mod.rs +++ b/crates/dsp/src/plugins/mod.rs @@ -7,6 +7,7 @@ pub mod harmonic; pub mod modulation; pub mod pitch; pub mod reverb; +pub mod sampler; pub mod strip; pub mod synth; pub mod utility; @@ -41,6 +42,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[ &utility::DITHER_INFO, &synth::SYNTH_INFO, &drums::DRUM_INFO, + &sampler::SAMPLER_INFO, ]; /// Constructs an (unprepared) plugin by id. @@ -72,6 +74,7 @@ pub(crate) fn instantiate(id: &str) -> Option> { "dither" => Box::new(utility::Dither::new()), "subtractive_synth" => Box::new(synth::SubtractiveSynth::new()), "drum_synth" => Box::new(drums::DrumSynth::new()), + "sampler" => Box::new(sampler::Sampler::new()), _ => return None, }) } diff --git a/crates/dsp/src/plugins/sampler.rs b/crates/dsp/src/plugins/sampler.rs new file mode 100644 index 0000000..c181687 --- /dev/null +++ b/crates/dsp/src/plugins/sampler.rs @@ -0,0 +1,817 @@ +//! Sampler: plays one recorded sample across the keyboard, 32 voices. +//! +//! Each note plays the sample pitched from its root note (an octave up plays twice as fast) with +//! 4-point cubic interpolation, which also converts the sample's own rate to the host's. Key and +//! velocity ranges, an amp ADSR with velocity sensitivity, a sample start point, forward or +//! ping-pong loops, and gate or one-shot playback. The host hands the audio over with +//! [`Plugin::set_sample`]; without one the Sampler is silent. + +use std::sync::Arc; + +use super::synth::{Adsr, AdsrRates, Event, EventKind, EventQueue, MAX_EVENTS, Stage}; +use crate::params::{Params, choice, lin, log, param_plumbing, toggle}; +use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr}; +use crate::{Category, ParamInfo, Plugin, PluginInfo, SampleSource, Unit, db_to_gain}; + +const PLAY_MODES: &[&str] = &["Gate", "One Shot"]; +const LOOP_MODES: &[&str] = &["Off", "Forward", "Ping-Pong"]; + +const ROOT: usize = 0; +const TRANSPOSE: usize = 1; +const FINE: usize = 2; +const KEY_TRACK: usize = 3; +const KEY_LOW: usize = 4; +const KEY_HIGH: usize = 5; +const VEL_LOW: usize = 6; +const VEL_HIGH: usize = 7; +const VEL_SENS: usize = 8; +const ATTACK: usize = 9; +const DECAY: usize = 10; +const SUSTAIN: usize = 11; +const RELEASE: usize = 12; +const PLAY_MODE: usize = 13; +const START: usize = 14; +const LOOP_MODE: usize = 15; +const LOOP_START: usize = 16; +const LOOP_END: usize = 17; +const LEVEL: usize = 18; + +static SAMPLER_PARAMS: [ParamInfo; 19] = [ + lin("root_note", "Root Note", 0.0, 127.0, 60.0, Unit::None), + lin("transpose", "Transpose", -48.0, 48.0, 0.0, Unit::Semitones), + lin("fine_tune", "Fine Tune", -100.0, 100.0, 0.0, Unit::Cents), + toggle("key_track", "Key Tracking", true), + lin("key_low", "Lowest Key", 0.0, 127.0, 0.0, Unit::None), + lin("key_high", "Highest Key", 0.0, 127.0, 127.0, Unit::None), + lin("vel_low", "Lowest Velocity", 1.0, 127.0, 1.0, Unit::None), + lin("vel_high", "Highest Velocity", 1.0, 127.0, 127.0, Unit::None), + lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent), + log("attack", "Attack", 0.1, 5000.0, 1.0, Unit::Ms), + log("decay", "Decay", 1.0, 10000.0, 300.0, Unit::Ms), + lin("sustain", "Sustain", 0.0, 100.0, 100.0, Unit::Percent), + log("release", "Release", 1.0, 10000.0, 150.0, Unit::Ms), + choice("play_mode", "Play Mode", PLAY_MODES, 0), + lin("start", "Sample Start", 0.0, 100.0, 0.0, Unit::Percent), + choice("loop_mode", "Loop", LOOP_MODES, 0), + lin("loop_start", "Loop Start", 0.0, 100.0, 0.0, Unit::Percent), + lin("loop_end", "Loop End", 0.0, 100.0, 100.0, Unit::Percent), + lin("level", "Level", -48.0, 12.0, 0.0, Unit::Db), +]; + +pub static SAMPLER_INFO: PluginInfo = PluginInfo { + id: "sampler", + name: "Sampler", + short_name: "Sampler", + category: Category::Instrument, + params: &SAMPLER_PARAMS, + is_instrument: true, + audiosuite: false, +}; + +const VOICES: usize = 32; +/// Fastest playback step in sample frames per output frame (keeps hostile pitch bounded). +const MAX_STEP: f64 = 256.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum LoopMode { + Off, + Forward, + PingPong, +} + +#[derive(Debug, Clone, Copy)] +struct Voice { + note: u8, + /// Key still down (gate mode only; one-shot voices are never held). + held: bool, + gain: f32, + /// Read position in sample frames. + pos: f64, + step: f64, + forward: bool, + looping: bool, + env: Adsr, +} + +impl Voice { + const fn new() -> Self { + Self { note: 0, held: false, gain: 0.0, pos: 0.0, step: 1.0, forward: true, looping: false, env: Adsr::new() } + } + + fn active(&self) -> bool { + self.env.stage != Stage::Idle + } + + fn stop(&mut self) { + *self = Voice::new(); + } +} + +/// Values derived from the parameters (refreshed on every change). +#[derive(Debug, Clone, Copy)] +struct Derived { + root: f64, + /// Transpose plus fine tune, in semitones. + offset: f64, + key_track: bool, + keys: (u8, u8), + vels: (u8, u8), + sens: f32, + one_shot: bool, + start: f64, + loop_mode: LoopMode, + /// Loop start and end as fractions of the sample (start <= end). + loop_span: (f64, f64), +} + +/// A MIDI key or velocity parameter as a whole number in `lo..=127`. +fn whole(v: f32, lo: u8) -> u8 { + if v.is_finite() { v.round().clamp(f32::from(lo), 127.0) as u8 } else { lo } +} + +fn ordered(a: u8, b: u8) -> (u8, u8) { + (a.min(b), a.max(b)) +} + +/// 4-point cubic (Catmull-Rom) interpolation of `x` at fractional frame `pos` (>= 0). Reads +/// outside the slice are silence. +#[inline] +fn hermite(x: &[f32], pos: f64) -> f32 { + let i = pos as usize; + let t = (pos - i as f64) as f32; + let at = |k: usize| x.get(k).copied().unwrap_or(0.0); + let x0 = at(i); + if t == 0.0 { + return if x0.is_finite() { x0 } else { 0.0 }; + } + let xm1 = at(i.saturating_sub(1)); + let x1 = at(i.saturating_add(1)); + let x2 = at(i.saturating_add(2)); + let c1 = 0.5 * (x1 - xm1); + let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2; + let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1); + let y = ((c3 * t + c2) * t + c1) * t + x0; + // A float file can hold NaN or infinities; they play as silence. + if y.is_finite() { y } else { 0.0 } +} + +/// Plays a sample across the keyboard. +pub struct Sampler { + p: Params, + d: Derived, + sr: f32, + ch: usize, + sample: Option>, + /// Frames in the sample (0 = nothing to play). + len: usize, + /// The sample's own rate. + sample_sr: f32, + voices: [Voice; VOICES], + events: EventQueue, + rates: AdsrRates, + level: Smoother, +} + +impl Default for Sampler { + fn default() -> Self { + Self::new() + } +} + +impl Sampler { + pub fn new() -> Self { + let mut s = Self { + p: Params::new(&SAMPLER_PARAMS), + d: Derived { + root: 60.0, + offset: 0.0, + key_track: true, + keys: (0, 127), + vels: (1, 127), + sens: 0.7, + one_shot: false, + start: 0.0, + loop_mode: LoopMode::Off, + loop_span: (0.0, 1.0), + }, + sr: 48_000.0, + ch: 0, + sample: None, + len: 0, + sample_sr: 48_000.0, + voices: [Voice::new(); VOICES], + events: EventQueue::new(), + rates: AdsrRates::default(), + level: Smoother::new(1.0), + }; + s.update(); + s + } + + fn update(&mut self) { + let v = |i| self.p.v(i); + let pct = |i| f64::from(v(i) / 100.0).clamp(0.0, 1.0); + let (ls, le) = (pct(LOOP_START), pct(LOOP_END)); + self.d = Derived { + root: f64::from(whole(v(ROOT), 0)), + offset: f64::from(v(TRANSPOSE)) + f64::from(v(FINE)) / 100.0, + key_track: self.p.on(KEY_TRACK), + keys: ordered(whole(v(KEY_LOW), 0), whole(v(KEY_HIGH), 0)), + vels: ordered(whole(v(VEL_LOW), 1), whole(v(VEL_HIGH), 1)), + sens: (v(VEL_SENS) / 100.0).clamp(0.0, 1.0), + one_shot: self.p.choice(PLAY_MODE) == 1, + start: pct(START), + loop_mode: match self.p.choice(LOOP_MODE) { + 1 => LoopMode::Forward, + 2 => LoopMode::PingPong, + _ => LoopMode::Off, + }, + loop_span: (ls.min(le), ls.max(le)), + }; + self.rates = AdsrRates::new(v(ATTACK), v(DECAY), v(SUSTAIN) / 100.0, v(RELEASE), self.sr); + self.level.set(db_to_gain(v(LEVEL))); + } + + /// The loop region in sample frames, when it is long enough to loop. + fn loop_frames(&self) -> Option<(f64, f64)> { + if self.d.loop_mode == LoopMode::Off { + return None; + } + let len = self.len as f64; + let (a, b) = (self.d.loop_span.0 * len, self.d.loop_span.1 * len); + (b - a >= 1.0).then_some((a, b)) + } + + fn start_note(&mut self, note: u8, vel: u8) { + if vel == 0 { + self.stop_note(note); + return; + } + let d = self.d; + let (note, vel) = (note.min(127), vel.min(127)); + if self.len == 0 || note < d.keys.0 || note > d.keys.1 || vel < d.vels.0 || vel > d.vels.1 { + return; + } + let semis = if d.key_track { f64::from(note) - d.root } else { 0.0 } + d.offset; + let step = f64::from(self.sample_sr) / f64::from(self.sr) * 2f64.powf(semis / 12.0); + if !step.is_finite() || step <= 0.0 { + return; + } + if !d.one_shot { + // Retriggering a held key releases the earlier voice instead of stacking it. + self.stop_note(note); + } + let idx = self + .voices + .iter() + .position(|v| !v.active()) + .or_else(|| { + self.voices + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| (a.held, a.env.level).partial_cmp(&(b.held, b.env.level)).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(i, _)| i) + }) + .unwrap_or(0); + let start = d.start * self.len.saturating_sub(1) as f64; + let looping = self.loop_frames().is_some_and(|(_, end)| start < end); + let gain = (1.0 - d.sens) + d.sens * f32::from(vel) / 127.0; + if let Some(v) = self.voices.get_mut(idx) { + *v = Voice { note, held: !d.one_shot, gain, pos: start, step: step.min(MAX_STEP), forward: true, looping, env: Adsr::new() }; + v.env.gate_on(); + } + } + + fn stop_note(&mut self, note: u8) { + for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) { + v.held = false; + v.env.gate_off(); + } + } + + fn apply(&mut self, kind: EventKind) { + match kind { + EventKind::On(n, v) => self.start_note(n, v), + EventKind::Off(n) => self.stop_note(n), + EventKind::AllOff => { + for v in &mut self.voices { + v.held = false; + v.env.gate_off(); + } + } + } + } + + /// Adds every voice's output for frame `n` into `io` and advances the voices. + #[inline] + fn render_frame(&mut self, io: &mut [Vec], ch: usize, n: usize, level: f32) { + let Some(sample) = self.sample.as_deref() else { return }; + let nch = sample.num_channels(); + let len = self.len as f64; + let mode = self.d.loop_mode; + let region = self.loop_frames(); + for v in &mut self.voices { + if !v.active() { + continue; + } + let a = v.env.next(&self.rates) * v.gain * level; + for (c, b) in io.iter_mut().take(ch).enumerate() { + let src = if nch == 1 { 0 } else { c }; + if let (Some(x), Some(data)) = (b.get_mut(n), sample.channel(src)) { + *x += hermite(data, v.pos) * a; + } + } + let r = if v.looping { region } else { None }; + advance(v, len, r, mode); + } + } +} + +/// Moves a voice's read position one output frame on, looping or ending it. +#[inline] +fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) { + v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step }; + match region { + Some((ls, le)) if mode == LoopMode::PingPong => { + if v.forward && v.pos >= le { + v.pos = (le - (v.pos - le)).max(ls); + v.forward = false; + } else if !v.forward && v.pos <= ls { + v.pos = (ls + (ls - v.pos)).min(le); + v.forward = true; + } + } + Some((ls, le)) => { + if v.pos >= le { + v.pos = ls + (v.pos - ls) % (le - ls); + } + } + None => { + if v.pos >= len || !v.pos.is_finite() { + v.stop(); + } + } + } + if !v.pos.is_finite() || v.pos < 0.0 { + v.stop(); + } +} + +impl Plugin for Sampler { + param_plumbing!(SAMPLER_INFO); + + fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) { + self.sr = sane_sr(sample_rate); + self.ch = sane_channels(channels); + self.level.set_time(20.0, self.sr); + self.update(); + self.reset(); + } + + fn reset(&mut self) { + for v in &mut self.voices { + v.stop(); + } + self.events.clear(); + self.level.snap(); + } + + fn process(&mut self, io: &mut [Vec], frames: usize) { + let ch = self.ch.min(io.len()); + let frames = frames_in(io, frames, ch); + for b in io.iter_mut().take(ch) { + b.iter_mut().take(frames).for_each(|x| *x = 0.0); + } + let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS]; + let count = { + let sorted = self.events.sorted(); + for (d, s) in evs.iter_mut().zip(sorted) { + *d = *s; + } + sorted.len().min(MAX_EVENTS) + }; + self.events.clear(); + let mut next = 0; + for n in 0..frames { + while let Some(e) = evs.get(next).filter(|_| next < count) { + if e.offset > n && n + 1 < frames { + break; + } + let kind = e.kind; + self.apply(kind); + next += 1; + } + let level = self.level.next(); + self.render_frame(io, ch, n, level); + } + // Events for an empty block take effect immediately. + for e in evs.iter().take(count).skip(next) { + self.apply(e.kind); + } + } + + fn tail_samples(&self) -> usize { + let release = ms_to_samples(self.p.v(RELEASE), self.sr); + // A one-shot keeps playing its sample after the note ends. + let rest = if self.d.one_shot { self.len as f32 * self.sr / self.sample_sr } else { 0.0 }; + let t = release + rest; + if t.is_finite() { t.min(1.0e9) as usize } else { 0 } + } + + fn note_on(&mut self, offset: usize, note: u8, velocity: u8) { + self.events.push(offset, EventKind::On(note, velocity)); + } + + fn note_off(&mut self, offset: usize, note: u8) { + self.events.push(offset, EventKind::Off(note)); + } + + fn all_notes_off(&mut self) { + self.events.push(0, EventKind::AllOff); + } + + fn set_sample(&mut self, sample: Option>) -> bool { + for v in &mut self.voices { + v.stop(); + } + self.len = sample.as_deref().map_or(0, |s| if s.num_channels() == 0 { 0 } else { s.frames() }); + let rate = sample.as_deref().map_or(self.sr, |s| s.sample_rate()); + // An unusable rate plays at the host rate rather than not at all. + self.sample_sr = if rate.is_finite() && rate > 0.0 { rate.min(1_536_000.0) } else { self.sr }; + self.sample = sample; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SampleBuffer; + use std::f32::consts::TAU; + + const SR: f32 = 48_000.0; + + fn sampler(sample: SampleBuffer) -> Sampler { + let mut s = Sampler::new(); + s.prepare(SR, 512, 2); + assert!(s.set_sample(Some(Arc::new(sample)))); + s + } + + fn mono(sample_rate: f32, data: Vec) -> SampleBuffer { + SampleBuffer { sample_rate, channels: vec![data] } + } + + fn sine(freq: f32, sr: f32, len: usize) -> Vec { + (0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect() + } + + /// Renders `len` frames in 512-frame blocks; returns both channels. + fn run(s: &mut Sampler, len: usize) -> [Vec; 2] { + let mut out = [Vec::new(), Vec::new()]; + let mut buf = vec![vec![1.0f32; 512]; 2]; + let mut done = 0; + while done < len { + let n = 512.min(len - done); + s.process(&mut buf, n); + out[0].extend_from_slice(&buf[0][..n]); + out[1].extend_from_slice(&buf[1][..n]); + done += n; + } + out + } + + fn peak(x: &[f32]) -> f32 { + x.iter().fold(0.0f32, |m, v| m.max(v.abs())) + } + + /// Frequency from upward zero crossings. + fn freq(x: &[f32], sr: f32) -> f32 { + let ups: Vec = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect(); + let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 }; + (ups.len() - 1) as f32 * sr / (b - a) as f32 + } + + #[test] + fn silent_without_a_sample_and_replaces_the_buffer() { + let mut s = Sampler::new(); + s.prepare(SR, 512, 2); + s.note_on(0, 60, 127); + let out = run(&mut s, 4800); + assert_eq!(peak(&out[0]), 0.0); + assert_eq!(peak(&out[1]), 0.0); + } + + #[test] + fn root_note_plays_the_sample_verbatim() { + let data = sine(440.0, SR, 9600); + let mut s = sampler(mono(SR, data.clone())); + s.set_param("velocity", 0.0); + s.note_on(0, 60, 100); + let out = run(&mut s, 9600); + // Past the 0.1-ms-ish attack the output is the sample itself, on both sides. + for i in 100..9600 { + assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]); + } + assert_eq!(out[0], out[1], "a mono sample feeds both channels"); + } + + #[test] + fn keys_transpose_from_the_root_note() { + let mut s = sampler(mono(SR, sine(400.0, SR, 96_000))); + s.note_on(0, 72, 127); + let up = run(&mut s, 24_000); + assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR)); + s.reset(); + s.note_on(0, 53, 127); + let down = run(&mut s, 24_000); + let want = 400.0 * 2f32.powf(-7.0 / 12.0); + assert!((freq(&down[0][480..], SR) - want).abs() < 2.0); + // Root note 48 makes key 60 play an octave up; transpose and fine tune add on top. + s.reset(); + s.set_param("root_note", 48.0); + s.set_param("transpose", -12.0); + s.set_param("fine_tune", 100.0); + s.note_on(0, 60, 127); + let out = run(&mut s, 24_000); + let want = 400.0 * 2f32.powf(1.0 / 12.0); + assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR)); + } + + #[test] + fn key_tracking_off_plays_every_key_at_the_sample_pitch() { + let mut s = sampler(mono(SR, sine(400.0, SR, 48_000))); + s.set_param("key_track", 0.0); + s.note_on(0, 90, 127); + let out = run(&mut s, 24_000); + assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0); + } + + #[test] + fn converts_the_sample_rate() { + // A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host. + let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050))); + s.note_on(0, 60, 127); + let out = run(&mut s, 60_000); + assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR)); + // And it lasts as long as the recording: one second. + assert!(peak(&out[0][46_000..47_500]) > 0.1); + assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended"); + // A 96 kHz recording is read faster, not cut short. + let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000))); + s.note_on(0, 60, 127); + let out = run(&mut s, 48_000); + assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0); + } + + #[test] + fn key_and_velocity_ranges_gate_notes() { + let mut s = sampler(mono(SR, vec![0.5; 4800])); + s.set_param("key_low", 48.0); + s.set_param("key_high", 60.0); + s.set_param("vel_low", 40.0); + s.set_param("vel_high", 100.0); + for (note, vel, sounds) in + [(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)] + { + s.reset(); + s.note_on(0, note, vel); + let out = run(&mut s, 960); + assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}"); + } + // Reversed ranges are read in order rather than muting everything. + s.set_param("key_low", 60.0); + s.set_param("key_high", 48.0); + s.reset(); + s.note_on(0, 50, 80); + assert!(peak(&run(&mut s, 960)[0]) > 0.0); + } + + #[test] + fn velocity_sensitivity_scales_level() { + let level = |sens: f32, vel: u8| { + let mut s = sampler(mono(SR, vec![0.5; 4800])); + s.set_param("velocity", sens); + s.note_on(0, 60, vel); + peak(&run(&mut s, 2400)[0][1000..]) + }; + assert!((level(100.0, 127) - 0.5).abs() < 1e-4); + assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4); + assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level"); + assert!(level(70.0, 30) < level(70.0, 127)); + } + + #[test] + fn adsr_shapes_the_note() { + let mut s = sampler(mono(SR, vec![0.5; 96_000])); + s.set_param("velocity", 0.0); + s.set_param("attack", 100.0); + s.set_param("decay", 100.0); + s.set_param("sustain", 50.0); + s.set_param("release", 100.0); + s.note_on(0, 60, 127); + let out = run(&mut s, 24_000); + // Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level. + assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]); + assert!(peak(&out[0][4700..4900]) > 0.49); + assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]); + s.note_off(0, 60); + let out = run(&mut s, 24_000); + assert!(out[0][100] > 0.2, "release starts from the sustain level"); + assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms"); + assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended"); + } + + #[test] + fn gate_mode_stops_on_note_off_and_one_shot_plays_through() { + let mut s = sampler(mono(SR, vec![0.5; 24_000])); + s.set_param("release", 1.0); + s.note_on(0, 60, 127); + s.note_off(1000, 60); + let out = run(&mut s, 24_000); + assert!(peak(&out[0][..900]) > 0.1); + assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off"); + s.set_param("play_mode", 1.0); + s.reset(); + s.note_on(0, 60, 127); + s.note_off(1000, 60); + let out = run(&mut s, 30_000); + assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off"); + assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample"); + assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample"); + // Stopping the transport still silences one-shots. + s.reset(); + s.note_on(0, 60, 127); + run(&mut s, 512); + s.all_notes_off(); + assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0); + } + + #[test] + fn retriggering_a_held_key_releases_the_old_voice() { + let mut s = sampler(mono(SR, vec![0.25; 48_000])); + s.set_param("velocity", 0.0); + s.set_param("release", 1.0); + s.note_on(0, 60, 127); + s.note_on(256, 60, 127); + let out = run(&mut s, 4800); + assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..])); + // One-shots overlap. + s.set_param("play_mode", 1.0); + s.reset(); + s.note_on(0, 60, 127); + s.note_on(256, 60, 127); + let out = run(&mut s, 4800); + assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3); + } + + #[test] + fn forward_loop_sustains_past_the_end() { + let data = sine(480.0, SR, 4800); + let mut s = sampler(mono(SR, data.clone())); + s.set_param("velocity", 0.0); + s.set_param("loop_mode", 1.0); + s.set_param("loop_start", 50.0); + s.note_on(0, 60, 127); + let out = run(&mut s, 48_000); + assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample"); + // After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz). + for i in 0..2400 { + assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}"); + } + assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0); + // Without the loop the note ends with the sample. + s.set_param("loop_mode", 0.0); + s.reset(); + s.note_on(0, 60, 127); + assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0); + } + + #[test] + fn ping_pong_loop_reverses_direction() { + // A rising ramp, looped ping-pong over the whole sample, rises then falls. + let data: Vec = (0..1000).map(|i| i as f32 / 1000.0).collect(); + let mut s = sampler(mono(SR, data)); + s.set_param("velocity", 0.0); + s.set_param("loop_mode", 2.0); + s.note_on(0, 60, 127); + let out = run(&mut s, 3000); + assert!(out[0][900] > out[0][500], "rising"); + assert!(out[0][1500] < out[0][1100], "falling after the loop end"); + assert!(out[0][2500] > out[0][2100], "rising again after the loop start"); + assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0)); + } + + #[test] + fn a_loop_too_short_to_loop_plays_once() { + let mut s = sampler(mono(SR, vec![0.5; 1000])); + s.set_param("loop_mode", 1.0); + s.set_param("loop_start", 40.0); + s.set_param("loop_end", 40.0); + s.note_on(0, 60, 127); + assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0); + } + + #[test] + fn sample_start_skips_into_the_sample() { + let data: Vec = (0..1000).map(|i| i as f32 / 1000.0).collect(); + let mut s = sampler(mono(SR, data)); + s.set_param("velocity", 0.0); + s.set_param("attack", 0.1); + s.set_param("start", 50.0); + s.note_on(0, 60, 127); + let out = run(&mut s, 2000); + assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]); + assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left"); + } + + #[test] + fn stereo_samples_keep_their_sides() { + let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] }; + let mut s = sampler(sample); + s.set_param("velocity", 0.0); + s.note_on(0, 60, 127); + let out = run(&mut s, 2400); + assert!((out[0][1000] - 0.5).abs() < 1e-6); + assert!((out[1][1000] + 0.25).abs() < 1e-6); + } + + #[test] + fn note_offsets_are_sample_accurate() { + let mut s = sampler(mono(SR, vec![0.5; 4800])); + s.note_on(300, 60, 127); + let out = run(&mut s, 512); + assert!(out[0][..300].iter().all(|&v| v == 0.0)); + assert!(out[0][301..].iter().any(|&v| v != 0.0)); + } + + #[test] + fn many_notes_steal_voices_and_stay_bounded() { + let mut s = sampler(mono(SR, sine(220.0, SR, 48_000))); + s.set_param("level", 12.0); + for n in 0..100u8 { + s.note_on(usize::from(n), n, 127); + } + let out = run(&mut s, 9600); + assert!(out[0].iter().all(|v| v.is_finite())); + assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES); + } + + #[test] + fn a_new_sample_stops_the_old_voices() { + let mut s = sampler(mono(SR, vec![0.5; 48_000])); + s.note_on(0, 60, 127); + run(&mut s, 512); + assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10]))))); + assert_eq!(peak(&run(&mut s, 512)[0]), 0.0); + assert!(s.set_sample(None)); + s.note_on(0, 60, 127); + assert_eq!(peak(&run(&mut s, 512)[0]), 0.0); + } + + #[test] + fn hostile_samples_and_params_never_panic() { + let samples = [ + SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] }, + SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] }, + SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] }, + SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] }, + SampleBuffer { sample_rate: SR, channels: vec![] }, + SampleBuffer { sample_rate: SR, channels: vec![vec![]] }, + SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] }, + SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] }, + SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] }, + ]; + let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect(); + for sample in samples { + let mut s = Sampler::new(); + s.prepare(f32::NAN, 0, 99); + s.set_sample(Some(Arc::new(sample))); + for (id, v) in ¶ms { + assert!(s.set_param(id, *v)); + for n in [0u8, 60, 127, 255] { + s.note_on(0, n, 255); + s.note_on(7, n, 1); + } + let mut io = vec![vec![0.0f32; 64]; 3]; + s.process(&mut io, 1000); + assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}"); + s.process(&mut [], 64); + let _ = s.tail_samples(); + } + } + // Loops with every mode at the extremes of pitch. + let mut s = sampler(mono(SR, sine(100.0, SR, 1000))); + for mode in [0.0, 1.0, 2.0] { + s.set_param("loop_mode", mode); + for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] { + s.set_param("transpose", t); + s.set_param("root_note", root); + s.note_on(0, 127, 127); + s.note_on(0, 0, 127); + let out = run(&mut s, 4096); + assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}"); + } + } + } +} diff --git a/crates/dsp/src/plugins/synth.rs b/crates/dsp/src/plugins/synth.rs index 8e7f8c3..be662c6 100644 --- a/crates/dsp/src/plugins/synth.rs +++ b/crates/dsp/src/plugins/synth.rs @@ -97,7 +97,7 @@ const VOICES: usize = 16; const SILENT: f32 = 1.0e-5; #[derive(Debug, Clone, Copy, PartialEq)] -enum Stage { +pub(crate) enum Stage { Idle, Attack, Decay, @@ -107,13 +107,13 @@ enum Stage { /// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release. #[derive(Debug, Clone, Copy)] -struct Adsr { - stage: Stage, - level: f32, +pub(crate) struct Adsr { + pub(crate) stage: Stage, + pub(crate) level: f32, } #[derive(Debug, Clone, Copy, Default)] -struct AdsrRates { +pub(crate) struct AdsrRates { attack: f32, decay: f32, sustain: f32, @@ -121,29 +121,29 @@ struct AdsrRates { } impl AdsrRates { - fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self { + pub(crate) fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self { let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp(); Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) } } } impl Adsr { - const fn new() -> Self { + pub(crate) const fn new() -> Self { Self { stage: Stage::Idle, level: 0.0 } } - fn gate_on(&mut self) { + pub(crate) fn gate_on(&mut self) { self.stage = Stage::Attack; } - fn gate_off(&mut self) { + pub(crate) fn gate_off(&mut self) { if self.stage != Stage::Idle { self.stage = Stage::Release; } } #[inline] - fn next(&mut self, r: &AdsrRates) -> f32 { + pub(crate) fn next(&mut self, r: &AdsrRates) -> f32 { match self.stage { Stage::Idle => self.level = 0.0, Stage::Attack => { diff --git a/crates/engine/src/cmd/instrument.rs b/crates/engine/src/cmd/instrument.rs index 6b34657..2a08ba1 100644 --- a/crates/engine/src/cmd/instrument.rs +++ b/crates/engine/src/cmd/instrument.rs @@ -8,15 +8,26 @@ use serde_json::json; use soundcraft_model::{Insert, TrackKind}; pub fn specs() -> Vec { - vec![cmd!( - "mix.instrument", - "Set Instrument", - [], - None, - "{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id", - has_selection, - set_instrument - )] + vec![ + cmd!( + "mix.instrument", + "Set Instrument", + [], + None, + "{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id", + has_selection, + set_instrument + ), + cmd!( + "mix.instrument_param", + "Set Instrument Parameter", + [], + None, + "{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)", + has_selection, + set_instrument_param + ), + ] } fn set_instrument(e: &mut Engine, p: &Value) -> Result { @@ -39,6 +50,20 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result { Ok(json!({"track": t, "plugin": info.id})) } +fn set_instrument_param(e: &mut Engine, p: &Value) -> Result { + let id = "mix.instrument_param"; + let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string(); + let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32; + let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?; + let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?; + // Hosted instruments keep their parameters in their own state. + let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?; + let pi = info.param(¶m).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?; + let v = pi.clamp(value); + ins.params.insert(param.clone(), v); + Ok(json!({"track": t, "param": param, "value": v})) +} + #[cfg(test)] mod tests { use crate::Engine; @@ -68,6 +93,24 @@ mod tests { assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth")); } + #[test] + fn sets_a_built_in_instruments_parameters() { + let mut e = Engine::default(); + let t = track(&mut e, "instrument"); + let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 5000})).unwrap(); + assert_eq!(r["value"], 5000.0); + let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 1.0e9})).unwrap(); + assert_eq!(r["value"], 20000.0, "clamped"); + let v = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get("cutoff").copied()); + assert_eq!(v, Some(20000.0)); + assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "nope", "value": 1})).is_err()); + assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff"})).is_err()); + assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": "loud"})).is_err()); + let audio = track(&mut e, "audio"); + assert!(e.execute("mix.instrument_param", &json!({"track": audio.0, "param": "cutoff", "value": 1})).is_err()); + e.execute("edit.undo", &json!({})).unwrap(); + } + #[test] fn refuses_effects_unknown_plugins_and_other_track_kinds() { let mut e = Engine::default(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b72971..e0a3e81 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ mod more_util; mod options; mod plugin_state; mod query; +mod sampler; mod setup_more; mod surround; mod track; @@ -123,6 +124,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(surround::specs()); v.extend(plugin_state::specs()); v.extend(instrument::specs()); + v.extend(sampler::specs()); v.extend(video::specs()); v }) diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs new file mode 100644 index 0000000..ea659a8 --- /dev/null +++ b/crates/engine/src/cmd/sampler.rs @@ -0,0 +1,321 @@ +//! The built-in Sampler's sample: `mix.sampler_load` decodes an audio file (WAV, AIFF, FLAC, MP3, +//! OGG, anything `file.import_audio` reads) into a session source, resampled to the session rate, +//! and makes it the sample an instrument track's Sampler plays; `mix.sampler_clear` removes it. +//! The source is saved in `Audio Files/` with the session like any other audio, so a session that +//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set +//! with `mix.instrument_param`, or all at once with `params` here. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{Insert, TrackKind}; +use std::path::Path; + +const SAMPLER: &str = "sampler"; + +/// Largest sample accepted (frames × channels): ten minutes of 192 kHz stereo. +const MAX_SAMPLE_VALUES: usize = 192_000 * 600 * 2; + +pub fn specs() -> Vec { + vec![ + cmd!( + "mix.sampler_load", + "Load Sampler Sample", + [], + None, + "{track?, path, params?: {root_note, key_low, key_high, attack, loop_mode, …}}: an instrument track plays the file through the built-in Sampler (made its instrument if it is not already) → {track, source, name, frames, channels, sample_rate}", + has_selection, + load + ), + cmd!("mix.sampler_clear", "Clear Sampler Sample", [], None, "{track?}: the track's Sampler stops playing its sample", has_selection, clear), + ] +} + +/// The instrument track a sampler command acts on. +fn instrument_track(e: &Engine, id: &str, p: &Value) -> Result { + let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?; + if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) { + return Err(bad(id, "only instrument tracks have a sampler")); + } + Ok(t) +} + +fn load(e: &mut Engine, p: &Value) -> Result { + let id = "mix.sampler_load"; + let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string(); + let t = instrument_track(e, id, p)?; + let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?; + // Validate the parameters before touching the session. + let mut values = Vec::new(); + if let Some(v) = p.get("params") { + let obj = v.as_object().ok_or_else(|| bad(id, "`params` must be an object"))?; + for (k, v) in obj { + let pi = info.param(k).ok_or_else(|| bad(id, format!("the sampler has no parameter `{k}`")))?; + let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(id, format!("`{k}` must be a number")))?; + values.push((k.clone(), pi.clamp(x as f32))); + } + } + let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); + let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); + let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; + if buf.frames() == 0 || buf.num_channels() == 0 { + return Err(EngineError::Io(format!("{name}: no audio"))); + } + if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES { + return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)"))); + } + let channels = buf.num_channels(); + let s = e.session_mut(); + let src = crate::io::add_source(s, &name, buf, Some(&path), format.format); + let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone())); + let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?; + let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER); + if !keep { + let mut ins = Insert::new(SAMPLER); + for pi in info.params { + ins.params.insert(pi.id.to_string(), pi.default); + } + tr.instrument = Some(ins); + } + if let Some(ins) = tr.instrument.as_mut() { + ins.sample = Some(src); + for (k, v) in values { + ins.params.insert(k, v); + } + } + Ok(json!({"track": t, "source": src, "name": stem, "frames": frames, "channels": channels, "sample_rate": rate})) +} + +fn clear(e: &mut Engine, p: &Value) -> Result { + let id = "mix.sampler_clear"; + let t = instrument_track(e, id, p)?; + let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).filter(|i| i.plugin == SAMPLER); + let ins = ins.ok_or_else(|| bad(id, "the track's instrument is not the sampler"))?; + let had = ins.sample.take(); + Ok(json!({"track": t, "cleared": had})) +} + +#[cfg(test)] +mod tests { + use crate::Engine; + use serde_json::json; + use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat}; + use soundcraft_midi::{Note, Sequence}; + use soundcraft_model::{Clip, TrackId}; + use soundcraft_time::{Range, TICKS_PER_QUARTER}; + use std::path::PathBuf; + + /// A scratch folder for one test. + fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("sc-sampler-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// Writes a generated tone (no recorded or third-party audio) as a 16-bit WAV. + fn tone_wav(path: &std::path::Path, freq: f32, rate: u32, secs: f32, channels: usize) { + let n = (rate as f32 * secs) as usize; + let one: Vec = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect(); + let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] }; + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None }; + std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap(); + } + + fn instrument_track(e: &mut Engine) -> TrackId { + e.execute("track.new", &json!({"kind": "instrument", "format": "stereo"})).unwrap(); + e.session().tracks.last().unwrap().id + } + + /// One MIDI note (pitch, start in quarter notes, length in quarters) on the track at 120 bpm. + fn add_notes(e: &mut Engine, t: TrackId, notes: &[(u8, i64, i64)]) { + let q = TICKS_PER_QUARTER; + let mut seq = Sequence::default(); + for &(pitch, start, len) in notes { + seq.notes.push(Note { pitch, velocity: 127, release_velocity: 64, channel: 0, start: start * q, length: len * q }); + } + let s = e.session_mut(); + let cid = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::midi(cid, "notes", 0, 48_000 * 8, seq)); + } + + fn render(e: &Engine, secs: i64) -> Vec> { + soundcraft_mix::render_range(e.session(), Range::new(0, 48_000 * secs), 512) + } + + fn peak(x: &[f32]) -> f32 { + x.iter().fold(0.0f32, |m, v| m.max(v.abs())) + } + + fn crossings(x: &[f32]) -> usize { + x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count() + } + + #[test] + fn loads_a_sample_plays_it_and_undoes() { + let d = dir("play"); + let wav = d.join("tone.wav"); + tone_wav(&wav, 440.0, 44_100, 2.0, 1); + let mut e = Engine::default(); + let t = instrument_track(&mut e); + add_notes(&mut e, t, &[(60, 0, 1), (72, 2, 1)]); + assert!(peak(&render(&e, 2)[0]) > 0.0, "the default synth plays the notes first"); + let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap(); + assert_eq!(r["frames"], 96_000, "resampled to the 48 kHz session: {r}"); + assert_eq!(r["sample_rate"], 48_000); + assert_eq!(r["channels"], 1); + assert_eq!(r["name"], "tone"); + let ins = e.session().track(t).unwrap().instrument.clone().unwrap(); + assert_eq!(ins.plugin, "sampler"); + assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64()); + let out = render(&e, 2); + // Middle C plays the tone at its pitch for the first quarter (0.5 s), key 72 an octave up. + let first = &out[0][2400..21_600]; + let second = &out[0][50_400..69_600]; + assert!(peak(first) > 0.05, "the sample plays"); + let (a, b) = (crossings(first), crossings(second)); + assert!((a as f32 - 176.0).abs() <= 2.0, "440 Hz over 0.4 s: {a}"); + assert!((b as f32 - 352.0).abs() <= 3.0, "an octave up: {b}"); + assert!(peak(&out[0][36_000..48_000]) < 1e-3, "released between the notes"); + e.execute("edit.undo", &json!({})).unwrap(); + let back = e.session().track(t).unwrap().instrument.clone().unwrap(); + assert_eq!(back.plugin, "subtractive_synth"); + assert!(back.sample.is_none()); + assert!(e.session().sources.is_empty(), "undo removes the source too"); + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn loads_flac_and_aiff_too() { + let d = dir("formats"); + let n = 4800; + let tone: Vec = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * 440.0 * i as f32 / 96_000.0).sin()).collect(); + let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] }; + let mut e = Engine::default(); + let t = instrument_track(&mut e); + for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] { + let path = d.join(format!("tone.{ext}")); + let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None }; + std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap(); + let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["channels"], 2, "{ext}"); + assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session"); + } + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn instrument_params_reach_the_sampler() { + let d = dir("params"); + let wav = d.join("tone.wav"); + tone_wav(&wav, 440.0, 48_000, 2.0, 2); + let mut e = Engine::default(); + let t = instrument_track(&mut e); + add_notes(&mut e, t, &[(60, 0, 1)]); + e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"key_low": 61}})).unwrap(); + assert_eq!(peak(&render(&e, 1)[0]), 0.0, "key 60 is below the key range"); + e.execute("mix.instrument_param", &json!({"track": t.0, "param": "key_low", "value": 0})).unwrap(); + e.execute("mix.instrument_param", &json!({"track": t.0, "param": "root_note", "value": 48})).unwrap(); + let out = render(&e, 1); + assert!((crossings(&out[0][2400..21_600]) as f32 - 352.0).abs() <= 3.0, "root note 48: key 60 plays an octave up"); + assert!(peak(&out[1]) > 0.05, "a stereo sample plays on both sides"); + assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"nope": 1}})).is_err()); + assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": "C3"}})).is_err()); + assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": 3})).is_err()); + assert_eq!(e.session().sources.len(), 1, "rejected loads add nothing"); + // Reloading keeps the sampler's settings and swaps the sample. + let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap(); + let ins = e.session().track(t).unwrap().instrument.clone().unwrap(); + assert_eq!(ins.params.get("root_note"), Some(&48.0)); + assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64()); + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn clear_silences_the_sampler() { + let d = dir("clear"); + let wav = d.join("tone.wav"); + tone_wav(&wav, 440.0, 48_000, 1.0, 1); + let mut e = Engine::default(); + let t = instrument_track(&mut e); + add_notes(&mut e, t, &[(60, 0, 1)]); + e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap(); + assert!(peak(&render(&e, 1)[0]) > 0.05); + let r = e.execute("mix.sampler_clear", &json!({"track": t.0})).unwrap(); + assert!(r["cleared"].is_u64()); + assert_eq!(peak(&render(&e, 1)[0]), 0.0); + e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap(); + assert!(e.execute("mix.sampler_clear", &json!({"track": t.0})).is_err(), "not a sampler"); + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn a_sampled_session_saves_and_reopens_with_its_sample() { + let d = dir("save"); + let wav = d.join("pluck.wav"); + tone_wav(&wav, 330.0, 22_050, 1.0, 1); + let mut e = Engine::default(); + let t = instrument_track(&mut e); + add_notes(&mut e, t, &[(64, 0, 1)]); + e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": 64, "loop_mode": 1}})).unwrap(); + let before = render(&e, 1); + let path = d.join("song.scraft"); + e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap(); + std::fs::remove_file(&wav).unwrap(); + let mut e2 = Engine::default(); + e2.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let ins = e2.session().track(t).unwrap().instrument.clone().unwrap(); + assert_eq!(ins.plugin, "sampler"); + assert_eq!(ins.params.get("loop_mode"), Some(&1.0)); + let after = render(&e2, 1); + assert!(peak(&after[0]) > 0.05, "the saved copy in Audio Files plays"); + let diff = before[0].iter().zip(&after[0]).fold(0.0f32, |m, (a, b)| m.max((a - b).abs())); + assert!(diff < 1e-5, "same audio after reopening: {diff}"); + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn refuses_bad_input() { + let d = dir("bad"); + let junk = d.join("junk.wav"); + std::fs::write(&junk, b"definitely not audio").unwrap(); + let empty = d.join("empty.wav"); + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None }; + std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap(); + let wav = d.join("ok.wav"); + tone_wav(&wav, 440.0, 48_000, 0.1, 1); + let mut e = Engine::default(); + let t = instrument_track(&mut e); + e.execute("track.new", &json!({"kind": "audio"})).unwrap(); + let audio = e.session().tracks.last().unwrap().id; + let load = |e: &mut Engine, track: u64, path: &std::path::Path| { + e.execute("mix.sampler_load", &json!({"track": track, "path": path.to_string_lossy()})) + }; + assert!(load(&mut e, t.0, &junk).is_err(), "not audio"); + assert!(load(&mut e, t.0, &empty).is_err(), "no frames"); + assert!(load(&mut e, t.0, &d.join("missing.wav")).is_err(), "missing file"); + assert!(load(&mut e, audio.0, &wav).is_err(), "an audio track"); + assert!(load(&mut e, 999_999, &wav).is_err(), "no such track"); + assert!(e.execute("mix.sampler_load", &json!({"track": t.0})).is_err(), "no path"); + assert!(e.execute("mix.sampler_clear", &json!({"track": audio.0})).is_err()); + assert!(e.session().sources.is_empty()); + assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth")); + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn missing_media_plays_silence() { + let d = dir("missing"); + let wav = d.join("tone.wav"); + tone_wav(&wav, 440.0, 48_000, 1.0, 1); + let mut e = Engine::default(); + let t = instrument_track(&mut e); + add_notes(&mut e, t, &[(60, 0, 1)]); + let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap(); + let src = soundcraft_model::SourceId(r["source"].as_u64().unwrap()); + e.session_mut().pool.remove(src); + assert_eq!(peak(&render(&e, 1)[0]), 0.0); + let _ = std::fs::remove_dir_all(d); + } +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..3d83568 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -43,14 +43,22 @@ //! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session); //! it calls into the plugins and allocates, so a realtime host runs it only on request. //! +//! A built-in instrument's parameter values are applied the same way. A sample-playing +//! instrument (the Sampler) is handed the decoded audio of its insert's +//! [`Insert::sample`] source straight from the session's pool, shared and never copied. +//! //! [`Insert::state`]: soundcraft_model::Insert::state +//! [`Insert::sample`]: soundcraft_model::Insert::sample #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; -use soundcraft_dsp::{Plugin, db_to_gain}; -use soundcraft_model::{AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, Speaker, SurroundPan, Track, TrackId, TrackKind}; +use soundcraft_dsp::{Plugin, SampleSource, db_to_gain}; +use soundcraft_model::{ + AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind, +}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; +use std::sync::Arc; /// Default (stereo) main mix width. The actual width is [`main_channels`]. pub const MAIN_CHANNELS: usize = 2; @@ -187,6 +195,8 @@ struct Strip { scratch: Vec, plugins: Vec>, instrument: Option, + /// The pool audio handed to the instrument with `set_sample` (sample-playing instruments). + sample: Option>, /// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek. held: Vec, muted: bool, @@ -261,6 +271,7 @@ impl Strip { scratch: Vec::new(), plugins: Vec::new(), instrument: None, + sample: None, held: Vec::new(), muted: false, gr: 0.0, @@ -509,7 +520,9 @@ impl MixEngine { let old = strip.instrument.take(); cx.retire(old); strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich)); + strip.sample = None; } + sync_sample(strip, want, s); } // Instances nobody claimed (the session changed again before they arrived). let leftovers: Vec<(TrackId, usize)> = cx.adopt.keys().copied().collect(); @@ -826,6 +839,9 @@ fn process_strip( } TrackKind::Instrument | TrackKind::Midi => { if let Some(inst) = &mut strip.instrument { + if let Some(ins) = t.instrument.as_ref().filter(|i| !is_third_party(&i.plugin)) { + apply_instrument_params(inst, ins); + } schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); @@ -1002,6 +1018,10 @@ fn structure_fingerprint(s: &Session) -> usize { snd.target.hash(&mut h); } t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h); + let sample = t.instrument.as_ref().and_then(|x| x.sample); + sample.hash(&mut h); + // Which decoded audio it is, so new audio under the same id (another session) re-syncs. + sample.and_then(|id| s.pool.get(id)).map(|a| Arc::as_ptr(a) as usize).hash(&mut h); } for b in &s.busses { b.id.hash(&mut h); @@ -1278,6 +1298,54 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } } +/// Pushes a built-in instrument's parameter values (changed ones only; no allocation once the +/// parameter count is stable). Third-party instruments keep their own state. +fn apply_instrument_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert) { + if slot.applied.len() != ins.params.len() { + slot.applied = vec![f32::NAN; ins.params.len()]; + } + for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) { + if v.to_bits() != last.to_bits() { + slot.plugin.set_param(k, *v); + *last = *v; + } + } +} + +/// Session audio shared with a sample-playing instrument. +struct PoolSample(Arc); + +impl SampleSource for PoolSample { + fn sample_rate(&self) -> f32 { + self.0.buffer.sample_rate as f32 + } + fn num_channels(&self) -> usize { + self.0.buffer.num_channels() + } + fn channel(&self, ch: usize) -> Option<&[f32]> { + self.0.channel(ch) + } +} + +/// Hands a strip's instrument the audio of its insert's sample source when it changed (a new +/// sample, a cleared one, or a new instance). Missing media plays nothing. +fn sync_sample(strip: &mut Strip, want: Option<&soundcraft_model::Insert>, s: &Session) { + let audio = want.and_then(|i| i.sample).and_then(|id| s.pool.get(id)); + let same = match (strip.sample.as_ref(), audio) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + }; + if same { + return; + } + if let Some(inst) = strip.instrument.as_mut() { + let shared = audio.map(|a| Arc::new(PoolSample(Arc::clone(a))) as Arc); + inst.plugin.set_sample(shared); + } + strip.sample = audio.cloned(); +} + fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { let slot_u8 = u8::try_from(i).unwrap_or(0); let automated = t.mixer.automation_mode.reads() diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs index e9a7867..d1e5612 100644 --- a/crates/model/src/mixer.rs +++ b/crates/model/src/mixer.rs @@ -1,6 +1,6 @@ //! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo. -use crate::{AutomationMode, BusId}; +use crate::{AutomationMode, BusId, SourceId}; use std::collections::BTreeMap; pub const INSERT_SLOTS: usize = 10; @@ -438,6 +438,9 @@ pub struct Insert { /// state), standard base64. Restored into a new instance before it first processes. #[serde(default, skip_serializing_if = "Option::is_none")] pub state: Option, + /// The session audio source a sample-playing instrument (the built-in Sampler) plays. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample: Option, } fn yes() -> bool { @@ -446,7 +449,15 @@ fn yes() -> bool { impl Insert { pub fn new(plugin: impl Into) -> Self { - Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from(""), state: None } + Insert { + plugin: plugin.into(), + params: BTreeMap::new(), + bypass: false, + active: true, + preset: String::from(""), + state: None, + sample: None, + } } /// The decoded plugin state (`None` when absent or malformed). From 9ed4bdd4b152deaf1e28afeba04de3313d9b0766 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:45:22 +0100 Subject: [PATCH 004/137] Shift+Cmd+K exports clips as files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export Clips as Files had no shortcut and no menu entry, so Shift+Cmd+K, its key in Pro Tools, did nothing (#7). It now opens the command's folder prompt, and OK exports the selected clips there. Trying it showed three more gaps: - Browse… in a folder prompt opened an audio-file picker, so no folder could be chosen. Folder prompts (Export Clips, Bounce Stems) now use a folder picker, a new `pick_folder` host service; the web build has none and keeps the text field. - A successful export said nothing. The status bar now reads "Exported N clip(s) to ", and the command returns the folder. - With nothing selected the export succeeded and wrote no files. It now fails with "no clips selected". The shortcut lookup moves into `command_for` so a test can check what a key resolves to. --- apps/soundcraft/src/main.rs | 3 +++ crates/engine/src/cmd/file.rs | 21 +++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/shortcuts.rs | 23 +++++++++++++++++------ 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 87da4f0..91074ec 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -165,6 +165,9 @@ fn services() -> Services { } d.save_file().map(|p| p.to_string_lossy().into_owned()) })), + pick_folder: Some(Box::new(|start: &str| { + rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned()) + })), } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..cc823c2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -109,11 +109,14 @@ pub fn specs() -> Vec { } Ok(json!({"text": text})) }), - cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| { + cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| { let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string(); let ids = clip_ids_param(e, p); + if ids.is_empty() { + return Err(bad("file.export_clips", "no clips selected")); + } let n = crate::io::export_clips(e, &ids, &dir)?; - Ok(json!({"written": n})) + Ok(json!({"written": n, "dir": dir})) }), cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string(); @@ -428,4 +431,18 @@ mod tests { assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn exports_the_selected_clips_and_refuses_an_empty_selection() { + let mut e = crate::demo::demo_engine(); + let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id())); + let p = json!({"dir": dir.to_string_lossy()}); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(e.execute("file.export_clips", &p).is_err()); + e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap(); + let r = e.execute("file.export_clips", &p).unwrap(); + let files = std::fs::read_dir(&dir).map_or(0, Iterator::count); + let _ = std::fs::remove_dir_all(&dir); + assert!(files >= 1 && r["written"] == files, "{r}, {files} files"); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..846b3c3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -291,11 +291,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { 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, &[]) { *path = p; } + if let Some(pick) = &app.services.pick_folder + && key == "dir" + && ui.button("Browse…").clicked() + && let Some(p) = pick(path) + { + *path = p; + } if let Some(pick) = &app.services.pick_save && (cmd == "session.save_as" || cmd == "session.save_copy") && ui.button("Browse…").clicked() @@ -412,8 +420,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } if let Some((cmd, params)) = action { keep = false; - if let Err(e) = app.run(&cmd, params) { - app.ui.status = e; + match app.run(&cmd, params) { + Ok(v) if cmd == "file.export_clips" => { + app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default()); + } + Ok(_) => {} + Err(e) => app.ui.status = e, } } if keep { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6da9b92..c77a088 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,7 @@ impl Default for UiState { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + pub pick_folder: Option Option>>, } /// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`). diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 90a4e08..981a1b7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -73,10 +73,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { if fixed(app, key, mods, mac) { continue; } - let hit = - soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))); - if let Some(c) = hit { - let id = c.id; + if let Some(id) = command_for(key, mods, mac) { if crate::menus::invoke_shortcut_dialog(app, id) { continue; } @@ -85,6 +82,13 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { } } +fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> { + soundcraft_engine::command_specs() + .iter() + .find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))) + .map(|c| c.id) +} + /// Keys with context-dependent meaning. Returns true when handled. fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let plain = !m.command && !m.alt && !m.ctrl && !m.shift; @@ -282,8 +286,8 @@ mod label_tests { #[cfg(test)] mod tests { - use super::{mods_match, parse, search_chord}; - use egui::Modifiers; + use super::{command_for, mods_match, parse, search_chord}; + use egui::{Key, Modifiers}; /// Ctrl held on Windows or Linux, as egui reports it. const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; @@ -313,4 +317,11 @@ mod tests { assert!(!search_chord(MAC_CMD, true)); assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true)); } + + #[test] + fn shift_cmd_k_prompts_for_a_folder_to_export_clips() { + assert_eq!(command_for(Key::K, Modifiers { shift: true, ..MAC_CMD }, true), Some("file.export_clips")); + assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips")); + assert!(crate::dialogs::takes_path("file.export_clips")); + } } From 718bb5c7fbfd422dab97e67e93561cdf12de546b Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:27:39 +0100 Subject: [PATCH 005/137] Open Save As when saving a session that has never been saved Cmd+S and File > Save Session on an untitled session only left "no path yet: pass `path` (Save As)" in the status line. From a menu, the command palette or the keyboard, Save now opens the Save Session As prompt until the session has a file. Calls from the control channel, MCP and the CLI still need a `path`. The prompt suggests ~//.scraft, but saving a session without audio there failed with "No such file or directory", because only writing audio created the session folder. Saving now creates the folder. --- crates/engine/src/cmd/file.rs | 10 ++++++++++ crates/engine/src/io.rs | 1 + crates/ui-egui/src/dialogs.rs | 5 +++-- crates/ui-egui/src/menus.rs | 21 +++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..ca7393d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -391,6 +391,16 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn saving_a_session_without_audio_creates_its_folder() { + let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song").join("Song.scraft"); + Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(&dir); + } + fn tone_engine() -> (Engine, u64) { let mut e = Engine::default(); e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..dd9a04a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -519,6 +519,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; // Atomic-ish write: temp file then rename. let tmp = p.with_extension("scraft.tmp"); std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..e2faaee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -72,8 +72,9 @@ impl Dialogs { "file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() }, "file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() }, "session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() }, - "session.save_as" | "session.save_copy" => Dialog::PathPrompt { - cmd: id.into(), + "session.save" if e.path.is_some() => return false, + "session.save" | "session.save_as" | "session.save_copy" => Dialog::PathPrompt { + cmd: if id == "session.save_copy" { id } else { "session.save_as" }.into(), title: "Save Session As".into(), path: format!("{home}/{name}/{name}.scraft"), key: "path".into(), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 89aca57..21d4c6e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -149,6 +149,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "file.import_audio", "file.import_midi", "session.open", + "session.save", "session.save_as", "session.save_copy", "track.group", @@ -618,4 +619,24 @@ mod tests { invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix..."); assert_eq!(app.dialogs.open_name(), Some("bounce")); } + + #[test] + fn save_asks_for_a_path_until_the_session_has_one() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + invoke_menu(&mut app, "session.save", "File > Save Session"); + assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as")); + app.dialogs.open = None; + assert!(invoke_shortcut_dialog(&mut app, "session.save")); + app.dialogs.open = None; + + let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id())); + let path = dir.join("Song.scraft"); + app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap(); + app.run("track.new", json!({})).unwrap(); + assert!(!invoke_shortcut_dialog(&mut app, "session.save")); + invoke_menu(&mut app, "session.save", "File > Save Session"); + assert!(app.dialogs.open.is_none()); + assert!(!app.engine.is_dirty()); + let _ = std::fs::remove_dir_all(&dir); + } } From 6964c50d004dcae44814e7e248ef23be85642d31 Mon Sep 17 00:00:00 2001 From: natocool2 Date: Sat, 10 Oct 2026 15:52:38 +0100 Subject: [PATCH 006/137] Web: open, import, save and export files in the browser (fixes #63, fixes #64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build could not reach any file: File > Open Session…, Import > Audio… and Import > MIDI… showed a text box for a path the browser can't read, and every save or export failed with «operation not supported on this platform», so work was lost on close. On the web now: - Open Session…, Import Audio… and Import MIDI… show the browser's file picker; files dropped on the page open (sessions) or import (audio, MIDI) by extension. Files travel as bytes; imports are one undo step each, like the import commands. - A session is saved as ONE file, a bundle `.scraftz`: a zip (stored entries) holding the `.scraft` and its `Audio Files/` as 32-bit float WAV — the same layout as a session folder, since the browser has no folders. `io::open_session_bytes` opens a bundle or a bare `.scraft` (its audio is then reported missing). Bundles are hostile input: entry count and size are capped, and declared sizes are never read past. - Every single-file output (save, Save As Template, bounce, MIDI/score/text export, track presets) goes through `io::write_file`: a file natively, a download in the browser via a delivery hook (`io::set_deliver`) that only the web app installs. - In the browser Save As asks for the bundle's name (`.scraftz`) instead of a folder path. Native builds keep writing files and session folders as before. Folder outputs (stems, clip exports) are still not available on the web. Pairs with #157 (Save on a never-saved session opens Save As), which the browser needs too: the web app always starts on the unsaved demo. New dependency: `zip` (MIT), default features off (stored entries only). Checked: engine tests for the bundle round trip (every sample identical), same-named sources, bare sessions and hostile bundles; `cargo xtask ci`. In Chromium on the packaged web build: ⌘⇧I picks and imports a WAV; WAV, MP3, FLAC, OGG, M4A, AIFF and MIDI import by drop; Save As → `Midnight Groove.scraftz` downloads with the imported audio inside; that bundle reopens in a fresh page with all tracks and no missing media, and saves again with the same content. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 36 ++++ Cargo.toml | 2 + apps/soundcraft-web/Cargo.toml | 6 +- apps/soundcraft-web/src/main.rs | 5 +- apps/soundcraft-web/src/web.rs | 181 ++++++++++++++++++- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/file.rs | 10 +- crates/engine/src/cmd/track_more.rs | 2 +- crates/engine/src/io.rs | 258 +++++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 7 +- 10 files changed, 484 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b26758..7428447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2133,6 +2133,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3502,6 +3514,7 @@ dependencies = [ "soundcraft-video", "soundcraft-vst3-host", "thiserror 2.0.21", + "zip", ] [[package]] @@ -3623,8 +3636,12 @@ version = "0.4.0" dependencies = [ "eframe", "egui", + "js-sys", "log", + "rfd", + "serde_json", "soundcraft-engine", + "soundcraft-model", "soundcraft-playback", "soundcraft-ui-egui", "wasm-bindgen", @@ -4084,6 +4101,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5374,6 +5397,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "nix", + "typed-path", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 95b9a8e..bb61412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,8 @@ proptest = "1" # audio symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] } rustfft = "6" +# Session bundles (one file holding a session and its audio, for the browser): stored entries only. +zip = { version = "9", default-features = false } cpal = "0.16" # ui diff --git a/apps/soundcraft-web/Cargo.toml b/apps/soundcraft-web/Cargo.toml index 64cdb1a..50ff3aa 100644 --- a/apps/soundcraft-web/Cargo.toml +++ b/apps/soundcraft-web/Cargo.toml @@ -17,10 +17,14 @@ path = "src/main.rs" soundcraft-engine = { workspace = true } soundcraft-ui-egui = { workspace = true } soundcraft-playback = { workspace = true } +soundcraft-model = { workspace = true } egui = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } +rfd = { workspace = true } +js-sys = "0.3" # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] } diff --git a/apps/soundcraft-web/src/main.rs b/apps/soundcraft-web/src/main.rs index cb089a5..6e52278 100644 --- a/apps/soundcraft-web/src/main.rs +++ b/apps/soundcraft-web/src/main.rs @@ -7,7 +7,10 @@ //! //! Differences from the desktop app: //! - no TCP control channel (browsers can't listen on sockets); -//! - no native file dialogs: the demo session is loaded on start; +//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are +//! read as bytes through the browser's file picker; a session is saved as ONE bundle file +//! (`.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a +//! download (the demo session is still loaded on start); //! - browsers only start audio after a user gesture, so the first click may be needed before //! playback is heard. //! diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 78acba4..02f1e13 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -1,7 +1,11 @@ //! The browser shell: the eframe web runner around [`SoundApp`]. -use soundcraft_engine::Engine; +use serde_json::json; +use soundcraft_engine::{Engine, io}; +use soundcraft_ui_egui::dialogs::Dialog; use soundcraft_ui_egui::{Services, SoundApp}; +use std::cell::RefCell; +use std::rc::Rc; use std::sync::Arc; use wasm_bindgen::JsCast as _; @@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading"; pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + io::set_deliver(download); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { log::error!("no document"); @@ -36,7 +41,7 @@ pub fn start() { let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() }; let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone())); let app = SoundApp::new(engine, Some(player), Services::default()); - Ok(Box::new(WebShell(app))) + Ok(Box::new(WebShell { app, inbox: Rc::default() })) }), ) .await; @@ -53,18 +58,182 @@ fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } -struct WebShell(SoundApp); +/// What a file read from the browser is for. +#[derive(Debug, Clone, Copy)] +enum Purpose { + Session, + Audio, + Midi, +} + +const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION]; +const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"]; +const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"]; + +impl Purpose { + /// A dropped file goes where its extension says: sessions open, MIDI and audio import. + fn of(name: &str) -> Self { + let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default(); + if SESSION_EXTS.contains(&ext.as_str()) { + Self::Session + } else if MIDI_EXTS.contains(&ext.as_str()) { + Self::Midi + } else { + Self::Audio + } + } + + /// The command whose path prompt this replaces in the browser. + fn for_command(cmd: &str) -> Option { + match cmd { + "session.open" => Some(Self::Session), + "file.import_audio" => Some(Self::Audio), + "file.import_midi" => Some(Self::Midi), + _ => None, + } + } + + fn filter(self) -> (&'static str, &'static [&'static str]) { + match self { + Self::Session => ("SoundCraft Session", SESSION_EXTS), + Self::Audio => ("Audio", AUDIO_EXTS), + Self::Midi => ("MIDI", MIDI_EXTS), + } + } +} + +/// Files picked or dropped, read asynchronously (browsers can only read files that way) and +/// handled on the next frame. The browser has no paths: files travel as bytes. +type Inbox = Rc)>>>; + +struct WebShell { + app: SoundApp, + inbox: Inbox, +} + +impl WebShell { + /// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the + /// browser can't read (#63, #64). Show the browser's file picker instead. + fn take_path_prompt(&mut self, ctx: &egui::Context) { + let purpose = match &self.app.dialogs.open { + Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd), + _ => None, + }; + let Some(purpose) = purpose else { return }; + self.app.dialogs.open = None; + let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let (label, exts) = purpose.filter(); + let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return }; + let bytes = file.read().await; + inbox.borrow_mut().push((purpose, file.file_name(), bytes)); + ctx.request_repaint(); + }); + } + + fn take_drops(&mut self, ctx: &egui::Context) { + // In the browser a dropped file's `path()` is only its name: read its bytes instead. + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into()); + let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + match f.bytes_async().await { + Ok(bytes) => { + inbox.borrow_mut().push((Purpose::of(&name), name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + } + + fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) { + let e = &mut self.app.engine; + let at = e.session().edit.selection.start.max(0); + let result = match purpose { + Purpose::Session => { + let _ = self.app.run("transport.stop", json!({})); + io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| { + if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) } + }) + } + // Imports are edits: one undo step each, like the import commands. + Purpose::Audio => { + let before = e.session_arc(); + io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| { + e.push_undo("Import Audio", before); + format!("Imported {name}") + }) + } + Purpose::Midi => { + let before = e.session_arc(); + io::import_midi_bytes(e, bytes, at, true).map(|_| { + e.push_undo("Import MIDI", before); + format!("Imported {name}") + }) + } + }; + self.app.ui.status = result.unwrap_or_else(|err| err.to_string()); + } +} impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.0.logic(ctx); + self.take_drops(ctx); + self.take_path_prompt(ctx); + let pending = std::mem::take(&mut *self.inbox.borrow_mut()); + for (purpose, name, bytes) in pending { + self.handle(purpose, &name, &bytes); + } + self.app.logic(ctx); + // A prompt opened during this frame becomes a picker on the next one. + self.take_path_prompt(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.app.ui(ui); } } + +/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery +/// hook on the web). +fn download(name: &str, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}"); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() { + Some("scraftz") => "application/zip", + Some("wav") => "audio/wav", + Some("aif" | "aiff") => "audio/aiff", + Some("flac") => "audio/flac", + Some("mid" | "midi") => "audio/midi", + Some("svg") => "image/svg+xml", + Some("txt") => "text/plain", + Some("json" | "scraft") => "application/json", + Some("musicxml") => "application/vnd.recordare.musicxml+xml", + _ => "application/octet-stream", + }); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; + a.set_href(&url); + a.set_download(name); + a.style().set_property("display", "none").map_err(js)?; + document.body().ok_or("no body")?.append_child(&a).map_err(js)?; + a.click(); + a.remove(); + // Revoke after the click was dispatched; the download keeps its own reference. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; + Ok(()) +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b698856..9b097a0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true log.workspace = true +zip.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..4e26b85 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { s.sources.clear(); s.videos.clear(); let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; Ok(json!({"path": path})) }), cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { @@ -99,13 +99,13 @@ pub fn specs() -> Vec { cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); let bytes = crate::io::export_midi(e)?; - std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| { let text = crate::inspect::session_text(e); if let Some(path) = str_param(p, "path") { - std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(path, text.as_bytes())?; } Ok(json!({"text": text})) }), @@ -261,7 +261,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?; - std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, &bytes)?; Ok( json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), ) @@ -280,7 +280,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result Some(p) => p.to_string(), None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(), }; - std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0); Ok(json!({"path": path, "parts": parts.len(), "measures": measures})) } diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index e3d202e..8818342 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result { "comments": tr.comments, }); let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; Ok(json!({"path": path, "track": first})) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..8c774d8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -456,7 +456,13 @@ pub fn sanitize_name(n: &str) -> String { /// 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. +/// +/// In the browser there are no folders: the session and all its audio go out as one bundle file +/// (`.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]). pub fn save_session(e: &mut Engine, path: &str) -> Result { + if cfg!(target_arch = "wasm32") { + return save_session_bundle(e, path); + } let (fresh, written, p) = write_session(e.session(), path, false)?; // Keep the in-memory document in sync (paths, unsaved flags) without an undo step. *e.session_mut() = fresh; @@ -535,16 +541,11 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { for src in s.sources.clone() { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; match std::fs::read(&p) { - Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) { - Ok((_, mut buf)) => { - if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { - buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); - buf.sample_rate = s.sample_rate.hz(); - } - s.pool.insert(src.id, Arc::new(SourceAudio::new(buf))); + Ok(bytes) => { + if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) { + missing.push(format!("{}: {err}", p.display())); } - Err(err) => missing.push(format!("{}: {err}", p.display())), - }, + } Err(_) => missing.push(p.display().to_string()), } } @@ -557,6 +558,173 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { Ok(missing) } +/// Decode one source's audio file into the session pool, at the session rate. +fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> { + let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?; + if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); + buf.sample_rate = s.sample_rate.hz(); + } + s.pool.insert(id, Arc::new(SourceAudio::new(buf))); + Ok(()) +} + +// ─── Hosts without a filesystem (the browser) ───────────────────────────────────────────────── + +/// Extension of a session bundle: one zip file holding the `.scraft` session and its +/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it +/// saves and opens sessions as bundles. +pub const BUNDLE_EXTENSION: &str = "scraftz"; + +/// Most entries and bytes a bundle may unpack to (bundles are hostile input). +const MAX_BUNDLE_ENTRIES: usize = 10_000; +const MAX_BUNDLE_BYTES: u64 = 4 << 30; + +/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app +/// installs one that starts a download. Native builds write files and never install it. +pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>; + +static DELIVER: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Install the delivery hook (once, at start-up of the web app). +pub fn set_deliver(h: DeliverHook) { + let _ = DELIVER.set(h); +} + +/// Write one output file: to `path` natively; in the browser, delivered under its file name. +/// Without this, every export on the web failed with «operation not supported on this platform». +pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + if cfg!(target_arch = "wasm32") { + let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?; + let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); + return deliver(name, bytes).map_err(EngineError::Io); + } + std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}"))) +} + +/// The session as a bundle: `.scraft` plus every source's audio as 32-bit float WAV under +/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the +/// session with its source paths pointing into the bundle. +pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { + use std::io::Write as _; + let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); + let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}")); + let mut s = session.clone(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true); + let mut taken = std::collections::HashSet::new(); + for src in &mut s.sources { + let Some(audio) = s.pool.get(src.id) else { continue }; + let base = sanitize_name(&src.name); + let mut target = format!("Audio Files/{base}.wav"); + let mut k = 1; + while taken.contains(&target) && k <= 9_999 { + target = format!("Audio Files/{base}_{k:02}.wav"); + k += 1; + } + let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; + let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?; + w.start_file(target.as_str(), opts).map_err(zerr)?; + w.write_all(&bytes).map_err(ioerr)?; + taken.insert(target.clone()); + src.path = target; + src.unsaved = false; + src.format = FileFormat::Wav; + } + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?; + w.write_all(text.as_bytes()).map_err(ioerr)?; + let bytes = w.finish().map_err(zerr)?.into_inner(); + Ok((bytes, s)) +} + +/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]). +fn save_session_bundle(e: &mut Engine, path: &str) -> Result { + let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string); + let mut s = e.session().clone(); + if let Some(stem) = stem { + s.name = stem; + } + let (bytes, fresh) = session_bundle(&s)?; + let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name)); + write_file(&file, &bytes)?; + let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count(); + *e.session_mut() = fresh; + e.path = Some(file); + e.mark_clean(); + Ok(written) +} + +/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare +/// `.scraft`, whose audio is then reported missing. Returns the missing media, like +/// [`open_session`]. +pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result> { + let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") { + read_bundle(bytes)? + } else { + ( + String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?, + std::collections::HashMap::new(), + ) + }; + let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; + let mut missing = Vec::new(); + for src in s.sources.clone() { + match files.remove(&src.path) { + Some(audio) => { + let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string); + if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) { + missing.push(format!("{}: {err}", src.path)); + } + } + None => missing.push(src.path.clone()), + } + } + // Movies are referenced by path, never copied into a session: none can be found here. + missing.extend(s.videos.iter().map(|v| v.path.clone())); + let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string(); + e.replace_session(s); + e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem))); + for m in &missing { + e.message(format!("missing media: {m}")); + } + Ok(missing) +} + +/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path. +fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap>)> { + use std::io::Read as _; + let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?; + if z.len() > MAX_BUNDLE_ENTRIES { + return Err(EngineError::Io("bundle: too many files".into())); + } + let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64); + for i in 0..z.len() { + let mut f = z.by_index(i).map_err(zerr)?; + if f.is_dir() { + continue; + } + let rel = f.name().map_err(zerr)?.to_string(); + let size = f.size(); + total = total.saturating_add(size); + if total > MAX_BUNDLE_BYTES { + return Err(EngineError::Io("bundle: too large".into())); + } + let mut data = Vec::new(); + // The declared size is hostile too: never read past it. + f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?; + let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION); + if is_session && text.is_none() { + text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?); + } else { + files.insert(rel, data); + } + } + let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?; + Ok((text, files)) +} + /// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record"). /// `channels` is the planar input capture at `rate`; each track takes the input channels its /// input route names ("In 3" → channel 2), defaulting to the first channels. @@ -638,3 +806,75 @@ mod stem_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod bundle_tests { + use super::*; + + #[test] + fn bundle_round_trips_session_and_audio() { + let mut e = crate::demo::demo_engine(); + e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap(); + let (bytes, fresh) = session_bundle(e.session()).unwrap(); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved)); + let mut r = Engine::default(); + let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap(); + assert!(missing.is_empty(), "{missing:?}"); + assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); + assert_eq!(r.session().tracks.len(), e.session().tracks.len()); + for src in &e.session().sources { + let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap()); + assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name); + } + assert!(!r.is_dirty()); + assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz")); + } + + #[test] + fn same_named_sources_get_distinct_files() { + let mut e = crate::demo::demo_engine(); + let name = e.session().sources[0].name.clone(); + let s = e.session_mut(); + let n = s.sources.len(); + s.sources[1].name = name; + let (bytes, fresh) = session_bundle(e.session()).unwrap(); + let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect(); + assert_eq!(paths.len(), n); + let mut r = Engine::default(); + assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty()); + } + + #[test] + fn bare_session_opens_with_its_audio_reported_missing() { + let e = crate::demo::demo_engine(); + let (_, fresh) = session_bundle(e.session()).unwrap(); + let text = fresh.to_json().unwrap(); + let mut r = Engine::default(); + let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap(); + assert_eq!(missing.len(), fresh.sources.len()); + assert_eq!(r.session().tracks.len(), fresh.tracks.len()); + } + + #[test] + fn hostile_bundles_are_errors_not_panics() { + let mut r = Engine::default(); + for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] { + assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err()); + } + // A valid zip without a session inside. + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap(); + let only_audio = w.finish().unwrap().into_inner(); + assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err()); + assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone"); + } + + #[test] + fn write_file_writes_to_disk_natively() { + let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id())); + write_file(&p.to_string_lossy(), b"hello").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"hello"); + let _ = std::fs::remove_file(&p); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..add16b9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -75,7 +75,12 @@ impl Dialogs { "session.save_as" | "session.save_copy" => Dialog::PathPrompt { cmd: id.into(), title: "Save Session As".into(), - path: format!("{home}/{name}/{name}.scraft"), + // The browser has no folders: the name becomes the bundle file's name. + path: if home.is_empty() { + format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION) + } else { + format!("{home}/{name}/{name}.scraft") + }, key: "path".into(), }, "edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() }, From aa4f038f1a3a624c92b82e62fb942babe5cde1aa Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:01:33 +0100 Subject: [PATCH 007/137] Open the Rename Clip dialog from Clip > Rename Clip > Rename... and Cmd+Alt+Shift+R ran clip.rename without a name, so they only put "`name` required" in the status line. They now open the Rename Clip dialog, which only the clip's right-click menu opened so far, for the selected clip (or the first clip the edit selection touches). The dialog's name field, and Rename Track's, now opens with the old name selected, so typing replaces it instead of adding to it. --- crates/ui-egui/src/dialogs.rs | 34 +++++++++++++++++++++++---- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/tests/rename_dialog.rs | 30 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/rename_dialog.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..5848673 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -100,6 +100,11 @@ impl Dialogs { let s = soundcraft_engine::score::ScoreSetup::from_session(e.session()); Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names } } + "clip.rename" => { + let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id)); + let Some((_, clip)) = target else { return false }; + Dialog::RenameClip { id: clip.id, name: clip.name.clone() } + } _ => match path_dialog(id, &home, &name) { Some(d) => d, None => return false, @@ -273,15 +278,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::RenameTrack { id, name } => { - let r = ui.text_edit_singleline(name); - r.request_focus(); + name_field(ui, name); if buttons(ui, "OK", enter) { action = Some(("track.rename".into(), json!({"track": id.0, "name": name}))); } } Dialog::RenameClip { id, name } => { - let r = ui.text_edit_singleline(name); - r.request_focus(); + name_field(ui, name); if buttons(ui, "OK", enter) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } @@ -436,6 +439,17 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { pressed } +/// A name field that takes the focus with its text selected, so typing replaces the old name. +fn name_field(ui: &mut egui::Ui, name: &mut String) { + let mut out = egui::TextEdit::singleline(name).show(ui); + if !out.response.has_focus() { + out.response.request_focus(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count())); + out.state.cursor.set_char_range(Some(all)); + out.state.store(ui.ctx(), out.response.id); + } +} + /// The command the tempo-change dialog runs on OK. fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { ("event.tempo".into(), json!({"bpm": bpm, "at": at})) @@ -515,6 +529,18 @@ mod tests { assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); } + #[test] + fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() { + let mut e = soundcraft_engine::demo::demo_engine(); + let mut d = Dialogs::default(); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(!d.open_for_command(&e, "clip.rename")); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap(); + assert!(d.open_for_command(&e, "clip.rename")); + assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name)); + } + #[test] fn tempo_change_dialog_runs_a_real_command_at_its_position() { let (cmd, params) = tempo_change_action(96_000, 133.5); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 89aca57..0e540e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -159,6 +159,7 @@ const DIALOG_COMMANDS: &[&str] = &[ "event.quantize", "event.transpose", "edit.strip_silence", + "clip.rename", ]; /// Shared menu hierarchy for egui and native desktop hosts. diff --git a/crates/ui-egui/tests/rename_dialog.rs b/crates/ui-egui/tests/rename_dialog.rs new file mode 100644 index 0000000..7b10b73 --- /dev/null +++ b/crates/ui-egui/tests/rename_dialog.rs @@ -0,0 +1,30 @@ +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +#[test] +fn typing_in_the_rename_clip_dialog_replaces_the_old_name() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].clone(); + app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap(); + menus::invoke_menu(&mut app, "clip.rename", "Clip > Rename..."); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..3 { + h.step(); + } + h.input_mut().events.push(Event::Text("Intro".into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + let session = h.state().engine.session(); + let renamed = session.tracks.iter().flat_map(|t| t.clips()).find(|c| c.id == clip.id).map(|c| c.name.clone()); + assert_eq!(renamed.as_deref(), Some("Intro")); +} From bd00c3855ccca4d73d8a0620ea1296279f57743e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:03:28 +0100 Subject: [PATCH 008/137] Name the step in the Edit menu's Undo and Redo The Edit menu always read "Can't Undo" and "Can't Redo", the incumbent's labels for an empty history, even when there was something to undo. The items now name the step they would take ("Undo Mute", "Redo Mute"), in the in-window menu bar and the macOS system menu alike, and read "Can't Undo" / "Can't Redo" only when there is nothing. --- apps/soundcraft/src/native_menu.rs | 5 +++++ crates/ui-egui/src/menus.rs | 13 ++++++++++++- crates/ui-egui/tests/edit_menu.rs | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/edit_menu.rs diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs index 9cc72de..82cd25a 100644 --- a/apps/soundcraft/src/native_menu.rs +++ b/apps/soundcraft/src/native_menu.rs @@ -105,6 +105,11 @@ impl NativeMenu { self.last_sync = Some(now); for entry in &mut self.entries { let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref()); + if let Some(label) = menus::live_label(app, entry.command.as_deref()) + && entry.item.text() != label + { + entry.item.set_text(label); + } if enabled != entry.enabled { entry.item.set_enabled(enabled); entry.enabled = enabled; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0e540e0..5b8f079 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -259,7 +259,8 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str } let id = command_for_path(&n.path, extra); let (enabled, checked) = item_state(app, &n.path, id.as_deref()); - let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() }; + let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone()); + let label = if checked { format!("✔ {label}") } else { label }; let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or(""); let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac())); if ui.add_enabled(enabled, btn).clicked() @@ -387,6 +388,16 @@ const MENU_WINDOWS: &[(&str, &str)] = &[ ("Event > MIDI Real-Time Properties", "window.rtp"), ]; +/// Undo and Redo name the step they would undo or redo; other items keep their catalog label. +pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option { + let e = &app.engine; + match id? { + "edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))), + "edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))), + _ => None, + } +} + /// Menu-style invocation: may open a dialog. pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) diff --git a/crates/ui-egui/tests/edit_menu.rs b/crates/ui-egui/tests/edit_menu.rs new file mode 100644 index 0000000..8fdbb52 --- /dev/null +++ b/crates/ui-egui/tests/edit_menu.rs @@ -0,0 +1,15 @@ +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +#[test] +fn undo_and_redo_name_the_step_they_would_take() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let label = |app: &SoundApp, id| menus::live_label(app, Some(id)); + assert_eq!(label(&app, "edit.undo").as_deref(), Some("Can't Undo")); + app.run("mix.mute", json!({"track": "Kick"})).unwrap(); + assert_eq!(label(&app, "edit.undo").as_deref(), Some("Undo Mute")); + assert_eq!(label(&app, "edit.redo").as_deref(), Some("Can't Redo")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(label(&app, "edit.redo").as_deref(), Some("Redo Mute")); + assert_eq!(label(&app, "edit.copy"), None); +} From 6b98c21fedac5063a5676aa472df4ae754eb714c Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:20:55 +0100 Subject: [PATCH 009/137] Make the Window menu's shortcuts work The Window menu's keys never fired: Cmd+1 Transport, Cmd+3 Big Counter, Cmd+4 Automation, Cmd+5 Memory Locations, Cmd+9 Video, Cmd+Alt+M Narrow Mix, Cmd+W Close Window, Cmd+Ctrl+W Hide All Floating Windows and Cmd+Ctrl+= Score Editor. The shortcut handler only looked at engine commands, and these are window commands. It now falls back to them, and the menus and the Keyboard Shortcuts window show their keys. On a Mac, Cmd+Ctrl+= toggled Mix/Edit instead of opening the Score Editor. The Mix/Edit toggle now needs Cmd+= without Control there. The two Control chords still have no key on Windows and Linux. --- crates/ui-egui/src/menus.rs | 7 +++- crates/ui-egui/src/shortcuts.rs | 6 +++- crates/ui-egui/src/windows.rs | 11 +++--- crates/ui-egui/tests/window_shortcuts.rs | 43 ++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/window_shortcuts.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5b8f079..bbee052 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -261,7 +261,7 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str let (enabled, checked) = item_state(app, &n.path, id.as_deref()); let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone()); let label = if checked { format!("✔ {label}") } else { label }; - let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or(""); + let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or(""); let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac())); if ui.add_enabled(enabled, btn).clicked() && let Some(id) = id @@ -388,6 +388,11 @@ const MENU_WINDOWS: &[(&str, &str)] = &[ ("Event > MIDI Real-Time Properties", "window.rtp"), ]; +/// The shortcut of an engine or window command. +pub fn shortcut_for(id: &str) -> Option<&'static str> { + soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut) +} + /// Undo and Redo name the step they would undo or redo; other items keep their catalog label. pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option { let e = &app.engine; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 90a4e08..8438d3a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -81,6 +81,10 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { continue; } let _ = app.run(id, json!({})); + } else if let Some(c) = + crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))) + { + let _ = app.run(c.0, json!({})); } } } @@ -102,7 +106,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("window.search", json!({})); true } - Key::Equals if m.command => { + Key::Equals if m.command && !(mac && m.ctrl) => { let _ = app.run("window.toggle_mix_edit", json!({})); true } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b7b34e5..b4145e9 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -488,10 +488,13 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) { win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| { egui::ScrollArea::vertical().show(ui, |ui| { egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| { - for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) { - ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(c.shortcut.unwrap_or(""), mac)).font(mono(11.0))); - ui.label(c.label); - ui.label(egui::RichText::new(c.menu.join(" › ")).small()); + let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › "))); + let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › "))); + for (shortcut, label, menu) in engine.chain(windows) { + let Some(shortcut) = shortcut else { continue }; + ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0))); + ui.label(label); + ui.label(egui::RichText::new(menu).small()); ui.end_row(); } }); diff --git a/crates/ui-egui/tests/window_shortcuts.rs b/crates/ui-egui/tests/window_shortcuts.rs new file mode 100644 index 0000000..11e591e --- /dev/null +++ b/crates/ui-egui/tests/window_shortcuts.rs @@ -0,0 +1,43 @@ +use egui::os::OperatingSystem; +use egui::{Key, Modifiers}; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts}; + +const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn window_shortcuts_open_and_close_windows() { + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, cmd) in [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(os); + for key in [Key::Num1, Key::Num3, Key::Num4, Key::Num5, Key::Num9] { + press(&ctx, &mut app, key, cmd); + } + press(&ctx, &mut app, Key::M, Modifiers { alt: true, ..cmd }); + let ui = &app.ui; + assert!( + ui.show_transport && ui.show_big_counter && ui.show_automation && ui.show_memory_locations && ui.show_video && ui.narrow_mix, + "{os:?}" + ); + press(&ctx, &mut app, Key::W, cmd); + assert!(!app.ui.show_transport && !app.ui.show_big_counter && !app.ui.show_memory_locations, "{os:?}"); + } +} + +#[test] +fn cmd_ctrl_equals_opens_the_score_editor_on_a_mac() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(OperatingSystem::Mac); + press(&ctx, &mut app, Key::Equals, Modifiers { ctrl: true, ..MAC_CMD }); + assert!(app.ui.show_score); + assert_eq!(app.ui.window, MainWindow::Edit); + press(&ctx, &mut app, Key::Equals, MAC_CMD); + assert_eq!(app.ui.window, MainWindow::Mix); +} From 99992fe78c61be5e43234a4e034decebd6f9d2df Mon Sep 17 00:00:00 2001 From: Royce Date: Sat, 10 Oct 2026 23:23:17 +0800 Subject: [PATCH 010/137] fix(mix): push an instrument's stored params to its plugin before each block (#152) When a session is reopened with a saved instrument-level change such as 'level = -12 dB', the value sat in t.instrument.params but was never written to the live plugin instance, so the bounce produced the same audio as the default (-6 dB). The inserts loop already calls apply_params per block; the instrument branch did not. Call apply_params(slot, ins, t, INSTRUMENT_SLOT, pos) before each instrument's process() so a saved level (or any other param set via 'mix.insert_state') reaches the plugin exactly the way insert params do. Refs #152. --- crates/mix/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..64ab140 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -826,6 +826,14 @@ fn process_strip( } TrackKind::Instrument | TrackKind::Midi => { if let Some(inst) = &mut strip.instrument { + // #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); + } schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); From 16b7c38b6b1aecb4f84173661e77517773a0795d Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:46:39 +0100 Subject: [PATCH 011/137] Scroll a plugin window's parameters when they don't fit A plugin window listed every parameter in one grid. VST3 and CLAP plugins expose all of theirs, so a plugin with more parameters than fit on the screen ran off the bottom edge, and the ones below it couldn't be reached (#6). The parameters now scroll inside the window, below the header and the EQ curve, and the window stays on the screen. --- crates/ui-egui/src/panels.rs | 74 ++++++++++++++------------- crates/ui-egui/tests/plugin_window.rs | 19 +++++++ 2 files changed, 57 insertions(+), 36 deletions(-) create mode 100644 crates/ui-egui/tests/plugin_window.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9ee4ba2..1b41882 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -407,44 +407,46 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { if info.id == "eq_7band" || info.id == "eq_1band" { eq_curve(ui, info.id, &ins); } - egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| { - for p in info.params { - let v = ins.params.get(p.id).copied().unwrap_or(p.default); - ui.label(p.name); - let mut x = v; - let resp = if !p.choices.is_empty() { - let mut idx = x.round().max(0.0) as usize; - let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id)) - .selected_text(p.choices.get(idx).copied().unwrap_or("?")) - .show_ui(ui, |ui| { - for (i, c) in p.choices.iter().enumerate() { - ui.selectable_value(&mut idx, i, *c); - } - }); - x = idx as f32; - r.response - } else { - let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); - if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { - sl = sl.logarithmic(true); - } - ui.add(sl) - }; - if (x - v).abs() > f32::EPSILON { - let _ = app.engine.execute_merged( - "mix.insert_param", - &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}), - &format!("param:{}:{slot}:{}", tid.0, p.id), - ); - if let Some(pl) = &app.player { - // Keep the plugin's own editor in step. - pl.editor_set_param(tid, slot, p.id, x); + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| { + for p in info.params { + let v = ins.params.get(p.id).copied().unwrap_or(p.default); + ui.label(p.name); + let mut x = v; + let resp = if !p.choices.is_empty() { + let mut idx = x.round().max(0.0) as usize; + let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id)) + .selected_text(p.choices.get(idx).copied().unwrap_or("?")) + .show_ui(ui, |ui| { + for (i, c) in p.choices.iter().enumerate() { + ui.selectable_value(&mut idx, i, *c); + } + }); + x = idx as f32; + r.response + } else { + let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); + if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { + sl = sl.logarithmic(true); + } + ui.add(sl) + }; + if (x - v).abs() > f32::EPSILON { + let _ = app.engine.execute_merged( + "mix.insert_param", + &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}), + &format!("param:{}:{slot}:{}", tid.0, p.id), + ); + if let Some(pl) = &app.player { + // Keep the plugin's own editor in step. + pl.editor_set_param(tid, slot, p.id, x); + } } + let _ = resp; + ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); + ui.end_row(); } - let _ = resp; - ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); - ui.end_row(); - } + }); }); }); if open { diff --git a/crates/ui-egui/tests/plugin_window.rs b/crates/ui-egui/tests/plugin_window.rs new file mode 100644 index 0000000..2387033 --- /dev/null +++ b/crates/ui-egui/tests/plugin_window.rs @@ -0,0 +1,19 @@ +use egui_kittest::Harness; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn a_long_plugin_window_stays_on_a_short_screen() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.ui.plugin_windows = vec![(kick, 0)]; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 500.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap(); + assert!(window.bottom() <= 500.0, "{window:?}"); +} From 992f60c79c6d6ab6c64624b1ad99d23605d989b7 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:21:46 +0100 Subject: [PATCH 012/137] Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom - Pinch and Cmd+scroll zoom the timeline around the pointer. egui 0.36 turns both into zoom_delta, so the old Cmd+scroll branch never ran; Shift+wheel pans sideways again for the same reason. - Two-finger panning keeps the sub-sample remainder, so slow motion still moves at the closest zoom; the scrollbar drag pans the same way (its range now takes in the view, so the thumb stays in the bar), and the rulers pan too. Panning stays open-ended to the right, as before. - A swipe keeps to the axis it starts along, so panning doesn't drift between tracks and scrolling doesn't drift in time. The larger share of its first 6 points decides until egui reports the scroll over: the fingers lift, or 150 ms pass without wheel events. - The MIDI editor scrolls by the point instead of rounding each frame's delta to whole rows (slow swipes did nothing), and its range fits the panel (pitch 0 could not be reached; a taller panel showed blank rows). - Zoom and scroll write through `Engine::zoom_mut` (`view` commands): no playback resync (a session copy sent to the audio thread every frame), no copy of the whole session per frame, no journal entry per frame, and no end to an undo gesture (scrolling during an automation pass made each write its own undo step). New `view.zoom_by {factor, x?}`; `view.scroll` saturates; Fill Window and Zoom to Selection stay within the zoom range (zooming out after them on a long session zoomed in). - Control channel: `ui.scroll` (trackpad swipe or wheel notches) and `ui.zoom` (pinch); `session.inspect` reports zoom. --- README.md | 3 +- crates/engine/src/cmd/mod.rs | 18 ++-- crates/engine/src/cmd/plugin_state.rs | 1 + crates/engine/src/cmd/view.rs | 139 ++++++++++++++++++-------- crates/engine/src/inspect.rs | 1 + crates/engine/src/lib.rs | 33 ++++-- crates/engine/tests/hostile_params.rs | 3 + crates/ui-egui/src/control.rs | 31 +++++- crates/ui-egui/src/edit_window.rs | 60 +++++++---- crates/ui-egui/src/midi_editor.rs | 25 +++-- crates/ui-egui/tests/trackpad.rs | 95 ++++++++++++++++++ docs/control-protocol.md | 4 +- 12 files changed, 318 insertions(+), 95 deletions(-) create mode 100644 crates/ui-egui/tests/trackpad.rs diff --git a/README.md b/README.md index 89ec40e..f98c1ff 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ where things are. Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes; - clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything. + clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that + follows your fingers and pinch-to-zoom around the pointer; undo for everything. - **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders, VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip effects, and automatic plugin delay compensation. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b72971..3a69106 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -51,6 +51,8 @@ pub struct CommandSpec { pub run: Run, pub journal: bool, pub undoable: bool, + /// Only touches `edit.zoom`, through `Engine::zoom_mut`. + pub view: bool, } /// Serializable description of a command. @@ -80,18 +82,16 @@ impl CommandSpec { } } -/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants. +/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants. #[macro_export] macro_rules! cmd { - (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false } - }; - (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false } - }; - ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true } + (query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) }; + (noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) }; + (view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) }; + (@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view } }; + ($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) }; } pub fn command_specs() -> &'static [CommandSpec] { diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs index be533aa..b3ca0d3 100644 --- a/crates/engine/src/cmd/plugin_state.rs +++ b/crates/engine/src/cmd/plugin_state.rs @@ -17,6 +17,7 @@ pub fn specs() -> Vec { run: insert_state, journal: false, undoable: false, + view: false, }] } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9f24e3c..7b36430 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -7,54 +7,59 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat}; pub fn specs() -> Vec { vec![ - cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)), - cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)), - cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| { - let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0); - e.session_mut().edit.zoom.samples_per_px = v; + cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)), + cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)), + cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| { + let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?; + zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64)) + }), + cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| { + let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP); + e.zoom_mut().samples_per_px = v; Ok(json!({"samples_per_px": v})) }), - cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| { + cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| { let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize; - let s = e.session_mut(); + let z = e.zoom_mut(); if bool_or(p, "store", false) { - if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px } - } else if let Some(z) = s.edit.zoom.presets.get(i) { - s.edit.zoom.samples_per_px = *z; + if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px } + } else if let Some(v) = z.presets.get(i) { + z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP); } - Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px})) + Ok(json!({"samples_per_px": z.samples_per_px})) }), - cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| { + cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| { let w = f64_or(p, "width_px", 1200.0).max(100.0); - let s = e.session_mut(); - let r = s.edit.selection; + let r = e.session().edit.selection; if !r.is_empty() { - s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25); - s.edit.zoom.scroll = r.start; + let z = e.zoom_mut(); + z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP); + z.scroll = r.start; } Ok(json!({})) }), - cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| { + cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| { let w = f64_or(p, "width_px", 1200.0).max(100.0); - let s = e.session_mut(); + let s = e.session(); let end = s.content_end().max(s.sample_rate.samples(30.0)); - s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25); - s.edit.zoom.scroll = 0; + let z = e.zoom_mut(); + z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP); + z.scroll = 0; Ok(json!({})) }), - cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| { + cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| { let to = position_param(e, "view.scroll", p, "to")?; let by = f64_or(p, "by_px", 0.0); - let s = e.session_mut(); - let spp = s.edit.zoom.samples_per_px; - s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0); - Ok(json!({"scroll": s.edit.zoom.scroll})) + let z = e.zoom_mut(); + let by = soundcraft_time::to_samples(by * z.samples_per_px); + z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION); + Ok(json!({"scroll": z.scroll})) }), - cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| { - let s = e.session_mut(); - let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) }; - s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 }; - Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom})) + cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| { + let z = e.zoom_mut(); + let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) }; + z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 }; + Ok(json!({"waveform_zoom": z.waveform_zoom})) }), cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler), cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| { @@ -88,18 +93,19 @@ pub fn specs() -> Vec { ] } -fn zoom(e: &mut Engine, f: f64) -> Result { - let s = e.session_mut(); - let anchor = s.edit.selection.start; - let old = s.edit.zoom.samples_per_px; - let new = (old * f).clamp(0.25, 1_000_000.0); - // Keep the insertion point at the same screen x when it is visible. - let x = (anchor - s.edit.zoom.scroll) as f64 / old; - if (0.0..4000.0).contains(&x) { - s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0); +const MIN_SPP: f64 = 0.25; +const MAX_SPP: f64 = 1_000_000.0; + +fn zoom(e: &mut Engine, f: f64, x: Option) -> Result { + let insertion = e.session().edit.selection.start as f64; + let z = e.zoom_mut(); + let (old, scroll) = (z.samples_per_px, z.scroll as f64); + let new = (old * f).clamp(MIN_SPP, MAX_SPP); + if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) { + z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION); } - s.edit.zoom.samples_per_px = new; - Ok(json!({"samples_per_px": new})) + z.samples_per_px = new; + Ok(json!({"samples_per_px": new, "scroll": z.scroll})) } fn ruler(e: &mut Engine, p: &Value) -> Result { @@ -157,4 +163,55 @@ mod tests { assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5))); assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None); } + + fn view(e: &Engine) -> (f64, Samples) { + (e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll) + } + + #[test] + fn zoom_by_holds_the_position_under_the_pointer() { + let mut e = Engine::default(); + e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap(); + e.execute("view.scroll", &json!({"to": 48_000})).unwrap(); + e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap(); + assert_eq!(view(&e), (50.0, 78_000 - 300 * 50)); + e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap(); + assert_eq!(view(&e), (100.0, 48_000)); + } + + #[test] + fn zoom_and_scroll_stay_in_range() { + let mut e = Engine::default(); + e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap(); + e.execute("view.zoom_to_selection", &json!({})).unwrap(); + assert_eq!(view(&e).0, MAX_SPP); + e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap(); + assert_eq!(view(&e).0, MIN_SPP); + e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap(); + assert_eq!(view(&e), (MAX_SPP, 0)); + for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] { + assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}"); + } + e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap(); + assert_eq!(view(&e).1, MAX_POSITION); + e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap(); + assert_eq!(view(&e).1, 0); + } + + #[test] + fn view_commands_skip_undo_the_journal_and_session_copies() { + let mut e = Engine::default(); + let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone(); + let steps = e.undo_history().len(); + for db in [-1.0, -2.0] { + e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap(); + e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap(); + } + assert_eq!(e.undo_history().len(), steps + 1); + let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len()); + let before = state(&e); + e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap(); + e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap(); + assert_eq!(state(&e), before); + } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index b5de447..191adb8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -86,6 +86,7 @@ pub fn session(e: &Engine, full: bool) -> Value { })).collect::>(), "selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}, "edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(), + "zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll}, "transport": e.transport, "undo": e.undo_label(), "redo": e.redo_label(), }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f12901d..c9c0fd4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,7 +16,7 @@ pub mod io; pub mod score; use serde_json::{Value, json}; -use soundcraft_model::{Clip, Session, TrackId}; +use soundcraft_model::{Clip, Session, TrackId, ZoomState}; use soundcraft_time::Samples; use std::sync::Arc; @@ -108,7 +108,7 @@ pub struct Engine { pub journal: Vec<(String, Value)>, /// Path of the session file, if saved. pub path: Option, - /// Revision counter; bumps on every document change (UI caches key on it). + /// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it). pub revision: u64, dirty: bool, /// Gesture key for undo coalescing (see `execute_merged`). @@ -158,6 +158,12 @@ impl Engine { Arc::make_mut(&mut self.doc) } + /// Like `session_mut`, for `edit.zoom` only and without bumping `revision`. + pub fn zoom_mut(&mut self) -> &mut ZoomState { + self.dirty = true; + &mut Arc::make_mut(&mut self.doc).edit.zoom + } + /// Replace the whole document (open / new); clears history. pub fn replace_session(&mut self, s: Session) { self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() }; @@ -227,7 +233,6 @@ impl Engine { /// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the /// document is restored. pub fn execute(&mut self, id: &str, params: &Value) -> Result { - self.merge_key = None; let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if let Err(reason) = (spec.enabled)(self) { // A call that names its targets (tracks, clips, a range) does not need a selection; @@ -237,6 +242,10 @@ impl Engine { return Err(EngineError::Disabled(id.to_string(), reason)); } } + if spec.view { + return self.execute_view(spec, params); + } + self.merge_key = None; let before = Arc::clone(&self.doc); let rev = self.revision; let dirty = self.dirty; @@ -244,12 +253,10 @@ impl Engine { let result = match result { Ok(r) => r, Err(p) => { - let msg = - p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); self.doc = before; self.revision = rev.wrapping_add(1); self.dirty = dirty; - return Err(EngineError::Internal(id.to_string(), msg)); + return Err(EngineError::Internal(id.to_string(), panic_message(&*p))); } }; match result { @@ -274,6 +281,16 @@ impl Engine { } } + fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result { + let zoom = self.doc.edit.zoom; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))) + .unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p)))); + if result.is_err() && self.doc.edit.zoom != zoom { + Arc::make_mut(&mut self.doc).edit.zoom = zoom; + } + result + } + /// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls /// with the same `key` collapse into a single undo step. pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result { @@ -329,6 +346,10 @@ fn install_panic_hook() { }); } +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 merge_tests { use super::*; diff --git a/crates/engine/tests/hostile_params.rs b/crates/engine/tests/hostile_params.rs index 8a2a382..5aa6d39 100644 --- a/crates/engine/tests/hostile_params.rs +++ b/crates/engine/tests/hostile_params.rs @@ -45,6 +45,9 @@ const KEYS: &[&str] = &[ "numerator", "denominator", "factor", + "x", + "by_px", + "samples_per_px", "sensitivity", "rating", "direction", diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 93b0626..2ea380e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -7,6 +7,7 @@ //! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect` //! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs) //! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}` +//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch) //! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}` //! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}` //! - `app.quit` @@ -91,6 +92,8 @@ fn mods(p: &Value) -> egui::Modifiers { fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option { let p = &req.params; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let pos = egui::pos2(f("x"), f("y")); + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); Some(match req.method.as_str() { "engine.execute" | "command" => { let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or(""); @@ -147,11 +150,10 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Err(e) => err(e), }, "ui.move" => { - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + app.synthetic.push(egui::Event::PointerMoved(pos)); ok(json!({})) } "ui.click" => { - let pos = egui::pos2(f("x"), f("y")); let button = match p.get("button").and_then(Value::as_str) { Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => egui::PointerButton::Primary, @@ -166,9 +168,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti ok(json!({})) } "ui.drag" => { - let a = egui::pos2(f("x"), f("y")); - let b = egui::pos2(f("to_x"), f("to_y")); - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); + let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y"))); let m = mods(p); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); @@ -179,6 +179,27 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); ok(json!({})) } + "ui.scroll" => { + let line = p.get("unit").and_then(Value::as_str) == Some("line"); + let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; + let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32; + let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p)); + let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers }; + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO))); + app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta))); + app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO))); + ok(json!({})) + } + "ui.zoom" => { + let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else { + return Some(err("ui.zoom needs a positive factor (> 1 zooms in)")); + }; + let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32; + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each))); + ok(json!({})) + } "ui.key" => { let name = p.get("key").and_then(Value::as_str).unwrap_or(""); let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) }; diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..cb1f4e0 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -44,6 +44,11 @@ pub struct EditLayout { pub last_scroll: Samples, /// Follow target issued by the last overlay frame (`None` when none). pub last_follow_to: Option, + #[serde(skip)] + pub scroll_carry: f64, + /// The current swipe's movement per axis until either passes 6 points; it scrolls only along the larger. + #[serde(skip)] + swipe: egui::Vec2, } pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { @@ -195,23 +200,7 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h)); app.edit_layout.timeline = [tl.min.x, tl.min.y, tl.max.x, tl.max.y]; ui.painter().rect_filled(full, 0.0, t.window_bg); - // Wheel: vertical scroll tracks, shift/horizontal scroll timeline, cmd = zoom. - let hovered = ui.rect_contains_pointer(Rect::from_min_max(pos2(full.min.x, tl.min.y), full.max)); - if hovered { - let (delta, mods) = ui.input(|i| (i.smooth_scroll_delta, i.modifiers)); - if mods.command && delta.y.abs() > 0.0 { - let id = if delta.y > 0.0 { "view.zoom_in" } else { "view.zoom_out" }; - let _ = app.run(id, json!({})); - } else { - let dx = if mods.shift { delta.y } else { delta.x }; - if dx.abs() > 0.0 { - let _ = app.engine.execute("view.scroll", &json!({"by_px": -dx})); - } - if !mods.shift && delta.y.abs() > 0.0 { - app.edit_layout.scroll_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0)); - } - } - } + scroll_and_zoom(app, ui, full, tl); draw_rulers(app, ui, rulers_rect, tl, &rulers); // Tracks. let ids: Vec = @@ -262,12 +251,45 @@ fn folder_collapsed(s: &Session, t: &Track) -> bool { t.folder.and_then(|f| s.track(f)).is_some_and(|f| !f.folder_open) } +fn scroll_and_zoom(app: &mut SoundApp, ui: &Ui, area: Rect, tl: Rect) { + let (delta, zoom, pointer, scrolling) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.is_scrolling())); + let mut swipe = if scrolling { app.edit_layout.swipe } else { egui::Vec2::ZERO }; + if swipe.max_elem() < 6.0 { + swipe += delta.abs(); + } + app.edit_layout.swipe = swipe; + let Some(p) = pointer.filter(|_| tl.is_positive() && ui.rect_contains_pointer(area)) else { return }; + if zoom != 1.0 { + let _ = app.engine.execute("view.zoom_by", &json!({"factor": zoom, "x": p.x.clamp(tl.min.x, tl.max.x) - tl.min.x})); + } + let horizontal = swipe.x > swipe.y; + if horizontal && delta.x != 0.0 { + pan(app, -f64::from(delta.x)); + } + if !horizontal && delta.y != 0.0 { + app.edit_layout.scroll_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0)); + } +} + +fn pan(app: &mut SoundApp, px: f64) { + let z = app.engine.session().edit.zoom; + let want = (z.scroll as f64 + app.edit_layout.scroll_carry + px * z.samples_per_px).clamp(0.0, soundcraft_engine::cmd::MAX_POSITION as f64); + if want.is_nan() { + return; + } + let to = soundcraft_time::to_samples(want); + app.edit_layout.scroll_carry = want - to as f64; + if to != z.scroll { + let _ = app.engine.execute("view.scroll", &json!({"to": to})); + } +} + fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { let t = Tokens::current(); ui.painter().rect_filled(sb, 0.0, t.panel_bg); let s = app.engine.session(); - let total = (s.content_end() + s.sample_rate.samples(30.0)).max(1) as f64; let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px; + let total = (s.content_end().saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis); let frac = (vis / total).clamp(0.02, 1.0) as f32; let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32; let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0)); @@ -276,7 +298,7 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { if resp.dragged() { let dx = resp.drag_delta().x / sb.width(); let by_px = f64::from(dx) * total / s.edit.zoom.samples_per_px; - let _ = app.engine.execute("view.scroll", &json!({"by_px": by_px})); + pan(app, by_px); } } diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index a4882d4..e69d625 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0; pub struct MidiEditorState { pub selected: Vec, pub drag: Option, - pub top_pitch: i32, + pub scroll: f32, pub clip: Option, /// Rubber-band selection start (screen position). pub band: Option, @@ -127,28 +127,27 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); // Vertical range: centre on the notes the first time. let rows = (roll.height() / ROW_H).floor() as i32; - if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) { + if app.midi.clip != Some(cid) { let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72); let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48); - let mid = (hi + lo) / 2; - app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127); + app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H; app.midi.clip = Some(cid); } if ui.rect_contains_pointer(roll) { - let dy = ui.input(|i| i.smooth_scroll_delta.y); - if dy.abs() > 0.5 { - app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127); - } + app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y); } - let top = app.midi.top_pitch; + 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 + (top - pitch) as f32 * ROW_H; + 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 painter = ui.painter().with_clip_rect(roll.union(keys)); // Background rows and keys. - for r in 0..=rows { + let top = pitch_at(roll.min.y); + for r in 0..=rows + 1 { let pitch = top - r; - let y = roll.min.y + r as f32 * ROW_H; + let y = 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)); @@ -288,7 +287,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Audition on the instrument would go here (needs a live MIDI input path). } None => { - let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127); + let pitch = 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", diff --git a/crates/ui-egui/tests/trackpad.rs b/crates/ui-egui/tests/trackpad.rs new file mode 100644 index 0000000..83a4180 --- /dev/null +++ b/crates/ui-egui/tests/trackpad.rs @@ -0,0 +1,95 @@ +use egui::{Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::{Value, json}; +use soundcraft_time::to_samples; +use soundcraft_ui_egui::{ControlRequest, Services, SoundApp}; +use std::sync::mpsc::{Sender, channel}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + tx: Sender, + width: f32, +} + +impl Rig { + fn new(width: f32) -> Rig { + let (tx, rx) = channel(); + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx); + let mut r = Rig { ctx: egui::Context::default(), app, tx, width }; + r.frames(6); + r + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() }; + self.app.raw_input_hook(&mut raw); + self.ctx + .run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }) + .textures_delta + .clear(); + } + } + + fn send(&mut self, method: &str, params: Value) { + let (req, reply) = ControlRequest::new(method, params); + self.tx.send(req).unwrap(); + self.frames(40); + assert_eq!(reply.try_recv().unwrap()["ok"], true, "{method}"); + } + + fn view(&self) -> (f64, i64) { + let z = self.app.engine.session().edit.zoom; + (z.samples_per_px, z.scroll) + } + + fn timeline(&self) -> Rect { + let [x0, y0, x1, y1] = self.app.edit_layout.timeline; + Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)) + } +} + +#[test] +fn swipes_and_wheels_scroll_with_the_fingers_on_one_axis_even_slowly() { + let mut r = Rig::new(1400.0); + let (tl, (spp, scroll)) = (r.timeline(), r.view()); + let (x, y) = (tl.center().x, tl.center().y); + assert!(r.app.edit_layout.content_h - tl.height() > 60.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -240.0, "dy": -60.0})); + assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, 0.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -30.0, "dy": -60.0})); + assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, 60.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": 1.0, "unit": "line", "steps": 1})); + assert!((r.app.edit_layout.scroll_y - 20.0).abs() < 1.0, "{}", r.app.edit_layout.scroll_y); + r.send("ui.scroll", json!({"x": x, "y": tl.min.y - 20.0, "dx": 100_000.0})); + assert_eq!(r.view(), (spp, 0)); + r.app.engine.execute("view.zoom_set", &json!({"samples_per_px": 0.25})).unwrap(); + let scroll_y = r.app.edit_layout.scroll_y; + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -12.0, "dy": -6.0, "steps": 24})); + assert_eq!(r.view().1, 3); + assert_eq!(r.app.edit_layout.scroll_y, scroll_y); +} + +#[test] +fn pinch_and_cmd_scroll_zoom_around_the_pointer_and_shift_scroll_pans() { + let mut r = Rig::new(1400.0); + let tl = r.timeline(); + let (x, y) = (tl.min.x + 300.0, tl.center().y); + let under = |r: &Rig| r.view().1 as f64 + 300.0 * r.view().0; + let (spp, at) = (r.view().0, under(&r)); + r.send("ui.zoom", json!({"x": x, "y": y, "factor": 4.0})); + assert!((r.view().0 * 4.0 / spp - 1.0).abs() < 1e-4, "{spp} -> {}", r.view().0); + assert!((under(&r) - at).abs() < 8.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": 100.0, "cmd": true})); + assert!(r.view().0 < spp / 4.0); + assert!((under(&r) - at).abs() < 8.0); + let ((spp, scroll), scroll_y) = (r.view(), r.app.edit_layout.scroll_y); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": -100.0, "shift": true})); + assert_eq!(r.view(), (spp, scroll + to_samples(100.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, scroll_y); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a64c4c8..548b54a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,13 +30,15 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr | `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. | | `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). | | `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. | -| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, transport, undo label. | +| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. | | `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). | | `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. | | `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). | | `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). | | `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). | | `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. | +| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. | +| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). | | `ui.move` | `{x, y}` | Move the pointer. | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). | | `ui.text` | `{text}` | Type text into the focused field. | From 534b44d430c9e3a02fd5719c65d23f529e588752 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:12:58 +0100 Subject: [PATCH 013/137] Reopen the window at its last size and interface scale The window opened at 1600x1000 on every launch, and the interface scale set in UI Customization reset to 100%. The app now keeps the window's size, whether it was maximized and the scale in its preferences (ui.json, saved on quit) and restores them at launch. The size is kept in unscaled points, so a scaled interface doesn't change the window's size from one launch to the next. The position is left to the system, so a disconnected monitor can't leave the window off screen. A window too big for the screen it opens on (sized on a larger monitor) opens maximized instead and keeps its saved size for that monitor. Runs with SOUNDCRAFT_NO_PREFS still start at the defaults. --- apps/soundcraft/src/main.rs | 16 +++++++++---- crates/ui-egui/src/lib.rs | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index a54e7e1..54266c5 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -49,6 +49,13 @@ impl eframe::App for App { if self.0.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + soundcraft_ui_egui::fit_window_to_screen(ctx); + let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false))); + self.0.ui.window_maximized = maximized; + if let Some(r) = inner.filter(|_| !maximized) { + self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into(); + } + self.0.ui.ui_scale = self.0.extra.ui_scale; // With the system menu bar there is no egui bar showing the window and dirty state, so the // title carries them; elsewhere the egui menu bar already does. let title = if self.0.native_menu_bar { @@ -213,10 +220,12 @@ fn main() -> eframe::Result { } let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; + let prefs = load_prefs().unwrap_or_default(); let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("SoundCraft") - .with_inner_size([1600.0, 1000.0]) + .with_inner_size(prefs.window_size) + .with_maximized(prefs.window_maximized) .with_min_inner_size([960.0, 600.0]) .with_drag_and_drop(true) .with_app_id("ai.storyteller.soundcraft"), @@ -236,9 +245,8 @@ fn main() -> eframe::Result { let mut app = SoundApp::new(engine, player, services()); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); - if let Some(ui) = load_prefs() { - app.ui = ui; - } + app.extra.ui_scale = prefs.ui_scale; + app.ui = prefs; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..83f5135 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,11 @@ pub struct UiState { pub configurations: Vec<(String, Value)>, /// Appearance (`ui.theme`): dark by default, light or follow the system on request. pub theme: theme::ThemeMode, + /// The main window's size (logical points) and maximized state, and the interface scale, as + /// the desktop app last saw them; it restores them at launch. + pub window_size: [f32; 2], + pub window_maximized: bool, + pub ui_scale: f32, } impl Default for UiState { @@ -153,6 +158,9 @@ impl Default for UiState { workspace_dir: String::new(), configurations: Vec::new(), theme: theme::ThemeMode::Dark, + window_size: [1600.0, 1000.0], + window_maximized: false, + ui_scale: 1.0, } } } @@ -169,6 +177,15 @@ pub fn preset_folder_name(plugin: &str) -> String { plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect() } +/// On the first frame, maximizes a window too big for the screen it opened on (sized on a larger +/// monitor) instead of letting it run off the edge; its saved size is kept for that monitor. +pub fn fit_window_to_screen(ctx: &egui::Context) { + let (inner, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size)); + if ctx.cumulative_pass_nr() == 0 && inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true)); + } +} + /// Drag-and-drop payload: an audio file (source id) dragged from the Clip List. #[derive(Debug, Clone, Copy)] pub struct DragSource(pub u64); @@ -997,4 +1014,33 @@ mod tests { fn automation_write_fires_after_loop_wrap() { assert!(automation_step_due(0, 1_000_000, 960)); } + + #[test] + fn preferences_saved_before_the_window_was_remembered_keep_full_scale() { + let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap(); + assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0)); + } + + #[test] + fn a_window_bigger_than_its_screen_opens_maximized() { + let maximizes = |w: f32, frames: usize| { + let ctx = egui::Context::default(); + let info = egui::ViewportInfo { + inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 1000.0))), + monitor_size: Some(egui::vec2(1728.0, 1117.0)), + ..Default::default() + }; + let mut maximized = false; + for _ in 0..frames { + let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, info.clone())].into_iter().collect(), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| super::fit_window_to_screen(ui.ctx())); + out.textures_delta.clear(); + maximized = out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Maximized(true)); + } + maximized + }; + assert!(maximizes(2400.0, 1)); + assert!(!maximizes(1600.0, 1)); + assert!(!maximizes(2400.0, 2)); + } } From d7c38188596d866b03c9d07fd532df145dc85301 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:31:26 +0100 Subject: [PATCH 014/137] Let the desktop app sleep when idle or covered The app sent the window title to egui on every frame. Each viewport command asks for another frame, so the window redrew at the display's refresh rate with nothing happening: 35-43% of a CPU core and 13-20% of the GPU per window on a 120 Hz Mac. While the window was covered or minimized, eframe runs only App::logic, with no vsync to pace it, and the same request kept one core at 100%. The title is now sent only when it changes. The last title sent is kept in egui's context data rather than compared with the window's, because winit can't read a title back on X11 or Wayland. Some work only ran because another frame always came, so an idle window still wakes once a second, and every 30 ms while a plugin editor is open. Autosave and the macOS menu bar's throttled refresh run on frames, and plugin editors are native windows egui never hears from: their edits (VST3 performEdit, CLAP output events) and main-thread requests are serviced on frames. egui takes its predicted frame time off every delay, so it is added back. The menu bar can now trail a shortcut by up to a second instead of 250 ms. Playback, falling meters, pending screenshots and scripted input ask for the next frame from ui() instead of logic(). eframe skips ui() for a covered or minimized window, so a hidden window runs on the heartbeat instead of spinning, and egui redraws it as soon as it shows again. Autosave was timed by egui's frame clock, which stands still while the window is hidden, so a modified session wasn't autosaved until the window showed again. It is timed by the wall clock now. Only the desktop app sets an autosave folder, so the web build never reads it. Tests check that an unchanged title sends no command, that only a shown window animates while a hidden one wakes once a second, and that a hidden window still autosaves. Measured on a 120 Hz MacBook with a release build, the demo session and audio off: an idle Edit window went from 35-43% CPU and 13-20% GPU to 1.5% and 0.4% (the insertion point blinks), the Mix window to 0.2%, and a covered window, idle or playing, from ~100% CPU to 0%. --- apps/soundcraft/src/main.rs | 29 ++++++++++++++++++++++++- crates/playback/src/lib.rs | 4 ++++ crates/ui-egui/src/lib.rs | 42 +++++++++++++++++++++++++----------- crates/ui-egui/tests/idle.rs | 27 +++++++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/idle.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index a54e7e1..27684a1 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -25,6 +25,15 @@ use std::sync::Arc; struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option); +/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back. +fn set_title(ctx: &egui::Context, title: String) { + let id = egui::Id::new("window_title"); + if ctx.data(|d| d.get_temp::(id)).as_deref() != Some(title.as_str()) { + ctx.data_mut(|d| d.insert_temp(id, title.clone())); + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + } +} + impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // What the realtime audio thread logged is written here, off that thread. @@ -61,7 +70,7 @@ impl eframe::App for App { } else { format!("{} — SoundCraft", self.0.engine.session().name) }; - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + set_title(ctx, title); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); @@ -267,3 +276,21 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::set_title; + + #[test] + fn the_title_is_sent_only_when_it_changes() { + let ctx = egui::Context::default(); + let titles = |title: &str| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string())); + out.textures_delta.clear(); + out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count() + }; + assert_eq!(titles("Song — SoundCraft"), 1); + assert_eq!(titles("Song — SoundCraft"), 0); + assert_eq!(titles("Song * — SoundCraft"), 1); + } +} diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..0e0a380 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -584,6 +584,10 @@ impl Player { self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open()) } + pub fn any_editor_open(&self) -> bool { + self.side().editors.values().any(|e| e.is_open()) + } + /// Shows a parameter change made elsewhere in the plugin's open editor. pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) { if let Some(e) = self.side().editors.get_mut(&(track, slot)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..1e28b04 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -216,7 +216,7 @@ pub struct SoundApp { pub autosave_dir: Option, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, - last_autosave: f64, + last_autosave: Option, pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, @@ -276,7 +276,7 @@ impl SoundApp { native_menu_bar: false, autosave_dir: None, preset_dir: None, - last_autosave: 0.0, + last_autosave: None, dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), @@ -473,15 +473,14 @@ impl SoundApp { } /// Every two minutes, copy a modified session to the autosave folder (not while recording). - fn autosave(&mut self, now: f64) { + /// Timed by the wall clock: egui's stands still while the window is hidden. + fn autosave(&mut self) { let Some(dir) = self.autosave_dir.clone() else { return }; - if self.last_autosave == 0.0 { - self.last_autosave = now; - } - if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording { + let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now); + if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording { return; } - self.last_autosave = now; + self.last_autosave = Some(std::time::Instant::now()); self.capture_plugin_states(); let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); let path = dir.join(&name).join(format!("{name}.scraft")); @@ -798,16 +797,17 @@ impl SoundApp { } } self.write_automation(ctx); - self.autosave(now); + self.autosave(); if self.recorder.is_none() && !self.recorder_failed && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm) { self.ensure_input(); } - if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { - ctx.request_repaint(); - } + // Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from. + // egui takes its predicted frame time off every delay, so add it back. + let editors = self.player.as_ref().is_some_and(Player::any_editor_open); + ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt)); control::collect_screenshots(self, ctx); } @@ -873,6 +873,10 @@ impl SoundApp { score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + // Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it. + if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + ctx.request_repaint(); + } } /// UI state as JSON (control channel `ui.inspect`). @@ -960,6 +964,20 @@ mod tests { assert_eq!(app.engine.transport.position, 0); } + #[test] + fn a_hidden_window_still_autosaves() { + let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id())); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.autosave_dir = Some(dir.clone()); + app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap(); + app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121)); + let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() }; + let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() }; + let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx)); + assert!(app.latest_autosave().is_some()); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); diff --git a/crates/ui-egui/tests/idle.rs b/crates/ui-egui/tests/idle.rs new file mode 100644 index 0000000..be7cfa7 --- /dev/null +++ b/crates/ui-egui/tests/idle.rs @@ -0,0 +1,27 @@ +use soundcraft_ui_egui::{Services, SoundApp}; +use std::time::Duration; + +fn pass(ctx: &egui::Context, app: &mut SoundApp, shown: bool) -> Duration { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + if shown { + app.ui(ui); + } + }); + out.textures_delta.clear(); + out.viewport_output[&egui::ViewportId::ROOT].repaint_delay +} + +#[test] +fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + for _ in 0..3 { + pass(&ctx, &mut app, true); + } + assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO); + app.run("transport.play", serde_json::json!({})).unwrap(); + assert_eq!(pass(&ctx, &mut app, true), Duration::ZERO); + pass(&ctx, &mut app, false); + assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3); +} From 86824b592ae5d6b394e5bf7dfc4aa1b7a16e717f Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:55:53 +0100 Subject: [PATCH 015/137] Never overwrite audio files when saving Audio that exists only in memory (takes, consolidations, AudioSuite renders, commits) gets the default path `Audio Files/.wav`, and Save treated an existing file at that path as the source's own and rewrote it. Names repeat easily: two sessions in one folder both call their first take on Audio 1 "Audio 1_02", and consolidating twice on Kick makes two "Kick_consolidated" sources. Saving silently replaced the earlier audio, so the other session or the earlier clip played the wrong audio once reopened. Save As also trusted a same-named file in the target folder for audio saved elsewhere, so saving into another session's folder could point the new session at that session's audio. New audio now goes to the first free name (`.wav`, `_01.wav`, ...), created with create_new so no existing file is ever truncated, or to a file that already holds exactly the same bytes, such as a take saved again after undoing past a save. Saved audio is reused only when saving into the folder it was saved in, which the session now remembers, so undoing past a Save As or saving after Save Copy In can't pick up another session's files either. Audio that failed to load keeps pointing at its old folder when the session is saved elsewhere. --- crates/engine/src/io.rs | 161 +++++++++++++++++++++++++++++++----- crates/model/src/session.rs | 4 + 2 files changed, 146 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..a563387 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -466,7 +466,7 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result { } /// Write a copy of the session (and any audio it needs) without touching the open document — -/// used for autosave / crash recovery. Audio files in the target folder are overwritten in place. +/// used for autosave / crash recovery. Audio already written for a source is reused. pub fn save_copy(e: &Engine, path: &str) -> Result { write_session(e.session(), path, true).map(|(_, n, _)| n) } @@ -479,42 +479,42 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); + let from = s.folder.clone(); + let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir)); let mut written = 0; for src in &mut s.sources { - let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); - let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); + if !same_dir + && s.pool.get(src.id).is_none() + && Path::new(&src.path).is_relative() + && let Some(f) = &from + { + src.path = f.join(&src.path).to_string_lossy().into_owned(); + } + let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); if exists_rel && !src.unsaved { continue; } let Some(audio) = s.pool.get(src.id) else { continue }; std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; - let mut target = rel_target.clone(); + let mut stem = sanitize_name(&src.name); if overwrite { // One file per source id so autosaves never collide or pile up. - target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0); - if dir.join(&target).exists() { - src.path = target; + stem = format!("{stem}_{}", src.id.0); + if audio_dir.join(format!("{stem}.wav")).exists() { + src.path = format!("Audio Files/{stem}.wav"); src.unsaved = false; continue; } - } else { - let mut k = 1; - while dir.join(&target).exists() && !(exists_rel && target == src.path) { - target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); - k += 1; - if k > 999 { - break; - } - } } let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?; - src.path = target; + let (name, new) = store_wav(&audio_dir, &stem, &bytes)?; + src.path = format!("Audio Files/{name}"); src.unsaved = false; src.format = FileFormat::Wav; - written += 1; + written += usize::from(new); } + s.folder = Some(dir.clone()); if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { s.name = stem.to_string(); } @@ -526,6 +526,38 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess Ok((s, written, p)) } +fn same_path(a: &Path, b: &Path) -> bool { + let full = |p: &Path| if p.as_os_str().is_empty() { Path::new(".").canonicalize() } else { p.canonicalize() }; + a == b || matches!((full(a), full(b)), (Ok(x), Ok(y)) if x == y) +} + +/// Store `bytes` as the first of `.wav`, `_01.wav`, … in `dir` that is free or already +/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was +/// written. +fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> { + use std::io::Write; + for k in 0..10_000u32 { + let name = if k == 0 { format!("{stem}.wav") } else { format!("{stem}_{k:02}.wav") }; + let path = dir.join(&name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut f) => { + if let Err(err) = f.write_all(bytes) { + let _ = std::fs::remove_file(&path); + return Err(EngineError::Io(format!("{}: {err}", path.display()))); + } + return Ok((name, true)); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) { + return Ok((name, false)); + } + } + Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))), + } + } + Err(EngineError::Io(format!("{}: no free file name for {stem}", dir.display()))) +} + /// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal). pub fn open_session(e: &mut Engine, path: &str) -> Result> { let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; @@ -549,6 +581,7 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { } } missing.extend(relink_videos(&mut s, &dir)); + s.folder = Some(dir); e.replace_session(s); e.path = Some(path.to_string()); for m in &missing { @@ -622,6 +655,96 @@ mod save_tests { assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); let _ = std::fs::remove_dir_all(&dir); } + + fn with_take(level: f32) -> Engine { + let mut e = Engine::default(); + let s = e.session_mut(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let src = add_source(s, "Audio 1_02", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_02", src, 0, 0, 4_800)); + e + } + + fn reopen(path: &str) -> Engine { + let mut r = Engine::default(); + assert_eq!(open_session(&mut r, path).unwrap(), Vec::::new()); + r + } + + #[test] + fn saving_never_replaces_audio_of_another_session() { + let dir = std::env::temp_dir().join(format!("soundcraft-no-replace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |p: &str| dir.join(p).to_string_lossy().into_owned(); + save_session(&mut with_take(0.5), &path("A/A.scraft")).unwrap(); + save_session(&mut with_take(-0.25), &path("A/A v2.scraft")).unwrap(); + save_session(&mut with_take(0.75), &path("B/B.scraft")).unwrap(); + save_session(&mut reopen(&path("B/B.scraft")), &path("A/B.scraft")).unwrap(); + let levels = ["A/A.scraft", "A/A v2.scraft", "B/B.scraft", "A/B.scraft"].map(|p| { + let r = reopen(&path(p)); + r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0] + }); + assert_eq!(levels, [0.5, -0.25, 0.75, 0.75]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn consolidating_twice_keeps_both_renders() { + let dir = std::env::temp_dir().join(format!("soundcraft-consolidate-twice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().id; + let end = e.session().content_end(); + let render = |e: &Engine| soundcraft_mix::render_clips(e.session(), kick, Range::new(0, end)); + e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 500_000, "end": 560_000})).unwrap(); + save_session(&mut e, &path).unwrap(); + e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 1_400_000, "end": 1_500_000})).unwrap(); + save_session(&mut e, &path).unwrap(); + assert!(render(&reopen(&path)) == render(&e)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saving_after_undo_reuses_the_saved_audio() { + let dir = std::env::temp_dir().join(format!("soundcraft-undo-resave-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let mut e = with_take(0.5); + e.execute("track.new", &json!({})).unwrap(); + assert_eq!(save_session(&mut e, &path).unwrap(), 1); + e.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(save_session(&mut e, &path).unwrap(), 0); + assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saved_audio_is_trusted_only_in_the_folder_it_was_saved_in() { + let dir = std::env::temp_dir().join(format!("soundcraft-own-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = |p: &str| dir.join(p).to_string_lossy().into_owned(); + let level = |r: &Engine| r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0]; + save_session(&mut with_take(-0.25), &path("B/Other.scraft")).unwrap(); + let mut e = with_take(0.5); + save_session(&mut e, &path("A/A.scraft")).unwrap(); + e.execute("track.new", &json!({})).unwrap(); + save_session(&mut e, &path("B/B.scraft")).unwrap(); + e.execute("edit.undo", &json!({})).unwrap(); + e.execute("session.save", &json!({})).unwrap(); + assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5); + e.execute("session.save_copy", &json!({"path": path("A/Copy.scraft")})).unwrap(); + e.execute("session.save", &json!({})).unwrap(); + assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5); + std::fs::remove_file(dir.join("A/Audio Files/Audio 1_02.wav")).unwrap(); + let mut missing = Engine::default(); + open_session(&mut missing, &path("A/A.scraft")).unwrap(); + save_session(&mut missing, &path("B/Moved.scraft")).unwrap(); + let mut r = Engine::default(); + assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index f3537d7..2b942fc 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -308,6 +308,9 @@ pub struct Session { pub midi_originals: std::collections::BTreeMap, #[serde(skip)] pub pool: SourcePool, + /// The folder relative source paths point into: where the session was opened or last saved. + #[serde(skip)] + pub folder: Option, } impl Default for Session { @@ -341,6 +344,7 @@ impl Session { comments: String::new(), midi_originals: std::collections::BTreeMap::new(), pool: SourcePool::default(), + folder: None, }; s.edit.pre_roll = sample_rate.samples(2.0); s.edit.post_roll = sample_rate.samples(2.0); From 62a0b683e5479980e45ffc728026bae7fe82110e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:57:03 +0100 Subject: [PATCH 016/137] Ask before Save As replaces another session Save As, Save Copy In, Save As Template and Export Selected Tracks as New Session wrote over any existing file without asking. The Save As prompt proposes ~//.scraft, so every new session suggests ~/Untitled/Untitled.scraft, and saving a second new session with the suggested path replaced the first one. These commands now refuse to write over an existing file unless `replace` is true; Save, Save As and Save Copy In may still write the open session's own file. Scripts, MCP agents and the control channel can't replace a session by accident. While the path in the prompt names an existing file, the prompt asks whether to replace it and its OK button becomes Replace; Enter alone doesn't replace. Saving also creates the session's folder, so a new session without audio can be saved to the suggested path. The CLI's --save names its output explicitly and keeps replacing it, like --bounce. --- apps/soundcraft-cli/src/main.rs | 2 +- crates/automation/src/mcp.rs | 7 +++- crates/engine/src/cmd/file.rs | 29 ++++++++++---- crates/engine/src/io.rs | 51 ++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 10 ++++- crates/ui-egui/tests/save_as_replace.rs | 38 ++++++++++++++++++ docs/mcp.md | 2 +- 7 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/save_as_replace.rs diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 46271c9..2d8861c 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -187,7 +187,7 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode { } } if let Some(path) = arg_value(args, "--save") { - match e.execute("session.save", &json!({"path": path})) { + match e.execute("session.save", &json!({"path": path, "replace": true})) { Ok(v) => outln!("{v}"), Err(err) => return fail(err), } diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 1b38dcc..b3d21ab 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -54,7 +54,12 @@ pub fn tool_definitions() -> Vec { &[], ), tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]), - tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]), + tool( + "save_session", + "Save the session (path required the first time; replace: true to save over another existing file).", + json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}), + &[], + ), tool( "import_audio", "Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..8c91d0a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,32 +15,37 @@ pub fn specs() -> Vec { Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing})) }), cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }), - cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| { + cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?, replace?: false}", always, |e, p| { let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?; + refuse_replacing(e, "session.save", &crate::io::session_file(&path), p)?; let written = crate::io::save_session(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), - cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| { + cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path, replace?: false}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string(); + refuse_replacing(e, "session.save_as", &crate::io::session_file(&path), p)?; let written = crate::io::save_session(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), - cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| { + cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path, replace?: false}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string(); + refuse_replacing(e, "session.save_copy", &crate::io::session_file(&path), p)?; let keep = e.path.clone(); let written = crate::io::save_session(e, &path)?; e.path = keep; Ok(json!({"path": path, "audio_files_written": written})) }), - cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| { + cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path, replace?: false}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string(); + let file = crate::io::session_file(&path); + refuse_replacing(e, "session.save_template", &file, p)?; let mut s = e.session().clone(); for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } } s.sources.clear(); s.videos.clear(); let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; - Ok(json!({"path": path})) + std::fs::write(&file, text).map_err(|err| EngineError::Io(format!("{}: {err}", file.display())))?; + Ok(json!({"path": file})) }), cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?; @@ -115,8 +120,9 @@ pub fn specs() -> Vec { let n = crate::io::export_clips(e, &ids, &dir)?; Ok(json!({"written": n})) }), - cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| { + cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?, replace?: false}", has_selection, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string(); + refuse_replacing(e, "file.export_selected_tracks_as_session", &crate::io::session_file(&path), p)?; let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?; let mut s = e.session().clone(); s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master); @@ -180,6 +186,14 @@ pub fn specs() -> Vec { ] } +/// Writing a session over an existing file needs `replace: true` (see `io::replaces`). +fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> { + if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) { + return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display()))); + } + Ok(()) +} + fn new_session(e: &mut Engine, p: &Value) -> Result { let name = str_param(p, "name").unwrap_or("Untitled").to_string(); let sr = @@ -380,6 +394,7 @@ mod tests { fn session_open_reports_missing_media() { let mut e = crate::demo::demo_engine(); let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); let path = dir.join("Gone.scraft").to_string_lossy().into_owned(); e.execute("session.save_as", &json!({"path": path})).unwrap(); let mut ok = Engine::default(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index a563387..655955e 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -474,8 +474,7 @@ pub fn save_copy(e: &Engine, path: &str) -> Result { /// Write `session` to `path`; returns the session with updated media paths, the number of audio /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { - let p = PathBuf::from(path); - let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }; + let p = session_file(path); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); @@ -519,6 +518,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; // Atomic-ish write: temp file then rename. let tmp = p.with_extension("scraft.tmp"); std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?; @@ -526,6 +526,25 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess Ok((s, written, p)) } +/// The file a session saved to `path` goes to: `path`, with `.scraft` added when it has no +/// extension and isn't an existing file. +pub fn session_file(path: &str) -> PathBuf { + let p = PathBuf::from(path); + if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p } +} + +/// True when `command` would write a session over the existing `file`. Save, Save As and Save +/// Copy In may write the open session's own file; other commands never ask. +pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool { + match command { + "session.save" | "session.save_as" | "session.save_copy" => { + file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file)) + } + "session.save_template" | "file.export_selected_tracks_as_session" => file.exists(), + _ => false, + } +} + fn same_path(a: &Path, b: &Path) -> bool { let full = |p: &Path| if p.as_os_str().is_empty() { Path::new(".").canonicalize() } else { p.canonicalize() }; a == b || matches!((full(a), full(b)), (Ok(x), Ok(y)) if x == y) @@ -745,6 +764,34 @@ mod save_tests { assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn saving_over_another_session_needs_replace() { + let dir = std::env::temp_dir().join(format!("soundcraft-replace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let mut first = with_take(0.5); + first.execute("session.save_as", &json!({"path": path})).unwrap(); + first.execute("session.save", &json!({})).unwrap(); + first.execute("session.save_as", &json!({"path": dir.join("Song")})).unwrap(); + assert!(first.execute("session.save_template", &json!({"path": path})).is_err()); + let mut second = with_take(-0.25); + let track = second.session().tracks[0].id.0; + for id in ["session.save", "session.save_as", "session.save_copy", "session.save_template", "file.export_selected_tracks_as_session"] { + let err = second.execute(id, &json!({"path": path, "tracks": [track]})).unwrap_err(); + assert!(err.to_string().contains("`replace: true`"), "{id}: {err}"); + } + assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1); + Engine::default().execute("session.save_as", &json!({"path": dir.join("New/New.scraft")})).unwrap(); + std::fs::copy(&path, dir.join("Song")).unwrap(); + let mut bare = Engine::default(); + open_session(&mut bare, &dir.join("Song").to_string_lossy()).unwrap(); + bare.execute("session.save", &json!({})).unwrap(); + second.execute("session.save_as", &json!({"path": path, "replace": true})).unwrap(); + let r = reopen(&path); + assert_eq!(r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0], -0.25); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..d49b2af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -303,9 +303,17 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { { *path = p; } - if buttons(ui, "OK", enter) { + 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)); } } diff --git a/crates/ui-egui/tests/save_as_replace.rs b/crates/ui-egui/tests/save_as_replace.rs new file mode 100644 index 0000000..717e2a6 --- /dev/null +++ b/crates/ui-egui/tests/save_as_replace.rs @@ -0,0 +1,38 @@ +//! Save As over another session asks first, and Enter alone never replaces it. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog}; + +#[test] +fn saving_over_another_session_takes_the_replace_button() { + let dir = std::env::temp_dir().join(format!("soundcraft-ui-replace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Song.scraft"); + soundcraft_engine::Engine::default().execute("session.save_as", &json!({"path": path})).unwrap(); + let other = std::fs::read(&path).unwrap(); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.dialogs.open = Some(Dialog::PathPrompt { + cmd: "session.save_as".into(), + title: "Save Session As".into(), + path: path.to_string_lossy().into(), + key: "path".into(), + }); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(std::fs::read(&path).unwrap(), other); + h.get_by_label("Replace").click(); + h.run_steps(3); + assert_ne!(std::fs::read(&path).unwrap(), other); + assert_eq!(h.state().engine.path, Some(path.to_string_lossy().into_owned())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/mcp.md b/docs/mcp.md index 2949871..deb026d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,7 +24,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo | `execute` | Run one command with JSON params. | | `batch` | Run several commands in order (`keep_going` optional). | | `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). | -| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). | +| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). `save_session` won't save over another existing file unless `replace` is true. | | `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. | | `bounce_mix` | Render to WAV/AIFF/FLAC; returns peak dBFS and integrated LUFS. | | `parity` | Feature-parity report. | From 153381e03b31bd577342be33e380f4d12357b4b2 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:57:17 +0100 Subject: [PATCH 017/137] Flush saves to disk and keep backups of the session file Nothing a save wrote was synced to disk, so a power cut or crash of the system soon after Cmd+S could leave an empty or truncated session file (the rename can reach the disk before the data), or audio files that the session points at but that never reached the disk. No earlier version of the session file was kept either. New audio files, the Audio Files folder and the session's temporary file are now synced before the session file is renamed into place, and the session folder after the rename; templates are written the same way. A failed write or rename removes its temporary file instead of leaving it on a full disk. Before a save replaces the session file with different contents, the previous version is copied to Backups/ backup .scraft beside it, and the newest ten are kept. Autosaves make no backups. --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/io.rs | 81 +++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8c91d0a..bc93d11 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { s.sources.clear(); s.videos.clear(); let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&file, text).map_err(|err| EngineError::Io(format!("{}: {err}", file.display())))?; + crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?; Ok(json!({"path": file})) }), cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 655955e..bfd8c26 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -513,6 +513,9 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess src.format = FileFormat::Wav; written += usize::from(new); } + if written > 0 { + sync_dir(&audio_dir); + } s.folder = Some(dir.clone()); if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { s.name = stem.to_string(); @@ -521,11 +524,63 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; // Atomic-ish write: temp file then rename. let tmp = p.with_extension("scraft.tmp"); - std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?; - std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?; + if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) { + back_up(&p); + } + replace_file(&tmp, &p, text.as_bytes())?; Ok((s, written, p)) } +/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the +/// old or the new contents, never a mix. +pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> { + if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) { + let _ = std::fs::remove_file(tmp); + return Err(EngineError::Io(format!("{}: {err}", file.display()))); + } + sync_dir(file.parent().unwrap_or(Path::new(""))); + Ok(()) +} + +fn sync_dir(dir: &Path) { + let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; + let _ = std::fs::File::open(dir).and_then(|d| d.sync_all()); +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut f = std::fs::File::create(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +/// Copy the session file about to be replaced to `Backups/ backup .`, keeping the +/// newest ten. +fn back_up(file: &Path) { + let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str())) + else { + return; + }; + let backups = dir.join("Backups"); + let prefix = format!("{stem} backup "); + let suffix = format!(".{ext}"); + let numbers: Vec = std::fs::read_dir(&backups) + .into_iter() + .flatten() + .flatten() + .filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok()) + .collect(); + let next = numbers.iter().max().map_or(1, |n| n.saturating_add(1)); + let copied = std::fs::create_dir_all(&backups).and_then(|()| std::fs::copy(file, backups.join(format!("{prefix}{next}{suffix}")))); + if let Err(err) = copied { + log::warn!("no backup of {}: {err}", file.display()); + return; + } + for n in numbers.into_iter().filter(|n| n.saturating_add(10) <= next) { + let _ = std::fs::remove_file(backups.join(format!("{prefix}{n}{suffix}"))); + } +} + /// The file a session saved to `path` goes to: `path`, with `.scraft` added when it has no /// extension and isn't an existing file. pub fn session_file(path: &str) -> PathBuf { @@ -560,7 +615,7 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> { let path = dir.join(&name); match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { Ok(mut f) => { - if let Err(err) = f.write_all(bytes) { + if let Err(err) = f.write_all(bytes).and_then(|()| f.sync_all()) { let _ = std::fs::remove_file(&path); return Err(EngineError::Io(format!("{}: {err}", path.display()))); } @@ -792,6 +847,26 @@ mod save_tests { assert_eq!(r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0], -0.25); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn saving_keeps_the_last_ten_versions_as_backups() { + let dir = std::env::temp_dir().join(format!("soundcraft-backups-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Song.scraft"); + let mut e = Engine::default(); + let mut versions = Vec::new(); + for i in 0..12 { + e.execute("track.new", &json!({"name": format!("T{i}")})).unwrap(); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + versions.push(std::fs::read(&path).unwrap()); + } + let backup = |n: usize| std::fs::read(dir.join(format!("Backups/Song backup {n}.scraft"))).ok(); + assert_eq!(std::fs::read_dir(dir.join("Backups")).unwrap().count(), 10); + assert_eq!((backup(1), backup(2), backup(11)), (None, Some(versions[1].clone()), Some(versions[10].clone()))); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] From 54e3761d620cbdc810e2bb2dddc5692829993df3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:57:18 +0100 Subject: [PATCH 018/137] Open sessions whose plugin parameters were saved as null JSON has no NaN or infinity, so serde writes them as null, and loading null into a plugin parameter failed the whole session with "not a SoundCraft session: invalid type: null, expected f32". One plugin reporting a non-finite value, or a value like 1e39 that overflows f32 in an edited file (it loads as infinity), made the session and every later save and autosave of it unopenable. Insert and instrument parameters that are null or overflow f32 are now dropped on load, so they keep the plugin's default and the rest of the session opens. --- crates/model/src/mixer.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs index e9a7867..001f2a1 100644 --- a/crates/model/src/mixer.rs +++ b/crates/model/src/mixer.rs @@ -425,7 +425,7 @@ pub enum Route { pub struct Insert { /// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`). pub plugin: String, - #[serde(default)] + #[serde(default, deserialize_with = "finite_params")] pub params: BTreeMap, #[serde(default)] pub bypass: bool, @@ -577,6 +577,13 @@ pub(crate) fn finite_or(v: f32, d: f32) -> f32 { if v.is_finite() { v } else { d } } +/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so +/// they keep their defaults instead of failing the whole session. +fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let params: BTreeMap> = serde::Deserialize::deserialize(d)?; + Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect()) +} + /// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12). pub fn fader_pos_to_db(pos: f32) -> f32 { let p = finite_or(pos, 0.0).clamp(0.0, 1.0); @@ -624,6 +631,16 @@ mod tests { assert_eq!(bad.state_bytes(), None); } + #[test] + fn params_saved_as_null_or_out_of_range_are_dropped() { + let mut i = Insert::new("eq_7band"); + i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]); + let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap(); + assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)])); + let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap(); + assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)])); + } + #[test] fn fader_taper_round_trips() { for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] { From 18a25581048e4b721e467b3152cbcdbdd9a29cff Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:04 +0100 Subject: [PATCH 019/137] Ask to save changes before closing a modified session Close Session, New, Open, Revert, Quit, the window's close button, dropping a session file on the window and opening one from the Workspace window all replaced or closed the session without asking, so unsaved edits were lost (#136). They now ask first: Save, Don't Save or Cancel. Save on a session that has never been saved goes through Save As, then carries on. Revert asks for confirmation instead, and Revert to Saved is unavailable until the session has a file. A take being recorded is stopped first, so it counts as a change. The web build has nowhere to save, so it offers only Don't Save and Cancel. Calls from the control channel, MCP and the CLI never ask. Selecting, locating, zooming and scrolling no longer mark the session modified, so the question only comes after real edits. Undoing back to the saved state makes the session clean again. The engine compares the document with the one last opened or saved, ignoring the selection, the playhead and the zoom, once per revision. Fixes #136 --- apps/soundcraft/src/main.rs | 6 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/lib.rs | 62 +++++++++++++++++- crates/ui-egui/src/dialogs.rs | 84 ++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 9 +-- crates/ui-egui/src/windows.rs | 2 +- crates/ui-egui/tests/save_changes.rs | 97 ++++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/save_changes.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 87da4f0..5d9385c 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -19,7 +19,7 @@ mod logging; mod native_menu; use soundcraft_engine::Engine; -use soundcraft_ui_egui::{Services, SoundApp, UiState}; +use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs}; use std::sync::Arc; struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option); @@ -47,6 +47,8 @@ impl eframe::App for App { } if self.0.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } // With the system menu bar there is no egui bar showing the window and dirty state, so the // title carries them; elsewhere the egui menu bar already does. @@ -83,7 +85,7 @@ impl eframe::App for App { fn open_path(app: &mut SoundApp, p: &str) { let lower = p.to_ascii_lowercase(); let r = if lower.ends_with(".scraft") { - app.run("session.open", serde_json::json!({"path": p})) + dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p})) } else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") { app.run("file.import_midi", serde_json::json!({"path": p})) } else { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ca7393d..104580b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; Ok(json!({"path": path})) }), - cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { + cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", |e| if e.path.is_some() { Ok(()) } else { Err("the session has not been saved".into()) }, |e, _| { let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?; crate::io::open_session(e, &path)?; Ok(json!({})) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f12901d..d186c20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod score; use serde_json::{Value, json}; use soundcraft_model::{Clip, Session, TrackId}; use soundcraft_time::Samples; +use std::cell::Cell; use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; @@ -111,6 +112,10 @@ pub struct Engine { /// Revision counter; bumps on every document change (UI caches key on it). pub revision: u64, dirty: bool, + /// The document as last opened or saved, for `is_dirty`. + saved: Arc, + /// `is_dirty` as of a revision. + dirty_at: Cell<(u64, bool)>, /// Gesture key for undo coalescing (see `execute_merged`). merge_key: Option, /// Messages for the UI status line / agents. @@ -126,8 +131,10 @@ impl Default for Engine { impl Engine { pub fn new(session: Session) -> Self { install_panic_hook(); + let doc = Arc::new(session); Engine { - doc: Arc::new(session), + saved: Arc::clone(&doc), + doc, undo: Vec::new(), redo: Vec::new(), history_limit: 200, @@ -138,6 +145,7 @@ impl Engine { path: None, revision: 1, dirty: false, + dirty_at: Cell::new((0, false)), merge_key: None, messages: Vec::new(), } @@ -164,17 +172,30 @@ impl Engine { self.transport_requests.clear(); self.transport_requests.push(TransportRequest::ResetSession); self.doc = Arc::new(s); + self.saved = Arc::clone(&self.doc); self.undo.clear(); self.redo.clear(); self.revision = self.revision.wrapping_add(1); self.dirty = false; } + /// Whether the document changed since it was opened or saved. Selecting, locating and zooming + /// don't count, and undoing back to the saved state makes it clean again. pub fn is_dirty(&self) -> bool { - self.dirty + if !self.dirty { + return false; + } + let (rev, dirty) = self.dirty_at.get(); + if rev == self.revision { + return dirty; + } + let dirty = !same_document(&self.saved, &self.doc); + self.dirty_at.set((self.revision, dirty)); + dirty } pub fn mark_clean(&mut self) { self.dirty = false; + self.saved = Arc::clone(&self.doc); } pub fn can_undo(&self) -> bool { @@ -318,6 +339,19 @@ 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(|| { @@ -346,3 +380,27 @@ mod merge_tests { assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0); } } + +#[cfg(test)] +mod dirty_tests { + use super::*; + + #[test] + fn selecting_locating_and_zooming_leave_the_session_clean() { + let mut e = crate::demo::demo_engine(); + for (id, p) in [ + ("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})), + ("edit.select_none", json!({})), + ("transport.locate", json!({"at": 96_000})), + ("view.zoom_in", json!({})), + ("view.scroll", json!({"to": 48_000})), + ] { + e.execute(id, &p).unwrap(); + assert!(!e.is_dirty(), "{id}"); + } + e.execute("mix.mute", &json!({"track": "Kick"})).unwrap(); + assert!(e.is_dirty()); + e.undo(); + assert!(!e.is_dirty()); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e2faaee..e15407c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,11 +23,14 @@ pub enum Dialog { Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, Session { frame_rate: String, bit_depth: String }, ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool }, + SaveChanges { then: (String, Value) }, } #[derive(Default)] pub struct Dialogs { pub open: Option, + /// Runs once the session has been saved from `SaveChanges`. + after_save: Option<(String, Value)>, } impl Dialogs { @@ -46,6 +49,7 @@ impl Dialogs { Dialog::Group { .. } => "group", Dialog::Session { .. } => "session", Dialog::ScoreSetup { .. } => "score_setup", + Dialog::SaveChanges { .. } => "save_changes", }) } @@ -59,6 +63,7 @@ impl Dialogs { /// Open the dialog for a command; false when the command has no dialog. pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool { + self.after_save = None; let home = default_dir(); let name = e.session().name.clone(); self.open = Some(match id { @@ -110,6 +115,32 @@ impl Dialogs { } } +/// 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. +pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool { + if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit") + || soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err()) + { + return false; + } + if app.engine.transport.recording { + app.stop_play(); + } + if !app.engine.is_dirty() { + return false; + } + app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) }); + true +} + +/// Runs `id`, unless it asks to save first (`ask_to_save`). +pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result { + if ask_to_save(app, id, ¶ms) { + return Ok(Value::Null); + } + app.run(id, params) +} + /// A path prompt for any command whose parameters start with a required `path` or `dir` (plus /// Print Score, whose path is optional), with a default file name from the command. pub fn takes_path(id: &str) -> bool { @@ -164,6 +195,8 @@ 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 save_first: Option<(String, Value)> = None; + let confirmed = matches!(d, Dialog::SaveChanges { .. }); let title = match &d { Dialog::NewTracks { .. } => "New Tracks", Dialog::NewSession { .. } => "New Session", @@ -177,6 +210,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Group { .. } => "Create Group", Dialog::Session { .. } => "Session Setup", Dialog::ScoreSetup { .. } => "Score Setup", + Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved", + Dialog::SaveChanges { .. } => "Unsaved Changes", } .to_string(); let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)); @@ -405,18 +440,67 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { )); } } + Dialog::SaveChanges { then } => { + let name = &app.engine.session().name; + let revert = then.0 == "session.revert"; + if revert || cfg!(target_arch = "wasm32") { + let (question, ok) = if revert { + (format!("Revert “{name}” to its last saved version?"), "Revert") + } else { + (format!("Close “{name}” without saving?"), "Don't Save") + }; + ui.label(format!("{question} Your changes will be lost.")); + if buttons(ui, ok, false) { + action = Some(then.clone()); + } + } else { + ui.label(format!("Save changes to “{name}” before closing?")); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Don't Save").clicked() { + action = Some(then.clone()); + } + if buttons(ui, "Save", enter) { + save_first = Some(then.clone()); + } + }); + } + } } }); if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::new("dlg_cancel")).unwrap_or(false)) { keep = false; + app.dialogs.after_save = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::new("dlg_cancel"))); } + if let Some(then) = save_first { + keep = false; + if !app.dialogs.open_for_command(&app.engine, "session.save") { + action = Some(("session.save".into(), json!({}))); + } + app.dialogs.after_save = Some(then); + } + if let Some((cmd, params)) = &action + && !confirmed + && ask_to_save(app, cmd, params) + { + keep = false; + action = None; + } + let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save")); if let Some((cmd, params)) = action { keep = false; if let Err(e) = app.run(&cmd, params) { app.ui.status = e; } } + if saving + && let Some((then, params)) = app.dialogs.after_save.take() + && !app.engine.is_dirty() + && let Err(e) = app.run(&then, params) + { + app.ui.status = e; + } if keep { app.dialogs.open = Some(d); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 21d4c6e..23a3e69 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -219,7 +219,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { } ui.separator(); if ui.button("Quit").clicked() { - let _ = app.run("app.quit", json!({})); + let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({})); } }); let extra = ui_aliases(); @@ -413,12 +413,13 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { return; } let params = params_for(path, id); - let _ = app.run(id, params); + let _ = crate::dialogs::run_or_ask(app, id, params); } -/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled. +/// Keyboard shortcut for a dialog command, or one that would close a modified session: open its +/// dialog. Returns true when handled. pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool { - wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) + (wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({})) } /// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b7b34e5..007809a 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -350,7 +350,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) { if is_dir { app.ui.workspace_dir = path; } else if name.ends_with(".scraft") { - let _ = app.run("session.open", json!({"path": path})); + let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path})); } else if name.ends_with(".mid") || name.ends_with(".midi") { let _ = app.run("file.import_midi", json!({"path": path})); } else { diff --git a/crates/ui-egui/tests/save_changes.rs b/crates/ui-egui/tests/save_changes.rs new file mode 100644 index 0000000..e500a8d --- /dev/null +++ b/crates/ui-egui/tests/save_changes.rs @@ -0,0 +1,97 @@ +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +fn harness() -> Harness<'static, SoundApp> { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) +} + +fn steps(h: &mut Harness<'_, SoundApp>) { + for _ in 0..4 { + h.step(); + } +} + +fn click(h: &mut Harness<'_, SoundApp>, label: &str) { + h.get_by_label(label).click(); + steps(h); +} + +fn close_session(h: &mut Harness<'_, SoundApp>) { + menus::invoke_menu(h.state_mut(), "session.close", "File > Close Session"); + steps(h); +} + +fn mute_kick(h: &mut Harness<'_, SoundApp>) { + h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap(); +} + +#[test] +fn closing_a_modified_session_asks_first() { + let mut h = harness(); + let tracks = h.state().engine.session().tracks.len(); + h.state_mut().run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})).unwrap(); + close_session(&mut h); + assert!(h.state().dialogs.open.is_none(), "a selection alone is not a change"); + assert!(h.state().engine.session().tracks.is_empty()); + + let mut h = harness(); + mute_kick(&mut h); + close_session(&mut h); + assert_eq!(h.state().dialogs.open_name(), Some("save_changes")); + click(&mut h, "Cancel"); + assert!(h.state().dialogs.open.is_none()); + assert_eq!(h.state().engine.session().tracks.len(), tracks); + + close_session(&mut h); + click(&mut h, "Don't Save"); + assert!(h.state().engine.session().tracks.is_empty()); + + let mut h = harness(); + mute_kick(&mut h); + menus::invoke_menu(h.state_mut(), "app.quit", ""); + steps(&mut h); + assert!(!h.state().quit_requested); + click(&mut h, "Don't Save"); + assert!(h.state().quit_requested); +} + +#[test] +fn save_in_the_question_saves_before_closing() { + let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + + let mut h = harness(); + mute_kick(&mut h); + close_session(&mut h); + click(&mut h, "Save"); + match &mut h.state_mut().dialogs.open { + Some(Dialog::PathPrompt { cmd, path: p, .. }) if cmd == "session.save_as" => p.clone_from(&path), + other => panic!("expected Save As, got {other:?}"), + } + steps(&mut h); + click(&mut h, "OK"); + assert!(h.state().engine.session().tracks.is_empty()); + + h.state_mut().run("session.open", json!({"path": path})).unwrap(); + assert!(h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute); + h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap(); + close_session(&mut h); + click(&mut h, "Save"); + assert!(h.state().dialogs.open.is_none()); + assert!(h.state().engine.session().tracks.is_empty()); + h.state_mut().run("session.open", json!({"path": path})).unwrap(); + assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute); + let _ = std::fs::remove_dir_all(&dir); +} From b9345a35767eb9499647e5a2e47406a74898bbc4 Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 17:58:18 -0300 Subject: [PATCH 020/137] feat: improve mixer routing and numeric controls --- crates/ui-egui/src/mix_window.rs | 334 +++++++++++++++++++++++++++---- 1 file changed, 300 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 0a6b84e..0f8cf32 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -225,12 +225,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)); let input = if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }; - let mut c = ui.new_child(egui::UiBuilder::new().max_rect(in_r)); + let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r)); let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text); - egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, true)); - let mut c = ui.new_child(egui::UiBuilder::new().max_rect(out_r)); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true)); + let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r)); let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text); - egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, false)); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, false)); // Automation mode. let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0)); ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim); @@ -307,7 +307,10 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let _ = resp; let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0)); ui.painter().rect_filled(pr, 1.0, t.counter_bg); - ui.painter().text(pr.center(), Align2::CENTER_CENTER, pan_text(v), regular(10.0), t.counter_text); + let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan); + if let Some(v) = entered { + let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); + } } y += ks + 20.0; } @@ -372,7 +375,17 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh // 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)); ui.painter().rect_filled(vr, 1.0, t.counter_bg); - ui.painter().text(vr.center(), Align2::CENTER_CENTER, db_text(track.mixer.volume_db), regular(11.0), t.counter_text); + let (_, entered) = editable_readout( + ui, + vr, + ui.id().with(("volume-value", id.0)), + &db_text(track.mixer.volume_db), + &format!("{:.1}", track.mixer.volume_db), + parse_db, + ); + if let Some(db) = entered { + let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); + } let kind = match track.kind { TrackKind::Audio => "audio", TrackKind::Aux => "aux", @@ -611,9 +624,11 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec let t = Tokens::current(); let id = track.id; let snd = track.mixer.sends.get(slot).cloned().flatten(); - let resp = ui.interact(r, ui.id().with(("snd", id.0, slot)), Sense::click_and_drag()); let fill = if snd.is_some() { t.send_on } else { t.slot_bg }; ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside); + let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max); + let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); + let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); match &snd { Some(s) => { let name = route_name(app, &s.target); @@ -624,44 +639,257 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec 0.0, t.counter_text, ); - ui.painter().with_clip_rect(r).text( - r.center() - vec2(0.0, 1.0), - Align2::CENTER_CENTER, + ui.painter().with_clip_rect(route_r).text( + pos2(r.min.x + 4.0, r.center().y - 1.0), + Align2::LEFT_CENTER, name, - regular(10.0), + regular(9.0), if s.mute { t.mute } else { t.text }, ); - if resp.dragged() { - let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0); + let (level_resp, entered) = editable_readout( + ui, + level_r, + ui.id().with(("send-value", id.0, slot)), + &format!("{} dB", db_text(s.level_db)), + &format!("{:.1}", s.level_db), + parse_db, + ); + if let Some(db) = entered { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + } + if level_resp.as_ref().is_some_and(egui::Response::dragged) { + // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so + // 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_per_point = if fine { 0.02 } else { 0.15 }; + let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } - if resp.double_clicked() { + if level_resp.as_ref().is_some_and(egui::Response::double_clicked) { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); } - let _ = resp.clone().on_hover_text(format!("Send {}: {} dB (drag to change)", (b'A' + slot as u8) as char, db_text(s.level_db))); + if let Some(resp) = level_resp { + let _ = resp.clone().on_hover_text(format!( + "Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options", + (b'A' + slot as u8) as char, + db_text(s.level_db) + )); + resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); + } } None => { ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); } } - let popup = if snd.is_none() { egui::Popup::menu(&resp) } else { egui::Popup::context_menu(&resp) }; - popup.show(|ui| { - if snd.is_some() && ui.button("no send").clicked() { - let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot})); - } - let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); - for b in busses { - if ui.button(&b).clicked() { - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": b})); + if let Some(send) = &snd { + egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(250.0); + ui.horizontal(|ui| { + ui.strong(format!("Send {}", (b'A' + slot as u8) as char)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(route_name(app, &send.target)).color(Tokens::current().counter_text)); + }); + }); + ui.label(egui::RichText::new("DESTINATION").size(8.0).color(Tokens::current().text_dim)); + send_route_menu(app, ui, id, slot, Some(&send.target)); + ui.separator(); + if ui.button("Remove send").clicked() { + let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot})); + ui.close(); + } + }); + } else { + egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(250.0); + ui.strong(format!("Send {} · Choose destination", (b'A' + slot as u8) as char)); + send_route_menu(app, ui, id, slot, None); + }); + } +} + +/// Secondary controls live behind the amount readout, separate from the bus selector like Logic. +fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) { + ui.set_min_width(230.0); + ui.strong(format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target))); + let mut db = send.level_db.max(-60.0); + if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB")).changed() { + let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); + } + send_level_scale(ui, send.level_db); + ui.horizontal(|ui| { + for (label, delta) in [("−3", -3.0), ("−1", -1.0), ("+1", 1.0), ("+3", 3.0)] { + if ui.small_button(label).clicked() { + let db = (send.level_db.max(-60.0) + delta).clamp(-60.0, 12.0); + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); } } - ui.separator(); - if ui.button("new bus...").clicked() { - let n = app.engine.session().busses.len() + 1; - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + if ui.small_button("0 dB").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); + } + if ui.small_button("−∞").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": -144.0})); } }); + let mut pan = send.pan; + if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text("Pan")).changed() { + let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "pan": pan}), &format!("send-pan:{}:{slot}", id.0)); + } + ui.horizontal(|ui| { + let mut mute = send.mute; + if ui.checkbox(&mut mute, "Mute").changed() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": mute})); + } + let mut pre = send.pre_fader; + if ui.checkbox(&mut pre, "Pre-fader").changed() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": pre})); + } + }); + if let Route::Bus(bus) = send.target + && let Some(aux) = + app.engine.session().tracks.iter().find(|t| t.kind == TrackKind::Aux && t.mixer.input == Route::Bus(bus)).map(|t| (t.id, t.name.clone())) + && ui.button("Rename bus…").clicked() + { + app.dialogs.open_rename_track(aux.0, &aux.1); + ui.close(); + } +} + +/// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away +/// commits a valid value; Escape cancels. The interaction response remains available for dragging. +fn editable_readout( + ui: &mut Ui, + r: Rect, + id: egui::Id, + display: &str, + edit_initial: &str, + parse: fn(&str) -> Option, +) -> (Option, Option) { + let t = Tokens::current(); + let active_id = id.with("active"); + let text_id = id.with("text"); + let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false); + let mut started = false; + let mut response = None; + if !active { + let resp = ui.interact(r, id, Sense::click_and_drag()); + if resp.clicked() && !resp.double_clicked() { + active = true; + started = true; + ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string())); + } else { + ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, display, regular(9.0), t.counter_text); + } + response = Some(resp); + } + let mut value = None; + if active { + let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string()); + let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r)); + child.visuals_mut().override_text_color = Some(t.counter_text); + let edit = egui::TextEdit::singleline(&mut text) + .id(id.with("editor")) + .frame(egui::Frame::NONE) + .horizontal_align(egui::Align::Center) + .font(regular(9.0)) + .desired_width(r.width()); + let edit_resp = child.add_sized(r.size(), edit); + if started { + edit_resp.request_focus(); + } + let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if escape { + active = false; + } else if enter || edit_resp.lost_focus() { + if let Some(parsed) = parse(&text) { + value = Some(parsed); + active = false; + } else if edit_resp.lost_focus() { + active = false; + } + } + ui.data_mut(|d| d.insert_temp(text_id, text)); + } + ui.data_mut(|d| d.insert_temp(active_id, active)); + (response, value) +} + +fn parse_db(text: &str) -> Option { + let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', "."); + if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") { + return Some(-144.0); + } + cleaned.trim().parse::().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0)) +} + +fn parse_pan(text: &str) -> Option { + let cleaned = text.trim().to_ascii_lowercase().replace(',', "."); + let (sign, number) = if let Some(v) = cleaned.strip_prefix('<').or_else(|| cleaned.strip_prefix('l')) { + (-1.0, v) + } else if let Some(v) = cleaned.strip_suffix('>').or_else(|| cleaned.strip_prefix('r')) { + (1.0, v) + } else { + (1.0, cleaned.as_str()) + }; + let raw = number.trim().parse::().ok()? * sign; + let value = if number.contains('.') && raw.abs() <= 1.0 { raw } else { raw / 100.0 }; + value.is_finite().then_some(value.clamp(-1.0, 1.0)) +} + +/// A compact calibrated scale for a send. The marks use the same taper as the strip's level bar. +fn send_level_scale(ui: &mut Ui, db: f32) { + let t = Tokens::current(); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover()); + let line_y = r.min.y + 4.0; + ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border)); + for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() { + let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark); + ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim)); + let align = if i == 0 { + Align2::LEFT_TOP + } else if i == 5 { + Align2::RIGHT_TOP + } else { + Align2::CENTER_TOP + }; + let label = if mark > 0.0 { format!("+{mark:.0}") } else { format!("{mark:.0}") }; + ui.painter().text(pos2(x, line_y + 5.0), align, label, regular(8.0), t.text_dim); + } + let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(db); + ui.painter().line_segment([pos2(x, r.min.y), pos2(x, line_y + 3.0)], Stroke::new(2.0, t.counter_text)); +} + +fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) { + let busses = app.engine.session().busses.clone(); + let search_id = egui::Id::new(("send-bus-search", id.0, slot)); + let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); + ui.data_mut(|d| d.insert_temp(search_id, search.clone())); + let needle = search.trim().to_ascii_lowercase(); + let visible: Vec<_> = busses.iter().filter(|bus| needle.is_empty() || bus.name.to_ascii_lowercase().contains(&needle)).collect(); + egui::ScrollArea::vertical().id_salt(("send-route-busses", id.0, slot)).max_height(260.0).auto_shrink([false, true]).show_rows( + ui, + 22.0, + visible.len(), + |ui, rows| { + for bus in &visible[rows] { + let selected = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == bus.id); + if ui.selectable_label(selected, &bus.name).clicked() { + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus.name})); + ui.close(); + } + } + }, + ); + ui.separator(); + if ui.button("new bus...").clicked() { + let mut n = busses.len() + 1; + while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() { + n += 1; + } + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + ui.close(); + } } fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { @@ -669,29 +897,67 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { let cmd = if input { "track.input" } else { "track.output" }; if ui.button(if input { "no input" } else { "no output" }).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: "none"})); + ui.close(); } let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone()); if !input && ui.button(main_name).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: "main"})); + ui.close(); } if input { for i in 1..=8 { if ui.button(format!("In {i}")).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")})); + ui.close(); } } } ui.separator(); let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); - ui.menu_button("bus", |ui| { - for b in &busses { + ui.strong("Bus"); + let search_id = egui::Id::new(("route-bus-search", id.0, input)); + let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); + ui.data_mut(|d| d.insert_temp(search_id, search.clone())); + let needle = search.trim().to_ascii_lowercase(); + egui::ScrollArea::vertical().id_salt(("track-route-busses", id.0, input)).max_height(420.0).show(ui, |ui| { + for b in busses.iter().filter(|b| needle.is_empty() || b.to_ascii_lowercase().contains(&needle)) { if ui.button(b).clicked() { let _ = app.run(cmd, json!({"track": id.0, key: b})); + ui.close(); } } - if ui.button("new bus...").clicked() { - let n = busses.len() + 1; - let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")})); - } }); + if ui.button("new bus...").clicked() { + let mut n = busses.len() + 1; + while busses.contains(&format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() { + n += 1; + } + let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")})); + ui.close(); + } +} + +#[cfg(test)] +mod tests { + use super::{parse_db, parse_pan}; + + #[test] + fn typed_db_accepts_console_notation_and_clamps() { + assert_eq!(parse_db("-6 dB"), Some(-6.0)); + assert_eq!(parse_db("+3,5"), Some(3.5)); + assert_eq!(parse_db("off"), Some(-144.0)); + assert_eq!(parse_db("99"), Some(12.0)); + assert_eq!(parse_db("loud"), None); + } + + #[test] + fn typed_pan_accepts_percent_console_and_fractional_values() { + assert_eq!(parse_pan("-50"), Some(-0.5)); + assert_eq!(parse_pan("<35"), Some(-0.35)); + assert_eq!(parse_pan("35>"), Some(0.35)); + assert_eq!(parse_pan("R25"), Some(0.25)); + assert_eq!(parse_pan("0.5"), Some(0.5)); + assert_eq!(parse_pan("center"), None); + } } From d552da531fb55cae5c52e1774d5d6eb4e2a5c05d Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 19:03:38 -0300 Subject: [PATCH 021/137] fix: keep macOS plugin editors out of the Dock --- crates/vst3-host/src/ffi.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index f27ea39..38fa41d 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1749,7 +1749,7 @@ impl EditorLink { mod window { use objc2::rc::Retained; use objc2::{MainThreadMarker, MainThreadOnly}; - use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowStyleMask}; + use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask}; use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; use std::ffi::{c_char, c_void}; @@ -1780,7 +1780,10 @@ mod window { let mtm = MainThreadMarker::new().ok_or_else(|| "not on the main thread".to_string())?; let _app = NSApplication::sharedApplication(mtm); let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h)); - let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Miniaturizable; + // Plugin editors are transient utility windows. In particular they must not be + // minimizable: minimized NSWindows become persistent thumbnails in the macOS Dock, + // which made every editor visited in a session look like another running app. + let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::UtilityWindow; // SAFETY: a freshly allocated window on the main thread; valid rect and style. let window = unsafe { NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false) @@ -1791,6 +1794,8 @@ mod window { window.setTitle(&NSString::from_str(title)); window.setLevel(NSFloatingWindowLevel); window.setHidesOnDeactivate(true); + window.setExcludedFromWindowsMenu(true); + window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle); window.center(); Ok(HostWindow { window: Retained::into_raw(window) }) } From 5f122fb30dc87515150e7e5cf4d06414ed64743e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:21:04 +0100 Subject: [PATCH 022/137] Fix drag selection and the insertion point in the Edit window Fixes #166: dragging a selection up from the last track deselected it and selected every track above instead. Fixed, along with related Edit-window drag problems: - Drag selection spans exactly the tracks between the press and the pointer, in either direction, and the whole window updates in the same frame. - Shift+click in another track adds it to the selection with the same range, as in Pro Tools, so the selected tracks no longer have to be adjacent. A click outside the range extends it. - Dropping a track's name plate lower down no longer sends the track to the end. - Drags act where you pressed: a quick drag from a clip edge now trims instead of moving the clip. - Dragging past the edge of the track area scrolls, and a flick past the last track selects down to it. - The insertion point moves on press, shows immediately, and only on the selected tracks. - edit.select no longer makes a new document revision when nothing changed (each one is resent to the audio engine). - Fixes a panic when a fade drag's clip shrinks to zero length. --- crates/engine/src/cmd/edit.rs | 45 +- crates/ui-egui/src/edit_window.rs | 788 ++++++++++++++++++++++++------ crates/ui-egui/src/lib.rs | 6 +- 3 files changed, 665 insertions(+), 174 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e4f2d5..02c8597 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -350,7 +350,8 @@ fn select(e: &mut Engine, p: &Value) -> Result { let clips = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); let exact = bool_or(p, "exact", false); - let s = e.session_mut(); + let s = e.session(); + let mut ed = s.edit.clone(); if let Some(mut t) = tracks { // Active edit groups select all their members together. if !exact { @@ -364,11 +365,11 @@ fn select(e: &mut Engine, p: &Value) -> Result { let order: Vec = s.tracks.iter().map(|x| x.id).collect(); t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); } - s.edit.selected_tracks = t; + ed.selected_tracks = t; } match (st, en) { - (Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)), - (Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)), + (Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)), + (Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)), _ => {} } if let Some(c) = clips { @@ -384,17 +385,21 @@ fn select(e: &mut Engine, p: &Value) -> Result { } } if let Some(r) = range { - s.edit.selection = r; - s.edit.selected_tracks = trs; + ed.selection = r; + ed.selected_tracks = trs; } - s.edit.selected_clips = c; + ed.selected_clips = c; } else if st.is_some() { - s.edit.selected_clips.clear(); + ed.selected_clips.clear(); } - if s.edit.link_timeline_edit { - s.edit.timeline_selection = s.edit.selection; + if ed.link_timeline_edit { + ed.timeline_selection = ed.selection; } - Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips})) + let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips}); + if ed != s.edit { + e.session_mut().edit = ed; + } + Ok(out) } fn shift_selection(e: &mut Engine, dir: i64) -> Result { @@ -750,3 +755,21 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { } Ok(json!({"consolidated": n})) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selecting_what_is_already_selected_leaves_the_document_alone() { + let mut e = Engine::default(); + let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200}); + e.execute("edit.select", &sel).unwrap(); + let rev = e.revision; + assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]])); + assert_eq!(e.revision, rev); + e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap(); + assert_ne!(e.revision, rev); + } +} diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..e95cc99 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -46,6 +46,20 @@ pub struct EditLayout { pub last_follow_to: Option, } +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)); + } +} + +pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect { + Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)) +} + +fn lane_rect(track: &Track, row: Rect, tl: Rect) -> Rect { + Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + track.height.points())) +} + pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { tl.min.x + ((at - s.edit.zoom.scroll) as f64 / s.edit.zoom.samples_per_px.max(0.01)) as f32 } @@ -63,6 +77,7 @@ pub fn track_height(t: &Track) -> f32 { pub fn show(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); + drive_drag(app, ui.ctx()); egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui)); @@ -208,31 +223,31 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { let _ = app.engine.execute("view.scroll", &json!({"by_px": -dx})); } if !mods.shift && delta.y.abs() > 0.0 { - app.edit_layout.scroll_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0)); + app.edit_layout.scroll_by(-delta.y, tl.height()); } } } draw_rulers(app, ui, rulers_rect, tl, &rulers); // Tracks. - let ids: Vec = - app.engine.session().tracks.iter().filter(|x| !x.hidden && !folder_collapsed(app.engine.session(), x)).map(|x| x.id).collect(); let mut y = tl.min.y - app.edit_layout.scroll_y; app.edit_layout.rows.clear(); - let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y)); let mut content_h = 0.0; - for id in ids { - let Some(h) = app.engine.session().track(id).map(track_height) else { continue }; - let row = Rect::from_min_max(pos2(full.min.x, y), pos2(tl.max.x, y + h)); - if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); - child.set_clip_rect(clip_rect); - track_row(app, &mut child, id, row, tl); - } - app.edit_layout.rows.push((id.0, [row.min.x, row.min.y, row.max.x, row.max.y])); + for t in app.engine.session().tracks.iter().filter(|x| !x.hidden && !folder_collapsed(app.engine.session(), x)) { + let h = track_height(t); + app.edit_layout.rows.push((t.id.0, [full.min.x, y, tl.max.x, y + h])); y += h + 1.0; content_h += h + 1.0; } app.edit_layout.content_h = content_h; + let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y)); + for (id, r) in app.edit_layout.rows.clone() { + let row = rect(r); + if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); + child.set_clip_rect(clip_rect); + track_row(app, &mut child, TrackId(id), row, tl); + } + } // Empty area click clears track selection. let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max); if empty.height() > 4.0 && ui.interact(empty, ui.id().with("empty"), Sense::click()).clicked() { @@ -253,7 +268,7 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(thumb, 3.0, t.button_hi); let resp = ui.interact(vs, ui.id().with("vscroll"), Sense::drag()); if resp.dragged() { - app.edit_layout.scroll_y = (app.edit_layout.scroll_y + resp.drag_delta().y / frac).clamp(0.0, (content_h - tl.height() + 40.0).max(0.0)); + app.edit_layout.scroll_by(resp.drag_delta().y / frac, tl.height()); } } } @@ -632,7 +647,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) header_column(app, ui, &track, c, cr); } let main_h = track.height.points(); - let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + main_h)); + let lane = lane_rect(&track, row, tl); // Header background. ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 }); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); @@ -1444,14 +1459,16 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, 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 hit = clip_at(track, at).cloned(); - let upper = p.y < lane.center().y; + 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 hit = clip_at(track, at_origin).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 (p.x - xs).abs() < 6.0 { + if (origin.x - xs).abs() < 6.0 { Some(true) - } else if (p.x - xe).abs() < 6.0 { + } else if (origin.x - xe).abs() < 6.0 { Some(false) } else { None @@ -1490,12 +1507,12 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, hit.as_ref().and_then(|c| { let xs = x_of(&s, tl, c.start); let xe = x_of(&s, tl, c.end()); - let top = p.y < lane.min.y + lane.height() * 0.28; + let top = origin.y < lane.min.y + lane.height() * 0.28; if !top { None - } else if (p.x - xs) > 0.0 && (p.x - xs) < 14.0 { + } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { Some(true) - } else if (xe - p.x) > 0.0 && (xe - p.x) < 14.0 { + } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { Some(false) } else { None @@ -1514,8 +1531,11 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if resp.hovered() && gain_icon_at(p).is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); - let gain_drag = if resp.drag_started() { gain_icon_at(press) } else { None }; + let selector_press = eff == Tool::Selector && fade_corner.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 }; if let Some(c) = gain_drag { // `clip.gain` refuses to run with nothing selected. if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { @@ -1523,151 +1543,49 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } let pos = crate::widgets::clip_gain_to_pos(c.gain_db); let screen = ui.ctx().content_rect(); - let y = (press.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); - let fader_bottom = pos2(press.x.max(screen.min.x + 26.0), y); + let y = (origin.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); + let fader_bottom = pos2(origin.x.max(screen.min.x + 26.0), y); let fine = mods.command || mods.ctrl; - app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (press.y, pos), fine, fader_bottom }); + app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (origin.y, pos), fine, fader_bottom }); } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at }); } else if resp.drag_started() { app.gesture = match eff { - Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at), 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, delta: 0, from_track: track.id, to_track: track.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) { Some(false) => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, - _ if at - c.start < c.end() - at => Gesture::TrimStart { clip: c.id, track: track.id, to: c.start }, + _ 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() }, }), Tool::Scrubber => Some(Gesture::Scrub { last: at }), Tool::Pencil => Some(Gesture::Pencil { track: track.id, points: Vec::new() }), - Tool::Zoom => Some(Gesture::ZoomBox { start: p.x }), + Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; } - if resp.dragged() { - let to_track = app.edit_layout.rows.iter().find(|(_, r)| p.y >= r[1] && p.y <= r[3]).map(|(i, _)| TrackId(*i)); - match &mut app.gesture { - Some(Gesture::Select { tracks, .. }) => { - if let Some(t2) = to_track { - // Extend across the tracks between the anchor row and the pointer row. - let order: Vec = app.edit_layout.rows.iter().map(|(i, _)| TrackId(*i)).collect(); - let a = order.iter().position(|x| *x == track.id).unwrap_or(0); - let b = order.iter().position(|x| *x == t2).unwrap_or(a); - let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; - *tracks = order.get(lo..=hi).map(<[TrackId]>::to_vec).unwrap_or_default(); - } - } - Some(Gesture::MoveClips { grab_at, delta, to_track: tt, .. }) => { - let raw = at - *grab_at; - *delta = if s.edit.edit_mode.is_grid() { - snap(&s, hit.as_ref().map_or(*grab_at, |c| c.start) + raw) - hit.as_ref().map_or(*grab_at, |c| c.start) - } else { - raw - }; - if s.edit.edit_mode == soundcraft_model::EditMode::Spot { - *delta = 0; - } - if let Some(t2) = to_track { - *tt = t2; - } - } - Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), - Some(Gesture::Fade { to, .. }) => *to = at, - Some(Gesture::ClipGain { clip, pos, anchor, fine, .. }) => { - // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. - if *fine != (mods.command || mods.ctrl) { - *fine = !*fine; - *anchor = (p.y, *pos); - } - let scale = if *fine { 0.1 } else { 1.0 }; - *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); - let db = crate::widgets::clip_gain_from_pos(*pos); - if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { - let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); - } - } - Some(Gesture::Scrub { last }) => { - *last = at; - let _ = app.engine.execute("transport.locate", &json!({"at": at})); - } - Some(Gesture::Pencil { points, .. }) => { - if let Some(param) = &auto_view { - let (lo, hi, _) = param.range(); - let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { - (-60.0, 12.0) - } else if matches!(param, AutoParam::Plugin { .. }) { - plugin_range(track, param) - } else { - (lo, hi) - }; - let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); - let v = lo + (hi - lo) * k; - if points.last().is_none_or(|(a, _)| (at - a).abs() as f64 > s.edit.zoom.samples_per_px * 3.0) { - points.push((at, v)); - } - } - } - _ => {} - } - // Live selection feedback. - if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { - let r = Range::new(*anchor, snap(&s, at)); - let ids: Vec = tracks.iter().map(|x| x.0).collect(); - let _ = app.engine.execute("edit.select", &json!({"tracks": ids, "start": r.start, "end": r.end})); - } - } - if let Some(Gesture::ClipGain { track: t, pos, fader_bottom, .. }) = &app.gesture + if let Some(Gesture::ClipGain { clip, track: t, pos, anchor, fine, fader_bottom }) = &mut app.gesture && *t == track.id && resp.dragged() { + // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. + if *fine != (mods.command || mods.ctrl) { + *fine = !*fine; + *anchor = (p.y, *pos); + } + let scale = if *fine { 0.1 } else { 1.0 }; + *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); + let db = crate::widgets::clip_gain_from_pos(*pos); + if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { + let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); + } ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } - if resp.drag_stopped() { - let g = app.gesture.take(); - match g { - Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { - let ids: Vec = clips.iter().map(|c| c.0).collect(); - let mut params = json!({"clips": ids, "by": delta}); - if to_track != from_track { - params["track"] = json!(to_track.0); - } - if delta != 0 || to_track != from_track { - let _ = app.run("edit.move_clips", params); - } - } - Some(Gesture::Fade { clip, track, fade_in, to }) => { - if let Some((_, c)) = app.engine.session().find_clip(clip).map(|(t, c)| (t, c.clone())) { - let (a, b) = if fade_in { (c.start, to.clamp(c.start + 1, c.end())) } else { (to.clamp(c.start, c.end() - 1), c.end()) }; - let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); - } - } - Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), - Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), - Some(Gesture::Pencil { track, points }) => { - if let Some(param) = &auto_view { - let pid = automation_id(param); - for (a, v) in points { - let _ = app.run("automation.set_point", json!({"track": track.0, "param": pid, "at": a, "value": v})); - } - } - } - Some(Gesture::ZoomBox { start }) => { - let a = sample_at(&s, tl, start.min(p.x)); - let b = sample_at(&s, tl, start.max(p.x)); - if b > a { - let spp = ((b - a) as f64 / f64::from(tl.width())).max(0.25); - let _ = app.run("view.zoom_set", json!({"samples_per_px": spp})); - let _ = app.run("view.scroll", json!({"to": a})); - } - } - _ => {} - } - } if resp.clicked() { match eff { Tool::Grabber => { @@ -1691,16 +1609,8 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, ); } } - _ => { - let at = snap(&s, at); - if mods.shift && s.edit.selected_tracks.contains(&track.id) { - let sel = s.edit.selection; - let r = if at < sel.start { Range::new(at, sel.end) } else { Range::new(sel.start, at) }; - let _ = app.run("edit.select", json!({"start": r.start, "end": r.end})); - } else { - let _ = app.run("edit.select", json!({"tracks": [track.id.0], "start": at})); - } - } + _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), + _ => {} } } if resp.double_clicked() @@ -1711,6 +1621,153 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } } +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; + let params = if extend && s.edit.selected_tracks.contains(&track) { + let r = if at < sel.start { Range::new(at, sel.end) } else { Range::new(sel.start, at) }; + json!({"start": r.start, "end": r.end}) + } else if extend && !s.edit.selected_tracks.is_empty() { + let tracks: Vec = s.edit.selected_tracks.iter().chain([&track]).map(|t| t.0).collect(); + json!({"tracks": tracks, "start": sel.start.min(at), "end": sel.end.max(at)}) + } else { + json!({"tracks": [track.0], "start": at}) + }; + let rev = app.engine.revision; + let _ = app.run("edit.select", params); + if app.engine.revision != rev { + ui.ctx().request_discard("insertion point moved"); + } +} + +/// Runs before anything is painted, against the last frame's layout. +fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { + if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { + return; + } + let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline)); + let p = ctx.pointer_latest_pos(); + if !ctx.input(|i| i.pointer.any_down()) { + finish_drag(app, &s, tl, p); + return; + } + let Some(p) = p else { return }; + let at = sample_at(&s, tl, p.x).max(0); + let inside = pos2(p.x.max(tl.min.x).min(tl.max.x), p.y.max(tl.min.y).min(tl.max.y)); + let rows = &app.edit_layout.rows; + let gap = |r: &[f32; 4]| (r[1] - inside.y).max(inside.y - r[3]).max(0.0); + let to_track = rows.iter().min_by(|a, b| gap(&a.1).total_cmp(&gap(&b.1))).map(|(i, _)| TrackId(*i)); + match &mut app.gesture { + Some(Gesture::Select { track, tracks, .. }) => { + let row_of = |t: TrackId| rows.iter().position(|(i, _)| *i == t.0); + if let (Some(a), Some(b)) = (row_of(*track), to_track.and_then(row_of)) { + *tracks = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| TrackId(*i)).collect(); + } + } + Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => { + let raw = at - *grab_at; + *delta = if s.edit.edit_mode.is_grid() { + let base = s.track(*from_track).and_then(|t| clip_at(t, at)).map_or(*grab_at, |c| c.start); + snap(&s, base + raw) - base + } else { + raw + }; + if s.edit.edit_mode == soundcraft_model::EditMode::Spot { + *delta = 0; + } + if let Some(t2) = to_track { + *tt = t2; + } + } + Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), + Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::Scrub { last }) => { + *last = at; + let _ = app.engine.execute("transport.locate", &json!({"at": at})); + } + Some(Gesture::Pencil { track, points }) => { + if let Some(t) = s.track(*track) + && let Some(param) = AutoParam::parse(&t.view) + && let Some((_, row)) = rows.iter().find(|(i, _)| *i == t.id.0) + { + let lane = lane_rect(t, rect(*row), tl); + let (lo, hi, _) = param.range(); + let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { + (-60.0, 12.0) + } else if matches!(param, AutoParam::Plugin { .. }) { + plugin_range(t, ¶m) + } else { + (lo, hi) + }; + let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); + if points.last().is_none_or(|(a, _)| (at - a).abs() as f64 > s.edit.zoom.samples_per_px * 3.0) { + points.push((at, lo + (hi - lo) * k)); + } + } + } + _ => {} + } + if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { + let r = Range::new(*anchor, snap(&s, at)); + if *tracks != s.edit.selected_tracks || r != s.edit.selection { + let ids: Vec = tracks.iter().map(|x| x.0).collect(); + let _ = app.run("edit.select", json!({"tracks": ids, "start": r.start, "end": r.end})); + } + } + let out = p - inside; + if out != egui::Vec2::ZERO && matches!(app.gesture, Some(Gesture::Select { .. } | Gesture::MoveClips { .. })) { + let k = 12.0 * ctx.input(|i| i.stable_dt).min(0.1); + if out.x != 0.0 { + let _ = app.engine.execute("view.scroll", &json!({"by_px": out.x * k})); + } + app.edit_layout.scroll_by(out.y * k, tl.height()); + ctx.request_repaint(); + } +} + +fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { + match app.gesture.take() { + Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { + let ids: Vec = clips.iter().map(|c| c.0).collect(); + let mut params = json!({"clips": ids, "by": delta}); + if to_track != from_track { + params["track"] = json!(to_track.0); + } + if delta != 0 || to_track != from_track { + let _ = app.run("edit.move_clips", params); + } + } + Some(Gesture::Fade { clip, track, fade_in, to }) => { + if let Some((_, c)) = s.find_clip(clip) { + // Not clamp: it panics on a zero-length clip. + let (a, b) = if fade_in { (c.start, to.max(c.start + 1).min(c.end())) } else { (to.max(c.start).min(c.end() - 1), c.end()) }; + let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); + } + } + Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), + Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), + Some(Gesture::Pencil { track, points }) => { + if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) { + let pid = automation_id(¶m); + for (a, v) in points { + let _ = app.run("automation.set_point", json!({"track": track.0, "param": pid, "at": a, "value": v})); + } + } + } + Some(Gesture::ZoomBox { start }) => { + let Some(p) = p else { return }; + let a = sample_at(s, tl, start.min(p.x)); + let b = sample_at(s, tl, start.max(p.x)); + if b > a { + let spp = ((b - a) as f64 / f64::from(tl.width())).max(0.25); + let _ = app.run("view.zoom_set", json!({"samples_per_px": spp})); + let _ = app.run("view.scroll", json!({"to": a})); + } + } + _ => {} + } +} + fn automation_id(p: &AutoParam) -> String { match p { AutoParam::Volume => "volume".into(), @@ -1840,15 +1897,25 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } } else if !app.is_playing() { let x = x_of(s, tl, sel.start); - let blink = (ui.input(|i| i.time) * 2.0) as i64 % 2 == 0; - if blink { - for (_, r) in &app.edit_layout.rows { - if s.edit.selected_tracks.iter().any(|_| true) { + let now = ui.input(|i| i.time); + let (id, key) = (ui.id().with("insertion_blink"), (sel.start, s.edit.selected_tracks.clone())); + let since = ui.ctx().data_mut(|d| { + let (last, since) = d.get_temp_mut_or_insert_with(id, || (key.clone(), now)); + if *last != key { + *last = key; + *since = now; + } + *since + }); + let phase = (now - since) * 2.0; + if (phase as i64) % 2 == 0 { + for (id, r) in &app.edit_layout.rows { + if s.edit.selected_tracks.contains(&TrackId(*id)) { painter.line_segment([pos2(x, r[1].max(tl.min.y)), pos2(x, r[3])], Stroke::new(1.0, t.insertion)); } } } - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + ui.ctx().request_repaint_after_secs(((1.0 - phase.fract()) / 2.0) as f32); } if app.is_playing() { let x = x_of(s, tl, app.position()); @@ -1906,7 +1973,8 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { #[cfg(test)] mod tests { - use super::follow_update; + use super::*; + use egui_kittest::Harness; #[test] fn follow_jumps_to_offscreen_playhead_while_playing() { @@ -1946,4 +2014,402 @@ mod tests { assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None)); assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None)); } + + fn harness(n: usize) -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"count": n})).unwrap(); + harness_with(engine) + } + + fn harness_with(engine: soundcraft_engine::Engine) -> Harness<'static, SoundApp> { + let app = SoundApp::new(engine, None, crate::Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(4); + h + } + + fn smart_demo() -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.execute("edit.tool", &json!({"tool": "smart"})).unwrap(); + engine.execute("edit.mode", &json!({"mode": "slip"})).unwrap(); + harness_with(engine) + } + + fn row(h: &Harness, i: usize) -> Rect { + rect(h.state().edit_layout.rows[i].1) + } + + fn row_ids(h: &Harness) -> Vec { + h.state().edit_layout.rows.iter().map(|(i, _)| TrackId(*i)).collect() + } + + fn timeline(h: &Harness) -> Rect { + rect(h.state().edit_layout.timeline) + } + + fn lane_point(h: &Harness, i: usize) -> Pos2 { + pos2(timeline(h).min.x + 100.0, row(h, i).min.y + 10.0) + } + + fn clip_on_screen(h: &Harness, ok: impl Fn(f32, f32) -> bool) -> (Clip, Rect) { + let (s, tl) = (h.state().engine.session(), timeline(h)); + h.state() + .edit_layout + .rows + .iter() + .filter_map(|(id, r)| s.track(TrackId(*id)).map(|t| (t, r))) + .find_map(|(t, r)| { + let c = t.clips().iter().find(|c| ok(x_of(s, tl, c.start), x_of(s, tl, c.end())))?; + Some((c.clone(), lane_rect(t, rect(*r), tl))) + }) + .expect("no such clip on screen") + } + + fn start_drag(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.hover_at(at + vec2(20.0, 0.0)); + h.step(); + } + + fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + h.hover_at(from); + h.drag_at(from); + for k in 1..=8 { + h.hover_at(from + (to - from) * (k as f32 / 8.0)); + } + h.drop_at(to); + h.step(); + } + + fn quick_drag(h: &mut Harness, at: Pos2, to: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.hover_at(at + (to - at) * 0.75); + h.hover_at(to); + h.drop_at(to); + h.step(); + } + + fn click(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.step(); + } + + fn shift_click(h: &mut Harness, at: Pos2) { + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + click(h, at); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + + fn painted_rows(h: &Harness, y_of: impl Fn(&Shape) -> Option) -> Vec { + let rows = &h.state().edit_layout.rows; + let mut out: Vec = h + .output() + .shapes + .iter() + .filter_map(|c| y_of(&c.shape)) + .filter_map(|y| rows.iter().position(|(_, r)| (r[1]..=r[3]).contains(&y))) + .collect(); + out.sort_unstable(); + out.dedup(); + out + } + + fn highlighted_rows(h: &Harness) -> Vec { + painted_rows(h, |shape| match shape { + Shape::Rect(r) if r.fill == Tokens::current().selection => Some(r.rect.center().y), + _ => None, + }) + } + + fn insertion_rows(h: &Harness) -> Vec { + painted_rows(h, |shape| match shape { + Shape::LineSegment { points, stroke } if stroke.color == Tokens::current().insertion => Some((points[0].y + points[1].y) / 2.0), + _ => None, + }) + } + + #[test] + fn drag_selection_spans_the_rows_from_anchor_to_pointer() { + let mut h = harness(4); + let ids = row_ids(&h); + for (from, to) in [(3, 2), (0, 1), (2, 0), (1, 3)] { + let (a, b) = (lane_point(&h, from), lane_point(&h, to) + vec2(80.0, 0.0)); + drag(&mut h, a, b); + let want = ids.get(from.min(to)..=from.max(to)).unwrap(); + assert_eq!(h.state().engine.session().edit.selected_tracks, want, "drag from row {from} to row {to}"); + } + } + + #[test] + fn the_frame_a_drag_reaches_a_row_paints_it_selected() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 2) + vec2(60.0, 0.0)); + start_drag(&mut h, a); + h.hover_at(b); + h.step(); + assert_eq!(highlighted_rows(&h), [2, 3]); + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_paints_each_frame_once() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 0) + vec2(300.0, 0.0)); + start_drag(&mut h, a); + for k in 1..=10 { + h.hover_at(a + (b - a) * (k as f32 / 10.0)); + h.step(); + assert_eq!(h.output().platform_output.num_completed_passes, 1, "move {k}"); + } + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_frame_changes_the_document_once() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 2) + vec2(60.0, 0.0)); + start_drag(&mut h, a); + let rev = h.state().engine.revision; + h.hover_at(b); + h.step(); + assert_eq!(h.state().engine.revision, rev + 1); + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_selection_starts_where_the_button_went_down() { + let mut h = harness(4); + let a = lane_point(&h, 1); + drag(&mut h, a, a + vec2(200.0, 0.0)); + let s = h.state().engine.session(); + assert_eq!(s.edit.selection.start, sample_at(s, timeline(&h), a.x)); + } + + #[test] + fn pressing_in_a_lane_places_the_insertion_point_before_release() { + let mut h = harness(4); + let a = lane_point(&h, 2); + h.hover_at(a); + h.drag_at(a); + h.step(); + let s = h.state().engine.session(); + assert_eq!(s.edit.selection, Range::point(sample_at(s, timeline(&h), a.x))); + assert_eq!(s.edit.selected_tracks, row_ids(&h)[2..3]); + h.drop_at(a); + h.step(); + } + + #[test] + fn a_moved_insertion_point_shows_at_once_even_mid_blink() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 0), lane_point(&h, 2) + vec2(150.0, 0.0)); + click(&mut h, a); + for _ in 0..90 { + if insertion_rows(&h).is_empty() { + break; + } + h.step(); + } + assert!(insertion_rows(&h).is_empty(), "the insertion point never blinked off"); + click(&mut h, b); + assert!(!insertion_rows(&h).is_empty()); + } + + #[test] + fn the_insertion_point_shows_only_on_selected_tracks() { + let mut h = harness(4); + let a = lane_point(&h, 2); + click(&mut h, a); + assert_eq!(insertion_rows(&h), [2]); + } + + #[test] + fn shift_click_adds_another_track_to_the_selection() { + let mut h = harness(4); + let ids = row_ids(&h); + let (a, b, c) = (lane_point(&h, 0), lane_point(&h, 2) + vec2(100.0, 0.0), lane_point(&h, 3) + vec2(300.0, 0.0)); + drag(&mut h, a, a + vec2(200.0, 0.0)); + let sel = h.state().engine.session().edit.selection; + shift_click(&mut h, b); + assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]]); + assert_eq!(h.state().engine.session().edit.selection, sel); + shift_click(&mut h, c); + let s = h.state().engine.session(); + assert_eq!(s.edit.selected_tracks, [ids[0], ids[2], ids[3]]); + assert_eq!(s.edit.selection, Range::new(sel.start, sample_at(s, timeline(&h), c.x))); + assert_eq!(highlighted_rows(&h), [0, 2, 3]); + } + + #[test] + fn a_quick_drag_from_a_clip_edge_trims_it() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xe > tl.min.x + 100.0 && xe < tl.max.x - 20.0 && xe - xs > 100.0); + let press = pos2(x_of(h.state().engine.session(), tl, clip.end()) - 2.0, lane.min.y + lane.height() * 0.75); + quick_drag(&mut h, press, press - vec2(40.0, 0.0)); + let after = h.state().engine.session().find_clip(clip.id).unwrap().1.clone(); + assert_eq!(after.start, clip.start, "the clip moved instead of being trimmed"); + assert!(after.end() < clip.end(), "the clip end did not move"); + } + + #[test] + fn a_quick_clip_drag_keeps_the_clip_under_the_pointer() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xe - xs > 120.0 && (xs + xe) / 2.0 > tl.min.x + 60.0 && (xs + xe) / 2.0 < tl.max.x - 60.0); + let s = h.state().engine.session(); + let grab = pos2((x_of(s, tl, clip.start) + x_of(s, tl, clip.end())) / 2.0, lane.min.y + lane.height() * 0.75); + let by = sample_at(s, tl, grab.x + 40.0) - sample_at(s, tl, grab.x); + quick_drag(&mut h, grab, grab + vec2(40.0, 0.0)); + let after = h.state().engine.session().find_clip(clip.id).unwrap().1.clone(); + assert_eq!(after.start - clip.start, by, "the clip trails the pointer"); + } + + #[test] + fn a_fade_drag_survives_its_clip_shrinking_to_nothing() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 20.0 && xe - xs > 100.0); + let corner = pos2(x_of(h.state().engine.session(), tl, clip.start) + 6.0, lane.min.y + 4.0); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { .. }))); + if let Some(c) = h.state_mut().engine.session_mut().find_clip_mut(clip.id) { + c.length = 0; + } + h.drop_at(corner + vec2(20.0, 0.0)); + h.step(); + assert!(h.state().gesture.is_none()); + } + + #[test] + fn a_drag_keeps_going_when_its_row_scrolls_away() { + let mut h = harness(20); + let (ids, a) = (row_ids(&h), lane_point(&h, 0)); + start_drag(&mut h, a); + h.state_mut().edit_layout.scroll_y = 500.0; + h.step(); + assert!(row(&h, 0).max.y < timeline(&h).min.y, "the first row is still on screen"); + let b = lane_point(&h, 8); + h.hover_at(b); + h.drop_at(b); + h.step(); + assert_eq!(h.state().engine.session().edit.selected_tracks, ids[..=8]); + assert!(h.state().gesture.is_none(), "the drag outlived the release"); + } + + #[test] + fn a_gesture_left_over_after_the_release_is_dropped() { + let mut h = harness(1); + h.state_mut().gesture = Some(Gesture::Scrub { last: 0 }); + h.step(); + assert!(h.state().gesture.is_none()); + } + + #[test] + fn a_flick_past_the_last_track_selects_down_to_it() { + let mut h = harness(4); + let ids = row_ids(&h); + let a = lane_point(&h, 1); + let below = pos2(a.x + 60.0, row(&h, 3).max.y + 100.0); + start_drag(&mut h, a); + h.hover_at(below); + h.drop_at(below); + h.step(); + assert_eq!(h.state().engine.session().edit.selected_tracks, ids[1..]); + } + + #[test] + fn dragging_past_the_bottom_scrolls_and_keeps_selecting() { + let mut h = harness(20); + let first = row_ids(&h)[0]; + let a = lane_point(&h, 0); + let below = pos2(a.x + 60.0, timeline(&h).max.y + 40.0); + start_drag(&mut h, a); + h.hover_at(below); + h.step(); + h.run_steps(60); + assert!(h.state().edit_layout.scroll_y > 300.0, "scrolled {} px", h.state().edit_layout.scroll_y); + h.drop_at(below); + h.step(); + let tl = timeline(&h); + let last_seen = h.state().edit_layout.rows.iter().filter(|(_, r)| r[1] < tl.max.y).count(); + let sel = &h.state().engine.session().edit.selected_tracks; + assert_eq!((sel.first(), sel.len()), (Some(&first), last_seen)); + } + + #[test] + fn dragging_past_the_right_edge_scrolls_the_timeline() { + let mut h = harness(2); + let a = lane_point(&h, 0); + let right = pos2(timeline(&h).max.x + 40.0, a.y); + let s = h.state().engine.session(); + let (before, pointed) = (s.edit.zoom.scroll, sample_at(s, timeline(&h), right.x)); + start_drag(&mut h, a); + h.hover_at(right); + h.step(); + h.run_steps(30); + h.drop_at(right); + h.step(); + let s = h.state().engine.session(); + assert!(s.edit.zoom.scroll > before, "the timeline did not scroll"); + assert!(s.edit.selection.end > pointed, "the selection did not follow the scroll"); + } + + #[test] + fn a_redrawn_frame_counts_once_in_the_frame_time() { + let mut h = harness(4); + let mut t = 1.0; + let mut frame = |h: &mut Harness| { + t += 1.0 / 60.0; + h.input_mut().time = Some(t); + h.step(); + }; + for _ in 0..120 { + frame(&mut h); + } + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + for k in 0..30 { + let at = lane_point(&h, k % 4) + vec2(k as f32 * 7.0, 0.0); + h.hover_at(at); + frame(&mut h); + h.event(button(at, true)); + frame(&mut h); + h.event(button(at, false)); + frame(&mut h); + } + let ms = h.state().frame_ms; + assert!((ms - 1000.0 / 60.0).abs() < 0.5, "{ms} ms/frame"); + } + + #[test] + fn dragging_a_name_plate_down_drops_it_where_released() { + let mut h = harness(4); + let order = |h: &Harness| -> Vec { h.state().engine.session().tracks.iter().map(|t| t.id).collect() }; + let before = order(&h); + let grab = row(&h, 0).min + vec2(40.0, 12.0); + let release = pos2(grab.x, row(&h, 2).min.y); + drag(&mut h, grab, release); + let want: Vec = [1, 0, 2, 3].iter().map(|&i| before[i]).collect(); + assert_eq!(order(&h), want); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..fede065 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -731,6 +731,9 @@ impl SoundApp { /// Per-frame logic: control channel, transport, document sync. pub fn logic(&mut self, ctx: &egui::Context) { + if ctx.current_pass_index() > 0 { + return; + } // Fonts set now take effect next frame, so draw only from the frame after. if !self.fonts_ready { if self.fonts_installed { @@ -747,8 +750,7 @@ impl SoundApp { self.theme_applied = Some(light); } let now = ctx.input(|i| i.time); - let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); - self.last_frame = Some(now); + let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; control::drain(self, ctx); self.apply_editor_edits(); From 66bf162aa99b6e2214fa66bd768f9023983ba2c5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:15:57 -0400 Subject: [PATCH 023/137] Drop UtilityWindow: AppKit documents it only for NSPanel Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/src/ffi.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 38fa41d..341ad53 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1780,10 +1780,10 @@ mod window { let mtm = MainThreadMarker::new().ok_or_else(|| "not on the main thread".to_string())?; let _app = NSApplication::sharedApplication(mtm); let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h)); - // Plugin editors are transient utility windows. In particular they must not be + // Plugin editors are transient windows. In particular they must not be // minimizable: minimized NSWindows become persistent thumbnails in the macOS Dock, // which made every editor visited in a session look like another running app. - let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::UtilityWindow; + let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable; // SAFETY: a freshly allocated window on the main thread; valid rect and style. let window = unsafe { NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false) From 69570e5b54a614e732f05bdfd28ced77f22916fe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:16:15 -0400 Subject: [PATCH 024/137] Cap session bundles at 1 GiB: 4 GiB is all of wasm32's address space Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 8c774d8..464740e 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -578,7 +578,7 @@ pub const BUNDLE_EXTENSION: &str = "scraftz"; /// Most entries and bytes a bundle may unpack to (bundles are hostile input). const MAX_BUNDLE_ENTRIES: usize = 10_000; -const MAX_BUNDLE_BYTES: u64 = 4 << 30; +const MAX_BUNDLE_BYTES: u64 = 1 << 30; /// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app /// installs one that starts a download. Native builds write files and never install it. From ec920cd975420605a6279863c79b0da18c38684b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:17:53 -0400 Subject: [PATCH 025/137] Instrument params: never read insert slot A's automation; regression tests for #152 Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 60 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 64ab140..df4f129 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1287,20 +1287,31 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { - let slot_u8 = u8::try_from(i).unwrap_or(0); + // Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it + // must not pick up slot A's lanes. + let Ok(slot_u8) = u8::try_from(i) else { + return apply_values(slot, ins, |_, v| v); + }; let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty()); - if slot.applied.len() != ins.params.len() { - slot.applied = vec![f32::NAN; ins.params.len()]; - } - for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) { - let mut val = *v; + apply_values(slot, ins, |k, val| { if automated && let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k)) && !l.points.is_empty() { - val = l.value_at(pos, val); + return l.value_at(pos, val); } + val + }); +} + +/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time. +fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) { + if slot.applied.len() != ins.params.len() { + slot.applied = vec![f32::NAN; ins.params.len()]; + } + for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) { + let val = value(k, *v); if val.to_bits() != last.to_bits() { slot.plugin.set_param(k, val); *last = val; @@ -1808,6 +1819,41 @@ mod tests { use soundcraft_model::{Insert, SourceAudio, SourceId}; use std::sync::Arc; + fn synth_session(level_db: f32) -> Session { + use soundcraft_midi::{Note, Sequence}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None); + let mut seq = Sequence::default(); + seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }); + let cid = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::midi(cid, "n", 0, 24_000, seq)); + let mut ins = Insert::new("subtractive_synth"); + ins.params.insert("level".into(), level_db); + tr.instrument = Some(ins); + s + } + + #[test] + fn stored_instrument_params_reach_the_plugin() { + // #152: a saved instrument level of -12 dB must render quieter than -6 dB. + let peak = |db: f32| render_range(&synth_session(db), Range::new(0, 12_000), 512)[0].iter().fold(0.0f32, |m, x| m.max(x.abs())); + let (loud, quiet) = (peak(-6.0), peak(-12.0)); + assert!(loud > 0.0, "the synth should sound"); + assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the peak: {quiet} / {loud}"); + } + + #[test] + fn insert_slot_a_automation_does_not_drive_the_instrument() { + let mut s = synth_session(-6.0); + let reference = render_range(&s, Range::new(0, 12_000), 512); + let tr = &mut s.tracks[0]; + let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: 0, param: "level".into() }); + lane.set_point(0, -48.0); + tr.automation.push(lane); + assert_eq!(render_range(&s, Range::new(0, 12_000), 512), reference); + } + fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); From df70f6ad6963ec91e525541a0b624bede8a48edd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:18:11 -0400 Subject: [PATCH 026/137] Ignore a corrupt saved window size Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft/src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 54266c5..1ac7b17 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -224,7 +224,7 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("SoundCraft") - .with_inner_size(prefs.window_size) + .with_inner_size(sane_window_size(prefs.window_size)) .with_maximized(prefs.window_maximized) .with_min_inner_size([960.0, 600.0]) .with_drag_and_drop(true) @@ -275,3 +275,8 @@ fn main() -> eframe::Result { }), ) } + +/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny). +fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] { + if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] } +} From 6a35fcd3a916f0e2191298943c306568b4cabeb4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:19:19 -0400 Subject: [PATCH 027/137] Re-targeting a send keeps its level, pan, mute and pre/post Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mix.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 03574b2..e9fcd19 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { insert_params ), cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move), - cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send), + cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}", has_selection, send), cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| { let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?; if let Some(tr) = e.session_mut().track_mut(t) @@ -390,15 +390,17 @@ fn send(e: &mut Engine, p: &Value) -> Result { let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string(); let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?; let explicit = slot_of(p, "slot", SEND_SLOTS); - let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0); - let pre = bool_or(p, "pre_fader", false); let s = e.session_mut(); let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo); let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?; let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?; - let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid)); - sl.level_db = level; - sl.pre_fader = pre; + // Re-targeting an existing send keeps its level, pan, mute and pre/post setting. + let mut sl = match tr.mixer.sends.get(slot).cloned().flatten() { + Some(old) => SendSlot { target: soundcraft_model::Route::Bus(bid), ..old }, + None => SendSlot::new(soundcraft_model::Route::Bus(bid)), + }; + sl.level_db = f32_or(p, "level_db", sl.level_db).clamp(-144.0, 12.0); + sl.pre_fader = bool_or(p, "pre_fader", sl.pre_fader); if let Some(x) = tr.mixer.sends.get_mut(slot) { *x = Some(sl); } @@ -606,3 +608,22 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result { } Ok(json!({})) } + +#[cfg(test)] +mod tests { + use crate::Engine; + use serde_json::json; + + #[test] + fn retargeting_a_send_keeps_its_settings() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap(); + e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap(); + e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap(); + e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap(); + let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap(); + let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id; + assert_eq!(sl.target, soundcraft_model::Route::Bus(delay)); + assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true)); + } +} From d87d0373ee0b09fbed7fcce9c5d35152ef078c2e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:19:50 -0400 Subject: [PATCH 028/137] Record to disk: keep a partial frame between pops so channels never shift Co-Authored-By: Claude Opus 5.5 --- crates/playback/src/record.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index fb1e6c4..ec784ac 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -179,9 +179,12 @@ fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> let (mut block, mut header_written) = (Vec::new(), Instant::now()); loop { let stopping = !shared.armed.load(Ordering::Acquire); - block.clear(); + // The ring holds interleaved samples and a pop can end mid-frame: write whole frames and + // keep the remainder for the next pop, so channels never shift. shared.ring.pop_all(&mut block); - takes.iter_mut().for_each(|t| t.write(&block, channels)); + let whole = block.len() - block.len() % channels.max(1); + takes.iter_mut().for_each(|t| t.write(&block[..whole], channels)); + block.drain(..whole); if stopping { break; } From 3b947b94bdb758ebc7c2b5799af16fea3be97e22 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:21:45 -0400 Subject: [PATCH 029/137] Deselect All also clears the Clip List's file selection Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/clip_list.rs | 9 +++++++++ crates/engine/src/cmd/edit.rs | 1 + 2 files changed, 10 insertions(+) diff --git a/crates/engine/src/cmd/clip_list.rs b/crates/engine/src/cmd/clip_list.rs index 4df0d69..a852988 100644 --- a/crates/engine/src/cmd/clip_list.rs +++ b/crates/engine/src/cmd/clip_list.rs @@ -148,4 +148,13 @@ mod tests { assert!(e.execute("clip.clear", &p).is_err(), "{p}"); } } + + #[test] + fn deselect_all_clears_the_clip_list_selection() { + let mut e = crate::demo::demo_engine(); + let (f, _) = kick(&e); + e.execute("edit.select", &json!({"sources": [f]})).unwrap(); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(e.session().edit.selected_sources.is_empty()); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 6082afb..9d8d7f0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -52,6 +52,7 @@ pub fn specs() -> Vec { let s = e.session_mut(); s.edit.selected_tracks.clear(); s.edit.selected_clips.clear(); + s.edit.selected_sources.clear(); s.edit.selection.end = s.edit.selection.start; Ok(json!({})) }), From deea994688838e0f0db9a9f3073e99b06d87de7f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:22:24 -0400 Subject: [PATCH 030/137] Grid moves snap the grabbed clip, not whatever is under the pointer now Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 9f9c972..35d8270 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1660,7 +1660,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => { let raw = at - *grab_at; *delta = if s.edit.edit_mode.is_grid() { - let base = s.track(*from_track).and_then(|t| clip_at(t, at)).map_or(*grab_at, |c| c.start); + let base = s.track(*from_track).and_then(|t| clip_at(t, *grab_at)).map_or(*grab_at, |c| c.start); snap(&s, base + raw) - base } else { raw From 157e2a9547e945f5fe8093e4088efefcebf1d279 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:21:04 +0100 Subject: [PATCH 031/137] Fix drag selection and the insertion point in the Edit window Fixes #166: dragging a selection up from the last track deselected it and selected every track above instead. Fixed, along with related Edit-window drag problems: - Drag selection spans exactly the tracks between the press and the pointer, in either direction, and the whole window updates in the same frame. - Shift+click in another track adds it to the selection with the same range, as in Pro Tools, so the selected tracks no longer have to be adjacent. A click outside the range extends it. - Track names follow Pro Tools' rules in the Edit window, the Mix window and the Tracks list: Shift+click selects every track between the nearest selected one and the clicked one, and Cmd+click (Ctrl+click on Windows and Linux) adds or removes one. A click on a name used to replace the selection; only the Edit header's background added, and it treated Shift like Cmd. - Dropping a track's name plate lower down no longer sends the track to the end. - Drags act where you pressed: a quick drag from a clip edge now trims instead of moving the clip. - Dragging past the edge of the track area scrolls, and a flick past the last track selects down to it. - The insertion point moves on press, shows immediately, and only on the selected tracks. - edit.select no longer makes a new document revision when nothing changed (each one is resent to the audio engine). - Fixes a panic when a fade drag's clip shrinks to zero length. --- crates/engine/src/cmd/edit.rs | 45 +- crates/ui-egui/src/edit_window.rs | 842 ++++++++++++++++++++++++------ crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/mix_window.rs | 2 +- crates/ui-egui/src/panels.rs | 2 +- 5 files changed, 709 insertions(+), 188 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e4f2d5..02c8597 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -350,7 +350,8 @@ fn select(e: &mut Engine, p: &Value) -> Result { let clips = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); let exact = bool_or(p, "exact", false); - let s = e.session_mut(); + let s = e.session(); + let mut ed = s.edit.clone(); if let Some(mut t) = tracks { // Active edit groups select all their members together. if !exact { @@ -364,11 +365,11 @@ fn select(e: &mut Engine, p: &Value) -> Result { let order: Vec = s.tracks.iter().map(|x| x.id).collect(); t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); } - s.edit.selected_tracks = t; + ed.selected_tracks = t; } match (st, en) { - (Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)), - (Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)), + (Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)), + (Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)), _ => {} } if let Some(c) = clips { @@ -384,17 +385,21 @@ fn select(e: &mut Engine, p: &Value) -> Result { } } if let Some(r) = range { - s.edit.selection = r; - s.edit.selected_tracks = trs; + ed.selection = r; + ed.selected_tracks = trs; } - s.edit.selected_clips = c; + ed.selected_clips = c; } else if st.is_some() { - s.edit.selected_clips.clear(); + ed.selected_clips.clear(); } - if s.edit.link_timeline_edit { - s.edit.timeline_selection = s.edit.selection; + if ed.link_timeline_edit { + ed.timeline_selection = ed.selection; } - Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips})) + let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips}); + if ed != s.edit { + e.session_mut().edit = ed; + } + Ok(out) } fn shift_selection(e: &mut Engine, dir: i64) -> Result { @@ -750,3 +755,21 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { } Ok(json!({"consolidated": n})) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selecting_what_is_already_selected_leaves_the_document_alone() { + let mut e = Engine::default(); + let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200}); + e.execute("edit.select", &sel).unwrap(); + let rev = e.revision; + assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]])); + assert_eq!(e.revision, rev); + e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap(); + assert_ne!(e.revision, rev); + } +} diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..38698c3 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -46,6 +46,20 @@ pub struct EditLayout { pub last_follow_to: Option, } +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)); + } +} + +pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect { + Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)) +} + +fn lane_rect(track: &Track, row: Rect, tl: Rect) -> Rect { + Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + track.height.points())) +} + pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { tl.min.x + ((at - s.edit.zoom.scroll) as f64 / s.edit.zoom.samples_per_px.max(0.01)) as f32 } @@ -63,6 +77,7 @@ pub fn track_height(t: &Track) -> f32 { pub fn show(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); + drive_drag(app, ui.ctx()); egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui)); @@ -208,31 +223,31 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { let _ = app.engine.execute("view.scroll", &json!({"by_px": -dx})); } if !mods.shift && delta.y.abs() > 0.0 { - app.edit_layout.scroll_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0)); + app.edit_layout.scroll_by(-delta.y, tl.height()); } } } draw_rulers(app, ui, rulers_rect, tl, &rulers); // Tracks. - let ids: Vec = - app.engine.session().tracks.iter().filter(|x| !x.hidden && !folder_collapsed(app.engine.session(), x)).map(|x| x.id).collect(); let mut y = tl.min.y - app.edit_layout.scroll_y; app.edit_layout.rows.clear(); - let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y)); let mut content_h = 0.0; - for id in ids { - let Some(h) = app.engine.session().track(id).map(track_height) else { continue }; - let row = Rect::from_min_max(pos2(full.min.x, y), pos2(tl.max.x, y + h)); - if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); - child.set_clip_rect(clip_rect); - track_row(app, &mut child, id, row, tl); - } - app.edit_layout.rows.push((id.0, [row.min.x, row.min.y, row.max.x, row.max.y])); + for t in app.engine.session().tracks.iter().filter(|x| in_edit_window(app.engine.session(), x)) { + let h = track_height(t); + app.edit_layout.rows.push((t.id.0, [full.min.x, y, tl.max.x, y + h])); y += h + 1.0; content_h += h + 1.0; } app.edit_layout.content_h = content_h; + let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y)); + for (id, r) in app.edit_layout.rows.clone() { + let row = rect(r); + if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); + child.set_clip_rect(clip_rect); + track_row(app, &mut child, TrackId(id), row, tl); + } + } // Empty area click clears track selection. let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max); if empty.height() > 4.0 && ui.interact(empty, ui.id().with("empty"), Sense::click()).clicked() { @@ -253,13 +268,38 @@ fn main_area(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(thumb, 3.0, t.button_hi); let resp = ui.interact(vs, ui.id().with("vscroll"), Sense::drag()); if resp.dragged() { - app.edit_layout.scroll_y = (app.edit_layout.scroll_y + resp.drag_delta().y / frac).clamp(0.0, (content_h - tl.height() + 40.0).max(0.0)); + app.edit_layout.scroll_by(resp.drag_delta().y / frac, tl.height()); } } } -fn folder_collapsed(s: &Session, t: &Track) -> bool { - t.folder.and_then(|f| s.track(f)).is_some_and(|f| !f.folder_open) +fn in_edit_window(s: &Session, t: &Track) -> bool { + !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open) +} + +/// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one. +pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers, shown: impl Fn(&Session, &Track) -> bool) { + let s = app.engine.session(); + let order: Vec = s.tracks.iter().filter(|t| shown(s, t)).map(|t| t.id).collect(); + let row = |t: &TrackId| order.iter().position(|o| o == t); + let mut tracks = s.edit.selected_tracks.clone(); + if mods.command { + if tracks.contains(&id) { + tracks.retain(|t| *t != id); + } else { + tracks.push(id); + } + } else if mods.shift + && let Some(a) = row(&id) + && let Some(b) = tracks.iter().filter_map(row).min_by_key(|b| b.abs_diff(a)) + { + let span = order.get(a.min(b)..=a.max(b)).unwrap_or_default(); + tracks.extend(span.iter().filter(|t| !tracks.contains(t)).copied().collect::>()); + } else { + tracks = vec![id]; + } + let ids: Vec = tracks.iter().map(|t| t.0).collect(); + let _ = app.run("edit.select", json!({"tracks": ids})); } fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { @@ -632,7 +672,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) header_column(app, ui, &track, c, cr); } let main_h = track.height.points(); - let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + main_h)); + let lane = lane_rect(&track, row, tl); // Header background. ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 }); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); @@ -736,15 +776,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele // Click on header background selects the track. let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click()); if bg.clicked() { - let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); - let mut tracks = if add { app.engine.session().edit.selected_tracks.clone() } else { Vec::new() }; - if add && tracks.contains(&id) { - tracks.retain(|x| *x != id); - } else { - tracks.push(id); - } - let ids: Vec = tracks.iter().map(|x| x.0).collect(); - let _ = app.run("edit.select", json!({"tracks": ids})); + click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } bg.context_menu(|ui| track_context_menu(app, ui, id)); // Name field. @@ -767,7 +799,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } else if nresp.clicked() { - let _ = app.run("edit.select", json!({"tracks": [id.0]})); + click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } // Drag the name plate to reorder tracks. if nresp.dragged() @@ -1444,14 +1476,16 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, 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 hit = clip_at(track, at).cloned(); - let upper = p.y < lane.center().y; + 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 hit = clip_at(track, at_origin).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 (p.x - xs).abs() < 6.0 { + if (origin.x - xs).abs() < 6.0 { Some(true) - } else if (p.x - xe).abs() < 6.0 { + } else if (origin.x - xe).abs() < 6.0 { Some(false) } else { None @@ -1490,12 +1524,12 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, hit.as_ref().and_then(|c| { let xs = x_of(&s, tl, c.start); let xe = x_of(&s, tl, c.end()); - let top = p.y < lane.min.y + lane.height() * 0.28; + let top = origin.y < lane.min.y + lane.height() * 0.28; if !top { None - } else if (p.x - xs) > 0.0 && (p.x - xs) < 14.0 { + } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { Some(true) - } else if (xe - p.x) > 0.0 && (xe - p.x) < 14.0 { + } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { Some(false) } else { None @@ -1514,8 +1548,11 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if resp.hovered() && gain_icon_at(p).is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); - let gain_drag = if resp.drag_started() { gain_icon_at(press) } else { None }; + let selector_press = eff == Tool::Selector && fade_corner.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 }; if let Some(c) = gain_drag { // `clip.gain` refuses to run with nothing selected. if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { @@ -1523,151 +1560,49 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } let pos = crate::widgets::clip_gain_to_pos(c.gain_db); let screen = ui.ctx().content_rect(); - let y = (press.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); - let fader_bottom = pos2(press.x.max(screen.min.x + 26.0), y); + let y = (origin.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); + let fader_bottom = pos2(origin.x.max(screen.min.x + 26.0), y); let fine = mods.command || mods.ctrl; - app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (press.y, pos), fine, fader_bottom }); + app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (origin.y, pos), fine, fader_bottom }); } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at }); } else if resp.drag_started() { app.gesture = match eff { - Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at), 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, delta: 0, from_track: track.id, to_track: track.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) { Some(false) => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, - _ if at - c.start < c.end() - at => Gesture::TrimStart { clip: c.id, track: track.id, to: c.start }, + _ 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() }, }), Tool::Scrubber => Some(Gesture::Scrub { last: at }), Tool::Pencil => Some(Gesture::Pencil { track: track.id, points: Vec::new() }), - Tool::Zoom => Some(Gesture::ZoomBox { start: p.x }), + Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; } - if resp.dragged() { - let to_track = app.edit_layout.rows.iter().find(|(_, r)| p.y >= r[1] && p.y <= r[3]).map(|(i, _)| TrackId(*i)); - match &mut app.gesture { - Some(Gesture::Select { tracks, .. }) => { - if let Some(t2) = to_track { - // Extend across the tracks between the anchor row and the pointer row. - let order: Vec = app.edit_layout.rows.iter().map(|(i, _)| TrackId(*i)).collect(); - let a = order.iter().position(|x| *x == track.id).unwrap_or(0); - let b = order.iter().position(|x| *x == t2).unwrap_or(a); - let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; - *tracks = order.get(lo..=hi).map(<[TrackId]>::to_vec).unwrap_or_default(); - } - } - Some(Gesture::MoveClips { grab_at, delta, to_track: tt, .. }) => { - let raw = at - *grab_at; - *delta = if s.edit.edit_mode.is_grid() { - snap(&s, hit.as_ref().map_or(*grab_at, |c| c.start) + raw) - hit.as_ref().map_or(*grab_at, |c| c.start) - } else { - raw - }; - if s.edit.edit_mode == soundcraft_model::EditMode::Spot { - *delta = 0; - } - if let Some(t2) = to_track { - *tt = t2; - } - } - Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), - Some(Gesture::Fade { to, .. }) => *to = at, - Some(Gesture::ClipGain { clip, pos, anchor, fine, .. }) => { - // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. - if *fine != (mods.command || mods.ctrl) { - *fine = !*fine; - *anchor = (p.y, *pos); - } - let scale = if *fine { 0.1 } else { 1.0 }; - *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); - let db = crate::widgets::clip_gain_from_pos(*pos); - if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { - let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); - } - } - Some(Gesture::Scrub { last }) => { - *last = at; - let _ = app.engine.execute("transport.locate", &json!({"at": at})); - } - Some(Gesture::Pencil { points, .. }) => { - if let Some(param) = &auto_view { - let (lo, hi, _) = param.range(); - let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { - (-60.0, 12.0) - } else if matches!(param, AutoParam::Plugin { .. }) { - plugin_range(track, param) - } else { - (lo, hi) - }; - let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); - let v = lo + (hi - lo) * k; - if points.last().is_none_or(|(a, _)| (at - a).abs() as f64 > s.edit.zoom.samples_per_px * 3.0) { - points.push((at, v)); - } - } - } - _ => {} - } - // Live selection feedback. - if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { - let r = Range::new(*anchor, snap(&s, at)); - let ids: Vec = tracks.iter().map(|x| x.0).collect(); - let _ = app.engine.execute("edit.select", &json!({"tracks": ids, "start": r.start, "end": r.end})); - } - } - if let Some(Gesture::ClipGain { track: t, pos, fader_bottom, .. }) = &app.gesture + if let Some(Gesture::ClipGain { clip, track: t, pos, anchor, fine, fader_bottom }) = &mut app.gesture && *t == track.id && resp.dragged() { + // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. + if *fine != (mods.command || mods.ctrl) { + *fine = !*fine; + *anchor = (p.y, *pos); + } + let scale = if *fine { 0.1 } else { 1.0 }; + *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); + let db = crate::widgets::clip_gain_from_pos(*pos); + if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { + let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); + } ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } - if resp.drag_stopped() { - let g = app.gesture.take(); - match g { - Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { - let ids: Vec = clips.iter().map(|c| c.0).collect(); - let mut params = json!({"clips": ids, "by": delta}); - if to_track != from_track { - params["track"] = json!(to_track.0); - } - if delta != 0 || to_track != from_track { - let _ = app.run("edit.move_clips", params); - } - } - Some(Gesture::Fade { clip, track, fade_in, to }) => { - if let Some((_, c)) = app.engine.session().find_clip(clip).map(|(t, c)| (t, c.clone())) { - let (a, b) = if fade_in { (c.start, to.clamp(c.start + 1, c.end())) } else { (to.clamp(c.start, c.end() - 1), c.end()) }; - let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); - } - } - Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), - Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), - Some(Gesture::Pencil { track, points }) => { - if let Some(param) = &auto_view { - let pid = automation_id(param); - for (a, v) in points { - let _ = app.run("automation.set_point", json!({"track": track.0, "param": pid, "at": a, "value": v})); - } - } - } - Some(Gesture::ZoomBox { start }) => { - let a = sample_at(&s, tl, start.min(p.x)); - let b = sample_at(&s, tl, start.max(p.x)); - if b > a { - let spp = ((b - a) as f64 / f64::from(tl.width())).max(0.25); - let _ = app.run("view.zoom_set", json!({"samples_per_px": spp})); - let _ = app.run("view.scroll", json!({"to": a})); - } - } - _ => {} - } - } if resp.clicked() { match eff { Tool::Grabber => { @@ -1691,16 +1626,8 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, ); } } - _ => { - let at = snap(&s, at); - if mods.shift && s.edit.selected_tracks.contains(&track.id) { - let sel = s.edit.selection; - let r = if at < sel.start { Range::new(at, sel.end) } else { Range::new(sel.start, at) }; - let _ = app.run("edit.select", json!({"start": r.start, "end": r.end})); - } else { - let _ = app.run("edit.select", json!({"tracks": [track.id.0], "start": at})); - } - } + _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), + _ => {} } } if resp.double_clicked() @@ -1711,6 +1638,153 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } } +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; + let params = if extend && s.edit.selected_tracks.contains(&track) { + let r = if at < sel.start { Range::new(at, sel.end) } else { Range::new(sel.start, at) }; + json!({"start": r.start, "end": r.end}) + } else if extend && !s.edit.selected_tracks.is_empty() { + let tracks: Vec = s.edit.selected_tracks.iter().chain([&track]).map(|t| t.0).collect(); + json!({"tracks": tracks, "start": sel.start.min(at), "end": sel.end.max(at)}) + } else { + json!({"tracks": [track.0], "start": at}) + }; + let rev = app.engine.revision; + let _ = app.run("edit.select", params); + if app.engine.revision != rev { + ui.ctx().request_discard("insertion point moved"); + } +} + +/// Runs before anything is painted, against the last frame's layout. +fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { + if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { + return; + } + let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline)); + let p = ctx.pointer_latest_pos(); + if !ctx.input(|i| i.pointer.any_down()) { + finish_drag(app, &s, tl, p); + return; + } + let Some(p) = p else { return }; + let at = sample_at(&s, tl, p.x).max(0); + let inside = pos2(p.x.max(tl.min.x).min(tl.max.x), p.y.max(tl.min.y).min(tl.max.y)); + let rows = &app.edit_layout.rows; + let gap = |r: &[f32; 4]| (r[1] - inside.y).max(inside.y - r[3]).max(0.0); + let to_track = rows.iter().min_by(|a, b| gap(&a.1).total_cmp(&gap(&b.1))).map(|(i, _)| TrackId(*i)); + match &mut app.gesture { + Some(Gesture::Select { track, tracks, .. }) => { + let row_of = |t: TrackId| rows.iter().position(|(i, _)| *i == t.0); + if let (Some(a), Some(b)) = (row_of(*track), to_track.and_then(row_of)) { + *tracks = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| TrackId(*i)).collect(); + } + } + Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => { + let raw = at - *grab_at; + *delta = if s.edit.edit_mode.is_grid() { + let base = s.track(*from_track).and_then(|t| clip_at(t, at)).map_or(*grab_at, |c| c.start); + snap(&s, base + raw) - base + } else { + raw + }; + if s.edit.edit_mode == soundcraft_model::EditMode::Spot { + *delta = 0; + } + if let Some(t2) = to_track { + *tt = t2; + } + } + Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), + Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::Scrub { last }) => { + *last = at; + let _ = app.engine.execute("transport.locate", &json!({"at": at})); + } + Some(Gesture::Pencil { track, points }) => { + if let Some(t) = s.track(*track) + && let Some(param) = AutoParam::parse(&t.view) + && let Some((_, row)) = rows.iter().find(|(i, _)| *i == t.id.0) + { + let lane = lane_rect(t, rect(*row), tl); + let (lo, hi, _) = param.range(); + let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { + (-60.0, 12.0) + } else if matches!(param, AutoParam::Plugin { .. }) { + plugin_range(t, ¶m) + } else { + (lo, hi) + }; + let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); + if points.last().is_none_or(|(a, _)| (at - a).abs() as f64 > s.edit.zoom.samples_per_px * 3.0) { + points.push((at, lo + (hi - lo) * k)); + } + } + } + _ => {} + } + if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { + let r = Range::new(*anchor, snap(&s, at)); + if *tracks != s.edit.selected_tracks || r != s.edit.selection { + let ids: Vec = tracks.iter().map(|x| x.0).collect(); + let _ = app.run("edit.select", json!({"tracks": ids, "start": r.start, "end": r.end})); + } + } + let out = p - inside; + if out != egui::Vec2::ZERO && matches!(app.gesture, Some(Gesture::Select { .. } | Gesture::MoveClips { .. })) { + let k = 12.0 * ctx.input(|i| i.stable_dt).min(0.1); + if out.x != 0.0 { + let _ = app.engine.execute("view.scroll", &json!({"by_px": out.x * k})); + } + app.edit_layout.scroll_by(out.y * k, tl.height()); + ctx.request_repaint(); + } +} + +fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { + match app.gesture.take() { + Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { + let ids: Vec = clips.iter().map(|c| c.0).collect(); + let mut params = json!({"clips": ids, "by": delta}); + if to_track != from_track { + params["track"] = json!(to_track.0); + } + if delta != 0 || to_track != from_track { + let _ = app.run("edit.move_clips", params); + } + } + Some(Gesture::Fade { clip, track, fade_in, to }) => { + if let Some((_, c)) = s.find_clip(clip) { + // Not clamp: it panics on a zero-length clip. + let (a, b) = if fade_in { (c.start, to.max(c.start + 1).min(c.end())) } else { (to.max(c.start).min(c.end() - 1), c.end()) }; + let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); + } + } + Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), + Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), + Some(Gesture::Pencil { track, points }) => { + if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) { + let pid = automation_id(¶m); + for (a, v) in points { + let _ = app.run("automation.set_point", json!({"track": track.0, "param": pid, "at": a, "value": v})); + } + } + } + Some(Gesture::ZoomBox { start }) => { + let Some(p) = p else { return }; + let a = sample_at(s, tl, start.min(p.x)); + let b = sample_at(s, tl, start.max(p.x)); + if b > a { + let spp = ((b - a) as f64 / f64::from(tl.width())).max(0.25); + let _ = app.run("view.zoom_set", json!({"samples_per_px": spp})); + let _ = app.run("view.scroll", json!({"to": a})); + } + } + _ => {} + } +} + fn automation_id(p: &AutoParam) -> String { match p { AutoParam::Volume => "volume".into(), @@ -1840,15 +1914,25 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } } else if !app.is_playing() { let x = x_of(s, tl, sel.start); - let blink = (ui.input(|i| i.time) * 2.0) as i64 % 2 == 0; - if blink { - for (_, r) in &app.edit_layout.rows { - if s.edit.selected_tracks.iter().any(|_| true) { + let now = ui.input(|i| i.time); + let (id, key) = (ui.id().with("insertion_blink"), (sel.start, s.edit.selected_tracks.clone())); + let since = ui.ctx().data_mut(|d| { + let (last, since) = d.get_temp_mut_or_insert_with(id, || (key.clone(), now)); + if *last != key { + *last = key; + *since = now; + } + *since + }); + let phase = (now - since) * 2.0; + if (phase as i64) % 2 == 0 { + for (id, r) in &app.edit_layout.rows { + if s.edit.selected_tracks.contains(&TrackId(*id)) { painter.line_segment([pos2(x, r[1].max(tl.min.y)), pos2(x, r[3])], Stroke::new(1.0, t.insertion)); } } } - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + ui.ctx().request_repaint_after_secs(((1.0 - phase.fract()) / 2.0) as f32); } if app.is_playing() { let x = x_of(s, tl, app.position()); @@ -1906,7 +1990,8 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { #[cfg(test)] mod tests { - use super::follow_update; + use super::*; + use egui_kittest::Harness; #[test] fn follow_jumps_to_offscreen_playhead_while_playing() { @@ -1946,4 +2031,415 @@ mod tests { assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None)); assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None)); } + + fn harness(n: usize) -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"count": n})).unwrap(); + harness_with(engine) + } + + fn harness_with(engine: soundcraft_engine::Engine) -> Harness<'static, SoundApp> { + let app = SoundApp::new(engine, None, crate::Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(4); + h + } + + fn smart_demo() -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.execute("edit.tool", &json!({"tool": "smart"})).unwrap(); + engine.execute("edit.mode", &json!({"mode": "slip"})).unwrap(); + harness_with(engine) + } + + fn row(h: &Harness, i: usize) -> Rect { + rect(h.state().edit_layout.rows[i].1) + } + + fn row_ids(h: &Harness) -> Vec { + h.state().edit_layout.rows.iter().map(|(i, _)| TrackId(*i)).collect() + } + + fn timeline(h: &Harness) -> Rect { + rect(h.state().edit_layout.timeline) + } + + fn lane_point(h: &Harness, i: usize) -> Pos2 { + pos2(timeline(h).min.x + 100.0, row(h, i).min.y + 10.0) + } + + fn clip_on_screen(h: &Harness, ok: impl Fn(f32, f32) -> bool) -> (Clip, Rect) { + let (s, tl) = (h.state().engine.session(), timeline(h)); + h.state() + .edit_layout + .rows + .iter() + .filter_map(|(id, r)| s.track(TrackId(*id)).map(|t| (t, r))) + .find_map(|(t, r)| { + let c = t.clips().iter().find(|c| ok(x_of(s, tl, c.start), x_of(s, tl, c.end())))?; + Some((c.clone(), lane_rect(t, rect(*r), tl))) + }) + .expect("no such clip on screen") + } + + fn start_drag(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.hover_at(at + vec2(20.0, 0.0)); + h.step(); + } + + fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + h.hover_at(from); + h.drag_at(from); + for k in 1..=8 { + h.hover_at(from + (to - from) * (k as f32 / 8.0)); + } + h.drop_at(to); + h.step(); + } + + fn quick_drag(h: &mut Harness, at: Pos2, to: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.hover_at(at + (to - at) * 0.75); + h.hover_at(to); + h.drop_at(to); + h.step(); + } + + fn click(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.step(); + } + + fn click_with(h: &mut Harness, at: Pos2, mods: egui::Modifiers) { + h.event(egui::Event::ModifiersChanged(mods)); + click(h, at); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + + fn painted_rows(h: &Harness, y_of: impl Fn(&Shape) -> Option) -> Vec { + let rows = &h.state().edit_layout.rows; + let mut out: Vec = h + .output() + .shapes + .iter() + .filter_map(|c| y_of(&c.shape)) + .filter_map(|y| rows.iter().position(|(_, r)| (r[1]..=r[3]).contains(&y))) + .collect(); + out.sort_unstable(); + out.dedup(); + out + } + + fn highlighted_rows(h: &Harness) -> Vec { + painted_rows(h, |shape| match shape { + Shape::Rect(r) if r.fill == Tokens::current().selection => Some(r.rect.center().y), + _ => None, + }) + } + + fn insertion_rows(h: &Harness) -> Vec { + painted_rows(h, |shape| match shape { + Shape::LineSegment { points, stroke } if stroke.color == Tokens::current().insertion => Some((points[0].y + points[1].y) / 2.0), + _ => None, + }) + } + + #[test] + fn drag_selection_spans_the_rows_from_anchor_to_pointer() { + let mut h = harness(4); + let ids = row_ids(&h); + for (from, to) in [(3, 2), (0, 1), (2, 0), (1, 3)] { + let (a, b) = (lane_point(&h, from), lane_point(&h, to) + vec2(80.0, 0.0)); + drag(&mut h, a, b); + let want = ids.get(from.min(to)..=from.max(to)).unwrap(); + assert_eq!(h.state().engine.session().edit.selected_tracks, want, "drag from row {from} to row {to}"); + } + } + + #[test] + fn the_frame_a_drag_reaches_a_row_paints_it_selected() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 2) + vec2(60.0, 0.0)); + start_drag(&mut h, a); + h.hover_at(b); + h.step(); + assert_eq!(highlighted_rows(&h), [2, 3]); + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_paints_each_frame_once() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 0) + vec2(300.0, 0.0)); + start_drag(&mut h, a); + for k in 1..=10 { + h.hover_at(a + (b - a) * (k as f32 / 10.0)); + h.step(); + assert_eq!(h.output().platform_output.num_completed_passes, 1, "move {k}"); + } + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_frame_changes_the_document_once() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 3), lane_point(&h, 2) + vec2(60.0, 0.0)); + start_drag(&mut h, a); + let rev = h.state().engine.revision; + h.hover_at(b); + h.step(); + assert_eq!(h.state().engine.revision, rev + 1); + h.drop_at(b); + h.step(); + } + + #[test] + fn a_drag_selection_starts_where_the_button_went_down() { + let mut h = harness(4); + let a = lane_point(&h, 1); + drag(&mut h, a, a + vec2(200.0, 0.0)); + let s = h.state().engine.session(); + assert_eq!(s.edit.selection.start, sample_at(s, timeline(&h), a.x)); + } + + #[test] + fn pressing_in_a_lane_places_the_insertion_point_before_release() { + let mut h = harness(4); + let a = lane_point(&h, 2); + h.hover_at(a); + h.drag_at(a); + h.step(); + let s = h.state().engine.session(); + assert_eq!(s.edit.selection, Range::point(sample_at(s, timeline(&h), a.x))); + assert_eq!(s.edit.selected_tracks, row_ids(&h)[2..3]); + h.drop_at(a); + h.step(); + } + + #[test] + fn a_moved_insertion_point_shows_at_once_even_mid_blink() { + let mut h = harness(4); + let (a, b) = (lane_point(&h, 0), lane_point(&h, 2) + vec2(150.0, 0.0)); + click(&mut h, a); + for _ in 0..90 { + if insertion_rows(&h).is_empty() { + break; + } + h.step(); + } + assert!(insertion_rows(&h).is_empty(), "the insertion point never blinked off"); + click(&mut h, b); + assert!(!insertion_rows(&h).is_empty()); + } + + #[test] + fn the_insertion_point_shows_only_on_selected_tracks() { + let mut h = harness(4); + let a = lane_point(&h, 2); + click(&mut h, a); + assert_eq!(insertion_rows(&h), [2]); + } + + #[test] + fn shift_click_adds_another_track_to_the_selection() { + let mut h = harness(4); + let ids = row_ids(&h); + let (a, b, c) = (lane_point(&h, 0), lane_point(&h, 2) + vec2(100.0, 0.0), lane_point(&h, 3) + vec2(300.0, 0.0)); + drag(&mut h, a, a + vec2(200.0, 0.0)); + let sel = h.state().engine.session().edit.selection; + click_with(&mut h, b, egui::Modifiers::SHIFT); + assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]]); + assert_eq!(h.state().engine.session().edit.selection, sel); + click_with(&mut h, c, egui::Modifiers::SHIFT); + let s = h.state().engine.session(); + assert_eq!(s.edit.selected_tracks, [ids[0], ids[2], ids[3]]); + assert_eq!(s.edit.selection, Range::new(sel.start, sample_at(s, timeline(&h), c.x))); + assert_eq!(highlighted_rows(&h), [0, 2, 3]); + } + + #[test] + fn shift_and_cmd_click_on_track_names_extend_the_selection() { + let mut h = harness(5); + let ids = row_ids(&h); + let (none, shift, cmd) = (egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND); + for (i, mods, want) in [(1, none, vec![1]), (3, shift, vec![1, 2, 3]), (2, cmd, vec![1, 3]), (0, cmd, vec![0, 1, 3]), (4, none, vec![4])] { + let at = row(&h, i).min + vec2(60.0, 10.0); + click_with(&mut h, at, mods); + let want: Vec = want.into_iter().map(|k| ids[k]).collect(); + assert_eq!(h.state().engine.session().edit.selected_tracks, want, "row {i} clicked with {mods:?}"); + } + } + + #[test] + fn a_quick_drag_from_a_clip_edge_trims_it() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xe > tl.min.x + 100.0 && xe < tl.max.x - 20.0 && xe - xs > 100.0); + let press = pos2(x_of(h.state().engine.session(), tl, clip.end()) - 2.0, lane.min.y + lane.height() * 0.75); + quick_drag(&mut h, press, press - vec2(40.0, 0.0)); + let after = h.state().engine.session().find_clip(clip.id).unwrap().1.clone(); + assert_eq!(after.start, clip.start, "the clip moved instead of being trimmed"); + assert!(after.end() < clip.end(), "the clip end did not move"); + } + + #[test] + fn a_quick_clip_drag_keeps_the_clip_under_the_pointer() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xe - xs > 120.0 && (xs + xe) / 2.0 > tl.min.x + 60.0 && (xs + xe) / 2.0 < tl.max.x - 60.0); + let s = h.state().engine.session(); + let grab = pos2((x_of(s, tl, clip.start) + x_of(s, tl, clip.end())) / 2.0, lane.min.y + lane.height() * 0.75); + let by = sample_at(s, tl, grab.x + 40.0) - sample_at(s, tl, grab.x); + quick_drag(&mut h, grab, grab + vec2(40.0, 0.0)); + let after = h.state().engine.session().find_clip(clip.id).unwrap().1.clone(); + assert_eq!(after.start - clip.start, by, "the clip trails the pointer"); + } + + #[test] + fn a_fade_drag_survives_its_clip_shrinking_to_nothing() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 20.0 && xe - xs > 100.0); + let corner = pos2(x_of(h.state().engine.session(), tl, clip.start) + 6.0, lane.min.y + 4.0); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { .. }))); + if let Some(c) = h.state_mut().engine.session_mut().find_clip_mut(clip.id) { + c.length = 0; + } + h.drop_at(corner + vec2(20.0, 0.0)); + h.step(); + assert!(h.state().gesture.is_none()); + } + + #[test] + fn a_drag_keeps_going_when_its_row_scrolls_away() { + let mut h = harness(20); + let (ids, a) = (row_ids(&h), lane_point(&h, 0)); + start_drag(&mut h, a); + h.state_mut().edit_layout.scroll_y = 500.0; + h.step(); + assert!(row(&h, 0).max.y < timeline(&h).min.y, "the first row is still on screen"); + let b = lane_point(&h, 8); + h.hover_at(b); + h.drop_at(b); + h.step(); + assert_eq!(h.state().engine.session().edit.selected_tracks, ids[..=8]); + assert!(h.state().gesture.is_none(), "the drag outlived the release"); + } + + #[test] + fn a_gesture_left_over_after_the_release_is_dropped() { + let mut h = harness(1); + h.state_mut().gesture = Some(Gesture::Scrub { last: 0 }); + h.step(); + assert!(h.state().gesture.is_none()); + } + + #[test] + fn a_flick_past_the_last_track_selects_down_to_it() { + let mut h = harness(4); + let ids = row_ids(&h); + let a = lane_point(&h, 1); + let below = pos2(a.x + 60.0, row(&h, 3).max.y + 100.0); + start_drag(&mut h, a); + h.hover_at(below); + h.drop_at(below); + h.step(); + assert_eq!(h.state().engine.session().edit.selected_tracks, ids[1..]); + } + + #[test] + fn dragging_past_the_bottom_scrolls_and_keeps_selecting() { + let mut h = harness(20); + let first = row_ids(&h)[0]; + let a = lane_point(&h, 0); + let below = pos2(a.x + 60.0, timeline(&h).max.y + 40.0); + start_drag(&mut h, a); + h.hover_at(below); + h.step(); + h.run_steps(60); + assert!(h.state().edit_layout.scroll_y > 300.0, "scrolled {} px", h.state().edit_layout.scroll_y); + h.drop_at(below); + h.step(); + let tl = timeline(&h); + let last_seen = h.state().edit_layout.rows.iter().filter(|(_, r)| r[1] < tl.max.y).count(); + let sel = &h.state().engine.session().edit.selected_tracks; + assert_eq!((sel.first(), sel.len()), (Some(&first), last_seen)); + } + + #[test] + fn dragging_past_the_right_edge_scrolls_the_timeline() { + let mut h = harness(2); + let a = lane_point(&h, 0); + let right = pos2(timeline(&h).max.x + 40.0, a.y); + let s = h.state().engine.session(); + let (before, pointed) = (s.edit.zoom.scroll, sample_at(s, timeline(&h), right.x)); + start_drag(&mut h, a); + h.hover_at(right); + h.step(); + h.run_steps(30); + h.drop_at(right); + h.step(); + let s = h.state().engine.session(); + assert!(s.edit.zoom.scroll > before, "the timeline did not scroll"); + assert!(s.edit.selection.end > pointed, "the selection did not follow the scroll"); + } + + #[test] + fn a_redrawn_frame_counts_once_in_the_frame_time() { + let mut h = harness(4); + let mut t = 1.0; + let mut frame = |h: &mut Harness| { + t += 1.0 / 60.0; + h.input_mut().time = Some(t); + h.step(); + }; + for _ in 0..120 { + frame(&mut h); + } + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + for k in 0..30 { + let at = lane_point(&h, k % 4) + vec2(k as f32 * 7.0, 0.0); + h.hover_at(at); + frame(&mut h); + h.event(button(at, true)); + frame(&mut h); + h.event(button(at, false)); + frame(&mut h); + } + let ms = h.state().frame_ms; + assert!((ms - 1000.0 / 60.0).abs() < 0.5, "{ms} ms/frame"); + } + + #[test] + fn dragging_a_name_plate_down_drops_it_where_released() { + let mut h = harness(4); + let order = |h: &Harness| -> Vec { h.state().engine.session().tracks.iter().map(|t| t.id).collect() }; + let before = order(&h); + let grab = row(&h, 0).min + vec2(40.0, 12.0); + let release = pos2(grab.x, row(&h, 2).min.y); + drag(&mut h, grab, release); + let want: Vec = [1, 0, 2, 3].iter().map(|&i| before[i]).collect(); + assert_eq!(order(&h), want); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..fede065 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -731,6 +731,9 @@ impl SoundApp { /// Per-frame logic: control channel, transport, document sync. pub fn logic(&mut self, ctx: &egui::Context) { + if ctx.current_pass_index() > 0 { + return; + } // Fonts set now take effect next frame, so draw only from the frame after. if !self.fonts_ready { if self.fonts_installed { @@ -747,8 +750,7 @@ impl SoundApp { self.theme_applied = Some(light); } let now = ctx.input(|i| i.time); - let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); - self.last_frame = Some(now); + let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; control::drain(self, ctx); self.apply_editor_edits(); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 20591fe..d3e5afa 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -391,7 +391,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark); let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click()); if nresp.clicked() { - let _ = app.run("edit.select", json!({"tracks": [id.0]})); + crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video); } if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 33c4324..667aa31 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -37,7 +37,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color)); ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim }); if resp.clicked() { - let _ = app.run("edit.select", json!({"tracks": [id]})); + crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true); } } }); From 3055cbb0c07263d71517d708694410b25c8b49c4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:37:56 -0400 Subject: [PATCH 032/137] Measure the #152 regression test past parameter smoothing Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index df4f129..acb1a98 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1837,10 +1837,15 @@ mod tests { #[test] fn stored_instrument_params_reach_the_plugin() { // #152: a saved instrument level of -12 dB must render quieter than -6 dB. - let peak = |db: f32| render_range(&synth_session(db), Range::new(0, 12_000), 512)[0].iter().fold(0.0f32, |m, x| m.max(x.abs())); - let (loud, quiet) = (peak(-6.0), peak(-12.0)); + // RMS over the held part of the note, past any parameter smoothing. + let rms = |db: f32| { + let out = render_range(&synth_session(db), Range::new(0, 12_000), 512); + let tail = out[0].get(6_000..).unwrap_or_default(); + (tail.iter().map(|x| x * x).sum::() / tail.len().max(1) as f32).sqrt() + }; + let (loud, quiet) = (rms(-6.0), rms(-12.0)); assert!(loud > 0.0, "the synth should sound"); - assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the peak: {quiet} / {loud}"); + assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}"); } #[test] From b01abf6dd72955b04ca63c0634b203ed47410c82 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:43:27 -0400 Subject: [PATCH 033/137] rustfmt the mix.send spec Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mix.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index d77e12b..1b9b56d 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -105,7 +105,15 @@ pub fn specs() -> Vec { insert_params ), cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move), - cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}", has_selection, send), + cmd!( + "mix.send", + "Assign Send", + [], + None, + "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}", + has_selection, + send + ), cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| { let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?; if let Some(tr) = e.session_mut().track_mut(t) From f855836af103eab9b83c02ce9f7c5f49321b3c84 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:51:00 -0400 Subject: [PATCH 034/137] Idle test: playing repaints at the adaptive meter rate from #207 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/idle.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/idle.rs b/crates/ui-egui/tests/idle.rs index be7cfa7..fcdc6f4 100644 --- a/crates/ui-egui/tests/idle.rs +++ b/crates/ui-egui/tests/idle.rs @@ -21,7 +21,8 @@ fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() { } assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO); app.run("transport.play", serde_json::json!({})).unwrap(); - assert_eq!(pass(&ctx, &mut app, true), Duration::ZERO); + // Playing repaints at the meter rate (30 Hz in the Edit window, #207). + assert!(pass(&ctx, &mut app, true) <= Duration::from_millis(34)); pass(&ctx, &mut app, false); assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3); } From 2ec269833114dc11aa3936bff20d2ed6da2b02b6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:56:37 -0400 Subject: [PATCH 035/137] Grid moves snap the grabbed clip, not whatever is under the pointer now Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 38698c3..1b5163b 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1684,7 +1684,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => { let raw = at - *grab_at; *delta = if s.edit.edit_mode.is_grid() { - let base = s.track(*from_track).and_then(|t| clip_at(t, at)).map_or(*grab_at, |c| c.start); + let base = s.track(*from_track).and_then(|t| clip_at(t, *grab_at)).map_or(*grab_at, |c| c.start); snap(&s, base + raw) - base } else { raw From 092b3cc890f6a76427352bb6bfa54c4975a14d1f Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:02:56 +0200 Subject: [PATCH 036/137] Consolidate covers the whole edit selection Consolidate clamped its range to the track's last clip end plus one second, so on an empty track a 0-5 s selection made a 1 s clip and a 2-7 s selection made nothing. It now renders, clears and places exactly the selection, and refuses a selection too long to render instead of placing a shorter clip. Prepare DPE keeps the old per-track bound. Fixes #164 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/setup_more.rs | 4 +- crates/engine/src/io.rs | 23 ++-- crates/engine/tests/consolidate_selection.rs | 124 +++++++++++++++++++ 4 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/consolidate_selection.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..d904ea2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -759,7 +759,7 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { } let mut n = 0; for t in tracks { - if crate::io::consolidate(e, t, r)? { + if crate::io::consolidate(e, "edit.consolidate", t, r)? { n += 1; } } diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 7a936af..4e952a9 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result { let r = soundcraft_time::Range::new(0, end); let mut n = 0; for t in tracks { - if crate::io::consolidate(e, t, r)? { + // Each track keeps its own length (its material plus a second), as before. + let tr = crate::io::bounded_to_track(e.session(), t, r); + if crate::io::consolidate(e, id, t, tr)? { n += 1; } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..0076e28 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -387,18 +387,27 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S keep.len() } -/// Consolidate the selection on a track into one new clip backed by a rendered source. -pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { +/// 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. +pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result { let s = e.session(); - if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) { + let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) }; + let start = r.start.max(0); + let len = r.end.saturating_sub(start); + if len <= 0 { return Ok(false); } - let r = bounded_to_track(s, t, r); - if r.is_empty() { - return Ok(false); + let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1); + if usize::try_from(len).map_or(true, |n| n > max) { + let secs = max as f64 / s.sample_rate.as_f64(); + return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name))); } + let r = Range::new(start, start + len); let audio = soundcraft_mix::render_clips(s, t, r); - let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str())); + if audio.iter().any(|ch| ch.len() as i64 != len) { + return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into())); + } + let name = format!("{}_consolidated", tr.name); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; let s = e.session_mut(); let src = add_source(s, &name, buf, None, FileFormat::Wav); diff --git a/crates/engine/tests/consolidate_selection.rs b/crates/engine/tests/consolidate_selection.rs new file mode 100644 index 0000000..39af2f7 --- /dev/null +++ b/crates/engine/tests/consolidate_selection.rs @@ -0,0 +1,124 @@ +//! Consolidate makes one clip per track that covers exactly the edit selection (#164). + +use serde_json::json; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_engine::{Engine, EngineError}; +use soundcraft_model::{Clip, TrackId}; +use soundcraft_time::Range; + +fn stereo_tracks(n: u64) -> Engine { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": n, "format": "stereo"})).unwrap(); + e.session_mut().edit.selected_tracks = e.session().tracks.iter().map(|t| t.id).collect(); + e +} + +fn sr(e: &Engine) -> i64 { + e.session().sample_rate.samples(1.0) +} + +/// Put a constant-level clip of `secs` seconds at the start of track `i`. +fn add_clip(e: &mut Engine, i: usize, secs: i64) -> TrackId { + let frames = usize::try_from(secs * sr(e)).unwrap(); + let t = e.session().tracks[i].id; + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; frames]; 2] }; + let src = soundcraft_engine::io::add_source(e.session_mut(), "tone", buf, None, FileFormat::Wav); + let id = e.session_mut().new_clip_id(); + soundcraft_engine::edit::place_clip(e.session_mut(), t, Clip::audio(id, "tone", src, 0, 0, frames as i64)); + t +} + +fn clips(e: &Engine, i: usize) -> Vec<(i64, i64)> { + e.session().tracks[i].clips().iter().map(|c| (c.start, c.end())).collect() +} + +/// Frames in the source behind the only clip on track `i`, and its first sample. +fn source_of_only_clip(e: &Engine, i: usize) -> (usize, f32) { + let s = e.session(); + let c = &s.tracks[i].clips()[0]; + let audio = s.pool.get(c.source().unwrap()).unwrap(); + (audio.buffer.frames(), audio.buffer.channels[0][0]) +} + +fn assert_each_track_has(e: &Engine, start: i64, end: i64) { + for (i, t) in e.session().tracks.iter().enumerate() { + assert_eq!(clips(e, i), vec![(start, end)], "track {}", t.name); + assert_eq!(source_of_only_clip(e, i).0 as i64, end - start, "track {}", t.name); + } +} + +#[test] +fn consolidating_silence_from_zero_covers_the_whole_selection() { + let mut e = stereo_tracks(5); + let sr = sr(&e); + e.session_mut().edit.selection = Range::new(0, 5 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 0, 5 * sr); +} + +#[test] +fn consolidating_silence_later_in_the_timeline_covers_the_whole_selection() { + let mut e = stereo_tracks(5); + let sr = sr(&e); + e.session_mut().edit.selection = Range::new(2 * sr, 7 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 2 * sr, 7 * sr); +} + +#[test] +fn consolidating_past_the_end_of_a_clip_keeps_the_trailing_silence() { + let mut e = stereo_tracks(1); + let sr = sr(&e); + add_clip(&mut e, 0, 1); + e.session_mut().edit.selection = Range::new(0, 5 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 0, 5 * sr); + assert_eq!(source_of_only_clip(&e, 0).1, 0.25); +} + +#[test] +fn multi_track_consolidate_is_one_undo_step_and_fills_empty_tracks() { + let mut e = stereo_tracks(3); + let sr = sr(&e); + add_clip(&mut e, 1, 2); + let before = e.session().clone(); + let undo_depth = e.undo_history().len(); + e.session_mut().edit.selection = Range::new(sr, 4 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + // The clip on track 2 is split at the selection start; the head stays put. + assert_eq!(clips(&e, 0), vec![(sr, 4 * sr)]); + assert_eq!(clips(&e, 1), vec![(0, sr), (sr, 4 * sr)]); + assert_eq!(clips(&e, 2), vec![(sr, 4 * sr)]); + assert_eq!(e.undo_history().len(), undo_depth + 1); + assert!(e.undo()); + for (a, b) in e.session().tracks.iter().zip(before.tracks.iter()) { + assert_eq!(a.clips(), b.clips(), "track {}", a.name); + } +} + +#[test] +fn a_selection_too_long_to_render_is_refused_not_shortened() { + let mut e = stereo_tracks(2); + add_clip(&mut e, 0, 1); + let before: Vec<_> = (0..2).map(|i| clips(&e, i)).collect(); + // A stereo track renders at most MAX_RENDER_SAMPLES / 2 frames (about 93 minutes at 48 kHz). + let max = i64::try_from(soundcraft_mix::MAX_RENDER_SAMPLES / 2).unwrap(); + let err = e.execute("edit.consolidate", &json!({"start": 0, "end": max + 1})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams(..)), "{err}"); + assert!(err.to_string().contains("too long"), "{err}"); + let after: Vec<_> = (0..2).map(|i| clips(&e, i)).collect(); + assert_eq!(after, before); +} + +#[test] +fn prepare_dpe_still_consolidates_each_track_to_its_own_material() { + let mut e = stereo_tracks(2); + let sr = sr(&e); + let a = add_clip(&mut e, 0, 1); + let b = add_clip(&mut e, 1, 3); + e.execute("options.prepare_dpe_tracks", &json!({"tracks": [a.0, b.0]})).unwrap(); + // Unchanged from before #164: each track runs to its last clip plus a second, capped at the session end. + assert_eq!(clips(&e, 0), vec![(0, 2 * sr)]); + assert_eq!(clips(&e, 1), vec![(0, 3 * sr)]); + assert_eq!(source_of_only_clip(&e, 0), (2 * sr as usize, 0.25)); +} From cfce845f706219cd24cc6ca63fec4723a8476995 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:52:59 +0200 Subject: [PATCH 037/137] Saturate the bar offset for bars before 1 (#249) bar_start_tick subtracted 1 from the bar before its saturating multiply, so the minimum i64 bar panicked under overflow checks. Parsing "-9223372036854775808|01|000" as Bars|Beats now returns a saturated negative position instead. --- crates/time/src/format.rs | 9 +++++++++ crates/time/src/tempo.rs | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index a5f4a1a..b595031 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -143,6 +143,15 @@ mod tests { assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345"); } + #[test] + fn minimum_bar_parses_without_overflow() { + let (sr, map, rate) = ctx(); + let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0); + assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0)); + assert_eq!(p("2|3|000").ok(), Some(144_000)); + assert_eq!(p("0|1|000").ok(), Some(-96_000)); + } + #[test] fn parse_round_trips() { let (sr, map, rate) = ctx(); diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 5b87a3a..b9948f9 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -200,7 +200,7 @@ impl TempoMap { let mut cur_tick = 0i64; let first = self.meter_at_tick(0); if bar < 1 { - return (bar - 1).saturating_mul(first.ticks_per_bar()); + return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar()); } for (i, m) in self.meters.iter().enumerate() { let next_tick = self.meters.get(i + 1).map(|n| n.tick); @@ -288,6 +288,13 @@ mod tests { assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 }); } + #[test] + fn minimum_bar_saturates_instead_of_overflowing() { + let m = TempoMap::default(); + assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN); + assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4); + } + #[test] fn tempo_change_is_integrated() { let mut m = TempoMap::default(); From e1e15cf3abc00a91b813d06a10825cc2f8d185d1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:53:51 +0200 Subject: [PATCH 038/137] Compute timecode frame counts in i128 and clamp (#248) Timecode::to_frames multiplied caller-supplied hours in i64 without a check, so "9223372036854775807:00:00:00" panicked under overflow checks and wrapped without them. The count is now computed in i128 and clamped to i64, and samples_at_frame clamps instead of returning 0 on overflow, so huge labels saturate toward the right end of the timeline. --- crates/time/src/timecode.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs index c0db20f..6b3b0f3 100644 --- a/crates/time/src/timecode.rs +++ b/crates/time/src/timecode.rs @@ -109,7 +109,7 @@ impl FrameRate { return 0; } let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); - i64::try_from(v).unwrap_or(0) + i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) } /// Samples per frame (fractional). @@ -153,13 +153,14 @@ impl Timecode { /// Absolute frame count of this label. pub fn to_frames(&self, rate: FrameRate) -> i64 { let base = rate.nominal().max(1); - let total_min = self.hours * 60 + self.minutes; - let mut f = (total_min * 60 + self.seconds) * base + self.frames; + let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes); + let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames); if rate.is_drop() { let drop = if base >= 60 { 4 } else { 2 }; f -= drop * (total_min - total_min / 10); } - if self.negative { -f } else { f } + let f = if self.negative { -f } else { f }; + i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX }) } pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode { @@ -233,6 +234,19 @@ mod tests { } } + #[test] + fn huge_timecode_saturates_instead_of_overflowing() { + let sr = SampleRate::HZ_48000; + for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] { + let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MAX); + let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MIN); + } + let tc = Timecode::parse("01:02:03:04").unwrap(); + assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400); + } + #[test] fn parse_variants() { let tc = Timecode::parse("01:02:03:04").unwrap(); From 9f1a62470db844e971df353d596bdb2f8f84d29d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:54:26 +0200 Subject: [PATCH 039/137] Reject invalid --rate and --bit-depth in convert (#256) convert ignored a --rate that did not parse and mapped any unknown --bit-depth to 24-bit, then reported success. Both options are now validated before the input is read; an invalid value prints which option was wrong and exits non-zero without writing output. Omitted options keep their defaults. --- apps/soundcraft-cli/src/main.rs | 46 ++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index bb7628a..028b06c 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -114,6 +114,14 @@ fn info(args: &[String]) -> ExitCode { fn convert(args: &[String]) -> ExitCode { let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") }; + let rate = match parse_rate(arg_value(args, "--rate").as_deref()) { + Ok(r) => r, + Err(e) => return fail(e), + }; + let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) { + Ok(b) => b, + Err(e) => return fail(e), + }; let bytes = match std::fs::read(input) { Ok(b) => b, Err(e) => return fail(format!("{input}: {e}")), @@ -123,7 +131,7 @@ fn convert(args: &[String]) -> ExitCode { Ok(x) => x, Err(e) => return fail(format!("{input}: {e}")), }; - if let Some(rate) = arg_value(args, "--rate").and_then(|r| r.parse::().ok()) { + if let Some(rate) = rate { buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate); buf.sample_rate = rate; } @@ -134,11 +142,6 @@ fn convert(args: &[String]) -> ExitCode { let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else { return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)); }; - let bit_depth = match arg_value(args, "--bit-depth").as_deref() { - Some("16") => soundcraft_audio_io::BitDepth::Int16, - Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32, - _ => soundcraft_audio_io::BitDepth::Int24, - }; let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }; match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) { Ok(()) => { @@ -149,6 +152,25 @@ fn convert(args: &[String]) -> ExitCode { } } +fn parse_rate(v: Option<&str>) -> Result, String> { + match v { + None => Ok(None), + Some(r) => match r.parse::() { + Ok(hz) if hz > 0 => Ok(Some(hz)), + _ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")), + }, + } +} + +fn parse_bit_depth(v: Option<&str>) -> Result { + match v { + None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24), + Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16), + Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32), + Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")), + } +} + /// One stderr line per media file a session refers to but could not load (the exit code is unchanged). fn missing_warnings(missing: &[String]) -> Vec { missing.iter().map(|m| format!("warning: missing media: {m}")).collect() @@ -422,6 +444,18 @@ mod tests { } } + #[test] + fn convert_rejects_invalid_rate_and_bit_depth() { + assert_eq!(parse_rate(None), Ok(None)); + assert_eq!(parse_rate(Some("44100")), Ok(Some(44_100))); + assert!(parse_rate(Some("4410x")).is_err()); + assert!(parse_rate(Some("0")).is_err()); + assert!(matches!(parse_bit_depth(None), Ok(soundcraft_audio_io::BitDepth::Int24))); + assert!(matches!(parse_bit_depth(Some("16")), Ok(soundcraft_audio_io::BitDepth::Int16))); + assert!(matches!(parse_bit_depth(Some("32f")), Ok(soundcraft_audio_io::BitDepth::Float32))); + assert!(parse_bit_depth(Some("8")).is_err()); + } + #[test] fn missing_media_gets_one_warning_each() { assert!(missing_warnings(&[]).is_empty()); From faf2a1346847151157ac67abb6a24a66bbbe8f91 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:07:02 +0200 Subject: [PATCH 040/137] Add my contributor entry --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bc8bd31..97411a0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -40,3 +40,7 @@ sync_github_name = true real_name = "Vikram Bhaskaran" display_name = "Vikram Bhaskaran" sync_github_name = false + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false From 5e1c247eda4e31f6febcc26d68070808e2da22b7 Mon Sep 17 00:00:00 2001 From: TheMojoMan <17179921+TheMojoMan@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:10:00 +0200 Subject: [PATCH 041/137] fix(ui): horizontally scroll the Edit toolbar Groups clipped when the window was narrower than the toolbar; wrap the whole row in a horizontal ScrollArea so every tool stays reachable. --- crates/ui-egui/src/toolbar.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..cc9a101 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -23,18 +23,23 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.toolbar_bg); ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.spacing_mut().item_spacing.x = 8.0; - edit_modes(app, ui); - zoom_group(app, ui); - tools(app, ui); - counters(app, ui); - grid_nudge(app, ui); - transport(app, ui); - tempo_meter(app, ui); - output_meter(app, ui); - }); + egui::ScrollArea::horizontal() + .id_salt("edit_toolbar_scroll") + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 8.0; + edit_modes(app, ui); + zoom_group(app, ui); + tools(app, ui); + counters(app, ui); + grid_nudge(app, ui); + transport(app, ui); + tempo_meter(app, ui); + output_meter(app, ui); + }); + }); } fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { From 217bea462da6cb395d9f3a97d34f2670ce5f7bb2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 10:59:23 +0200 Subject: [PATCH 042/137] fix(dsp): a huge finite sample rate is no pitch, not an overflow (#241) --- crates/dsp/src/pitch_detect.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/dsp/src/pitch_detect.rs b/crates/dsp/src/pitch_detect.rs index 8271ff3..b1b2f0a 100644 --- a/crates/dsp/src/pitch_detect.rs +++ b/crates/dsp/src/pitch_detect.rs @@ -17,7 +17,7 @@ pub fn yin(frame: &[f32], sample_rate: f32, min_hz: f32, max_hz: f32, threshold: } let max_tau = ((sample_rate / min_hz.max(20.0)) as usize).min(frame.len() / 2); let min_tau = ((sample_rate / max_hz.max(min_hz + 1.0)) as usize).max(2); - if max_tau <= min_tau + 2 { + if max_tau <= min_tau.saturating_add(2) { return None; } let w = frame.len() - max_tau; @@ -140,4 +140,16 @@ mod tests { assert!(audio_to_notes(&[], f32::NAN).is_empty()); assert!(yin(&[0.0; 10], 48_000.0, 50.0, 1600.0, 0.15).is_none()); } + + #[test] + fn huge_finite_rate_is_unvoiced() { + // #241: the shortest lag saturates to usize::MAX; that is "no pitch", not an overflow. + assert!(yin(&[0.0; 64], 1.0e30, 50.0, 1600.0, 0.15).is_none()); + assert!(yin(&[0.5; 8192], f32::MAX, 50.0, 1600.0, 0.15).is_none()); + } + + #[test] + fn audio_to_notes_survives_huge_finite_rate() { + assert!(audio_to_notes(&[0.5; 8192], 1.0e30).is_empty()); + } } From 30372886cf6c603e008a78ea5cfa4bf1cee48f71 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 11:03:28 +0200 Subject: [PATCH 043/137] fix(track): vca: null clears a VCA assignment, as documented (#237) --- crates/engine/src/cmd/track.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2f231f4..685775e 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -128,7 +128,10 @@ pub fn specs() -> Vec { }), cmd!("track.vca", "Assign to VCA", [], None, "{tracks?, vca: VCA track id|name | null}", has_selection, |e, p| { let tracks = tracks_required(e, "track.vca", p)?; - let vca = track_param(e, "track.vca", p, "vca")?; + let vca = match p.get("vca") { + Some(Value::Null) => None, + _ => track_param(e, "track.vca", p, "vca")?, + }; if let Some(v) = vca && e.session().track(v).is_none_or(|t| t.kind != TrackKind::Vca) { @@ -680,4 +683,24 @@ mod tests { assert!(matches!(kick.mixer.output, Route::Bus(_))); assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output)); } + + #[test] + fn vca_null_clears_the_assignment() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"name": "Member"})).unwrap(); + e.execute("track.new", &json!({"kind": "vca", "name": "Master"})).unwrap(); + let (member, master) = (e.session().tracks[0].id, e.session().tracks[1].id); + let vca = |e: &Engine| e.session().track(member).unwrap().mixer.vca; + e.execute("track.vca", &json!({"tracks": [member.0], "vca": master.0})).unwrap(); + assert_eq!(vca(&e), Some(master.0)); + // #237: null is documented as "no VCA"; anything else that is not a VCA is still an error. + for invalid in [json!(true), json!(999), json!("Missing"), json!(member.0)] { + assert!(e.execute("track.vca", &json!({"tracks": [member.0], "vca": invalid})).is_err()); + assert_eq!(vca(&e), Some(master.0)); + } + e.execute("track.vca", &json!({"tracks": [member.0], "vca": null})).unwrap(); + assert_eq!(vca(&e), None); + assert!(e.undo()); + assert_eq!(vca(&e), Some(master.0)); + } } From df0c4790b72972cbb967d5af17228e782dbee420 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:44:38 -0400 Subject: [PATCH 044/137] Release prep: 0.6.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 1adc92d..153861a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3424,7 +3424,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.5.0" +version = "0.6.0" dependencies = [ "eframe", "egui", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "serde", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "proptest", @@ -3464,7 +3464,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "serde_json", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap-sys", "libloading", @@ -3486,7 +3486,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "proptest", @@ -3508,7 +3508,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "proptest", @@ -3530,7 +3530,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -3540,7 +3540,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "rayon", @@ -3556,7 +3556,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -3569,7 +3569,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.5.0" +version = "0.6.0" dependencies = [ "cpal", "log", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -3592,7 +3592,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "craft-ui", "egui", @@ -3618,7 +3618,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.5.0" +version = "0.6.0" dependencies = [ "image", "proptest", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.5.0" +version = "0.6.0" dependencies = [ "libloading", "log", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.5.0" +version = "0.6.0" dependencies = [ "eframe", "egui", @@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index f3c2aad..02b36ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 67158020f04f56ec26c70218838b13515e8f96cd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 11:56:06 +0200 Subject: [PATCH 045/137] fix(control): apply the engine.commands filter to UI commands (#255) --- crates/ui-egui/src/control.rs | 7 ++++- crates/ui-egui/tests/commands_filter.rs | 38 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/commands_filter.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea380e..13ac4c2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -108,8 +108,13 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Ok(v) => v, Err(e) => return Some(err(e)), }; + // Same predicate as the engine's `engine.commands`: id or label substring, ASCII case-insensitive. + let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); + let matches = |id: &str, label: &str| { + filter.as_deref().is_none_or(|f| id.to_ascii_lowercase().contains(f) || label.to_ascii_lowercase().contains(f)) + }; if let Some(arr) = list.as_array_mut() { - for (id, label, path, sc) in crate::menus::UI_COMMANDS { + for (id, label, path, sc) in crate::menus::UI_COMMANDS.iter().filter(|c| matches(c.0, c.1)) { arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"})); } } diff --git a/crates/ui-egui/tests/commands_filter.rs b/crates/ui-egui/tests/commands_filter.rs new file mode 100644 index 0000000..c0486f4 --- /dev/null +++ b/crates/ui-egui/tests/commands_filter.rs @@ -0,0 +1,38 @@ +use serde_json::{Value, json}; +use soundcraft_ui_egui::{ControlRequest, Services, SoundApp, control, menus}; + +fn commands(params: Value) -> Vec { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()).with_control(rx); + let (req, reply) = ControlRequest::new("engine.commands", params); + tx.send(req).unwrap(); + control::drain(&mut app, &egui::Context::default()); + let response = reply.try_recv().unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["result"].as_array().unwrap().clone() +} + +fn ids(rows: &[Value]) -> Vec<&str> { + rows.iter().map(|r| r["id"].as_str().unwrap()).collect() +} + +#[test] +fn filter_applies_to_ui_commands() { + assert_eq!(ids(&commands(json!({"filter": "window.search"}))), ["window.search"]); + assert_eq!(ids(&commands(json!({"filter": "WINDOW.Search"}))), ["window.search"]); + // "Arrange Panel" matches only by label, and only for ui.dock. + assert_eq!(ids(&commands(json!({"filter": "arrange PANEL"}))), ["ui.dock"]); + assert!(commands(json!({"filter": "zzzznomatch"})).is_empty()); +} + +#[test] +fn absent_or_empty_filter_lists_every_ui_command() { + for params in [json!({}), json!({"filter": ""})] { + let rows = commands(params); + let ids = ids(&rows); + for (id, ..) in menus::UI_COMMANDS { + assert!(ids.contains(id), "{id} missing"); + } + assert!(ids.contains(&"track.new")); + } +} From 4c4414d006d5af7f6af71b88d38b2a7b2e25f9ec Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 11:58:36 +0200 Subject: [PATCH 046/137] fix(window): keep the MIDI Editor open on Bring to Front (#254) --- crates/ui-egui/src/menus.rs | 6 +++- crates/ui-egui/tests/midi_front_visibility.rs | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/midi_front_visibility.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..92cfd6b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -490,7 +490,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_about), - "window.midi_editor" | "window.midi_front" => toggle(&mut app.ui.show_midi_editor), + "window.midi_editor" => toggle(&mut app.ui.show_midi_editor), + "window.midi_front" => { + app.ui.show_midi_editor = true; + json!({"value": true}) + } "window.midi_back" => { app.ui.show_midi_editor = false; json!({}) diff --git a/crates/ui-egui/tests/midi_front_visibility.rs b/crates/ui-egui/tests/midi_front_visibility.rs new file mode 100644 index 0000000..9fa1c73 --- /dev/null +++ b/crates/ui-egui/tests/midi_front_visibility.rs @@ -0,0 +1,28 @@ +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +#[test] +fn bring_to_front_keeps_an_open_midi_editor_open() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("window.midi_editor", json!({"value": true})).unwrap(); + let path = "Window > MIDI Editors > Bring to Front"; + let id = menus::command_for_path(path, &menus::ui_aliases()).unwrap(); + assert_eq!(id, "window.midi_front"); + for _ in 0..2 { + menus::invoke_menu(&mut app, &id, path); + assert!(app.ui.show_midi_editor, "Bring to Front hid the editor"); + } + + app.run("window.midi_editor", json!({"value": false})).unwrap(); + app.run("window.midi_front", json!({})).unwrap(); + assert!(app.ui.show_midi_editor, "Bring to Front did not show a hidden editor"); +} + +#[test] +fn the_midi_editor_command_still_toggles() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("window.midi_editor", json!({})).unwrap(); + assert!(app.ui.show_midi_editor); + app.run("window.midi_editor", json!({})).unwrap(); + assert!(!app.ui.show_midi_editor); +} From 7d173d3a2536e42b2f60840d700ad2bff849665f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 12:13:21 +0200 Subject: [PATCH 047/137] fix(midi-editor): a selected video clip is not a MIDI target (#258) --- crates/ui-egui/src/midi_editor.rs | 2 +- crates/ui-egui/tests/midi_panel.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index e69d625..10d7c5b 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -36,7 +36,7 @@ pub fn target_clip(app: &SoundApp) -> Option { let s = app.engine.session(); for id in &s.edit.selected_clips { if let Some((_, c)) = s.find_clip(*id) - && !c.is_audio() + && matches!(c.content, ClipContent::Midi { .. }) { return Some(*id); } diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs index 56cb12d..f9042fa 100644 --- a/crates/ui-egui/tests/midi_panel.rs +++ b/crates/ui-egui/tests/midi_panel.rs @@ -150,3 +150,23 @@ fn check_held_replacement(operation: &str) { assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset); assert_eq!(*app.engine.session(), session); } + +#[test] +fn a_selected_video_clip_is_not_a_midi_target() { + use soundcraft_model::{ChannelFormat, Clip, ClipContent, SourceId, Track, TrackKind}; + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + let midi = midi_editor::target_clip(&app).unwrap(); + let s = app.engine.session_mut(); + let track = s.add_track(TrackKind::Video, ChannelFormat::Mono, None); + let video = s.new_clip_id(); + let source = SourceId(s.alloc()); + s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(Clip::video(video, "picture", source, 0, 0, 48_000)); + + app.run("edit.select", json!({"clips": [video.0]})).unwrap(); + assert_eq!(midi_editor::target_clip(&app), None, "a video clip alone leaves the MIDI editors without a target"); + app.run("edit.select", json!({"clips": [video.0, midi.0]})).unwrap(); + assert_eq!(midi_editor::target_clip(&app), Some(midi), "the MIDI clip after a video clip is the target"); + let (_, clip) = app.engine.session().find_clip(midi).unwrap(); + assert!(matches!(clip.content, ClipContent::Midi { .. })); +} From 89ff513b78d484530d6c5f7f8448ba4b2c4dac8e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 12:15:28 +0200 Subject: [PATCH 048/137] fix(video-universe): filmstrip thumbnails use the clip's own movie time (#225) --- crates/ui-egui/src/video_window.rs | 39 ++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/video_window.rs b/crates/ui-egui/src/video_window.rs index d282b95..f6acd89 100644 --- a/crates/ui-egui/src/video_window.rs +++ b/crates/ui-egui/src/video_window.rs @@ -95,6 +95,12 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) { } } +/// Timeline position of strip coordinate `x` (the strip spans `left`..`left + width` and samples +/// 0..`end`), clamped to the start of the clip being drawn. +fn strip_position(x: f32, left: f32, width: f32, end: i64, clip_start: i64) -> i64 { + (((x - left) / width * end as f32) as i64).max(clip_start) +} + fn first_movie_aspect(s: &soundcraft_model::Session) -> Option { s.videos.first().map(|v| v.aspect() as f32) } @@ -131,8 +137,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) { let hpx = ((cr.height() * ppp / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32; let mut x = cr.min.x; while x < cr.max.x { - let at = c.start + ((x - r.min.x) / r.width() * end as f32) as i64 - c.start.min(0); - let secs = movie_secs(&s, c.start, offset, at.max(c.start)).max(0.0); + let secs = movie_secs(&s, c.start, offset, strip_position(x, r.min.x, r.width(), end, c.start)).max(0.0); if let Some(tex) = app.video.thumb(ctx, key, &v.path, secs, hpx) { let tr = Rect::from_min_size(pos2(x, cr.min.y), vec2(tw, cr.height())); let vis = tr.intersect(cr); @@ -164,3 +169,33 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_video_universe = false; } } + +#[cfg(test)] +mod tests { + use super::strip_position; + + // A 1024 px strip over 1_024_000 samples: 1000 samples per point, exact in f32. + const LEFT: f32 = 100.0; + const WIDTH: f32 = 1024.0; + const END: i64 = 1_024_000; + + #[test] + fn strip_position_is_the_timeline_position_for_clips_after_zero() { + // A clip at 48_000 has its left edge at x = 148. + assert_eq!(strip_position(148.0, LEFT, WIDTH, END, 48_000), 48_000); + assert_eq!(strip_position(248.0, LEFT, WIDTH, END, 48_000), 148_000); + let s = soundcraft_model::Session::default(); + let at = strip_position(148.0, LEFT, WIDTH, END, 48_000); + assert_eq!(crate::video_track::movie_secs(&s, 48_000, 0, at), 0.0, "the clip's left edge shows the movie's first picture"); + } + + #[test] + fn strip_position_is_unchanged_for_clips_at_or_before_zero() { + assert_eq!(strip_position(100.0, LEFT, WIDTH, END, 0), 0); + assert_eq!(strip_position(200.0, LEFT, WIDTH, END, 0), 100_000); + // A clip at -48_000 starts left of the strip; positions before it clamp to its start. + assert_eq!(strip_position(0.0, LEFT, WIDTH, END, -48_000), -48_000); + assert_eq!(strip_position(52.0, LEFT, WIDTH, END, -48_000), -48_000); + assert_eq!(strip_position(200.0, LEFT, WIDTH, END, -48_000), 100_000); + } +} From bff9ca139098188be373c6cfa49a46af603f8158 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 12:18:00 +0200 Subject: [PATCH 049/137] fix(window): Hide All Floating Windows also hides Automation (#253) --- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/tests/hide_floating.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 crates/ui-egui/tests/hide_floating.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..9a69ef0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -557,6 +557,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option Date: Mon, 12 Oct 2026 12:30:02 +0200 Subject: [PATCH 050/137] fix(edit-window): Pencil clicks on plugin lanes use the plugin's range (#224) --- crates/ui-egui/src/edit_window.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..220e4ae 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1633,7 +1633,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, Tool::Pencil => { if let Some(param) = &auto_view { let (lo, hi, _) = param.range(); - let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { (-60.0, 12.0) } else { (lo, hi) }; + let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { + (-60.0, 12.0) + } else if matches!(param, AutoParam::Plugin { .. }) { + plugin_range(track, param) + } else { + (lo, hi) + }; let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); let _ = app.run( "automation.set_point", @@ -2457,4 +2463,24 @@ mod tests { let want: Vec = [1, 0, 2, 3].iter().map(|&i| before[i]).collect(); assert_eq!(order(&h), want); } + + #[test] + fn a_pencil_click_on_a_plugin_lane_uses_the_plugin_range() { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.execute("track.view", &json!({"track": "Kick", "view": "plugin:a:input_gain"})).unwrap(); + engine.execute("edit.tool", &json!({"tool": "pencil"})).unwrap(); + let mut h = harness_with(engine); + let s = h.state().engine.session_arc(); + let kick = s.track_by_name("Kick").unwrap(); + let i = h.state().edit_layout.rows.iter().position(|(id, _)| *id == kick.id.0).unwrap(); + let lane = lane_rect(kick, row(&h, i), timeline(&h)); + // A quarter up the lane: -12 dB in the Input range of -24..24 dB. + let at = pos2(lane.min.x + 100.0, lane.max.y - 4.0 - (lane.height() - 8.0) * 0.25); + click(&mut h, at); + let s = h.state().engine.session_arc(); + let param = AutoParam::Plugin { slot: 0, param: "input_gain".into() }; + let points = s.track_by_name("Kick").and_then(|t| t.lane(¶m)).map(|l| l.points.clone()).unwrap_or_default(); + assert_eq!(points.len(), 1, "status: {}", h.state().ui.status); + assert!((points[0].value + 12.0).abs() < 0.01, "{}", points[0].value); + } } From f792eae580ada1e1ea898d273d0dbf3ebbe7ce25 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 12:32:41 +0200 Subject: [PATCH 051/137] fix(midi-editor): Quantize snaps to the grid the editor shows (#226) --- crates/ui-egui/src/midi_editor.rs | 28 ++++++++++---------- crates/ui-egui/tests/midi_quantize.rs | 38 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/midi_quantize.rs diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index e69d625..b8acb62 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -84,6 +84,19 @@ 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))); @@ -99,7 +112,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { 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": "1/16", "strength": 100}))); + op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100}))); } if let Some((id, mut p)) = op { p["clips"] = json!([cid.0]); @@ -109,19 +122,6 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let sr = s.sample_rate; let base_tick = s.tempo.samples_to_ticks(clip.start, sr); let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1); - let grid_ticks = match s.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, - }; let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0)); let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y)); let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); diff --git a/crates/ui-egui/tests/midi_quantize.rs b/crates/ui-egui/tests/midi_quantize.rs new file mode 100644 index 0000000..de9b0b3 --- /dev/null +++ b/crates/ui-egui/tests/midi_quantize.rs @@ -0,0 +1,38 @@ +//! The MIDI Editor's Quantize button snaps to the grid the editor shows. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +fn quantize_on(grid: &str) -> i64 { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"name": "Grid Check", "kind": "midi"})).unwrap(); + let clip = app.run("midi.new_clip", json!({"track": "Grid Check", "start": 0, "end": 96_000})).unwrap()["clip"].clone(); + app.run("midi.note_add", json!({"clip": clip, "pitch": 60, "start_ticks": 300, "length_ticks": 240})).unwrap(); + app.run("edit.select", json!({"clips": [clip]})).unwrap(); + app.run("view.grid", json!({"value": grid})).unwrap(); + app.ui.show_midi_editor = true; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Quantize").click(); + h.run_steps(3); + let notes = h.state_mut().run("midi.notes", json!({"clip": clip})).unwrap(); + assert_eq!(notes["notes"][0]["length"], 240, "{notes}"); + notes["notes"][0]["start"].as_i64().unwrap() +} + +#[test] +fn quantize_follows_a_quarter_note_grid() { + assert_eq!(quantize_on("1/4"), 0); +} + +#[test] +fn quantize_follows_a_sixteenth_note_grid() { + assert_eq!(quantize_on("1/16"), 240); +} From c73a8e22725265e5a88c6fa63d9e24331b42dae8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 12:34:35 +0200 Subject: [PATCH 052/137] fix(eq): switches and choices redesign the filter on a running EQ (#239) --- crates/dsp/src/plugins/eq.rs | 6 ++++++ crates/dsp/tests/plugins.rs | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/dsp/src/plugins/eq.rs b/crates/dsp/src/plugins/eq.rs index 6aefe91..fd948d4 100644 --- a/crates/dsp/src/plugins/eq.rs +++ b/crates/dsp/src/plugins/eq.rs @@ -184,12 +184,18 @@ impl Eq { } fn update(&mut self) { + let mut snapped = false; for (i, (s, info)) in self.sm.iter_mut().zip(self.info.params).enumerate() { s.set(self.p.v(i)); if matches!(info.unit, Unit::Toggle | Unit::Choice) { s.snap(); + snapped |= self.vals.get(i) != Some(&s.value()); } } + // A snapped smoother never settles, so `process` would not redesign for it. + if snapped { + self.redesign(); + } let g = db_to_gain(self.total_gain_db(|i| self.p.v(i))); self.gain.set(g); } diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/plugins.rs index 5d71f6f..e87d44b 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -199,6 +199,44 @@ fn eq_magnitude_at_center_matches_gain() { assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}"); } +#[test] +fn eq_switches_and_choices_apply_to_a_running_eq() { + // (plugin, settings, the one switch or choice changed while running, test tone) + let cases: [(&str, &[(&str, f32)], (&str, f32), f32); 7] = [ + ("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0)], ("hpf_on", 1.0), 1000.0), + ("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0), ("hpf_on", 1.0)], ("hpf_on", 0.0), 1000.0), + ("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 0.0), ("hpf_on", 1.0)], ("hpf_slope", 3.0), 1000.0), + ("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 3.0)], ("lpf_on", 1.0), 4000.0), + ("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 0.0), ("lpf_on", 1.0)], ("lpf_slope", 3.0), 4000.0), + ("eq_1band", &[("freq", 1000.0)], ("type", 4.0), 10_000.0), + ("channel_strip", &[("hpf_freq", 2000.0)], ("hpf_on", 1.0), 1000.0), + ]; + let mut stale = Vec::new(); + for (id, setup, (key, value), tone) in cases { + let x = [sine(tone, 0.5, 24_000)]; + let level = |p: &mut dyn Plugin| gain_to_db(rms(&run(p, &x)[0][12_000..])); + let build = |settings: &[(&str, f32)]| { + let mut p = create(id).unwrap(); + p.prepare(SR, BLOCK, 1); + for &(k, v) in settings { + assert!(p.set_param(k, v), "{id} {k}"); + } + p.reset(); + p + }; + let mut live = build(setup); + let before = level(live.as_mut()); + assert!(live.set_param(key, value)); + let after = level(live.as_mut()); + let want = level(build(&[setup, &[(key, value)]].concat()).as_mut()); + assert!((before - want).abs() > 3.0, "{id} {key}={value}: the tone must tell the settings apart"); + if (after - want).abs() >= 0.1 { + stale.push(format!("{id} {key}={value}: live {after:.2} dB, fresh {want:.2} dB, before {before:.2} dB")); + } + } + assert!(stale.is_empty(), "{stale:#?}"); +} + #[test] fn compressor_steady_state_gain_reduction() { let mut p = prepared("compressor"); From 9144570b6ea082d2afc78d97996ac04202495967 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 12:38:03 +0200 Subject: [PATCH 053/137] fix(video): H.264 parameter sets check their arithmetic before range checks (#251, #252) --- crates/video/src/h264/params.rs | 225 +++++++++++++++++++++++++++++--- 1 file changed, 209 insertions(+), 16 deletions(-) diff --git a/crates/video/src/h264/params.rs b/crates/video/src/h264/params.rs index b99404a..583c96b 100644 --- a/crates/video/src/h264/params.rs +++ b/crates/video/src/h264/params.rs @@ -1,9 +1,14 @@ //! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists. use crate::bitstream::BitReader; -use crate::h264::error::{Result, ensure, unsupported}; +use crate::h264::error::{Error, Result, ensure, unsupported}; use crate::h264::tables::*; +/// `v + k` for a coded `*_minus*` field, or the error its range check gives. +fn plus(v: u32, k: u32, err: &str) -> Result { + v.checked_add(k).ok_or_else(|| Error::Invalid(err.to_string())) +} + /// Scaling matrices after applying fall-back rules, stored in raster order (weightScale). #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScalingMatrices { @@ -150,7 +155,7 @@ pub struct HrdParameters { } fn parse_hrd(r: &mut BitReader) -> Result { - let cpb_cnt = r.read_ue()? + 1; + let cpb_cnt = plus(r.read_ue()?, 1, "cpb_cnt_minus1 out of range")?; ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range"); let mut h = HrdParameters { cpb_cnt, bit_rate_scale: r.read_bits(4)? as u8, cpb_size_scale: r.read_bits(4)? as u8, ..Default::default() }; for _ in 0..cpb_cnt { @@ -353,8 +358,8 @@ impl Sps { if chroma_format_idc == 3 { separate_colour_plane = r.read_flag()?; } - bit_depth_luma = r.read_ue()? + 8; - bit_depth_chroma = r.read_ue()? + 8; + bit_depth_luma = plus(r.read_ue()?, 8, "bit depth out of range")?; + bit_depth_chroma = plus(r.read_ue()?, 8, "bit depth out of range")?; ensure!(bit_depth_luma <= 14 && bit_depth_chroma <= 14, "bit depth out of range"); bypass = r.read_flag()?; seq_scaling_matrix_present = r.read_flag()?; @@ -363,7 +368,7 @@ impl Sps { scaling_lists = parse_scaling_lists(&mut r, count, None)?; } } - let log2_max_frame_num = r.read_ue()? + 4; + let log2_max_frame_num = plus(r.read_ue()?, 4, "log2_max_frame_num out of range")?; ensure!(log2_max_frame_num <= 16, "log2_max_frame_num out of range"); let pic_order_cnt_type = r.read_ue()?; ensure!(pic_order_cnt_type <= 2, "pic_order_cnt_type out of range"); @@ -373,7 +378,7 @@ impl Sps { let mut offset_for_top_to_bottom_field = 0; let mut offset_for_ref_frame = Vec::new(); if pic_order_cnt_type == 0 { - log2_max_poc_lsb = r.read_ue()? + 4; + log2_max_poc_lsb = plus(r.read_ue()?, 4, "log2_max_pic_order_cnt_lsb out of range")?; ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb out of range"); } else if pic_order_cnt_type == 1 { delta_pic_order_always_zero = r.read_flag()?; @@ -388,8 +393,8 @@ impl Sps { let max_num_ref_frames = r.read_ue()?; ensure!(max_num_ref_frames <= 16, "max_num_ref_frames out of range"); let gaps_in_frame_num_allowed = r.read_flag()?; - let pic_width_in_mbs = r.read_ue()? + 1; - let pic_height_in_map_units = r.read_ue()? + 1; + let pic_width_in_mbs = plus(r.read_ue()?, 1, "picture too large")?; + let pic_height_in_map_units = plus(r.read_ue()?, 1, "picture too large")?; ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large"); let frame_mbs_only = r.read_flag()?; let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false }; @@ -429,7 +434,8 @@ impl Sps { }; if let Some((l, rr, t, b)) = sps.frame_crop { let (cx, cy) = sps.crop_unit(); - ensure!((l + rr) * cx < sps.width() && (t + b) * cy < sps.height(), "frame cropping exceeds picture size"); + let fits = |a: u32, b: u32, unit: u32, size: u32| a.checked_add(b).and_then(|v| v.checked_mul(unit)).is_some_and(|v| v < size); + ensure!(fits(l, rr, cx, sps.width()) && fits(t, b, cy, sps.height()), "frame cropping exceeds picture size"); } Ok(sps) } @@ -466,7 +472,8 @@ impl Sps { None => (0, 0, self.width(), self.height()), Some((l, r, t, b)) => { let (cx, cy) = self.crop_unit(); - (l * cx, t * cy, self.width() - (l + r) * cx, self.height() - (t + b) * cy) + let cut = |a: u32, b: u32, unit: u32| a.saturating_add(b).saturating_mul(unit); + (l.saturating_mul(cx), t.saturating_mul(cy), self.width().saturating_sub(cut(l, r, cx)), self.height().saturating_sub(cut(t, b, cy))) } } } @@ -572,7 +579,7 @@ impl Pps { .ok_or_else(|| crate::h264::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?; let entropy_coding_mode = r.read_flag()?; let bottom_field_pic_order_in_frame_present = r.read_flag()?; - let num_slice_groups = r.read_ue()? + 1; + let num_slice_groups = plus(r.read_ue()?, 1, "num_slice_groups out of range")?; ensure!(num_slice_groups <= 8, "num_slice_groups out of range"); let mut slice_group_map_type = 0; if num_slice_groups > 1 { @@ -595,7 +602,7 @@ impl Pps { r.read_ue()?; } 6 => { - let n = r.read_ue()? + 1; + let n = plus(r.read_ue()?, 1, "pic_size_in_map_units_minus1 out of range")?; let bits = 32 - (num_slice_groups - 1).leading_zeros(); for _ in 0..n { r.read_bits(bits)?; @@ -604,13 +611,13 @@ impl Pps { _ => {} } } - let num_ref_idx_l0_default_active = r.read_ue()? + 1; - let num_ref_idx_l1_default_active = r.read_ue()? + 1; + let num_ref_idx_l0_default_active = plus(r.read_ue()?, 1, "num_ref_idx_default_active out of range")?; + let num_ref_idx_l1_default_active = plus(r.read_ue()?, 1, "num_ref_idx_default_active out of range")?; ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range"); let weighted_pred = r.read_flag()?; let weighted_bipred_idc = r.read_bits(2)?; - let pic_init_qp = 26 + r.read_se()?; - let pic_init_qs = 26 + r.read_se()?; + let pic_init_qp = r.read_se()?.checked_add(26).ok_or_else(|| Error::Invalid("pic_init_qp_minus26 out of range".into()))?; + let pic_init_qs = r.read_se()?.checked_add(26).ok_or_else(|| Error::Invalid("pic_init_qs_minus26 out of range".into()))?; let chroma_qp_index_offset = r.read_se()?; ensure!((-12..=12).contains(&chroma_qp_index_offset), "chroma_qp_index_offset out of range"); let deblocking_filter_control_present = r.read_flag()?; @@ -716,4 +723,190 @@ mod tests { assert_eq!(sps.pic_order_cnt_type, 2); assert_eq!(sps.crop_rect(), (0, 0, 176, 144)); } + + /// Coded SPS fields; the default is the minimal baseline SPS above. + #[derive(Clone, Copy)] + struct SpsFields { + high_profile: bool, + bit_depth_luma_minus8: u32, + log2_max_frame_num_minus4: u32, + poc_type: u32, + log2_max_poc_lsb_minus4: u32, + width_minus1: u32, + height_minus1: u32, + crop: Option<(u32, u32, u32, u32)>, + cpb_cnt_minus1: Option, + } + + impl Default for SpsFields { + fn default() -> Self { + Self { + high_profile: false, + bit_depth_luma_minus8: 0, + log2_max_frame_num_minus4: 0, + poc_type: 2, + log2_max_poc_lsb_minus4: 0, + width_minus1: 10, + height_minus1: 8, + crop: None, + cpb_cnt_minus1: None, + } + } + } + + fn sps(f: SpsFields) -> Result { + let mut w = BitWriter::new(); + w.write_bits(if f.high_profile { 100 } else { 66 }, 8); + w.write_bits(0, 8); + w.write_bits(30, 8); + w.write_ue(0); + if f.high_profile { + w.write_ue(1); // chroma_format_idc + w.write_ue(f.bit_depth_luma_minus8); + w.write_ue(0); // bit_depth_chroma_minus8 + w.write_bit(false); // qpprime_y_zero_transform_bypass + w.write_bit(false); // seq_scaling_matrix_present + } + w.write_ue(f.log2_max_frame_num_minus4); + w.write_ue(f.poc_type); + if f.poc_type == 0 { + w.write_ue(f.log2_max_poc_lsb_minus4); + } + w.write_ue(1); + w.write_bit(false); + w.write_ue(f.width_minus1); + w.write_ue(f.height_minus1); + w.write_bit(true); // frame_mbs_only + w.write_bit(true); // direct_8x8 + w.write_bit(f.crop.is_some()); + if let Some((l, r, t, b)) = f.crop { + for v in [l, r, t, b] { + w.write_ue(v); + } + } + w.write_bit(f.cpb_cnt_minus1.is_some()); // vui + if let Some(n) = f.cpb_cnt_minus1 { + for _ in 0..5 { + w.write_bit(false); // aspect ratio, overscan, video signal, chroma loc, timing + } + w.write_bit(true); // nal_hrd + w.write_ue(n); + w.write_bits(0, 8); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bits(0, 20); + w.write_bit(false); // vcl_hrd + w.write_bit(false); // low_delay_hrd + w.write_bit(false); // pic_struct_present + w.write_bit(false); // bitstream_restriction + } + w.rbsp_trailing(); + Sps::parse(&w.finish()) + } + + fn invalid_sps(f: SpsFields) -> String { + match sps(f) { + Err(crate::h264::Error::Invalid(m)) => m, + other => panic!("expected an invalid SPS, got {other:?}"), + } + } + + #[test] + fn sps_fields_of_the_test_writer_parse() { + let s = sps(SpsFields { high_profile: true, poc_type: 0, crop: Some((0, 2, 0, 2)), cpb_cnt_minus1: Some(0), ..Default::default() }).unwrap(); + assert_eq!((s.bit_depth_luma, s.log2_max_poc_lsb, s.width(), s.height()), (8, 4, 176, 144)); + assert_eq!(s.crop_rect(), (0, 0, 172, 140)); + assert_eq!(s.vui.and_then(|v| v.nal_hrd).map(|h| h.cpb_cnt), Some(1)); + } + + #[test] + fn huge_bit_depth_is_an_error() { + assert_eq!(invalid_sps(SpsFields { high_profile: true, bit_depth_luma_minus8: u32::MAX, ..Default::default() }), "bit depth out of range"); + } + + #[test] + fn huge_log2_max_frame_num_is_an_error() { + assert_eq!(invalid_sps(SpsFields { log2_max_frame_num_minus4: u32::MAX, ..Default::default() }), "log2_max_frame_num out of range"); + } + + #[test] + fn huge_log2_max_poc_lsb_is_an_error() { + let f = SpsFields { poc_type: 0, log2_max_poc_lsb_minus4: u32::MAX, ..Default::default() }; + assert_eq!(invalid_sps(f), "log2_max_pic_order_cnt_lsb out of range"); + } + + #[test] + fn huge_picture_size_is_an_error() { + assert_eq!(invalid_sps(SpsFields { width_minus1: u32::MAX, ..Default::default() }), "picture too large"); + assert_eq!(invalid_sps(SpsFields { height_minus1: u32::MAX, ..Default::default() }), "picture too large"); + } + + #[test] + fn crop_offsets_that_overflow_are_an_error() { + let msg = "frame cropping exceeds picture size"; + // The sum of two offsets overflows, then the scaling by the crop unit does. + assert_eq!(invalid_sps(SpsFields { crop: Some((u32::MAX, 1, 0, 0)), ..Default::default() }), msg); + assert_eq!(invalid_sps(SpsFields { crop: Some((0, 0, 1 << 31, 0)), ..Default::default() }), msg); + } + + #[test] + fn huge_cpb_count_is_an_error() { + assert_eq!(invalid_sps(SpsFields { cpb_cnt_minus1: Some(u32::MAX), ..Default::default() }), "cpb_cnt_minus1 out of range"); + } + + /// A PPS for the SPS above; `slice_groups` is (num_slice_groups_minus1, pic_size_in_map_units_minus1) + /// coded with slice_group_map_type 6. + fn pps(slice_groups: Option<(u32, u32)>, num_ref_idx_l0_minus1: u32, pic_init_qp_minus26: i32) -> Result { + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bit(false); + match slice_groups { + Some((groups, units)) => { + w.write_ue(groups); + w.write_ue(6); + w.write_ue(units); + } + None => w.write_ue(0), + } + w.write_ue(num_ref_idx_l0_minus1); + w.write_ue(0); + w.write_bit(false); + w.write_bits(0, 2); + w.write_se(pic_init_qp_minus26); + w.write_se(0); + w.write_se(0); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + Pps::parse(&w.finish(), &[Some(sps(SpsFields::default()).unwrap())]) + } + + fn invalid_pps(p: Result) -> String { + match p { + Err(crate::h264::Error::Invalid(m)) => m, + other => panic!("expected an invalid PPS, got {other:?}"), + } + } + + #[test] + fn pps_fields_of_the_test_writer_parse() { + let p = pps(None, 2, -4).unwrap(); + assert_eq!((p.num_slice_groups, p.num_ref_idx_l0_default_active, p.pic_init_qp), (1, 3, 22)); + } + + #[test] + fn huge_pps_counts_are_an_error() { + assert_eq!(invalid_pps(pps(Some((u32::MAX, 0)), 0, 0)), "num_slice_groups out of range"); + assert_eq!(invalid_pps(pps(Some((1, u32::MAX)), 0, 0)), "pic_size_in_map_units_minus1 out of range"); + assert_eq!(invalid_pps(pps(None, u32::MAX, 0)), "num_ref_idx_default_active out of range"); + } + + #[test] + fn huge_pic_init_qp_is_an_error() { + assert_eq!(invalid_pps(pps(None, 0, i32::MAX)), "pic_init_qp_minus26 out of range"); + } } From 35e9d63bee71973b60da56f6b5381bc475c47196 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 14:30:57 +0200 Subject: [PATCH 054/137] fix(markers): markers.edit applies the documented name (#236) --- crates/engine/src/cmd/event.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 34fb355..94f8cb1 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -314,7 +314,8 @@ fn edit_marker(e: &mut Engine, p: &Value) -> Result { let at = position_param(e, "markers.edit", p, "at")?; let s = e.session_mut(); let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) }; - if let Some(n) = p.get("rename").and_then(Value::as_str) { + // `name` is the documented new name; when it was the selector it already matches, so this is a no-op. + if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) { m.name = n.to_string(); } if let Some(c) = str_param(p, "comments") { @@ -376,4 +377,28 @@ mod tests { let tick = s.tempo.samples_to_ticks(at, s.sample_rate); assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); } + + fn marker_name(e: &crate::Engine, id: u64) -> String { + e.session().markers.iter().find(|m| m.id.0 == id).map(|m| m.name.clone()).unwrap_or_default() + } + + /// `markers.edit` documents `name`: with `id` or `number` selecting the location, it is the new name. + #[test] + fn edit_marker_renames_with_documented_name() { + let mut e = crate::Engine::default(); + let verse = e.execute("markers.add", &json!({"name": "Verse", "at": 1000})).unwrap(); + let outro = e.execute("markers.add", &json!({"name": "Outro", "at": 3000})).unwrap(); + let (verse_id, outro_id) = (verse["id"].as_u64().unwrap(), outro["id"].as_u64().unwrap()); + + e.execute("markers.edit", &json!({"id": verse_id, "name": "Chorus"})).unwrap(); + assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Outro".into())); + + e.execute("markers.edit", &json!({"number": outro["number"], "name": "Bridge"})).unwrap(); + assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Bridge".into())); + + e.execute("markers.edit", &json!({"name": "Bridge", "comments": "last"})).unwrap(); + let s = e.session(); + let bridge = s.markers.iter().find(|m| m.id.0 == outro_id).unwrap(); + assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last")); + } } From ae46052cac505fa73dead1431ba1d513a740b824 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 14:34:02 +0200 Subject: [PATCH 055/137] =?UTF-8?q?fix(dsp):=20pitch=5Fshift=20reaches=20?= =?UTF-8?q?=C2=B148=20semitones=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/dsp/src/offline.rs | 9 ++++++++- crates/dsp/tests/offline_meters.rs | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index b9a5eac..720cffe 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -341,7 +341,14 @@ pub fn pitch_shift(ch: &[Vec], semitones: f32, sample_rate: f32) -> Vec Date: Mon, 12 Oct 2026 14:44:57 +0200 Subject: [PATCH 056/137] fix(dsp): chorus and flanger tails follow their feedback (#242) --- crates/dsp/src/plugins/modulation.rs | 10 +++++++++- crates/dsp/tests/plugins.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/dsp/src/plugins/modulation.rs b/crates/dsp/src/plugins/modulation.rs index 38284f3..962f57d 100644 --- a/crates/dsp/src/plugins/modulation.rs +++ b/crates/dsp/src/plugins/modulation.rs @@ -316,7 +316,15 @@ impl Plugin for ModFx { } fn tail_samples(&self) -> usize { - ms_to_samples(200.0, self.sr) as usize + // Time for feedback to fall by 60 dB at the longest delay of the sweep, as for the delay + // above; never shorter than the 200 ms this used to report. + let longest = match self.kind { + ModKind::Chorus => self.p.v(2) + self.p.v(1) / 100.0 * 6.0, + ModKind::Flanger => self.p.v(2), + }; + let fb = (self.p.v(3).abs() / 100.0).clamp(0.0, 0.99); + let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 }; + ms_to_samples((longest * repeats).max(200.0), self.sr) as usize } } diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/plugins.rs index 5d71f6f..04aed49 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -329,6 +329,23 @@ fn delay_repeats_at_time() { assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}"); } +#[test] +fn modulation_tail_covers_feedback_echoes() { + for (id, delay, feedback) in [("chorus", 30.0, 90.0), ("flanger", 10.0, 95.0), ("flanger", 10.0, -95.0)] { + let mut p = prepared(id); + for (param, v) in [("delay", delay), ("feedback", feedback), ("depth", 0.0), ("mix", 100.0)] { + assert!(p.set_param(param, v)); + } + p.reset(); + let mut x = vec![0.0f32; 48_000 * 5]; + x[0] = 0.5; + let out = run(p.as_mut(), &[x]); + let floor = peak(&out[0]) * 1e-3; + let last = out[0].iter().rposition(|v| v.abs() > floor).unwrap(); + assert!(last < p.tail_samples(), "{id} feedback {feedback}: echo at {last}, tail {}", p.tail_samples()); + } +} + #[test] fn trim_and_invert() { let mut p = prepared("invert"); From fe919e5d57c86b5661b75c7699ee5f9a05644a5b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 14:46:05 +0200 Subject: [PATCH 057/137] fix(clip): Elastic Properties stretches the clip by the requested ratio (#227) --- crates/engine/src/cmd/clip.rs | 43 ++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e537..b2fddb0 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -199,9 +199,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result { continue; } let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1); - let mut warp = base.clone(); - warp.stretch = ratio; - let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &warp, out_len)? else { continue }; + let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue }; if orig.is_none() && let soundcraft_model::ClipContent::Audio { source, offset } = base.content { @@ -378,6 +376,45 @@ mod tests { assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0); } + /// A clip on an Elastic track playing `clip_len` samples of a source that is silent before `tone_at`. + fn elastic_clip(source_len: usize, tone_at: usize, clip_len: i64) -> (crate::Engine, TrackId, ClipId) { + use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic")); + let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] }; + let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let cid = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len)); + let mut e = crate::Engine::new(s); + e.execute("track.elastic", &json!({"track": t.0, "algorithm": "polyphonic"})).unwrap(); + (e, t, cid) + } + + fn rendered_peak(e: &crate::Engine, t: TrackId, cid: ClipId) -> f32 { + let range = e.session().find_clip(cid).unwrap().1.range(); + soundcraft_mix::render_clips(e.session(), t, range).iter().flatten().fold(0.0, |m, x| m.max(x.abs())) + } + + /// Elastic Properties stretches the material the clip plays, by the requested ratio (#227). + #[test] + fn elastic_properties_stretches_the_clip_material() { + let (mut e, t, cid) = elastic_clip(20_000, 10_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap(); + assert_eq!(e.session().find_clip(cid).unwrap().1.length, 40_000); + assert!(rendered_peak(&e, t, cid) > 0.3, "the tone in the second half was lost"); + + let (mut e, t, cid) = elastic_clip(60_000, 20_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap(); + assert_eq!(rendered_peak(&e, t, cid), 0.0, "audio from past the clip end leaked in"); + + for (ratio, len) in [(4.0, 80_000), (0.2, 4_000)] { + let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": ratio})).unwrap(); + assert_eq!(e.session().find_clip(cid).unwrap().1.length, len, "ratio {ratio}"); + } + } + #[test] fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() { let mut e = crate::demo::demo_engine(); From 5f4cc965781f0d9e201d6701e5667981c99940fd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:13:18 +0200 Subject: [PATCH 058/137] fix(midi-editor): Backspace in a text field leaves the selected notes alone (#223) --- crates/ui-egui/src/midi_editor.rs | 5 ++- crates/ui-egui/tests/midi_editor_keys.rs | 57 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/midi_editor_keys.rs diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index e69d625..57c31ec 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -330,7 +330,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi})); } } - if !app.midi.selected.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + if !app.midi.selected.is_empty() + && !ui.ctx().egui_wants_keyboard_input() + && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + { let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); app.midi.selected.clear(); } diff --git a/crates/ui-egui/tests/midi_editor_keys.rs b/crates/ui-egui/tests/midi_editor_keys.rs new file mode 100644 index 0000000..78906d0 --- /dev/null +++ b/crates/ui-egui/tests/midi_editor_keys.rs @@ -0,0 +1,57 @@ +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp}; + +/// The Keys track's MIDI clip in the editor with its first two notes selected, the Rename Track dialog open or not. +fn harness(rename: bool) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + let keys = app.engine.session().track_by_name("Keys").unwrap().id; + if rename { + app.dialogs.open_rename_track(keys, "Keys"); + } + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..3 { + h.step(); + } + h.state_mut().midi.selected = vec![0, 1]; + h +} + +fn press(h: &mut Harness<'_, SoundApp>, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +#[test] +fn deleting_text_in_the_rename_field_keeps_the_selected_notes() { + for key in [Key::Backspace, Key::Delete] { + let mut h = harness(true); + let before = h.state().engine.session().clone(); + press(&mut h, key); + // The field opens with the old name selected, so the key clears it. + assert!(matches!(&h.state().dialogs.open, Some(Dialog::RenameTrack { name, .. }) if name.is_empty()), "{key:?}"); + assert!(*h.state().engine.session() == before, "{key:?} edited the session"); + } +} + +#[test] +fn deleting_in_the_editor_still_removes_the_selected_notes() { + for key in [Key::Backspace, Key::Delete] { + let mut h = harness(false); + let before = h.state().engine.session().clone(); + press(&mut h, key); + assert!(*h.state().engine.session() != before, "{key:?} left the notes"); + assert!(h.state().midi.selected.is_empty()); + } +} From c85952ecc3a9ec3b9c5dea430c6d856c7c15986b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:17:52 +0200 Subject: [PATCH 059/137] fix(edit): cut clip gain keeps the removed gain for paste (#230) --- crates/engine/src/cmd/edit.rs | 9 ++++----- crates/engine/src/cmd/edit_more.rs | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..55fb414 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -15,11 +15,10 @@ pub fn specs() -> Vec { cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")), cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste), cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear), - cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain( - e, - p, - "edit.cut_clip_gain" - )), + cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| { + super::edit_more::copy_clip_gain(e, p)?; + reset_clip_gain(e, p, "edit.cut_clip_gain") + }), cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain( e, p, diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index c5c1082..08a8a8c 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -326,7 +326,7 @@ fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Re Ok(json!({"lanes": lanes_n, "length": r.len()})) } -fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result { +pub(super) fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result { let id = "edit.copy_clip_gain"; let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; @@ -1044,6 +1044,22 @@ mod tests { assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}"); } + #[test] + fn cut_clip_gain_can_be_pasted() { + let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000), (4000, 1000)]); + e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap(); + e.execute("clip.gain", &json!({"clip": ids[2].0, "db": -12.0})).unwrap(); + // An older copy sits in the clipboard: the paste must get the cut gain, not this one. + e.execute("edit.copy_clip_gain", &json!({"start": 4000, "end": 5000})).unwrap(); + e.execute("edit.select", &json!({"tracks": [t.0], "clips": [ids[0].0], "start": 0, "end": 1000})).unwrap(); + e.execute("edit.cut_clip_gain", &json!({})).unwrap(); + let src = e.session().track(t).unwrap().clips()[0].clone(); + assert!(clip_db(&src, 500).abs() < 1e-3, "cut resets the source: {src:?}"); + e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap(); + let c = e.session().track(t).unwrap().clips()[1].clone(); + assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}"); + } + #[test] fn move_to_new_playlist_clears_main() { let (mut e, t, _) = session_with_clips(&[(0, 1000)]); From 93ddd419f5ceab0bed5f2e6d45d687d85d63882f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:24:32 +0200 Subject: [PATCH 060/137] fix(track): Clear Trim Automation removes the trim lane (#233) --- crates/engine/src/cmd/track.rs | 2 +- crates/engine/src/cmd/track_more.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2f231f4..860cbd9 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -98,7 +98,7 @@ pub fn specs() -> Vec { Ok(json!({"track": t})) }), cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))), - cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, |_, _| Ok(json!({}))), + cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, super::track_more::clear_trim), cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click), cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)), cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)), diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index 8818342..e14e067 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -447,6 +447,19 @@ fn coalesce_trim(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tracks": n})) } +pub(super) fn clear_trim(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.clear_trim_automation", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + let before = tr.automation.len(); + tr.automation.retain(|l| l.param != trim_param()); + n += before - tr.automation.len(); + } + Ok(json!({"tracks": n})) +} + fn coalesce_vca(e: &mut Engine, p: &Value) -> Result { let id = "track.coalesce_vca"; let picked = tracks_param(e, id, p)?; @@ -649,6 +662,26 @@ mod tests { assert!(tr.lane(&trim_param()).is_none()); } + #[test] + fn clear_trim_automation_removes_only_trim() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 2})).unwrap(); + let ids: Vec = e.session().tracks.iter().map(|t| t.id).collect(); + let (a, b) = (ids[0], ids[1]); + e.execute("automation.set_point", &json!({"track": a.0, "param": "volume", "at": 0, "value": -3.0})).unwrap(); + e.execute("track.trim_automation_write", &json!({"tracks": [a.0, b.0], "start": 0, "end": 4800, "db": -12.0})).unwrap(); + let has_trim = |e: &Engine, t: TrackId| e.session().track(t).unwrap().lane(&trim_param()).is_some_and(|l| !l.points.is_empty()); + e.execute("edit.select", &json!({"tracks": [a.0]})).unwrap(); + e.execute("track.clear_trim_automation", &json!({})).unwrap(); + assert!(!has_trim(&e, a), "the selected track loses its trim"); + assert!(has_trim(&e, b), "other tracks keep theirs"); + assert_eq!(e.session().track(a).unwrap().lane(&AutoParam::Volume).unwrap().points.len(), 1); + e.execute("track.clear_trim_automation", &json!({"tracks": [b.0]})).unwrap(); + assert!(!has_trim(&e, b), "an explicit track is cleared too"); + assert!(e.undo()); + assert!(has_trim(&e, b), "undo brings the trim back"); + } + #[test] fn target_and_recent_playlists() { let mut e = crate::demo::demo_engine(); From e5d5996f7d9922f1eb66850615319adab705dcc2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:36:40 +0200 Subject: [PATCH 061/137] fix(event): Constant tempo applies only over its range (#238) --- crates/engine/src/cmd/event.rs | 45 +++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 34fb355..ed2cf6f 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { e.session_mut().tempo.scale_tempos(f).map_err(|err| bad("event.tempo_scale", err.to_string()))?; Ok(json!({})) }), - cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo), + cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo_constant), cmd!("event.insert_time", "Insert Time...", ["Event", "Time Operations"], None, "{start?, length | end}", has_tracks, insert_time), cmd!("event.cut_time", "Cut Time...", ["Event", "Time Operations"], None, "{start?, end?}", has_tracks, cut_time), cmd!( @@ -110,6 +110,30 @@ fn tempo(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tick": tick, "bpm": bpm})) } +fn tempo_constant(e: &mut Engine, p: &Value) -> Result { + let id = "event.tempo_constant"; + 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(); + // 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); + s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?; + let inside: Vec = s.tempo.tempos().iter().map(|x| x.tick).filter(|t| *t > t0 && *t < t1).collect(); + for t in inside { + s.tempo.remove_tempo(t); + } + if !had_end { + s.tempo.set_tempo(t1, after).map_err(|err| bad(id, err.to_string()))?; + } + Ok(json!({"tick": t0, "end_tick": t1, "bpm": bpm})) +} + fn meter(e: &mut Engine, p: &Value) -> Result { let num = i64_or(p, "numerator", 4).clamp(1, 64) as u32; let den = i64_or(p, "denominator", 4).clamp(1, 64) as u32; @@ -376,4 +400,23 @@ mod tests { let tick = s.tempo.samples_to_ticks(at, s.sample_rate); assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); } + + /// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it. + #[test] + fn tempo_constant_is_bounded_by_its_range() { + let mut e = crate::Engine::default(); + e.execute("event.tempo", &json!({"bpm": 100.0, "at": 0})).unwrap(); + e.execute("event.tempo", &json!({"bpm": 140.0, "at": 48_000})).unwrap(); + let s = e.session(); + let (t0, t1) = (s.tempo.samples_to_ticks(24_000, s.sample_rate), s.tempo.samples_to_ticks(96_000, s.sample_rate)); + let mid = s.tempo.samples_to_ticks(72_000, s.sample_rate); + let after = s.tempo.tempo_at_tick(t1); + e.execute("event.tempo_constant", &json!({"bpm": 90.0, "start": 24_000, "end": 96_000})).unwrap(); + let tempo = &e.session().tempo; + assert_eq!(tempo.tempo_at_tick(t0 - 1), 100.0, "before the range"); + assert_eq!(tempo.tempo_at_tick(t0), 90.0); + assert_eq!(tempo.tempo_at_tick(mid), 90.0, "the interior change is replaced"); + assert_eq!(tempo.tempo_at_tick(t1), after, "the old tempo resumes at the end"); + assert_eq!(after, 140.0); + } } From 4e14c980a40f2a0d3b882d72b06645422df740fa Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:50:38 +0200 Subject: [PATCH 062/137] fix(edit): trims stop at the neighbouring clips instead of overlapping them (#228, #229) Trim to File Boundaries and Trim to Fill Selection extended a clip within its source without looking at the clips around it, leaving two unfaded clips over the same samples. Both now go through one helper that caps the extension at the end of the previous clip and the start of the next one on the same playlist, the way the other commands that grow an existing clip do (Paste Merge MIDI, step input). An existing crossfade overlap is kept. --- crates/engine/src/cmd/edit.rs | 87 +++++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..7764858 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -591,13 +591,13 @@ fn trim_to_file(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut n = 0; for id in ids { - let frames = - s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)); - if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) { + if let Some((frames, lo, hi)) = trim_limits(s, id) + && let Some(c) = s.find_clip_mut(id) + { let off = c.source_offset(); let new_start = c.start - off; - c.trim_start_to(new_start); - c.trim_end_to(new_start + frames); + c.trim_start_to(new_start.max(lo)); + c.trim_end_to((new_start + frames).min(hi)); n += 1; } } @@ -610,18 +610,31 @@ fn trim_to_fill(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut n = 0; for id in ids { - let frames = - s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)); - if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) { + if let Some((frames, lo, hi)) = trim_limits(s, id) + && let Some(c) = s.find_clip_mut(id) + { let file_start = c.start - c.source_offset(); - c.trim_start_to(r.start.max(file_start)); - c.trim_end_to(r.end.min(file_start + frames)); + c.trim_start_to(r.start.max(file_start).max(lo)); + c.trim_end_to(r.end.min(file_start + frames).min(hi)); n += 1; } } Ok(json!({"trimmed": n})) } +/// How far a trim may extend an audio clip: its source length in frames, and the earliest +/// start and latest end before it would run over the clip before or after it on the playlist +/// (an extension never overwrites, like the trim tool). An overlap the clip already has with +/// a neighbour (a crossfade) is kept rather than trimmed away. +fn trim_limits(s: &Session, id: ClipId) -> Option<(Samples, Samples, Samples)> { + let (track, c) = s.find_clip(id)?; + let frames = c.source().and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX))?; + let others = || s.track(track).into_iter().flat_map(|t| t.clips()).filter(|x| x.id != id); + let lo = others().filter(|x| x.start < c.start).map(|x| x.end()).max().map_or(Samples::MIN, |end| end.min(c.start)); + let hi = others().filter(|x| x.start > c.start).map(|x| x.start).min().map_or(Samples::MAX, |start| start.max(c.end())); + Some((frames, lo, hi)) +} + fn mute_clips(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let s = e.session_mut(); @@ -782,4 +795,58 @@ mod tests { e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap(); assert_ne!(e.revision, rev); } + + /// A 60,000-frame source with `clips` placed on one track as (start, length, source offset). + fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec) { + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] }; + let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_audio_io::FileFormat::Wav); + let mut ids = Vec::new(); + for &(start, len, off) in clips { + let id = s.new_clip_id(); + ops::place_clip(&mut s, t, Clip::audio(id, "c", src, off, start, len)); + ids.push(id); + } + (Engine::new(s), t, ids) + } + + fn bounds(e: &Engine, t: TrackId) -> Vec<(Samples, Samples, Samples)> { + e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end(), c.source_offset())).collect() + } + + #[test] + fn trim_to_file_boundaries_stops_at_the_neighbours() { + let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]); + e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap(); + assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]); + } + + #[test] + fn trim_to_fill_selection_stops_at_the_neighbours() { + let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]); + e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[1].0], "start": 5_000, "end": 45_000})).unwrap(); + assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]); + } + + #[test] + fn trims_without_neighbours_expand_fully() { + let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]); + e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[0].0]})).unwrap(); + assert_eq!(bounds(&e, t), vec![(0, 60_000, 0)]); + let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]); + e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[0].0], "start": 5_000, "end": 45_000})).unwrap(); + assert_eq!(bounds(&e, t), vec![(5_000, 45_000, 5_000)]); + } + + #[test] + fn trim_to_file_boundaries_keeps_an_existing_crossfade() { + let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]); + e.execute("edit.fades_create", &json!({"tracks": [t], "start": 9_500, "end": 10_500})).unwrap(); + let faded = bounds(&e, t); + assert_eq!((faded[0].1, faded[1].0), (10_500, 9_500), "the fixture needs an overlapping crossfade"); + e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap(); + assert_eq!(bounds(&e, t), vec![faded[0], (9_500, 60_000, 9_500)]); + } } From 7a58e49775b4344c97b153247101e41e467dc95a Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:55:40 +0100 Subject: [PATCH 063/137] Reorder tracks by dragging them in the Tracks list Fixes #170: dragging a row in the Tracks list did nothing. Pro Tools reorders tracks when their names are dragged in the Track List, and the Edit window's name plates already did that here. - Rows now sense drags. While dragging, a line marks the gap between rows where the track will land, and the cursor shows a grab. - Dropping runs `track.move`, so it is one undo step. Dropping a row back on its own gap changes nothing. - The gap comes from the row pitch (row height plus the list's item spacing), so long drags land where the line is. - A click still selects the track as before. --- crates/ui-egui/src/panels.rs | 27 ++++++++- crates/ui-egui/tests/track_list.rs | 92 ++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/track_list.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..ad86016 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -7,6 +7,28 @@ use serde_json::json; use soundcraft_model::{ClipId, SourceId}; use soundcraft_time::format_position; +/// 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) { + let s = app.engine.session(); + let (Some(from), Some(p)) = (s.track_index(soundcraft_model::TrackId(id)), ui.ctx().pointer_latest_pos()) else { return }; + let spacing = ui.spacing().item_spacing.y; + let pitch = row.height() + spacing; + let gap = (from as f32 + ((p.y - row.center().y) / pitch).floor() + 1.0).clamp(0.0, s.tracks.len() as f32); + if dropped { + let to = gap as usize; + let to = if to > from { to - 1 } else { to }; + if to != from { + let _ = app.run("track.move", json!({"track": id, "to": to})); + } + } else { + let y = row.min.y + (gap - from as f32) * pitch - spacing / 2.0; + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + ui.ctx() + .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("track_list_reorder"))) + .line_segment([pos2(row.min.x, y), pos2(row.max.x, y)], Stroke::new(2.0, Tokens::current().accent)); + } +} + fn panel_header(ui: &mut Ui, title: &str) { let t = Tokens::current(); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); @@ -25,7 +47,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { }; egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).show(ui, |ui| { for (id, name, shown, color, sel) in tracks { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag()); if sel { ui.painter().rect_filled(r, 0.0, t.row_selected); } @@ -40,6 +62,9 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { if resp.clicked() { crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true); } + if resp.dragged() || resp.drag_stopped() { + drag_track(app, ui, r, id, resp.drag_stopped()); + } } }); ui.add_space(4.0); diff --git a/crates/ui-egui/tests/track_list.rs b/crates/ui-egui/tests/track_list.rs new file mode 100644 index 0000000..cd8f758 --- /dev/null +++ b/crates/ui-egui/tests/track_list.rs @@ -0,0 +1,92 @@ +//! Dragging rows in the Tracks list reorders the tracks (#170); a click still selects. + +use egui::{Event, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, pos2, vec2}; +use serde_json::json; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, +} + +impl Rig { + fn new() -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"count": 6})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + for _ in 0..4 { + r.frame(vec![]); + } + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + fn order(&self) -> Vec { + self.app.engine.session().tracks.iter().map(|t| t.id).collect() + } + + /// Centre of the Tracks list row naming the track at session index `i`. + fn row(&self, i: usize) -> Pos2 { + let name = &self.app.engine.session().tracks[i].name; + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == name && t.pos.x < 150.0 => { + Some(pos2(t.pos.x + 20.0, t.galley.rect.translate(t.pos.to_vec2()).center().y)) + } + _ => None, + }) + .expect("row not in the Tracks list") + } + + fn press(&mut self, at: Pos2, pressed: bool) { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ]); + } + + fn drag(&mut self, from: Pos2, to: Pos2) { + self.press(from, true); + for k in 1..=6 { + self.frame(vec![Event::PointerMoved(from.lerp(to, k as f32 / 6.0))]); + } + self.press(to, false); + self.frame(vec![]); + } +} + +#[test] +fn dragging_a_track_list_row_moves_the_track_to_the_gap_it_is_dropped_in() { + let mut r = Rig::new(); + let ids = r.order(); + let (from, lower_half_of_fifth) = (r.row(0), r.row(4) + vec2(0.0, 5.0)); + r.drag(from, lower_half_of_fifth); + assert_eq!(r.order(), vec![ids[1], ids[2], ids[3], ids[4], ids[0], ids[5]]); + let (from, upper_half_of_second) = (r.row(5), r.row(1) - vec2(0.0, 5.0)); + r.drag(from, upper_half_of_second); + assert_eq!(r.order(), vec![ids[1], ids[5], ids[2], ids[3], ids[4], ids[0]]); + let (from, own_lower_half) = (r.row(2), r.row(2) + vec2(0.0, 8.0)); + r.drag(from, own_lower_half); + assert_eq!(r.order(), vec![ids[1], ids[5], ids[2], ids[3], ids[4], ids[0]]); +} + +#[test] +fn clicking_a_track_list_row_still_selects_the_track() { + let mut r = Rig::new(); + let at = r.row(2); + r.press(at, true); + r.press(at, false); + assert_eq!(r.app.engine.session().edit.selected_tracks, vec![r.order()[2]]); +} From 5a156c2468b1e3e69247aad9c7a8aa688668daa8 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:02:11 +0100 Subject: [PATCH 064/137] Drag or type the tempo in the toolbar The first point of #189: clicking and dragging the toolbar tempo did nothing, because it was painted text. Typing a tempo needed Tempo Operations or a tempo-ruler dialog. - The tempo is now a drag value styled like the counter. It looks the same when idle and shows a faint frame on hover. Drag to change it (Shift for fine steps, 0.1 BPM per point otherwise), or click and type a value. The range is 5-1000 BPM, as the tempo map allows. - It edits the tempo change in effect at the playhead, the one the field shows, so later tempo changes are left alone. - A drag is one undo step. --- crates/ui-egui/src/toolbar.rs | 32 +++++++- crates/ui-egui/tests/toolbar_tempo.rs | 105 ++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/toolbar_tempo.rs diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..7187efa 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -370,7 +370,7 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 32.0), Align2::RIGHT_CENTER, meter, mono(11.0), t.counter_text); ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 48.0), Align2::RIGHT_CENTER, "Tempo", regular(11.0), t.counter_label); icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 82.0, r.min.y + 48.0), vec2(13.0, 13.0)), "note", t.counter_text); - ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 48.0), Align2::RIGHT_CENTER, format!("{bpm:.4}"), mono(11.0), t.counter_text); + tempo_value(app, ui, r.max.x - 8.0, r.min.y + 48.0, bpm); let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0)); icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim }); if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() { @@ -378,6 +378,36 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { } } +/// The tempo in effect at the playhead, right-aligned at `right`: drag it (Shift for fine steps), +/// or click and type. +fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) { + let t = Tokens::current(); + let w = ui.painter().layout_no_wrap(format!("{bpm:.4}"), mono(11.0), t.counter_text).size().x; + let rect = Rect::from_min_max(pos2(right - w - 4.0, y - 8.0), pos2(right + 4.0, y + 8.0)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + c.style_mut().override_font_id = Some(mono(11.0)); + let v = c.visuals_mut(); + v.override_text_color = Some(t.counter_text); + for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active] { + w.weak_bg_fill = Color32::TRANSPARENT; + w.bg_stroke = Stroke::NONE; + } + v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.counter_border); + let mut value = bpm; + let resp = c.add_sized(rect.size(), egui::DragValue::new(&mut value).speed(0.1).range(5.0..=1000.0).fixed_decimals(4)); + if resp.changed() { + let s = app.engine.session(); + let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate); + let event = s.tempo.tempos().iter().rev().find(|e| e.tick <= tick).map_or(0, |e| e.tick); + let at = s.tempo.tick_to_samples(event, s.sample_rate); + let _ = app.engine.execute_merged("event.tempo", &json!({"bpm": value, "at": at}), "toolbar_tempo"); + } + if resp.drag_stopped() || resp.lost_focus() { + app.engine.end_merge(); + } + resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it"); +} + fn output_meter(app: &mut SoundApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover()); let m = app.main_meter; diff --git a/crates/ui-egui/tests/toolbar_tempo.rs b/crates/ui-egui/tests/toolbar_tempo.rs new file mode 100644 index 0000000..67ad3f5 --- /dev/null +++ b/crates/ui-egui/tests/toolbar_tempo.rs @@ -0,0 +1,105 @@ +//! The toolbar's tempo can be dragged or typed (#189). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, vec2}; +use serde_json::json; +use soundcraft_time::BarBeat; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, +} + +impl Rig { + fn new() -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + r.frames(4); + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + fn bpm(&self, tick: i64) -> f64 { + self.app.engine.session().tempo.tempo_at_tick(tick) + } + + /// Centre of the toolbar's tempo, which shows `bpm`. + fn field(&self, bpm: f64) -> Pos2 { + let text = format!("{bpm:.4}"); + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2()).center()), + _ => None, + }) + .expect("tempo not in the toolbar") + } + + fn button(&mut self, at: Pos2, pressed: bool) { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + + fn type_into(&mut self, at: Pos2, text: &str) { + self.button(at, true); + self.button(at, false); + self.frames(2); + self.frame(vec![Event::Text(text.into())]); + self.frame(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + self.frames(2); + } +} + +#[test] +fn dragging_the_toolbar_tempo_changes_it_in_one_undo_step() { + let mut r = Rig::new(); + let at = r.field(120.0); + r.button(at, true); + for k in 1..=10 { + r.frame(vec![Event::PointerMoved(at - vec2(0.0, 6.0 * k as f32))]); + } + r.button(at - vec2(0.0, 60.0), false); + assert!(r.bpm(0) > 123.0 && r.bpm(0) < 127.0, "bpm {}", r.bpm(0)); + r.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(r.bpm(0), 120.0); +} + +#[test] +fn clicking_the_toolbar_tempo_lets_you_type_it() { + let mut r = Rig::new(); + let at = r.field(120.0); + r.type_into(at, "98.5"); + assert_eq!(r.bpm(0), 98.5); +} + +#[test] +fn the_toolbar_tempo_edits_the_tempo_change_in_effect_at_the_playhead() { + let mut r = Rig::new(); + let bar5 = r.app.engine.session().tempo.tick_at_bar_beat(BarBeat { bar: 5, beat: 1, tick: 0 }); + let at = r.app.engine.session().tempo.tick_to_samples(bar5, r.app.engine.session().sample_rate); + r.app.run("event.tempo", json!({"bpm": 140.0, "at": at})).unwrap(); + r.app.run("transport.locate", json!({"at": at + 48_000})).unwrap(); + r.frames(2); + let field = r.field(140.0); + r.type_into(field, "150"); + assert_eq!((r.bpm(0), r.bpm(bar5), r.app.engine.session().tempo.tempos().len()), (120.0, 150.0, 2)); +} From 57edb4385619a7c1ed7e0e95a5819768869d1c3c Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:07:37 +0100 Subject: [PATCH 065/137] Rename a marker by double-clicking it in the ruler From #186 ("markers created using Numpad Enter cannot be renamed"): double-clicking a marker in a marker ruler added a second marker on top of it, whose label overprinted the first. Renaming was only possible from the Memory Locations list. - Double-clicking a marker's flag now opens a Rename Marker dialog, like double-clicking a marker in Pro Tools opens its memory location. The field opens with the old name selected, as Rename Track and Rename Clip do, and OK runs `markers.edit`. - Double-clicking empty ruler space still adds a marker, and a single click on a flag still recalls it. --- crates/ui-egui/src/dialogs.rs | 13 +++++ crates/ui-egui/src/edit_window.rs | 21 ++++--- crates/ui-egui/tests/marker_rename.rs | 84 +++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/tests/marker_rename.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..2ca955d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,7 @@ pub enum Dialog { Bounce { path: String, format: String, bit_depth: String, normalize: bool }, RenameTrack { id: TrackId, name: String }, RenameClip { id: ClipId, name: String }, + RenameMarker { id: soundcraft_model::MarkerId, name: String }, ClearClips { clips: Vec, sources: Vec, message: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, @@ -42,6 +43,7 @@ impl Dialogs { Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", Dialog::RenameClip { .. } => "rename_clip", + Dialog::RenameMarker { .. } => "rename_marker", Dialog::ClearClips { .. } => "clear_clips", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", @@ -63,6 +65,10 @@ impl Dialogs { self.open = Some(Dialog::RenameClip { id, name: name.to_string() }); } + pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) { + self.open = Some(Dialog::RenameMarker { id, name: name.to_string() }); + } + /// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear. pub fn open_clear(&mut self, e: &Engine, clips: Vec, sources: Vec) -> bool { let Some(message) = clear_message(e.session(), &clips, &sources) else { return false }; @@ -261,6 +267,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", + Dialog::RenameMarker { .. } => "Rename Marker", Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", @@ -379,6 +386,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } } + Dialog::RenameMarker { id, name } => { + name_field(ui, name); + if buttons(ui, "OK", enter) { + action = Some(("markers.edit".into(), json!({"id": id.0, "rename": name}))); + } + } Dialog::ClearClips { clips, sources, message } => { ui.set_max_width(340.0); ui.label(message.as_str()); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..95db9c8 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -535,18 +535,23 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ )); painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), t.marker_text); } - // Click on the marker ruler: recall a marker; double-click adds one. + // Click on the marker ruler: recall a marker; double-click renames it, or adds one. let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click()); if let Some(p) = resp.interact_pointer_pos() { + let hit = s + .markers + .iter() + .filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0)) + .find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0); if resp.double_clicked() { - let at = sample_at(&s, tl, p.x).max(0); - let _ = app.run("markers.add", json!({"at": at, "ruler": lane})); + match hit { + Some(m) => app.dialogs.open_rename_marker(m.id, &m.name), + None => { + let _ = app.run("markers.add", json!({"at": sample_at(&s, tl, p.x).max(0), "ruler": lane})); + } + } } else if resp.clicked() - && let Some(m) = s - .markers - .iter() - .filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0)) - .find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0) + && let Some(m) = hit { let _ = app.run("markers.recall", json!({"number": m.number})); } diff --git a/crates/ui-egui/tests/marker_rename.rs b/crates/ui-egui/tests/marker_rename.rs new file mode 100644 index 0000000..10ec422 --- /dev/null +++ b/crates/ui-egui/tests/marker_rename.rs @@ -0,0 +1,84 @@ +//! Double-clicking a marker in the ruler renames it; elsewhere it still adds one (#186). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, +} + +impl Rig { + fn new() -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({})).unwrap(); + engine.execute("markers.add", &json!({"name": "Verse", "at": 96_000})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + r.frames(4); + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + /// The flag of the ruler marker named `name` (its label starts 8pt to the right). + fn flag(&self, name: &str) -> Pos2 { + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == name => Some(pos2(t.pos.x - 8.0, t.galley.rect.translate(t.pos.to_vec2()).center().y)), + _ => None, + }) + .expect("marker not on the ruler") + } + + fn double_click(&mut self, at: Pos2) { + for pressed in [true, false, true, false] { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + self.frames(2); + } + + fn names(&self) -> Vec { + self.app.engine.session().markers.iter().map(|m| m.name.clone()).collect() + } +} + +#[test] +fn double_clicking_a_ruler_marker_renames_it() { + let mut r = Rig::new(); + let flag = r.flag("Verse"); + r.double_click(flag); + assert_eq!(r.app.dialogs.open_name(), Some("rename_marker")); + r.frame(vec![Event::Text("Chorus".into())]); + r.frame(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + r.frames(2); + assert_eq!(r.names(), vec!["Chorus".to_string()]); +} + +#[test] +fn double_clicking_the_empty_ruler_still_adds_a_marker() { + let mut r = Rig::new(); + let flag = r.flag("Verse"); + r.double_click(flag + vec2(200.0, 0.0)); + assert_eq!(r.app.dialogs.open_name(), None); + assert_eq!(r.names().len(), 2); +} From dabcb4840a07b01cfb5c2302f4829f7fa2a35ca4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:27:57 +0200 Subject: [PATCH 066/137] Open .m4b audiobook files like .m4a M4B is the same MP4/AAC container as M4A, and the decoder already reads it, but the Open dialog, the web file picker and the file browser hid .m4b files. Add m4b to those lists and to the extension fallback. --- apps/soundcraft-web/src/web.rs | 4 +++- apps/soundcraft/src/main.rs | 2 +- crates/audio-io/src/lib.rs | 2 +- crates/audio-io/tests/malformed.rs | 1 + crates/ui-egui/src/windows.rs | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 02f1e13..452e807 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -67,7 +67,9 @@ enum Purpose { } const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION]; -const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"]; +const AUDIO_EXTS: &[&str] = &[ + "wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm", +]; const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"]; impl Purpose { diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7..1931d2c 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -162,7 +162,7 @@ fn services() -> Services { "file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]), _ => d.add_filter( "Audio", - &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"], + &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"], ), }; d.pick_file().map(|p| p.to_string_lossy().into_owned()) diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 87241c6..0e3fdd9 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -187,7 +187,7 @@ fn detect_by_ext(ext: &str) -> FileFormat { "flac" => FileFormat::Flac, "mp3" | "mp2" | "mp1" => FileFormat::Mp3, "ogg" | "oga" => FileFormat::Ogg, - "aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac, + "aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac, "caf" => FileFormat::Caf, _ => FileFormat::Other, } diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index 1cec656..384e4bf 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -240,6 +240,7 @@ fn detect_format_by_magic_and_hint() { assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav); assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff); assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac); + assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac); assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac); assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other); assert_eq!(detect_format(b"zz", None), FileFormat::Other); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..2b280cf 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -334,7 +334,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) { } let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase(); - (is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "caf", "mid", "midi", "scraft"].contains(&ext.as_str())) + (is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str())) .then_some((name, is_dir)) }) .collect() From 5e7ca6de22ea0df4857e212fac96f6a9606e6697 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:36:18 +0200 Subject: [PATCH 067/137] Apply the Feet+Frames reference to counters and typed positions (fixes #235) Setup > Current Feet+Frames Position stored its origin in feet_frames.start but nothing read it, so every Feet+Frames reading still counted from sample 0. format_position/parse_position now take the origin alongside the timecode start, and Session::format_position and Session::parse_position pass both, so the counters, rulers, session.format_time and typed positions share one reference. --- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/query.rs | 2 +- crates/engine/src/cmd/setup_more.rs | 5 +-- crates/engine/src/inspect.rs | 6 +-- .../tests/current_feet_frames_position.rs | 30 ++++++++++++++ crates/model/src/lib.rs | 2 +- crates/model/src/session.rs | 18 ++++++++ crates/time/src/format.rs | 41 +++++++++++-------- crates/time/src/timecode.rs | 8 ++-- crates/ui-egui/src/edit_window.rs | 11 ++--- crates/ui-egui/src/panels.rs | 7 ++-- crates/ui-egui/src/toolbar.rs | 4 +- crates/ui-egui/src/video_window.rs | 4 +- crates/ui-egui/src/windows.rs | 5 +-- 14 files changed, 97 insertions(+), 48 deletions(-) create mode 100644 crates/engine/tests/current_feet_frames_position.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..13d555e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -275,7 +275,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result Vec { let at = position_param(e, "session.format_time", p, "at")?.unwrap_or(0); let s = e.session(); let f = str_param(p, "format").and_then(soundcraft_time::TimeFormat::from_id).unwrap_or(s.edit.main_counter); - Ok(json!({"text": soundcraft_time::format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)})) + Ok(json!({"text": s.format_position(at, f)})) }), ] } diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 1d21e99..e6031a2 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -318,7 +318,7 @@ fn current_feet_frames(e: &mut Engine, p: &Value) -> Result { let at = s.edit.selection.start.max(0); let frames_at = s.frame_rate.frames_at(at, s.sample_rate); let start = ft * 16 + fr - frames_at; - s.edit.values.insert("feet_frames.start".into(), start as f64); + s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64); Ok(json!({"feet_frames_start": start})) } @@ -556,8 +556,7 @@ mod tests { e.execute("edit.select", &json!({"start": 48_000})).unwrap(); e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap(); let s = e.session(); - let shown = - soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode); assert_eq!(shown, "01:00:00:00"); } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 191adb8..9084a25 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -3,11 +3,11 @@ use crate::Engine; use serde_json::{Value, json}; use soundcraft_model::{Clip, ClipContent, ClipId, Track, TrackId}; -use soundcraft_time::{TimeFormat, format_position}; +use soundcraft_time::TimeFormat; fn clip_json(e: &Engine, c: &Clip) -> Value { let s = e.session(); - let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let fmt = |x| s.format_position(x, TimeFormat::MinSecs); let mut v = json!({ "id": c.id, "name": c.name, "start": c.start, "end": c.end(), "length": c.length, "start_time": fmt(c.start), "gain_db": c.gain_db, "muted": c.muted, @@ -110,7 +110,7 @@ pub fn clip(e: &Engine, id: ClipId) -> Option { /// Plain-text session report (File › Export › Session Info as Text). pub fn session_text(e: &Engine) -> String { let s = e.session(); - let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let fmt = |x| s.format_position(x, TimeFormat::MinSecs); let mut out = String::new(); 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(), diff --git a/crates/engine/tests/current_feet_frames_position.rs b/crates/engine/tests/current_feet_frames_position.rs new file mode 100644 index 0000000..467c49b --- /dev/null +++ b/crates/engine/tests/current_feet_frames_position.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::{Engine, model::Session}; +use soundcraft_time::{FrameRate, SampleRate}; + +fn feet_frames(engine: &mut Engine, at: i64) -> serde_json::Value { + engine.execute("session.format_time", &json!({"at": at, "format": "feet_frames"})).unwrap()["text"].clone() +} + +#[test] +fn current_feet_frames_sets_the_reading_at_the_insertion_point() { + let mut session = Session::new("Feet frames", SampleRate::HZ_48000); + session.frame_rate = FrameRate::Fps30; + let mut engine = Engine::new(session); + engine.execute("edit.select", &json!({"start": 48_000})).unwrap(); + assert_eq!(feet_frames(&mut engine, 48_000), "1+14"); + + let response = engine.execute("setup.current_feet_frames", &json!({"feet_frames": "12+08"})).unwrap(); + assert_eq!(response, json!({"feet_frames_start": 170})); + + assert_eq!(feet_frames(&mut engine, 48_000), "12+08"); + // 30 frames later, at 16 frames per foot. + assert_eq!(feet_frames(&mut engine, 96_000), "14+06"); + assert_eq!(engine.session().edit.selection.start, 48_000); + let samples = engine.execute("session.format_time", &json!({"at": 48_000, "format": "samples"})).unwrap(); + assert_eq!(samples["text"], "48000"); + + // Typed positions go through the same reference. + engine.execute("edit.select", &json!({"start": "feet_frames:14+06"})).unwrap(); + assert_eq!(engine.session().edit.selection.start, 96_000); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 55d605d..80be067 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -22,7 +22,7 @@ pub use markers::{Group, MarkerKind, MemoryLocation}; pub use mixer::{ Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db, }; -pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState}; +pub use session::{BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState}; pub use source::{Source, SourceAudio, SourcePool, VideoSource}; pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind}; diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 5956796..3432da0 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -9,6 +9,9 @@ use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap /// Native session file extension. pub const SESSION_EXTENSION: &str = "scraft"; +/// `EditState::values` key holding the feet+frames reading of sample 0, in frames. +pub const FEET_FRAMES_START: &str = "feet_frames.start"; + #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum SessionError { #[error("not a SoundCraft session: {0}")] @@ -421,6 +424,21 @@ impl Session { self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0) } + /// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position). + pub fn feet_frames_start(&self) -> i64 { + self.edit.value(FEET_FRAMES_START, 0.0) as i64 + } + + /// Format a position against this session's timecode and feet+frames references. + pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String { + soundcraft_time::format_position(at, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start()) + } + + /// Parse a position typed in `fmt` against this session's references. + pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result { + soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start()) + } + pub fn next_track_color(&self) -> [u8; 3] { let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count(); TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128]) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index a5f4a1a..d27e3a3 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -50,14 +50,15 @@ fn min_secs(s: Samples, sr: SampleRate) -> String { format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" }) } -/// Format an absolute position. -pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> String { +/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the +/// feet+frames reading of sample 0 in frames. +pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String { match fmt { TimeFormat::Samples => s.to_string(), TimeFormat::MinSecs => min_secs(s, sr), TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate), TimeFormat::FeetFrames => { - let (ft, fr) = feet_frames(s, rate, sr); + let (ft, fr) = feet_frames(s, rate, sr, ff_start); format!("{ft}+{fr:02}") } TimeFormat::BarsBeats => { @@ -80,12 +81,20 @@ pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRa format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1)) } TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate), - other => format_position(len, other, sr, map, rate, 0), + other => format_position(len, other, sr, map, rate, 0, 0), } } /// Parse a position typed into a counter or dialog. -pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> Result { +pub fn parse_position( + text: &str, + fmt: TimeFormat, + sr: SampleRate, + map: &TempoMap, + rate: FrameRate, + tc_start: Samples, + ff_start: i64, +) -> Result { let t = text.trim(); let err = || TimeError::Parse(t.to_string(), fmt.label()); match fmt { @@ -110,7 +119,7 @@ pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMa let (ft, fr) = t.split_once('+').ok_or_else(err)?; let ft: i64 = ft.trim().parse().map_err(|_| err())?; let fr: i64 = fr.trim().parse().map_err(|_| err())?; - Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr), sr)) + Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr)) } TimeFormat::BarsBeats => { let parts: Vec = @@ -136,11 +145,11 @@ mod tests { #[test] fn formats_like_pro_tools_counters() { let (sr, map, rate) = ctx(); - assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000"); - assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500"); - assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000"); - assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00"); - assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345"); + assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000"); + assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500"); + assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000"); + assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00"); + assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345"); } #[test] @@ -148,8 +157,8 @@ mod tests { let (sr, map, rate) = ctx(); for fmt in TimeFormat::ALL { for s in [0i64, 48_000, 96_000 * 3, 1_440_000] { - let text = format_position(s, fmt, sr, &map, rate, 0); - let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap(); + let text = format_position(s, fmt, sr, &map, rate, 0, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap(); assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}"); } } @@ -158,9 +167,9 @@ mod tests { #[test] fn parse_rejects_garbage() { let (sr, map, rate) = ctx(); - assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err()); - assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err()); - assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err()); + assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err()); } #[test] diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs index c0db20f..64cba7c 100644 --- a/crates/time/src/timecode.rs +++ b/crates/time/src/timecode.rs @@ -202,9 +202,9 @@ impl Timecode { } } -/// Feet+Frames for 35 mm film (16 frames per foot). -pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate) -> (i64, i64) { - let f = rate.frames_at(s, sr); +/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0. +pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) { + let f = rate.frames_at(s, sr).saturating_add(start); (f.div_euclid(16), f.rem_euclid(16)) } @@ -246,6 +246,6 @@ mod tests { #[test] fn feet_and_frames() { let sr = SampleRate::HZ_48000; - assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr), (1, 8)); + assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8)); } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..411f624 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -655,13 +655,9 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: TimeFormat::BarsBeats => s.tempo.bar_beat_at(at, sr).bar.to_string(), TimeFormat::MinSecs => { let secs = sr.seconds(at); - if secs.fract().abs() < 1e-9 { - format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60) - } else { - format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start) - } + if secs.fract().abs() < 1e-9 { format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60) } else { s.format_position(at, f) } } - _ => format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start), + _ => s.format_position(at, f), }; painter.text(pos2(x + 3.0, row.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.ruler_text); } else { @@ -1325,7 +1321,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track 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(format_position(clip.start, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)) + 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( @@ -1335,6 +1331,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track &s.tempo, s.frame_rate, 0, + 0, ) }) } else { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..515407a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -5,7 +5,6 @@ use crate::theme::{Tokens, bold, mono, regular, rgb}; use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{ClipId, SourceId}; -use soundcraft_time::format_position; fn panel_header(ui: &mut Ui, title: &str) { let t = Tokens::current(); @@ -218,7 +217,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { } }); let s = app.engine.session(); - let fmt = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let fmt = |x: i64| s.format_position(x, s.edit.main_counter); if flags.contains("view.transport.counters") || expanded || flags.is_empty() { ui.label(egui::RichText::new(fmt(app.position())).font(mono(20.0)).color(t.counter_text)); } @@ -271,7 +270,7 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { } egui::Window::new("Big Counter").open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| { let s = app.engine.session(); - let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let txt = s.format_position(app.position(), s.edit.main_counter); let r = ui.available_rect_before_wrap(); let t = Tokens::current(); ui.painter().rect_filled(r, 4.0, t.big_counter_bg); @@ -291,7 +290,7 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { }); let rows: Vec<(u32, String, String, String, String)> = { let s = app.engine.session(); - let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let f = |x: i64| s.format_position(x, s.edit.main_counter); s.markers .iter() .map(|m| { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..8049522 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -6,7 +6,7 @@ use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{EditMode, Tool}; -use soundcraft_time::{TimeFormat, format_length, format_position}; +use soundcraft_time::{TimeFormat, format_length}; pub const HEIGHT: f32 = 78.0; @@ -173,7 +173,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String { let s = app.engine.session(); - format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) + s.format_position(at, f) } fn counters(app: &mut SoundApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/video_window.rs b/crates/ui-egui/src/video_window.rs index d282b95..395273e 100644 --- a/crates/ui-egui/src/video_window.rs +++ b/crates/ui-egui/src/video_window.rs @@ -7,7 +7,7 @@ use crate::video_track::{MovieKey, movie_secs, online, picture_at}; use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; use soundcraft_model::{ClipContent, TrackKind}; -use soundcraft_time::{TimeFormat, format_position}; +use soundcraft_time::TimeFormat; pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if app.ui.show_video { @@ -81,7 +81,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) { painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170)); } if burn { - let tc = format_position(pos, TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let tc = s.format_position(pos, TimeFormat::Timecode); let size = (h / 12.0).clamp(10.0, 36.0); let galley = painter.layout_no_wrap(tc, mono(size), Color32::WHITE); let at = pos2(img.center().x - galley.size().x / 2.0, img.max.y - galley.size().y - size * 0.6); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..ac21f02 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -202,10 +202,7 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) { ("Clips", s.tracks.iter().map(|t| t.clips().len()).sum::().to_string()), ("Audio files", s.sources.len().to_string()), ("Markers", s.markers.len().to_string()), - ( - "Length", - soundcraft_time::format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, 0), - ), + ("Length", s.format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs)), ] { ui.label(k); ui.label(v); From 3765a32b9ab36b7a2d2587ffd4961f1d963311f2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:36:59 +0200 Subject: [PATCH 068/137] fix(mix): pan automation reads the same in playback and bounce (fixes #244) --- crates/mix/src/lib.rs | 74 +++++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..900570e 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -763,8 +763,7 @@ impl MixEngine { let m = build_matrix(src_fmt, src.len(), dst.fmt, dst.buf.len(), &PanSpec { stereo, surround }, law); apply_matrix(src, frames, &mut dst.buf, &m, gain); } - let spec = PanSpec { stereo: [pan_at(t, 0, pos), pan_at(t, 1, pos)], surround: t.mixer.surround.as_ref() }; - match &t.mixer.output { + let src: &[Vec] = match &t.mixer.output { Route::Main if strip.out_extra.len > 0 => { if strip.outbuf.len() != strip.buf.len() { strip.outbuf = vec![Vec::new(); strip.buf.len()]; @@ -774,20 +773,25 @@ impl MixEngine { d.extend_from_slice(src.get(..frames).unwrap_or(&[])); } strip.out_extra.process(&mut strip.outbuf, frames); - let m = build_matrix(src_fmt, strip.outbuf.len(), self.main_fmt, self.main.len(), &spec, law); - apply_matrix(&strip.outbuf, frames, &mut self.main, &m, 1.0); + &strip.outbuf } - Route::Main => { - let m = build_matrix(src_fmt, strip.buf.len(), self.main_fmt, self.main.len(), &spec, law); - apply_matrix(&strip.buf, frames, &mut self.main, &m, 1.0); - } - Route::Bus(b) => { - if let Some(dst) = self.busses.get_mut(b) { - let m = build_matrix(src_fmt, strip.buf.len(), dst.fmt, dst.buf.len(), &spec, law); - apply_matrix(&strip.buf, frames, &mut dst.buf, &m, 1.0); - } - } - _ => {} + _ => &strip.buf, + }; + let (dst, dst_fmt) = match &t.mixer.output { + Route::Main => (&mut self.main, self.main_fmt), + Route::Bus(b) => match self.busses.get_mut(b) { + Some(d) => (&mut d.buf, d.fmt), + None => return, + }, + _ => return, + }; + let mut start = 0; + while start < frames { + let (at, end) = pan_segment(t, pos, start, frames); + let spec = PanSpec { stereo: [pan_at(t, 0, at), pan_at(t, 1, at)], surround: t.mixer.surround.as_ref() }; + let m = build_matrix(src_fmt, src.len(), dst_fmt, dst.len(), &spec, law); + apply_matrix_span(src, start..end, dst, &m, 1.0); + start = end; } } } @@ -1458,6 +1462,23 @@ fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 { base } +/// Pan automation is read on a grid of [`PAN_STEP`] timeline samples rather than once per block, +/// so playback (callback-sized blocks) and bounces (1024-frame blocks) pan identically. +const PAN_STEP: Samples = 64; + +/// The position to read the pan at for the frames from `start` of the block at `pos`, and the end +/// of the frames that share it. Without pan automation the whole block shares one pan. +fn pan_segment(t: &Track, pos: Samples, start: usize, frames: usize) -> (Samples, usize) { + let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(l.param, AutoParam::Pan(_)) && !l.points.is_empty()); + if !automated { + return (pos, frames); + } + let here = pos.saturating_add(start as i64); + let at = here.div_euclid(PAN_STEP).saturating_mul(PAN_STEP); + let left = usize::try_from(at.saturating_add(PAN_STEP).saturating_sub(here)).unwrap_or(1).max(1); + (at, start.saturating_add(left).min(frames)) +} + fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 { if t.mixer.automation_mode.reads() && let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0))) @@ -1707,6 +1728,11 @@ fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: u /// `dst[d] += Σ_s src[s] × gain × m[s][d]` for every destination channel (no allocation). fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matrix, gain: f32) { + apply_matrix_span(src, 0..frames, dst, m, gain); +} + +/// [`apply_matrix`] over the frames in `span` only. +fn apply_matrix_span(src: &[Vec], span: std::ops::Range, dst: &mut [Vec], m: &Matrix, gain: f32) { let ns = src.len().min(MAX_CHANNELS); for (d, out) in dst.iter_mut().enumerate().take(MAX_CHANNELS) { let mut idx = [(0usize, 0.0f32); MAX_CHANNELS]; @@ -1724,7 +1750,7 @@ fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matri if taps.is_empty() { continue; } - for (i, o) in out.iter_mut().enumerate().take(frames) { + for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) { let mut acc = 0.0f32; for &(s, g) in taps { acc += src.get(s).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain * g; @@ -2063,6 +2089,22 @@ mod tests { assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4); } + #[test] + fn pan_automation_does_not_depend_on_the_block_size() { + // Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244). + let (mut s, t) = session_with_dc(0.5, 4800); + { + let l = s.track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)); + l.set_point(0, -1.0); + l.set_point(480, 1.0); + } + let bounce = render_range(&s, Range::new(0, 4800), 1024); + assert!(bounce[0][600].abs() < 1e-4 && bounce[1][600] > 0.49, "{} {}", bounce[0][600], bounce[1][600]); + for block in [512, 100, 37] { + assert_eq!(render_range(&s, Range::new(0, 4800), block), bounce, "block {block}"); + } + } + #[test] fn fades_and_clip_gain_apply() { let (mut s, t) = session_with_dc(1.0, 48_000); From 4235def686d74003caf2465d1376d450c19fca4b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:40:16 +0200 Subject: [PATCH 069/137] Locate clears plugin and delay-compensation state (fixes #247) --- crates/mix/src/lib.rs | 27 +++++++++++++++++++++++ crates/playback/src/lib.rs | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..7335c87 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -251,6 +251,9 @@ impl DelayLine { } self.idx = (start + frames) % self.len.max(1); } + fn clear(&mut self) { + self.bufs.iter_mut().for_each(|b| b.fill(0.0)); + } } /// Clip Effects: an EQ and a compressor per clip, configured from `edit.values` @@ -474,6 +477,9 @@ impl MixEngine { i.plugin.reset(); } s.held.clear(); + s.align.clear(); + s.align_pre.clear(); + s.out_extra.clear(); } self.meters.clear(); self.main_meter = StripMeter::default(); @@ -2287,6 +2293,27 @@ mod tests { assert_ne!(argmax(&out[0]), argmax(&out[1])); } + /// A reset (stop, locate) must not leave the previous position's audio in the alignment delay. + #[test] + fn reset_clears_delay_compensation_history() { + let (mut s, t) = session_with_dc(0.5, 4800); + s.edit.click = false; + s.track_mut(t).unwrap().mixer.pan = vec![1.0]; + let latent = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut comp = Insert::new("compressor"); + comp.params.insert("lookahead".into(), 5.0); + s.track_mut(latent).unwrap().mixer.inserts[0] = Some(comp); + let mut eng = MixEngine::new(48_000.0, 512); + let mut out = vec![vec![0.0f32; 512]; 2]; + for b in 0..9 { + eng.render(&s, b * 512, 512, &mut out); + } + assert!(eng.latency > 0 && out[1][500] > 0.3, "the fixture plays through the alignment delay"); + eng.reset(); + eng.render(&s, 1_000_000, 512, &mut out); + assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset"); + } + fn impulse_at(ch: &[f32]) -> Vec { ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect() } diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..4bc3191 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -176,6 +176,7 @@ impl AudioState { self.pos = at; self.pending_len = 0; self.read = 0.0; + self.mix.reset(); } } } @@ -949,6 +950,49 @@ mod tests { assert!(shared.playing.load(Ordering::Relaxed)); } + #[test] + fn locate_while_playing_drops_the_old_position_audio() { + use soundcraft_model::{ChannelFormat, Clip, Insert, SourceAudio, SourceId, TrackKind}; + for (plugin, params) in [("compressor", &[("lookahead", 5.0), ("ratio", 1.0)][..]), ("room_reverb", &[][..])] { + let mut s = soundcraft_model::Session::default(); + s.edit.click = false; + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.pool.insert( + SourceId(5), + Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })), + ); + let id = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 4800)); + let mut ins = Insert::new(plugin); + for (k, v) in params { + ins.params.insert((*k).into(), *v); + } + tr.mixer.inserts[0] = Some(ins); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + let mut out = vec![0.0f32; 2 * 512]; + let mut before = 0.0f32; + for _ in 0..9 { + st.fill(&mut out, 2); + before = out.iter().fold(before, |m, x| m.max(x.abs())); + } + assert!(before > 0.1, "{plugin}: the clip plays before the locate"); + tx.send(Cmd::Locate(1_000_000)).unwrap(); + for _ in 0..8 { + st.fill(&mut out, 2); + assert!(out.iter().all(|x| x.abs() < 1e-6), "{plugin}: audio from before the locate"); + } + } + } + #[test] fn armed_tracks_are_heard_and_metered_after_playback_stops() { use soundcraft_model::{ChannelFormat, TrackKind}; From 1f7a4ab3e4d47e36a63f1e54278c1e63687c83cb Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:42:19 +0200 Subject: [PATCH 070/137] Restore Last Selection brings back the previous selection (fixes #231) --- crates/engine/src/cmd/edit.rs | 55 +++++++++++++++++++++++++++++++++-- crates/engine/src/cmd/mod.rs | 2 ++ crates/engine/src/lib.rs | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..7e8bd9e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,7 +10,7 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))), - cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection), + cmd!(noundo "edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", has_last_selection, restore_selection), cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")), cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")), cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste), @@ -153,8 +153,43 @@ fn shuffle(s: &Session) -> bool { s.edit.edit_mode == EditMode::Shuffle } +const LAST: &str = "selection.last"; + +fn last_selection(s: &Session) -> Option<(Range, Vec)> { + let v = |k: &str| s.edit.values.get(&format!("{LAST}.{k}")).copied().filter(|v| v.is_finite() && *v >= 0.0); + let range = Range::new(v("start")? as Samples, v("end")? as Samples); + let tracks = (0..).map_while(|i| v(&format!("track.{i}"))).map(|t| TrackId(t as u64)).filter(|t| s.track(*t).is_some()).collect(); + Some((range, tracks)) +} + +fn has_last_selection(e: &Engine) -> std::result::Result<(), String> { + if last_selection(e.session()).is_some() { Ok(()) } else { Err("no previous selection to restore".into()) } +} + +/// Remember the selection `before` had when a command changed it, for Restore Last Selection +/// (kept in `edit.values` like the recent playlist, so it is saved with the session). +pub(crate) fn remember_selection(e: &mut Engine, before: &Session) { + let (old, new) = (&before.edit, &e.session().edit); + if old.selection == new.selection && old.selected_tracks == new.selected_tracks { + return; + } + let values = &mut e.session_mut().edit.values; + values.retain(|k, _| !k.starts_with(LAST)); + values.insert(format!("{LAST}.start"), old.selection.start as f64); + values.insert(format!("{LAST}.end"), old.selection.end as f64); + for (i, t) in old.selected_tracks.iter().enumerate() { + values.insert(format!("{LAST}.track.{i}"), t.0 as f64); + } +} + +/// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so +/// invoking it again goes back. fn restore_selection(e: &mut Engine, _: &Value) -> Result { - Ok(json!({"selection": e.session().edit.selection})) + let (range, tracks) = last_selection(e.session()).ok_or_else(|| bad("edit.restore_last_selection", "no previous selection to restore"))?; + let ed = &mut e.session_mut().edit; + ed.selection = range; + ed.selected_tracks = tracks; + Ok(json!({"selection": range})) } fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { @@ -782,4 +817,20 @@ mod tests { e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap(); assert_ne!(e.revision, rev); } + + #[test] + fn restore_last_selection_swaps_back_to_the_previous_range_and_tracks() { + let mut e = Engine::default(); + assert!(e.execute("edit.restore_last_selection", &json!({})).is_err(), "nothing to restore yet"); + let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 1_000, "end": 4_000})).unwrap(); + e.execute("edit.select", &json!({"tracks": [ids[1]], "start": 7_000, "end": 9_000})).unwrap(); + let tracks = |e: &Engine| e.session().edit.selected_tracks.iter().map(|t| t.0).collect::>(); + e.execute("edit.restore_last_selection", &json!({})).unwrap(); + assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(1_000, 4_000)); + assert_eq!(json!(tracks(&e)), json!([ids[0]])); + e.execute("edit.restore_last_selection", &json!({})).unwrap(); + assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(7_000, 9_000)); + assert_eq!(json!(tracks(&e)), json!([ids[1]])); + } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..96ba4a2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -36,6 +36,8 @@ mod view; mod view_more; mod vst3; +pub(crate) use edit::remember_selection; + pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. pub type Enabled = fn(&Engine) -> std::result::Result<(), String>; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534..f928b29 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -282,6 +282,7 @@ impl Engine { }; match result { Ok(v) => { + cmd::remember_selection(self, &before); if spec.undoable && !Arc::ptr_eq(&before, &self.doc) { self.push_undo(spec.label, before); } From 4b98064e460a8e9d59b089c75119e7166933c699 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:47:08 +0100 Subject: [PATCH 071/137] Automation passes replace the automation they play over A write pass added a point every 20 ms of playback and never removed the old points between them, so the old automation survived between the new points and the level jumped between the two rides. A covered window, which runs only once a second since #192, wrote one point a second and kept almost all of the old ride. automation.set_point takes an optional `from`, and the points between `from` and `at` are removed first. Each point of a pass gives the previous one, so it replaces everything since then however far apart the passes run, and Stop writes a last point where playback stopped. A locate or a restart while playing starts afresh, so a jump never erases what it skipped. The test writes over a dense old ride with passes a quarter second apart, jumps twice and stops, then checks each stretch. Removing any of the four changes fails it. --- crates/engine/src/cmd/mix.rs | 21 ++++++++++- crates/ui-egui/src/lib.rs | 73 +++++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 1b9b56d..fb00eb9 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Add Automation Breakpoint", [], None, - "{track?, param: volume|pan|mute|send_a_level|plugin:a:, at, value}", + "{track?, param: volume|pan|mute|send_a_level|plugin:a:, at, value, from?}", has_selection, set_point ), @@ -442,15 +442,21 @@ fn lane_param(p: &Value, cmd: &str) -> Result { str_param(p, "param").map_or(Ok(AutoParam::Volume), |s| AutoParam::parse(s).ok_or_else(|| bad(cmd, format!("unknown automation param `{s}`")))) } +/// With `from` (a write pass's previous point), the points between `from` and `at` are removed first. fn set_point(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "automation.set_point", p)?; let param = lane_param(p, "automation.set_point")?; let at = position_param(e, "automation.set_point", p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); + let from = position_param(e, "automation.set_point", p, "from")?; let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.set_point", "`value` required"))? as f32; let s = e.session_mut(); for t in &tracks { if let Some(tr) = s.track_mut(*t) { - tr.lane_mut(¶m).set_point(at, v); + let lane = tr.lane_mut(¶m); + if let Some(from) = from { + lane.clear_range(from.saturating_add(1), at); + } + lane.set_point(at, v); } } Ok(json!({"at": at, "value": v})) @@ -788,6 +794,17 @@ mod tests { } } + #[test] + fn set_point_from_drops_the_points_in_between() { + let (mut e, t) = tone_session(&[(100, -20.0), (150, -20.0), (200, -20.0), (250, -20.0), (300, -20.0)]); + e.execute("automation.set_point", &json!({"track": t.0, "from": 100, "at": 250, "value": 0.0})).unwrap(); + // A loop wrap: `from` lies after `at`, so nothing goes. + e.execute("automation.set_point", &json!({"track": t.0, "from": 400, "at": 50, "value": -6.0})).unwrap(); + let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap(); + let points: Vec<(i64, f32)> = lane.points.iter().map(|p| (p.at, p.value)).collect(); + assert_eq!(points, [(50, -6.0), (100, -20.0), (250, 0.0), (300, -20.0)]); + } + #[test] fn clip_gain_points_inside_the_selection_are_kept() { let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..fd20957 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -264,6 +264,7 @@ pub struct SoundApp { pub arrange_request: bool, /// Tracks whose automation was touched during this pass (Touch/Latch writing). touched: std::collections::HashSet, + /// 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, pub frame_ms: f32, last_frame: Option, @@ -383,6 +384,7 @@ impl SoundApp { } self.engine.transport.playing = true; self.engine.transport.position = from; + self.last_write_at = i64::MIN; // A fresh start resumes the playhead follow: clear a manual hold and // re-sync the follow tracker so the current view is not mistaken for // an outside move on the first frame. @@ -603,23 +605,21 @@ 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. One undo step per pass. + /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the + /// automation since the previous one, and Stop writes a last point. One undo step per pass. fn write_automation(&mut self, ctx: &egui::Context) { use soundcraft_model::AutomationMode as M; - if !self.is_playing() { - if !self.touched.is_empty() { - self.touched.clear(); - self.engine.end_merge(); - } + let playing = self.is_playing(); + if !playing && self.touched.is_empty() { self.last_write_at = i64::MIN; return; } - let pos = self.position(); + let pos = self.engine.transport.position; let step = self.engine.session().sample_rate.samples(0.02); - if !automation_step_due(pos, self.last_write_at, step) { + if playing && !automation_step_due(pos, self.last_write_at, step) { return; } - self.last_write_at = pos; + let from = std::mem::replace(&mut self.last_write_at, if playing { pos } else { i64::MIN }); let dragging = ctx.input(|i| i.pointer.any_down()); let fader_track = match &self.gesture { Some(Gesture::Fader { track, .. }) => Some(*track), @@ -644,11 +644,15 @@ impl SoundApp { .collect(); for (t, v) in writes { self.touched.insert(t); - let _ = self.engine.execute_merged( - "automation.set_point", - &serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}), - "automation_pass", - ); + let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}); + if from != i64::MIN { + point["from"] = from.into(); + } + let _ = self.engine.execute_merged("automation.set_point", &point, "automation_pass"); + } + if !playing { + self.touched.clear(); + self.engine.end_merge(); } } @@ -718,6 +722,7 @@ impl SoundApp { if let Some(sim) = &mut self.sim { sim.0 = at; } + self.last_write_at = i64::MIN; } self.engine.transport.position = at; } @@ -1060,6 +1065,46 @@ mod tests { assert!(automation_step_due(0, 1_000_000, 960)); } + #[test] + fn a_write_pass_replaces_the_automation_it_plays_over() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let fader = app.engine.session().track_by_name("Kick").unwrap().mixer.volume_db; + let q = i64::from(app.engine.session().sample_rate.hz()) / 4; + // The old ride: a point every 10 ms for 3 s. + for at in (0..12 * q).step_by(usize::try_from(q / 25).unwrap()) { + app.run("automation.set_point", json!({"track": "Kick", "at": at, "value": -30.0})).unwrap(); + } + app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap(); + app.run("transport.locate", json!({"at": 0})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + // Passes a quarter second apart, sparse like a covered window's. + let ctx = egui::Context::default(); + let pass = |app: &mut SoundApp| { + app.handle_transport(0.25); + app.write_automation(&ctx); + }; + pass(&mut app); + pass(&mut app); + // Two jumps: the stretches they skip keep the old ride. + app.run("transport.locate", json!({"at": 4 * q})).unwrap(); + pass(&mut app); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 7 * q, "end": 11 * q})).unwrap(); + app.run("transport.play_selection", json!({})).unwrap(); + pass(&mut app); + app.handle_transport(0.25); + app.run("transport.stop", json!({})).unwrap(); + app.handle_transport(0.0); + app.write_automation(&ctx); + let lane = app.engine.session().track_by_name("Kick").unwrap().lane(&soundcraft_model::AutoParam::Volume).unwrap(); + let values = |from, to| lane.points.iter().filter(|p| (from..=to).contains(&p.at)).map(|p| p.value).collect::>(); + assert_eq!(values(q, 2 * q), [fader; 2]); + assert_eq!(lane.value_at(3 * q, 0.0), -30.0); + assert_eq!(lane.value_at(6 * q, 0.0), -30.0); + // Stop writes the last point, at 9q. + assert_eq!(values(8 * q, 9 * q), [fader; 2]); + assert_eq!(lane.value_at(10 * q, 0.0), -30.0); + } + #[test] fn preferences_saved_before_the_window_was_remembered_keep_full_scale() { let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap(); From d5f069417e5c8940e0ebc08d5b118b31f9c8870e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:47:56 +0200 Subject: [PATCH 072/137] Keep the last selection out of the document so selecting leaves the session clean --- crates/engine/src/cmd/edit.rs | 25 +++++-------------------- crates/engine/src/lib.rs | 5 +++++ 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e8bd9e..0738341 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -153,39 +153,24 @@ fn shuffle(s: &Session) -> bool { s.edit.edit_mode == EditMode::Shuffle } -const LAST: &str = "selection.last"; - -fn last_selection(s: &Session) -> Option<(Range, Vec)> { - let v = |k: &str| s.edit.values.get(&format!("{LAST}.{k}")).copied().filter(|v| v.is_finite() && *v >= 0.0); - let range = Range::new(v("start")? as Samples, v("end")? as Samples); - let tracks = (0..).map_while(|i| v(&format!("track.{i}"))).map(|t| TrackId(t as u64)).filter(|t| s.track(*t).is_some()).collect(); - Some((range, tracks)) -} - fn has_last_selection(e: &Engine) -> std::result::Result<(), String> { - if last_selection(e.session()).is_some() { Ok(()) } else { Err("no previous selection to restore".into()) } + if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) } } -/// Remember the selection `before` had when a command changed it, for Restore Last Selection -/// (kept in `edit.values` like the recent playlist, so it is saved with the session). +/// Remember the selection `before` had when a command changed it, for Restore Last Selection. pub(crate) fn remember_selection(e: &mut Engine, before: &Session) { let (old, new) = (&before.edit, &e.session().edit); if old.selection == new.selection && old.selected_tracks == new.selected_tracks { return; } - let values = &mut e.session_mut().edit.values; - values.retain(|k, _| !k.starts_with(LAST)); - values.insert(format!("{LAST}.start"), old.selection.start as f64); - values.insert(format!("{LAST}.end"), old.selection.end as f64); - for (i, t) in old.selected_tracks.iter().enumerate() { - values.insert(format!("{LAST}.track.{i}"), t.0 as f64); - } + e.last_selection = Some((old.selection, old.selected_tracks.clone())); } /// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so /// invoking it again goes back. fn restore_selection(e: &mut Engine, _: &Value) -> Result { - let (range, tracks) = last_selection(e.session()).ok_or_else(|| bad("edit.restore_last_selection", "no previous selection to restore"))?; + let (range, mut tracks) = e.last_selection.clone().ok_or_else(|| bad("edit.restore_last_selection", "no previous selection to restore"))?; + tracks.retain(|t| e.session().track(*t).is_some()); let ed = &mut e.session_mut().edit; ed.selection = range; ed.selected_tracks = tracks; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f928b29..ba9f67e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -120,6 +120,9 @@ pub struct Engine { merge_key: Option, /// Messages for the UI status line / agents. pub messages: Vec, + /// The edit selection (range, tracks) before the last change, for Restore Last Selection. + /// Kept out of the document so selecting never marks the session modified. + pub last_selection: Option<(soundcraft_time::Range, Vec)>, } impl Default for Engine { @@ -148,6 +151,7 @@ impl Engine { dirty_at: Cell::new((0, false)), merge_key: None, messages: Vec::new(), + last_selection: None, } } @@ -181,6 +185,7 @@ impl Engine { self.saved = Arc::clone(&self.doc); self.undo.clear(); self.redo.clear(); + self.last_selection = None; self.revision = self.revision.wrapping_add(1); self.dirty = false; } From 4bacfb13d5437837b126b32c0d02f81f606b4109 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:48:50 +0200 Subject: [PATCH 073/137] Apply MIDI track offsets when scheduling notes (fixes #234) event.midi_track_offsets stored midi.offset. but the note scheduler never read it, so the render was unchanged. schedule_notes now looks that many samples back into the clips, which plays the track exactly as if its MIDI had been moved by the offset. The scheduler is shared by offline bounce and realtime playback. --- crates/engine/tests/midi_offset.rs | 30 ++++++++++++++++++++++++++++++ crates/mix/src/lib.rs | 4 ++++ 2 files changed, 34 insertions(+) create mode 100644 crates/engine/tests/midi_offset.rs diff --git a/crates/engine/tests/midi_offset.rs b/crates/engine/tests/midi_offset.rs new file mode 100644 index 0000000..e4a1b1b --- /dev/null +++ b/crates/engine/tests/midi_offset.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_time::Range; + +fn render(e: &Engine) -> Vec> { + soundcraft_mix::render_range(e.session(), Range::new(0, 72_000), 512) +} + +#[test] +fn midi_track_offset_moves_the_notes_like_moving_the_clip() { + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Timing", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + let r = e.execute("midi.new_clip", &json!({"track": track, "start": 12_000, "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(); + let baseline = render(&e); + assert!(baseline.iter().flatten().any(|x| x.abs() > 1e-6), "the fixture must make sound"); + + e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 24_000})).unwrap(); + let offset = render(&e); + assert!(offset != baseline, "the offset must change the render"); + + e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 0})).unwrap(); + assert!(render(&e) == baseline, "a zero offset plays as before"); + + // The offset shifts playback by exactly as much as moving the clip would. + 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"); +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..064116e 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1308,6 +1308,10 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut /// Send note on/offs for MIDI clips overlapping the block. fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec) { let sr = s.sample_rate; + // MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so + // the block looks that far back into the clips. + let offset = if s.edit.values.is_empty() { 0 } else { s.edit.value(&format!("midi.offset.{}", t.id.0), 0.0) as i64 }; + let pos = pos.saturating_sub(offset); let end = pos.saturating_add(frames as i64); for clip in t.clips() { if clip.muted { From 29497296a1e3c9a72caa74413be746b5c5e35dc8 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:50:04 -0400 Subject: [PATCH 074/137] Engine: check automation read and write roots on every path a command reads or writes --- crates/engine/src/clip_group_file.rs | 14 +- crates/engine/src/cmd/file.rs | 8 +- crates/engine/src/cmd/sampler.rs | 2 +- crates/engine/src/cmd/setup_more.rs | 4 +- crates/engine/src/cmd/track_more.rs | 2 +- crates/engine/src/cmd/video.rs | 2 +- crates/engine/src/file_access.rs | 487 +++++++++++++++++++++++++++ crates/engine/src/io.rs | 83 ++++- crates/engine/src/lib.rs | 1 + 9 files changed, 578 insertions(+), 25 deletions(-) create mode 100644 crates/engine/src/file_access.rs diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index c889c99..607d5b5 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -31,6 +31,9 @@ struct GroupFile { /// Write the clips `ids` (grouped by track) to `path`. Returns the number of clips written. pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { + let p = with_extension(path); + // First, so a refused group file leaves nothing behind and is refused whatever else is wrong. + crate::file_access::check_write(&p).map_err(EngineError::Io)?; let s = e.session(); let mut lanes: Vec<(usize, Vec)> = Vec::new(); for id in ids { @@ -47,7 +50,6 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { }; let n = clips.len(); lanes.sort_by_key(|(i, _)| *i); - let p = with_extension(path); let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string(); let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem)); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); @@ -62,10 +64,10 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { && let (Some(buf), Some(src)) = (s.pool.get(source), s.sources.iter().find(|x| x.id == source)) { let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0); - std::fs::create_dir_all(dir.join(&audio_dir_name)).map_err(|err| EngineError::Io(format!("{audio_dir_name}: {err}")))?; + crate::io::make_dir(dir.join(&audio_dir_name))?; let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(dir.join(&rel), bytes).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; + crate::io::write_path(dir.join(&rel), bytes)?; slot.insert(rel); } lane.push(c); @@ -74,7 +76,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { } let file = GroupFile { format: FORMAT.into(), version: 1, sample_rate: s.sample_rate.hz(), length: end - start, lanes: out_lanes, audio }; let text = serde_json::to_string_pretty(&file).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&p, text).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?; + crate::io::write_path(&p, text)?; Ok(n) } @@ -82,7 +84,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { /// are made when the session runs out). Returns the ids of the placed clips. pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -> Result> { let id = "file.import_clip_groups"; - let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text = crate::io::read_text(path)?; let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; if file.format != FORMAT { return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into())); @@ -98,7 +100,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - if !full.starts_with(&dir) || rel.contains("..") { return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); } - let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?; + let bytes = crate::io::read_file(&full)?; let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; decoded.insert(*old, (rel.clone(), buf)); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf8..df0b3ae 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { cmd!("file.import_midi", "MIDI...", ["File", "Import"], Some("Cmd+Alt+I"), "{path, at?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.import_midi", "`path` required"))?.to_string(); let at = position_param(e, "file.import_midi", p, "at")?.unwrap_or(0); - let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let bytes = crate::io::read_file(&path)?; let tracks = crate::io::import_midi_bytes(e, &bytes, at, bool_or(p, "tempo_map", true))?; Ok(json!({"tracks": tracks})) }), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.import_video", "`path` required"))?.to_string(); let at = position_param(e, "file.import_video", p, "at")?.unwrap_or(0).max(0); - let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let bytes = crate::io::read_file(&path)?; crate::io::import_video_bytes(e, &path, bytes, at) } ), @@ -194,6 +194,8 @@ pub fn specs() -> Vec { /// Writing a session over an existing file needs `replace: true` (see `io::replaces`). fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> { + // First, so a path outside the write root is refused as such and never reported as existing. + crate::file_access::check_write(file).map_err(EngineError::Io)?; if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) { return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display()))); } @@ -231,7 +233,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result { let target = import_audio_target(e, p)?; let mut out = Vec::new(); for path in &paths { - let bytes = std::fs::read(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let bytes = crate::io::read_file(path)?; let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?; out.push(r); diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs index ea659a8..7bac7e8 100644 --- a/crates/engine/src/cmd/sampler.rs +++ b/crates/engine/src/cmd/sampler.rs @@ -55,7 +55,7 @@ fn load(e: &mut Engine, p: &Value) -> Result { values.push((k.clone(), pi.clamp(x as f32))); } } - let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let bytes = crate::io::read_file(&path)?; let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 1d21e99..8fd8809 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -404,7 +404,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result { /// Decode a source file of another session (paths relative to that session's folder). fn load_source(dir: &Path, src: &Source, sr: u32) -> Option { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; - let bytes = std::fs::read(&p).ok()?; + let bytes = crate::io::read_file(&p).ok()?; let (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?; if buf.sample_rate != sr && buf.sample_rate > 0 { buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr); @@ -416,7 +416,7 @@ fn load_source(dir: &Path, src: &Source, sr: u32) -> Option Result { let id = "file.import_session_data"; let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string(); - let text_v = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text_v = crate::io::read_text(&path)?; let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default(); let names: Option> = diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index 8818342..30b1b32 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -271,7 +271,7 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result { let id = "track.load_preset"; let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string(); let tracks = tracks_required(e, id, p)?; - let text = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text = crate::io::read_text(&path)?; let v: Value = serde_json::from_str(&text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; if v.get("format").and_then(Value::as_str) != Some(PRESET_FORMAT) { return Err(EngineError::Io(format!("{path}: not a SoundCraft track preset"))); diff --git a/crates/engine/src/cmd/video.rs b/crates/engine/src/cmd/video.rs index 5277ca5..f361e0d 100644 --- a/crates/engine/src/cmd/video.rs +++ b/crates/engine/src/cmd/video.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { .map(|v| { let clips = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| c.video_source() == Some(v.id)).count(); json!({"id": v.id, "name": v.name, "path": v.path, "width": v.width, "height": v.height, "frame_rate": v.frame_rate, - "duration": v.duration, "codec": v.codec, "clips": clips, "file_found": std::path::Path::new(&v.path).is_file()}) + "duration": v.duration, "codec": v.codec, "clips": clips, "file_found": crate::io::file_found(&v.path)}) }) .collect(); Ok(json!({"videos": rows, "online": !s.edit.flag(OFFLINE_FLAG)})) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 0000000..be4ea9d --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,487 @@ +//! The folders automation may read and write. +//! +//! `soundcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in +//! force, every path a command reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`soundcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: impl AsRef, access: Access) -> Result<(), String> { + let path = path.as_ref(); + let shown_path = path.display(); + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown_path}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown_path}")), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path.as_ref(), Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path.as_ref(), Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &Path) -> Result { + if path.as_os_str().is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {}", path.display())); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => { + return Err(format!("automation path rejected: a link that leads nowhere: {}", path.display())); + } + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {}", path.display())); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("sc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.scraft"), "x").unwrap(); + std::fs::write(base.join("outside/secret.scraft"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(s(base.join("in/a.scraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(s(base.join("in/sub/new.scraft")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(s(base.join("out/new/deeper/x.wav")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(s(base.join("outside/secret.scraft")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(s(base.join("in/a.scraft")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(s(base.join("out/x.wav")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.scraft"), "x").unwrap(); + assert!(roots.check(s(base.join("in2/b.scraft")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(format!("{}2/c.scraft", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.scraft", s(base.join("in"))); + assert!(roots.check(up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.scraft", s(base.join("in/sub"))); + assert_eq!(roots.check(back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.scraft", s(base.join("out"))); + let e = roots.check(missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.scraft", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.scraft"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.scraft"), &base.join("in/leak.scraft")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(s(base.join("in/leak.scraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(s(base.join("out/away/new.wav")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.scraft"), &base.join("out/dangling.wav"))); + let e = roots.check(s(base.join("out/dangling.wav")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(s(base.join("in/alias/x.scraft")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/a.scraft")); + assert_eq!(roots.check(upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(s(base.join("In/Sub/New.scraft")), Access::Read), Ok(())); + let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.scraft")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.scraft", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.scraft", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`read_file`, `write_file` + /// and the other checked helpers there), which check the roots. Engine code that reaches for + /// the file system itself would bypass them, so none may: every file under `src/` except + /// `io.rs`, this module and the test files. Audio is decoded into the session when it enters + /// it (import, open, sampler load), so playback and bounce read nothing from disk and need no + /// check of their own. No engine module reads a fixed folder (plug-in scans live in the host + /// crates), so nothing else is skipped. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // `io` and this module are the checked gateway; `tests*.rs` are test-only modules. + if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Test modules (which build their own fixtures) come last in each file. + let code = text.split("#[cfg(test)]").next().unwrap_or(""); + for (n, line) in code.lines().enumerate() { + let line = line.trim_start(); + let touches = + ["std::fs", "fs::", "File::open", "File::create", "OpenOptions", ".is_file()", ".exists()", ".is_dir()", "canonicalize"] + .iter() + .any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use the checked helpers in crate::io (read_file, write_file, ...): {offenders:#?}"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..cc5311b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -11,6 +11,50 @@ use std::sync::Arc; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. const MAX_IMPORT_SAMPLES: usize = 1 << 30; +// ─── Checked file access ────────────────────────────────────────────────────────────────────── +// +// Every file a command names by path is read and written through the helpers below, so the +// automation roots (`crate::file_access`, `soundcraft-cli mcp --automation-read-root` and +// `--automation-write-root`) are checked in one place. A test in `file_access` keeps the rest of +// the engine from calling `std::fs` itself. Audio is decoded into the session's pool the moment it +// enters it (import, open, sampler load), so playback and bounce read nothing from disk and +// need no check of their own. + +/// The bytes of the file at `path`, which must be inside the automation read root when there is one. +pub fn read_file(path: impl AsRef) -> Result> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(EngineError::Io)?; + std::fs::read(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display()))) +} + +/// [`read_file`] for a text file. +pub fn read_text(path: impl AsRef) -> Result { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(EngineError::Io)?; + std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display()))) +} + +/// Is there a file at `path` the automation read root lets us see? A path outside the root is +/// reported as not found, so a command can't probe for files beyond it. +pub fn file_found(path: impl AsRef) -> bool { + let path = path.as_ref(); + crate::file_access::check_read(path).is_ok() && path.is_file() +} + +/// Create `dir` and its parents, inside the automation write root when there is one. +pub(crate) fn make_dir(dir: impl AsRef) -> Result<()> { + let dir = dir.as_ref(); + crate::file_access::check_write(dir).map_err(EngineError::Io)?; + std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display()))) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +pub(crate) fn write_path(path: impl AsRef, bytes: impl AsRef<[u8]>) -> Result<()> { + let path = path.as_ref(); + crate::file_access::check_write(path).map_err(EngineError::Io)?; + std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display()))) +} + /// A relative `Audio Files/` path no other source in `s` already uses. fn unique_audio_rel(s: &Session, stem: &str) -> String { let stem = sanitize_name(stem); @@ -152,12 +196,13 @@ pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec { let mut missing = Vec::new(); for v in &mut s.videos { let p = if Path::new(&v.path).is_absolute() { PathBuf::from(&v.path) } else { session_dir.join(&v.path) }; - if p.is_file() { + if file_found(&p) { continue; } let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default(); - let near = - [session_dir.join(&name), session_dir.join("Video Files").join(&name)].into_iter().find(|c| !name.as_os_str().is_empty() && c.is_file()); + let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)] + .into_iter() + .find(|c| !name.as_os_str().is_empty() && file_found(c)); match near { Some(c) => v.path = c.to_string_lossy().into_owned(), None => missing.push(format!("{} (video)", p.display())), @@ -303,7 +348,7 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re /// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`. pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result> { let s = e.session(); - std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; + make_dir(dir)?; let ext = match opts.format { FileFormat::Aiff => "aif", FileFormat::Flac => "flac", @@ -325,7 +370,7 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?; let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name))); - std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; + write_path(&path, bytes)?; out.push(path.to_string_lossy().into_owned()); } Ok(out) @@ -479,7 +524,7 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result { let s = e.session(); - std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; + make_dir(dir)?; let mut n = 0; for id in ids { let Some((t, c)) = s.find_clip(*id) else { continue }; @@ -496,7 +541,7 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> return Err(EngineError::Io(format!("{dir}: too many files named {stem}"))); } } - std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; + write_path(&path, bytes)?; n += 1; } Ok(n) @@ -541,6 +586,8 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result { /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { let p = session_file(path); + // Before any audio is written: a session saved outside the write root changes nothing. + crate::file_access::check_write(&p).map_err(EngineError::Io)?; let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); @@ -568,7 +615,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess continue; } let Some(audio) = s.pool.get(src.id) else { continue }; - std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; + make_dir(&audio_dir)?; let mut stem = sanitize_name(&src.name); if overwrite { // One file per source id so autosaves never collide or pile up. @@ -595,7 +642,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; + make_dir(&dir)?; // Atomic-ish write: temp file then rename. let tmp = p.with_extension("scraft.tmp"); if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) { @@ -608,6 +655,8 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess /// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the /// old or the new contents, never a mix. pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> { + crate::file_access::check_write(file).map_err(EngineError::Io)?; + crate::file_access::check_write(tmp).map_err(EngineError::Io)?; if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) { let _ = std::fs::remove_file(tmp); return Err(EngineError::Io(format!("{}: {err}", file.display()))); @@ -636,6 +685,10 @@ fn back_up(file: &Path) { return; }; let backups = dir.join("Backups"); + if let Err(err) = crate::file_access::check_write(&backups) { + log::warn!("no backup of {}: {err}", file.display()); + return; + } let prefix = format!("{stem} backup "); let suffix = format!(".{ext}"); let numbers: Vec = std::fs::read_dir(&backups) @@ -659,7 +712,9 @@ fn back_up(file: &Path) { /// extension and isn't an existing file. pub fn session_file(path: &str) -> PathBuf { let p = PathBuf::from(path); - if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p } + // Only a path the write root allows is probed, so a refused one can't be used to test for files. + let is_file = crate::file_access::check_write(&p).is_ok() && p.is_file(); + if p.extension().is_none() && !is_file { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p } } /// True when `command` would write a session over the existing `file`. Save, Save As and Save @@ -708,12 +763,17 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> { /// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal). pub fn open_session(e: &mut Engine, path: &str) -> Result> { - let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text = read_text(path)?; let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?; let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); let mut missing = Vec::new(); for src in s.sources.clone() { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; + // A session file is hostile input too: a path in it that leaves the read root is not read. + if let Err(why) = crate::file_access::check_read(&p) { + missing.push(why); + continue; + } match std::fs::read(&p) { Ok(bytes) => { if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) { @@ -774,6 +834,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); return deliver(name, bytes).map_err(EngineError::Io); } + crate::file_access::check_write(path).map_err(EngineError::Io)?; std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}"))) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534..c7c44b9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod clip_group_file; pub mod cmd; pub mod demo; pub mod edit; +pub mod file_access; pub mod inspect; pub mod io; pub mod score; From 57baab86d4c3ce47f9d2189c646f38e23b28dda0 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:50:05 -0400 Subject: [PATCH 075/137] MCP: confine the headless session to the automation roots --- crates/automation/src/lib.rs | 23 +- crates/automation/tests/automation_roots.rs | 352 ++++++++++++++++++++ 2 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 crates/automation/tests/automation_roots.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 84d4e23..17c3a5b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -11,8 +11,10 @@ pub mod mcp; use serde_json::{Value, json}; use soundcraft_engine::Engine; +use soundcraft_engine::file_access::{self, AutomationRoots}; use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; +use std::sync::Arc; use std::time::Duration; /// Answers control-protocol methods. @@ -26,16 +28,22 @@ pub trait Backend { /// An in-process engine (no UI). pub struct Headless { pub engine: Engine, + roots: Option>, } impl Headless { pub fn new(engine: Engine) -> Self { - Headless { engine } + Headless { engine, roots: None } } -} -impl Backend for Headless { - fn call(&mut self, method: &str, params: Value) -> Result { + /// Confine every file the session's commands read and write to `roots` + /// (`soundcraft-cli mcp --automation-read-root … --automation-write-root …`). + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + self.roots = roots; + self + } + + fn answer(&mut self, method: &str, params: Value) -> Result { match method { "engine.execute" | "command" => { let id = params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).ok_or("`command` required")?; @@ -56,6 +64,13 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.answer(method, params)) + } fn has_ui(&self) -> bool { false } diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/automation_roots.rs new file mode 100644 index 0000000..2c4fa16 --- /dev/null +++ b/crates/automation/tests/automation_roots.rs @@ -0,0 +1,352 @@ +//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads +//! stays inside the read root, every file it writes inside the write root. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use soundcraft_automation::{Headless, mcp::Server}; +use soundcraft_engine::Engine; +use soundcraft_engine::file_access::AutomationRoots; +use std::path::{Path, PathBuf}; + +/// A marker that must never come back from a file outside the read root. +const SECRET: &str = "TOP-SECRET-MARKER-5f3a"; + +struct Fixture { + base: PathBuf, + read: PathBuf, + write: PathBuf, + outside: PathBuf, +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } +} + +fn s(p: impl AsRef) -> String { + p.as_ref().to_string_lossy().into_owned() +} + +/// `in/` (read root), `out/` (write root) and `outside/`, each holding the same real files: +/// a session with audio, a WAV, a MIDI file, a track preset and a clip group file. `outside/` +/// also holds text files holding [`SECRET`] under the names a command might open. +fn fixture(name: &str) -> Fixture { + let base = std::env::temp_dir().join(format!("sc-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("outside")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + // Real files, made by an unconfined engine. + let mut e = soundcraft_engine::demo::demo_engine(); + for dir in [&read, &outside] { + let run = |e: &mut Engine, id: &str, p: Value| e.execute(id, &p).unwrap_or_else(|err| panic!("{id}: {err}")); + run(&mut e, "file.bounce_mix", json!({"path": s(dir.join("tone.wav")), "start": 0, "end": 4800})); + run(&mut e, "file.export_midi", json!({"path": s(dir.join("keys.mid")), "tracks": ["Keys"]})); + run(&mut e, "edit.select", json!({"tracks": ["Bass"]})); + run(&mut e, "track.save_preset", json!({"path": s(dir.join("bass.json"))})); + run(&mut e, "session.save_as", json!({"path": s(dir.join("song.scraft")), "replace": true})); + let clips = e.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect::>(); + run(&mut e, "file.export_clip_groups", json!({"path": s(dir.join("group.scgrp")), "clips": clips})); + } + for f in ["secret.wav", "secret.mid", "secret.scraft", "secret.json", "secret.scgrp", "secret.mp4"] { + std::fs::write(outside.join(f), SECRET).unwrap(); + } + Fixture { base, read, write, outside } +} + +fn server(read: Option<&Path>, write: Option<&Path>) -> Server { + let roots = AutomationRoots::new(read, write).unwrap(); + Server::new(Headless::new(Engine::default()).with_automation_roots(roots)) +} + +/// One `execute` call: whether it failed and the text of the result. +fn run(s: &mut Server, command: &str, params: Value) -> (bool, String) { + let r = s + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "execute", "arguments": {"command": command, "params": params}}})) + .unwrap(); + let res = &r["result"]; + (res["isError"].as_bool().unwrap(), res["content"][0]["text"].as_str().unwrap().to_string()) +} + +fn ok(s: &mut Server, command: &str, params: Value) -> String { + let (failed, text) = run(s, command, params); + assert!(!failed, "{command} failed: {text}"); + text +} + +fn refused(s: &mut Server, command: &str, params: Value, needle: &str) { + let (failed, text) = run(s, command, params.clone()); + assert!(failed, "{command} {params} must be refused, got {text}"); + assert!(text.contains(needle), "{command}: {text}"); + assert!(!text.contains(SECRET), "{command} leaked file contents: {text}"); +} + +/// Every file under `dir`, to show nothing was written. +fn listing(dir: &Path) -> Vec { + let mut out = vec![]; + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let p = entry.path(); + if p.is_dir() { + out.extend(listing(&p)); + } + out.push(s(&p)); + } + out.sort(); + out +} + +fn inspect(s: &mut Server) -> Value { + let r = s + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "inspect_session", "arguments": {"detail": "full"}}})) + .unwrap(); + serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap() +} + +/// Every command that reads a user-named file, with params for a file in `dir`. +fn reads(dir: &Path, name: &dyn Fn(&str) -> String) -> Vec<(&'static str, Value)> { + let p = |f: &str| s(dir.join(name(f))); + vec![ + ("session.open", json!({"path": p("song.scraft")})), + ("file.import_audio", json!({"path": p("tone.wav")})), + ("file.import_audio", json!({"paths": [p("tone.wav")]})), + ("file.import_midi", json!({"path": p("keys.mid")})), + ("file.import_video", json!({"path": p("movie.mp4")})), + ("file.import_clip_groups", json!({"path": p("group.scgrp")})), + ("file.import_session_data", json!({"path": p("song.scraft")})), + ("track.load_preset", json!({"path": p("bass.json"), "tracks": ["Bass"]})), + ] +} + +#[test] +fn reads_outside_the_read_root_are_refused_and_nothing_reaches_the_result() { + let f = fixture("reads"); + let mut srv = server(Some(&f.read), Some(&f.write)); + ok(&mut srv, "session.new", json!({"name": "Work"})); + ok(&mut srv, "track.new", json!({"name": "Bass", "count": 1})); + ok(&mut srv, "track.new", json!({"name": "Pads", "count": 1, "kind": "instrument"})); + let before = inspect(&mut srv); + // The secret files hold the marker under the names the commands read, so a read would show. + let secret_name = |f: &str| format!("secret.{}", f.rsplit('.').next().unwrap()); + for (command, params) in reads(&f.outside, &secret_name) { + refused(&mut srv, command, params, "outside the read root"); + } + refused(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.outside.join("secret.wav"))}), "outside the read root"); + // The open session is exactly as it was: nothing was imported or opened. + assert_eq!(inspect(&mut srv), before); + // The same commands work on files inside the root. + ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); + ok(&mut srv, "file.import_midi", json!({"path": s(f.read.join("keys.mid"))})); + ok(&mut srv, "file.import_clip_groups", json!({"path": s(f.read.join("group.scgrp"))})); + ok(&mut srv, "file.import_session_data", json!({"path": s(f.read.join("song.scraft")), "tracks": ["Kick"]})); + ok(&mut srv, "track.load_preset", json!({"path": s(f.read.join("bass.json")), "tracks": ["Bass"]})); + ok(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.read.join("tone.wav"))})); + // A missing movie inside the root is an ordinary error, not a refusal. + let (failed, text) = run(&mut srv, "file.import_video", json!({"path": s(f.read.join("movie.mp4"))})); + assert!(failed && !text.contains("read root"), "{text}"); + ok(&mut srv, "session.open", json!({"path": s(f.read.join("song.scraft"))})); + // The typed tools take the same path through the same checks. + let r = srv + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "open_session", "arguments": {"path": s(f.outside.join("secret.scraft"))}}})) + .unwrap(); + assert_eq!(r["result"]["isError"], true); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root")); +} + +/// A clip of the demo session. +fn kick_clip() -> u64 { + soundcraft_engine::demo::demo_engine().session().track_by_name("Kick").unwrap().clips()[0].id.0 +} + +/// Every command that writes a user-named file or folder, with params for `dir`. +fn writes(dir: &Path) -> Vec<(&'static str, Value)> { + let p = |f: &str| s(dir.join(f)); + let clip = kick_clip(); + vec![ + ("session.save", json!({"path": p("a.scraft")})), + ("session.save_as", json!({"path": p("b.scraft")})), + ("session.save_copy", json!({"path": p("c.scraft")})), + ("session.save_template", json!({"path": p("d.scraft")})), + ("file.bounce_mix", json!({"path": p("e.wav"), "start": 0, "end": 4800})), + ("file.bounce_stems", json!({"dir": p("stems"), "start": 0, "end": 4800})), + ("file.export_midi", json!({"path": p("f.mid")})), + ("file.export_session_text", json!({"path": p("g.txt")})), + ("file.export_clips", json!({"dir": p("clips"), "clips": [clip]})), + ("file.export_selected_tracks_as_session", json!({"path": p("h.scraft"), "tracks": ["Kick"]})), + ("file.export_sibelius", json!({"path": p("i.musicxml")})), + ("file.send_to_sibelius", json!({"path": p("j.musicxml")})), + ("file.print_score", json!({"path": p("k.svg")})), + ("file.export_clip_groups", json!({"path": p("l.scgrp"), "clips": [clip]})), + ("track.save_preset", json!({"path": p("m.json"), "tracks": ["Kick"]})), + ] +} + +fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server { + let roots = AutomationRoots::new(read, write).unwrap(); + Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots)) +} + +#[test] +fn every_writing_command_is_refused_outside_the_write_root_and_writes_nothing() { + let f = fixture("writes"); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + let before = listing(&f.base); + let outside_before = listing(&f.outside); + for (command, params) in writes(&f.outside) { + refused(&mut srv, command, params, "outside the write root"); + } + // Not even the temporary file a save goes through, nor a folder. + assert_eq!(listing(&f.base), before, "nothing may appear anywhere"); + // With no path, the score goes to the temp folder: outside the write root, so refused too. + refused(&mut srv, "file.send_to_sibelius", json!({}), "outside the write root"); + refused(&mut srv, "file.print_score", json!({}), "outside the write root"); + assert_eq!(listing(&f.base), before); + // Inside the write root every one of them works. + for (command, params) in writes(&f.write) { + let text = ok(&mut srv, command, params); + assert!(!text.is_empty(), "{command}"); + } + assert!(f.write.join("e.wav").is_file() && f.write.join("stems").is_dir() && f.write.join("l.scgrp").is_file()); + // A save puts the audio in `Audio Files/` beside it, inside the root as well. + assert!(f.write.join("Audio Files").is_dir()); + assert_eq!(listing(&f.outside), outside_before, "outside is as it was"); +} + +#[test] +fn saving_over_an_existing_file_outside_does_not_report_that_it_exists() { + let f = fixture("exists"); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + // Without `replace`, an existing target is reported as existing: not for a path outside the root. + refused(&mut srv, "session.save_as", json!({"path": s(f.outside.join("song.scraft"))}), "outside the write root"); +} + +#[test] +fn the_typed_bounce_and_save_tools_are_confined() { + let f = fixture("typed"); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + for (tool, args) in [ + ("bounce_mix", json!({"path": s(f.outside.join("x.wav")), "start": 0, "end": 4800})), + ("save_session", json!({"path": s(f.outside.join("x.scraft"))})), + ] { + let r = srv.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}})).unwrap(); + assert_eq!(r["result"]["isError"], true, "{tool}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the write root"), "{tool}"); + } + assert!(!f.outside.join("x.wav").exists() && !f.outside.join("x.scraft").exists()); + let r = srv + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "bounce_mix", "arguments": {"path": s(f.write.join("x.wav")), "start": 0, "end": 4800}}})) + .unwrap(); + assert_eq!(r["result"]["isError"], false); +} + +#[test] +fn one_root_grants_only_its_own_access() { + let f = fixture("one-root"); + // Read root only: reads work, nothing can be written anywhere. + let mut srv = demo_server(Some(&f.read), None); + ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); + let before = listing(&f.base); + for (command, params) in writes(&f.write) { + refused(&mut srv, command, params, "write authority is absent"); + } + for (command, params) in writes(&f.read) { + refused(&mut srv, command, params, "write authority is absent"); + } + assert_eq!(listing(&f.base), before); + // Write root only: writes work, nothing can be read anywhere. + let mut srv = demo_server(None, Some(&f.write)); + for (command, params) in writes(&f.write) { + ok(&mut srv, command, params); + } + let name = |f: &str| f.to_string(); + for (command, params) in reads(&f.read, &name) { + refused(&mut srv, command, params, "read authority is absent"); + } + refused(&mut srv, "file.import_audio", json!({"path": s(f.write.join("e.wav"))}), "read authority is absent"); +} + +#[test] +fn the_roots_may_be_the_same_folder_and_are_compared_by_whole_names() { + let f = fixture("same"); + let mut srv = demo_server(Some(&f.read), Some(&f.read)); + ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); + ok(&mut srv, "file.bounce_mix", json!({"path": s(f.read.join("again.wav")), "start": 0, "end": 4800})); + // `in2` shares the prefix of `in` and is a different folder. + let sibling = f.base.join("in2"); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::copy(f.read.join("tone.wav"), sibling.join("tone.wav")).unwrap(); + refused(&mut srv, "file.import_audio", json!({"path": s(sibling.join("tone.wav"))}), "outside the read root"); + refused(&mut srv, "file.bounce_mix", json!({"path": s(sibling.join("x.wav"))}), "outside the write root"); + // `..` out of the root is outside it. + let up = format!("{}/../outside/tone.wav", s(&f.read)); + refused(&mut srv, "file.import_audio", json!({"path": up}), "outside the read root"); +} + +#[cfg(unix)] +#[test] +fn links_out_of_the_roots_are_refused() { + use std::os::unix::fs::symlink; + let f = fixture("links"); + symlink(f.outside.join("tone.wav"), f.read.join("leak.wav")).unwrap(); + symlink(&f.outside, f.write.join("away")).unwrap(); + symlink(f.outside.join("not-yet.wav"), f.write.join("dangling.wav")).unwrap(); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + refused(&mut srv, "file.import_audio", json!({"path": s(f.read.join("leak.wav"))}), "outside the read root"); + let before = listing(&f.outside); + refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("away/x.wav"))}), "outside the write root"); + refused(&mut srv, "file.bounce_stems", json!({"dir": s(f.write.join("away/stems"))}), "outside the write root"); + refused(&mut srv, "session.save_as", json!({"path": s(f.write.join("away/x.scraft"))}), "outside the write root"); + refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("dangling.wav"))}), "a link that leads nowhere"); + assert_eq!(listing(&f.outside), before, "nothing was written through a link"); + assert!(!f.outside.join("not-yet.wav").exists()); + // A link to a folder inside the root stays inside it. + symlink(f.read.join("."), f.base.join("in-alias")).unwrap(); + let mut via_alias = demo_server(Some(&f.base.join("in-alias")), None); + ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); +} + +/// A session file is hostile input too: media it names outside the read root is not loaded, +/// and the refusal is listed with the other missing media. +#[test] +fn media_a_session_names_outside_the_read_root_is_not_loaded() { + let f = fixture("media"); + let session = f.read.join("song.scraft"); + let text = std::fs::read_to_string(&session).unwrap(); + let outside_wav = s(f.outside.join("tone.wav")); + let swapped = { + let v: Value = serde_json::from_str(&text).unwrap(); + let mut rel = vec![]; + for src in v["sources"].as_array().unwrap() { + rel.push(src["path"].as_str().unwrap().to_string()); + } + assert!(!rel.is_empty()); + rel.iter().fold(text.clone(), |t, r| t.replace(&format!("\"{r}\""), &format!("{}", json!(outside_wav)))) + }; + std::fs::write(&session, swapped).unwrap(); + let mut srv = server(Some(&f.read), None); + let opened = ok(&mut srv, "session.open", json!({"path": s(&session)})); + let opened: Value = serde_json::from_str(&opened).unwrap(); + let missing = opened["missing"].as_array().unwrap(); + assert!(!missing.is_empty() && missing.iter().all(|m| m.as_str().unwrap().contains("outside the read root")), "{opened}"); + let sources = inspect(&mut srv)["sources"].clone(); + assert!(sources.as_array().unwrap().iter().all(|x| x["loaded"] == false), "{sources}"); + // The same session opened without roots does load the file it names. + let mut free = server(None, None); + ok(&mut free, "session.open", json!({"path": s(&session)})); + assert!(inspect(&mut free)["sources"].as_array().unwrap().iter().all(|x| x["loaded"] == true)); +} + +#[test] +fn without_roots_nothing_is_confined() { + let f = fixture("free"); + let mut srv = server(None, None); + ok(&mut srv, "file.import_audio", json!({"path": s(f.outside.join("tone.wav"))})); + ok(&mut srv, "file.bounce_mix", json!({"path": s(f.outside.join("free.wav")), "start": 0, "end": 4800})); + assert!(f.outside.join("free.wav").is_file()); +} From a9fa027528eb1c3f1977c294e0f23ebd0ac4d68f Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:50:05 -0400 Subject: [PATCH 076/137] CLI: add --automation-read-root and --automation-write-root to mcp --- README.md | 3 + apps/soundcraft-cli/src/automation_args.rs | 90 +++++++++++++ apps/soundcraft-cli/src/main.rs | 64 +++++++++- apps/soundcraft-cli/tests/cli.rs | 140 +++++++++++++++++++++ 4 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 apps/soundcraft-cli/src/automation_args.rs create mode 100644 apps/soundcraft-cli/tests/cli.rs diff --git a/README.md b/README.md index 1d5f86a..789d0a8 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,9 @@ soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}' # Give Claude a DAW. claude mcp add soundcraft -- soundcraft-cli mcp --demo + +# Or keep the agent to some folders (reads and writes are separate roots). +soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR ``` See [`docs/control-protocol.md`](docs/control-protocol.md) and [`docs/mcp.md`](docs/mcp.md). diff --git a/apps/soundcraft-cli/src/automation_args.rs b/apps/soundcraft-cli/src/automation_args.rs new file mode 100644 index 0000000..f874226 --- /dev/null +++ b/apps/soundcraft-cli/src/automation_args.rs @@ -0,0 +1,90 @@ +//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed +//! flag must not silently remove a root and leave the server unconfined. + +use std::path::PathBuf; + +pub struct AutomationArgs { + pub read_root: Option, + pub write_root: Option, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option, write_root: Option) -> Self { + Self { read_root, write_root, read_given: false, write_given: false } + } + + /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, + /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); + let (root, given) = match flag { + "--automation-read-root" => (&mut self.read_root, &mut self.read_given), + "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + _ => return Ok(false), + }; + if *given { + return Err(format!("{flag} given twice")); + } + let value = match inline { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?; + if value.starts_with('-') { + return Err(format!("{flag} needs a folder; use {flag}= for a folder starting with '-'")); + } + value + } + }; + if value.is_empty() { + return Err(format!("{flag} needs a non-empty folder")); + } + *root = Some(value.into()); + *given = true; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_are_taken_once_and_other_arguments_are_left() { + let mut roots = AutomationArgs::new(None, None); + let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter(); + assert!(roots.consume("--automation-read-root", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); + assert!(!roots.consume("--demo", &mut args).unwrap()); + assert_eq!(roots.read_root, Some("a folder".into())); + assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(args.next().as_deref(), Some("song.scraft")); + assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); + assert_eq!(roots.read_root, Some("a folder".into())); + } + + #[test] + fn invalid_operands_leave_roots_unchanged() { + for flag in ["--automation-read-root", "--automation-write-root"] { + for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { + let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut args = operand.map(str::to_owned).into_iter(); + assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); + assert_eq!(roots.read_root, Some("read".into())); + assert_eq!(roots.write_root, Some("write".into())); + } + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + } + } + + #[test] + fn explicit_dash_folder_and_relative_dash_folder_are_valid() { + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_root, Some("-folder".into())); + assert_eq!(roots.write_root, Some("./-folder".into())); + } +} diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index bb7628a..8d17c63 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -8,15 +8,23 @@ //! 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 -//! soundcraft-cli mcp [--connect PORT] [--demo] MCP server on stdio +//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR] +//! MCP server on stdio. The two roots confine every file the +//! agent's commands read (open, import, presets) and write +//! (save, bounce, export) to those folders, links followed; +//! a root left out grants none of its access. They run the +//! in-process server, so they conflict with --connect. //! soundcraft-cli parity [--write PATH] parity report (markdown) //! soundcraft-cli plugins list built-in plugins //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod automation_args; + use serde_json::{Value, json}; use soundcraft_automation::{Backend, Headless, Remote, mcp::Server}; use soundcraft_engine::Engine; +use soundcraft_engine::file_access::{self, AutomationRoots}; use std::io::Write; use std::process::ExitCode; @@ -356,15 +364,62 @@ fn app(args: &[String]) -> ExitCode { } } +/// What `mcp` was asked for. +struct McpArgs { + connect: Option, + demo: bool, + roots: Option>, +} + +/// Parse the `mcp` options up front: a malformed or misspelled root flag must not leave the +/// server running unconfined. +fn parse_mcp_args(args: &[String]) -> Result { + let mut connect = None; + let mut demo = false; + let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut it = args.iter().cloned(); + while let Some(a) = it.next() { + if roots_args.consume(&a, &mut it)? { + continue; + } + match a.as_str() { + "--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?), + "--demo" => demo = true, + other if other.starts_with("--automation-") => return Err(format!("unknown mcp option `{other}`")), + _ => {} + } + } + let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + if roots.is_some() && connect.is_some() { + return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \ +so drop --connect (or use --connect without the roots)" + .into()); + } + Ok(McpArgs { connect, demo, roots }) +} + fn mcp(args: &[String]) -> ExitCode { + let McpArgs { connect, demo, roots } = match parse_mcp_args(args) { + Ok(a) => a, + Err(err) => return fail(err), + }; let stdin = std::io::stdin(); let stdout = std::io::stdout(); - let r = if let Some(port) = arg_value(args, "--connect") { + let r = if let Some(port) = connect { let mut s = Server::new(Remote::new(&port)); s.serve(stdin.lock(), stdout.lock()) } else { - let e = if args.iter().any(|a| a == "--demo") { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; - let mut s = Server::new(Headless::new(e)); + // `--demo` is the operator's own choice, made before the roots come into force. + let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; + let mut h = Headless::new(e); + if let Some(roots) = roots { + // Every thread of this process, not only the server's: nothing reaches past the roots. + file_access::confine_process(roots.clone()); + let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + h = h.with_automation_roots(Some(roots)); + } + let mut s = Server::new(h); s.serve(stdin.lock(), stdout.lock()) }; match r { @@ -446,6 +501,7 @@ mod tests { let help = help_text(); assert!(help.starts_with("soundcraft-cli info FILE")); assert!(help.ends_with("soundcraft-cli plugins list built-in plugins")); + assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR")); assert!(!help.contains("```")); assert!(!help.contains("#![") && !help.contains("use serde_json")); } diff --git a/apps/soundcraft-cli/tests/cli.rs b/apps/soundcraft-cli/tests/cli.rs new file mode 100644 index 0000000..ef2fb88 --- /dev/null +++ b/apps/soundcraft-cli/tests/cli.rs @@ -0,0 +1,140 @@ +//! `soundcraft-cli mcp` with the automation root flags, run as the real binary. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +use serde_json::Value; + +fn bin() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")); + cmd.env("SOUNDCRAFT_NO_PREFS", "1"); + cmd +} + +fn run(args: &[&str]) -> Output { + bin().args(args).output().unwrap() +} + +fn tmpdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp `; returns whether it exited +/// cleanly, its stderr, and the stdout lines after `initialize`, one per call. +fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec) { + let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + for (i, (name, arguments)) in calls.iter().enumerate() { + let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + writeln!(stdin, "{call}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); + (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies) +} + +fn text(reply: &Value) -> &str { + reply["result"]["content"][0]["text"].as_str().unwrap() +} + +/// A short WAV made by the binary itself. +fn make_wav(path: &std::path::Path) { + let o = run(&["run", "--demo", "--bounce", path.to_str().unwrap(), "--start", "0", "--end", "0.1"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); +} + +#[test] +fn mcp_automation_roots_confine_the_server() { + let base = tmpdir("roots"); + let (work, elsewhere) = (base.join("work"), base.join("elsewhere")); + std::fs::create_dir_all(&work).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + make_wav(&work.join("in.wav")); + make_wav(&elsewhere.join("secret.wav")); + let work_s = work.to_str().unwrap(); + let secret = elsewhere.join("secret.wav").to_str().unwrap().to_string(); + + // Both spellings of the flags; inside works, outside is refused with a readable message. + let read_flag = format!("--automation-read-root={work_s}"); + let calls = [ + ("import_audio", serde_json::json!({"path": work.join("in.wav").to_str().unwrap()})), + ("import_audio", serde_json::json!({"path": secret})), + ("save_session", serde_json::json!({"path": work.join("saved.scraft").to_str().unwrap()})), + ("save_session", serde_json::json!({"path": elsewhere.join("saved.scraft").to_str().unwrap()})), + ("bounce_mix", serde_json::json!({"path": elsewhere.join("mix.wav").to_str().unwrap()})), + ]; + let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls); + assert!(success, "{stderr}"); + assert!(stderr.contains("files confined"), "{stderr}"); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1])); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert!(work.join("saved.scraft").exists()); + assert_eq!(replies[3]["result"]["isError"], true); + assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3])); + assert_eq!(replies[4]["result"]["isError"], true); + assert!(text(&replies[4]).contains("outside the write root"), "{}", text(&replies[4])); + assert!(!elsewhere.join("saved.scraft").exists() && !elsewhere.join("mix.wav").exists()); + + // A root left out grants none of its access. + let (_, _, replies) = mcp_session(&[&read_flag], &calls[2..3]); + assert_eq!(replies[0]["result"]["isError"], true); + assert!(text(&replies[0]).contains("write authority is absent"), "{}", text(&replies[0])); + let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[0..1]); + assert_eq!(replies[0]["result"]["isError"], true); + assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0])); + + // No flags: nothing is confined. + let (success, _, replies) = mcp_session(&[], &calls[1..2]); + assert!(success); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn mcp_automation_root_flags_are_checked_up_front() { + let base = tmpdir("roots-args"); + let dir = base.to_str().unwrap(); + let missing = base.join("missing"); + let fails = |args: &[&str], needle: &str| { + let mut full = vec!["mcp"]; + full.extend_from_slice(args); + let o = run(&full); + assert!(!o.status.success(), "{args:?}"); + let err = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(err.contains(needle), "{args:?}: {err}"); + }; + // The flags confine the in-process server, so they cannot be combined with --connect. + fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); + fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); + fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); + fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); + fails(&["--automation-read-root"], "needs a folder"); + fails(&["--automation-write-root", "--demo"], "needs a folder"); + fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root="], "needs a non-empty folder"); + // A misspelled root flag must not start an unconfined server. + fails(&["--automation-read-roots", dir], "unknown mcp option"); + // A file is not a folder. + std::fs::write(base.join("f.txt"), "x").unwrap(); + fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn usage_lists_the_root_flags() { + let o = run(&["--help"]); + let help = String::from_utf8_lossy(&o.stdout).into_owned(); + assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}"); +} From 719fd909eee3daff82d9dad6f5ad2a617d3464ce Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:50:05 -0400 Subject: [PATCH 077/137] Docs: document --automation-read-root and --automation-write-root for mcp --- docs/mcp.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index 7102a23..d31712b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6,6 +6,7 @@ Claude and other agents can make music with it. ```sh soundcraft-cli mcp # headless engine (no window, no audio device) soundcraft-cli mcp --demo # headless, starting from the demo session +soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # headless, confined to folders soundcraft --control 7801 & # the desktop app… soundcraft-cli mcp --connect 7801 # …driven over MCP: same tools plus screenshots and clicks ``` @@ -16,6 +17,73 @@ Claude Code: claude mcp add soundcraft -- soundcraft-cli mcp --demo ``` +## Confining file access + +```sh +soundcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"soundcraft": {"command": "soundcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft and the other Craft apps take the same ones, so one client config +works across the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_session`, `session.open`, + `import_audio` and `file.import_audio`, `file.import_midi`, `file.import_video`, + `file.import_clip_groups`, `file.import_session_data`, `track.load_preset` and + `mix.sampler_load`. The media a session file names is read too, so an opened session whose + audio paths lead outside the read root loads without that audio and lists it under `missing`. +- Every file a command writes must lie inside the **write root**: `save_session`, `session.save`, + `session.save_as`, `session.save_copy`, `session.save_template`, `bounce_mix` and + `file.bounce_mix`, `file.bounce_stems`, `file.export_clips`, `file.export_midi`, + `file.export_session_text`, `file.export_selected_tracks_as_session`, `file.export_sibelius`, + `file.send_to_sibelius`, `file.print_score`, `file.export_clip_groups` and + `track.save_preset`, together with what a session save writes beside the session file + (`Audio Files/`, `Backups/`) and its temporary file. A score command given no `path` writes to + the temp folder, which is outside the write root unless you made it the root, so it is refused. +- Read and write access are separate. Give the same folder twice for both; a root left out grants + none of its access (with only `--automation-read-root`, nothing can be written to disk). Without + either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + its nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.wav` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.wav`. + A refused save path is reported as refused, never as "already exists". +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`), and a misspelled `--automation-...` flag is an + error rather than an unconfined server. + +The flags confine the in-process server, so `--connect` together with them is an error: the server +would otherwise talk to an app that isn't confined. SoundCraft's desktop app has no equivalent flags +yet, so a running app (`soundcraft --control`) is not confined. `--demo` is the operator's own +choice and is built before the roots apply. + +Playback and bounce need no check of their own. SoundCraft decodes audio into the session when it +enters it (import, open, sampler load), so rendering reads nothing from disk afterwards. Movies are +the exception: a session only references them by path and the desktop app decodes pictures from +that file, but the headless server never does, and the movie's path is checked when it is imported +and when a session naming it is opened. + +What the roots do not cover: commands that take no path still read and change the open session, +and `inspect_session` returns it. Plug-in discovery scans the system's plug-in folders and loads +plug-in binaries, which are fixed locations and native code, not agent paths. Recording is only +reachable in the desktop app. Anything that reads or writes a path goes through one place in the +engine (`io::read_file`, `io::write_file` and the helpers beside them in `crates/engine/src/io.rs`); +a test keeps the rest of the engine from touching the file system on its own. + +The checks hold for every thread of the server process. A path is checked, then opened: another +program that swaps a link in between can still win that race, so the roots keep an agent to the +files it was given rather than guarding against other software on the computer. + ## Tools | Tool | Purpose | From 2d42cf278c47d86aaa8ab179a0157bf0efeb379a Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:55:32 +0100 Subject: [PATCH 078/137] Free replaced session snapshots on the UI thread Every document change sends the audio callback a new Arc. Engine::session_mut clones the document whenever the callback still holds the previous snapshot, so after a non-undoable edit (selection, scrolling) and on every step of a merged gesture (a fader drag) the callback held the only reference to the snapshot it replaced. Replacing it freed the whole tree (tracks, clips, automation, plugin state strings) inside the audio callback: 268 frees for the demo session, and more for real ones. The callback now hands the replaced snapshot back over a bounded channel (preallocated, so try_send never allocates), and the UI thread drops it in Player::idle, as it does retired plugin instances. update_session empties the channel before each send, so it holds one or two at a time; if it is ever full, the snapshot is dropped in place, as before. --- crates/playback/src/lib.rs | 72 ++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..984d048 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -15,8 +15,9 @@ //! Plugins and threads: hosted third-party plugins (CLAP/VST3) are created, given their stored //! state and prepared on the thread that calls [`Player::update_session`] (the UI thread), and //! travel to the audio thread together with the session snapshot that needs them; the mix -//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires come back -//! on a return channel and are dropped on the UI thread ([`Player::idle`]). Plugin states for +//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires, and the +//! session snapshots the callback replaces, come back on return channels and are dropped on the UI +//! thread ([`Player::idle`]). Plugin states for //! saving are read on request ([`Player::capture_states`]: a command with a reply; the audio //! thread answers between two blocks, never blocking). Plugin editors are driven on the UI thread //! through handles taken from each instance before it leaves ([`Player::open_editor`]). @@ -30,11 +31,14 @@ use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel}; use std::sync::{Arc, Mutex, PoisonError}; const BLOCK: usize = 512; +/// Room for replaced snapshots on their way back; `update_session` empties it before each send. +const OLD_SESSIONS: usize = 16; + /// The realtime audio thread marker lives in the mix engine, which carries it into the strips it /// renders on worker threads; the cpal callbacks here set it. pub use soundcraft_mix::{mark_audio_thread, on_audio_thread}; @@ -55,6 +59,8 @@ pub type EditorEdit = (TrackId, usize, String, f32); enum Cmd { /// A new snapshot plus the third-party instances it needs (created on the UI thread). Session(Arc, Vec), + /// Where the callback sends the snapshots it replaces (freeing one takes thousands of frees). + OldSessions(SyncSender>), /// Read every plugin state and reply. CaptureStates(Sender), Input(Arc), @@ -97,6 +103,7 @@ struct AudioState { recording: bool, /// Retired plugin instances go back to the UI thread to be dropped there. garbage: Sender>>, + old_sessions: Option>>, } fn new_engine(sr: f32) -> MixEngine { @@ -112,6 +119,7 @@ impl AudioState { let nch = main_channels(&session); AudioState { garbage, + old_sessions: None, fold: fold_gains(&session), rx, shared, @@ -155,8 +163,12 @@ impl AudioState { self.read = 0.0; } self.fold = fold_gains(&s); - self.session = s; + let old = std::mem::replace(&mut self.session, s); + if let Some(tx) = &self.old_sessions { + let _ = tx.try_send(old); + } } + Cmd::OldSessions(tx) => self.old_sessions = Some(tx), Cmd::Play { from, end, looped } => { self.pos = from; self.end = end; @@ -326,14 +338,25 @@ struct Side { /// Editor handles of the live instances. editors: HashMap<(TrackId, usize), Box>, garbage: Receiver>>, + old_sessions: Option>>, } impl Side { - /// Drops retired instances (closing their editors first, inside the plugin's drop). + /// Drops retired instances (closing their editors first, inside the plugin's drop) and + /// replaced session snapshots. fn collect_garbage(&mut self) { while let Ok(g) = self.garbage.try_recv() { drop(g); } + if let Some(rx) = &self.old_sessions { + rx.try_iter().for_each(drop); + } + } + + fn return_old_sessions(&mut self, tx: &Sender) { + let (otx, orx) = sync_channel(OLD_SESSIONS); + self.old_sessions = Some(orx); + let _ = tx.send(Cmd::OldSessions(otx)); } fn forget(&mut self, key: (TrackId, usize)) { @@ -365,6 +388,7 @@ impl Player { /// Open the default output device. Never fails: without a device it runs a silent clock. pub fn new(session: Arc) -> Player { let p = Player::open(Arc::clone(&session)); + p.side().return_old_sessions(&p.tx); // Hand the initial session's third-party plugins over. p.update_session(session); p @@ -376,7 +400,13 @@ impl Player { fn open(session: Arc) -> Player { let (gtx, grx) = channel(); - let side = Mutex::new(Side { shadow: HashMap::new(), rate: session.sample_rate.as_f64() as f32, editors: HashMap::new(), garbage: grx }); + let side = Mutex::new(Side { + shadow: HashMap::new(), + rate: session.sample_rate.as_f64() as f32, + editors: HashMap::new(), + garbage: grx, + old_sessions: None, + }); let shared = Arc::new(Shared { position: AtomicI64::new(0), playing: AtomicBool::new(false), @@ -916,7 +946,7 @@ mod tests { fn side_creates_each_missing_instance_once() { use soundcraft_model::{ChannelFormat, Insert, TrackKind}; let (_gtx, grx) = channel(); - let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx }; + let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx, old_sessions: None }; let mut s = soundcraft_model::Session::default(); let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:does.not.exist")); @@ -930,6 +960,34 @@ mod tests { assert!(side.shadow.is_empty()); } + #[test] + fn replaced_sessions_are_freed_on_the_ui_thread() { + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let (gtx, grx) = channel(); + let mut st = AudioState::new(rx, shared, Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx); + let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx, old_sessions: None }; + side.return_old_sessions(&tx); + let mut out = vec![0.0f32; 256]; + let mut watched = Vec::new(); + for _ in 0..3 * OLD_SESSIONS { + let s = Arc::new(soundcraft_model::Session::default()); + watched.push(Arc::downgrade(&s)); + side.collect_garbage(); + tx.send(Cmd::Session(s, Vec::new())).unwrap(); + st.fill(&mut out, 2); + let replaced = watched.len().checked_sub(2).and_then(|i| watched.get(i)); + assert!(replaced.is_none_or(|w| w.strong_count() == 1), "freed on the audio thread"); + } + side.collect_garbage(); + assert_eq!(watched.iter().filter(|w| w.strong_count() > 0).count(), 1, "only the live snapshot is left"); + } + #[test] fn looping_wraps() { let s = Arc::new(soundcraft_model::Session::default()); From c9e53ee704fbf236f77d6694f3ca62c1aa33fb98 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:00:09 +0100 Subject: [PATCH 079/137] Look up clip effects and Real-Time Properties without allocating Once a session has any edit.values entry (click settings, Real-Time Properties, clip effects), MixEngine::render allocated every block, although the crate docs say steady-state rendering doesn't: every audio clip formatted two Strings and collected a Vec to look up its clip effects, and every MIDI clip formatted four keys for its Real-Time Properties. Strips render on rayon's workers while the audio callback waits for them, or on the audio thread itself below 4 strips. Lookups now format their keys on the stack (Key) and range over edit.values with &str bounds. A new test checks that Real-Time Properties still reach the instrument. A counting allocator on all threads, over 1000 blocks of the demo session with a clip effect on every clip and Real-Time Properties on every track, saw 10 to 43 allocations per block before and none after, apart from rayon's job queue (a new chunk every 63 blocks) and the one-time setup of each clip's effects when it first plays. --- crates/mix/src/lib.rs | 120 +++++++++++++++++++++++++++++++++++------- 1 file changed, 100 insertions(+), 20 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..a0a3e4b 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -58,6 +58,7 @@ use soundcraft_model::{ }; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; +use std::ops::Bound; use std::sync::Arc; /// Default (stereo) main mix width. The actual width is [`main_channels`]. @@ -852,7 +853,7 @@ fn process_strip( } 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(¶ms); + 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) { @@ -980,19 +981,49 @@ fn process_strip( } /// Clip-effect parameters of a clip, or None when it has none or they are bypassed. -fn clip_fx_params(s: &Session, clip: u64) -> Option> { - if s.edit.values.is_empty() { +fn clip_fx_params(s: &Session, clip: u64) -> Option + Clone> { + if s.edit.values.is_empty() || Key::new(format_args!("clip_fx.bypass.{clip}")).is_none_or(|k| s.edit.flag(k.as_str())) { return None; } - let pre = format!("clip_fx.{clip}."); - let v: Vec<(String, f32)> = s + let pre = Key::new(format_args!("clip_fx.{clip}."))?; + let mut params = s .edit .values - .range(pre.clone()..) - .take_while(|(k, _)| k.starts_with(&pre)) - .map(|(k, v)| (k.get(pre.len()..).unwrap_or("").to_string(), *v as f32)) - .collect(); - if v.is_empty() || s.edit.flag(&format!("clip_fx.bypass.{clip}")) { None } else { Some(v) } + .range::((Bound::Included(pre.as_str()), Bound::Unbounded)) + .take_while(move |(k, _)| k.starts_with(pre.as_str())) + .map(move |(k, v)| (k.get(pre.len..).unwrap_or(""), *v as f32)) + .peekable(); + params.peek()?; + Some(params) +} + +/// A short key formatted on the stack: lookups made on the audio thread must not allocate. +#[derive(Clone, Copy)] +struct Key { + buf: [u8; 64], + len: usize, +} + +impl Key { + /// None when the key doesn't fit. + fn new(args: std::fmt::Arguments<'_>) -> Option { + let mut k = Key { buf: [0; 64], len: 0 }; + std::fmt::Write::write_fmt(&mut k, args).ok()?; + Some(k) + } + + fn as_str(&self) -> &str { + self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("") + } +} + +impl std::fmt::Write for Key { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?; + self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes()); + self.len = end; + Ok(()) + } } impl ClipFx { @@ -1006,10 +1037,10 @@ impl ClipFx { }) } /// Modules exist only when one of their parameters is set. - fn configure(&mut self, params: &[(String, f32)]) { + fn configure<'a>(&mut self, params: impl Iterator + Clone) { self.gain_db = 0.0; - let has_eq = params.iter().any(|(k, _)| k.starts_with("eq.")); - let has_comp = params.iter().any(|(k, _)| k.starts_with("comp.")); + let has_eq = params.clone().any(|(k, _)| k.starts_with("eq.")); + let has_comp = params.clone().any(|(k, _)| k.starts_with("comp.")); if has_eq && self.eq.is_none() { self.eq = self.make("eq_7band"); } else if !has_eq { @@ -1023,18 +1054,18 @@ impl ClipFx { for (k, v) in params { if let Some(p) = k.strip_prefix("eq.") { if let Some(eq) = &mut self.eq - && eq.param(p) != Some(*v) + && eq.param(p) != Some(v) { - eq.set_param(p, *v); + eq.set_param(p, v); } } else if let Some(p) = k.strip_prefix("comp.") { if let Some(c) = &mut self.comp - && c.param(p) != Some(*v) + && c.param(p) != Some(v) { - c.set_param(p, *v); + c.set_param(p, v); } } else if k == "gain" { - self.gain_db = *v; + self.gain_db = v; } } } @@ -1319,7 +1350,7 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } let base = s.tempo.samples_to_ticks(clip.start, sr); // MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay). - let rtp = |name: &str, d: f64| s.edit.value(&format!("rtp.{}.{name}", t.id.0), d); + let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d)); let (dv, dp, dur, delay) = if s.edit.values.is_empty() { (0i64, 0i64, 1.0f64, 0i64) } else { @@ -1862,7 +1893,7 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> let mut tmp = vec![vec![0.0f32; len]; out.len()]; render_clip(s, c, range.start, len, &mut tmp, &mut scratch); let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len()); - fx.configure(¶ms); + fx.configure(params); let mut done = 0; while done < len { let n = (len - done).min(4096); @@ -2168,6 +2199,55 @@ mod tests { } } + struct Keys { + inner: Box, + heard: Arc>>, + } + + impl Plugin for Keys { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.inner.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.inner.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.inner.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.inner.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.inner.process(io, frames); + } + fn note_on(&mut self, _offset: usize, note: u8, velocity: u8) { + self.heard.lock().unwrap().push((note, velocity)); + } + } + + #[test] + fn real_time_properties_reach_the_instrument() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None); + s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys")); + let mut seq = soundcraft_midi::Sequence::default(); + seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 }); + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000, seq)); + s.edit.values.insert(format!("rtp.{}.transpose", t.0), 12.0); + s.edit.values.insert(format!("rtp.{}.velocity", t.0), -20.0); + let heard = Arc::new(std::sync::Mutex::new(Vec::new())); + let keys = Keys { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(keys) }]); + render_block(&mut eng, &s, 0); + assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]); + } + fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 { let mut out = vec![vec![0.0f32; 512]; 2]; eng.render(s, pos, 512, &mut out); From 6cf896e76c0ec82a11bf2494f209cfe7c8507f74 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:11:17 -0400 Subject: [PATCH 080/137] Release 0.6.1: rebuild with the fixed shared release workflow The previous release build failed in craft-common-actions, not in this repo: riscv64 packaging got --formats deb,rpm,tar (fixed in craft-common-actions#3), Linux jobs lacked the ALSA headers (craft-common-actions#5), and Windows signing needed the AZURE_* secrets listed in the caller. 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 153861a..3228925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3424,7 +3424,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.6.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "serde", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3464,7 +3464,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "serde_json", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap-sys", "libloading", @@ -3486,7 +3486,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3508,7 +3508,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3530,7 +3530,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3540,7 +3540,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "rayon", @@ -3556,7 +3556,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3569,7 +3569,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.6.0" +version = "0.6.1" dependencies = [ "cpal", "log", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3592,7 +3592,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.6.0" +version = "0.6.1" dependencies = [ "craft-ui", "egui", @@ -3618,7 +3618,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.6.0" +version = "0.6.1" dependencies = [ "image", "proptest", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "libloading", "log", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.6.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 02b36ac..a09510b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 9f4335388e664a6eeaa91d5dd9bb5e5676aaf430 Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 13 Oct 2026 04:34:49 +1300 Subject: [PATCH 081/137] docs: fix documentation (grammar-minor-002) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d5f86a..964c936 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ very welcome, in the issues or on Discord. ## Downloads -**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. +**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) have earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows From 89c7c9a0b0cdf2eb2c5f5a744351ca2ad744ae5a Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 13 Oct 2026 04:35:07 +1300 Subject: [PATCH 082/137] docs: fix documentation (grammar-minor-007) --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 77d7be9..2260b84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -42,7 +42,7 @@ at 0.5–1.5 h each, features at about 200–300 tested lines per agent-hour, fr 15–25, onboarding and user guide 15–25, save fixes 5–10. The previous figure in ROADMAP.md (2026-10-07: "~65 % overall feature parity", "110–150 h to 100 %") -measured feature *presence*. This pass measures readiness, and found new evidence that pulls the +measured feature *presence*. This pass measures readiness, and finds new evidence that pulls the numbers down: 37 open GitHub issues from the first public week (about 25 of them correctness bugs in features counted as done), no MIDI hardware input or output at all, no audio device or buffer size selection, no localization, and a macOS release that cannot load signed third-party VST3s From 23b7e023b643e470a572d8b35287dfeff2ded8a0 Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 13 Oct 2026 04:35:20 +1300 Subject: [PATCH 083/137] docs: fix documentation (grammar-minor-008) --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 77d7be9..3b48a42 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -83,7 +83,7 @@ post-production, tracking) can switch. "Ready" is depth, correctness and fidelit | UI/UX fidelity | 10 % | **~55 %** | estimated | 60–100 | [ui-parity.md](ui-parity.md) | Edit/Mix windows, every menu, floating windows; open UX bugs #3 #5 #6 #7 #140 #144; shortcuts only recently right on Windows/Linux (#15, #41); generic plugin UIs | | File formats | 10 % | **~40 %** | estimated | 70–120 | [file-format-parity.md](file-format-parity.md) | WAV/BWF/RF64/AIFF/FLAC read+write, compressed formats read; no `.ptx`, AAF, OMF, ADM BWF, MP3/AAC export, video export | | Hardware | 10 % | **~12 %** | estimated | 110–180 | [hardware-parity.md](hardware-parity.md) | Default output and default input device only, fixed 512-sample block, no ASIO, no MIDI devices, no control surfaces, no sync | -| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), X11 llvmpipe failure (#107) | +| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), an X11 llvmpipe failure (#107) | | Performance | 5 % | **~35 %** | estimated (unmeasured) | 40–70 | [hardware-parity.md](hardware-parity.md) | Parallel strip rendering and PDC exist; whole files decoded into RAM, recording into RAM (#84 open), no track-count or latency benchmarks | | Ecosystem / plugins | 4 % | **~30 %** | estimated | in Features + Hardware | [plugin-parity.md](plugin-parity.md) | CLAP/VST3/AU hosting; no AAX (Pro Tools' only format), no ARA, no sidechain, no control-surface ecosystem, no content library | | Platforms | 3 % | **~65 %** | estimated | 20–40 | [ROADMAP.md](../ROADMAP.md) | Builds for macOS, Windows x64/x86/arm64, Linux, FreeBSD, Web (Pro Tools: macOS, Windows), but only macOS is exercised at runtime; Web lacks file pickers (#63, #64) | From 8c73d9c8b365feaa10e3cce16f17d409115e8938 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:15:38 +0200 Subject: [PATCH 084/137] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 25ae055..d8b2a31 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249) - **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/ @@ -143,6 +144,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: bar offset saturates (#249) | | 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 | From 35609b756be8653e7ded1d67cfbebf12afb6f5b5 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:57:13 +0100 Subject: [PATCH 085/137] Make the Zoom Preset, Waveform Zoom and Clip Rating keys work; add Commands Focus 1-5 and N From #186: waveform zoom could not be zoomed back out, zoom presets had no keys, and N did nothing for Insertion Follows Playback. The registry writes three shortcuts as key families: "Cmd+Ctrl+1-5" (Zoom Preset), "Cmd+Alt+0-5" (Clip Rating) and "Cmd+Alt+[ / ]" (Waveform Zoom). The shortcut parser can't read them, so they never fired. The menus still showed them. - The keyboard handler takes these keys itself, as it does F1-F4: Cmd+Alt+[ and ] zoom the waveforms out and in, Cmd+Alt+0-5 rate the selected clips and Cmd+Ctrl+1-5 recall the zoom presets. Other digits do nothing. - Waveform Zoom and Clip Rating work on every OS (Ctrl+Alt on Windows and Linux). Cmd+Ctrl+1-5 stays a macOS key, like the other Mac Control chords, which the shortcut list already leaves out off macOS. - Commands Focus (the a-z button) gains Pro Tools' 1-5 for the zoom presets and N for Insertion Follows Playback, on every OS. --- crates/ui-egui/src/shortcuts.rs | 23 ++++++++++ crates/ui-egui/tests/key_families.rs | 63 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 crates/ui-egui/tests/key_families.rs diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..516d6c4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -175,6 +175,20 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } true } + // The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read. + Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => { + let factor = if key == Key::CloseBracket { 2.0 } else { 0.5 }; + let _ = app.run("view.waveform_zoom", json!({"factor": factor})); + true + } + Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => { + let _ = app.run("clip.rating", json!({"rating": digit(key)})); + true + } + Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::CTRL, m, mac) => { + let _ = app.run("view.zoom_preset", json!({"preset": digit(key)})); + true + } Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => { let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" }; let _ = app.run(id, json!({})); @@ -211,6 +225,11 @@ fn commands_focus(app: &mut SoundApp, key: Key) -> bool { Key::C => "edit.copy", Key::V => "edit.paste", Key::Z => "edit.undo", + Key::N => "options.insertion_follows_playback", + Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 => { + let _ = app.run("view.zoom_preset", json!({"preset": digit(key)})); + return true; + } Key::P => { move_selection_track(app, -1); return true; @@ -255,6 +274,10 @@ fn tab_clip(app: &mut SoundApp, back: bool) { } } +fn digit(key: Key) -> u8 { + key.name().parse().unwrap_or(0) +} + /// Shortcut text for display. The registry stores macOS-style strings ("Cmd+N"); off macOS the Cmd /// modifier is the Ctrl key. Mac-only Control chords ("Cmd+Ctrl+S") have no key there, so they show nothing. pub fn shortcut_label(s: &str, mac: bool) -> String { diff --git a/crates/ui-egui/tests/key_families.rs b/crates/ui-egui/tests/key_families.rs new file mode 100644 index 0000000..b3d728b --- /dev/null +++ b/crates/ui-egui/tests/key_families.rs @@ -0,0 +1,63 @@ +//! Shortcuts written as key families ("Cmd+Ctrl+1-5", "Cmd+Alt+0-5", "Cmd+Alt+[ / ]") fire, and Commands Focus +//! has Pro Tools' 1–5 zoom presets and N for Insertion Follows Playback. + +use egui::os::OperatingSystem; +use egui::{Key, Modifiers}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; +const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; +const OSES: [(OperatingSystem, Modifiers); 3] = + [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, PC_CTRL), (OperatingSystem::Nix, PC_CTRL)]; + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +fn rig(os: OperatingSystem) -> (egui::Context, SoundApp) { + let ctx = egui::Context::default(); + ctx.set_os(os); + (ctx, SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default())) +} + +#[test] +fn cmd_alt_brackets_zoom_waveforms_and_cmd_alt_digits_rate_clips() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + let cmd_alt = Modifiers { alt: true, ..cmd }; + press(&ctx, &mut app, Key::CloseBracket, cmd_alt); + press(&ctx, &mut app, Key::CloseBracket, cmd_alt); + press(&ctx, &mut app, Key::OpenBracket, cmd_alt); + assert_eq!(app.engine.session().edit.zoom.waveform_zoom, 2.0, "{os:?}"); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + press(&ctx, &mut app, Key::Num4, cmd_alt); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.rating, 4, "{os:?}"); + press(&ctx, &mut app, Key::Num9, cmd_alt); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.rating, 4, "{os:?}: 9 is outside 0-5"); + } +} + +#[test] +fn cmd_ctrl_digits_recall_zoom_presets_on_macos() { + let (ctx, mut app) = rig(OperatingSystem::Mac); + press(&ctx, &mut app, Key::Num2, Modifiers { ctrl: true, ..MAC_CMD }); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, 256.0); + press(&ctx, &mut app, Key::Num6, Modifiers { ctrl: true, ..MAC_CMD }); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, 256.0, "6 is outside 1-5"); +} + +#[test] +fn commands_focus_digits_recall_zoom_presets_and_n_toggles_insertion_follows_playback() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.engine.execute("options.keyboard_focus", &json!({"focus": "commands"})).unwrap(); + let follows = app.engine.session().edit.insertion_follows_playback; + press(&ctx, &mut app, Key::Num5, Modifiers::NONE); + press(&ctx, &mut app, Key::N, Modifiers::NONE); + let edit = &app.engine.session().edit; + assert_eq!((edit.zoom.samples_per_px, edit.insertion_follows_playback), (16384.0, !follows), "{os:?}"); + } +} From ec41f804ff13cd73252af2217a576c4fb4998023 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:26:37 +0200 Subject: [PATCH 086/137] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 25ae055..dd79f69 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248) - **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/ @@ -143,6 +144,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: timecode overflow (#248) | | 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 | From 6d626adf9f22e4f804bbd1ba77297be6296a6ca1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:31:37 +0200 Subject: [PATCH 087/137] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 25ae055..b34a8c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256) - **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/ @@ -143,6 +144,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: convert option validation (#256) | | 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 | From 557553cb064b18743cf6efed0a66c649a057a66b Mon Sep 17 00:00:00 2001 From: Ian Martin <4624371+iannitram@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:41 -0600 Subject: [PATCH 088/137] video: collapse the MP3 esds branch into a match guard Rust 1.95's clippy flags the nested if (collapsible_match), so cargo xtask ci failed on a machine with the README's minimum toolchain. No behaviour change: other MP3 sample entries still fall through to the catch-all arm. Co-Authored-By: Claude Opus 5.5 --- crates/video/src/isobmff/codec.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/video/src/isobmff/codec.rs b/crates/video/src/isobmff/codec.rs index 0edb8d5..45f08bd 100644 --- a/crates/video/src/isobmff/codec.rs +++ b/crates/video/src/isobmff/codec.rs @@ -1392,10 +1392,8 @@ impl SampleEntry { CodecConfig::Aac(c) => { b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size)); } - CodecConfig::Mp3 => { - if format == *b"mp4a" { - b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); - } + CodecConfig::Mp3 if format == *b"mp4a" => { + b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); } CodecConfig::Alac { cookie } => { let m = b.start_full(b"alac", 0, 0); From 218cbb66707dca7925c61e41a7d5d1492ad6c1c5 Mon Sep 17 00:00:00 2001 From: Ian Martin <4624371+iannitram@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:41 -0600 Subject: [PATCH 089/137] vst3-host: build the symlink test on Unix only On Windows the test body was compiled out but its setup was not, so `lib` was an unused variable and clippy -D warnings failed the gate. The test never ran anything on Windows; it is now cfg(unix) as a whole. Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/src/lib.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 0cfd3ab..b872dec 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -294,11 +294,13 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + // Unix only: the test points a ".vst3" at a system library through a symlink. + #[cfg(unix)] #[test] fn a_real_library_without_the_vst3_export_is_rejected() { // A system library loads fine but has no `GetPluginFactory`. // (macOS system libraries live only in the dyld shared cache, not as files.) - let lib = if cfg!(target_os = "macos") || cfg!(windows) { + let lib = if cfg!(target_os = "macos") { None } else { ["/lib/x86_64-linux-gnu/libz.so.1", "/usr/lib/libz.so.1", "/lib64/libz.so.1"].iter().map(PathBuf::from).find(|p| p.exists()) @@ -308,12 +310,9 @@ mod tests { let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); // Point a single-file ".vst3" at it via a symlink (no copying of binaries). - #[cfg(unix)] - { - std::os::unix::fs::symlink(&lib, d.join("z.vst3")).unwrap(); - let e = load_bundle(&d.join("z.vst3")).unwrap_err(); - assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); - } + std::os::unix::fs::symlink(&lib, d.join("z.vst3")).unwrap(); + let e = load_bundle(&d.join("z.vst3")).unwrap_err(); + assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); let _ = std::fs::remove_dir_all(&d); } } From b2f636126529903f20e55b272d5926e2222c84bc Mon Sep 17 00:00:00 2001 From: Ian Martin <4624371+iannitram@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:41 -0600 Subject: [PATCH 090/137] engine: build the expected missing-file path the way the engine does saved_audio_is_trusted_only_in_the_folder_it_was_saved_in compared path strings. On Windows the engine reports \Audio Files/x.wav while the test expected /Audio Files/x.wav, so the test failed there although the two name the same file. The expected value is now the session folder joined with the stored relative path, on every platform. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/io.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..aee00b8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1139,7 +1139,10 @@ mod save_tests { open_session(&mut missing, &path("A/A.scraft")).unwrap(); save_session(&mut missing, &path("B/Moved.scraft")).unwrap(); let mut r = Engine::default(); - assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]); + assert_eq!( + open_session(&mut r, &path("B/Moved.scraft")).unwrap(), + vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()] + ); let _ = std::fs::remove_dir_all(&dir); } From f55010d2449db925477af4259599849a6331dfe7 Mon Sep 17 00:00:00 2001 From: Ian Martin <4624371+iannitram@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:41 -0600 Subject: [PATCH 091/137] vst3-host: fixture builds on Windows (bus flags are i32 there) The VST3 bindings type C enum constants as i32 on Windows and u32 elsewhere, so assigning kDefaultActive to BusInfo.flags (u32) failed to compile and seven integration tests could not build their fixture. Cast it like the other constants in this file. Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/tests/fixture/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 3ba07c6..a537ef6 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -52,7 +52,7 @@ fn audio_bus(bus: *mut BusInfo, dir: BusDirection, name: &str) { b.channelCount = 2; copy_c16(name, &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as u32; } unsafe fn channels<'a>(bus: &AudioBusBuffers, frames: usize) -> Vec<&'a mut [f32]> { @@ -461,7 +461,7 @@ impl IComponentTrait for Synth { b.channelCount = 16; copy_c16("Notes", &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as u32; return kResultOk; } kInvalidArgument From ced57be7238e21c6653c1908c9b6c7e52e441126 Mon Sep 17 00:00:00 2001 From: Ian Martin <4624371+iannitram@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:41 -0600 Subject: [PATCH 092/137] vst3-host: the late-exit test expects Windows' exit order module_exit_shutdown asserts that the exit handler alone runs the module exit too late. That holds where exit handlers share one list. Windows runs the executable's handlers before a DLL's own, so the fixture's late state is still alive and the child exits cleanly; the test failed there. The Windows case now asserts the clean exit. Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/tests/module_exit_shutdown.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/vst3-host/tests/module_exit_shutdown.rs b/crates/vst3-host/tests/module_exit_shutdown.rs index cc337c9..274823a 100644 --- a/crates/vst3-host/tests/module_exit_shutdown.rs +++ b/crates/vst3-host/tests/module_exit_shutdown.rs @@ -38,7 +38,13 @@ fn main() { assert!(up_front.status.success(), "shutdown before exit: {:?}\n{}", up_front.status, String::from_utf8_lossy(&up_front.stderr)); let too_late = run("exit-handler"); let err = String::from_utf8_lossy(&too_late.stderr); - assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}"); - assert!(err.contains("module exit after the process exit tore down state"), "{err}"); + if cfg!(windows) { + // Windows runs the executable's exit handlers before a DLL's own, so the fixture's late + // state is still there when the host's exit handler runs its module exit. + assert!(too_late.status.success(), "the exit handler alone: {:?}\n{err}", too_late.status); + } else { + assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}"); + assert!(err.contains("module exit after the process exit tore down state"), "{err}"); + } println!("module_exit_shutdown: ok"); } From 3a702d9c6f087b704015a4dd18a8d708fdf90f30 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:35:33 +0100 Subject: [PATCH 093/137] Refuse renders too long to finish instead of cutting the audio short One offline render holds MAX_RENDER_SAMPLES samples, about 93 minutes of stereo at 48 kHz, and the offline time stretch, pitch shift and resample stop at MAX_OFFLINE_SAMPLES frames. Longer audio was cut short without a word: Render Clip Gain and Clip Effects Render left the end of the clip silent, every AudioSuite process shortened the clip, the track Commit made fell silent at the limit, and Export Clips wrote shortened files. Above 74 kHz an hour no longer fits, so TCE, Elastic Properties and Remove Pitch Shift padded the cut end with silence. Each now checks the length before it renders and refuses with the limit in seconds, as Consolidate does in #212. AudioSuite counts what Time Stretch, Pitch Shift and Varispeed make, so a fourfold stretch is refused at a quarter of the limit. Export Clips checks every clip before it writes a file. --- crates/engine/src/cmd/audiosuite.rs | 23 +++++++++----- crates/engine/src/cmd/clip.rs | 2 +- crates/engine/src/cmd/clip_more.rs | 2 +- crates/engine/src/cmd/file.rs | 4 +++ crates/engine/src/cmd/more_util.rs | 19 ++++++++--- crates/engine/src/io.rs | 19 ++++++++++- crates/engine/tests/long_renders.rs | 49 +++++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/long_renders.rs diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index e2abfca..3cf88b2 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -40,12 +40,25 @@ fn process(e: &mut Engine, p: &Value) -> Result { return Err(bad("audiosuite.process", "select audio clips first")); } let sr = e.session().sample_rate; + let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0)); + let semitones = get("semitones", 0.0).clamp(-24.0, 24.0); + let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32; + // These processes cut their output at MAX_OFFLINE_SAMPLES frames. + let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize; + let most = match name.as_str() { + "time_stretch" => longest(ratio), + "pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)), + "varispeed" => longest(f64::from(to) / sr.as_f64()), + _ => usize::MAX, + }; let mut done = 0; for id in ids { let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; if !clip.is_audio() { continue; } + let channels = e.session().track(track).map_or(1, |t| t.channels()); + crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?; let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range()); // Render without the clip's own fades: re-render a copy without fades so they stay editable. let mut tmp = e.session().clone(); @@ -78,13 +91,9 @@ fn process(e: &mut Engine, p: &Value) -> Result { audio } "duplicate" => audio, - "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, f64::from(get("ratio", 1.0).clamp(0.25, 4.0)), srf), - "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, get("semitones", 0.0).clamp(-24.0, 24.0), srf), - "varispeed" => { - let speed = get("speed", 1.0).clamp(0.25, 4.0); - let to = (sr.as_f64() / f64::from(speed)).round().max(1.0) as u32; - soundcraft_dsp::offline::resample(&audio, sr.hz(), to) - } + "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf), + "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf), + "varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to), plugin => { let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) }; pl.prepare(srf, 1024, audio.len().max(1)); diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e537..5df1cf6 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { let ids = clip_ids_param(e, p); let mut n = 0; for id in ids { - if crate::io::render_clip_gain(e, id)? { + if crate::io::render_clip_gain(e, "clip.gain_render", id)? { n += 1 } } diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 74dfd8d..7aa7203 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -204,7 +204,7 @@ fn effects_render(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let mut n = 0; for id in &ids { - if crate::io::render_clip_gain(e, *id)? { + if crate::io::render_clip_gain(e, "clip.effects_render", *id)? { n += 1; } let s = e.session_mut(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf8..1a90f51 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -123,6 +123,10 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("file.export_clips", "no clips selected")); } + for (t, c) in ids.iter().filter_map(|id| e.session().find_clip(*id)) { + let channels = e.session().track(t).map_or(1, |x| x.channels()); + crate::io::refuse_longer(e.session(), "file.export_clips", &c.name, "export", c.length, crate::io::render_limit(channels))?; + } let n = crate::io::export_clips(e, &ids, &dir)?; Ok(json!({"written": n, "dir": dir})) }), diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs index 756106b..bf16982 100644 --- a/crates/engine/src/cmd/more_util.rs +++ b/crates/engine/src/cmd/more_util.rs @@ -132,9 +132,10 @@ pub fn set_clip_env(c: &mut Clip, mut pts: Vec<(Samples, f32)>) { } } -/// Longest audio we render in one stretch (one hour at the session rate). +/// Longest audio we render in one stretch: one hour at the session rate, and no more than +/// `time_stretch` makes. fn max_render(s: &Session) -> Samples { - s.sample_rate.samples(3600.0) + s.sample_rate.samples(3600.0).min(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples) } /// Render the audio a clip plays, time-stretched (pitch preserved) to `out_len` samples, into a new @@ -142,8 +143,10 @@ fn max_render(s: &Session) -> Samples { pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) -> Result> { let ClipContent::Audio { source, offset } = c.content else { return Ok(None) }; let Some(audio) = s.pool.get(source).cloned() else { return Ok(None) }; - if out_len <= 0 || out_len > max_render(s) { - return Err(bad(cmd, "the target length must be between 1 sample and one hour")); + let max = max_render(s); + if out_len <= 0 || out_len > max { + let secs = max as f64 / s.sample_rate.as_f64(); + return Err(bad(cmd, format!("the target length must be between 1 sample and {secs:.0} s"))); } let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; let src_len = ((c.length as f64) / stretch).round().max(1.0); @@ -202,4 +205,12 @@ mod tests { assert_eq!(env_db(&[], 50), 0.0); assert_eq!(env_db(&env, 500), -10.0); } + + #[test] + fn stretches_are_no_longer_than_time_stretch_makes() { + let mut s = Session::default(); + assert_eq!(max_render(&s), s.sample_rate.samples(3600.0)); + s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap(); + assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..fd17b36 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -408,13 +408,28 @@ pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { Ok(true) } +/// The most frames of `channels`-channel audio one offline render holds. +pub fn render_limit(channels: usize) -> usize { + soundcraft_mix::MAX_RENDER_SAMPLES / channels.max(1) +} + +/// Refuses `len` frames of `name` past `max`, where a render would cut the audio short. +pub fn refuse_longer(s: &Session, cmd: &str, name: &str, verb: &str, len: Samples, max: usize) -> Result<()> { + if len <= Samples::try_from(max).unwrap_or(Samples::MAX) { + return Ok(()); + } + let secs = max as f64 / s.sample_rate.as_f64(); + Err(crate::cmd::bad(cmd, format!("`{name}` is too long to {verb} (at most {secs:.0} s)"))) +} + /// Render clip gain into a new source and reset the clip's gain. -pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result { +pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result { let s = e.session(); let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) }; if !c.is_audio() { return Ok(false); } + refuse_longer(s, cmd, &c.name, "render", c.length, render_limit(s.track(t).map_or(1, |x| x.channels())))?; let mut c2 = c.clone(); c2.fade_in = Default::default(); c2.fade_out = Default::default(); @@ -449,6 +464,8 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { return Ok(None); } let range = Range::new(0, end + s.sample_rate.samples(2.0)); + // The track renders through a main mix of at least two channels. + refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?; let audio = soundcraft_mix::render_track_pre_fader(s, t, range); let name = format!("{}.cm", tr.name); let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2)); diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/long_renders.rs new file mode 100644 index 0000000..c2c9fce --- /dev/null +++ b/crates/engine/tests/long_renders.rs @@ -0,0 +1,49 @@ +//! Renders longer than one offline buffer holds are refused, rather than cut short and kept. + +use serde_json::{Value, json}; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES; +use soundcraft_engine::Engine; +use soundcraft_mix::MAX_RENDER_SAMPLES; +use soundcraft_model::{ChannelFormat, Clip, ClipId, Session, TrackKind}; + +/// A stereo track "Talk" with one clip `frames` long. Its source is one second: the commands +/// refuse on the clip's length before they render anything. +fn long_clip(frames: usize) -> (Engine, ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Talk")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 48_000]; 2] }; + let src = soundcraft_engine::io::add_source(&mut s, "talk", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64)); + (Engine::new(s), id) +} + +fn refused(e: &mut Engine, cmd: &str, params: &Value) { + let before = serde_json::to_value(e.session()).unwrap(); + let err = e.execute(cmd, params).expect_err(cmd).to_string(); + assert!(err.contains("is too long to"), "{cmd}: {err}"); + assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "{cmd} changed the session"); +} + +#[test] +fn clips_longer_than_one_render_are_refused() { + let (mut e, id) = long_clip(MAX_RENDER_SAMPLES / 2 + 1); + let dir = std::env::temp_dir().join(format!("soundcraft-long-renders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + refused(&mut e, "clip.gain_render", &json!({"clips": [id.0]})); + refused(&mut e, "clip.effects_render", &json!({"clips": [id.0]})); + refused(&mut e, "audiosuite.process", &json!({"process": "reverse", "clips": [id.0]})); + refused(&mut e, "track.commit", &json!({"tracks": ["Talk"]})); + refused(&mut e, "file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [id.0]})); + assert!(!dir.exists(), "Export Clips started writing before it refused"); +} + +#[test] +fn audiosuite_refuses_results_longer_than_one_render() { + let (mut e, id) = long_clip(MAX_OFFLINE_SAMPLES / 4 + 1); + let fourfold = [("time_stretch", json!({"ratio": 4.0})), ("pitch_shift", json!({"semitones": 24.0})), ("varispeed", json!({"speed": 0.25}))]; + for (process, params) in fourfold { + refused(&mut e, "audiosuite.process", &json!({"process": process, "params": params, "clips": [id.0]})); + } +} From fe3d759b53709e89620014800feb527dcf3d2179 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:52:12 +0100 Subject: [PATCH 094/137] Add tracks from the Tracks list and the empty space below the tracks From #307: adding a track took the Track menu or Shift+Cmd+N (Shift+Ctrl+N on Windows and Linux). Nothing on screen offered it, not even in an empty session. - The TRACKS header in the Tracks list has a + button that opens New Tracks. The list is in both the Edit and the Mix window. - A double-click in the empty space below the last track in the Edit window (header column or timeline) opens New Tracks, as in Pro Tools. A single click there still clears the selection. - A double-click right of the last strip in the Mix window does the same. With no tracks, that is the whole window. --- crates/ui-egui/src/edit_window.rs | 11 +++-- crates/ui-egui/src/mix_window.rs | 4 ++ crates/ui-egui/src/panels.rs | 6 +++ crates/ui-egui/tests/new_tracks.rs | 67 ++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/new_tracks.rs diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..1f3cf43 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -230,10 +230,15 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { track_row(app, &mut child, TrackId(id), row, tl); } } - // Empty area click clears track selection. + // Below the tracks a click clears the selection and a double-click opens New Tracks. let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max); - if empty.height() > 4.0 && ui.interact(empty, ui.id().with("empty"), Sense::click()).clicked() { - let _ = app.run("edit.select_none", json!({})); + if empty.height() > 4.0 { + let resp = ui.interact(empty, ui.id().with("empty"), Sense::click()); + if resp.double_clicked() { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + } else if resp.clicked() { + let _ = app.run("edit.select_none", json!({})); + } } overlay(app, ui, tl, Rect::from_min_max(pos2(tl.min.x, rulers_rect.min.y), tl.max)); let _ = header; diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..205b40e 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -29,6 +29,10 @@ pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { for id in ids { strip(app, ui, id, snap.as_ref()); } + let rest = Rect::from_min_max(pos2(ui.cursor().min.x, ui.clip_rect().min.y), ui.clip_rect().max); + if rest.width() > 4.0 && ui.interact(rest, ui.id().with("past_strips"), Sense::click()).double_clicked() { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + } }); }); } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..345cdc5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -18,7 +18,13 @@ fn panel_header(ui: &mut Ui, title: &str) { pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let total = ui.available_height(); + let header = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0)); panel_header(ui, "TRACKS"); + let plus = Rect::from_center_size(pos2(header.max.x - 13.0, header.center().y), vec2(18.0, 18.0)); + let tip = format!("New Tracks… ({})", crate::shortcuts::shortcut_label("Cmd+Shift+N", ui.ctx().os().is_mac())); + if crate::widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(plus)), plus.size(), "plus", false, &tip).clicked() { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + } let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = { let s = app.engine.session(); s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect() diff --git a/crates/ui-egui/tests/new_tracks.rs b/crates/ui-egui/tests/new_tracks.rs new file mode 100644 index 0000000..86f9132 --- /dev/null +++ b/crates/ui-egui/tests/new_tracks.rs @@ -0,0 +1,67 @@ +//! New Tracks opens from the Tracks list's + button, and from a double-click below the last +//! track (Edit) or right of the last strip (Mix). + +use egui::{Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness(tracks: usize) -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::Engine::default(); + if tracks > 0 { + engine.execute("track.new", &serde_json::json!({"count": tracks})).unwrap(); + } + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn click(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.step(); +} + +#[test] +fn the_tracks_list_plus_button_opens_new_tracks() { + let mut h = harness(0); + h.get_by_label_contains("New Tracks").click(); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); +} + +#[test] +fn double_click_below_the_last_track_opens_new_tracks() { + let mut h = harness(2); + let rows = h.state().edit_layout.rows.clone(); + let below = rows[1].1[3] + 60.0; + for x in [h.state().edit_layout.timeline[0] + 100.0, rows[0].1[0] + 60.0] { + click(&mut h, pos2(x, below)); + assert_eq!(h.state().dialogs.open_name(), None, "one click only clears the selection"); + assert!(h.state().engine.session().edit.selected_tracks.is_empty()); + click(&mut h, pos2(x, below)); + assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); + h.state_mut().dialogs.open = None; + h.run_steps(30); + } +} + +#[test] +fn double_click_right_of_the_last_strip_opens_new_tracks() { + let mut h = harness(1); + h.state_mut().ui.window = MainWindow::Mix; + h.run_steps(4); + let strip = h.get_by_label("Record enable").rect(); + let at = pos2(strip.max.x + 150.0, strip.center().y); + click(&mut h, at); + click(&mut h, at); + assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); +} From 7ea64c119012916ccb93e77913caec45f6470aea Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:03:36 +0100 Subject: [PATCH 095/137] =?UTF-8?q?Send=20levels=20drag=20down=20to=20?= =?UTF-8?q?=E2=88=92=E2=88=9E,=20reset=20with=20Alt-click=20and=20don't=20?= =?UTF-8?q?stick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From #186 ("Adjusting send levels can get stuck"). The send readout in the Mix window had three problems: - A drag stopped at −60 dB, so a send couldn't be dragged off, and a new send (−∞) jumped to −60 on the first drag. Drags now move along the fader's taper, like the level bar under the send name: smoothly down to −∞ and back up, at about the old 0.15 dB a point near 0 dB. - Its tooltip promised "double-click for 0 dB", but the first click already turned the readout into a text field, so that never ran. Alt-click now resets it to 0 dB, as on the fader. The volume and pan readouts reset on Alt-click too (0 dB, centre), since all three share the readout, and Alt-click no longer opens their text field. - The text field stayed open when it lost focus while hidden (switching windows with it open, for longer than a frame), and a later Enter anywhere committed its stale text. Now it gives the readout back, and Enter only commits while the field has focus. --- crates/ui-egui/src/mix_window.rs | 82 +++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..a00b30b 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -303,7 +303,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let _ = resp; let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0)); ui.painter().rect_filled(pr, 1.0, t.counter_bg); - let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan); + let (_, entered) = + editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan, 0.0); if let Some(v) = entered { let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); } @@ -378,6 +379,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh &db_text(track.mixer.volume_db), &format!("{:.1}", track.mixer.volume_db), parse_db, + 0.0, ); if let Some(db) = entered { let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); @@ -649,6 +651,7 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec &format!("{} dB", db_text(s.level_db)), &format!("{:.1}", s.level_db), parse_db, + 0.0, ); if let Some(db) = entered { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); @@ -657,17 +660,13 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so // 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_per_point = if fine { 0.02 } else { 0.15 }; - let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0); + let db = dragged_send_db(s.level_db, dy, fine); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } - if level_resp.as_ref().is_some_and(egui::Response::double_clicked) { - let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); - } if let Some(resp) = level_resp { let _ = resp.clone().on_hover_text(format!( - "Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options", + "Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", (b'A' + slot as u8) as char, db_text(s.level_db) )); @@ -751,8 +750,16 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, } } +/// A send level after a vertical drag of `dy` points: along the fader's taper, like the level bar, +/// so it runs smoothly down to −∞ and back. About 0.15 dB a point near 0 dB (0.02 when fine). +fn dragged_send_db(db: f32, dy: f32, fine: bool) -> f32 { + let per_point = if fine { 0.0004 } else { 0.003 }; + soundcraft_model::fader_pos_to_db(soundcraft_model::fader_db_to_pos(db) - dy * per_point) +} + /// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away -/// commits a valid value; Escape cancels. The interaction response remains available for dragging. +/// commits a valid value; Escape cancels, and so does the field losing focus while hidden. +/// Alt-click returns `reset`. The interaction response remains available for dragging. fn editable_readout( ui: &mut Ui, r: Rect, @@ -760,6 +767,7 @@ fn editable_readout( display: &str, edit_initial: &str, parse: fn(&str) -> Option, + reset: f32, ) -> (Option, Option) { let t = Tokens::current(); let active_id = id.with("active"); @@ -767,9 +775,14 @@ fn editable_readout( let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false); let mut started = false; let mut response = None; + let mut value = None; if !active { let resp = ui.interact(r, id, Sense::click_and_drag()); - if resp.clicked() && !resp.double_clicked() { + let alt = ui.input(|i| i.modifiers.alt); + if resp.clicked() && alt { + value = Some(reset); + } + if resp.clicked() && !alt && !resp.double_clicked() { active = true; started = true; ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string())); @@ -778,7 +791,6 @@ fn editable_readout( } response = Some(resp); } - let mut value = None; if active { let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string()); let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r)); @@ -796,13 +808,15 @@ fn editable_readout( let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); if escape { active = false; - } else if enter || edit_resp.lost_focus() { + } else if (enter && edit_resp.has_focus()) || edit_resp.lost_focus() { if let Some(parsed) = parse(&text) { value = Some(parsed); active = false; } else if edit_resp.lost_focus() { active = false; } + } else if !started && !edit_resp.has_focus() { + active = false; } ui.data_mut(|d| d.insert_temp(text_id, text)); } @@ -936,7 +950,8 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { #[cfg(test)] mod tests { - use super::{parse_db, parse_pan}; + use super::{dragged_send_db, editable_readout, parse_db, parse_pan}; + use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; #[test] fn typed_db_accepts_console_notation_and_clamps() { @@ -956,4 +971,47 @@ mod tests { assert_eq!(parse_pan("0.5"), Some(0.5)); assert_eq!(parse_pan("center"), None); } + + #[test] + fn send_drags_follow_the_fader_taper_down_to_minus_infinity() { + assert!((dragged_send_db(0.0, -10.0, false) - 1.44).abs() < 0.01); + assert!((dragged_send_db(0.0, -10.0, true) - 0.192).abs() < 0.01); + assert_eq!(dragged_send_db(0.0, 400.0, false), -144.0); + let up = dragged_send_db(-144.0, -1.0, false); + assert!(up > -144.0 && up < -100.0, "one point up leaves −∞ gently, got {up}"); + assert_eq!(dragged_send_db(12.0, -50.0, false), 12.0); + } + + #[test] + fn readouts_reset_on_alt_click_and_drop_a_field_that_lost_focus() { + let ctx = egui::Context::default(); + let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(40.0, 14.0)); + let click = |mods: Modifiers| { + let press = |pressed| Event::PointerButton { pos: r.center(), button: PointerButton::Primary, pressed, modifiers: mods }; + vec![Event::ModifiersChanged(mods), Event::PointerMoved(r.center()), press(true), press(false)] + }; + let mut time = 0.0; + let mut frame = |events: Vec, shown: bool| { + time += 0.5; + let mut out = (false, None); + let mut full = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| { + if shown { + let (resp, value) = editable_readout(ui, r, egui::Id::new("readout"), "-3.0", "-3.0", parse_db, 0.0); + out = (resp.is_some(), value); + } + }); + full.textures_delta.clear(); + out + }; + assert_eq!(frame(vec![], true), (true, None)); + assert_eq!(frame(click(Modifiers::ALT), true), (true, Some(0.0)), "Alt-click resets"); + frame(vec![Event::ModifiersChanged(Modifiers::NONE)], true); + frame(click(Modifiers::NONE), true); + assert_eq!(frame(vec![], true), (false, None), "a click opens the text field"); + for _ in 0..3 { + frame(vec![], false); + } + frame(vec![], true); + assert_eq!(frame(vec![], true), (true, None), "hidden, the field lost focus, so the readout is back"); + } } From edf4fc59f60a0e1a37d085107cf311007256fa49 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:07:47 +0100 Subject: [PATCH 096/137] Right-click a track's name for the track menu, in Edit and Mix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From #186 and #305: right-clicking a track's name did nothing. In the Edit window the track menu (Rename, Duplicate, Make Inactive, Hide, Delete, height, colour…) was only on the header's background, and the name plate drawn over it took the click. The Mix window had no track menu at all. Pro Tools opens it from the name in both windows. Both name plates now open the same track menu on a right-click. Clicks, double-click to rename and dragging to reorder are unchanged. --- crates/ui-egui/src/edit_window.rs | 3 +- crates/ui-egui/src/mix_window.rs | 1 + crates/ui-egui/tests/track_name_menu.rs | 40 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/track_name_menu.rs diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..74f0327 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -816,6 +816,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } else if nresp.clicked() { click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } + nresp.context_menu(|ui| track_context_menu(app, ui, id)); // Drag the name plate to reorder tracks. if nresp.dragged() && let Some(p) = ui.ctx().pointer_latest_pos() @@ -1079,7 +1080,7 @@ fn view_menu(app: &mut SoundApp, ui: &mut Ui, track: &Track) { } } -fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { +pub(crate) fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let tid = id.0; let items: [(&str, &str); 10] = [ ("Rename...", "ui.rename_track"), diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..970d9a9 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -409,6 +409,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } + nresp.context_menu(|ui| crate::edit_window::track_context_menu(app, ui, id)); if app.ui.mix_views.iter().any(|v| v == "color") { ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x, r.max.y - 5.0), vec2(w, 5.0)), 0.0, rgb(track.color)); } diff --git a/crates/ui-egui/tests/track_name_menu.rs b/crates/ui-egui/tests/track_name_menu.rs new file mode 100644 index 0000000..e11ed78 --- /dev/null +++ b/crates/ui-egui/tests/track_name_menu.rs @@ -0,0 +1,40 @@ +//! Right-clicking a track's name opens the track menu, in the Edit and in the Mix window. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn right_click(h: &mut Harness, at: Pos2) { + h.hover_at(at); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); +} + +#[test] +fn right_click_on_a_track_name_opens_the_track_menu() { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &serde_json::json!({"name": "Voice"})).unwrap(); + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let row = h.state().edit_layout.rows[0].1; + right_click(&mut h, pos2(row[0] + 60.0, row[1] + 10.0)); + assert!(h.query_by_label("Make Inactive").is_some(), "the Edit window's name plate"); + + h.key_press(egui::Key::Escape); + h.state_mut().ui.window = MainWindow::Mix; + h.run_steps(4); + assert!(h.query_by_label("Make Inactive").is_none()); + let strip = h.get_by_label("Record enable").rect(); + right_click(&mut h, pos2(strip.center().x, 900.0 - 17.0)); + assert!(h.query_by_label("Make Inactive").is_some(), "the Mix strip's name plate"); +} From dab2bb956c0b033e7043181904d8c6fb61a66cc2 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:31:33 +0100 Subject: [PATCH 097/137] Don't let Strip Silence delete clips past what one render holds Strip Silence rendered the whole selection at once, and a render stops at 2^29 samples over all channels: 93 minutes of stereo at 48 kHz, 46 at 96 kHz. Everything past that read as silence, so the clips there were deleted. A 105-minute selection on a stereo track lost its clip at 100 minutes. It now renders at most 2^24 samples at a time, only where unmuted audio clips play, into a streaming version of the silence detector (`SilenceScan`, which `non_silent_ranges` now runs on). A window takes at most 64 MB instead of up to 2 GiB, and more than a day of clips is refused rather than scanned for minutes. `render_clips` now skips clips outside its range before looking up their clip effects, so a track of 5,000 clips strips as fast as before. Results are unchanged on the demo sessions and on 10 minutes of tone bursts that cross window edges. --- crates/dsp/src/offline.rs | 86 ++++++++++++++++++-------- crates/dsp/tests/offline_meters.rs | 20 ++++++ crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/io.rs | 98 +++++++++++++++++++++++++----- crates/mix/src/lib.rs | 5 +- 5 files changed, 163 insertions(+), 48 deletions(-) diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index b9a5eac..2c3136f 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -443,39 +443,71 @@ pub fn detect_transients(ch: &[Vec], sample_rate: f32, sensitivity: f32) -> /// Each region is padded by `pad_before`/`pad_after` (clamped to the buffer) and overlapping /// regions are merged. pub fn non_silent_ranges(ch: &[Vec], threshold_db: f32, min_len: usize, pad_before: usize, pad_after: usize) -> Vec<(usize, usize)> { - let len = max_len(ch); - let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db }); - let mut raw: Vec<(usize, usize)> = Vec::new(); - let mut start: Option = None; - let mut last_loud = 0usize; - for i in 0..len { - let loud = ch.iter().any(|c| c.get(i).is_some_and(|v| v.is_finite() && v.abs() > thr)); - if !loud { - continue; - } - match start { - Some(s) if i - last_loud > min_len.max(1) => { - raw.push((s, last_loud + 1)); - start = Some(i); + let mut scan = SilenceScan::new(threshold_db, min_len as u64); + scan.push(ch); + let at = |x: u64| usize::try_from(x).unwrap_or(usize::MAX); + scan.finish(pad_before as u64, pad_after as u64).into_iter().map(|(s, e)| (at(s), at(e))).collect() +} + +/// [`non_silent_ranges`] over audio that arrives a block at a time, so material of any length can +/// be scanned without holding all of it. +pub struct SilenceScan { + thr: f32, + min_len: u64, + len: u64, + start: Option, + last_loud: u64, + raw: Vec<(u64, u64)>, +} + +impl SilenceScan { + pub fn new(threshold_db: f32, min_len: u64) -> Self { + let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db }); + SilenceScan { thr, min_len, len: 0, start: None, last_loud: 0, raw: Vec::new() } + } + + /// Scan the next block (planar), which follows the audio scanned so far. + pub fn push(&mut self, ch: &[Vec]) { + let n = max_len(ch); + for k in 0..n { + if !ch.iter().any(|c| c.get(k).is_some_and(|v| v.is_finite() && v.abs() > self.thr)) { + continue; } - None => start = Some(i), - _ => {} + let i = self.len.saturating_add(k as u64); + match self.start { + Some(s) if i - self.last_loud > self.min_len.max(1) => { + self.raw.push((s, self.last_loud.saturating_add(1))); + self.start = Some(i); + } + None => self.start = Some(i), + _ => {} + } + self.last_loud = i; } - last_loud = i; + self.len = self.len.saturating_add(n as u64); } - if let Some(s) = start { - raw.push((s, last_loud + 1)); + + /// Pass over `n` samples known to be silent. + pub fn skip(&mut self, n: u64) { + self.len = self.len.saturating_add(n); } - let mut out: Vec<(usize, usize)> = Vec::with_capacity(raw.len()); - for (s, e) in raw { - let s = s.saturating_sub(pad_before); - let e = e.saturating_add(pad_after).min(len); - match out.last_mut() { - Some(last) if s <= last.1 => last.1 = last.1.max(e), - _ => out.push((s, e)), + + /// The regions found, padded and merged (see [`non_silent_ranges`]). + pub fn finish(mut self, pad_before: u64, pad_after: u64) -> Vec<(u64, u64)> { + if let Some(s) = self.start { + self.raw.push((s, self.last_loud.saturating_add(1))); } + let mut out: Vec<(u64, u64)> = Vec::with_capacity(self.raw.len()); + for (s, e) in self.raw { + let s = s.saturating_sub(pad_before); + let e = e.saturating_add(pad_after).min(self.len); + match out.last_mut() { + Some(last) if s <= last.1 => last.1 = last.1.max(e), + _ => out.push((s, e)), + } + } + out } - out } /// Runs a plugin over whole buffers in blocks, compensating its latency so the output lines up diff --git a/crates/dsp/tests/offline_meters.rs b/crates/dsp/tests/offline_meters.rs index f83f241..deeca6b 100644 --- a/crates/dsp/tests/offline_meters.rs +++ b/crates/dsp/tests/offline_meters.rs @@ -200,6 +200,26 @@ fn non_silent_ranges_finds_bursts() { assert!(offline::non_silent_ranges(&[vec![0.0; 100]], -40.0, 10, 5, 5).is_empty()); } +#[test] +fn silence_scan_in_blocks_finds_what_one_pass_finds() { + let mut x = vec![0.0f32; 48_000]; + for (i, v) in sine(500.0, 0.5, 48_000.0, 4800).into_iter().enumerate() { + x[10_000 + i] = v; + x[30_000 + i] = v; + } + let whole: Vec<(u64, u64)> = + offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200).into_iter().map(|(a, b)| (a as u64, b as u64)).collect(); + let mut blocks = offline::SilenceScan::new(-40.0, 480); + x.chunks(1000).for_each(|b| blocks.push(&[b.to_vec()])); + assert_eq!(blocks.finish(100, 200), whole); + // Skipping the silence between the bursts counts its length like scanning it. + let mut skipped = offline::SilenceScan::new(-40.0, 480); + skipped.push(&[x[..20_000].to_vec()]); + skipped.skip(10_000); + skipped.push(&[x[30_000..].to_vec()]); + assert_eq!(skipped.finish(100, 200), whole); +} + #[test] fn transients_detected_near_onsets() { let sr = 48_000.0; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..51068eb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -665,7 +665,7 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut kept = 0; for t in &tracks { - kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post); + kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?; } Ok(json!({"clips": kept})) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..6f11450 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -349,20 +349,13 @@ pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec usize { +pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result { let r = bounded_to_track(s, t, r); - let audio = soundcraft_mix::render_clips(s, t, r); - let keep = soundcraft_dsp::offline::non_silent_ranges( - &audio, - thr, - usize::try_from(min_len).unwrap_or(0), - usize::try_from(pre).unwrap_or(0), - usize::try_from(post).unwrap_or(0), - ); + let keep = non_silent(s, t, r, thr, min_len, pre, post)?; let mut cut_points = vec![r.start]; for (a, b) in &keep { - cut_points.push(r.start + *a as i64); - cut_points.push(r.start + *b as i64); + cut_points.push(*a); + cut_points.push(*b); } cut_points.push(r.end); for p in &cut_points { @@ -372,11 +365,10 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S let mut gaps = Vec::new(); let mut prev = r.start; for (a, b) in &keep { - let a = r.start + *a as i64; - if a > prev { - gaps.push(Range::new(prev, a)); + if *a > prev { + gaps.push(Range::new(prev, *a)); } - prev = r.start + *b as i64; + prev = *b; } if prev < r.end { gaps.push(Range::new(prev, r.end)); @@ -384,7 +376,44 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) { pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end)); } - keep.len() + Ok(keep.len()) +} + +/// Most samples (over all channels) Strip Silence renders at once. +const STRIP_WINDOW_SAMPLES: usize = 1 << 24; + +/// The non-silent ranges of `t`'s clips in `r`. The audio is rendered a window at a time where +/// audio clips play, so all of `r` is scanned however long it is (one render stops at +/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session. +fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result> { + let Some(tr) = s.track(t) else { return Ok(Vec::new()) }; + let mut clips: Vec = tr.clips().iter().filter(|c| c.is_audio() && !c.muted).filter_map(|c| c.range().intersect(&r)).collect(); + clips.sort_by_key(|x| x.start); + let mut spans: Vec = Vec::new(); + for x in clips { + match spans.last_mut() { + Some(last) if x.start <= last.end => last.end = last.end.max(x.end), + _ => spans.push(x), + } + } + if spans.iter().map(Range::len).fold(0, i64::saturating_add) > s.sample_rate.samples(86_400.0) { + return Err(EngineError::BadParams("edit.strip_silence".into(), format!("`{}` has more than a day of clips to scan", tr.name))); + } + let window = i64::try_from(STRIP_WINDOW_SAMPLES / tr.channels().max(1)).unwrap_or(i64::MAX); + let len = |n: Samples| u64::try_from(n).unwrap_or(0); + let mut scan = soundcraft_dsp::offline::SilenceScan::new(thr, len(min_len)); + let mut at = r.start; + for x in spans { + scan.skip(len(x.start - at)); + at = x.start; + while at < x.end { + let end = at.saturating_add(window).min(x.end); + scan.push(&soundcraft_mix::render_clips(s, t, Range::new(at, end))); + at = end; + } + } + scan.skip(len(r.end - at)); + Ok(scan.finish(len(pre), len(post)).into_iter().map(|(a, b)| (r.start.saturating_add_unsigned(a), r.start.saturating_add_unsigned(b))).collect()) } /// Consolidate the selection on a track into one new clip backed by a rendered source. @@ -1229,6 +1258,43 @@ mod stem_tests { } } +#[cfg(test)] +mod strip_tests { + use super::*; + + /// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone. + fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"format": "stereo"})).unwrap(); + let t = e.session().tracks[0].id; + let s = e.session_mut(); + let tone = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 48_000]; 2] }; + let src = add_source(s, "tone", tone, None, FileFormat::Wav); + for at in starts { + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, "tone", src, 0, *at, length)); + } + (e, t) + } + + #[test] + fn clips_past_what_one_render_holds_are_kept() { + // 100 minutes in: a stereo render stops at 2^28 frames, about 93 minutes. + let far = 48_000 * 6000; + let (mut e, t) = tones(&[0, far], 48_000); + let r = e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": far + 96_000})).unwrap(); + assert_eq!(r["clips"], 2); + assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far)); + } + + #[test] + fn more_than_a_day_of_clips_is_refused() { + let (mut e, t) = tones(&[0], 48_000 * 86_401); + assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 48_000i64 * 86_402})).is_err()); + assert_eq!(e.session().track(t).unwrap().clips().len(), 1); + } +} + #[cfg(test)] mod bundle_tests { use super::*; diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..0c3ead9 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1852,10 +1852,7 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1)); let mut out = vec![vec![0.0f32; len]; t.channels().max(1)]; let mut scratch = Vec::new(); - for c in t.clips() { - if c.muted { - continue; - } + for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) { match clip_fx_params(s, c.id.0) { None => render_clip(s, c, range.start, len, &mut out, &mut scratch), Some(params) => { From 5e7309537ee2ae506f5f999ccf6f681d09bdb1e3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:11:22 +0100 Subject: [PATCH 098/137] Make an automation pass one undo step, its fader ride included write_automation merged a pass's points under one key, but the fader driving the pass merged under its own, so each frame of a ride started two new undo steps: one second of riding made 120, and in two seconds everything before the pass was gone from the 200-step history. Stop's last point was a step of its own as well, because every command, transport.stop included, ended the gesture. Engine::hold_merge makes every merged gesture take the held key until it is released. While playing with a track in a write mode, write_automation holds the pass's key, so the pass and whatever is dragged during it are one undo step. A command now ends a gesture only when it changes the document, so Stop no longer splits the pass it ends. --- crates/engine/src/lib.rs | 49 ++++++++++++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 30 ++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534..be5a96f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -118,6 +118,8 @@ pub struct Engine { dirty_at: Cell<(u64, bool)>, /// Gesture key for undo coalescing (see `execute_merged`). merge_key: Option, + /// The key every merged gesture takes while held (see `hold_merge`). + merge_hold: Option, /// Messages for the UI status line / agents. pub messages: Vec, } @@ -147,6 +149,7 @@ impl Engine { dirty: false, dirty_at: Cell::new((0, false)), merge_key: None, + merge_hold: None, messages: Vec::new(), } } @@ -266,7 +269,6 @@ impl Engine { if spec.view { return self.execute_view(spec, params); } - self.merge_key = None; let before = Arc::clone(&self.doc); let rev = self.revision; let dirty = self.dirty; @@ -280,6 +282,10 @@ impl Engine { return Err(EngineError::Internal(id.to_string(), panic_message(&*p))); } }; + if !Arc::ptr_eq(&before, &self.doc) { + // Only a change to the document ends a gesture, so Stop doesn't split the pass it ends. + self.merge_key = None; + } match result { Ok(v) => { if spec.undoable && !Arc::ptr_eq(&before, &self.doc) { @@ -315,17 +321,26 @@ impl Engine { /// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls /// with the same `key` collapse into a single undo step. pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result { - let continuing = self.merge_key.as_deref() == Some(key); + let key = self.merge_hold.clone().unwrap_or_else(|| key.to_string()); + let continuing = self.merge_key.as_ref() == Some(&key); let before_len = self.undo.len(); let r = self.execute(id, params)?; if continuing && self.undo.len() == before_len + 1 && before_len > 0 { // Drop the step just pushed; the earlier one still holds the pre-gesture document. self.undo.pop(); } - self.merge_key = Some(key.to_string()); + self.merge_key = Some(key); Ok(r) } + /// While `key` is held, every `execute_merged` gesture takes it instead of its own, so they + /// all join one undo step (an automation pass and whatever is dragged during it). + pub fn hold_merge(&mut self, key: Option<&str>) { + if self.merge_hold.as_deref() != key { + self.merge_hold = key.map(str::to_string); + } + } + /// End a gesture started with `execute_merged`. pub fn end_merge(&mut self) { self.merge_key = None; @@ -400,6 +415,34 @@ mod merge_tests { e.undo(); assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0); } + + #[test] + fn a_held_merge_joins_other_gestures_into_its_step() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None); + e.hold_merge(Some("pass")); + for db in [-1.0, -2.0, -3.0] { + e.execute_merged("mix.volume", &json!({"track": t.0, "db": db}), "fader").unwrap(); + e.execute_merged("mix.pan", &json!({"track": t.0, "pan": db / 4.0}), "pan").unwrap(); + } + assert_eq!(e.undo_history().len(), 1); + e.hold_merge(None); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -6.0}), "fader").unwrap(); + assert_eq!(e.undo_history().len(), 2); + } + + #[test] + fn only_a_change_to_the_document_ends_a_gesture() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -1.0}), "fader").unwrap(); + e.execute("transport.stop", &json!({})).unwrap(); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -2.0}), "fader").unwrap(); + assert_eq!(e.undo_history().len(), 1); + e.execute("mix.mute", &json!({"track": t.0})).unwrap(); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -3.0}), "fader").unwrap(); + assert_eq!(e.undo_history().len(), 3); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fd20957..5a1fd0e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -606,10 +606,15 @@ 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 - /// automation since the previous one, and Stop writes a last point. One undo step per pass. + /// 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. fn write_automation(&mut self, ctx: &egui::Context) { use soundcraft_model::AutomationMode as M; + const PASS: &str = "automation_pass"; let playing = self.is_playing(); + 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() { self.last_write_at = i64::MIN; return; @@ -648,7 +653,7 @@ impl SoundApp { if from != i64::MIN { point["from"] = from.into(); } - let _ = self.engine.execute_merged("automation.set_point", &point, "automation_pass"); + let _ = self.engine.execute_merged("automation.set_point", &point, PASS); } if !playing { self.touched.clear(); @@ -1105,6 +1110,27 @@ mod tests { assert_eq!(lane.value_at(10 * q, 0.0), -30.0); } + #[test] + fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + let steps = app.engine.undo_history().len(); + let ctx = egui::Context::default(); + for frame in 0..60 { + app.handle_transport(1.0 / 60.0); + app.write_automation(&ctx); + // What the Mix window's fader sends while it is dragged. + let db = -10.0 + frame as f32 / 10.0; + app.engine.execute_merged("mix.volume", &json!({"track": kick.0, "db": db}), &format!("fader:{}", kick.0)).unwrap(); + } + app.run("transport.stop", json!({})).unwrap(); + app.handle_transport(0.0); + app.write_automation(&ctx); + assert_eq!(app.engine.undo_history().len(), steps + 1); + } + #[test] fn preferences_saved_before_the_window_was_remembered_keep_full_scale() { let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap(); From acaa91008ca3f6ed8bbbcf0efb587680e75293ca Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 12 Oct 2026 19:27:12 +0200 Subject: [PATCH 099/137] mix: a newly adopted instance replaces a matching one in its slot When a third-party plugin was removed and re-added (same id, same channel count) and both session updates reached the audio thread in one drain, sync saw an unchanged slot, kept the old instance and retired the new one as an unclaimed leftover. The host-side editor belonged to the new, retired instance, so reopening it failed with "the plugin is gone", and the old instance kept running with no editor. The host only hands over an instance when it no longer tracks the one in the slot, so an instance waiting for a slot now always replaces it (inserts and instruments). Debug logs record instance creation and release on the UI thread. Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++-- crates/playback/src/lib.rs | 6 +++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..3a6d175 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -344,6 +344,13 @@ impl SlotCtx<'_> { } } + /// True when the host handed over an instance for this slot. It replaces whatever the slot + /// holds, even an instance of the same plugin: the host made it because it no longer tracks + /// the old one (it was removed and re-added between two renders), and drives its editor. + fn waiting(&self, t: TrackId, idx: usize) -> bool { + self.external && self.adopt.contains_key(&(t, idx)) + } + /// A new instance for `ins` on track `t`, slot `idx`, prepared for `ch` channels. fn make(&mut self, t: TrackId, idx: usize, ins: &soundcraft_model::Insert, ch: usize) -> Option { let third = is_third_party(&ins.plugin); @@ -511,7 +518,7 @@ impl MixEngine { strip.plugins.resize_with(t.mixer.inserts.len(), || None); for (idx, (slot, ins)) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()).enumerate() { match ins { - Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) => { + Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) || cx.waiting(t.id, idx) => { let old = slot.take(); cx.retire(old); *slot = cx.make(t.id, idx, i, ch); @@ -525,7 +532,9 @@ impl MixEngine { } let want = t.instrument.as_ref(); let ich = ch.max(2); - if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich)) { + if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich)) + || (want.is_some() && cx.waiting(t.id, INSTRUMENT_SLOT)) + { let old = strip.instrument.take(); cx.retire(old); strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich)); @@ -2218,6 +2227,31 @@ mod tests { assert!(eng.drain_plugins().is_empty()); } + #[test] + fn a_new_instance_replaces_a_matching_one_in_its_slot() { + // The host removed and re-added the plugin, and both session updates reached the audio + // thread in one go: the slot still holds the old instance (same id, same channels), but + // the host made a new one and drives its editor. The new one must win, or the editor + // talks to an instance that was retired ("the plugin is gone"). + let (mut s, t) = session_with_dc(0.5, 48_000); + s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.fake")); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let make = |blob: &[u8]| { + let mut gain = soundcraft_dsp::create("gain").unwrap(); + gain.prepare(48_000.0, 512, 1); + Box::new(Stateful { inner: gain, blob: blob.to_vec(), sets: Arc::clone(&sets) }) + }; + eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"old") }]); + render_block(&mut eng, &s, 0); + assert_eq!(eng.capture_states(), vec![(t, 0, b"old".to_vec())]); + eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"new") }]); + render_block(&mut eng, &s, 512); + assert_eq!(eng.capture_states(), vec![(t, 0, b"new".to_vec())]); + assert_eq!(eng.take_retired().len(), 1, "the old instance is retired"); + } + #[test] fn stored_state_comes_first_then_session_params() { let (mut s, t) = session_with_dc(0.5, 48_000); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..ec76ea9 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -332,7 +332,10 @@ impl Side { /// Drops retired instances (closing their editors first, inside the plugin's drop). fn collect_garbage(&mut self) { while let Ok(g) = self.garbage.try_recv() { - drop(g); + for p in g { + log::debug!("plugin instance released: {}", p.info().name); + drop(p); + } } } @@ -681,6 +684,7 @@ impl Side { } self.forget(key); let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch); + log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some()); self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some())); if let Some(mut plugin) = made { if let Some(e) = plugin.editor() { From 0a5fc6ccba09a6262783c624ba8423d9eb66831f Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:47:27 +0100 Subject: [PATCH 100/137] Say at the Record button why Record did nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #316: in a new session, clicking Record did nothing visible. With no record-enabled track there is nothing to record, and the reason went only to the end of the dim status line at the bottom of the Edit window. In a new session it also pointed at "the red button in its header", which no track existed to show. - Record with nothing to record on says what to do: add a track with Track › New… when the session has none, else click R on the track to record. - The message shows for 4 seconds below the toolbar's Record button (under the whole transport group, so it covers none of its buttons) and under the Transport window's Record button, as well as in the status line. It goes early once a track is record-enabled. - Nothing is armed or created on the user's behalf, and the transport stays stopped. --- crates/ui-egui/src/lib.rs | 41 ++++++++++++++++++++++++++++- crates/ui-egui/src/panels.rs | 10 ++++--- crates/ui-egui/src/toolbar.rs | 10 +++++-- crates/ui-egui/tests/record_hint.rs | 25 ++++++++++++++++++ 4 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/record_hint.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..b2a6390 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -259,6 +259,8 @@ pub struct SoundApp { pub(crate) docking_context: Option, /// Transport simulation when there is no player (tests, offscreen renders). sim: Option<(Samples, Option, Option)>, + /// When Record last found nothing to record on, and why: the Record buttons show it for a moment. + record_hint: Option<(f64, &'static str)>, pub quit_requested: bool, /// Reset floating-window positions next frame (Window › Arrange). pub arrange_request: bool, @@ -318,6 +320,7 @@ impl SoundApp { theme_applied: None, docking_context: None, sim: None, + record_hint: None, quit_requested: false, arrange_request: false, touched: std::collections::HashSet::new(), @@ -369,6 +372,13 @@ impl SoundApp { self.engine.transport.playing } + /// Why the last Record press did nothing, for a few seconds and while it still holds. + pub(crate) fn record_hint(&self) -> Option<&'static str> { + let (at, why) = self.record_hint?; + let fresh = self.last_frame.unwrap_or_default() - at < 4.0; + (fresh && !self.engine.session().tracks.iter().any(|t| t.mixer.record_arm)).then_some(why) + } + /// Current transport position (playhead while playing, else the edit insertion). pub fn position(&self) -> Samples { if self.engine.transport.playing { self.engine.transport.position } else { self.engine.session().edit.selection.start } @@ -435,7 +445,13 @@ impl SoundApp { fn start_recording(&mut self) { let armed = self.engine.session().tracks.iter().any(|t| t.mixer.record_arm); if !armed { - self.ui.status = "Record-enable a track first (the red button in its header).".into(); + let why = if self.engine.session().tracks.is_empty() { + "Nothing to record on yet: add a track with Track › New…, then click its R button." + } else { + "No track is record-enabled: click the R button on the track to record." + }; + self.ui.status = why.into(); + self.record_hint = Some((self.last_frame.unwrap_or_default(), why)); return; } self.ensure_input(); @@ -998,6 +1014,29 @@ mod tests { assert_eq!(app.engine.transport.position, 0); } + #[test] + fn record_with_nothing_armed_says_what_to_do() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("transport.record", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(!app.is_playing() && !app.engine.transport.recording); + assert!(app.record_hint().is_some_and(|why| why.contains("Track › New")), "{:?}", app.record_hint()); + + app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap(); + app.run("transport.record", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.record_hint().is_some_and(|why| why.contains("R button")), "{:?}", app.record_hint()); + assert_eq!(Some(app.ui.status.as_str()), app.record_hint()); + app.last_frame = Some(5.0); + assert_eq!(app.record_hint(), None, "the hint shows for a few seconds"); + + app.last_frame = None; + app.run("transport.record", json!({})).unwrap(); + app.handle_transport(0.25); + app.run("mix.record_arm", json!({})).unwrap(); + assert_eq!(app.record_hint(), None, "arming a track answers the hint"); + } + #[test] fn a_hidden_window_still_autosaves() { let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id())); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..d0813c7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -198,15 +198,19 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { ("end", "transport.go_to_end"), ("record", "transport.record"), ] { - if crate::widgets::icon_button( + let resp = crate::widgets::icon_button( ui, vec2(34.0, 26.0), icon, (cmd == "transport.play" && app.is_playing()) || (cmd == "transport.record" && app.engine.transport.recording), cmd, - ) - .clicked() + ); + if cmd == "transport.record" + && let Some(why) = app.record_hint() { + egui::Tooltip::for_widget(&resp).show(|ui| ui.label(why)); + } + if resp.clicked() { let _ = app.run(cmd, json!({})); } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..e8f7bb9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -275,7 +275,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { let playing = app.is_playing(); let recording = app.engine.transport.recording; let looped = app.engine.session().edit.loop_playback; - group(ui, 176.0, 64.0, |ui, _| { + group(ui, 176.0, 64.0, |ui, g| { ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(3.0, 4.0); ui.horizontal(|ui| { @@ -328,7 +328,13 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { StrokeKind::Inside, ); icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec); - if resp.on_hover_text("Record (Cmd+Space)").clicked() { + let resp = resp.on_hover_text("Record (Cmd+Space)"); + if let Some(why) = app.record_hint() { + // Below the whole group, so the hint covers none of its buttons. + let below = Rect::from_min_max(r.min, pos2(r.max.x, g.max.y)); + egui::Tooltip::always_open(ui.ctx().clone(), resp.layer_id, resp.id, below).show(|ui| ui.label(why)); + } + if resp.clicked() { let _ = app.run("transport.record", json!({})); } }); diff --git a/crates/ui-egui/tests/record_hint.rs b/crates/ui-egui/tests/record_hint.rs new file mode 100644 index 0000000..afcd11a --- /dev/null +++ b/crates/ui-egui/tests/record_hint.rs @@ -0,0 +1,25 @@ +//! Record with nothing to record on explains itself at the Record buttons. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn record_in_an_empty_session_shows_why_at_both_record_buttons() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.ui.show_transport = true; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + assert_eq!(h.query_all_by_label_contains("Nothing to record on").count(), 0); + h.get_by_label("transport.record").click(); + h.run_steps(3); + assert!(!h.state().engine.transport.recording); + assert_eq!(h.query_all_by_label_contains("Nothing to record on").count(), 2, "the toolbar's and the Transport window's"); +} From c40078f32a78f582cff8ca9b7db5d64ca0ef59c7 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:37:21 +0100 Subject: [PATCH 101/137] Don't crash on CAF files that claim more data than they hold Importing a 72-byte CAF file ended the process. symphonia allocates whatever a CAF magic cookie, packet table or packet claims before it reads it, and when that allocation fails Rust aborts, which no catch_unwind can stop. A cookie claiming 54 PB, a table of 2^50 packets and a single packet of 2^50 bytes each did it, on import and in `soundcraft-cli info`. symph::open now runs symphonia's probe loop itself, and when the container it finds is a CAF stream, it first walks the chunks the way symphonia reads them. A file whose cookie, packet table or packets claim more bytes than it holds is refused as malformed. So is a packet table symphonia would refuse only after reserving 32 bytes per packet: one that runs past the file, or one listing packets of a constant bit rate stream. The probe's hint was never read by symphonia 0.5, so it is no longer passed. --- crates/audio-io/src/caf.rs | 87 ++++++++++++++++++++++++++++++ crates/audio-io/src/lib.rs | 13 ++--- crates/audio-io/src/pcm.rs | 6 +++ crates/audio-io/src/symph.rs | 44 +++++++++------ crates/audio-io/tests/malformed.rs | 57 ++++++++++++++++++++ 5 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 crates/audio-io/src/caf.rs diff --git a/crates/audio-io/src/caf.rs b/crates/audio-io/src/caf.rs new file mode 100644 index 0000000..fe4dda1 --- /dev/null +++ b/crates/audio-io/src/caf.rs @@ -0,0 +1,87 @@ +//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie, +//! a packet table or a packet claims, and a failed allocation aborts the process. + +use crate::pcm::{be_u16, be_u32, be_u64}; +use crate::{AudioError, Result}; + +/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes +/// than the file holds. +pub(crate) fn check(b: &[u8], at: usize) -> Result<()> { + if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) } +} + +fn malformed(what: &str) -> AudioError { + AudioError::Malformed(format!("CAF {what} is larger than the file")) +} + +/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by +/// the declared size, and after a `data` chunk of unknown size it reads on into the audio. +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 used = match kind { + b"desc" if size == 32 => { + lists_sizes = be_u32(b, body + 16) == Some(0); + lists_frames = be_u32(b, body + 20) == Some(0); + described = true; + Some(32) + } + b"data" if size == -1 => 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"desc" | b"pakt" | b"chan" => None, + _ => u64::try_from(size).ok(), + }; + 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, + _ => return Ok(()), + } + } + Ok(()) +} + +/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or +/// None where symphonia stops. It reserves room for every packet before reading any, so a table +/// it would refuse after that, or that runs past the file, is refused first. +fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result> { + let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0); + let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) }; + let mut at = body + 24; + if packets > b.len().saturating_sub(at) as u64 { + return Err(malformed("packet table")); + } + if !lists_sizes && !lists_frames { + return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) }; + } + for _ in 0..packets { + if lists_sizes { + let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) }; + if size > b.len() as u64 { + return Err(malformed("packet")); + } + at += len; + } + if lists_frames { + let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) }; + at += len; + } + } + Ok(Some((at - body) as u64)) +} + +/// A CAF variable-length integer at `at` and its length in bytes. +fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> { + let mut v = 0u64; + for i in 0..9 { + let x = *b.get(at + i)?; + v |= u64::from(x & 0x7f); + if x & 0x80 == 0 { + return Some((v, i + 1)); + } + v <<= 7; + } + None +} diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 87241c6..86030f0 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -14,6 +14,7 @@ mod aiff; mod buffer; +mod caf; mod flac; mod pcm; pub mod peaks; @@ -205,16 +206,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result { FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? { wav::WavParse::Native(l) => Ok(l.info), wav::WavParse::Foreign { bwf } => { - let mut info = symph::probe(bytes, Some("wav"), format)?; + let mut info = symph::probe(bytes, format)?; info.bwf = bwf; Ok(info) } }, FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? { aiff::AiffParse::Native(l) => Ok(l.info), - aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format), + aiff::AiffParse::Foreign => symph::probe(bytes, format), }, - _ => symph::probe(bytes, ext_hint, format), + _ => symph::probe(bytes, format), } } @@ -230,7 +231,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB Ok((info, buf)) } wav::WavParse::Foreign { bwf } => { - let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?; + let (mut info, buf) = symph::decode(bytes, format)?; info.bwf = bwf; Ok((info, buf)) } @@ -242,9 +243,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB info.frames = buf.frames() as u64; Ok((info, buf)) } - aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format), + aiff::AiffParse::Foreign => symph::decode(bytes, format), }, - _ => symph::decode(bytes, ext_hint, format), + _ => symph::decode(bytes, format), } } diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs index a1fe894..a998fec 100644 --- a/crates/audio-io/src/pcm.rs +++ b/crates/audio-io/src/pcm.rs @@ -36,6 +36,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option { Some(u32::from_be_bytes(s)) } +/// Big-endian `u64` at `pos`. +pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option { + let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?; + Some(u64::from_be_bytes(s)) +} + /// Four-byte tag at `pos`. pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> { b.get(pos..pos.checked_add(4)?)?.try_into().ok() diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 8fa1916..65de3b6 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -12,9 +12,9 @@ use symphonia::core::codecs::{ }; use symphonia::core::errors::Error as SymError; use symphonia::core::formats::{FormatOptions, FormatReader}; -use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions}; +use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes}; use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; +use symphonia::core::probe::Instantiate; use crate::pcm::MAX_DECODE_SAMPLES; use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat}; @@ -32,15 +32,25 @@ fn guarded(f: impl FnOnce() -> Result) -> Result { catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into()))) } -fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result> { - let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); - let mut hint = Hint::new(); - if let Some(ext) = ext_hint { - hint.with_extension(ext.trim_start_matches('.')); - } +/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it +/// finds before its reader allocates whatever the file claims. +fn open(bytes: &[u8]) -> Result> { + let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; - let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?; - Ok(probed.format) + loop { + match symphonia::default::get_probe().next(&mut mss).map_err(map_err)? { + Instantiate::Format(reader) => { + let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); + if bytes.get(at..at + 4) == Some(b"caff") { + crate::caf::check(bytes, at)?; + } + return reader(mss, &fmt_opts).map_err(map_err); + } + Instantiate::Metadata(tag) => { + tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?; + } + } + } } fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat { @@ -115,8 +125,8 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi } /// Decode the whole first audio track. When `count_only`, samples are counted but not stored. -fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { - let mut reader = open(bytes, ext_hint)?; +fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { + let mut reader = open(bytes)?; let (track_id, mut info) = info_of(reader.as_ref(), detected)?; if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) { let total = n.saturating_mul(u64::from(info.channels.max(1))); @@ -206,18 +216,18 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b Ok((info, AudioBuffer { sample_rate, channels: planar })) } -pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result { +pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { guarded(|| { - let reader = open(bytes, ext_hint)?; + let reader = open(bytes)?; let (_, info) = info_of(reader.as_ref(), detected)?; if info.frames > 0 && info.channels > 0 { return Ok(info); } // Length not in the header (e.g. MP3 without a Xing frame): count by decoding. - run(bytes, ext_hint, detected, true).map(|(i, _)| i) + run(bytes, detected, true).map(|(i, _)| i) }) } -pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { - guarded(|| run(bytes, ext_hint, detected, false)) +pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { + guarded(|| run(bytes, detected, false)) } diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index 1cec656..bfa2606 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -153,6 +153,63 @@ fn absurd_chunk_sizes() { assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0); } +fn caf(chunks: &[(&[u8; 4], i64, Vec)]) -> Vec { + let mut out = b"caff\0\x01\0\0".to_vec(); + for (kind, size, body) in chunks { + out.extend_from_slice(*kind); + out.extend_from_slice(&size.to_be_bytes()); + out.extend_from_slice(body); + } + out +} + +fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec) { + let mut d = 48_000f64.to_be_bytes().to_vec(); + d.extend_from_slice(format); + for v in [0, bytes_per_packet, frames_per_packet, channels, bits] { + d.extend_from_slice(&v.to_be_bytes()); + } + (b"desc", 32, d) +} + +fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec { + [&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat() +} + +#[test] +fn caf_sizes_beyond_the_file_are_errors_not_aborts() { + let aac = || caf_desc(b"aac ", 0, 1024, 2, 0); + let huge = 1i64 << 50; + let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]); + let files = [ + cookie.clone(), + [vec![0; 100], cookie].concat(), + caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]), + caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]), + caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]), + caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]), + ]; + for f in files { + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } + let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]); + assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets"))); +} + +#[test] +fn well_formed_caf_still_reads() { + let pcm: Vec = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect(); + let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat(); + let data = [vec![0; 4], pcm].concat(); + let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]); + let (info, buf) = decode(&lpcm, None).unwrap(); + assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480)); + let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]); + let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]); + assert_eq!(probe(&aac, None).unwrap().frames, 3072); +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; From 37f240ed54060882eefada975d4b0daa81fc6418 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:15:17 +0100 Subject: [PATCH 102/137] Don't crash on MP4 files that claim more data than they hold An M4A whose handler atom claimed a 64-bit size of 2^60 bytes ended the process on import: symphonia reads `hdlr` and the `data`, `name` and `mean` atoms of iTunes metadata into buffers of their declared size, and an allocation that fails aborts. Its sample tables reserve room for whatever entry count they claim (up to 68 GB from a 19 KB file), and every sample is read into a buffer of the size `stsz` gives it. macOS hands out most of that lazily; on Linux, Windows and the 32-bit Windows build such a request fails and aborts. When the probe finds an MP4 file, the atoms symphonia descends into are now walked first, the way symphonia reads them: a header that starts inside its container is read whole, and the walk ends where symphonia stops, after an atom that runs past its container. A file is refused as malformed when one of those atoms, a sample table or a sample claims more bytes than the file holds. Atoms symphonia skips, and an `mdat` cut short, are left alone, so truncated recordings still read. --- crates/audio-io/src/lib.rs | 1 + crates/audio-io/src/mp4.rs | 106 +++++++++++++++++++++++++++++ crates/audio-io/src/symph.rs | 6 +- crates/audio-io/tests/malformed.rs | 61 +++++++++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 crates/audio-io/src/mp4.rs diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 86030f0..e94ba3d 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -16,6 +16,7 @@ mod aiff; mod buffer; mod caf; mod flac; +mod mp4; mod pcm; pub mod peaks; mod symph; diff --git a/crates/audio-io/src/mp4.rs b/crates/audio-io/src/mp4.rs new file mode 100644 index 0000000..06e68dd --- /dev/null +++ b/crates/audio-io/src/mp4.rs @@ -0,0 +1,106 @@ +//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample +//! table or a sample claims, and a failed allocation aborts the process. + +use crate::pcm::{be_u32, be_u64}; +use crate::{AudioError, Result}; + +#[derive(Clone, Copy)] +enum Level { + Top, + Moov, + Trak, + Mdia, + Minf, + Stbl, + Udta, + Meta, + Ilst, + Tag, +} + +/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a +/// sample table or a sample claims more bytes than the file holds. +pub(crate) fn check(b: &[u8], at: usize) -> Result<()> { + atoms(b, at, b.len(), Level::Top).map(|_| ()) +} + +fn malformed(what: &str) -> AudioError { + AudioError::Malformed(format!("MP4 {what} is larger than the file")) +} + +/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where +/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`. +fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result { + // A header that starts inside the container is read whole, even past its end. + while pos < end { + let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) }; + let head = if size == 1 { 16 } else { 8 }; + // Size 0: no data, and the next atom starts at the container's end. + let len = match size { + 0 => head, + 1 => be_u64(b, pos + 8).unwrap_or(0), + n => u64::from(n), + }; + if len < head { + return Ok(false); + } + let body = pos + head as usize; + let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n)); + let next = if size == 0 { Some(end) } else { data_end }; + let inner = data_end.map_or(b.len(), |n| n.min(b.len())); + let more = match (level, kind) { + (Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?, + (Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?, + (Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?, + (Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?, + (Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?, + (Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?, + (Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?, + (Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?, + (Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?, + (Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?, + (Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?, + (Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?, + (Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?, + (Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => { + return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind)))); + } + _ => true, + }; + match next { + Some(n) if more && n <= end => pos = n, + _ => return Ok(false), + } + } + Ok(true) +} + +/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for +/// all of them first. False if they run past the table's atom (`end`), where symphonia stops. +fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result { + let Some(n) = be_u32(b, count) else { return Ok(false) }; + let (first, bytes) = (count + 4, u64::from(n) * entry); + if bytes > b.len().saturating_sub(first) as u64 { + return Err(malformed("sample table")); + } + Ok(first as u64 + bytes <= end as u64) +} + +/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size. +/// Sizes past the atom are never used, as symphonia stops before reading a packet. +fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result { + let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) }; + let file = b.len() as u64; + if u64::from(size) > file { + return Err(malformed("sample")); + } + if size != 0 { + return Ok(true); + } + let fits = table(b, body + 8, 4, end)?; + let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>(); + if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) { + return Err(malformed("sample")); + } + Ok(fits) +} diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 65de3b6..6469df5 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -41,8 +41,10 @@ fn open(bytes: &[u8]) -> Result> { match symphonia::default::get_probe().next(&mut mss).map_err(map_err)? { Instantiate::Format(reader) => { let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); - if bytes.get(at..at + 4) == Some(b"caff") { - crate::caf::check(bytes, at)?; + match bytes.get(at..at + 4) { + Some(b"caff") => crate::caf::check(bytes, at)?, + Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, + _ => {} } return reader(mss, &fmt_opts).map_err(map_err); } diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index bfa2606..55a18cd 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -210,6 +210,67 @@ fn well_formed_caf_still_reads() { assert_eq!(probe(&aac, None).unwrap().frames, 3072); } +fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { + [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat() +} + +/// An MP4 of `frames` frames of 16-bit mono PCM; `swap` may replace any version-0 atom. +fn pcm_mp4(frames: u32, swap: impl Fn(&[u8; 4], Vec) -> Vec) -> Vec { + let full = |kind: &[u8; 4], fields: &[u32]| { + let body: Vec = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect(); + swap(kind, atom(kind, &body)) + }; + let entry = atom(b"sowt", &[&[0u8; 6][..], &[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0], &(48_000u32 << 16).to_be_bytes()].concat()); + 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", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), 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, frames, 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, frames, 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; + let pcm: Vec = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect(); + [ftyp, moov(offset), atom(b"mdat", &pcm)].concat() +} + +#[test] +fn mp4_sizes_beyond_the_file_are_errors_not_aborts() { + let (_, buf) = decode(&pcm_mp4(480, |_, a| a), None).unwrap(); + assert_eq!(buf.frames(), 480); + let huge = |kind: &[u8; 4], a: Vec| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat(); + // The first field after version and flags: stts's entry count, stsz's sample size. + let first_field_max = |mut a: Vec| { + a[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + a + }; + let sizes: Vec = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect(); + let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat()); + // A header that starts inside its container is read whole, past the container's end. + let mdia = atom(b"mdia", &1u32.to_be_bytes()); + let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat(); + let files = [ + pcm_mp4(480, |k, a| if k == b"hdlr" { huge(k, a) } else { a }), + pcm_mp4(480, |k, a| if k == b"stts" { first_field_max(a) } else { a }), + pcm_mp4(480, |k, a| if k == b"stsz" { first_field_max(a) } else { a }), + pcm_mp4(480, |k, a| if k == b"stsz" { per_sample.clone() } else { a }), + [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(), + ]; + for f in files { + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } +} + +#[test] +fn atoms_running_past_their_parent_are_walked_once() { + // Each moov holds a trak that runs to the end of the file; symphonia stops after the first. + let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat()); + let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat(); + assert!(decode(&f, None).is_err()); +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; From d64d5f491ec8dafe16a2c321396aec3e022c6892 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:17:41 +0100 Subject: [PATCH 103/137] Refuse files whose packets claim millions of frames A 59 KB ALAC file in an M4A made the app use 25 GB of memory before it gave up: symphonia's ALAC decoder sizes its buffers from the frame length in the file's codec settings, and nothing bounds it. The PCM and ADPCM decoders likewise allocate the frames per packet the container declares, which an MP4 version-2 LPCM entry takes from the file. Before a decoder is made, the frames a packet may hold, and an ALAC frame length, must be at most 2^20 (about 22 seconds at 48 kHz; Apple's ALAC encoder uses 4096), or the file is refused as malformed. --- crates/audio-io/src/symph.rs | 10 +++++++- crates/audio-io/tests/malformed.rs | 38 +++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 6469df5..875f05f 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -16,9 +16,13 @@ use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Instantiate; -use crate::pcm::MAX_DECODE_SAMPLES; +use crate::pcm::{MAX_DECODE_SAMPLES, be_u32}; use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat}; +/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC +/// takes its frame length from the file (encoders use 4096). +const MAX_PACKET_FRAMES: u64 = 1 << 20; + fn map_err(e: SymError) -> AudioError { match e { SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()), @@ -142,6 +146,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf .find(|t| t.id == track_id) .map(|t| t.codec_params.clone()) .ok_or_else(|| AudioError::Malformed("track vanished".into()))?; + let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0)); + if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) { + return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); + } let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?; let mut planar: Vec> = Vec::new(); diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index 55a18cd..158c485 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -214,15 +214,20 @@ fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat() } -/// An MP4 of `frames` frames of 16-bit mono PCM; `swap` may replace any version-0 atom. -fn pcm_mp4(frames: u32, swap: impl Fn(&[u8; 4], Vec) -> Vec) -> Vec { +/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms. +fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec { + let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat()) +} + +/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom. +fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec) -> Vec) -> Vec { let full = |kind: &[u8; 4], fields: &[u32]| { let body: Vec = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect(); swap(kind, atom(kind, &body)) }; - let entry = atom(b"sowt", &[&[0u8; 6][..], &[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0], &(48_000u32 << 16).to_be_bytes()].concat()); let moov = |offset: u32| { - let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat()); + let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat()); let stbl = [stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])]; let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat(); @@ -238,7 +243,8 @@ fn pcm_mp4(frames: u32, swap: impl Fn(&[u8; 4], Vec) -> Vec) -> Vec #[test] fn mp4_sizes_beyond_the_file_are_errors_not_aborts() { - let (_, buf) = decode(&pcm_mp4(480, |_, a| a), None).unwrap(); + let pcm = sample_entry(b"sowt", 0, &[], &[]); + let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap(); assert_eq!(buf.frames(), 480); let huge = |kind: &[u8; 4], a: Vec| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat(); // The first field after version and flags: stts's entry count, stsz's sample size. @@ -252,10 +258,10 @@ fn mp4_sizes_beyond_the_file_are_errors_not_aborts() { let mdia = atom(b"mdia", &1u32.to_be_bytes()); let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat(); let files = [ - pcm_mp4(480, |k, a| if k == b"hdlr" { huge(k, a) } else { a }), - pcm_mp4(480, |k, a| if k == b"stts" { first_field_max(a) } else { a }), - pcm_mp4(480, |k, a| if k == b"stsz" { first_field_max(a) } else { a }), - pcm_mp4(480, |k, a| if k == b"stsz" { per_sample.clone() } else { a }), + mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }), [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(), ]; for f in files { @@ -271,6 +277,20 @@ fn atoms_running_past_their_parent_are_walked_once() { assert!(decode(&f, None).is_err()); } +#[test] +fn packets_longer_than_any_codec_uses_are_refused() { + let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::>(); + let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat(); + let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]); + let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat(); + let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames))); + let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap(); + assert_eq!(buf.frames(), 480); + for entry in [lpcm(1 << 21), alac(1 << 21)] { + assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets"))); + } +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; From a4213cf90f62c8375c8b58c9f7005ba2f4b97917 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:22:42 +0100 Subject: [PATCH 104/137] Check FLAC picture and comment lengths before symphonia reads them symphonia reads a FLAC file's picture and Vorbis comment blocks field by field, allocating each field's declared length before it finds out the block is shorter: a picture claiming 0xFFFFFFFF bytes of image data asked for 4 GB. macOS gives that lazily, but a 32-bit build or a system without that much memory to commit aborts. When the probe finds a FLAC stream, the lengths of its picture fields and comments are now checked against their metadata block first, and a file whose fields don't fit is refused as malformed. --- crates/audio-io/src/flac.rs | 52 ++++++++++++++++++++++++++++-- crates/audio-io/src/symph.rs | 1 + crates/audio-io/tests/malformed.rs | 15 +++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 2f5550b..88b333e 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -1,8 +1,8 @@ //! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per //! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with -//! partitioned Rice-coded residuals. +//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`]. -use crate::pcm::{Quantizer, validate_buffer}; +use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer}; use crate::{AudioBuffer, AudioError, BitDepth, Result}; const BLOCK_SIZE: usize = 4096; @@ -354,6 +354,54 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result Ok(out) } +/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its +/// metadata block holds: symphonia allocates each field's length before reading it. +pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> { + let mut pos = at + 4; + while let Some(head) = be_u32(b, pos) { + let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize); + let end = (body + len).min(b.len()); + let overruns = match (head >> 24) & 0x7f { + 4 => comments_overrun(b, body, end), + // Picture: type, MIME type, description, four size fields, data. + 6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]), + _ => false, + }; + if overruns { + return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into())); + } + if head >> 31 == 1 { + break; + } + pos = body + len; + } + Ok(()) +} + +/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds. +/// The vendor string is skipped unread. +fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool { + let Some(vendor) = le_u32(b, body) else { return false }; + 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)) +} + +/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are +/// the bytes skipped before each length, which `read` reads. +fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option, gaps: impl IntoIterator) -> bool { + for gap in gaps { + at = at.saturating_add(gap); + let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false }; + at += 4; + if n as usize > end - at { + return true; + } + at += n as usize; + } + false +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 875f05f..e12ff31 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -48,6 +48,7 @@ fn open(bytes: &[u8]) -> Result> { match bytes.get(at..at + 4) { Some(b"caff") => crate::caf::check(bytes, at)?, Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, + Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?, _ => {} } return reader(mss, &fmt_opts).map_err(map_err); diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index 158c485..13ca7e7 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -291,6 +291,21 @@ fn packets_longer_than_any_codec_uses_are_refused() { } } +#[test] +fn flac_metadata_fields_beyond_their_block_are_refused() { + let with_block = |kind: u8, body: &[u8]| { + let f = small(FileFormat::Flac, BitDepth::Int16, 1); + let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes(); + [&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat() + }; + 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)] { + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block"))); + } +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; From 777c08ef1aabc566c183351f1be0ea04e4e3b3e3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:23:54 +0100 Subject: [PATCH 105/137] Skip ID3 tags instead of having symphonia read them An MP3 whose ID3v2 tag had a frame claiming 0x7FFFFFFF bytes made symphonia's probe allocate 2 GB to read that frame (4 GB at 0xFFFFFFFF). It reads every tag it finds ahead of the audio, though SoundCraft never uses the result. macOS gives such memory lazily; a 32-bit build or a system that can't commit it aborts. The probe loop now skips an ID3v2 tag by the size in its header and carries on looking for the audio, so a tag can't cost more than the bytes it occupies, and a damaged tag no longer stops an MP3 from opening. --- crates/audio-io/src/symph.rs | 17 ++++++++++++----- crates/audio-io/tests/malformed.rs | 8 ++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index e12ff31..5c40ecd 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -42,10 +42,12 @@ fn open(bytes: &[u8]) -> Result> { let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; loop { - match symphonia::default::get_probe().next(&mut mss).map_err(map_err)? { - Instantiate::Format(reader) => { - let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); - match bytes.get(at..at + 4) { + let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?; + let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); + let head = bytes.get(at..).unwrap_or_default(); + match (found, head.get(..10)) { + (Instantiate::Format(reader), _) => { + match head.get(..4) { Some(b"caff") => crate::caf::check(bytes, at)?, Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?, @@ -53,7 +55,12 @@ fn open(bytes: &[u8]) -> Result> { } return reader(mss, &fmt_opts).map_err(map_err); } - Instantiate::Metadata(tag) => { + // Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it. + (Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => { + let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f)); + mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?; + } + (Instantiate::Metadata(tag), _) => { tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?; } } diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index 13ca7e7..f28e535 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -306,6 +306,14 @@ fn flac_metadata_fields_beyond_their_block_are_refused() { } } +#[test] +fn an_id3_tag_before_the_audio_is_skipped_unread() { + let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat(); + let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat(); + let flac = small(FileFormat::Flac, BitDepth::Int16, 1); + assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300); +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; From de5f2e0fb33e2628aa3fe58f6e13d3de8f129321 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:25:16 +0100 Subject: [PATCH 106/137] Check INFO entry sizes in WAV files that symphonia reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAV files that aren't plain PCM (A-law, µ-law, ADPCM) go to symphonia, which reads every entry of an INFO list into a buffer of its declared size, bounded only by the list's and file's declared lengths. A file claiming a 4 GB RIFF and a 2 GB title asked for 2 GB before failing. macOS gives that lazily; a 32-bit build or a system that can't commit it aborts. Before symphonia reads such a WAV, its INFO entries are checked against the bytes the file holds, and one that claims more is refused as malformed. Chunks are walked where symphonia reads them, not where their lengths say: A-law, µ-law and ADPCM `fmt` chunks are read by their own extra size, extensible ones by 40 bytes, and a list is padded twice. --- crates/audio-io/src/symph.rs | 1 + crates/audio-io/src/wav.rs | 51 ++++++++++++++++++++++++++++++ crates/audio-io/tests/malformed.rs | 27 ++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 5c40ecd..6169111 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -51,6 +51,7 @@ fn open(bytes: &[u8]) -> Result> { Some(b"caff") => crate::caf::check(bytes, at)?, Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?, + Some(b"RIFF") => crate::wav::check_info(bytes, at)?, _ => {} } return reader(mss, &fmt_opts).map_err(map_err); diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index df56010..d6ce7cd 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -83,6 +83,57 @@ fn parse_bext(c: &[u8]) -> Option { }) } +/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry +/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are +/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length. +pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> { + if tag(b, at + 8) != Some(*b"WAVE") { + return Ok(()); + } + let mut pos = at + 12; + while let Some(id) = tag(b, pos) { + let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break }; + let (body, pad) = (pos + 8, len & 1); + let used = match &id { + b"data" => break, + b"fmt " => fmt_used(b, body, len), + b"LIST" if len < 4 => break, + b"LIST" => { + let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 }; + // The list pads itself, then the chunk is padded again. + 4 + entries.max(len - 4) + pad + } + _ => len, + }; + pos = body.saturating_add(used).saturating_add(pad); + } + Ok(()) +} + +/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size. +fn fmt_used(b: &[u8], body: usize, len: usize) -> usize { + match le_u16(b, body) { + Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)), + Some(0xfffe) => 40, + _ => len, + } +} + +/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of +/// them, with the pad it reads after an odd entry. +fn info_entries(b: &[u8], start: usize, len: usize) -> Result { + let mut used = 0; + while used + 8 <= len { + let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break }; + if n > b.len().saturating_sub(start + used + 8) { + return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into())); + } + used += 8 + n; + used += used & 1; + } + Ok(used) +} + /// True when the bytes start like a RIFF/RF64/BW64 WAVE file. pub(crate) fn is_wav(b: &[u8]) -> bool { matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE") diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index f28e535..49062d7 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -314,6 +314,33 @@ fn an_id3_tag_before_the_audio_is_skipped_unread() { assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300); } +#[test] +fn wav_info_entries_beyond_the_file_are_refused() { + let alaw = |len: u32| { + let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat(); + riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]) + }; + assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800); + let mut f = alaw(0x7fff_fff0); + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + let list = f.windows(4).position(|w| w == b"LIST").unwrap(); + f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); +} + +#[test] +fn wav_info_entries_are_found_where_symphonia_reads_them() { + let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); + let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat()); + // A-law's extra bytes are read past its chunk; an odd list is padded twice. + let files = + [riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()]; + for mut f in files { + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; From 6d03eab566151490abc083c932086ffd91553e94 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:06:13 +0100 Subject: [PATCH 107/137] Make the fader scale readable in the Light theme The fader's dB numbers and ticks were painted in fixed greys (150 and 110), which are faint on the Light theme's strips: the numbers had a 2.5:1 contrast. They now use the theme's text_dim, at 70% for the ticks. Light reads at about 5:1 and Dark looks the same as before. Each tick now starts 2pt after its number instead of running through the digits, and is left out when the strip is too narrow for it. --- crates/ui-egui/src/widgets.rs | 6 +- crates/ui-egui/tests/mix_strip_layout.rs | 77 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/mix_strip_layout.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1eca07c..cb6bad1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -204,8 +204,10 @@ pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { // Scale marks. for mark in [12.0f32, 6.0, 0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0] { let y = slot.max.y - slot.height() * soundcraft_model::fader_db_to_pos(mark); - ui.painter().line_segment([pos2(r.min.x + 2.0, y), pos2(slot.min.x - 3.0, y)], Stroke::new(1.0, Color32::from_rgb(110, 110, 110))); - ui.painter().text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), Color32::from_rgb(150, 150, 150)); + let x = ui.painter().text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), t.text_dim).max.x + 2.0; + if x < slot.min.x - 3.0 { + ui.painter().hline(x..=slot.min.x - 3.0, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7))); + } } let pos = soundcraft_model::fader_db_to_pos(db); let y = slot.max.y - slot.height() * pos; diff --git a/crates/ui-egui/tests/mix_strip_layout.rs b/crates/ui-egui/tests/mix_strip_layout.rs new file mode 100644 index 0000000..46ebd10 --- /dev/null +++ b/crates/ui-egui/tests/mix_strip_layout.rs @@ -0,0 +1,77 @@ +use egui::{Color32, FullOutput, Shape, epaint::TextShape, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{ + Services, SoundApp, + theme::{ThemeMode, Tokens}, +}; + +const SCALE: [&str; 10] = ["12", "6", "0", "5", "10", "15", "20", "30", "40", "60"]; + +// The palette is process-wide, so tests in this file take turns. +static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn mix(mode: ThemeMode) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.session_mut().edit.selected_tracks.clear(); + // Fader caps at the bottom, clear of the scale. + for track in &mut app.engine.session_mut().tracks { + track.mixer.volume_db = -144.0; + } + app.run("window.mix", json!({})).unwrap(); + app.run("ui.theme", json!({"mode": mode.id()})).unwrap(); + let mut harness = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + harness.step(); + } + harness +} + +fn texts<'a>(output: &'a FullOutput, keep: impl Fn(&TextShape) -> bool + 'a) -> impl Iterator + 'a { + output.shapes.iter().filter_map(move |shape| match &shape.shape { + Shape::Text(text) if keep(text) => Some(text), + _ => None, + }) +} + +fn contrast(a: Color32, b: Color32) -> f64 { + let luminance = |color: Color32| { + let linear = |channel: u8| { + let s = f64::from(channel) / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) + }; + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +#[test] +fn fader_scale_numbers_are_readable_and_clear_of_their_ticks() { + let _palette = PALETTE.lock().unwrap_or_else(|e| e.into_inner()); + for (mode, palette) in [(ThemeMode::Dark, Tokens::DARK), (ThemeMode::Light, Tokens::LIGHT)] { + let harness = mix(mode); + let output = harness.output(); + let numbers: Vec<_> = + texts(output, |t| t.galley.job.sections[0].format.font_id.size == 8.0 && SCALE.contains(&t.galley.job.text.as_str())).collect(); + assert!(numbers.len() >= SCALE.len() * 2, "{mode:?}: found {} scale numbers", numbers.len()); + for number in numbers { + let color = number.override_text_color.unwrap_or(number.galley.job.sections[0].format.color); + let ratio = contrast(color, palette.strip_bg); + assert!(ratio >= 4.5, "{mode:?}: scale number {:?} contrast {ratio:.2}:1", number.galley.job.text); + let glyphs = number.visual_bounding_rect(); + for shape in &output.shapes { + if let Shape::LineSegment { points: [a, b], .. } = &shape.shape { + let crosses = a.y == b.y && glyphs.y_range().contains(a.y) && a.x.min(b.x) < glyphs.max.x && a.x.max(b.x) > glyphs.min.x; + assert!(!crosses, "{mode:?}: a tick runs through {:?}", number.galley.job.text); + } + } + } + } +} From 66caedd923914b15f7f0b1129eb11817ba51aa67 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:06:19 +0100 Subject: [PATCH 108/137] Line up stereo Mix strips with mono ones Stereo strips draw two smaller pan knobs (32pt or less instead of 34pt), and the rows under them were placed from the knob size. So the pan readouts, the I/record and S/M keys and everything below them sat 2pt higher than on mono strips. The rows now use the mono positions on every strip. --- crates/ui-egui/src/mix_window.rs | 6 +++--- crates/ui-egui/tests/mix_strip_layout.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..7c66d8c 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -292,7 +292,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let ks = if n == 2 { (inner_w / 2.0 - 6.0).min(32.0) } else { 34.0 }; for i in 0..n { let cx = if n == 2 { x0 + inner_w * (0.25 + 0.5 * i as f32) } else { x0 + inner_w * 0.5 }; - let kr = Rect::from_center_size(pos2(cx, y + ks * 0.5), vec2(ks, ks)); + let kr = Rect::from_center_size(pos2(cx, y + 17.0), vec2(ks, ks)); let mut c = ui.new_child(egui::UiBuilder::new().max_rect(kr)); let mut v = track.mixer.pan.get(i).copied().unwrap_or(0.0); let before = v; @@ -301,14 +301,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); } let _ = resp; - let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0)); + let pr = Rect::from_center_size(pos2(cx, y + 42.0), vec2(ks + 8.0, 13.0)); ui.painter().rect_filled(pr, 1.0, t.counter_bg); let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan); if let Some(v) = entered { let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); } } - y += ks + 20.0; + y += 54.0; } // Rec / Input / Solo / Mute. let bw = (inner_w - 6.0) / 2.0; diff --git a/crates/ui-egui/tests/mix_strip_layout.rs b/crates/ui-egui/tests/mix_strip_layout.rs index 46ebd10..aa6bad1 100644 --- a/crates/ui-egui/tests/mix_strip_layout.rs +++ b/crates/ui-egui/tests/mix_strip_layout.rs @@ -75,3 +75,13 @@ fn fader_scale_numbers_are_readable_and_clear_of_their_ticks() { } } } + +#[test] +fn stereo_strips_line_up_with_mono_strips() { + let _palette = PALETTE.lock().unwrap_or_else(|e| e.into_inner()); + let harness = mix(ThemeMode::Dark); + let solo_keys: Vec = texts(harness.output(), |t| t.galley.job.text == "S").map(|t| t.visual_bounding_rect().center().y).collect(); + assert!(solo_keys.len() >= 6, "found {} solo keys", solo_keys.len()); + let (top, bottom) = solo_keys.iter().fold((f32::MAX, f32::MIN), |(lo, hi), &y| (lo.min(y), hi.max(y))); + assert!(bottom - top < 0.5, "solo keys span {top}..{bottom}"); +} From e53b772aea6f28ed81e7d62a446b161fa3743fdd Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:11:33 +0100 Subject: [PATCH 109/137] Open, add and change plugins from the Edit window Pro Tools lets you work with a track's plugins from the Edit window. Here its Inserts and Sends columns (View > Edit Window Views) only printed the names. - The Inserts A-E/F-J and Sends A-E/F-J columns now use the Mix window's slots, so they work as they do there: click a plugin to open its window, click an empty slot to insert one, right-click to bypass, remove or replace it, and route or set the level of a send. - A new Instruments column shows an instrument track's instrument. - The columns run in Pro Tools' order: Instruments, Inserts, Sends, I/O, Comments. Each gets a title, boxed in its own row under the rulers, and the ruler names now end at the track headers. - A Views button at the top left of the rulers shows and hides the columns, like Pro Tools' Edit Window View selector. Its menu stays open until a click elsewhere, so several columns take one trip. - In both windows, insert slots show a power light, green while the plugin runs and dark while it's bypassed, with the name left-aligned and dimmed when bypassed. A plugin whose code is missing shows its id instead of looking like an empty slot. - Cmd+click (Ctrl+click on Windows and Linux) bypasses an insert, as in Pro Tools. - Each column draws in a Ui of its own, so its widget ids can't clash with the track header's. --- crates/ui-egui/src/edit_window.rs | 113 +++++++++++++++----- crates/ui-egui/src/mix_window.rs | 34 ++++-- crates/ui-egui/tests/edit_window_plugins.rs | 110 +++++++++++++++++++ 3 files changed, 218 insertions(+), 39 deletions(-) create mode 100644 crates/ui-egui/tests/edit_window_plugins.rs diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..7d32f5a 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -10,14 +10,26 @@ use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_p pub const HEADER_W: f32 = 236.0; pub const COLUMN_W: f32 = 112.0; +/// The row under the rulers that holds the column titles, when any column is shown. +const COLUMN_TITLES_H: f32 = 16.0; -/// Optional Edit-window columns (View › Edit Window Views) that are switched on. -pub fn edit_columns(s: &Session) -> Vec<&'static str> { +/// An optional Edit-window column: view id, menu label and title. +type Column = (&'static str, &'static str, &'static str); + +const COLUMNS: [Column; 7] = [ + ("instruments", "Instruments", "INSTRUMENT"), + ("inserts_ae", "Inserts A-E", "INSERTS A-E"), + ("inserts_fj", "Inserts F-J", "INSERTS F-J"), + ("sends_ae", "Sends A-E", "SENDS A-E"), + ("sends_fj", "Sends F-J", "SENDS F-J"), + ("io", "I/O", "I/O"), + ("comments", "Comments", "COMMENTS"), +]; + +/// Optional Edit-window columns (View › Edit Window Views) that are switched on, left to right. +pub fn edit_columns(s: &Session) -> Vec { let all = s.edit.flag("edit_view.all"); - ["io", "inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "comments"] - .into_iter() - .filter(|c| all || s.edit.flag(&format!("edit_view.{c}"))) - .collect() + COLUMNS.into_iter().filter(|c| all || s.edit.flag(&format!("edit_view.{}", c.0))).collect() } pub fn header_width(s: &Session) -> f32 { @@ -200,7 +212,9 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { { rulers.insert(i + 1, "tempo_editor".into()); } - let rulers_h = rulers.iter().map(|r| ruler_height(r)).sum::() + 6.0; + let cols = edit_columns(app.engine.session()); + let titles_h = if cols.is_empty() { 0.0 } else { COLUMN_TITLES_H }; + let rulers_h = rulers.iter().map(|r| ruler_height(r)).sum::() + 6.0 + titles_h; let hscroll_h = 14.0; let hw = header_width(app.engine.session()); let header = Rect::from_min_max(full.min, pos2(full.min.x + hw, full.max.y - hscroll_h)); @@ -210,6 +224,8 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(full, 0.0, t.window_bg); scroll_and_zoom(app, ui, full, tl); draw_rulers(app, ui, rulers_rect, tl, &rulers); + views_button(app, ui, rulers_rect); + column_titles(ui, rulers_rect, &cols); // Tracks. let mut y = tl.min.y - app.edit_layout.scroll_y; app.edit_layout.rows.clear(); @@ -425,7 +441,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ for (i, id) in rulers.iter().enumerate() { let h = ruler_height(id); let row = Rect::from_min_max(pos2(tl.min.x, y0), pos2(tl.max.x, y0 + h)); - let lab = Rect::from_min_max(pos2(area.min.x, y0), pos2(tl.min.x, y0 + h)); + let lab = Rect::from_min_max(pos2(area.min.x, y0), pos2(area.min.x + HEADER_W, y0 + h)); y0 += h; let painter = ui.painter().with_clip_rect(row); ui.painter().text(pos2(lab.max.x - 12.0, lab.center().y), Align2::RIGHT_CENTER, ruler_label(id), bold(11.0), t.ruler_text); @@ -673,6 +689,51 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: } } +/// Captions over the header columns, boxed in their own row under the rulers. +fn column_titles(ui: &Ui, rulers: Rect, cols: &[Column]) { + if cols.is_empty() { + return; + } + let t = Tokens::current(); + let line = Stroke::new(1.0, t.border); + let mut x = rulers.min.x + HEADER_W; + let end = x + cols.len() as f32 * COLUMN_W; + let row = Rect::from_min_max(pos2(x, rulers.max.y - COLUMN_TITLES_H - 3.0), pos2(end, rulers.max.y)); + ui.painter().rect_filled(row, 0.0, t.panel_bg2); + ui.painter().hline(rulers.min.x..=end, row.min.y + 0.5, line); + ui.painter().hline(rulers.min.x..=end, row.max.y - 0.5, line); + ui.painter().vline(end - 0.5, row.y_range(), line); + for &(_, _, title) in cols { + ui.painter().vline(x + 0.5, row.y_range(), line); + ui.painter().text(pos2(x + COLUMN_W / 2.0, row.center().y), Align2::CENTER_CENTER, title, bold(10.0), t.section_label); + x += COLUMN_W; + } +} + +/// The Edit window view selector: which columns sit beside the track headers. +fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) { + let r = Rect::from_min_size(rulers.min + vec2(6.0, 5.0), vec2(50.0, 18.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); + let resp = selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, child.is_enabled(), "Views")); + let resp = resp.on_hover_text("Columns beside the track headers: inserts, sends, I/O…"); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let shown = edit_columns(app.engine.session()); + for (id, label, _) in COLUMNS { + if ui.add(egui::Button::new(label).selected(shown.iter().any(|c| c.0 == id))).clicked() { + let _ = app.run(&format!("view.edit_{id}"), json!({})); + } + } + ui.separator(); + if ui.button("All").clicked() { + let _ = app.run("view.edit_all", json!({})); + } + if ui.button("Minimal").clicked() { + let _ = app.run("view.edit_minimal", json!({})); + } + }); +} + // ---- track rows ------------------------------------------------------------------------------ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) { @@ -684,7 +745,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) let cols = edit_columns(app.engine.session()); for (i, c) in cols.iter().enumerate() { let cr = Rect::from_min_size(pos2(head.max.x + i as f32 * COLUMN_W, row.min.y), vec2(COLUMN_W, row.height())); - header_column(app, ui, &track, c, cr); + header_column(app, ui, &track, c.0, cr); } let main_h = track.height.points(); let lane = lane_rect(&track, row, tl); @@ -978,7 +1039,12 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } } -/// One optional Edit-window column for a track (I/O, inserts, sends, comments). +/// A header column's own Ui, so its slots' ids can't collide with the track header's. +fn column_ui(ui: &mut Ui, col: &str, r: Rect) -> Ui { + ui.new_child(egui::UiBuilder::new().id_salt(("edit_column", col.to_string())).max_rect(r)) +} + +/// One optional Edit-window column for a track (instrument, inserts, sends, I/O, comments). fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { let t = Tokens::current(); ui.painter().rect_filled(r, 0.0, t.panel_bg); @@ -1005,32 +1071,23 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R text(ui, item(1), &format!("out: {}", route(&track.mixer.output)), t.text); } } + "instruments" => { + if track.kind == TrackKind::Instrument { + crate::mix_window::instrument_slot(app, &mut column_ui(ui, col, r), track, item(0)); + } + } "inserts_ae" | "inserts_fj" => { let off = if col == "inserts_ae" { 0 } else { 5 }; + let mut ui = column_ui(ui, col, r); for i in 0..5.min(rows) { - let name = track - .mixer - .inserts - .get(off + i) - .cloned() - .flatten() - .and_then(|x| soundcraft_dsp::plugin_info(&x.plugin).map(|p| p.name)) - .unwrap_or(""); - text(ui, item(i), name, t.text); + crate::mix_window::insert_slot(app, &mut ui, track, off + i, item(i)); } } "sends_ae" | "sends_fj" => { let off = if col == "sends_ae" { 0 } else { 5 }; + let mut ui = column_ui(ui, col, r); for i in 0..5.min(rows) { - let s = track - .mixer - .sends - .get(off + i) - .cloned() - .flatten() - .map(|x| format!("{} {:.0}", route(&x.target), x.level_db.max(-99.0))) - .unwrap_or_default(); - text(ui, item(i), &s, t.text); + crate::mix_window::send_slot(app, &mut ui, track, off + i, item(i)); } } _ => { diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..b37e58e 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -417,7 +417,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh /// 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. -fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { +pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { let t = Tokens::current(); let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT); let ins = track.instrument.as_ref(); @@ -441,7 +441,7 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { popup.show(|ui| instrument_menu(app, ui, id)); } -fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { +pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; let ins = track.mixer.inserts.get(slot).cloned().flatten(); @@ -458,16 +458,28 @@ fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: R Stroke::new(1.0, t.slot_border), StrokeKind::Inside, ); - let label = ins.as_ref().and_then(|i| plugin_info(&i.plugin)).map_or("", |p| p.short_name); - if label.is_empty() { - ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); - } else { - ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, label, regular(10.5), t.text); + match &ins { + None => { + ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); + } + Some(i) => { + let led = if i.bypass { t.text_dim.gamma_multiply(0.45) } else { t.meter_green }; + ui.painter().circle_filled(pos2(r.min.x + 7.0, r.center().y), 2.5, led); + let name = plugin_info(&i.plugin).map_or(i.plugin.as_str(), |p| p.short_name); + let color = if i.bypass { t.text_dim } else { t.text }; + ui.painter().with_clip_rect(r.shrink(1.0)).text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, regular(10.5), color); + } } - if resp.clicked() && ins.is_some() && !app.ui.plugin_windows.contains(&(id, slot)) { - app.ui.plugin_windows.push((id, slot)); + if resp.clicked() && ins.is_some() { + if ui.input(|i| i.modifiers.command) { + let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot})); + } else if !app.ui.plugin_windows.contains(&(id, slot)) { + app.ui.plugin_windows.push((id, slot)); + } } - let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text("Click: open plugin · right-click: change") }; + let cmd = if ui.ctx().os().is_mac() { "⌘" } else { "Ctrl+" }; + let tip = format!("Click: open plugin · {cmd}click: bypass · right-click: change"); + let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text(tip) }; let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) }; popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some())); } @@ -616,7 +628,7 @@ pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> { .or_else(|| soundcraft_au_host::plugin_info(id)) } -fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { +pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; let snd = track.mixer.sends.get(slot).cloned().flatten(); diff --git a/crates/ui-egui/tests/edit_window_plugins.rs b/crates/ui-egui/tests/edit_window_plugins.rs new file mode 100644 index 0000000..14b4e15 --- /dev/null +++ b/crates/ui-egui/tests/edit_window_plugins.rs @@ -0,0 +1,110 @@ +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_model::{Track, TrackId}; +use soundcraft_ui_egui::edit_window::HEADER_W; +use soundcraft_ui_egui::{Services, SoundApp}; + +const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + +fn step(h: &mut Harness<'_, SoundApp>) { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, button: PointerButton, modifiers: Modifiers) { + h.state_mut().synthetic.extend([ + Event::ModifiersChanged(modifiers), + Event::PointerMoved(pos), + Event::PointerButton { pos, button, pressed: true, modifiers }, + Event::PointerButton { pos, button, pressed: false, modifiers }, + Event::ModifiersChanged(Modifiers::NONE), + ]); + for _ in 0..10 { + step(h); + } +} + +fn click(h: &mut Harness<'_, SoundApp>, pos: Pos2) { + press(h, pos, PointerButton::Primary, Modifiers::NONE); +} + +fn kick(h: &Harness<'_, SoundApp>) -> Track { + h.state().engine.session().track_by_name("Kick").cloned().unwrap() +} + +fn insert(h: &Harness<'_, SoundApp>, slot: usize) -> Option { + kick(h).mixer.inserts.get(slot).cloned().flatten().map(|i| i.plugin) +} + +fn bypassed(h: &Harness<'_, SoundApp>, slot: usize) -> bool { + kick(h).mixer.inserts.get(slot).cloned().flatten().is_some_and(|i| i.bypass) +} + +fn harness(view: &str) -> (Harness<'static, SoundApp>, TrackId, [f32; 4]) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run(view, json!({})).unwrap(); + app.run("track.height", json!({"tracks": ["Kick"], "height": "medium"})).unwrap(); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + step(&mut h); + } + let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == kick.0).map(|(_, r)| *r).unwrap(); + (h, kick, row) +} + +#[test] +fn edit_window_insert_slots_open_change_and_add_plugins() { + let (mut h, kick, row) = harness("view.edit_inserts_ae"); + let slot = |i: usize| pos2(row[0] + HEADER_W + 56.0, row[1] + 9.5 + i as f32 * 15.0); + + click(&mut h, slot(0)); + assert!(h.state().ui.plugin_windows.contains(&(kick, 0))); + h.state_mut().ui.plugin_windows.clear(); + + press(&mut h, slot(0), PointerButton::Primary, CMD); + assert!(bypassed(&h, 0)); + assert!(h.state().ui.plugin_windows.is_empty()); + + press(&mut h, slot(1), PointerButton::Secondary, Modifiers::NONE); + let remove = h.get_by_label("no insert").rect().center(); + click(&mut h, remove); + assert_eq!(insert(&h, 1), None); + + let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); + click(&mut h, slot(2)); + let category = h.get_by_label(&format!("{:?} ⏵", plugin.category)).rect().center(); + h.state_mut().synthetic.push(Event::PointerMoved(category)); + for _ in 0..10 { + step(&mut h); + } + let pick = h.get_by_label(plugin.name).rect().center(); + click(&mut h, pick); + assert_eq!(insert(&h, 2).as_deref(), Some(plugin.id)); +} + +#[test] +fn the_views_menu_stays_open_until_a_click_elsewhere() { + let (mut h, _, _) = harness("view.edit_inserts_ae"); + let views = h.get_by_label("Views").rect().center(); + click(&mut h, views); + for column in ["Sends A-E", "I/O"] { + let item = h.get_by_label(column).rect().center(); + click(&mut h, item); + } + let edit = &h.state().engine.session().edit; + assert!(edit.flag("edit_view.sends_ae") && edit.flag("edit_view.io")); + click(&mut h, pos2(800.0, 990.0)); + assert!(h.query_by_label("Sends A-E").is_none()); +} From 6e361262e12fe5fccb141cb39d81c596241ef392 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:20:02 +0100 Subject: [PATCH 110/137] Hardware-style knobs, faders, keys and meters The shared controls were flat rectangles and discs. They now follow Pro Tools' shapes and colours, drawn in code with vertex-coloured meshes (no assets), and keep a modern, soft shading: - Faders: a silver cap, taller than wide, with a dark centre line and faint grip ridges, in a slot that leaves room for the cap at both ends. - Pan knobs: a softly shaded disc with a light rim and a white pointer beside the green value arc. - Keys (S/M/I, record arm, toolbar and transport buttons, edit modes, Grid, Count Off, selector boxes, filled insert, send and instrument slots): lit from above and sunk while pressed. Record arm shows an unlit salmon light when off. The edit-mode keys take their width from the widest label, so SHUFFLE and REL GRID fit. - Meters: LED segments with thin gaps, the unlit ones faintly visible. - Mix sections and the pan and volume readouts sit in outlined wells. Strips get a soft gradient and a lit left edge, track headers a lit top edge, and a line separates the Edit window's headers from the lanes. - Filled insert slots are grey, as in Pro Tools. They no longer brighten on hover: on the grey, that dropped their label below 4.5:1 in Dark. mix_instrument_contrast now finds the key's face and rim, and checks that hovering keeps the face. Part of #138. --- crates/ui-egui/src/edit_window.rs | 4 +- crates/ui-egui/src/mix_window.rs | 49 +++-- crates/ui-egui/src/theme.rs | 10 +- crates/ui-egui/src/toolbar.rs | 70 ++----- crates/ui-egui/src/widgets.rs | 189 ++++++++++++------ .../ui-egui/tests/mix_instrument_contrast.rs | 24 +-- 6 files changed, 199 insertions(+), 147 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 7d32f5a..c16b998 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -753,8 +753,10 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 }); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border)); + ui.painter().hline(head.min.x + 5.0..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light)); track_header(app, ui, &track, head, selected); // Lane. + ui.painter().vline(tl.min.x - 0.5, row.y_range(), Stroke::new(1.0, t.border)); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg }); let s = app.engine.session().clone(); @@ -1053,7 +1055,7 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R let rows = ((r.height() - 4.0) / line_h).floor().max(1.0) as usize; let item = |i: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 3.0 + i as f32 * line_h), vec2(r.width() - 8.0, line_h - 2.0)); let text = |ui: &Ui, rr: Rect, s: &str, c: Color32| { - ui.painter().rect_filled(rr, 2.0, t.slot_bg); + ui.painter().rect(rr, 2.0, t.slot_bg, Stroke::new(1.0, t.slot_border), StrokeKind::Inside); ui.painter().with_clip_rect(rr).text(pos2(rr.min.x + 4.0, rr.center().y), Align2::LEFT_CENTER, s, regular(10.0), c); }; let route = |r: &soundcraft_model::Route| match r { diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index e68a28f..8334636 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -3,8 +3,8 @@ use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, selector_box_with_tooltip, - surround_panner, text_toggle, + PannerEdit, PannerSpeaker, db_text, fader, inset, key, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, + selector_box_with_tooltip, shade, surround_panner, text_toggle, vgradient, }; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; @@ -96,7 +96,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let h = ui.available_height().max(560.0); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); let selected = app.engine.session().edit.selected_tracks.contains(&id); - ui.painter().rect_filled(r, 0.0, if selected { t.strip_selected } else { t.strip_bg }); + let bg = if selected { t.strip_selected } else { t.strip_bg }; + vgradient(ui.painter(), r, shade(bg, 0.06), bg); + ui.painter().vline(r.min.x + 0.5, r.y_range(), Stroke::new(1.0, t.edge_light)); let mut y = r.min.y + 4.0; let inner_w = w - 8.0; let x0 = r.min.x + 4.0; @@ -109,7 +111,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh if all || has("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)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "PREAMP"); if has_input { let kr = Rect::from_min_size(pos2(x0 + 6.0, sec.min.y + 15.0), vec2(inner_w - 12.0, 16.0)); @@ -127,7 +129,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } if all || has("instruments") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "INSTRUMENT"); let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 16.0), vec2(inner_w - 4.0, 15.0)); let slot = track.mixer.inserts.iter().position(|i| i.as_ref().is_some_and(|i| plugin_info(&i.plugin).is_some_and(|p| p.is_instrument))); @@ -163,7 +165,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh continue; } let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, title); for k in 0..5 { let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + k as f32 * 17.0), vec2(inner_w - 4.0, 15.0)); @@ -215,7 +217,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } if all || has("io") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "I / O"); let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0)); let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)); @@ -302,7 +304,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } let _ = resp; let pr = Rect::from_center_size(pos2(cx, y + 42.0), vec2(ks + 8.0, 13.0)); - ui.painter().rect_filled(pr, 1.0, t.counter_bg); + inset(ui.painter(), pr, t.counter_bg); let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan); if let Some(v) = entered { let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); @@ -370,7 +372,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } // 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)); - ui.painter().rect_filled(vr, 1.0, t.counter_bg); + inset(ui.painter(), vr, t.counter_bg); let (_, entered) = editable_readout( ui, vr, @@ -424,13 +426,7 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: let info = ins.and_then(|i| plugin_info(&i.plugin)); let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click()); let fill = if info.is_some() { t.insert_on } else { t.slot_bg }; - ui.painter().rect( - r, - CornerRadius::same(2), - if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }, - Stroke::new(1.0, t.slot_border), - StrokeKind::Inside, - ); + slot_face(ui, r, &resp, fill, info.is_some()); ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text); let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin)); if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) { @@ -441,6 +437,16 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: popup.show(|ui| instrument_menu(app, ui, id)); } +/// A filled slot is a key; it doesn't brighten on hover, which would cost its label contrast. +fn slot_face(ui: &Ui, r: Rect, resp: &egui::Response, fill: Color32, occupied: bool) { + if occupied { + key(ui.painter(), r, resp, fill); + } else { + let fill = if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }; + ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::current().slot_border), StrokeKind::Inside); + } +} + pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; @@ -451,13 +457,7 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: Some(_) => t.insert_on, None => t.slot_bg, }; - ui.painter().rect( - r, - CornerRadius::same(2), - if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }, - Stroke::new(1.0, t.slot_border), - StrokeKind::Inside, - ); + slot_face(ui, r, &resp, fill, ins.is_some()); match &ins { None => { ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); @@ -632,11 +632,10 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us let t = Tokens::current(); let id = track.id; let snd = track.mixer.sends.get(slot).cloned().flatten(); - let fill = if snd.is_some() { t.send_on } else { t.slot_bg }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside); let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max); let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); + slot_face(ui, r, &route_resp, if snd.is_some() { t.send_on } else { t.slot_bg }, snd.is_some()); match &snd { Some(s) => { let name = route_name(app, &s.target); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c667e6f..0c201e2 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -57,6 +57,8 @@ pub struct Tokens { pub toolbar_group: Color32, pub border: Color32, pub border_light: Color32, + /// The lit top/left edge of raised panels: track headers and Mix strips. + pub edge_light: Color32, pub text: Color32, pub text_dim: Color32, pub text_dark: Color32, @@ -167,6 +169,7 @@ impl Tokens { toolbar_group: Color32::from_rgb(24, 24, 25), border: Color32::from_rgb(14, 14, 14), border_light: Color32::from_rgb(64, 64, 66), + edge_light: Color32::from_rgba_premultiplied(20, 20, 20, 20), text: Color32::from_rgb(214, 214, 214), text_dim: Color32::from_rgb(150, 150, 152), text_dark: Color32::from_rgb(16, 16, 16), @@ -233,9 +236,9 @@ impl Tokens { meter_bg: Color32::from_rgb(8, 8, 8), meter_clip_off: Color32::from_rgb(60, 20, 20), knob_bg: Color32::from_rgb(26, 26, 28), - knob_ring: Color32::from_rgb(90, 90, 94), + knob_ring: Color32::from_rgb(150, 150, 154), knob_pointer: Color32::from_rgb(230, 230, 230), - insert_on: Color32::from_rgb(52, 62, 80), + insert_on: Color32::from_rgb(86, 88, 92), insert_bypass: Color32::from_rgb(70, 56, 30), send_on: Color32::from_rgb(46, 66, 56), slot_border: Color32::from_rgb(16, 16, 16), @@ -250,6 +253,7 @@ impl Tokens { toolbar_group: Color32::from_rgb(242, 243, 246), border: Color32::from_rgb(190, 193, 200), border_light: Color32::from_rgb(210, 213, 220), + edge_light: Color32::from_rgba_premultiplied(150, 150, 150, 150), text: Color32::from_rgb(35, 36, 40), text_dim: Color32::from_rgb(95, 98, 106), text_dark: Color32::from_rgb(20, 21, 24), @@ -318,7 +322,7 @@ impl Tokens { knob_bg: Color32::from_rgb(252, 252, 253), knob_ring: Color32::from_rgb(150, 154, 162), knob_pointer: Color32::from_rgb(40, 42, 48), - insert_on: Color32::from_rgb(206, 218, 236), + insert_on: Color32::from_rgb(214, 216, 221), insert_bypass: Color32::from_rgb(240, 222, 186), send_on: Color32::from_rgb(204, 230, 214), slot_border: Color32::from_rgb(175, 179, 187), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..6d8a739 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,7 +1,7 @@ //! The Edit window toolbar. use crate::theme::{Tokens, bold, mono, regular}; -use crate::widgets::icon_button; +use crate::widgets::{icon_button, key, key_fill}; use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; @@ -40,21 +40,23 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let cur = app.engine.session().edit.edit_mode; - group(ui, 112.0, 52.0, |ui, r| { + let modes = [ + (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), + (EditMode::Spot, 1, 0, "SPOT", "F3"), + (EditMode::Slip, 0, 1, "SLIP", "F2"), + (EditMode::Grid, 1, 1, "GRID", "F4"), + ]; + let widest = + modes.iter().map(|m| m.3).chain(["REL GRID"]).map(|l| ui.painter().layout_no_wrap(l.into(), bold(10.5), t.text).size().x).fold(0.0, f32::max); + group(ui, 2.0 * widest + 44.0, 52.0, |ui, r| { let inner = r.shrink(6.0); let cw = inner.width() / 2.0; let ch = inner.height() / 2.0; - let modes = [ - (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), - (EditMode::Spot, 1, 0, "SPOT", "F3"), - (EditMode::Slip, 0, 1, "SLIP", "F2"), - (EditMode::Grid, 1, 1, "GRID", "F4"), - ]; - for (m, cx, cy, label, key) in modes { + for (m, cx, cy, label, fkey) in modes { let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0); let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click()); let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative); - ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { t.mode_off }); + key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off }); let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label }; ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text }); if resp.clicked() { @@ -67,7 +69,7 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { }; let _ = app.run("edit.mode", json!({"mode": id})); } - resp.on_hover_text(format!("{} mode ({key})", label.to_lowercase())); + resp.on_hover_text(format!("{} mode ({fkey})", label.to_lowercase())); } }); } @@ -228,9 +230,10 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover()); ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside); let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0)); - ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { t.counter_off }); + let resp = ui.interact(gl, ui.id().with("grid_lines"), Sense::click()); + key(ui.painter(), gl, &resp, if lines { t.mode_on } else { t.counter_off }); ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text }); - if ui.interact(gl, ui.id().with("grid_lines"), Sense::click()).on_hover_text("Show grid lines").clicked() { + if resp.on_hover_text("Show grid lines").clicked() { let _ = app.run("view.grid_lines", json!({})); } let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0)); @@ -284,49 +287,19 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("options.loop_playback", json!({})); } let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(3), - if resp.hovered() { t.button_hi } else { t.button }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, key_fill(&resp)); icons::draw(ui.painter(), r.shrink(5.0), "stop", if playing { t.text } else { t.accent }); if resp.on_hover_text("Stop (Space)").clicked() { let _ = app.run("transport.stop", json!({})); } let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(3), - if playing { - Color32::from_rgb(40, 90, 50) - } else if resp.hovered() { - t.button_hi - } else { - t.button - }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, if playing { Color32::from_rgb(40, 90, 50) } else { key_fill(&resp) }); icons::draw(ui.painter(), r.shrink(5.0), "play", if playing { Color32::from_rgb(120, 255, 140) } else { t.counter_text }); if resp.on_hover_text("Play (Space)").clicked() { let _ = app.run("transport.play", json!({})); } let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(3), - if recording { - Color32::from_rgb(110, 30, 30) - } else if resp.hovered() { - t.button_hi - } else { - t.button - }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, if recording { Color32::from_rgb(110, 30, 30) } else { key_fill(&resp) }); icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec); if resp.on_hover_text("Record (Cmd+Space)").clicked() { let _ = app.run("transport.record", json!({})); @@ -360,9 +333,10 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover()); ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside); let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0)); - ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { t.counter_off }); + let resp = ui.interact(co, ui.id().with("countoff"), Sense::click()); + key(ui.painter(), co, &resp, if countoff { t.mode_on } else { t.counter_off }); ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text }); - if ui.interact(co, ui.id().with("countoff"), Sense::click()).clicked() { + if resp.clicked() { let _ = app.run("options.countoff", json!({})); } ui.painter().text(pos2(r.max.x - 8.0, co.center().y), Align2::RIGHT_CENTER, format!("{bars} bars"), mono(11.0), t.counter_text); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cb6bad1..338ce1f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -4,7 +4,71 @@ use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; use crate::icons; use crate::theme::{Tokens, bold, regular}; -use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, Color32, CornerRadius, Mesh, Painter, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +/// `c` moved towards white (`k > 0`) or black (`k < 0`). +pub fn shade(c: Color32, k: f32) -> Color32 { + if k >= 0.0 { c.lerp_to_gamma(Color32::WHITE, k) } else { c.lerp_to_gamma(Color32::BLACK, -k) } +} + +fn quad(mesh: &mut Mesh, r: Rect, [tl, tr, bl, br]: [Color32; 4]) { + let i = mesh.vertices.len() as u32; + for (p, c) in [(r.left_top(), tl), (r.right_top(), tr), (r.left_bottom(), bl), (r.right_bottom(), br)] { + mesh.colored_vertex(p, c); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i + 1, i + 2, i + 3); +} + +/// Fills `r` with a vertical gradient from `top` to `bottom`. +pub fn vgradient(p: &Painter, r: Rect, top: Color32, bottom: Color32) { + let mut mesh = Mesh::default(); + quad(&mut mesh, r, [top, top, bottom, bottom]); + p.add(mesh); +} + +/// A rounded rectangle shaded from `top` to `bottom`, with egui's anti-aliased edges. +pub fn rounded_vgradient(p: &Painter, r: Rect, radius: f32, top: Color32, bottom: Color32) { + let options = p.ctx().tessellation_options(|o| *o); + let mut tessellator = egui::epaint::Tessellator::new(p.pixels_per_point(), options, [1, 1], Vec::new()); + let mut mesh = Mesh::default(); + tessellator.tessellate_rect(&egui::epaint::RectShape::filled(r, radius, Color32::WHITE), &mut mesh); + for v in &mut mesh.vertices { + let k = ((v.pos.y - r.min.y) / r.height()).clamp(0.0, 1.0); + v.color = top.lerp_to_gamma(bottom, k).gamma_multiply(v.color.a() as f32 / 255.0); + } + p.add(mesh); +} + +pub fn inset(p: &Painter, r: Rect, fill: Color32) { + let t = Tokens::current(); + p.rect(r, 2.0, fill, Stroke::new(1.0, t.border), StrokeKind::Inside); + p.hline(r.min.x + 1.0..=r.max.x - 1.0, r.min.y + 1.5, Stroke::new(1.0, Color32::from_black_alpha(60))); + p.hline(r.min.x + 2.0..=r.max.x - 2.0, r.max.y + 0.5, Stroke::new(1.0, t.edge_light)); +} + +pub fn key_fill(resp: &Response) -> Color32 { + let t = Tokens::current(); + if resp.hovered() { t.button_hi } else { t.button } +} + +/// A key in `fill`, softly lit from above; it sinks while pressed. +pub fn key(p: &Painter, r: Rect, resp: &Response, fill: Color32) { + let face = r.shrink(1.0); + if resp.is_pointer_button_down_on() { + vgradient(p, face, shade(fill, -0.14), shade(fill, 0.02)); + } else { + p.rect_filled(face, 0.0, fill); + key_light(p, face); + } + p.rect_stroke(r, 3.0, Stroke::new(1.0, Tokens::current().button_border), StrokeKind::Inside); +} + +/// Light from above over a key face already filled with its colour. +fn key_light(p: &Painter, face: Rect) { + vgradient(p, face, Color32::from_white_alpha(26), Color32::from_black_alpha(31)); + p.hline(face.min.x + 1.0..=face.max.x - 1.0, face.min.y + 0.5, Stroke::new(1.0, Color32::from_white_alpha(30))); +} /// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar. fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle { @@ -13,7 +77,7 @@ fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> Ico IconButtonStyle { normal: visuals(t.button, normal_icon), hovered: visuals(t.button_hi, normal_icon), - pressed: None, + pressed: Some(visuals(shade(t.button, -0.1), normal_icon)), selected: visuals(selected_fill, Color32::WHITE), corner_radius: CornerRadius::same(radius), focus_stroke: Stroke::new(1.0, t.accent), @@ -25,6 +89,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st let t = Tokens::current(); let style = button_style(t.accent, t.text, 3); let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + key_light(ui.painter(), r.shrink(1.0)); let s = r.height().min(r.width()) * 0.78; icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint); }); @@ -35,14 +100,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - on_color - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); + key(ui.painter(), r, &resp, if on { on_color } else { key_fill(&resp) }); let col = if on { t.text_dark } else { t.text }; ui.painter().text(r.center(), Align2::CENTER_CENTER, text, bold((size.y * 0.62).clamp(8.0, 13.0)), col); resp.on_hover_text(tip) @@ -50,9 +108,23 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color /// Record-enable button (red dot); the caller owns track arming and command dispatch. pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response { - let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2); + let t = Tokens::current(); + let style = button_style(t.rec, Color32::from_rgb(218, 112, 106), 2); let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { - ui.painter().circle_filled(r.center(), size.y * 0.24, tint); + key_light(ui.painter(), r.shrink(1.0)); + let rad = size.y * 0.24; + if on { + ui.painter().circle_filled(r.center(), rad, tint); + } else { + rounded_vgradient( + ui.painter(), + Rect::from_center_size(r.center(), Vec2::splat(2.0 * rad)), + 2.0 * rad, + tint, + Color32::from_rgb(176, 76, 72), + ); + } + ui.painter().circle_stroke(r.center(), rad, Stroke::new(1.0, Color32::from_black_alpha(90))); }); resp.on_hover_text(tip) } @@ -66,13 +138,7 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(2), - if resp.hovered() { t.button_hi } else { t.button }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, key_fill(&resp)); let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max); icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim); let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0)); @@ -97,9 +163,9 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response if resp.double_clicked() { *value = 0.0; } + let p = ui.painter(); let c = r.center(); let rad = size * 0.42; - ui.painter().circle(c, rad, t.knob_bg, Stroke::new(1.0, t.knob_ring)); let n = 24; let start = std::f32::consts::PI * 0.75; let sweep = std::f32::consts::PI * 1.5; @@ -114,9 +180,13 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response let mid = start + sweep * 0.5; let at = start + sweep * (*value * 0.5 + 0.5); let (a0, a1) = if at < mid { (at, mid) } else { (mid, at) }; - ui.painter().add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text))); - let tip_pos = c + vec2(at.cos(), at.sin()) * rad * 0.85; - ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, t.knob_pointer)); + p.add(egui::Shape::line(arc(start, start + sweep), Stroke::new(2.0, t.meter_bg))); + p.add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text))); + p.circle_filled(c + vec2(0.0, 1.0), rad, Color32::from_black_alpha(90)); + rounded_vgradient(p, Rect::from_center_size(c, Vec2::splat(2.0 * rad)), 2.0 * rad, shade(t.knob_bg, 0.14), shade(t.knob_bg, -0.2)); + p.circle_stroke(c, rad - 0.75, Stroke::new(1.5, t.knob_ring)); + let d = vec2(at.cos(), at.sin()); + p.line_segment([c + d * rad * 0.15, c + d * (rad - 2.5)], Stroke::new(2.0, t.knob_pointer)); resp.on_hover_text(tip) } @@ -130,10 +200,11 @@ pub fn pan_text(v: f32) -> String { } } -/// Vertical peak meter with green/yellow/red zones; values are linear peaks. +/// Vertical LED peak meter with green/yellow/red zones; values are linear peaks. pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { let t = Tokens::current(); - ui.painter().rect_filled(r, 0.0, t.meter_bg); + let p = ui.painter(); + p.rect_filled(r, 0.0, t.meter_bg); let pos = |v: f32| -> f32 { let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() }; meter_pos(db) @@ -141,29 +212,29 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { let h = r.height() - 4.0; let inner = Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 3.0), pos2(r.max.x - 1.0, r.max.y - 1.0)); let lv = pos(level); - if lv > 0.0 { - let top = inner.max.y - h * lv; - // Draw in zones. - let zones = - [(0.0, meter_pos(-12.0), t.meter_green), (meter_pos(-12.0), meter_pos(-3.0), t.meter_yellow), (meter_pos(-3.0), 1.0, t.meter_red)]; - for (a, b, col) in zones { - let y0 = inner.max.y - h * a; - let y1 = (inner.max.y - h * b).max(top); - if y1 < y0 { - ui.painter().rect_filled(Rect::from_min_max(pos2(inner.min.x, y1), pos2(inner.max.x, y0)), 0.0, col); - } - } + let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0)); + let mut leds = Mesh::default(); + for i in 0..(h / 3.0).clamp(0.0, 1000.0) as usize { + let y = inner.max.y - i as f32 * 3.0; + let f = (i as f32 * 3.0 + 1.0) / h; + let zone = if f > red { + t.meter_red + } else if f > amber { + t.meter_yellow + } else { + t.meter_green + }; + let col = if f <= lv { zone } else { zone.gamma_multiply(0.1) }; + quad(&mut leds, Rect::from_x_y_ranges(inner.x_range(), y - 2.0..=y), [col; 4]); } + p.add(leds); let hv = pos(hold); if hv > 0.01 { let y = inner.max.y - h * hv; - ui.painter().line_segment( - [pos2(inner.min.x, y), pos2(inner.max.x, y)], - Stroke::new(1.0, if hv > meter_pos(-3.0) { t.meter_red } else { t.meter_yellow }), - ); + p.line_segment([pos2(inner.min.x, y), pos2(inner.max.x, y)], Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow })); } let clip_r = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 2.5)); - ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off }); + p.rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off }); } /// dB → 0..1 meter position (logarithmic-ish scale like hardware meters). @@ -199,30 +270,32 @@ pub fn clip_gain_to_pos(db: f32) -> f32 { pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { let t = Tokens::current(); let resp = ui.interact(r, id, Sense::click_and_drag()); - let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - 14.0)); - ui.painter().rect_filled(slot, 2.0, t.fader_track); - // Scale marks. + let p = ui.painter(); + let cap_size = vec2((r.width() * 0.44).clamp(16.0, 24.0), 34.0); + let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - cap_size.y)); + p.rect_filled(slot, 2.0, t.fader_track); + p.vline(slot.min.x + 0.5, slot.min.y + 1.0..=slot.max.y - 1.0, Stroke::new(1.0, Color32::from_black_alpha(120))); + p.vline(slot.max.x + 0.5, slot.min.y + 2.0..=slot.max.y - 2.0, Stroke::new(1.0, Color32::from_white_alpha(18))); for mark in [12.0f32, 6.0, 0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0] { let y = slot.max.y - slot.height() * soundcraft_model::fader_db_to_pos(mark); - let x = ui.painter().text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), t.text_dim).max.x + 2.0; + let x = p.text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), t.text_dim).max.x + 2.0; if x < slot.min.x - 3.0 { - ui.painter().hline(x..=slot.min.x - 3.0, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7))); + p.hline(x..=slot.min.x - 3.0, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7))); } } let pos = soundcraft_model::fader_db_to_pos(db); let y = slot.max.y - slot.height() * pos; - let cap = Rect::from_center_size(pos2(slot.center().x, y), vec2(r.width() * 0.62, 22.0)); - ui.painter().rect(cap, 2.0, Color32::from_rgb(70, 70, 72), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside); - ui.painter().line_segment( - [pos2(cap.min.x + 2.0, cap.center().y), pos2(cap.max.x - 2.0, cap.center().y)], - Stroke::new(2.0, Color32::from_rgb(230, 230, 230)), - ); - for dy in [-6.0, -3.0, 3.0, 6.0] { - ui.painter().line_segment( - [pos2(cap.min.x + 3.0, cap.center().y + dy), pos2(cap.max.x - 3.0, cap.center().y + dy)], - Stroke::new(1.0, Color32::from_rgb(40, 40, 40)), - ); + let cap = Rect::from_center_size(pos2(slot.center().x, y), cap_size); + p.rect_filled(cap.translate(vec2(0.0, 2.0)).expand(1.0), 4.0, Color32::from_black_alpha(80)); + let g = Color32::from_gray; + vgradient(p, cap.shrink(1.0), g(226), g(146)); + p.rect_stroke(cap, 3.0, Stroke::new(1.0, g(34)), StrokeKind::Inside); + for dy in [-10.0, -7.0, 7.0, 10.0] { + let x = cap.min.x + 3.0..=cap.max.x - 3.0; + p.hline(x.clone(), cap.center().y + dy, Stroke::new(1.0, Color32::from_black_alpha(50))); + p.hline(x, cap.center().y + dy + 1.0, Stroke::new(1.0, Color32::from_white_alpha(90))); } + p.hline(cap.min.x + 1.0..=cap.max.x - 1.0, cap.center().y, Stroke::new(2.0, g(24))); if resp.double_clicked() || (resp.clicked() && ui.input(|i| i.modifiers.alt)) { return Some(0.0); } diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs index fb33610..6621c32 100644 --- a/crates/ui-egui/tests/mix_instrument_contrast.rs +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -23,19 +23,22 @@ fn slot(output: &FullOutput, label: &str) -> Slot { }) .unwrap_or_else(|| panic!("missing painted label {label}")); let center = text.visual_bounding_rect().center(); - let rect = output - .shapes - .iter() - .find_map(|shape| match &shape.shape { - Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect), + let rects = || { + output.shapes.iter().filter_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.rect.height() <= 15.0 && rect.rect.contains(center) => Some(rect), _ => None, }) + }; + // A filled slot is a key: an opaque face inside a separately stroked rim. + let face = rects() + .filter(|rect| rect.fill.a() == 255) + .min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())) .unwrap_or_else(|| panic!("missing painted slot for {label}")); + let rim = rects().filter(|rect| rect.stroke.width > 0.0).max_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).unwrap_or(face); let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color); assert_eq!(text.opacity_factor, 1.0); assert_eq!(foreground.a(), 255); - assert_eq!(rect.fill.a(), 255); - Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color } + Slot { rect: rim.rect, fill: face.fill, text: foreground, border: rim.stroke.color } } fn contrast(a: Color32, b: Color32) -> f64 { @@ -126,11 +129,8 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { if ratio < 4.5 { failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1")); } - if !light { - let original = Color32::from_rgb(52, 62, 80); - assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original }); - assert_eq!(painted.border, Color32::from_rgb(16, 16, 16)); - } + assert_eq!(painted.fill, palette.insert_on, "{name} hover={hovered}"); + assert_eq!(painted.border, palette.button_border, "{name} hover={hovered}"); if let Some(dir) = &proof_dir { harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap(); } From 4c38ef59c11d1980e06fe39d15506e05004a7725 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:22:44 +0100 Subject: [PATCH 111/137] Silver Light theme The Light palette was near-white with a blue tint. Pro Tools' lighter look is silver, so panels, strips, lanes, rulers and keys move to neutral greys between 184 and 236, and meters sit in dark wells as they do in Pro Tools. Borders and text are darkened to keep their contrast, borders a little more so that tracks, Mix sections and controls don't run into each other. Dark is unchanged. Part of #138. --- crates/ui-egui/src/theme.rs | 122 ++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0c201e2..d392d21 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -246,42 +246,42 @@ impl Tokens { }; pub const LIGHT: Tokens = Tokens { - window_bg: Color32::from_rgb(238, 239, 242), - panel_bg: Color32::from_rgb(248, 249, 251), - panel_bg2: Color32::from_rgb(255, 255, 255), - toolbar_bg: Color32::from_rgb(230, 232, 236), - toolbar_group: Color32::from_rgb(242, 243, 246), - border: Color32::from_rgb(190, 193, 200), - border_light: Color32::from_rgb(210, 213, 220), + window_bg: Color32::from_rgb(184, 186, 190), + panel_bg: Color32::from_rgb(204, 206, 210), + panel_bg2: Color32::from_rgb(216, 218, 222), + toolbar_bg: Color32::from_rgb(194, 196, 200), + toolbar_group: Color32::from_rgb(208, 210, 214), + border: Color32::from_rgb(100, 103, 109), + border_light: Color32::from_rgb(150, 153, 159), edge_light: Color32::from_rgba_premultiplied(150, 150, 150, 150), - text: Color32::from_rgb(35, 36, 40), - text_dim: Color32::from_rgb(95, 98, 106), - text_dark: Color32::from_rgb(20, 21, 24), - header_text: Color32::from_rgb(45, 47, 53), - counter_bg: Color32::from_rgb(224, 226, 231), - counter_text: Color32::from_rgb(22, 116, 45), - counter_label: Color32::from_rgb(65, 68, 75), + text: Color32::from_rgb(24, 25, 28), + text_dim: Color32::from_rgb(68, 70, 76), + text_dark: Color32::from_rgb(16, 17, 20), + header_text: Color32::from_rgb(30, 32, 36), + counter_bg: Color32::from_rgb(224, 226, 228), + counter_text: Color32::from_rgb(18, 108, 40), + counter_label: Color32::from_rgb(54, 57, 63), accent: Color32::from_rgb(62, 116, 180), accent_dark: Color32::from_rgb(43, 88, 145), mode_on: Color32::from_rgb(75, 158, 88), mode_on_text: Color32::from_rgb(255, 255, 255), - mode_off_text: Color32::from_rgb(48, 122, 62), - button: Color32::from_rgb(224, 226, 230), - button_hi: Color32::from_rgb(210, 213, 219), - button_border: Color32::from_rgb(175, 179, 187), - name_field: Color32::from_rgb(255, 255, 255), - name_field_sel: Color32::from_rgb(236, 242, 252), - ruler_bg: Color32::from_rgb(226, 228, 233), - ruler_label_bg: Color32::from_rgb(226, 228, 233), - ruler_text: Color32::from_rgb(70, 73, 80), - ruler_tick: Color32::from_rgb(130, 134, 142), - tempo_ruler: Color32::from_rgb(104, 170, 124), - meter_ruler: Color32::from_rgb(92, 158, 184), - marker_ruler: Color32::from_rgb(210, 213, 219), - playlist_bg: Color32::from_rgb(246, 247, 249), - playlist_alt: Color32::from_rgb(238, 240, 244), - grid_line: Color32::from_rgb(210, 213, 219), - bar_line: Color32::from_rgb(185, 189, 197), + mode_off_text: Color32::from_rgb(38, 106, 52), + button: Color32::from_rgb(214, 216, 220), + button_hi: Color32::from_rgb(200, 203, 208), + button_border: Color32::from_rgb(108, 111, 117), + name_field: Color32::from_rgb(236, 237, 239), + name_field_sel: Color32::from_rgb(224, 232, 244), + ruler_bg: Color32::from_rgb(198, 200, 204), + ruler_label_bg: Color32::from_rgb(198, 200, 204), + ruler_text: Color32::from_rgb(48, 50, 56), + ruler_tick: Color32::from_rgb(108, 111, 118), + tempo_ruler: Color32::from_rgb(92, 156, 112), + meter_ruler: Color32::from_rgb(80, 144, 172), + marker_ruler: Color32::from_rgb(186, 189, 194), + playlist_bg: Color32::from_rgb(210, 212, 216), + playlist_alt: Color32::from_rgb(202, 204, 208), + grid_line: Color32::from_rgb(188, 191, 196), + bar_line: Color32::from_rgb(162, 166, 172), selection: Color32::from_rgba_premultiplied(100, 145, 205, 90), playhead: Color32::from_rgb(210, 48, 42), insertion: Color32::from_rgb(35, 36, 40), @@ -296,37 +296,37 @@ impl Tokens { meter_green: Color32::from_rgb(36, 160, 58), meter_yellow: Color32::from_rgb(190, 162, 20), meter_red: Color32::from_rgb(195, 42, 35), - fader_track: Color32::from_rgb(198, 201, 207), - strip_bg: Color32::from_rgb(235, 237, 241), - strip_section: Color32::from_rgb(244, 245, 247), - slot_bg: Color32::from_rgb(248, 249, 251), - automation_line: Color32::from_rgb(40, 42, 48), - menu_bar: Color32::from_rgb(222, 224, 229), - group_border: Color32::from_rgb(190, 193, 200), - mode_off: Color32::from_rgb(236, 240, 237), - counter_off: Color32::from_rgb(214, 230, 217), - counter_border: Color32::from_rgb(190, 193, 200), - counter_rule: Color32::from_rgb(204, 207, 213), - tool_bracket_off: Color32::from_rgb(200, 203, 210), - row_selected: Color32::from_rgb(198, 216, 240), - visible_dot: Color32::from_rgb(110, 113, 120), - big_counter_bg: Color32::from_rgb(224, 226, 231), - header_selected: Color32::from_rgb(206, 219, 238), - strip_selected: Color32::from_rgb(206, 219, 238), - marker_text: Color32::from_rgb(35, 36, 40), - playlist_lane: Color32::from_rgb(232, 234, 238), - universe_bg: Color32::from_rgb(228, 230, 234), + fader_track: Color32::from_rgb(120, 123, 129), + strip_bg: Color32::from_rgb(198, 200, 204), + strip_section: Color32::from_rgb(210, 212, 216), + slot_bg: Color32::from_rgb(194, 196, 201), + automation_line: Color32::from_rgb(30, 32, 38), + menu_bar: Color32::from_rgb(204, 206, 210), + group_border: Color32::from_rgb(118, 121, 127), + mode_off: Color32::from_rgb(202, 212, 204), + counter_off: Color32::from_rgb(192, 212, 198), + counter_border: Color32::from_rgb(118, 121, 127), + counter_rule: Color32::from_rgb(188, 191, 196), + tool_bracket_off: Color32::from_rgb(172, 175, 181), + row_selected: Color32::from_rgb(174, 196, 226), + visible_dot: Color32::from_rgb(86, 89, 96), + big_counter_bg: Color32::from_rgb(224, 226, 228), + header_selected: Color32::from_rgb(184, 200, 224), + strip_selected: Color32::from_rgb(184, 200, 224), + marker_text: Color32::from_rgb(24, 25, 28), + playlist_lane: Color32::from_rgb(200, 202, 206), + universe_bg: Color32::from_rgb(198, 200, 204), universe_view: Color32::from_rgb(40, 42, 48), - meter_bg: Color32::from_rgb(196, 199, 206), - meter_clip_off: Color32::from_rgb(232, 200, 198), - knob_bg: Color32::from_rgb(252, 252, 253), - knob_ring: Color32::from_rgb(150, 154, 162), - knob_pointer: Color32::from_rgb(40, 42, 48), - insert_on: Color32::from_rgb(214, 216, 221), - insert_bypass: Color32::from_rgb(240, 222, 186), - send_on: Color32::from_rgb(204, 230, 214), - slot_border: Color32::from_rgb(175, 179, 187), - section_label: Color32::from_rgb(95, 98, 106), + meter_bg: Color32::from_rgb(52, 54, 58), + meter_clip_off: Color32::from_rgb(92, 54, 54), + knob_bg: Color32::from_rgb(232, 233, 236), + knob_ring: Color32::from_rgb(116, 120, 128), + knob_pointer: Color32::from_rgb(30, 32, 38), + insert_on: Color32::from_rgb(228, 230, 233), + insert_bypass: Color32::from_rgb(224, 204, 164), + send_on: Color32::from_rgb(192, 218, 202), + slot_border: Color32::from_rgb(112, 115, 121), + section_label: Color32::from_rgb(56, 58, 64), }; pub fn current() -> Tokens { From 3189a1a5122ab6829417063f05be68066f024834 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:47:48 +0100 Subject: [PATCH 112/137] Tint each Edit window track with its colour As in Pro Tools, a track's colour now runs through its whole row instead of only the strip at its left edge: the header (and any header columns) takes a muted shade of it and the lane a faint one, so clips in the full colour still stand out. - Window > Color Palette has a "Track tint in the Edit window" slider; at zero tracks look as before. The setting is saved with the UI prefs. - A selected track's header gets an accent outline, since its blue background would otherwise be lost under the tint. --- crates/ui-egui/src/edit_window.rs | 12 ++++++++---- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/theme.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/windows.rs | 2 ++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index c16b998..0ee2ae8 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -749,16 +749,19 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) } let main_h = track.height.points(); let lane = lane_rect(&track, row, tl); - // Header background. - ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 }); + let (head_bg, lane_bg) = crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint); + ui.painter().rect_filled(head, 0.0, head_bg); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border)); ui.painter().hline(head.min.x + 5.0..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light)); + if selected && app.ui.track_tint > 0.0 { + ui.painter().rect_stroke(head.with_min_x(head.min.x + 5.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } track_header(app, ui, &track, head, selected); // Lane. ui.painter().vline(tl.min.x - 0.5, row.y_range(), Stroke::new(1.0, t.border)); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); - painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg }); + painter.rect_filled(lane, 0.0, lane_bg); let s = app.engine.session().clone(); grid_lines(&painter, &s, tl, lane); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); @@ -1049,7 +1052,8 @@ fn column_ui(ui: &mut Ui, col: &str, r: Rect) -> Ui { /// One optional Edit-window column for a track (instrument, inserts, sends, I/O, comments). fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { let t = Tokens::current(); - ui.painter().rect_filled(r, 0.0, t.panel_bg); + let selected = app.engine.session().edit.selected_tracks.contains(&track.id); + ui.painter().rect_filled(r, 0.0, crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint).0); ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, t.border)); let line_h = 15.0; let rows = ((r.height() - 4.0) / line_h).floor().max(1.0) as usize; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..b307e47 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -68,6 +68,8 @@ pub struct UiState { pub mix_views: Vec, /// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color. pub edit_views: Vec, + /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip). + pub track_tint: f32, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>, pub audiosuite: Option, @@ -128,6 +130,7 @@ impl Default for UiState { narrow_mix: false, mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], edit_views: vec!["color".into()], + track_tint: 0.5, plugin_windows: Vec::new(), audiosuite: None, status: String::new(), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0c201e2..54d4afa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -348,6 +348,20 @@ pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { } } +/// A track's header and lane backgrounds in the Edit window, tinted with its colour by `tint` (0..1). +pub fn track_backgrounds(color: [u8; 3], selected: bool, tint: f32) -> (Color32, Color32) { + let t = Tokens::current(); + let k = tint.clamp(0.0, 1.0); + if k <= 0.0 { + return if selected { (t.header_selected, t.playlist_alt) } else { (t.panel_bg2, t.playlist_bg) }; + } + let c = rgb(color); + let header = t.panel_bg2.lerp_to_gamma(c, k * 0.55); + let header = if selected { crate::widgets::shade(header, 0.1) } else { header }; + let lane = if selected { t.playlist_alt } else { t.playlist_bg }.lerp_to_gamma(c, k * 0.2); + (header, lane) +} + /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; @@ -428,6 +442,12 @@ mod tests { assert_eq!(old.theme, ThemeMode::Dark); } + #[test] + fn prefs_saved_before_track_tint_get_the_default() { + let ui: UiState = serde_json::from_value(json!({"theme": "light"})).unwrap(); + assert!((ui.track_tint - 0.5).abs() < f32::EPSILON); + } + #[test] fn modes_round_trip_through_prefs() { for mode in ThemeMode::ALL { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..6c00df9 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -143,6 +143,8 @@ fn color_palette(app: &mut SoundApp, ctx: &egui::Context) { } } }); + ui.separator(); + ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text("Track tint in the Edit window")); }); app.ui.show_color_palette = open; } From c9b694907f19b6ff8beefcf6b7d81b67bff27bd5 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:27:53 +0100 Subject: [PATCH 113/137] Mark the Tracks panel's edge with a grey scroll bar The Tracks and Groups lists met the Edit and Mix windows at a faint one-point line. A 10-point grey gutter with dark edges now runs down the panel's right side and carries each list's scroll thumb when the list is longer than its space: drag it, or click above or below it to page. egui's own scroll bars are off there. --- crates/ui-egui/src/panels.rs | 53 ++++++++++++++++++- crates/ui-egui/tests/tracks_scroll_bar.rs | 64 +++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/tracks_scroll_bar.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..b55a8ec 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -15,15 +15,28 @@ fn panel_header(ui: &mut Ui, title: &str) { ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); } +/// The strip down the Tracks panel's right edge: the lists' scroll bars, and a clear line between +/// the panel and the window beside it. +const GUTTER_W: f32 = 10.0; + pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); + let full = ui.max_rect(); + let gutter = full.with_min_x(full.max.x - GUTTER_W); + ui.painter().rect_filled(gutter, 0.0, t.panel_bg.lerp_to_gamma(t.text_dim, 0.15)); + ui.painter().vline(gutter.min.x + 0.5, full.y_range(), Stroke::new(1.0, t.border)); + ui.painter().vline(gutter.max.x - 0.5, full.y_range(), Stroke::new(1.0, t.border)); + let ui = &mut ui.new_child(egui::UiBuilder::new().max_rect(full.with_max_x(gutter.min.x))); let total = ui.available_height(); panel_header(ui, "TRACKS"); let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = { let s = app.engine.session(); s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect() }; - egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).show(ui, |ui| { + let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden; + let lists = + egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).scroll_bar_visibility(hidden); + let out = lists.show(ui, |ui| { for (id, name, shown, color, sel) in tracks { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); if sel { @@ -42,11 +55,12 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { } } }); + gutter_scroll(ui, gutter, &out); ui.add_space(4.0); panel_header(ui, "GROUPS"); let groups: Vec<(u64, char, String, bool, [u8; 3])> = app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect(); - egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).show(ui, |ui| { + let out = egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover()); ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "", regular(11.5).clone(), t.text); let all = ui.interact(r, ui.id().with("group_all"), Sense::click()); @@ -96,6 +110,41 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { }); } }); + gutter_scroll(ui, gutter, &out); +} + +/// A list's scroll thumb in the gutter beside it, when the list is longer than its space: drag it, +/// or click above or below it to page. +fn gutter_scroll(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOutput) { + let t = Tokens::current(); + let (view, content) = (out.inner_rect.height(), out.content_size.y); + if content <= view + 0.5 { + return; + } + let bar = Rect::from_x_y_ranges(gutter.x_range(), out.inner_rect.y_range()); + let (max, h) = (content - view, (bar.height() * view / content).max(20.0)); + let mut state = out.state; + let thumb = Rect::from_min_size(pos2(bar.min.x + 2.0, bar.min.y + (bar.height() - h) * state.offset.y / max), vec2(bar.width() - 4.0, h)); + let resp = ui.interact(bar, out.id.with("gutter"), Sense::click_and_drag()); + ui.painter().rect_filled(thumb, 3.0, if resp.hovered() || resp.dragged() { t.button_hi.lerp_to_gamma(Color32::WHITE, 0.1) } else { t.button_hi }); + let to = if resp.dragged() { + Some(state.offset.y + resp.drag_delta().y * max / (bar.height() - h).max(1.0)) + } else if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.clicked()) { + let page = if p.y < thumb.min.y { + -view + } else if p.y > thumb.max.y { + view + } else { + 0.0 + }; + Some(state.offset.y + page) + } else { + None + }; + if let Some(y) = to { + state.offset.y = y.clamp(0.0, max); + state.store(ui.ctx(), out.id); + } } pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { diff --git a/crates/ui-egui/tests/tracks_scroll_bar.rs b/crates/ui-egui/tests/tracks_scroll_bar.rs new file mode 100644 index 0000000..d596cde --- /dev/null +++ b/crates/ui-egui/tests/tracks_scroll_bar.rs @@ -0,0 +1,64 @@ +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, Shape, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme::Tokens}; + +fn step(h: &mut Harness<'_, SoundApp>) { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn click(h: &mut Harness<'_, SoundApp>, pos: Pos2) { + h.state_mut().synthetic.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + for _ in 0..6 { + step(h); + } +} + +/// The Tracks list's first row, the leftmost "Kick" painted. +fn first_row(h: &Harness<'_, SoundApp>) -> Rect { + h.output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(text) if text.galley.job.text == "Kick" => Some(text.visual_bounding_rect()), + _ => None, + }) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap() +} + +#[test] +fn the_tracks_gutter_pages_a_long_list() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("track.new", json!({"count": 60})).unwrap(); + 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, + ); + for _ in 0..6 { + step(&mut h); + } + let thumb = h + .output() + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.fill == Tokens::DARK.button_hi && rect.rect.width() == 6.0 => Some(rect.rect), + _ => None, + }) + .expect("a list longer than its space shows a thumb"); + let before = first_row(&h); + click(&mut h, pos2(thumb.center().x, thumb.max.y + 20.0)); + let after = first_row(&h); + assert!(after.min.y < before.min.y - 100.0, "a click below the thumb didn't page the list: {before:?} -> {after:?}"); +} From c8021d4c906a51e72c3222545279b38eebb7ce8b Mon Sep 17 00:00:00 2001 From: highperfocused Date: Mon, 12 Oct 2026 21:41:43 +0200 Subject: [PATCH 114/137] fix(mix): mix.insert_params rejects unknown parameter names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unknown keys in `params` were silently dropped, so a typo like `gain_db` reported `ok: true, set: 0` — indistinguishable from a successful no-op. The command now fails and lists the unrecognized keys (and non-numeric values), matching how `mix.insert` already validates its params. Preset recall in the plugin menu drops stale keys before calling the command. Fixes #266 --- crates/engine/src/cmd/mix.rs | 65 +++++++++++++++++++++++++++++++++--- crates/ui-egui/src/panels.rs | 14 +++++--- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 1b9b56d..2fac5a6 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -100,7 +100,7 @@ pub fn specs() -> Vec { "Set Plugin Parameters", [], None, - "{track?, slot, params: {id: value}, preset?: name}", + "{track?, slot, params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)", has_selection, insert_params ), @@ -360,7 +360,8 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result { Ok(json!({"value": v})) } -/// Apply many parameters at once (preset recall); unknown ids are ignored, values clamped. +/// Apply many parameters at once (preset recall); values are clamped. Unknown ids and +/// non-numeric values are errors, not silent no-ops: a typo must not look like success. fn insert_params(e: &mut Engine, p: &Value) -> Result { let (t, slot) = track_slot(e, p, "mix.insert_params", INSERT_SLOTS)?; let obj = p.get("params").and_then(Value::as_object).cloned().ok_or_else(|| bad("mix.insert_params", "`params` must be an object"))?; @@ -372,11 +373,29 @@ fn insert_params(e: &mut Engine, p: &Value) -> Result { .and_then(Option::as_mut) .ok_or_else(|| bad("mix.insert_params", "empty slot"))?; let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_params", "unknown plugin"))?; + let mut unknown: Vec<&str> = Vec::new(); + let mut non_numeric: Vec<&str> = Vec::new(); + for (k, v) in &obj { + if !info.params.iter().any(|x| x.id == k.as_str()) { + unknown.push(k.as_str()); + } else if v.as_f64().is_none_or(|f| !f.is_finite()) { + non_numeric.push(k.as_str()); + } + } + if !unknown.is_empty() { + unknown.sort_unstable(); + return Err(bad("mix.insert_params", format!("`{}` has no parameter(s): {}", info.id, unknown.join(", ")))); + } + if !non_numeric.is_empty() { + non_numeric.sort_unstable(); + return Err(bad("mix.insert_params", format!("parameter(s) need a numeric value: {}", non_numeric.join(", ")))); + } let mut n = 0; for (k, v) in obj { - let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) else { continue }; - ins.params.insert(k, (f as f32).clamp(pi.min, pi.max)); - n += 1; + if let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) { + ins.params.insert(k, (f as f32).clamp(pi.min, pi.max)); + n += 1; + } } if let Some(name) = preset { ins.preset = name; @@ -802,6 +821,42 @@ mod tests { } } + #[test] + fn insert_params_rejects_unknown_and_non_numeric_keys() { + let mut e = Engine::default(); + e.execute("track.new", &json!({})).unwrap(); + let t = e.session().tracks[0].id.0; + e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap(); + fn gain(e: &Engine) -> f32 { + e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"] + } + + // A typo names no parameter: the call fails and says which key. + let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err(); + assert!(err.to_string().contains("gain_db"), "{err}"); + + // Mixed keys fail as a whole and change nothing. + let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err(); + assert!(err.to_string().contains("width"), "{err}"); + assert_eq!(gain(&e), 0.0); + + // Non-numeric and non-finite values are rejected too. + assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err()); + assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err()); + assert_eq!(gain(&e), 0.0); + + // Correct keys still apply, clamped to the parameter range. + let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap(); + assert_eq!(r["set"], 1); + assert_eq!(gain(&e), -14.0); + e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap(); + assert_eq!(gain(&e), 24.0); + + // Empty params with a preset name still recall (the rename path). + e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap(); + assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved"); + } + #[test] fn retargeting_a_send_keeps_its_settings() { let mut e = Engine::default(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..59dd9ca 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -563,10 +563,16 @@ fn preset_menu( if let Ok(text) = std::fs::read_to_string(dir.join(format!("{n}.json"))) && let Ok(v) = serde_json::from_str::(&text) { - let _ = app.run( - "mix.insert_params", - json!({"track": tid.0, "slot": slot, "params": v.get("params").cloned().unwrap_or_default(), "preset": n}), - ); + // Old presets may carry parameters the plugin no longer exposes; drop them so recall succeeds. + let params: serde_json::Map = v + .get("params") + .and_then(serde_json::Value::as_object) + .into_iter() + .flatten() + .filter(|(k, val)| info.params.iter().any(|pi| pi.id == k.as_str()) && val.as_f64().is_some_and(f64::is_finite)) + .map(|(k, val)| (k.clone(), val.clone())) + .collect(); + let _ = app.run("mix.insert_params", json!({"track": tid.0, "slot": slot, "params": params, "preset": n})); } ui.close(); } From bebca3aa85acb854378402b1c1487a4bd2d802cd Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:43:09 +0100 Subject: [PATCH 115/137] Draw grid lines over clips in the Edit window The Edit window painted the bar and grid lines on the empty lane and then the opaque clip bodies on top, so the grid vanished wherever an audio or MIDI clip sat. Pro Tools draws grid lines over the clips. - While grid lines are shown, the lines are drawn after the clips (audio, MIDI, video and their overlays) and before automation and the selection. - The lines are a translucent white (Dark) or black (Light), worked out from the palette's line and lane colours, so a line on an empty lane looks as before and waveforms and notes stay readable under it. Bar lines now also stand out where a grid line lands on them; the opaque grid line used to cover them. - With grid lines hidden, the bar lines stay behind the clips as before. Test: tests/grid_lines.rs renders the demo Edit window in both themes and checks that the lines over the Kick clip are painted after it only while grid lines are shown, and that each one looks like the theme's line colour on an empty lane. --- crates/ui-egui/src/edit_window.rs | 25 +++++++++-- crates/ui-egui/tests/grid_lines.rs | 70 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/grid_lines.rs diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..28be900 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -697,7 +697,10 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg }); let s = app.engine.session().clone(); - grid_lines(&painter, &s, tl, lane); + // Shown grid lines go over the clips; otherwise the bar lines stay behind them. + if !s.edit.grid_lines { + grid_lines(&painter, &s, tl, lane); + } let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); let auto_view = AutoParam::parse(&track.view); for clip in track.clips() { @@ -707,6 +710,9 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) draw_clip(app, &painter, &s, &track, clip, tl, lane, auto_view.is_some()); } crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane); + if s.edit.grid_lines { + grid_lines(&painter, &s, tl, lane); + } if let Some(p) = &auto_view { draw_automation(&painter, &s, &track, p, tl, lane); } @@ -1130,22 +1136,35 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px; if bar_px > 6.0 { + let col = grid_veil(t.bar_line, t.playlist_bg); for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) { let x = x_of(s, tl, at); - painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.bar_line)); + painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col)); } } if s.edit.grid_lines { let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px; if step_px > 7.0 { + let col = grid_veil(t.grid_line, t.playlist_bg); for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) { let x = x_of(s, tl, at); - painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.grid_line)); + painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col)); } } } } +/// The translucent white or black that turns `bg` into `line`, so grid lines also show over clips. +fn grid_veil(line: Color32, bg: Color32) -> Color32 { + let level = |c: Color32| (f32::from(c.r()) + f32::from(c.g()) + f32::from(c.b())) / 3.0; + let (l, b) = (level(line), level(bg)); + if l > b { + Color32::from_white_alpha(((l - b) / (255.0 - b) * 255.0).round() as u8) + } else { + Color32::from_black_alpha(((b - l) / b.max(1.0) * 255.0).round() as u8) + } +} + fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); diff --git a/crates/ui-egui/tests/grid_lines.rs b/crates/ui-egui/tests/grid_lines.rs new file mode 100644 index 0000000..cac269f --- /dev/null +++ b/crates/ui-egui/tests/grid_lines.rs @@ -0,0 +1,70 @@ +use egui::{Color32, FullOutput, Pos2, RawInput, Rect, Shape, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::theme::Tokens; +use soundcraft_ui_egui::{Services, SoundApp, edit_window}; + +fn frame(ctx: &egui::Context, app: &mut SoundApp) -> FullOutput { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out +} + +fn over(top: Color32, bg: Color32) -> [i32; 3] { + let keep = 255 - i32::from(top.a()); + [(top.r(), bg.r()), (top.g(), bg.g()), (top.b(), bg.b())].map(|(t, b)| i32::from(t) + i32::from(b) * keep / 255) +} + +fn near(a: [i32; 3], b: Color32) -> bool { + a.iter().zip([b.r(), b.g(), b.b()]).all(|(x, y)| (x - i32::from(y)).abs() <= 6) +} + +#[test] +fn grid_lines_are_drawn_over_clips_while_shown() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + for theme in ["dark", "light"] { + for shown in [true, false] { + app.run("ui.theme", json!({"mode": theme})).unwrap(); + app.run("view.grid_lines", json!({"value": shown})).unwrap(); + frame(&ctx, &mut app); + let out = frame(&ctx, &mut app); + let t = Tokens::current(); + let s = app.engine.session().clone(); + let kick = s.track_by_name("Kick").unwrap(); + let clip = kick.clips()[0].clone(); + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let tl = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1; + let lane = Rect::from_min_max(pos2(tl.min.x, row[1]), pos2(tl.max.x, row[1] + kick.height.points())); + let (cx0, cx1) = (edit_window::x_of(&s, tl, clip.start), edit_window::x_of(&s, tl, clip.end())); + let body = out + .shapes + .iter() + .position(|c| matches!(&c.shape, Shape::Rect(r) if r.rect.min.x == cx0 && r.rect.max.x == cx1 && r.rect.min.y == lane.min.y + 1.0)) + .unwrap(); + let lines: Vec<(usize, Color32)> = out + .shapes + .iter() + .enumerate() + .filter_map(|(i, c)| match &c.shape { + Shape::LineSegment { points: [a, b], stroke } + if a.x == b.x && a.x > cx0 + 1.0 && a.x < cx1 - 1.0 && a.y == lane.min.y && b.y == lane.max.y => + { + Some((i, stroke.color)) + } + _ => None, + }) + .collect(); + assert!(lines.len() > 4, "{theme} {shown}: {} lines", lines.len()); + assert!(lines.iter().all(|(i, _)| (*i > body) == shown), "{theme} {shown}"); + for (_, col) in &lines { + let look = over(*col, t.playlist_bg); + assert!(near(look, t.grid_line) || near(look, t.bar_line), "{theme} {shown}: {look:?}"); + } + } + } +} From 3617011342d3809f1fa9b4468b75c0c95809dd91 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:17:26 +0100 Subject: [PATCH 116/137] Pro Tools keys for nudging, Grid and Nudge values, playlists and windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands that already worked but had no key get the ones Pro Tools uses, on macOS, Windows and Linux alike (Alt is Option on macOS; Cmd is Ctrl elsewhere): - Shift+= and Shift+- step the Grid value to the next larger or smaller one, and Alt+Shift+= and Alt+Shift+- the Nudge value, as the Pro Tools Reference Guide gives them. `view.grid` and `view.nudge` take `step`. `GridValue::stepped` walks the note values, keeping a dot or triplet, and steps seconds, frames and samples by tens. macOS sends Alt+Shift+= as = (the key under ±), so that arm takes = as well as +. Both commands list the keys as descriptive shortcuts, like Nudge's Plus/Minus, so they show wherever commands list their shortcuts; the strings don't parse, so the registry never dispatches them and `shortcuts::fixed` handles the keys. - Commands Focus: , and . nudge back and forward, M and / nudge by the next larger Nudge value (`edit.nudge` takes `next`), 0 copies the Timeline selection to the Edit selection and O the other way, [ and ] play the Edit and Timeline selections, and Shift+Z redoes. Shift+P and Shift+; add the track above or below the selection, keeping its range (P and ; already move it). They run Extend Edit Up/Down, which now skip tracks the Edit window doesn't show (hidden, or in a closed folder); before, they could add an invisible track. Shift+; can arrive as :, so both are matched. - Playlists: Alt+Shift+Up and Alt+Shift+Down copy the selection to the target playlist and to a new one, Alt+Shift+T and Alt+Shift+N move it there, Shift+Right shows the target playlist and Cmd+Shift+Right designates the shown one. The MIDI editor's piano roll moved notes on any arrow key, whatever the modifiers; it now leaves Alt, Cmd and Ctrl arrows, and Shift+Left/Right, to these shortcuts. - F12 records, as in Pro Tools. Cmd+Space is Spotlight on macOS. - Ctrl+Tab goes back to the previous clip boundary off macOS, where Windows, GNOME and KDE take Alt+Tab; Pro Tools for Windows uses Ctrl+Tab too. Cmd+Alt+Tab now toggles Tab to Transient: the Tab arm took it as Alt+Tab and tabbed back instead. - Windows: Alt+7 Universe, Alt+6 Clip Effects, Alt+= MIDI Event List, Shift+K MIDI Keyboard, Cmd+Alt+J Window Configuration List, Alt+' Task Manager, Alt+I Workspace, Alt+J Workspaces to Front. Tests drive the keys through `shortcuts::handle` for each OS (tests/pro_tools_keys.rs), plus the piano roll with modifier arrows held. --- crates/engine/src/cmd/edit.rs | 34 ++-- crates/engine/src/cmd/edit_more.rs | 16 +- crates/engine/src/cmd/track_more.rs | 4 +- crates/engine/src/cmd/view.rs | 36 +++- crates/time/src/grid.rs | 33 ++++ crates/ui-egui/src/menus.rs | 16 +- crates/ui-egui/src/midi_editor.rs | 9 +- crates/ui-egui/src/shortcuts.rs | 40 ++++- crates/ui-egui/tests/pro_tools_keys.rs | 234 +++++++++++++++++++++++++ 9 files changed, 385 insertions(+), 37 deletions(-) create mode 100644 crates/ui-egui/tests/pro_tools_keys.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..ecba4ad 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -103,7 +103,15 @@ pub fn specs() -> Vec { cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file), cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill), cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips), - cmd!("edit.copy_to_new_playlist", "New Playlist", ["Edit", "Copy Selection to..."], None, "{}", has_range, copy_to_new_playlist), + cmd!( + "edit.copy_to_new_playlist", + "New Playlist", + ["Edit", "Copy Selection to..."], + Some("Alt+Shift+Down"), + "{}", + has_range, + copy_to_new_playlist + ), cmd!( "edit.strip_silence", "Strip Silence", @@ -125,7 +133,7 @@ pub fn specs() -> Vec { cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete), cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)), cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)), - cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge), + cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge), cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| { let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?; e.session_mut().edit.edit_mode = m; @@ -429,20 +437,25 @@ fn scale_selection(e: &mut Engine, f: f64) -> Result { Ok(json!({"selection": s.edit.selection})) } +/// Adds the nearest track above (`dir` < 0) or below the selected ones that the Edit window shows. fn extend_tracks(e: &mut Engine, dir: i64) -> Result { - let s = e.session_mut(); + let s = e.session(); + let shown = |t: &&soundcraft_model::Track| !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open); let idxs: Vec = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect(); - let next = if dir < 0 { idxs.iter().min().and_then(|i| i.checked_sub(1)) } else { idxs.iter().max().map(|i| i + 1) }; - if let Some(t) = next.and_then(|i| s.tracks.get(i)).map(|t| t.id) { + let next = match (idxs.iter().min(), idxs.iter().max()) { + (Some(&top), _) if dir < 0 => s.tracks.iter().take(top).rev().find(shown), + (_, Some(&bottom)) if dir > 0 => s.tracks.iter().skip(bottom + 1).find(shown), + (None, _) => s.tracks.iter().find(shown), + _ => None, + } + .map(|t| t.id); + let s = e.session_mut(); + if let Some(t) = next { if dir < 0 { s.edit.selected_tracks.insert(0, t); } else { s.edit.selected_tracks.push(t); } - } else if idxs.is_empty() - && let Some(t) = s.tracks.first() - { - s.edit.selected_tracks.push(t.id); } Ok(json!({"tracks": s.edit.selected_tracks})) } @@ -715,7 +728,8 @@ fn nudge(e: &mut Engine, p: &Value) -> Result { let dir = i64_or(p, "direction", 1).signum(); let count = i64_or(p, "count", 1).clamp(1, 1000); let s = e.session(); - let step = s.edit.nudge.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir }; + let value = if bool_or(p, "next", false) { s.edit.nudge.stepped(true) } else { s.edit.nudge }; + let step = value.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir }; let ids = clip_ids_param(e, p); let s = e.session_mut(); let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) }; diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index c5c1082..d63bf43 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "edit.copy_to_target_playlist", "Target Playlist", ["Edit", "Copy Selection to..."], - None, + Some("Alt+Shift+Up"), "{tracks?, start?, end?} — copies the selection into each track's target playlist", has_range, |e, p| to_playlist(e, p, Dest::Target, false, "edit.copy_to_target_playlist") @@ -196,14 +196,20 @@ pub fn specs() -> Vec { "edit.move_to_target_playlist", "Target Playlist", ["Edit", "Move Selection to..."], - None, + Some("Alt+Shift+T"), "{tracks?, start?, end?} — copies to the target playlist and clears it from the main", has_range, |e, p| to_playlist(e, p, Dest::Target, true, "edit.move_to_target_playlist") ), - cmd!("edit.move_to_new_playlist", "New Playlist", ["Edit", "Move Selection to..."], None, "{tracks?, start?, end?}", has_range, |e, p| { - to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist") - }), + cmd!( + "edit.move_to_new_playlist", + "New Playlist", + ["Edit", "Move Selection to..."], + Some("Alt+Shift+N"), + "{tracks?, start?, end?}", + has_range, + |e, p| to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist") + ), cmd!( "edit.move_to_duplicate_playlist", "Duplicate of Main Playlist", diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index 8818342..7109c66 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "track.designate_target_playlist", "Designate as Target Playlist", ["Track"], - None, + Some("Cmd+Shift+Right"), "{tracks?, index?} — the active playlist (or `index`) becomes the target for Copy/Move Selection to", has_selection, designate_target @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "track.show_target_playlist", "Show Target Playlist", ["Track"], - None, + Some("Shift+Right"), "{tracks?} — makes the target playlist the main playlist", has_selection, show_target diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 7b36430..6dee816 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -72,8 +72,8 @@ pub fn specs() -> Vec { e.session_mut().edit.sub_counter = f; Ok(json!({"format": f})) }), - cmd!(noundo "view.grid", "Grid Value", [], None, "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, lines?: bool}", always, |e, p| { - let g = grid_param(p, "value").ok_or_else(|| bad("view.grid", "unknown grid value"))?; + cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| { + let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?; let lines = p.get("lines").and_then(Value::as_bool); let s = e.session_mut(); s.edit.grid = g; @@ -85,8 +85,8 @@ pub fn specs() -> Vec { s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines); Ok(json!({"value": s.edit.grid_lines})) }), - cmd!(noundo "view.nudge", "Nudge Value", [], None, "{value: like grid}", always, |e, p| { - let g = grid_param(p, "value").ok_or_else(|| bad("view.nudge", "unknown nudge value"))?; + cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| { + let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?; e.session_mut().edit.nudge = g; Ok(json!({"nudge": g.label()})) }), @@ -122,6 +122,12 @@ fn ruler(e: &mut Engine, p: &Value) -> Result { Ok(json!({"ruler": r, "visible": vis})) } +/// `value`, or the value `step` places larger (or smaller, when negative) than `current`. +fn value_or_step(p: &Value, current: GridValue) -> Option { + let Some(step) = p.get("step").and_then(Value::as_i64) else { return grid_param(p, "value") }; + Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0))) +} + /// Parse grid/nudge values. pub fn grid_param(p: &Value, key: &str) -> Option { let v = p.get(key)?; @@ -214,4 +220,26 @@ mod tests { e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap(); assert_eq!(state(&e), before); } + + #[test] + fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() { + let mut e = Engine::default(); + e.execute("view.grid", &json!({"value": "1/16"})).unwrap(); + e.execute("view.grid", &json!({"step": 2})).unwrap(); + assert_eq!(e.session().edit.grid.label(), "1/4 note"); + e.execute("view.grid", &json!({"step": -1})).unwrap(); + assert_eq!(e.session().edit.grid.label(), "1/8 note"); + e.execute("view.nudge", &json!({"value": {"seconds": 0.01}})).unwrap(); + e.execute("view.nudge", &json!({"step": i64::MIN})).unwrap(); + assert_eq!(e.session().edit.nudge, GridValue::Seconds(0.001)); + assert!(e.execute("view.nudge", &json!({"step": "up"})).is_err()); + let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone(); + e.execute("view.nudge", &json!({"value": "1/16"})).unwrap(); + e.execute("edit.select", &json!({"tracks": [t], "start": 0, "end": 1000})).unwrap(); + e.execute("edit.nudge", &json!({"direction": 1, "next": true})).unwrap(); + assert_eq!(e.session().edit.selection.start, 12_000); + e.execute("edit.nudge", &json!({"direction": -1})).unwrap(); + assert_eq!(e.session().edit.selection.start, 6_000); + assert_eq!(e.session().edit.nudge.label(), "1/16 note"); + } } diff --git a/crates/time/src/grid.rs b/crates/time/src/grid.rs index 30e8f63..dea9053 100644 --- a/crates/time/src/grid.rs +++ b/crates/time/src/grid.rs @@ -86,6 +86,24 @@ impl GridValue { } } + /// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their + /// dot or triplet; seconds, frames and samples step by tens. + pub fn stepped(self, larger: bool) -> GridValue { + fn next(ladder: &[T], v: T, larger: bool) -> Option { + if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) } + } + match self { + GridValue::Note { value, dotted, triplet } => { + let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0); + let j = if larger { i.checked_sub(1) } else { i.checked_add(1) }; + j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet }) + } + GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds), + GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames), + GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples), + } + } + fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 { let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() }; let mut t = base; @@ -199,4 +217,19 @@ mod tests { let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }; assert_eq!(d.step_samples(0, sr, &map, rate), 36_000); } + + #[test] + fn values_step_to_the_next_larger_or_smaller() { + let note = |value, triplet| GridValue::Note { value, dotted: false, triplet }; + assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false)); + assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true)); + assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false)); + assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false)); + assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0)); + assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10)); + assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10)); + assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1)); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..7bef428 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,23 +26,23 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")), ("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")), ("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None), - ("window.universe", "Universe", "View > Other Displays > Universe", None), + ("window.universe", "Universe", "View > Other Displays > Universe", Some("Alt+7")), ("window.video", "Video", "Window > Video", Some("Cmd+9")), ("window.video_universe", "Video Universe", "Window > Video Universe", None), ("window.automation", "Automation", "Window > Automation", Some("Cmd+4")), ("window.color_palette", "Color Palette", "Window > Color Palette", None), ("window.disk_usage", "Disk Usage", "Window > Disk Usage", None), ("window.system_usage", "System Usage", "Window > System Usage", None), - ("window.task_manager", "Task Manager", "Window > Task Manager", None), + ("window.task_manager", "Task Manager", "Window > Task Manager", Some("Alt+'")), ("window.metadata", "Metadata Inspector", "Window > Metadata Inspector", None), - ("window.event_list", "MIDI Event List", "Window > MIDI Event List", None), - ("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", None), - ("window.workspace", "Workspace", "Window > New Workspace > Default", None), + ("window.event_list", "MIDI Event List", "Window > MIDI Event List", Some("Alt+=")), + ("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", Some("Shift+K")), + ("window.workspace", "Workspace", "Window > New Workspace > Default", Some("Alt+I")), ("window.workspace_presets", "Workspace", "Window > New Workspace > Track Presets", None), ("window.workspace_sounds", "Workspace", "Window > New Workspace > Soundbase", None), - ("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", None), + ("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", Some("Alt+J")), ("window.workspace_close", "Close All Workspaces", "Window > Workspaces > Close All Workspaces", None), - ("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", None), + ("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", Some("Cmd+Alt+J")), ("window.config_new", "New Configuration", "Window > Configurations > New Configuration...", None), ("window.config_update", "Update Active Configuration", "Window > Configurations > Update Active Configuration", None), ("window.config_auto_update", "Auto-Update Active Configuration", "Window > Configurations > Auto-Update Active Configuration", None), @@ -55,7 +55,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.midi_front", "MIDI Editors to Front", "Window > MIDI Editors > Bring to Front", None), ("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None), ("window.clip_effects", "Clip Effects", "Window > Clip Effects", None), - ("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", None), + ("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", Some("Alt+6")), ("window.search", "Search", "Window > Pro Tools Search", Some("Cmd+Ctrl+S")), ("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")), ("window.beat_detective", "Beat Detective", "", None), diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index e69d625..c79692c 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -300,12 +300,13 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { // 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| { + let arrows = !i.modifiers.alt && !i.modifiers.command && !i.modifiers.ctrl; ( i.modifiers.command && i.key_pressed(egui::Key::A), - i.key_pressed(egui::Key::ArrowUp), - i.key_pressed(egui::Key::ArrowDown), - i.key_pressed(egui::Key::ArrowLeft), - i.key_pressed(egui::Key::ArrowRight), + arrows && i.key_pressed(egui::Key::ArrowUp), + arrows && i.key_pressed(egui::Key::ArrowDown), + arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowLeft), + arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowRight), i.modifiers.shift, ) }); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..7a32a16 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -120,6 +120,16 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("markers.add", json!({})); true } + // Shift+=/- step the Grid value and Alt+Shift+=/- the Nudge value; macOS sends Alt+Shift+= as =, not +. + Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && app.ui.window == MainWindow::Edit => { + let step = if key == Key::Minus { -1 } else { 1 }; + let _ = app.run(if m.alt { "view.nudge" } else { "view.grid" }, json!({"step": step})); + true + } + Key::F12 if plain => { + let _ = app.run("transport.record", json!({})); + true + } Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => { let mode = match key { Key::F1 => "shuffle", @@ -147,11 +157,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("edit.nudge", json!({"direction": dir})); true } - Key::Tab if plain || m.alt => { + // Alt+Tab goes back; off macOS the desktop takes Alt+Tab, so Ctrl+Tab does. Cmd+Alt+Tab is Tab to Transient. + Key::Tab if plain || (m.alt && !m.command) || (!mac && m.ctrl && !m.alt) => { // Tab to next clip boundary (or transient when enabled). let s = app.engine.session(); let at = s.edit.selection.start; - let back = m.alt; + let back = !plain; if s.edit.tab_to_transient && !back && let Some(t) = s.edit.selected_tracks.first().copied() @@ -187,7 +198,15 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { true } // Commands Keyboard Focus: single-key editing (Edit window only). - k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true, + k if !m.command + && !m.alt + && !m.ctrl + && app.ui.window == MainWindow::Edit + && app.engine.session().edit.keyboard_focus == "commands" + && commands_focus(app, k, m.shift) => + { + true + } Key::R | Key::T if plain => { // Zoom out / in on the timeline (Commands Focus style). let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" }; @@ -199,13 +218,26 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } /// Single-key commands. Returns true when `key` was handled. -fn commands_focus(app: &mut SoundApp, key: Key) -> bool { +fn commands_focus(app: &mut SoundApp, key: Key, shift: bool) -> bool { let id = match key { + Key::Z if shift => "edit.redo", + Key::P if shift => "edit.extend_selection_up", + Key::Semicolon | Key::Colon if shift => "edit.extend_selection_down", + _ if shift => return false, Key::A => "edit.trim_start_to_insertion", Key::S => "edit.trim_end_to_insertion", Key::D => "edit.fade_to_start", Key::G => "edit.fade_to_end", Key::F => "edit.fades_create", + Key::Comma | Key::Period | Key::M | Key::Slash => { + let direction = if matches!(key, Key::Period | Key::Slash) { 1 } else { -1 }; + let _ = app.run("edit.nudge", json!({"direction": direction, "next": matches!(key, Key::M | Key::Slash)})); + return true; + } + Key::Num0 => "edit.timeline_to_selection", + Key::O => "edit.selection_to_timeline", + Key::OpenBracket => "transport.play_selection", + Key::CloseBracket => "edit.play_timeline", Key::B => "edit.separate", Key::X => "edit.cut", Key::C => "edit.copy", diff --git a/crates/ui-egui/tests/pro_tools_keys.rs b/crates/ui-egui/tests/pro_tools_keys.rs new file mode 100644 index 0000000..a6af215 --- /dev/null +++ b/crates/ui-egui/tests/pro_tools_keys.rs @@ -0,0 +1,234 @@ +//! Pro Tools keys for commands that had none: Grid and Nudge values, Commands Focus nudging and selection +//! keys, playlists, F12, Ctrl+Tab off macOS and the window keys. + +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; +use serde_json::json; +use soundcraft_engine::TransportRequest; +use soundcraft_model::ClipContent; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; +const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; +const OSES: [(OperatingSystem, Modifiers); 3] = + [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, PC_CTRL), (OperatingSystem::Nix, PC_CTRL)]; +const ALT_SHIFT: Modifiers = Modifiers { alt: true, shift: true, ..Modifiers::NONE }; + +fn keys(key: Key, modifiers: Modifiers) -> Vec { + let presses = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + [Event::ModifiersChanged(modifiers)].into_iter().chain(presses).collect() +} + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + ctx.run_ui(RawInput { events: keys(key, modifiers), ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +fn rig(os: OperatingSystem) -> (egui::Context, SoundApp) { + let ctx = egui::Context::default(); + ctx.set_os(os); + (ctx, SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default())) +} + +fn start(app: &SoundApp) -> i64 { + app.engine.session().edit.selection.start +} + +#[test] +fn shift_equals_and_minus_step_the_grid_value_and_with_alt_the_nudge_value() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.run("view.grid", json!({"value": "1/16"})).unwrap(); + app.run("view.nudge", json!({"value": "1/16"})).unwrap(); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 48_000, "end": 96_000})).unwrap(); + press(&ctx, &mut app, Key::Plus, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Minus, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Minus, Modifiers::SHIFT); + let up = if os == OperatingSystem::Mac { Key::Equals } else { Key::Plus }; + press(&ctx, &mut app, up, ALT_SHIFT); + press(&ctx, &mut app, up, ALT_SHIFT); + press(&ctx, &mut app, Key::Minus, ALT_SHIFT); + let edit = &app.engine.session().edit; + assert_eq!((edit.grid.label(), edit.nudge.label()), ("1/32 note".to_string(), "1/8 note".to_string()), "{os:?}"); + assert_eq!(start(&app), 48_000, "{os:?}"); + press(&ctx, &mut app, Key::Plus, Modifiers::NONE); + assert!(start(&app) > 48_000, "{os:?}"); + } +} + +#[test] +fn commands_focus_nudges_matches_the_selections_plays_and_redoes() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.run("options.keyboard_focus", json!({"focus": "commands"})).unwrap(); + app.run("view.nudge", json!({"value": {"samples": 100}})).unwrap(); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 48_000, "end": 96_000})).unwrap(); + let mut at = vec![]; + for key in [Key::Period, Key::Slash, Key::Comma, Key::M] { + press(&ctx, &mut app, key, Modifiers::NONE); + at.push(start(&app)); + } + assert_eq!(at, [48_100, 49_100, 49_000, 48_000], "{os:?}"); + press(&ctx, &mut app, Key::Z, Modifiers::NONE); + assert_eq!(start(&app), 49_000, "{os:?}"); + press(&ctx, &mut app, Key::Z, Modifiers::SHIFT); + assert_eq!(start(&app), 48_000, "{os:?}"); + + app.run("options.link_timeline_edit", json!({"value": false})).unwrap(); + press(&ctx, &mut app, Key::O, Modifiers::NONE); + let edit_selection = app.engine.session().edit.selection; + assert_eq!(app.engine.session().edit.timeline_selection, edit_selection, "{os:?}"); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 10})).unwrap(); + press(&ctx, &mut app, Key::Num0, Modifiers::NONE); + assert_eq!(app.engine.session().edit.selection, edit_selection, "{os:?}"); + + app.engine.transport_requests.clear(); + press(&ctx, &mut app, Key::OpenBracket, Modifiers::NONE); + press(&ctx, &mut app, Key::CloseBracket, Modifiers::NONE); + assert_eq!( + app.engine.transport_requests, + [TransportRequest::PlaySelection, TransportRequest::Locate(48_000), TransportRequest::Play], + "{os:?}" + ); + } +} + +#[test] +fn commands_focus_shift_p_and_semicolon_extend_the_selection_onto_shown_tracks() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.run("track.move_to_new_folder", json!({"tracks": ["Bass"], "name": "Low"})).unwrap(); + app.run("track.folder_toggle", json!({"track": "Low", "open": false})).unwrap(); + app.run("track.hide", json!({"tracks": ["Hats"], "hidden": true})).unwrap(); + app.run("options.keyboard_focus", json!({"focus": "commands"})).unwrap(); + app.run("edit.select", json!({"tracks": ["Snare"], "start": 48_000, "end": 96_000})).unwrap(); + press(&ctx, &mut app, Key::P, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Colon, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Semicolon, Modifiers::SHIFT); + let s = app.engine.session(); + let names: Vec<&str> = s.edit.selected_tracks.iter().map(|t| s.track(*t).unwrap().name.as_str()).collect(); + assert_eq!(names, ["Kick", "Snare", "Low", "Pad"], "{os:?}"); + assert_eq!((s.edit.selection.start, s.edit.selection.end), (48_000, 96_000), "{os:?}"); + } +} + +#[test] +fn alt_shift_and_shift_right_keys_work_the_playlists() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + let first = app.engine.session().track(kick).unwrap().clips()[0].range(); + let third = first.len() / 3; + let select = |app: &mut SoundApp, part: i64| { + let start = first.start + part * third; + app.run("edit.select", json!({"tracks": ["Kick"], "start": start, "end": start + third})).unwrap(); + }; + select(&mut app, 0); + let playlists = |app: &SoundApp| { + let t = app.engine.session().track(kick).unwrap(); + (t.playlists.iter().map(|p| p.clips.len()).collect::>(), t.active_playlist) + }; + let main = playlists(&app).0[0]; + + press(&ctx, &mut app, Key::ArrowDown, ALT_SHIFT); + assert_eq!(playlists(&app), (vec![main, 1], 1), "{os:?}: copied to a new playlist, now shown"); + press(&ctx, &mut app, Key::ArrowRight, Modifiers { shift: true, ..cmd }); + app.run("track.playlist_select", json!({"index": 0})).unwrap(); + select(&mut app, 1); + press(&ctx, &mut app, Key::ArrowUp, ALT_SHIFT); + assert_eq!(playlists(&app).0[1], 2, "{os:?}: copied to the target playlist"); + select(&mut app, 2); + press(&ctx, &mut app, Key::T, ALT_SHIFT); + assert_eq!(playlists(&app).0[1], 3, "{os:?}: moved to the target playlist"); + select(&mut app, 0); + press(&ctx, &mut app, Key::N, ALT_SHIFT); + assert_eq!(playlists(&app).0.len(), 3, "{os:?}: moved to a new playlist"); + press(&ctx, &mut app, Key::ArrowRight, Modifiers::SHIFT); + assert_eq!(playlists(&app).1, 1, "{os:?}: the target playlist is shown"); + } +} + +#[test] +fn f12_records_ctrl_tab_goes_back_off_macos_and_cmd_alt_tab_toggles_tab_to_transient() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + press(&ctx, &mut app, Key::F12, Modifiers::NONE); + assert_eq!(app.engine.transport_requests.last(), Some(&TransportRequest::Record), "{os:?}"); + + let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].range(); + let mid = clip.start + clip.len() / 2; + app.run("edit.select", json!({"tracks": ["Kick"], "start": mid, "end": mid})).unwrap(); + press(&ctx, &mut app, Key::Tab, Modifiers::NONE); + assert_eq!(start(&app), clip.end, "{os:?}"); + let mac = os == OperatingSystem::Mac; + press(&ctx, &mut app, Key::Tab, if mac { Modifiers::CTRL } else { cmd }); + assert_eq!(start(&app), if mac { clip.end } else { clip.start }, "{os:?}: Ctrl+Tab"); + app.run("edit.select", json!({"tracks": ["Kick"], "start": mid, "end": mid})).unwrap(); + press(&ctx, &mut app, Key::Tab, Modifiers::ALT); + assert_eq!(start(&app), clip.start, "{os:?}: Alt+Tab"); + + let transient = app.engine.session().edit.tab_to_transient; + let at = start(&app); + press(&ctx, &mut app, Key::Tab, Modifiers { alt: true, ..cmd }); + assert_eq!((app.engine.session().edit.tab_to_transient, start(&app)), (!transient, at), "{os:?}"); + } +} + +#[test] +fn window_keys_open_their_windows() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + for key in [Key::Num7, Key::Num6, Key::Equals, Key::Quote] { + press(&ctx, &mut app, key, Modifiers::ALT); + } + press(&ctx, &mut app, Key::K, Modifiers::SHIFT); + press(&ctx, &mut app, Key::J, Modifiers { alt: true, ..cmd }); + let ui = &app.ui; + assert!( + ui.show_universe && ui.show_clip_effects && ui.show_event_list && ui.show_task_manager && ui.show_midi_keyboard && ui.show_configurations, + "{os:?}" + ); + for key in [Key::I, Key::J] { + app.run("window.workspace_close", json!({})).unwrap(); + press(&ctx, &mut app, key, Modifiers::ALT); + assert!(app.ui.show_workspace, "{os:?}: Alt+{key:?}"); + } + } +} + +#[test] +fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut SoundApp, events: Vec| { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + for _ in 0..6 { + frame(&mut app, vec![]); + } + let body = ctx.data(|d| d.get_temp::(egui::Id::new("soundcraft-midi-body"))).unwrap(); + frame(&mut app, vec![Event::PointerMoved(body.center())]); + app.midi.selected = vec![0]; + let pitch = |app: &SoundApp| { + let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap(); + match &app.engine.session().find_clip(clip).unwrap().1.content { + ClipContent::Midi { sequence } => sequence.notes[0].pitch, + _ => panic!("not a MIDI clip"), + } + }; + let before = pitch(&app); + for modifiers in [ALT_SHIFT, Modifiers::ALT, MAC_CMD, Modifiers::CTRL] { + frame(&mut app, keys(Key::ArrowUp, modifiers)); + } + frame(&mut app, keys(Key::ArrowRight, Modifiers::SHIFT)); + assert_eq!(pitch(&app), before); + frame(&mut app, keys(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(pitch(&app), before + 1); +} From 921a36aa456ea2339cc025c4027d0d8e312a7685 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:31:17 +0100 Subject: [PATCH 117/137] Clicking a send's level opens its Send window with a fader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Pro Tools, clicking a send opens its Send window: the send's fader, pan, Mute and Pre/Post. SoundCraft turned the send's level readout into a text field instead, so there was no fader to grab. - A click on a send's level in the Mix window opens the Send window: the destination, Pre and Mute buttons, a pan knob, the fader (Alt- click for 0 dB, Cmd/Ctrl for fine moves, down to −∞) and the level, which takes a typed value. It stays open while you play. - One Send window at a time, as with Pro Tools' Target lit: clicking another send's level moves the window to that send, and clicking the same send again closes it. The send shows an outline while its window is open. Removing the send or its track closes the window. - Escape closes it too, unless the Escape is for something on top: an open menu, a level being typed, a dialog or the search. egui closes menus and drops text focus on Escape before the window is drawn, so that is read from the previous frame. - Dragging the level and Alt-click on it still work in the strip, and right-click still opens the options menu. - The send's level and the window's fader have accessibility labels ("Send A level", "Send level"). --- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/mix_window.rs | 117 +++++++++++++++++++++++----- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/tests/send_window.rs | 110 ++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/tests/send_window.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..34a569b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,9 @@ pub struct UiState { pub edit_views: Vec, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>, + /// The send in the Send window (track, slot). + #[serde(skip)] + pub send_window: Option<(TrackId, usize)>, pub audiosuite: Option, pub status: String, pub show_automation: bool, @@ -129,6 +132,7 @@ impl Default for UiState { mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], edit_views: vec!["color".into()], plugin_windows: Vec::new(), + send_window: None, audiosuite: None, status: String::new(), show_automation: false, diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index a00b30b..2bdc4b4 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -644,19 +644,23 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec regular(9.0), if s.mute { t.mute } else { t.text }, ); - let (level_resp, entered) = editable_readout( - ui, - level_r, - ui.id().with(("send-value", id.0, slot)), - &format!("{} dB", db_text(s.level_db)), - &format!("{:.1}", s.level_db), - parse_db, - 0.0, - ); - if let Some(db) = entered { - let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + let letter = (b'A' + slot as u8) as char; + let shown = app.ui.send_window == Some((id, slot)); + let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag()); + level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {letter} level"))); + if shown { + ui.painter().rect_stroke(level_r.shrink(0.5), 1.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } - if level_resp.as_ref().is_some_and(egui::Response::dragged) { + let level = format!("{} dB", db_text(s.level_db)); + ui.painter().with_clip_rect(level_r).text(level_r.center(), Align2::CENTER_CENTER, level, regular(9.0), t.counter_text); + if level_resp.clicked() { + if ui.input(|i| i.modifiers.alt) { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); + } else { + app.ui.send_window = (!shown).then_some((id, slot)); + } + } + if level_resp.dragged() { // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so // applying it to the already-updated value made trackpad adjustment accelerate. let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); @@ -664,14 +668,11 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } - if let Some(resp) = level_resp { - let _ = resp.clone().on_hover_text(format!( - "Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", - (b'A' + slot as u8) as char, - db_text(s.level_db) - )); - resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); - } + let _ = level_resp.clone().on_hover_text(format!( + "Send {letter}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", + db_text(s.level_db) + )); + level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); } None => { ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); @@ -703,6 +704,82 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec } } +/// The Send window, as in Pro Tools: one send's fader, level, pan, Mute and Pre-fader. Clicking a +/// send's level opens it, moves it to that send, or closes it, and so does Escape. +pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) { + // egui closes menus and drops text focus on Escape before this runs, so whether this Escape + // was theirs comes from the last frame. A dialog or the search on top takes it too. + let busy_id = egui::Id::new("send_window_escape"); + let busy = ctx.data(|d| d.get_temp(busy_id)).unwrap_or(false) || app.dialogs.open.is_some() || app.ui.show_search; + if !busy && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.send_window = None; + } + send_window_contents(app, ctx); + let busy = egui::Popup::is_any_open(ctx) || ctx.text_edit_focused(); + ctx.data_mut(|d| d.insert_temp(busy_id, busy)); +} + +fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { + let Some((id, slot)) = app.ui.send_window else { return }; + let Some((name, send)) = app.engine.session().track(id).and_then(|t| Some((t.name.clone(), t.mixer.sends.get(slot).cloned().flatten()?))) else { + app.ui.send_window = None; + return; + }; + let t = Tokens::current(); + let mut open = true; + egui::Window::new(format!("{name} · Send {}", (b'A' + slot as u8) as char)) + .id(egui::Id::new("send_window")) + .open(&mut open) + .auto_sized() + .collapsible(false) + .default_pos(ctx.pointer_latest_pos().map_or(pos2(200.0, 200.0), |p| p + vec2(24.0, -160.0))) + .show(ctx, |ui| { + ui.set_width(150.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(route_name(app, &send.target)).color(t.counter_text)); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.add_space(((ui.available_width() - 62.0) / 2.0).max(0.0)); + if text_toggle(ui, vec2(36.0, 18.0), "PRE", send.pre_fader, t.accent, "Pre-fader").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": !send.pre_fader})); + } + if text_toggle(ui, vec2(22.0, 18.0), "M", send.mute, t.mute, "Mute send").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": !send.mute})); + } + }); + let mut pan = send.pan; + pan_knob(ui, 32.0, &mut pan, "Send pan (drag; double-click centres)"); + if (pan - send.pan).abs() > f32::EPSILON { + let _ = app.engine.execute_merged( + "mix.send_level", + &json!({"track": id.0, "slot": slot, "pan": pan}), + &format!("send-pan:{}:{slot}", id.0), + ); + } + ui.label(egui::RichText::new(pan_text(send.pan)).color(t.counter_text).small()); + let (fr, fresp) = ui.allocate_exact_size(vec2(56.0, 220.0), Sense::hover()); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Send level")); + if let Some(db) = fader(ui, fr, send.level_db, ui.id().with("send_fader")) { + let _ = app.engine.execute_merged( + "mix.send_level", + &json!({"track": id.0, "slot": slot, "db": db}), + &format!("send:{}:{slot}", id.0), + ); + } + let (rr, _) = ui.allocate_exact_size(vec2(64.0, 15.0), Sense::hover()); + ui.painter().rect_filled(rr, 1.0, t.counter_bg); + let shown = db_text(send.level_db); + let (_, typed) = editable_readout(ui, rr, ui.id().with("send_level_value"), &shown, &format!("{:.1}", send.level_db), parse_db, 0.0); + if let Some(db) = typed { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + } + }); + }); + if !open { + app.ui.send_window = None; + } +} + /// Secondary controls live behind the amount readout, separate from the bus selector like Logic. fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) { ui.set_min_width(230.0); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..038bce6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -175,6 +175,7 @@ pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { transport_window(app, ctx); big_counter(app, ctx); plugin_windows(app, ctx); + crate::mix_window::send_window(app, ctx); about(app, ctx); session_info(app, ctx); } diff --git a/crates/ui-egui/tests/send_window.rs b/crates/ui-egui/tests/send_window.rs new file mode 100644 index 0000000..908885d --- /dev/null +++ b/crates/ui-egui/tests/send_window.rs @@ -0,0 +1,110 @@ +//! A send's level opens its Send window, as in Pro Tools: a fader with Alt-click for 0 dB, moved to +//! another send by clicking that send's level, closed by clicking the same one again. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness() -> (Harness<'static, SoundApp>, soundcraft_model::TrackId) { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"name": "Vox"})).unwrap(); + let vox = engine.session().tracks[0].id; + for (slot, bus) in [(0, "Verb"), (1, "Delay")] { + engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus, "level_db": -10.0})).unwrap(); + } + let mut app = SoundApp::new(engine, None, Services::default()); + app.ui.window = MainWindow::Mix; + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, vox) +} + +fn level(h: &Harness, slot: usize) -> f32 { + h.state().engine.session().tracks[0].mixer.sends[slot].as_ref().unwrap().level_db +} + +fn press(h: &mut Harness, at: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }); + h.step(); +} + +#[test] +fn a_sends_level_opens_its_send_window() { + let (mut h, vox) = harness(); + h.get_by_label("Send A level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 0))); + assert!(h.query_by_label_contains("Vox · Send A").is_some()); + + let fader = h.get_by_label("Send level").rect(); + h.hover_at(fader.center()); + press(&mut h, fader.center(), true, Modifiers::ALT); + press(&mut h, fader.center(), false, Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(level(&h, 0), 0.0, "Alt-click on the fader gives 0 dB"); + + press(&mut h, fader.center(), true, Modifiers::NONE); + for dy in [10.0, 60.0, 150.0, 300.0] { + h.hover_at(fader.center() + vec2(0.0, dy)); + h.step(); + } + press(&mut h, fader.center() + vec2(0.0, 300.0), false, Modifiers::NONE); + assert_eq!(level(&h, 0), -144.0, "the fader goes down to −∞"); + assert_eq!(level(&h, 1), -10.0); + + h.get_by_label("Send B level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 1)), "another send's level moves the window there"); + assert!(h.query_by_label_contains("Vox · Send B").is_some()); + h.get_by_label("Send B level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, None, "clicking the same send again closes it"); + assert!(h.query_by_label_contains("Vox · Send").is_none()); +} + +#[test] +fn escape_closes_the_send_window_after_menus_and_typing() { + let (mut h, vox) = harness(); + h.get_by_label("Send A level").click(); + h.run_steps(3); + + // A right-click menu on top takes the first Escape. + let at = h.get_by_label("Send A level").rect().center(); + h.hover_at(at); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.query_by_label("Pre-fader").is_some(), "the send's options menu is open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_label("Pre-fader").is_none()); + assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only closed the menu"); + + // So does typing a level in the window. + let fader = h.get_by_label("Send level").rect(); + let field = egui::pos2(fader.center().x, fader.max.y + 10.0); + h.hover_at(field); + press(&mut h, field, true, Modifiers::NONE); + press(&mut h, field, false, Modifiers::NONE); + h.run_steps(3); + assert!(h.state().ui.send_window.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only cancelled the typing"); + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, None); + assert_eq!(level(&h, 0), -10.0); +} From 8716dc90254cf62fe60526c99e6895aa5e97f4fe Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:53:23 +0000 Subject: [PATCH 118/137] Add Jason Brashear to the contributor names --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bc8bd31..3144370 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -40,3 +40,6 @@ sync_github_name = true real_name = "Vikram Bhaskaran" display_name = "Vikram Bhaskaran" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" From 34d8488284764a255ceb26d55f949506eae44b69 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:55:29 +0000 Subject: [PATCH 119/137] Keep the clip gain line where a clip is trimmed or separated --- crates/model/src/clip.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 4d30bea..322d375 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -215,7 +215,12 @@ impl Clip { self.length -= delta; self.start = new_start; self.clamp_fades(); - self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect(); + // Keep the gain at the cut when earlier breakpoints are dropped. + if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) { + self.gain_env.insert(0, (delta, env_value(&self.gain_env, delta))); + } + self.gain_env.retain(|&(t, _)| t >= delta); + self.gain_env.iter_mut().for_each(|(t, _)| *t = t.saturating_sub(delta)); } /// Move the end edge to `new_end`. @@ -282,6 +287,27 @@ mod tests { assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4); } + #[test] + fn trim_start_keeps_gain_envelope_at_cut() { + for (env, cut, expected) in [ + (vec![(0, 0.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![(0, 0.0), (100, -20.0)], 500, vec![(0, -20.0)]), + (vec![(0, 0.0), (500, -10.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![], 500, vec![]), + (vec![(100, -3.0)], 0, vec![(100, -3.0)]), + (vec![(100, -3.0)], -100, vec![(200, -3.0)]), + ] { + let mut clip = Clip::audio(ClipId(1), "a", SourceId(1), 100, 0, 1000); + clip.gain_env = env; + let gain_at_cut = clip.gain_at(cut); + clip.trim_start_to(cut); + assert_eq!(clip.gain_env, expected); + if cut > 0 { + assert!((clip.gain_at(0) - gain_at_cut).abs() < 1e-6); + } + } + } + #[test] fn trims_keep_material_anchored() { let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000); From c39773b62d7d2fb366e62b6ff3854c92db9415c1 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:53:01 +0000 Subject: [PATCH 120/137] Keep automation thinning within its tolerance --- crates/model/src/automation.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs index c4ee449..3a68b04 100644 --- a/crates/model/src/automation.rs +++ b/crates/model/src/automation.rs @@ -204,21 +204,28 @@ impl AutomationLane { /// Thin: remove points whose removal changes the curve by less than `tolerance`. pub fn thin(&mut self, tolerance: f32) -> usize { - if self.points.len() < 3 { + if self.points.len() < 3 || tolerance.is_nan() { return 0; } + let tolerance = f64::from(tolerance); let mut keep = vec![true; self.points.len()]; let mut last = 0usize; + // Slopes from the last kept point that keep every point removed since then within `tolerance`. + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); for i in 1..self.points.len() - 1 { let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue }; - let span = (c.at - a.at) as f32; - let interp = if span > 0.0 { a.value + (c.value - a.value) * ((b.at - a.at) as f32 / span) } else { a.value }; - if (interp - b.value).abs() <= tolerance { + let dt = b.at.saturating_sub(a.at) as f64; + let rise = f64::from(b.value) - f64::from(a.value); + lo = lo.max((rise - tolerance) / dt); + hi = hi.min((rise + tolerance) / dt); + let slope = (f64::from(c.value) - f64::from(a.value)) / c.at.saturating_sub(a.at) as f64; + if (lo..=hi).contains(&slope) { if let Some(k) = keep.get_mut(i) { *k = false; } } else { last = i; + (lo, hi) = (f64::NEG_INFINITY, f64::INFINITY); } } let before = self.points.len(); @@ -279,6 +286,23 @@ mod tests { assert_eq!(l.points.len(), 2); } + #[test] + fn thin_keeps_removed_points_within_tolerance() { + for direction in [1.0, -1.0] { + let mut lane = AutomationLane::new(AutoParam::Volume); + for (at, value) in [(0, 0.0), (100, 0.0), (200, 0.0), (300, 0.12), (400, 0.26)] { + lane.set_point(at, value * direction); + } + let original = lane.points.clone(); + let tolerance = 0.1; + assert!(lane.thin(tolerance) > 0); + for point in original { + let error = (lane.value_at(point.at, 0.0) - point.value).abs(); + assert!(error <= tolerance, "at {}: error {error} exceeds {tolerance}", point.at); + } + } + } + #[test] fn param_ids() { assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1))); From d6ea8cf00134a1dae70d84f67ecf0c408a2f0d21 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Mon, 12 Oct 2026 22:43:33 +0200 Subject: [PATCH 121/137] fix(ui-egui): own the test palette lock for every SoundApp lifetime Parallel test runs could flake: theme::LIGHT_THEME is process-wide, and the edit_window harness tests ran whole frames without holding lock_test_palette(), so a theme test could flip the palette between a harness test painting rows and looking them up by colour (and a harness frame could reset it to dark mid-theme-test). Make the test palette lock re-entrant per thread and take it inside SoundApp::new (test builds only), so every test that builds an app owns the palette for the app's whole lifetime. Adds a regression test that the lock is re-entrant on the owning thread and excludes others. Fixes #323 --- crates/ui-egui/src/lib.rs | 6 +++ crates/ui-egui/src/theme.rs | 81 +++++++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..3dac122 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -270,6 +270,10 @@ pub struct SoundApp { last_meter_poll: f64, /// Movies for the Video window and Video track (decoded in the background). pub video: video_track::VideoPool, + /// Test-only: owns the process-wide palette lock while this app exists, so frames painted + /// and colour assertions in one test can never straddle another test's palette change. + #[cfg(test)] + _palette_lock: theme::TestPaletteGuard, } /// Whether another automation-write pass is due: the transport moved at least @@ -326,6 +330,8 @@ impl SoundApp { last_frame: None, last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), + #[cfg(test)] + _palette_lock: theme::lock_test_palette(), } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c667e6f..bb23cda 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -6,11 +6,64 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// Whether the Light palette is active. Dark is the default (the incumbent is dark-only). static LIGHT_THEME: AtomicBool = AtomicBool::new(false); -/// Unit tests that change the process-wide palette must own it until their assertions finish. +/// Unit tests that change or read the process-wide palette must own it until their assertions +/// finish. The lock is re-entrant per thread, so a test may hold it and still build apps +/// (`SoundApp::new` takes it too) without deadlocking itself. #[cfg(test)] -pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> { - static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(()); - PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +pub(crate) fn lock_test_palette() -> TestPaletteGuard { + TestPaletteGuard::acquire() +} + +/// RAII handle for the re-entrant, per-thread test palette lock; see [`lock_test_palette`]. +#[cfg(test)] +pub(crate) struct TestPaletteGuard; + +#[cfg(test)] +impl TestPaletteGuard { + fn acquire() -> Self { + palette_lock::acquire(); + Self + } +} + +#[cfg(test)] +impl Drop for TestPaletteGuard { + fn drop(&mut self) { + palette_lock::release(); + } +} + +#[cfg(test)] +mod palette_lock { + use std::cell::{Cell, RefCell}; + use std::sync::{Mutex, MutexGuard, PoisonError}; + + static PALETTE: Mutex<()> = Mutex::new(()); + + thread_local! { + /// How many guards this thread holds; the OS lock is taken on 0→1 and released on 1→0. + static DEPTH: Cell = const { Cell::new(0) }; + static GUARD: RefCell>> = const { RefCell::new(None) }; + } + + pub(super) fn acquire() { + DEPTH.with(|depth| { + if depth.get() == 0 { + GUARD.with(|guard| *guard.borrow_mut() = Some(PALETTE.lock().unwrap_or_else(PoisonError::into_inner))); + } + depth.set(depth.get() + 1); + }); + } + + pub(super) fn release() { + DEPTH.with(|depth| { + let left = depth.get().saturating_sub(1); + depth.set(left); + if left == 0 { + GUARD.with(|guard| *guard.borrow_mut() = None); + } + }); + } } /// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in. @@ -415,6 +468,26 @@ mod tests { use crate::{Services, SoundApp, UiState}; use serde_json::json; + #[test] + fn test_palette_lock_is_reentrant_and_excludes_other_threads() { + // Regression for the parallel-run flakes: the process-wide palette must be owned for a + // whole test, re-entrant on the owning thread (so `SoundApp::new` can take it too), and + // exclusive against other threads (so a frame cannot flip it mid-test). + let outer = lock_test_palette(); + let inner = lock_test_palette(); + let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _held = lock_test_palette(); + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "another thread must wait for the palette lock"); + drop(inner); + drop(outer); + drop(app); + assert!(rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(), "the palette lock must be released when every holder drops it"); + } + #[test] fn dark_is_the_default() { assert_eq!(ThemeMode::default(), ThemeMode::Dark); From 7a62ab3be098347aee690774b29f011e150ab5cc Mon Sep 17 00:00:00 2001 From: Sajidur Rahman Date: Tue, 13 Oct 2026 02:44:59 +0600 Subject: [PATCH 122/137] =?UTF-8?q?Add=20Clip=20=E2=80=BA=20New=20MIDI=20C?= =?UTF-8?q?lip=20menu=20entry=20and=20command=20reachability=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit midi.new_clip had no UI path, so an empty instrument/MIDI track could never get a MIDI clip (#282). The menu bar is built from the catalog, so the entry is added to menus::tree() and placed first in Clip. The new test checks every mutating command is reachable from the GUI (built menu tree, shortcut, or UI reference), with today's gaps listed. Closes #282 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/midi.rs | 2 +- crates/ui-egui/src/menus.rs | 6 + crates/ui-egui/tests/command_reachability.rs | 110 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/command_reachability.rs diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index fe9fdc2..c442ecb 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { cmd!( "midi.new_clip", "New MIDI Clip", - [], + ["Clip"], None, "{track, start?, end?|length?} (default: the edit selection, or 1 bar)", has_tracks, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..87b1e22 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -200,6 +200,12 @@ pub fn tree() -> Vec { insert_path(&mut roots, line); } insert_path(&mut roots, "Window > Reset Panel Layout"); + // Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282). + // It creates a clip rather than acting on the selected ones, so it leads the Clip menu. + insert_path(&mut roots, "Clip > New MIDI Clip"); + if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") { + clip.children.rotate_right(1); + } for (path, _) in MENU_WINDOWS { insert_path(&mut roots, path); } diff --git a/crates/ui-egui/tests/command_reachability.rs b/crates/ui-egui/tests/command_reachability.rs new file mode 100644 index 0000000..5768ed0 --- /dev/null +++ b/crates/ui-egui/tests/command_reachability.rs @@ -0,0 +1,110 @@ +//! Every user-facing engine command must be reachable from the GUI: through a menu, a shortcut, +//! or a widget that names it in `src/`. Commands that only work from the control channel / CLI / +//! MCP left users stuck (#282: `midi.new_clip` had no UI path, so an empty instrument track could +//! never get a MIDI clip). + +use soundcraft_engine::{catalog, cmd::command_specs}; +use soundcraft_ui_egui::menus::{self, MenuNode}; +use std::collections::HashSet; +use std::path::Path; + +/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today. +/// Give each one a UI path and remove it from this list; don't add to it. +const KNOWN_UNREACHED: &[&str] = &[ + "automation.clear", + "edit.paste_clip_effects", + "edit.paste_clip_gain", + "event.midi_note", + "file.bounce_stems", + "markers.next", + "markers.previous", + "mix.clear_mutes", + "mix.clear_solos", + "mix.insert_move", + "mix.instrument_param", + "mix.phase_invert", + "mix.sampler_clear", + "mix.sampler_load", + "mix.send_surround_pan", + "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.elastic", + "track.load_preset", + "track.pin", + "track.timebase", + "track.trim_automation_write", + "track.vca", + "transport.pause", + "transport.status", + "video.offset", + "view.sub_counter", +]; + +fn ui_source() -> String { + fn walk(dir: &Path, out: &mut String) { + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + walk(&p, out); + } else if p.extension().is_some_and(|e| e == "rs") { + out.push_str(&std::fs::read_to_string(&p).unwrap()); + } + } + } + let mut s = String::new(); + walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut s); + s +} + +/// Command ids behind the leaves of the menu bar actually built by the app. A spec's own `menu` +/// field is not enough: the bar is built from the catalog, so an off-catalog path never shows. +fn menu_commands() -> HashSet { + fn walk(nodes: &[MenuNode], extra: &[(&str, &str)], out: &mut HashSet) { + for n in nodes { + if n.children.is_empty() { + out.extend(catalog::implemented_by(&n.path, extra)); + } + walk(&n.children, extra, out); + } + } + let mut out = HashSet::new(); + walk(&menus::tree(), &menus::ui_aliases(), &mut out); + out +} + +#[test] +fn every_command_has_a_ui_path() { + let src = ui_source(); + let in_menus = menu_commands(); + let mut unreached = Vec::new(); + let mut stale = Vec::new(); + for c in command_specs() { + // Queries and view-only commands are for agents / the UI's own reads. + if !c.journal || c.view { + continue; + } + let reached = in_menus.contains(c.id) || c.shortcut.is_some() || src.contains(&format!("\"{}\"", c.id)); + let known = KNOWN_UNREACHED.contains(&c.id); + if !reached && !known { + unreached.push(c.id); + } else if reached && known { + stale.push(c.id); + } + } + assert!(unreached.is_empty(), "commands with no menu, shortcut or UI reference: {unreached:?}"); + assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}"); +} + +#[test] +fn new_midi_clip_is_in_the_clip_menu() { + let extra = menus::ui_aliases(); + assert_eq!(catalog::implemented_by("Clip > New MIDI Clip", &extra).as_deref(), Some("midi.new_clip")); + assert!(menu_commands().contains("midi.new_clip")); + let tree = menus::tree(); + let clip = tree.iter().find(|n| n.label == "Clip").unwrap(); + assert_eq!(clip.children[0].label, "New MIDI Clip", "creates rather than edits, so it comes first"); +} From d26d6fec77e81be2e4775a3f1c8b81416ba9b4c3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:31:41 +0100 Subject: [PATCH 123/137] Lay out Edit window tracks and plugin slots like Pro Tools Measured from the user's Pro Tools screenshots: - Track headers are 136 points wide (were 236). A 14-point colour strip holds an options button (a folder's open/close) and, at the bottom, a key that shows or hides volume automation. The name is bold beside a playlist menu and highlighted when the track is selected (the blue outline that marked a selected tinted header is gone). Then come 16-point record, input, solo and mute keys (record stays dark red when off, and a mute implied by another track's solo shows dark orange); the track view; freeze, voice (dyn, or off to make the track inactive) and automation mode; timebase and the Elastic Audio algorithm (an instrument track's instrument); and a slim meter down the right edge. Rows drop away on shorter tracks. - The vol/pan and inserts summaries and the AUX/INST labels are gone: the I/O and insert columns show them. - The I/O column has input and output selectors, a fader key that shows the track in the Mix window, and a black vol/pan readout. - Insert, send and instrument columns are 75 points wide on a neutral well, with 15-point cells every 18 points. An empty insert shows a dot, an empty send its letter. Tracks too short for three cells show a row of squares, with arrows for sends. - A used slot is a rounded, raised grey button: its dot or letter, a groove, the name centred in 9.5-point bold, and for sends a small level box (the level in dB is in the tooltip; the box still opens the Send window or drags the level). A bypassed insert turns blue. The Mix window's slots share this drawing; filled ones don't brighten on hover, which would cost the name its contrast. - New slot_well, slot_empty, slot_face, slot_bypassed and slot_mark colours replace insert_on, insert_bypass, send_on and slot_border. mix_instrument_contrast now reads the slot's painted gradient, and finds the empty instrument slot by its cell, which shows a dot rather than "no instrument". - The header's record, input, solo and mute keys carry accessibility labels. Part of #138. --- crates/ui-egui/src/edit_window.rs | 592 ++++++++++++------ crates/ui-egui/src/mix_window.rs | 137 ++-- crates/ui-egui/src/theme.rs | 33 +- crates/ui-egui/src/widgets.rs | 10 + crates/ui-egui/tests/edit_io_readout.rs | 61 ++ ...ndow_plugins.rs => edit_window_headers.rs} | 64 +- .../ui-egui/tests/mix_instrument_contrast.rs | 60 +- 7 files changed, 671 insertions(+), 286 deletions(-) create mode 100644 crates/ui-egui/tests/edit_io_readout.rs rename crates/ui-egui/tests/{edit_window_plugins.rs => edit_window_headers.rs} (62%) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ee2ae8..9b1caeb 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1,29 +1,34 @@ //! The Edit window: toolbar, rulers, track headers and playlists. use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; -use crate::widgets::{rec_toggle, selector_box, text_toggle}; -use crate::{Gesture, SoundApp, icons, toolbar}; +use crate::widgets::{raised_key, shade}; +use crate::{Gesture, SoundApp, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position}; -pub const HEADER_W: f32 = 236.0; +pub const HEADER_W: f32 = 136.0; pub const COLUMN_W: f32 = 112.0; +/// Insert, send and instrument columns: Pro Tools' narrow stacks of slots. +const PLUGIN_COLUMN_W: f32 = 75.0; +const IO_COLUMN_W: f32 = 94.0; +/// The track colour strip at the left of each track header. +const STRIP_W: f32 = 14.0; /// The row under the rulers that holds the column titles, when any column is shown. const COLUMN_TITLES_H: f32 = 16.0; -/// An optional Edit-window column: view id, menu label and title. -type Column = (&'static str, &'static str, &'static str); +/// An optional Edit-window column: view id, menu label, title and width. +type Column = (&'static str, &'static str, &'static str, f32); const COLUMNS: [Column; 7] = [ - ("instruments", "Instruments", "INSTRUMENT"), - ("inserts_ae", "Inserts A-E", "INSERTS A-E"), - ("inserts_fj", "Inserts F-J", "INSERTS F-J"), - ("sends_ae", "Sends A-E", "SENDS A-E"), - ("sends_fj", "Sends F-J", "SENDS F-J"), - ("io", "I/O", "I/O"), - ("comments", "Comments", "COMMENTS"), + ("instruments", "Instruments", "INSTRUMENT", PLUGIN_COLUMN_W), + ("inserts_ae", "Inserts A-E", "INSERTS A-E", PLUGIN_COLUMN_W), + ("inserts_fj", "Inserts F-J", "INSERTS F-J", PLUGIN_COLUMN_W), + ("sends_ae", "Sends A-E", "SENDS A-E", PLUGIN_COLUMN_W), + ("sends_fj", "Sends F-J", "SENDS F-J", PLUGIN_COLUMN_W), + ("io", "I/O", "I/O", IO_COLUMN_W), + ("comments", "Comments", "COMMENTS", COLUMN_W), ]; /// Optional Edit-window columns (View › Edit Window Views) that are switched on, left to right. @@ -33,7 +38,7 @@ pub fn edit_columns(s: &Session) -> Vec { } pub fn header_width(s: &Session) -> f32 { - HEADER_W + COLUMN_W * edit_columns(s).len() as f32 + HEADER_W + edit_columns(s).iter().map(|c| c.3).sum::() } pub const RULER_H: f32 = 17.0; @@ -697,16 +702,16 @@ fn column_titles(ui: &Ui, rulers: Rect, cols: &[Column]) { let t = Tokens::current(); let line = Stroke::new(1.0, t.border); let mut x = rulers.min.x + HEADER_W; - let end = x + cols.len() as f32 * COLUMN_W; + let end = x + cols.iter().map(|c| c.3).sum::(); let row = Rect::from_min_max(pos2(x, rulers.max.y - COLUMN_TITLES_H - 3.0), pos2(end, rulers.max.y)); ui.painter().rect_filled(row, 0.0, t.panel_bg2); ui.painter().hline(rulers.min.x..=end, row.min.y + 0.5, line); ui.painter().hline(rulers.min.x..=end, row.max.y - 0.5, line); ui.painter().vline(end - 0.5, row.y_range(), line); - for &(_, _, title) in cols { + for &(_, _, title, w) in cols { ui.painter().vline(x + 0.5, row.y_range(), line); - ui.painter().text(pos2(x + COLUMN_W / 2.0, row.center().y), Align2::CENTER_CENTER, title, bold(10.0), t.section_label); - x += COLUMN_W; + ui.painter().text(pos2(x + w / 2.0, row.center().y), Align2::CENTER_CENTER, title, bold(10.0), t.section_label); + x += w; } } @@ -714,12 +719,12 @@ fn column_titles(ui: &Ui, rulers: Rect, cols: &[Column]) { fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) { let r = Rect::from_min_size(rulers.min + vec2(6.0, 5.0), vec2(50.0, 18.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); - let resp = selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text); + let resp = crate::widgets::selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, child.is_enabled(), "Views")); let resp = resp.on_hover_text("Columns beside the track headers: inserts, sends, I/O…"); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let shown = edit_columns(app.engine.session()); - for (id, label, _) in COLUMNS { + for (id, label, _, _) in COLUMNS { if ui.add(egui::Button::new(label).selected(shown.iter().any(|c| c.0 == id))).clicked() { let _ = app.run(&format!("view.edit_{id}"), json!({})); } @@ -742,22 +747,19 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) let Some(track) = s.track(id).cloned() else { return }; let selected = s.edit.selected_tracks.contains(&id); let head = Rect::from_min_max(row.min, pos2(row.min.x + HEADER_W, row.max.y)); - let cols = edit_columns(app.engine.session()); - for (i, c) in cols.iter().enumerate() { - let cr = Rect::from_min_size(pos2(head.max.x + i as f32 * COLUMN_W, row.min.y), vec2(COLUMN_W, row.height())); - header_column(app, ui, &track, c.0, cr); + let (head_bg, lane_bg) = crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint); + let mut x = head.max.x; + for (col, _, _, w) in edit_columns(app.engine.session()) { + header_column(app, ui, &track, col, Rect::from_min_size(pos2(x, row.min.y), vec2(w, row.height())), head_bg); + x += w; } let main_h = track.height.points(); let lane = lane_rect(&track, row, tl); - let (head_bg, lane_bg) = crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint); ui.painter().rect_filled(head, 0.0, head_bg); - ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); + ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(STRIP_W, head.height())), 0.0, rgb(track.color)); ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border)); - ui.painter().hline(head.min.x + 5.0..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light)); - if selected && app.ui.track_tint > 0.0 { - ui.painter().rect_stroke(head.with_min_x(head.min.x + 5.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } - track_header(app, ui, &track, head, selected); + ui.painter().hline(head.min.x + STRIP_W..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light)); + track_header(app, ui, &track, head, selected, head_bg); // Lane. ui.painter().vline(tl.min.x - 0.5, row.y_range(), Stroke::new(1.0, t.border)); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); @@ -834,7 +836,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai } } -fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) { +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). @@ -852,7 +854,6 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let _ = app.run("track.height", json!({"tracks": [id.0], "height": best.label()})); } } - let x0 = head.min.x + 12.0; let h = head.height(); // Click on header background selects the track. let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click()); @@ -860,22 +861,55 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } bg.context_menu(|ui| track_context_menu(app, ui, id)); - // Name field. - let name_r = Rect::from_min_size(pos2(x0, head.min.y + 4.0), vec2(150.0, 17.0)); - ui.painter().rect( - name_r, - CornerRadius::same(2), - if selected { t.name_field_sel } else { t.name_field }, - Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), - StrokeKind::Inside, - ); + 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); + // 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.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 })); + let open = !track.is_folder() || track.folder_open; + arrow(ui.painter(), round.center(), open, shade(strip, -0.8)); + if track.is_folder() { + if rresp.on_hover_text(if track.folder_open { "Close folder" } else { "Open folder" }).clicked() { + let _ = app.run("track.folder_toggle", json!({"track": id.0})); + } + } else { + egui::Popup::menu(&rresp.on_hover_text("Track options")).show(|ui| track_context_menu(app, ui, id)); + } + if h >= 40.0 && track.kind.has_playlist() { + let ar = at(3.0, h - 12.0, 9.0, 9.0); + let aresp = ui.interact(ar, ui.id().with(("auto_lane", id.0)), Sense::click()); + let showing = AutoParam::parse(&track.view).is_some(); + ui.painter().rect_filled(ar, 1.5, shade(strip, if aresp.hovered() || showing { 0.6 } else { 0.45 })); + let ink = shade(strip, -0.8); + let (a, b) = (ar.min + vec2(2.5, 2.5), ar.max - vec2(2.5, 2.5)); + ui.painter().line_segment([a, b], Stroke::new(1.0, ink)); + ui.painter().circle_filled(a, 1.3, ink); + ui.painter().circle_filled(b, 1.3, ink); + if aresp.on_hover_text(if showing { "Hide automation" } else { "Show volume automation" }).clicked() { + let view = if !showing { + "volume" + } else if track.kind.is_midi() { + "notes" + } else { + "waveform" + }; + let _ = app.run("track.view", json!({"track": id.0, "view": view})); + } + } + // Name, highlighted when the track is selected, and the playlist menu beside it. + let name_r = at(16.0, 1.5, 97.0, 15.0); + if selected { + ui.painter().rect_filled(name_r, 2.0, t.name_field_sel); + } let shown_name = if app.engine.session().edit.flag("edit_view.track_number") { let n = app.engine.session().track_index(id).map_or(0, |i| i + 1); format!("{n} {}", track.name) } else { track.name.clone() }; - ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &shown_name, bold(12.0), t.text_dark); + let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) }; + ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color); let nresp = ui.interact(name_r, ui.id().with(("name", id.0)), Sense::click_and_drag()); if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); @@ -913,10 +947,11 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let _ = app.run("track.move", json!({"track": id.0, "to": to})); } } - // Playlist selector arrow. - let pl_r = Rect::from_min_size(pos2(name_r.max.x + 3.0, name_r.min.y), vec2(16.0, 17.0)); - icons::draw(ui.painter(), pl_r.shrink(3.0), "triangle_down", t.text); + let pl_r = at(114.0, 1.0, 11.0, 14.5); let presp = ui.interact(pl_r, ui.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 })); + arrow(ui.painter(), pos2(pl_r.center().x, pl_r.min.y + 5.75), true, t.text); + let presp = presp.on_hover_text("Playlists"); egui::Popup::menu(&presp).show(|ui| { if ui.button("New...").clicked() { let _ = app.run("track.playlist_new", json!({"track": id.0})); @@ -934,113 +969,228 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } } }); - if track.is_folder() { - let fr = Rect::from_min_size(pos2(head.min.x + 6.0, name_r.min.y), vec2(10.0, 17.0)); - icons::draw(ui.painter(), fr, if track.folder_open { "triangle_down" } else { "triangle_right" }, t.text); - if ui.interact(fr, ui.id().with(("fold", id.0)), Sense::click()).clicked() { - let _ = app.run("track.folder_toggle", json!({"track": id.0})); - } - } - let kind_label = match track.kind { - TrackKind::Audio => "", - TrackKind::Aux => "AUX", - TrackKind::Master => "MASTER", - TrackKind::Midi => "MIDI", - TrackKind::Instrument => "INST", - TrackKind::Vca => "VCA", - TrackKind::Folder => "FOLDER", - TrackKind::Video => "VIDEO", - }; - if !kind_label.is_empty() { - let kr = ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim); - if track.kind == TrackKind::Instrument { - // The instrument picker (and the instrument's editor) behind the INST label. - let name = track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("none", |p| p.name); - let resp = ui - .interact(kr.expand(3.0), ui.id().with(("instrument", id.0)), Sense::click()) - .on_hover_text(format!("Instrument: {name} (click to change or open its editor)")); - egui::Popup::menu(&resp).show(|ui| crate::mix_window::instrument_menu(app, ui, id)); - } - } - if h < 30.0 { - return; - } - // Buttons row. - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_size(pos2(x0, name_r.max.y + 5.0), vec2(170.0, 20.0))) - .layout(egui::Layout::left_to_right(egui::Align::Min)), - ); - child.spacing_mut().item_spacing.x = 4.0; let m = &track.mixer; - let bs = vec2(26.0, 18.0); - if track.kind.has_playlist() { - if rec_toggle(&mut child, bs, m.record_arm, "Record enable").clicked() { - let _ = app.run("mix.record_arm", json!({"track": id.0})); - } - if text_toggle(&mut child, bs, "I", m.input_monitor, t.input, "Input monitoring").clicked() { - let _ = app.run("mix.input_monitor", json!({"track": id.0})); - } - } else { - child.add_space(bs.x * 2.0 + 4.0); - } - if track.kind != TrackKind::Master { - if text_toggle(&mut child, bs, "S", m.solo, t.solo, "Solo").clicked() { - let _ = app.run("mix.solo", json!({"track": id.0})); - } - if text_toggle(&mut child, bs, "M", m.mute, t.mute, "Mute").clicked() { - let _ = app.run("mix.mute", json!({"track": id.0})); - } - } - if h < 60.0 { - return; - } - // View selector. - let view_r = Rect::from_min_size(pos2(x0, head.min.y + 49.0), vec2(118.0, 16.0)); - let mut c2 = ui.new_child(egui::UiBuilder::new().max_rect(view_r)); - let vresp = selector_box(&mut c2, view_r.width(), view_r.height(), &view_label(&track.view), t.text); - egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); - // Automation mode. - let am_r = Rect::from_min_size(pos2(x0, head.min.y + 68.0), vec2(118.0, 16.0)); - let mode = m.automation_mode; - let col = match mode { - soundcraft_model::AutomationMode::Read => t.auto_read, - soundcraft_model::AutomationMode::Write => t.auto_write, - soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch, - soundcraft_model::AutomationMode::Latch => t.auto_latch, - _ => t.text_dim, - }; - let mut c3 = ui.new_child(egui::UiBuilder::new().max_rect(am_r)); - let aresp = selector_box(&mut c3, am_r.width(), am_r.height(), mode.label(), col); - egui::Popup::menu(&aresp).show(|ui| { - for m in soundcraft_model::AutomationMode::ALL { - if ui.selectable_label(m == mode, m.label()).clicked() { - let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()})); + if h >= 37.0 { + let key = |i: usize| at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0); + if track.kind.has_playlist() { + if header_toggle(ui, key(0), id, HeaderKey::Rec, m.record_arm, false).clicked() { + let _ = app.run("mix.record_arm", json!({"track": id.0})); + } + if header_toggle(ui, key(1), id, HeaderKey::Input, m.input_monitor, false).clicked() { + let _ = app.run("mix.input_monitor", json!({"track": id.0})); + } + } + if track.kind != TrackKind::Master { + if header_toggle(ui, key(2), id, HeaderKey::Solo, m.solo, false).clicked() { + let _ = app.run("mix.solo", json!({"track": id.0})); + } + let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo); + if header_toggle(ui, key(3), id, HeaderKey::Mute, m.mute, implied).clicked() { + let _ = app.run("mix.mute", json!({"track": id.0})); } } - }); - // Inline header meter. - let md = app.meters.get(&id).copied().unwrap_or_default(); - let mr = Rect::from_min_max(pos2(head.max.x - 16.0, head.min.y + 4.0), pos2(head.max.x - 6.0, head.max.y - 4.0)); - if track.channels() >= 2 { - let w = mr.width() / 2.0 - 0.5; - crate::widgets::meter(ui, Rect::from_min_size(mr.min, vec2(w, mr.height())), md.level[0], md.hold[0], md.clip); - crate::widgets::meter(ui, Rect::from_min_size(pos2(mr.min.x + w + 1.0, mr.min.y), vec2(w, mr.height())), md.level[1], md.hold[1], md.clip); - } else { - crate::widgets::meter(ui, mr, md.level[0], md.hold[0], md.clip); } - // Volume readout and inserts summary. - let info = format!("vol {} pan {}", crate::widgets::db_text(m.volume_db), m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p))); - ui.painter().text(pos2(view_r.max.x + 8.0, view_r.center().y), Align2::LEFT_CENTER, info, regular(10.0), t.text_dim); - let ins: Vec<&str> = m.inserts.iter().flatten().filter_map(|i| soundcraft_dsp::plugin_info(&i.plugin).map(|p| p.short_name)).collect(); - if !ins.is_empty() { - ui.painter().with_clip_rect(Rect::from_min_max(pos2(am_r.max.x + 6.0, am_r.min.y), pos2(head.max.x - 20.0, am_r.max.y))).text( - pos2(am_r.max.x + 8.0, am_r.center().y), - Align2::LEFT_CENTER, - ins.join(" · "), - regular(10.0), - t.text_dim, - ); + if h >= 58.0 { + let vresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.id().with(("view", id.0)), &view_label(&track.view), t.text, true); + egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); + } + 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); + let resp = ui.interact(r, ui.id().with(("freeze", id.0)), Sense::click()); + raised_key(ui.painter(), r, if track.frozen { t.slot_bypassed } else { t.slot_face }, resp.hovered()); + for a in [0.0f32, 60.0, 120.0] { + let d = vec2(a.to_radians().sin(), a.to_radians().cos()) * 3.5; + ui.painter().line_segment([r.center() - d, r.center() + d], Stroke::new(1.5, t.text)); + } + if resp.on_hover_text(if track.frozen { "Unfreeze" } else { "Freeze" }).clicked() { + let _ = app.run("track.freeze", json!({"tracks": [id.0]})); + } + } + if !matches!(track.kind, TrackKind::Master | TrackKind::Vca | TrackKind::Folder) { + let (voice, color) = if track.inactive { ("off", t.text_dim) } else { ("dyn", t.text) }; + let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.id().with(("voice", id.0)), voice, color, false); + egui::Popup::menu(&resp.on_hover_text("Voice: dyn plays the track, off makes it inactive")).show(|ui| { + for (label, inactive) in [("dyn", false), ("off", true)] { + if ui.selectable_label(track.inactive == inactive, label).clicked() { + let _ = app.run("track.make_inactive", json!({"tracks": [id.0], "inactive": inactive})); + } + } + }); + } + let mode = m.automation_mode; + let col = match mode { + soundcraft_model::AutomationMode::Read => t.auto_read, + soundcraft_model::AutomationMode::Write => t.auto_write, + soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch, + soundcraft_model::AutomationMode::Latch => t.auto_latch, + _ => t.text_dim, + }; + let aresp = header_selector(ui, at(63.0, 57.0, 59.0, 17.0), ui.id().with(("auto_mode", id.0)), mode.label(), col, true); + egui::Popup::menu(&aresp).show(|ui| { + for m in soundcraft_model::AutomationMode::ALL { + if ui.selectable_label(m == mode, m.label()).clicked() { + let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()})); + } + } + }); + } + if h >= 94.0 { + if track.kind.has_playlist() { + let r = at(17.0, 75.0, 13.0, 17.0); + let resp = ui.interact(r, ui.id().with(("timebase", id.0)), Sense::click()); + raised_key(ui.painter(), r, t.slot_face, resp.hovered()); + let c = r.center(); + if track.ticks_timebase { + let green = Color32::from_rgb(86, 172, 103); + ui.painter().add(Shape::convex_polygon( + vec![c + vec2(-3.0, 3.25), c + vec2(3.0, 3.25), c + vec2(1.0, -3.25), c + vec2(-1.0, -3.25)], + green, + Stroke::NONE, + )); + ui.painter().line_segment([c + vec2(0.0, 2.0), c + vec2(2.0, -3.0)], Stroke::new(1.0, t.text)); + } else { + ui.painter().circle_filled(c, 3.25, Color32::from_rgb(100, 184, 249)); + ui.painter().line_segment([c, c + vec2(0.0, -3.25)], Stroke::new(1.0, t.slot_face[1])); + ui.painter().line_segment([c, c + vec2(3.25, 0.0)], Stroke::new(1.0, t.slot_face[1])); + } + let tip = if track.ticks_timebase { "Timebase: ticks" } else { "Timebase: samples" }; + egui::Popup::menu(&resp.on_hover_text(tip)).show(|ui| { + for (label, ticks) in [("Samples", false), ("Ticks", true)] { + if ui.selectable_label(track.ticks_timebase == ticks, label).clicked() { + let _ = app.run("track.timebase", json!({"tracks": [id.0], "ticks": ticks})); + } + } + }); + } + let field = at(30.0, 75.0, 92.0, 17.0); + if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument) { + let resp = ui.interact(field, ui.id().with(("field", id.0)), Sense::click()); + ui.painter().rect_filled(field, 2.0, shade(head_bg, if resp.hovered() { -0.02 } else { -0.1 })); + if track.kind == TrackKind::Instrument { + let name = + track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("no instrument", |p| p.short_name); + ui.painter().with_clip_rect(field).text(pos2(field.min.x + 5.0, field.center().y), Align2::LEFT_CENTER, name, bold(10.0), t.text); + egui::Popup::menu(&resp.on_hover_text("Instrument")).show(|ui| crate::mix_window::instrument_menu(app, ui, id)); + } else { + let c = pos2(field.min.x + 7.0, field.center().y); + ui.painter().line_segment([c + vec2(0.0, -3.5), c + vec2(0.0, 1.0)], Stroke::new(1.0, t.text)); + ui.painter().add(Shape::convex_polygon(vec![c + vec2(0.0, 0.5), c + vec2(3.0, 3.5), c + vec2(-3.0, 3.5)], t.text, Stroke::NONE)); + if let Some(alg) = &track.elastic { + ui.painter().with_clip_rect(field).text(pos2(field.min.x + 14.0, field.center().y), Align2::LEFT_CENTER, alg, bold(10.0), t.text); + } + egui::Popup::menu(&resp.on_hover_text("Elastic Audio")).show(|ui| { + for alg in ["none", "polyphonic", "rhythmic", "monophonic", "varispeed", "x-form"] { + let current = track.elastic.as_deref().unwrap_or("none") == alg; + if ui.selectable_label(current, alg).clicked() { + let _ = app.run("track.elastic", json!({"tracks": [id.0], "algorithm": alg})); + } + } + }); + } + } + } + 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. +fn arrow(p: &egui::Painter, c: Pos2, down: bool, color: Color32) { + let pts = if down { + vec![c + vec2(-2.5, -1.75), c + vec2(2.5, -1.75), c + vec2(0.0, 1.75)] + } else { + vec![c + vec2(-1.75, -2.5), c + vec2(1.75, 0.0), c + vec2(-1.75, 2.5)] + }; + p.add(Shape::convex_polygon(pts, color, Stroke::NONE)); +} + +/// A header selector (track view, automation mode…): the value centred, and for menus an arrow +/// in the top-right corner. +fn header_selector(ui: &Ui, r: Rect, id: egui::Id, text: &str, color: Color32, menu: bool) -> egui::Response { + let resp = ui.interact(r, id, Sense::click()); + raised_key(ui.painter(), r, Tokens::current().slot_face, resp.hovered()); + let tr = if menu { r.with_max_x(r.max.x - 7.0) } else { r }; + ui.painter().with_clip_rect(tr.shrink(1.0)).text(tr.center(), Align2::CENTER_CENTER, text, bold(10.0), color); + if menu { + arrow(ui.painter(), pos2(r.max.x - 5.5, r.min.y + 5.75), true, Tokens::current().text); + } + resp +} + +#[derive(Clone, Copy, PartialEq)] +enum HeaderKey { + Rec, + Input, + Solo, + Mute, +} + +/// Record, input, solo or mute: `lit` gives the key its colour, `implied` a dark shade of it (a +/// mute that another track's solo implies). Record keys stay dark red when off, as in Pro Tools. +fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, implied: bool) -> egui::Response { + let t = Tokens::current(); + let (label, color) = match key { + HeaderKey::Rec => ("Record enable", t.rec), + HeaderKey::Input => ("Input monitoring", t.input), + HeaderKey::Solo => ("Solo", t.solo), + HeaderKey::Mute => ("Mute", t.mute), + }; + let resp = ui.interact(r, ui.id().with((label, track.0)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), lit, label)); + let rec = key == HeaderKey::Rec; + let (tint, k) = if lit { + (color, 0.85) + } else if implied { + (shade(color, -0.35), 0.55) + } else if rec { + (shade(color, -0.5), 0.35) + } else { + (color, 0.0) + }; + let [top, bottom] = t.slot_face.map(|f| f.lerp_to_gamma(tint, k)); + raised_key(ui.painter(), r, [shade(top, 0.08), shade(bottom, -0.12)], resp.hovered()); + let ink = if lit { + t.text_dark + } else if implied { + t.text.lerp_to_gamma(color, 0.3) + } else { + t.text + }; + if rec { + ui.painter().circle_filled(r.center(), 4.0, if lit { Color32::WHITE } else { t.text.lerp_to_gamma(color, 0.3) }); + } else { + ui.painter().text(r.center(), Align2::CENTER_CENTER, label.get(..1).unwrap_or_default(), bold(11.0), ink); + } + resp.on_hover_text(label) +} + +/// The header's level meter: black-rimmed bars, dim where unlit, with a clip light on top. +fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) { + let t = Tokens::current(); + let p = ui.painter(); + p.rect_filled(r, 0.0, Color32::BLACK); + let inner = r.shrink(1.0); + let clip = inner.with_max_y(inner.min.y + 4.0); + p.rect_filled(clip, 0.0, if md.clip { t.meter_red } else { t.meter_red.gamma_multiply(0.18) }); + let body = inner.with_min_y(clip.max.y + 1.0); + let n = if stereo { 2 } else { 1 }; + let w = (body.width() - (n - 1) as f32) / n as f32; + let pos = |v: f32| if v <= 1e-6 { 0.0 } else { crate::widgets::meter_pos(20.0 * v.log10()) }; + let (amber, red) = (crate::widgets::meter_pos(-12.0), crate::widgets::meter_pos(-3.0)); + for ch in 0..n { + let bar = Rect::from_min_size(pos2(body.min.x + ch as f32 * (w + 1.0), body.min.y), vec2(w, body.height())); + let y = |f: f32| bar.max.y - bar.height() * f; + let lv = pos(md.level[ch]); + for (lo, hi, col) in [(0.0, amber, t.meter_green), (amber, red, t.meter_yellow), (red, 1.0, t.meter_red)] { + p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(hi)..=y(lo)), 0.0, col.gamma_multiply(0.18)); + if lv > lo { + p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(lv.min(hi))..=y(lo)), 0.0, col); + } + } + let hv = pos(md.hold[ch]); + if hv > 0.01 { + p.hline(bar.x_range(), y(hv), Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow })); + } } } @@ -1049,54 +1199,13 @@ fn column_ui(ui: &mut Ui, col: &str, r: Rect) -> Ui { ui.new_child(egui::UiBuilder::new().id_salt(("edit_column", col.to_string())).max_rect(r)) } -/// One optional Edit-window column for a track (instrument, inserts, sends, I/O, comments). -fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { - let t = Tokens::current(); - let selected = app.engine.session().edit.selected_tracks.contains(&track.id); - ui.painter().rect_filled(r, 0.0, crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint).0); - ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, t.border)); - let line_h = 15.0; - let rows = ((r.height() - 4.0) / line_h).floor().max(1.0) as usize; - let item = |i: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 3.0 + i as f32 * line_h), vec2(r.width() - 8.0, line_h - 2.0)); - let text = |ui: &Ui, rr: Rect, s: &str, c: Color32| { - ui.painter().rect(rr, 2.0, t.slot_bg, Stroke::new(1.0, t.slot_border), StrokeKind::Inside); - ui.painter().with_clip_rect(rr).text(pos2(rr.min.x + 4.0, rr.center().y), Align2::LEFT_CENTER, s, regular(10.0), c); - }; - let route = |r: &soundcraft_model::Route| match r { - soundcraft_model::Route::None => "none".to_string(), - soundcraft_model::Route::Main => "Out 1-2".to_string(), - soundcraft_model::Route::Bus(b) => app.engine.session().bus(*b).map_or("bus".into(), |b| b.name.clone()), - soundcraft_model::Route::Hardware(h) => h.clone(), - }; +/// One optional Edit-window column for a track (I/O, inserts, sends, comments). +fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect, head_bg: Color32) { match col { - "io" => { - if rows >= 1 { - text(ui, item(0), &format!("in: {}", route(&track.mixer.input)), t.text); - } - if rows >= 2 { - text(ui, item(1), &format!("out: {}", route(&track.mixer.output)), t.text); - } - } - "instruments" => { - if track.kind == TrackKind::Instrument { - crate::mix_window::instrument_slot(app, &mut column_ui(ui, col, r), track, item(0)); - } - } - "inserts_ae" | "inserts_fj" => { - let off = if col == "inserts_ae" { 0 } else { 5 }; - let mut ui = column_ui(ui, col, r); - for i in 0..5.min(rows) { - crate::mix_window::insert_slot(app, &mut ui, track, off + i, item(i)); - } - } - "sends_ae" | "sends_fj" => { - let off = if col == "sends_ae" { 0 } else { 5 }; - let mut ui = column_ui(ui, col, r); - for i in 0..5.min(rows) { - crate::mix_window::send_slot(app, &mut ui, track, off + i, item(i)); - } - } - _ => { + "io" => io_column(app, ui, track, r), + "comments" => { + ui.painter().rect_filled(r, 0.0, head_bg); + ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, Tokens::current().border)); let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0))); if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() { @@ -1104,6 +1213,99 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R app.engine.execute_merged("track.comments", &json!({"track": track.id.0, "comments": c}), &format!("comments:{}", track.id.0)); } } + _ => plugin_column(app, ui, track, col, r), + } +} + +/// The I/O column as Pro Tools draws it: input and output selectors, then volume and pan. +fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { + let t = Tokens::current(); + let id = track.id; + ui.painter().rect_filled(r, 0.0, t.slot_well); + let ui = column_ui(ui, "io", r); + let (x, w) = (r.min.x + 7.0, r.width() - 14.0); + if r.height() >= 20.0 { + let input = if track.kind.has_playlist() && track.mixer.input == soundcraft_model::Route::None { + "In 1".to_string() + } else { + crate::mix_window::route_name(app, &track.mixer.input) + }; + let resp = header_selector(&ui, Rect::from_min_size(pos2(x, r.min.y + 4.0), vec2(w, 15.0)), ui.id().with(("in", id.0)), &input, t.text, true); + egui::Popup::menu(&resp.on_hover_text("Input")) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| crate::mix_window::route_menu(app, ui, id, true)); + } + if r.height() >= 39.0 { + let out = Rect::from_min_size(pos2(x, r.min.y + 21.0), vec2(w, 17.0)); + let (sel, fader) = (out.with_max_x(out.max.x - 11.0), out.with_min_x(out.max.x - 11.0)); + let resp = ui.interact(sel, ui.id().with(("out", id.0)), Sense::click()); + let fresp = ui.interact(fader, ui.id().with(("out_fader", id.0)), Sense::click()); + raised_key(ui.painter(), out, t.slot_face, resp.hovered() || fresp.hovered()); + let [top, _] = t.slot_face; + ui.painter().vline(fader.min.x - 0.5, out.min.y + 1.0..=out.max.y - 1.0, Stroke::new(1.0, shade(top, -0.42))); + ui.painter().vline(fader.min.x + 0.5, out.min.y + 1.0..=out.max.y - 1.0, Stroke::new(1.0, shade(top, 0.12))); + let name = crate::mix_window::route_name(app, &track.mixer.output); + ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, name, bold(10.0), t.text); + let c = fader.center() + vec2(0.5, 0.0); + ui.painter().vline(c.x, c.y - 5.0..=c.y + 5.0, Stroke::new(1.0, t.text)); + ui.painter().rect_filled(Rect::from_center_size(c + vec2(0.0, -1.5), vec2(5.0, 2.5)), 0.5, t.text); + egui::Popup::menu(&resp.on_hover_text("Output")) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| crate::mix_window::route_menu(app, ui, id, false)); + if fresp.on_hover_text("Show in the Mix window").clicked() { + let _ = app.run("edit.select", json!({"tracks": [id.0]})); + app.ui.window = crate::MainWindow::Mix; + } + } + if r.height() >= 76.0 { + let vr = Rect::from_min_size(pos2(x + 1.0, r.min.y + 45.0), vec2(w - 2.0, 30.0)); + ui.painter().rect_filled(vr, 2.0, Color32::from_gray(15)); + ui.painter().rect_filled(vr.shrink(1.5), 1.5, Color32::BLACK); + let m = &track.mixer; + let pan = m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p)); + // The readout is black in both themes, so it takes the dark palette's green. + let ink = Tokens::DARK.counter_text; + for (y, label, value) in [(7.5, "vol", crate::widgets::db_text(m.volume_db)), (22.0, "pan", pan)] { + ui.painter().text(pos2(vr.min.x + 7.0, vr.min.y + y), Align2::LEFT_CENTER, label, bold(10.0), ink); + ui.painter().text(pos2(vr.max.x - 6.5, vr.min.y + y), Align2::RIGHT_CENTER, value, bold(10.0), ink); + } + ui.painter().hline(vr.min.x + 7.0..=vr.max.x - 6.0, vr.min.y + 14.5, Stroke::new(1.0, Color32::from_gray(35))); + } +} + +/// Inserts, sends or the instrument, laid out as Pro Tools does: 15-point cells every 18 points +/// on a neutral well, or on tracks too short for three of them, a row of squares. +fn plugin_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { + let t = Tokens::current(); + ui.painter().rect_filled(r, 0.0, t.slot_well); + let rows = if r.height() < 16.0 { 0 } else { ((r.height() - 16.0) / 18.0) as usize + 1 }; + let cell = |row: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 1.0 + row as f32 * 18.0), vec2(r.width() - 8.0, 15.0)); + let square = + |i: usize, row: usize| Rect::from_min_size(pos2(r.min.x + 4.0 + i as f32 * 14.0, r.min.y + 1.0 + row as f32 * 18.0), vec2(10.0, 15.0)); + let mut ui = column_ui(ui, col, r); + if col == "instruments" { + if rows > 0 && track.kind == TrackKind::Instrument { + crate::mix_window::instrument_slot(app, &mut ui, track, cell(0)); + } + return; + } + let off = if col.ends_with("ae") { 0 } else { 5 }; + let slots: Vec = match rows { + 0 => Vec::new(), + 1 | 2 => (0..5).map(|i| square(i, 0)).collect(), + _ => (0..rows.min(5)).map(cell).collect(), + }; + for (i, sr) in slots.into_iter().enumerate() { + if col.starts_with("inserts") { + crate::mix_window::insert_slot(app, &mut ui, track, off + i, sr); + } else { + crate::mix_window::send_slot(app, &mut ui, track, off + i, sr); + } + } + if rows == 2 { + for i in 0..5 { + ui.painter().rect_filled(square(i, 1), 1.5, t.slot_empty); + } } } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 910415d..fd18207 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -3,7 +3,7 @@ use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, inset, key, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, + PannerEdit, PannerSpeaker, db_text, fader, inset, meter, multi_meter, pan_knob, pan_text, raised_key, rec_toggle, selector_box, selector_box_with_tooltip, shade, surround_panner, text_toggle, vgradient, }; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; @@ -79,7 +79,7 @@ fn section_label(ui: &Ui, r: Rect, text: &str) { ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Tokens::current().section_label); } -fn route_name(app: &SoundApp, r: &Route) -> String { +pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String { match r { Route::None => "no output".into(), Route::Main => app.engine.session().outputs.first().map_or_else(|| "Out 1-2".into(), |o| o.name.clone()), @@ -427,9 +427,10 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: let ins = track.instrument.as_ref(); let info = ins.and_then(|i| plugin_info(&i.plugin)); let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click()); - let fill = if info.is_some() { t.insert_on } else { t.slot_bg }; - slot_face(ui, r, &resp, fill, info.is_some()); - ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text); + match info { + Some(info) => used_slot(ui.painter(), r, t.slot_face, Mark::Dot, info.short_name, t.text, None), + None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), + } let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin)); if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) { app.ui.plugin_windows.push((id, slot)); @@ -439,37 +440,82 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: popup.show(|ui| instrument_menu(app, ui, id)); } -/// A filled slot is a key; it doesn't brighten on hover, which would cost its label contrast. -fn slot_face(ui: &Ui, r: Rect, resp: &egui::Response, fill: Color32, occupied: bool) { - if occupied { - key(ui.painter(), r, resp, fill); - } else { - let fill = if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::current().slot_border), StrokeKind::Inside); +/// What a plugin slot shows at its left: a dot for inserts, the send's letter, or an arrow for +/// a send on a short track. +enum Mark<'a> { + Dot, + Letter(&'a str), + Arrow, +} + +/// Slots narrower than this are a short track's squares: a mark, no name. +const COMPACT_SLOT: f32 = 30.0; +const NAME_SIZE: f32 = 9.5; + +fn draw_mark(p: &egui::Painter, r: Rect, mark: &Mark, color: Color32) { + let at = r.min + vec2(4.75, 4.75); + match mark { + Mark::Dot => { + p.circle_filled(at, 2.0, color); + } + Mark::Letter(l) => { + p.text(pos2(r.min.x + 2.5, r.center().y), Align2::LEFT_CENTER, *l, bold(NAME_SIZE), color); + } + Mark::Arrow => { + let at = at + vec2(0.0, 0.5); + p.rect_filled(Rect::from_min_max(at + vec2(-2.5, -1.5), at + vec2(0.0, 1.5)), 0.0, color); + p.add(egui::Shape::convex_polygon(vec![at + vec2(-0.5, -3.5), at + vec2(2.75, 0.0), at + vec2(-0.5, 3.5)], color, Stroke::NONE)); + } } } +fn empty_slot(p: &egui::Painter, r: Rect, hovered: bool, mark: Mark) { + let t = Tokens::current(); + p.rect_filled(r, 2.0, if hovered { shade(t.slot_empty, 0.06) } else { t.slot_empty }); + let color = if matches!(mark, Mark::Letter(_)) { t.slot_mark.lerp_to_gamma(t.text, 0.55) } else { t.slot_mark }; + draw_mark(p, r, &mark, color); +} + +/// A used slot, a button a point bigger than the empty cell: the mark, a groove, the name +/// centred, and for sends a level box at the right. It doesn't brighten on hover, which would +/// cost its name contrast. +fn used_slot(p: &egui::Painter, r: Rect, faces: [Color32; 2], mark: Mark, name: &str, color: Color32, level: Option) { + let t = Tokens::current(); + let outer = r.expand(1.0); + let top = raised_key(p, outer, faces, false); + draw_mark(p, r, &mark, if matches!(mark, Mark::Dot) { t.slot_mark.lerp_to_gamma(color, 0.35) } else { color }); + if r.width() < COMPACT_SLOT { + return; + } + let groove = r.min.x + 10.5; + p.vline(groove, r.y_range(), Stroke::new(1.0, shade(top, -0.42))); + p.vline(groove + 1.0, r.y_range(), Stroke::new(1.0, shade(top, 0.12))); + let mut text = r.with_min_x(groove + 1.5); + if let Some(k) = level { + let well = Rect::from_min_max(pos2(outer.max.x - 9.0, outer.min.y + 2.0), pos2(outer.max.x - 4.0, outer.max.y - 2.0)); + p.rect_filled(well, 1.0, Color32::BLACK); + let inner = well.shrink(1.0); + p.rect_filled(inner, 0.0, Color32::from_rgb(36, 38, 8)); + p.rect_filled(inner.with_min_y(inner.max.y - inner.height() * k.clamp(0.0, 1.0)), 0.0, Color32::from_rgb(51, 49, 11)); + text.max.x = well.min.x - 1.0; + } + let g = p.layout_no_wrap(name.to_string(), bold(NAME_SIZE), color); + let x = if g.size().x <= text.width() { text.center().x - g.size().x / 2.0 } else { text.min.x + 1.0 }; + p.with_clip_rect(text).galley(pos2(x, text.center().y - g.size().y / 2.0), g, color); +} + pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; let ins = track.mixer.inserts.get(slot).cloned().flatten(); let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click()); - let fill = match &ins { - Some(i) if i.bypass => t.insert_bypass, - Some(_) => t.insert_on, - None => t.slot_bg, - }; - slot_face(ui, r, &resp, fill, ins.is_some()); + let name = ins.as_ref().map(|i| plugin_info(&i.plugin).map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default(); match &ins { - None => { - ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); - } + None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), Some(i) => { - let led = if i.bypass { t.text_dim.gamma_multiply(0.45) } else { t.meter_green }; - ui.painter().circle_filled(pos2(r.min.x + 7.0, r.center().y), 2.5, led); - let name = plugin_info(&i.plugin).map_or(i.plugin.as_str(), |p| p.short_name); - let color = if i.bypass { t.text_dim } else { t.text }; - ui.painter().with_clip_rect(r.shrink(1.0)).text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, regular(10.5), color); + let faces = if i.bypass { t.slot_bypassed } else { t.slot_face }; + let color = if plugin_info(&i.plugin).is_some() { t.text } else { t.text_dim }; + used_slot(ui.painter(), r, faces, Mark::Dot, name, color, None); } } if resp.clicked() && ins.is_some() { @@ -480,7 +526,7 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: } } let cmd = if ui.ctx().os().is_mac() { "⌘" } else { "Ctrl+" }; - let tip = format!("Click: open plugin · {cmd}click: bypass · right-click: change"); + let tip = format!("{name}: click to open · {cmd}click to bypass · right-click to change"); let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text(tip) }; let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) }; popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some())); @@ -637,33 +683,22 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max); let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); - slot_face(ui, r, &route_resp, if snd.is_some() { t.send_on } else { t.slot_bg }, snd.is_some()); + let hovered = ui.rect_contains_pointer(r); + let letter = ((b'a' + slot as u8) as char).to_string(); + let mark = if r.width() < COMPACT_SLOT { Mark::Arrow } else { Mark::Letter(&letter) }; match &snd { Some(s) => { let name = route_name(app, &s.target); - // Level bar. - let k = soundcraft_model::fader_db_to_pos(s.level_db); - ui.painter().rect_filled( - Rect::from_min_max(pos2(r.min.x + 1.0, r.max.y - 3.0), pos2(r.min.x + 1.0 + (r.width() - 2.0) * k, r.max.y - 1.0)), - 0.0, - t.counter_text, - ); - ui.painter().with_clip_rect(route_r).text( - pos2(r.min.x + 4.0, r.center().y - 1.0), - Align2::LEFT_CENTER, - name, - regular(9.0), - if s.mute { t.mute } else { t.text }, - ); - let letter = (b'A' + slot as u8) as char; + let level = soundcraft_model::fader_db_to_pos(s.level_db); + let color = if s.mute { t.mute } else { t.text }; + used_slot(ui.painter(), r, t.slot_face, mark, &name, color, Some(level)); + let which = (b'A' + slot as u8) as char; let shown = app.ui.send_window == Some((id, slot)); let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag()); - level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {letter} level"))); + level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {which} level"))); if shown { ui.painter().rect_stroke(level_r.shrink(0.5), 1.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } - let level = format!("{} dB", db_text(s.level_db)); - ui.painter().with_clip_rect(level_r).text(level_r.center(), Align2::CENTER_CENTER, level, regular(9.0), t.counter_text); if level_resp.clicked() { if ui.input(|i| i.modifiers.alt) { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); @@ -680,14 +715,12 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } let _ = level_resp.clone().on_hover_text(format!( - "Send {letter}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", + "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", db_text(s.level_db) )); level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); } - None => { - ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); - } + None => empty_slot(ui.painter(), r, hovered, mark), } if let Some(send) = &snd { egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { @@ -939,7 +972,7 @@ fn send_level_scale(ui: &mut Ui, db: f32) { let t = Tokens::current(); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover()); let line_y = r.min.y + 4.0; - ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border)); + ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.border)); for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() { let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark); ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim)); @@ -990,7 +1023,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu } } -fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { +pub(crate) fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { let key = if input { "input" } else { "output" }; let cmd = if input { "track.input" } else { "track.output" }; if ui.button(if input { "no input" } else { "no output" }).clicked() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0f68803..bae43a7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -148,16 +148,15 @@ pub struct Tokens { pub knob_ring: Color32, /// Pan knob pointer. pub knob_pointer: Color32, - /// Mix insert slot holding a plugin. - pub insert_on: Color32, - /// Mix insert slot, bypassed. - pub insert_bypass: Color32, - /// Mix send slot in use. - pub send_on: Color32, - /// Mix insert/send slot outline. - pub slot_border: Color32, /// Mix-window section captions (INSERTS A-E, I/O…). pub section_label: Color32, + /// Plugin slots, as Pro Tools draws them: the well they sit in, an empty slot, a used slot's + /// face (top, bottom), a bypassed one's, and the dots, letters and arrows on them. + pub slot_well: Color32, + pub slot_empty: Color32, + pub slot_face: [Color32; 2], + pub slot_bypassed: [Color32; 2], + pub slot_mark: Color32, } impl Tokens { @@ -238,11 +237,12 @@ impl Tokens { knob_bg: Color32::from_rgb(26, 26, 28), knob_ring: Color32::from_rgb(150, 150, 154), knob_pointer: Color32::from_rgb(230, 230, 230), - insert_on: Color32::from_rgb(86, 88, 92), - insert_bypass: Color32::from_rgb(70, 56, 30), - send_on: Color32::from_rgb(46, 66, 56), - slot_border: Color32::from_rgb(16, 16, 16), section_label: Color32::from_rgb(200, 200, 200), + slot_well: Color32::from_rgb(43, 43, 43), + slot_empty: Color32::from_rgb(39, 39, 39), + slot_face: [Color32::from_rgb(92, 92, 92), Color32::from_rgb(75, 75, 75)], + slot_bypassed: [Color32::from_rgb(66, 96, 146), Color32::from_rgb(50, 76, 120)], + slot_mark: Color32::from_rgb(159, 159, 159), }; pub const LIGHT: Tokens = Tokens { @@ -322,11 +322,12 @@ impl Tokens { knob_bg: Color32::from_rgb(232, 233, 236), knob_ring: Color32::from_rgb(116, 120, 128), knob_pointer: Color32::from_rgb(30, 32, 38), - insert_on: Color32::from_rgb(228, 230, 233), - insert_bypass: Color32::from_rgb(224, 204, 164), - send_on: Color32::from_rgb(192, 218, 202), - slot_border: Color32::from_rgb(112, 115, 121), section_label: Color32::from_rgb(56, 58, 64), + slot_well: Color32::from_rgb(212, 214, 218), + slot_empty: Color32::from_rgb(198, 200, 205), + slot_face: [Color32::from_rgb(244, 245, 247), Color32::from_rgb(214, 216, 220)], + slot_bypassed: [Color32::from_rgb(190, 210, 240), Color32::from_rgb(160, 186, 228)], + slot_mark: Color32::from_rgb(92, 95, 102), }; pub fn current() -> Tokens { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 338ce1f..fec410f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -40,6 +40,16 @@ pub fn rounded_vgradient(p: &Painter, r: Rect, radius: f32, top: Color32, bottom p.add(mesh); } +/// A raised key as Pro Tools draws them: a dark rim, a face shaded from `faces[0]` to `faces[1]` +/// and a lit top edge. Returns the face's top colour. +pub fn raised_key(p: &Painter, r: Rect, faces: [Color32; 2], hovered: bool) -> Color32 { + let [top, bottom] = if hovered { faces.map(|c| shade(c, 0.06)) } else { faces }; + p.rect_filled(r, 3.0, shade(Tokens::current().slot_well, -0.3)); + rounded_vgradient(p, r.shrink(1.0), 2.0, top, bottom); + p.hline(r.min.x + 2.0..=r.max.x - 2.0, r.min.y + 1.5, Stroke::new(1.0, shade(top, 0.17))); + top +} + pub fn inset(p: &Painter, r: Rect, fill: Color32) { let t = Tokens::current(); p.rect(r, 2.0, fill, Stroke::new(1.0, t.border), StrokeKind::Inside); diff --git a/crates/ui-egui/tests/edit_io_readout.rs b/crates/ui-egui/tests/edit_io_readout.rs new file mode 100644 index 0000000..fe7b106 --- /dev/null +++ b/crates/ui-egui/tests/edit_io_readout.rs @@ -0,0 +1,61 @@ +use egui::{Color32, Shape, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode}; + +fn contrast(a: Color32, b: Color32) -> f64 { + let luminance = |color: Color32| { + let linear = |channel: u8| { + let s = f64::from(channel) / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) + }; + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +#[test] +fn the_io_columns_vol_and_pan_readout_is_readable_in_both_themes() { + for mode in [ThemeMode::Dark, ThemeMode::Light] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("view.edit_io", json!({})).unwrap(); + app.run("track.height", json!({"tracks": ["Kick"], "height": "medium"})).unwrap(); + app.run("ui.theme", json!({"mode": mode.id()})).unwrap(); + let mut harness = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + harness.step(); + } + let output = harness.output(); + let labels: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(text) if matches!(text.galley.job.text.as_str(), "vol" | "pan") => Some(text), + _ => None, + }) + .collect(); + assert!(!labels.is_empty(), "{mode:?}: no vol/pan readout painted"); + for label in labels { + let center = label.visual_bounding_rect().center(); + let well = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.rect.contains(center) && rect.fill.a() == 255 => Some(rect), + _ => None, + }) + .min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())) + .unwrap_or_else(|| panic!("{mode:?}: no readout behind {:?}", label.galley.job.text)); + let ink = label.override_text_color.unwrap_or(label.galley.job.sections[0].format.color); + let ratio = contrast(ink, well.fill); + assert!(ratio >= 4.5, "{mode:?}: {:?} reads at {ratio:.2}:1", label.galley.job.text); + } + } +} diff --git a/crates/ui-egui/tests/edit_window_plugins.rs b/crates/ui-egui/tests/edit_window_headers.rs similarity index 62% rename from crates/ui-egui/tests/edit_window_plugins.rs rename to crates/ui-egui/tests/edit_window_headers.rs index 14b4e15..67d8a92 100644 --- a/crates/ui-egui/tests/edit_window_plugins.rs +++ b/crates/ui-egui/tests/edit_window_headers.rs @@ -4,7 +4,7 @@ use egui_kittest::kittest::Queryable; use serde_json::json; use soundcraft_model::{Track, TrackId}; use soundcraft_ui_egui::edit_window::HEADER_W; -use soundcraft_ui_egui::{Services, SoundApp}; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; @@ -44,10 +44,10 @@ fn bypassed(h: &Harness<'_, SoundApp>, slot: usize) -> bool { kick(h).mixer.inserts.get(slot).cloned().flatten().is_some_and(|i| i.bypass) } -fn harness(view: &str) -> (Harness<'static, SoundApp>, TrackId, [f32; 4]) { +fn harness(height: &str, view: &str) -> (Harness<'static, SoundApp>, TrackId, [f32; 4]) { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); app.run(view, json!({})).unwrap(); - app.run("track.height", json!({"tracks": ["Kick"], "height": "medium"})).unwrap(); + app.run("track.height", json!({"tracks": ["Kick"], "height": height})).unwrap(); let kick = app.engine.session().track_by_name("Kick").unwrap().id; let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( |ui, app: &mut SoundApp| { @@ -64,10 +64,30 @@ fn harness(view: &str) -> (Harness<'static, SoundApp>, TrackId, [f32; 4]) { (h, kick, row) } +#[test] +fn header_keys_freeze_silence_and_show_automation() { + let (mut h, _, row) = harness("medium", "view.edit_io"); + let at = |x: f32, y: f32| pos2(row[0] + x, row[1] + y); + + click(&mut h, at(23.5, 65.5)); + assert!(kick(&h).frozen); + + click(&mut h, at(46.5, 65.5)); + let off = h.get_by_label("off").rect().center(); + click(&mut h, off); + assert!(kick(&h).inactive); + + click(&mut h, at(7.5, row[3] - row[1] - 7.5)); + assert_eq!(kick(&h).view, "volume"); + + click(&mut h, at(HEADER_W + 81.5, 29.5)); + assert_eq!(h.state().ui.window, MainWindow::Mix); +} + #[test] fn edit_window_insert_slots_open_change_and_add_plugins() { - let (mut h, kick, row) = harness("view.edit_inserts_ae"); - let slot = |i: usize| pos2(row[0] + HEADER_W + 56.0, row[1] + 9.5 + i as f32 * 15.0); + let (mut h, kick, row) = harness("medium", "view.edit_inserts_ae"); + let slot = |i: usize| pos2(row[0] + HEADER_W + 37.5, row[1] + 8.5 + i as f32 * 18.0); click(&mut h, slot(0)); assert!(h.state().ui.plugin_windows.contains(&(kick, 0))); @@ -94,9 +114,25 @@ fn edit_window_insert_slots_open_change_and_add_plugins() { assert_eq!(insert(&h, 2).as_deref(), Some(plugin.id)); } +#[test] +fn short_tracks_show_insert_slots_as_squares() { + let (mut h, kick, row) = harness("small", "view.edit_inserts_ae"); + let square = |i: usize| pos2(row[0] + HEADER_W + 9.0 + i as f32 * 14.0, row[1] + 8.5); + + click(&mut h, square(1)); + assert!(h.state().ui.plugin_windows.contains(&(kick, 1))); + + press(&mut h, square(0), PointerButton::Primary, CMD); + assert!(bypassed(&h, 0)); + + click(&mut h, square(4)); + let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); + assert!(h.query_by_label(&format!("{:?} ⏵", plugin.category)).is_some()); +} + #[test] fn the_views_menu_stays_open_until_a_click_elsewhere() { - let (mut h, _, _) = harness("view.edit_inserts_ae"); + let (mut h, _, _) = harness("medium", "view.edit_inserts_ae"); let views = h.get_by_label("Views").rect().center(); click(&mut h, views); for column in ["Sends A-E", "I/O"] { @@ -108,3 +144,19 @@ fn the_views_menu_stays_open_until_a_click_elsewhere() { click(&mut h, pos2(800.0, 990.0)); assert!(h.query_by_label("Sends A-E").is_none()); } + +#[test] +fn the_io_columns_route_menus_stay_open_while_searching_buses() { + let (mut h, _, row) = harness("medium", "view.edit_io"); + click(&mut h, pos2(row[0] + HEADER_W + 40.0, row[1] + 29.5)); + let bus = h.get_by_label("Bus").rect(); + let search = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .map(|node| node.rect()) + .filter(|r| r.min.y >= bus.max.y) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + click(&mut h, search); + assert!(h.query_by_label("no output").is_some(), "a click in the bus search closed the Output menu"); +} diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs index 6621c32..0a8031a 100644 --- a/crates/ui-egui/tests/mix_instrument_contrast.rs +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -6,12 +6,15 @@ use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode}; #[derive(Debug)] struct Slot { rect: Rect, + /// The face behind the label's centre line. fill: Color32, + /// The face's top and bottom colours. + faces: (Color32, Color32), text: Color32, border: Color32, } -// Read the rectangle and foreground emitted by the real Mix drawing path, rather than +// Read the face and foreground emitted by the real Mix drawing path, rather than // testing a palette token that a custom-painted control might never use. fn slot(output: &FullOutput, label: &str) -> Slot { let text = output @@ -23,22 +26,39 @@ fn slot(output: &FullOutput, label: &str) -> Slot { }) .unwrap_or_else(|| panic!("missing painted label {label}")); let center = text.visual_bounding_rect().center(); - let rects = || { - output.shapes.iter().filter_map(|shape| match &shape.shape { - Shape::Rect(rect) if rect.rect.height() <= 15.0 && rect.rect.contains(center) => Some(rect), + // A used slot is a raised key: a rim, then a face shaded from top to bottom. + let face = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Mesh(mesh) if mesh.calc_bounds().contains(center) => Some(mesh), _ => None, }) - }; - // A filled slot is a key: an opaque face inside a separately stroked rim. - let face = rects() - .filter(|rect| rect.fill.a() == 255) - .min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())) + .min_by(|a, b| a.calc_bounds().area().total_cmp(&b.calc_bounds().area())) .unwrap_or_else(|| panic!("missing painted slot for {label}")); - let rim = rects().filter(|rect| rect.stroke.width > 0.0).max_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).unwrap_or(face); + let opaque = || face.vertices.iter().filter(|v| v.color.a() == 255); + let top = opaque().min_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap_or_else(|| panic!("{label}: face has no opaque vertices")); + let bottom = opaque().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap_or(top); + let k = ((center.y - top.pos.y) / (bottom.pos.y - top.pos.y).max(1.0)).clamp(0.0, 1.0); + let rim = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.rect.height() <= 17.0 && rect.rect.contains(center) => Some(rect), + _ => None, + }) + .min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())) + .unwrap_or_else(|| panic!("missing painted rim for {label}")); let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color); assert_eq!(text.opacity_factor, 1.0); assert_eq!(foreground.a(), 255); - Slot { rect: rim.rect, fill: face.fill, text: foreground, border: rim.stroke.color } + Slot { rect: rim.rect, fill: top.color.lerp_to_gamma(bottom.color, k), faces: (top.color, bottom.color), text: foreground, border: rim.fill } +} + +/// Face colours as painted: the anti-aliased edge sits half a pixel inside the gradient's ends. +fn near((a, b): (Color32, Color32), (c, d): (Color32, Color32)) -> bool { + let close = |x: Color32, y: Color32| x.to_array().iter().zip(y.to_array()).all(|(p, q)| p.abs_diff(q) <= 2); + close(a, c) && close(b, d) } fn contrast(a: Color32, b: Color32) -> f64 { @@ -104,12 +124,19 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { } let ordinary = slot(harness.output(), "SubSynth"); let insert = slot(harness.output(), "EQ7"); - let empty = slot(harness.output(), "no instrument"); let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK }; - assert_eq!(insert.fill, palette.insert_on); - assert_eq!(empty.fill, palette.slot_bg); + let face = (palette.slot_face[0], palette.slot_face[1]); + assert!(near(insert.faces, face), "{name}: insert faces {:?}", insert.faces); + // The empty instrument track's slot sits beside the populated one, drawn as an empty cell. + let row = ordinary.rect.center().y; + let empty = harness.output().shapes.iter().any(|shape| { + matches!(&shape.shape, Shape::Rect(rect) if rect.fill == palette.slot_empty + && rect.rect.y_range().contains(row) + && rect.rect.min.x > ordinary.rect.max.x) + }); + assert!(empty, "{name}: missing the empty instrument slot"); assert_eq!(ordinary.text, palette.text); - if ordinary.fill != insert.fill || ordinary.border != insert.border { + if !near(ordinary.faces, insert.faces) || ordinary.border != insert.border { failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}")); } for hovered in [false, true] { @@ -129,8 +156,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { if ratio < 4.5 { failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1")); } - assert_eq!(painted.fill, palette.insert_on, "{name} hover={hovered}"); - assert_eq!(painted.border, palette.button_border, "{name} hover={hovered}"); + assert!(near(painted.faces, face), "{name} hover={hovered}: faces {:?}", painted.faces); if let Some(dir) = &proof_dir { harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap(); } From 0b078d2f22aad966f68147a26d347b5f2e4a0717 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:03:48 +0100 Subject: [PATCH 124/137] Open a send's level only from its fader icon In Pro Tools a send slot's name picks its destination, and only the small fader icon at its right opens the send's level. Here the level control covered the slot's right 42 points, more than half of an Edit window cell, while the icon is 5 points wide. It now covers the icon and 3 points either side. The icon does what the whole level area did: a click opens the Send window, Alt-click sets 0 dB, right-click opens the send's options and a drag sets the level. A short track's square has no icon, so all of it stays the level control. --- crates/ui-egui/src/mix_window.rs | 12 +++++++++-- crates/ui-egui/tests/edit_window_headers.rs | 22 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index fd18207..ccf62cd 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -492,7 +492,7 @@ fn used_slot(p: &egui::Painter, r: Rect, faces: [Color32; 2], mark: Mark, name: p.vline(groove + 1.0, r.y_range(), Stroke::new(1.0, shade(top, 0.12))); let mut text = r.with_min_x(groove + 1.5); if let Some(k) = level { - let well = Rect::from_min_max(pos2(outer.max.x - 9.0, outer.min.y + 2.0), pos2(outer.max.x - 4.0, outer.max.y - 2.0)); + let well = level_icon(r); p.rect_filled(well, 1.0, Color32::BLACK); let inner = well.shrink(1.0); p.rect_filled(inner, 0.0, Color32::from_rgb(36, 38, 8)); @@ -504,6 +504,12 @@ fn used_slot(p: &egui::Painter, r: Rect, faces: [Color32; 2], mark: Mark, name: p.with_clip_rect(text).galley(pos2(x, text.center().y - g.size().y / 2.0), g, color); } +/// A used send's level icon: the small fader at the right of its slot. +fn level_icon(r: Rect) -> Rect { + let outer = r.expand(1.0); + Rect::from_min_max(pos2(outer.max.x - 9.0, outer.min.y + 2.0), pos2(outer.max.x - 4.0, outer.max.y - 2.0)) +} + pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; @@ -680,7 +686,9 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us let t = Tokens::current(); let id = track.id; let snd = track.mixer.sends.get(slot).cloned().flatten(); - let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max); + // As in Pro Tools, only the fader icon opens a send's level; the rest of the slot picks its + // destination. A short track's square has no icon, so all of it is the level. + let level_r = if r.width() < COMPACT_SLOT { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) }; let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); let hovered = ui.rect_contains_pointer(r); diff --git a/crates/ui-egui/tests/edit_window_headers.rs b/crates/ui-egui/tests/edit_window_headers.rs index 67d8a92..0fa90f8 100644 --- a/crates/ui-egui/tests/edit_window_headers.rs +++ b/crates/ui-egui/tests/edit_window_headers.rs @@ -160,3 +160,25 @@ fn the_io_columns_route_menus_stay_open_while_searching_buses() { click(&mut h, search); assert!(h.query_by_label("no output").is_some(), "a click in the bus search closed the Output menu"); } + +#[test] +fn only_a_sends_fader_icon_opens_its_level() { + let (mut h, _, _) = harness("medium", "view.edit_sends_ae"); + h.state_mut().run("track.height", json!({"tracks": ["Snare"], "height": "medium"})).unwrap(); + for _ in 0..4 { + step(&mut h); + } + let snare = h.state().engine.session().track_by_name("Snare").map(|t| t.id.0).unwrap(); + let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == snare).map(|(_, r)| *r).unwrap(); + let send_a = |x: f32| pos2(row[0] + HEADER_W + x, row[1] + 8.5); + + // The right of the name, where the level used to start, picks the destination. + click(&mut h, send_a(50.0)); + assert!(h.query_by_label("DESTINATION").is_some()); + assert_eq!(h.state().ui.send_window, None); + click(&mut h, pos2(800.0, 990.0)); + + click(&mut h, send_a(65.5)); + assert_eq!(h.state().ui.send_window, Some((TrackId(snare), 0))); + assert!(h.query_by_label("DESTINATION").is_none()); +} From 6f8f1e769255d919feb5c0aca11fb4a632ebd6ab Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:00:00 +0100 Subject: [PATCH 125/137] Simplify the Edit window's track rows and plugin slots No change in behaviour or look: the Edit and Mix windows render pixel-identical in both themes. - The Mix strip and the Edit window's I/O column share input_name (In 1 for a recording track with no input) and route_popup, which keeps the route menu open while its bus search is typed in. - The Mix slots and the I/O column's output key share widgets::groove; the Edit header meter and widgets::meter share level_pos. - The column list is read once per frame and passed to each track row; header_width takes it. - The record, input, solo and mute keys are a table, the slot columns one loop, and send marks a char instead of a String. - Inserts look their plugin up once; the unused triangle_right icon is gone. --- crates/ui-egui/src/edit_window.rs | 118 +++++++----------- crates/ui-egui/src/icons.rs | 3 - crates/ui-egui/src/mix_window.rs | 56 +++++---- crates/ui-egui/src/widgets.rs | 20 ++- crates/ui-egui/tests/edit_window_headers.rs | 30 ++--- .../ui-egui/tests/mix_instrument_contrast.rs | 11 +- 6 files changed, 109 insertions(+), 129 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 9b1caeb..b378219 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1,7 +1,7 @@ //! The Edit window: toolbar, rulers, track headers and playlists. use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; -use crate::widgets::{raised_key, shade}; +use crate::widgets::{groove, level_pos, meter_pos, raised_key, shade}; use crate::{Gesture, SoundApp, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; @@ -12,7 +12,6 @@ pub const HEADER_W: f32 = 136.0; pub const COLUMN_W: f32 = 112.0; /// Insert, send and instrument columns: Pro Tools' narrow stacks of slots. const PLUGIN_COLUMN_W: f32 = 75.0; -const IO_COLUMN_W: f32 = 94.0; /// The track colour strip at the left of each track header. const STRIP_W: f32 = 14.0; /// The row under the rulers that holds the column titles, when any column is shown. @@ -27,7 +26,7 @@ const COLUMNS: [Column; 7] = [ ("inserts_fj", "Inserts F-J", "INSERTS F-J", PLUGIN_COLUMN_W), ("sends_ae", "Sends A-E", "SENDS A-E", PLUGIN_COLUMN_W), ("sends_fj", "Sends F-J", "SENDS F-J", PLUGIN_COLUMN_W), - ("io", "I/O", "I/O", IO_COLUMN_W), + ("io", "I/O", "I/O", 94.0), ("comments", "Comments", "COMMENTS", COLUMN_W), ]; @@ -37,8 +36,8 @@ pub fn edit_columns(s: &Session) -> Vec { COLUMNS.into_iter().filter(|c| all || s.edit.flag(&format!("edit_view.{}", c.0))).collect() } -pub fn header_width(s: &Session) -> f32 { - HEADER_W + edit_columns(s).iter().map(|c| c.3).sum::() +pub fn header_width(cols: &[Column]) -> f32 { + HEADER_W + cols.iter().map(|c| c.3).sum::() } pub const RULER_H: f32 = 17.0; @@ -203,7 +202,7 @@ pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) { if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") { ruler_height_total += ruler_height("tempo_editor"); } - let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0); + let minimum = vec2(header_width(&edit_columns(session)) + 64.0, ruler_height_total + 100.0); crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui)); } @@ -221,7 +220,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let titles_h = if cols.is_empty() { 0.0 } else { COLUMN_TITLES_H }; let rulers_h = rulers.iter().map(|r| ruler_height(r)).sum::() + 6.0 + titles_h; let hscroll_h = 14.0; - let hw = header_width(app.engine.session()); + let hw = header_width(&cols); let header = Rect::from_min_max(full.min, pos2(full.min.x + hw, full.max.y - hscroll_h)); let tl = Rect::from_min_max(pos2(full.min.x + hw, full.min.y + rulers_h), pos2(full.max.x - 12.0, full.max.y - hscroll_h)); let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h)); @@ -248,7 +247,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); child.set_clip_rect(clip_rect); - track_row(app, &mut child, TrackId(id), row, tl); + track_row(app, &mut child, TrackId(id), row, tl, &cols); } } // Empty area click clears track selection. @@ -702,7 +701,7 @@ fn column_titles(ui: &Ui, rulers: Rect, cols: &[Column]) { let t = Tokens::current(); let line = Stroke::new(1.0, t.border); let mut x = rulers.min.x + HEADER_W; - let end = x + cols.iter().map(|c| c.3).sum::(); + let end = rulers.min.x + header_width(cols); let row = Rect::from_min_max(pos2(x, rulers.max.y - COLUMN_TITLES_H - 3.0), pos2(end, rulers.max.y)); ui.painter().rect_filled(row, 0.0, t.panel_bg2); ui.painter().hline(rulers.min.x..=end, row.min.y + 0.5, line); @@ -741,7 +740,7 @@ fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) { // ---- track rows ------------------------------------------------------------------------------ -fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) { +fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) { let t = Tokens::current(); let s = app.engine.session(); let Some(track) = s.track(id).cloned() else { return }; @@ -749,7 +748,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) let head = Rect::from_min_max(row.min, pos2(row.min.x + HEADER_W, row.max.y)); let (head_bg, lane_bg) = crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint); let mut x = head.max.x; - for (col, _, _, w) in edit_columns(app.engine.session()) { + for &(col, _, _, w) in cols { header_column(app, ui, &track, col, Rect::from_min_size(pos2(x, row.min.y), vec2(w, row.height())), head_bg); x += w; } @@ -863,12 +862,12 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele 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.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 })); - let open = !track.is_folder() || track.folder_open; - arrow(ui.painter(), round.center(), open, shade(strip, -0.8)); + arrow(ui.painter(), round.center(), !track.is_folder() || track.folder_open, ink); if track.is_folder() { if rresp.on_hover_text(if track.folder_open { "Close folder" } else { "Open folder" }).clicked() { let _ = app.run("track.folder_toggle", json!({"track": id.0})); @@ -881,7 +880,6 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let aresp = ui.interact(ar, ui.id().with(("auto_lane", id.0)), Sense::click()); let showing = AutoParam::parse(&track.view).is_some(); ui.painter().rect_filled(ar, 1.5, shade(strip, if aresp.hovered() || showing { 0.6 } else { 0.45 })); - let ink = shade(strip, -0.8); let (a, b) = (ar.min + vec2(2.5, 2.5), ar.max - vec2(2.5, 2.5)); ui.painter().line_segment([a, b], Stroke::new(1.0, ink)); ui.painter().circle_filled(a, 1.3, ink); @@ -951,8 +949,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let presp = ui.interact(pl_r, ui.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 })); arrow(ui.painter(), pos2(pl_r.center().x, pl_r.min.y + 5.75), true, t.text); - let presp = presp.on_hover_text("Playlists"); - egui::Popup::menu(&presp).show(|ui| { + egui::Popup::menu(&presp.on_hover_text("Playlists")).show(|ui| { if ui.button("New...").clicked() { let _ = app.run("track.playlist_new", json!({"track": id.0})); } @@ -971,22 +968,17 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele }); let m = &track.mixer; if h >= 37.0 { - let key = |i: usize| at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0); - if track.kind.has_playlist() { - if header_toggle(ui, key(0), id, HeaderKey::Rec, m.record_arm, false).clicked() { - let _ = app.run("mix.record_arm", json!({"track": id.0})); - } - if header_toggle(ui, key(1), id, HeaderKey::Input, m.input_monitor, false).clicked() { - let _ = app.run("mix.input_monitor", json!({"track": id.0})); - } - } - if track.kind != TrackKind::Master { - if header_toggle(ui, key(2), id, HeaderKey::Solo, m.solo, false).clicked() { - let _ = app.run("mix.solo", json!({"track": id.0})); - } - let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo); - if header_toggle(ui, key(3), id, HeaderKey::Mute, m.mute, implied).clicked() { - let _ = app.run("mix.mute", json!({"track": id.0})); + let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master); + let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo); + let keys = [ + (HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records), + (HeaderKey::Input, m.input_monitor, false, "mix.input_monitor", records), + (HeaderKey::Solo, m.solo, false, "mix.solo", mixes), + (HeaderKey::Mute, m.mute, implied, "mix.mute", mixes), + ]; + for (i, (key, lit, implied, cmd, shown)) in keys.into_iter().enumerate() { + if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, key, lit, implied).clicked() { + let _ = app.run(cmd, json!({"track": id.0})); } } } @@ -1175,19 +1167,18 @@ fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) { let body = inner.with_min_y(clip.max.y + 1.0); let n = if stereo { 2 } else { 1 }; let w = (body.width() - (n - 1) as f32) / n as f32; - let pos = |v: f32| if v <= 1e-6 { 0.0 } else { crate::widgets::meter_pos(20.0 * v.log10()) }; - let (amber, red) = (crate::widgets::meter_pos(-12.0), crate::widgets::meter_pos(-3.0)); + let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0)); for ch in 0..n { let bar = Rect::from_min_size(pos2(body.min.x + ch as f32 * (w + 1.0), body.min.y), vec2(w, body.height())); let y = |f: f32| bar.max.y - bar.height() * f; - let lv = pos(md.level[ch]); + let lv = level_pos(md.level[ch]); for (lo, hi, col) in [(0.0, amber, t.meter_green), (amber, red, t.meter_yellow), (red, 1.0, t.meter_red)] { p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(hi)..=y(lo)), 0.0, col.gamma_multiply(0.18)); if lv > lo { p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(lv.min(hi))..=y(lo)), 0.0, col); } } - let hv = pos(md.hold[ch]); + let hv = level_pos(md.hold[ch]); if hv > 0.01 { p.hline(bar.x_range(), y(hv), Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow })); } @@ -1196,7 +1187,7 @@ fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) { /// A header column's own Ui, so its slots' ids can't collide with the track header's. fn column_ui(ui: &mut Ui, col: &str, r: Rect) -> Ui { - ui.new_child(egui::UiBuilder::new().id_salt(("edit_column", col.to_string())).max_rect(r)) + ui.new_child(egui::UiBuilder::new().id_salt(("edit_column", col)).max_rect(r)) } /// One optional Edit-window column for a track (I/O, inserts, sends, comments). @@ -1205,7 +1196,7 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R "io" => io_column(app, ui, track, r), "comments" => { ui.painter().rect_filled(r, 0.0, head_bg); - ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, Tokens::current().border)); + ui.painter().vline(r.min.x, r.y_range(), Stroke::new(1.0, Tokens::current().border)); let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0))); if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() { @@ -1223,42 +1214,29 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { let id = track.id; ui.painter().rect_filled(r, 0.0, t.slot_well); let ui = column_ui(ui, "io", r); - let (x, w) = (r.min.x + 7.0, r.width() - 14.0); - if r.height() >= 20.0 { - let input = if track.kind.has_playlist() && track.mixer.input == soundcraft_model::Route::None { - "In 1".to_string() - } else { - crate::mix_window::route_name(app, &track.mixer.input) - }; - let resp = header_selector(&ui, Rect::from_min_size(pos2(x, r.min.y + 4.0), vec2(w, 15.0)), ui.id().with(("in", id.0)), &input, t.text, true); - egui::Popup::menu(&resp.on_hover_text("Input")) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| crate::mix_window::route_menu(app, ui, id, true)); + let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h))); + if let Some(input) = row(4.0, 15.0) { + let resp = header_selector(&ui, input, ui.id().with(("in", id.0)), &crate::mix_window::input_name(app, track), t.text, true); + crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true); } - if r.height() >= 39.0 { - let out = Rect::from_min_size(pos2(x, r.min.y + 21.0), vec2(w, 17.0)); + if let Some(out) = row(21.0, 17.0) { let (sel, fader) = (out.with_max_x(out.max.x - 11.0), out.with_min_x(out.max.x - 11.0)); let resp = ui.interact(sel, ui.id().with(("out", id.0)), Sense::click()); let fresp = ui.interact(fader, ui.id().with(("out_fader", id.0)), Sense::click()); raised_key(ui.painter(), out, t.slot_face, resp.hovered() || fresp.hovered()); - let [top, _] = t.slot_face; - ui.painter().vline(fader.min.x - 0.5, out.min.y + 1.0..=out.max.y - 1.0, Stroke::new(1.0, shade(top, -0.42))); - ui.painter().vline(fader.min.x + 0.5, out.min.y + 1.0..=out.max.y - 1.0, Stroke::new(1.0, shade(top, 0.12))); + groove(ui.painter(), fader.min.x - 0.5, out.min.y + 1.0..=out.max.y - 1.0, t.slot_face[0]); let name = crate::mix_window::route_name(app, &track.mixer.output); ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, name, bold(10.0), t.text); let c = fader.center() + vec2(0.5, 0.0); ui.painter().vline(c.x, c.y - 5.0..=c.y + 5.0, Stroke::new(1.0, t.text)); ui.painter().rect_filled(Rect::from_center_size(c + vec2(0.0, -1.5), vec2(5.0, 2.5)), 0.5, t.text); - egui::Popup::menu(&resp.on_hover_text("Output")) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| crate::mix_window::route_menu(app, ui, id, false)); + crate::mix_window::route_popup(app, &resp.on_hover_text("Output"), id, false); if fresp.on_hover_text("Show in the Mix window").clicked() { let _ = app.run("edit.select", json!({"tracks": [id.0]})); - app.ui.window = crate::MainWindow::Mix; + let _ = app.run("window.mix", json!({})); } } - if r.height() >= 76.0 { - let vr = Rect::from_min_size(pos2(x + 1.0, r.min.y + 45.0), vec2(w - 2.0, 30.0)); + if let Some(vr) = row(45.0, 30.0).map(|vr| vr.shrink2(vec2(1.0, 0.0))) { ui.painter().rect_filled(vr, 2.0, Color32::from_gray(15)); ui.painter().rect_filled(vr.shrink(1.5), 1.5, Color32::BLACK); let m = &track.mixer; @@ -1280,8 +1258,7 @@ fn plugin_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R ui.painter().rect_filled(r, 0.0, t.slot_well); let rows = if r.height() < 16.0 { 0 } else { ((r.height() - 16.0) / 18.0) as usize + 1 }; let cell = |row: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 1.0 + row as f32 * 18.0), vec2(r.width() - 8.0, 15.0)); - let square = - |i: usize, row: usize| Rect::from_min_size(pos2(r.min.x + 4.0 + i as f32 * 14.0, r.min.y + 1.0 + row as f32 * 18.0), vec2(10.0, 15.0)); + let square = |i: usize, row: usize| Rect::from_min_size(cell(row).min + vec2(i as f32 * 14.0, 0.0), vec2(10.0, 15.0)); let mut ui = column_ui(ui, col, r); if col == "instruments" { if rows > 0 && track.kind == TrackKind::Instrument { @@ -1290,17 +1267,14 @@ fn plugin_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R return; } let off = if col.ends_with("ae") { 0 } else { 5 }; - let slots: Vec = match rows { - 0 => Vec::new(), - 1 | 2 => (0..5).map(|i| square(i, 0)).collect(), - _ => (0..rows.min(5)).map(cell).collect(), + let slot = if col.starts_with("inserts") { crate::mix_window::insert_slot } else { crate::mix_window::send_slot }; + let n = match rows { + 0 => 0, + 1 | 2 => 5, + _ => rows.min(5), }; - for (i, sr) in slots.into_iter().enumerate() { - if col.starts_with("inserts") { - crate::mix_window::insert_slot(app, &mut ui, track, off + i, sr); - } else { - crate::mix_window::send_slot(app, &mut ui, track, off + i, sr); - } + for i in 0..n { + slot(app, &mut ui, track, off + i, if rows < 3 { square(i, 0) } else { cell(i) }); } if rows == 2 { for i in 0..5 { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7de4119..885824d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -123,9 +123,6 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { "triangle_down" => { painter.add(Shape::convex_polygon(vec![p(r, 0.25, 0.35), p(r, 0.75, 0.35), p(r, 0.5, 0.68)], c, Stroke::NONE)); } - "triangle_right" => { - painter.add(Shape::convex_polygon(vec![p(r, 0.35, 0.25), p(r, 0.68, 0.5), p(r, 0.35, 0.75)], c, Stroke::NONE)); - } "note" => { painter.circle_filled(p(r, 0.38, 0.72), r.width() * 0.13, c); painter.line_segment([p(r, 0.5, 0.72), p(r, 0.5, 0.2)], s); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index ccf62cd..2d4f74e 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -3,7 +3,7 @@ use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, inset, meter, multi_meter, pan_knob, pan_text, raised_key, rec_toggle, selector_box, + PannerEdit, PannerSpeaker, db_text, fader, groove, inset, meter, multi_meter, pan_knob, pan_text, raised_key, rec_toggle, selector_box, selector_box_with_tooltip, shade, surround_panner, text_toggle, vgradient, }; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; @@ -88,6 +88,16 @@ pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String { } } +pub(crate) fn input_name(app: &SoundApp, track: &Track) -> String { + if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) } +} + +/// The input or output menu under `resp`. It closes only on a click outside, so its bus search +/// field can be typed in. +pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId, input: bool) { + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input)); +} + fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) { let t = Tokens::current(); let Some(track) = app.engine.session().track(id).cloned() else { return }; @@ -221,14 +231,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh section_label(ui, sec, "I / O"); let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0)); let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)); - let input = - if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }; let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r)); - let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text); - egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true)); + let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input_name(app, &track), t.text); + route_popup(app, &resp, id, true); let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r)); let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text); - egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, false)); + route_popup(app, &resp, id, false); // Automation mode. let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0)); ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim); @@ -442,9 +450,10 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: /// What a plugin slot shows at its left: a dot for inserts, the send's letter, or an arrow for /// a send on a short track. -enum Mark<'a> { +#[derive(Clone, Copy)] +enum Mark { Dot, - Letter(&'a str), + Letter(char), Arrow, } @@ -452,14 +461,14 @@ enum Mark<'a> { const COMPACT_SLOT: f32 = 30.0; const NAME_SIZE: f32 = 9.5; -fn draw_mark(p: &egui::Painter, r: Rect, mark: &Mark, color: Color32) { +fn draw_mark(p: &egui::Painter, r: Rect, mark: Mark, color: Color32) { let at = r.min + vec2(4.75, 4.75); match mark { Mark::Dot => { p.circle_filled(at, 2.0, color); } Mark::Letter(l) => { - p.text(pos2(r.min.x + 2.5, r.center().y), Align2::LEFT_CENTER, *l, bold(NAME_SIZE), color); + p.text(pos2(r.min.x + 2.5, r.center().y), Align2::LEFT_CENTER, l, bold(NAME_SIZE), color); } Mark::Arrow => { let at = at + vec2(0.0, 0.5); @@ -473,7 +482,7 @@ fn empty_slot(p: &egui::Painter, r: Rect, hovered: bool, mark: Mark) { let t = Tokens::current(); p.rect_filled(r, 2.0, if hovered { shade(t.slot_empty, 0.06) } else { t.slot_empty }); let color = if matches!(mark, Mark::Letter(_)) { t.slot_mark.lerp_to_gamma(t.text, 0.55) } else { t.slot_mark }; - draw_mark(p, r, &mark, color); + draw_mark(p, r, mark, color); } /// A used slot, a button a point bigger than the empty cell: the mark, a groove, the name @@ -483,14 +492,13 @@ fn used_slot(p: &egui::Painter, r: Rect, faces: [Color32; 2], mark: Mark, name: let t = Tokens::current(); let outer = r.expand(1.0); let top = raised_key(p, outer, faces, false); - draw_mark(p, r, &mark, if matches!(mark, Mark::Dot) { t.slot_mark.lerp_to_gamma(color, 0.35) } else { color }); + draw_mark(p, r, mark, if matches!(mark, Mark::Dot) { t.slot_mark.lerp_to_gamma(color, 0.35) } else { color }); if r.width() < COMPACT_SLOT { return; } - let groove = r.min.x + 10.5; - p.vline(groove, r.y_range(), Stroke::new(1.0, shade(top, -0.42))); - p.vline(groove + 1.0, r.y_range(), Stroke::new(1.0, shade(top, 0.12))); - let mut text = r.with_min_x(groove + 1.5); + let groove_x = r.min.x + 10.5; + groove(p, groove_x, r.y_range(), top); + let mut text = r.with_min_x(groove_x + 1.5); if let Some(k) = level { let well = level_icon(r); p.rect_filled(well, 1.0, Color32::BLACK); @@ -515,12 +523,13 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: let id = track.id; let ins = track.mixer.inserts.get(slot).cloned().flatten(); let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click()); - let name = ins.as_ref().map(|i| plugin_info(&i.plugin).map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default(); + let info = ins.as_ref().and_then(|i| plugin_info(&i.plugin)); + let name = ins.as_ref().map(|i| info.map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default(); match &ins { None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), Some(i) => { let faces = if i.bypass { t.slot_bypassed } else { t.slot_face }; - let color = if plugin_info(&i.plugin).is_some() { t.text } else { t.text_dim }; + let color = if info.is_some() { t.text } else { t.text_dim }; used_slot(ui.painter(), r, faces, Mark::Dot, name, color, None); } } @@ -686,14 +695,13 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us let t = Tokens::current(); let id = track.id; let snd = track.mixer.sends.get(slot).cloned().flatten(); + let compact = r.width() < COMPACT_SLOT; // As in Pro Tools, only the fader icon opens a send's level; the rest of the slot picks its // destination. A short track's square has no icon, so all of it is the level. - let level_r = if r.width() < COMPACT_SLOT { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) }; + let level_r = if compact { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) }; let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); - let hovered = ui.rect_contains_pointer(r); - let letter = ((b'a' + slot as u8) as char).to_string(); - let mark = if r.width() < COMPACT_SLOT { Mark::Arrow } else { Mark::Letter(&letter) }; + let mark = if compact { Mark::Arrow } else { Mark::Letter((b'a' + slot as u8) as char) }; match &snd { Some(s) => { let name = route_name(app, &s.target); @@ -728,7 +736,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us )); level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); } - None => empty_slot(ui.painter(), r, hovered, mark), + None => empty_slot(ui.painter(), r, ui.rect_contains_pointer(r), mark), } if let Some(send) = &snd { egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { @@ -1031,7 +1039,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu } } -pub(crate) fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { +fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { let key = if input { "input" } else { "output" }; let cmd = if input { "track.input" } else { "track.output" }; if ui.button(if input { "no input" } else { "no output" }).clicked() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fec410f..ad0ec6a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -50,6 +50,13 @@ pub fn raised_key(p: &Painter, r: Rect, faces: [Color32; 2], hovered: bool) -> C top } +/// A groove across a raised key whose face starts at `top`: a dark line, then a light one. +pub fn groove(p: &Painter, x: f32, ys: impl Into, top: Color32) { + let ys = ys.into(); + p.vline(x, ys, Stroke::new(1.0, shade(top, -0.42))); + p.vline(x + 1.0, ys, Stroke::new(1.0, shade(top, 0.12))); +} + pub fn inset(p: &Painter, r: Rect, fill: Color32) { let t = Tokens::current(); p.rect(r, 2.0, fill, Stroke::new(1.0, t.border), StrokeKind::Inside); @@ -215,13 +222,9 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { let t = Tokens::current(); let p = ui.painter(); p.rect_filled(r, 0.0, t.meter_bg); - let pos = |v: f32| -> f32 { - let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() }; - meter_pos(db) - }; let h = r.height() - 4.0; let inner = Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 3.0), pos2(r.max.x - 1.0, r.max.y - 1.0)); - let lv = pos(level); + let lv = level_pos(level); let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0)); let mut leds = Mesh::default(); for i in 0..(h / 3.0).clamp(0.0, 1000.0) as usize { @@ -238,7 +241,7 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { quad(&mut leds, Rect::from_x_y_ranges(inner.x_range(), y - 2.0..=y), [col; 4]); } p.add(leds); - let hv = pos(hold); + let hv = level_pos(hold); if hv > 0.01 { let y = inner.max.y - h * hv; p.line_segment([pos2(inner.min.x, y), pos2(inner.max.x, y)], Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow })); @@ -247,6 +250,11 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { p.rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off }); } +/// A linear peak → 0..1 meter position. +pub fn level_pos(v: f32) -> f32 { + meter_pos(if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() }) +} + /// dB → 0..1 meter position (logarithmic-ish scale like hardware meters). pub fn meter_pos(db: f32) -> f32 { if !db.is_finite() || db <= -80.0 { diff --git a/crates/ui-egui/tests/edit_window_headers.rs b/crates/ui-egui/tests/edit_window_headers.rs index 0fa90f8..a9a2052 100644 --- a/crates/ui-egui/tests/edit_window_headers.rs +++ b/crates/ui-egui/tests/edit_window_headers.rs @@ -44,11 +44,11 @@ fn bypassed(h: &Harness<'_, SoundApp>, slot: usize) -> bool { kick(h).mixer.inserts.get(slot).cloned().flatten().is_some_and(|i| i.bypass) } -fn harness(height: &str, view: &str) -> (Harness<'static, SoundApp>, TrackId, [f32; 4]) { +fn harness(track: &str, height: &str, view: &str) -> (Harness<'static, SoundApp>, TrackId, [f32; 4]) { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); app.run(view, json!({})).unwrap(); - app.run("track.height", json!({"tracks": ["Kick"], "height": height})).unwrap(); - let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.run("track.height", json!({"tracks": [track], "height": height})).unwrap(); + let id = app.engine.session().track_by_name(track).unwrap().id; let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( |ui, app: &mut SoundApp| { let ctx = ui.ctx().clone(); @@ -60,13 +60,13 @@ fn harness(height: &str, view: &str) -> (Harness<'static, SoundApp>, TrackId, [f for _ in 0..6 { step(&mut h); } - let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == kick.0).map(|(_, r)| *r).unwrap(); - (h, kick, row) + let row = h.state().edit_layout.rows.iter().find(|(t, _)| *t == id.0).map(|(_, r)| *r).unwrap(); + (h, id, row) } #[test] fn header_keys_freeze_silence_and_show_automation() { - let (mut h, _, row) = harness("medium", "view.edit_io"); + let (mut h, _, row) = harness("Kick", "medium", "view.edit_io"); let at = |x: f32, y: f32| pos2(row[0] + x, row[1] + y); click(&mut h, at(23.5, 65.5)); @@ -86,7 +86,7 @@ fn header_keys_freeze_silence_and_show_automation() { #[test] fn edit_window_insert_slots_open_change_and_add_plugins() { - let (mut h, kick, row) = harness("medium", "view.edit_inserts_ae"); + let (mut h, kick, row) = harness("Kick", "medium", "view.edit_inserts_ae"); let slot = |i: usize| pos2(row[0] + HEADER_W + 37.5, row[1] + 8.5 + i as f32 * 18.0); click(&mut h, slot(0)); @@ -116,7 +116,7 @@ fn edit_window_insert_slots_open_change_and_add_plugins() { #[test] fn short_tracks_show_insert_slots_as_squares() { - let (mut h, kick, row) = harness("small", "view.edit_inserts_ae"); + let (mut h, kick, row) = harness("Kick", "small", "view.edit_inserts_ae"); let square = |i: usize| pos2(row[0] + HEADER_W + 9.0 + i as f32 * 14.0, row[1] + 8.5); click(&mut h, square(1)); @@ -132,7 +132,7 @@ fn short_tracks_show_insert_slots_as_squares() { #[test] fn the_views_menu_stays_open_until_a_click_elsewhere() { - let (mut h, _, _) = harness("medium", "view.edit_inserts_ae"); + let (mut h, _, _) = harness("Kick", "medium", "view.edit_inserts_ae"); let views = h.get_by_label("Views").rect().center(); click(&mut h, views); for column in ["Sends A-E", "I/O"] { @@ -147,7 +147,7 @@ fn the_views_menu_stays_open_until_a_click_elsewhere() { #[test] fn the_io_columns_route_menus_stay_open_while_searching_buses() { - let (mut h, _, row) = harness("medium", "view.edit_io"); + let (mut h, _, row) = harness("Kick", "medium", "view.edit_io"); click(&mut h, pos2(row[0] + HEADER_W + 40.0, row[1] + 29.5)); let bus = h.get_by_label("Bus").rect(); let search = h @@ -163,13 +163,7 @@ fn the_io_columns_route_menus_stay_open_while_searching_buses() { #[test] fn only_a_sends_fader_icon_opens_its_level() { - let (mut h, _, _) = harness("medium", "view.edit_sends_ae"); - h.state_mut().run("track.height", json!({"tracks": ["Snare"], "height": "medium"})).unwrap(); - for _ in 0..4 { - step(&mut h); - } - let snare = h.state().engine.session().track_by_name("Snare").map(|t| t.id.0).unwrap(); - let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == snare).map(|(_, r)| *r).unwrap(); + let (mut h, snare, row) = harness("Snare", "medium", "view.edit_sends_ae"); let send_a = |x: f32| pos2(row[0] + HEADER_W + x, row[1] + 8.5); // The right of the name, where the level used to start, picks the destination. @@ -179,6 +173,6 @@ fn only_a_sends_fader_icon_opens_its_level() { click(&mut h, pos2(800.0, 990.0)); click(&mut h, send_a(65.5)); - assert_eq!(h.state().ui.send_window, Some((TrackId(snare), 0))); + assert_eq!(h.state().ui.send_window, Some((snare, 0))); assert!(h.query_by_label("DESTINATION").is_none()); } diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs index 0a8031a..663cab3 100644 --- a/crates/ui-egui/tests/mix_instrument_contrast.rs +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -9,7 +9,7 @@ struct Slot { /// The face behind the label's centre line. fill: Color32, /// The face's top and bottom colours. - faces: (Color32, Color32), + faces: [Color32; 2], text: Color32, border: Color32, } @@ -52,13 +52,12 @@ fn slot(output: &FullOutput, label: &str) -> Slot { let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color); assert_eq!(text.opacity_factor, 1.0); assert_eq!(foreground.a(), 255); - Slot { rect: rim.rect, fill: top.color.lerp_to_gamma(bottom.color, k), faces: (top.color, bottom.color), text: foreground, border: rim.fill } + Slot { rect: rim.rect, fill: top.color.lerp_to_gamma(bottom.color, k), faces: [top.color, bottom.color], text: foreground, border: rim.fill } } /// Face colours as painted: the anti-aliased edge sits half a pixel inside the gradient's ends. -fn near((a, b): (Color32, Color32), (c, d): (Color32, Color32)) -> bool { - let close = |x: Color32, y: Color32| x.to_array().iter().zip(y.to_array()).all(|(p, q)| p.abs_diff(q) <= 2); - close(a, c) && close(b, d) +fn near(a: [Color32; 2], b: [Color32; 2]) -> bool { + a.iter().zip(b).all(|(x, y)| x.to_array().iter().zip(y.to_array()).all(|(p, q)| p.abs_diff(q) <= 2)) } fn contrast(a: Color32, b: Color32) -> f64 { @@ -125,7 +124,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { let ordinary = slot(harness.output(), "SubSynth"); let insert = slot(harness.output(), "EQ7"); let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK }; - let face = (palette.slot_face[0], palette.slot_face[1]); + let face = palette.slot_face; assert!(near(insert.faces, face), "{name}: insert faces {:?}", insert.faces); // The empty instrument track's slot sits beside the populated one, drawn as an empty cell. let row = ordinary.rect.center().y; From 950f3e708571ee94091ca2b738aa9f607d56d030 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:01:13 +0000 Subject: [PATCH 126/137] Keep clips in order after Trim to Selection --- crates/engine/src/edit.rs | 1 + crates/engine/tests/trim_to_selection.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 crates/engine/tests/trim_to_selection.rs diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 05cc0e6..74806d4 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -221,6 +221,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { n += 1; } } + pl.sort(); n } diff --git a/crates/engine/tests/trim_to_selection.rs b/crates/engine/tests/trim_to_selection.rs new file mode 100644 index 0000000..f632899 --- /dev/null +++ b/crates/engine/tests/trim_to_selection.rs @@ -0,0 +1,20 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, Clip, Session, SourceId, TrackKind}; + +#[test] +fn trim_to_selection_keeps_clips_sorted() { + let mut session = Session::default(); + let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let long = session.new_clip_id(); + let short = session.new_clip_id(); + session.track_mut(track).unwrap().playlist_mut().unwrap().clips = + vec![Clip::audio(long, "long", SourceId(1), 0, 0, 30), Clip::audio(short, "short", SourceId(1), 0, 5, 5)]; + let mut engine = Engine::new(session); + engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 15, "end": 20})).unwrap(); + let clips = engine.session().track(track).unwrap().clips(); + assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::>(), vec![(short, 5, 5), (long, 15, 5)]); + engine.undo(); + let clips = engine.session().track(track).unwrap().clips(); + assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::>(), vec![(long, 0, 30), (short, 5, 5)]); +} From 81ec430341f6fe7dd85501fd0a72bdaeedfe95a3 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:45:03 +0200 Subject: [PATCH 127/137] fix(meter): keep K-weighting stable below ~3.4 kHz sample rates The shelf corner (~1682 Hz) sits at or above Nyquist for rates under ~3.4 kHz. The bilinear prewarp tan() then wraps negative (unstable poles, +750 LUFS readings, -144 integrated) or aliases. Hold the design frequency below Nyquist (0.49 * sr, as Coeffs::design does). Coefficients at 8 kHz and above are unchanged. Fixes #240 Co-Authored-By: Claude Opus 5.5 --- crates/dsp/src/meter.rs | 8 +++++--- crates/dsp/tests/offline_meters.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/dsp/src/meter.rs b/crates/dsp/src/meter.rs index bff76e7..3727f83 100644 --- a/crates/dsp/src/meter.rs +++ b/crates/dsp/src/meter.rs @@ -164,10 +164,12 @@ fn lufs(energy: f64) -> f32 { /// K-weighting pre-filter (high shelf) and RLB high-pass designed for any sample rate. fn k_weighting(sr: f32) -> [Coeffs; 2] { let sr = f64::from(sr); - let pi = std::f64::consts::PI; + // Bilinear prewarp. A corner at or past Nyquist wraps tan() negative (poles outside the unit + // circle) or aliases it, so it is held just below Nyquist, as `Coeffs::design` does. + let prewarp = |f0: f64| (std::f64::consts::PI * f0.min(sr * 0.49) / sr).tan(); // Stage 1: shelving. let (f0, g, q) = (1_681.974_450_955_533, 3.999_843_853_973_347, 0.707_175_236_955_419_6); - let k = (pi * f0 / sr).tan(); + let k = prewarp(f0); let vh = 10f64.powf(g / 20.0); let vb = vh.powf(0.499_666_774_154_541_6); let a0 = 1.0 + k / q + k * k; @@ -180,7 +182,7 @@ fn k_weighting(sr: f32) -> [Coeffs; 2] { }; // Stage 2: high-pass. let (f0, q) = (38.135_470_876_024_44, 0.500_327_037_323_877_3); - let k = (pi * f0 / sr).tan(); + let k = prewarp(f0); let a0 = 1.0 + k / q + k * k; let s2 = Coeffs { b0: 1.0, b1: -2.0, b2: 1.0, a1: (2.0 * (k * k - 1.0) / a0) as f32, a2: ((1.0 - k / q + k * k) / a0) as f32 }; [s1, s2] diff --git a/crates/dsp/tests/offline_meters.rs b/crates/dsp/tests/offline_meters.rs index f83f241..fb19e28 100644 --- a/crates/dsp/tests/offline_meters.rs +++ b/crates/dsp/tests/offline_meters.rs @@ -65,6 +65,21 @@ fn loudness_full_scale_mono_and_44k() { } } +#[test] +fn loudness_stays_bounded_at_low_sample_rates() { + // The K-weighting shelf corner (~1.7 kHz) sits at or above Nyquist below ~3.4 kHz; the + // filter must stay stable there instead of reading hundreds of LUFS (issue #240). + for sr in [1000.0f32, 1200.0, 1600.0, 2000.0, 3000.0, 3400.0, 8000.0, 48_000.0] { + let len = sr as usize; + let mut m = LoudnessMeter::new(sr, 1); + assert_eq!(m.sample_rate(), sr); + m.process(&[sine(100.0, 0.5, sr, len)], len); + for level in [m.integrated_lufs(), m.momentary_lufs(), m.short_term_lufs()] { + assert!(level.is_finite() && (-30.0..=0.0).contains(&level), "{sr}: {level}"); + } + } +} + #[test] fn loudness_gating_ignores_silence() { let sr = 48_000.0; From 3432b52373a4226297c161f67863276e9af28a66 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:52:50 +0200 Subject: [PATCH 128/137] Keep later meter changes on their bars when an earlier meter is edited set_meter and remove_meter now re-place every later meter event at the bar number it started before the edit, so meter changes stay on bar lines and bar|beat positions round-trip. Clips, notes and tempo events keep their positions. Fixes #250 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/event.rs | 16 ++++++ crates/time/src/tempo.rs | 41 +++++++++++--- crates/time/tests/meter_grid_contract.rs | 72 ++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 crates/time/tests/meter_grid_contract.rs diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 34fb355..c6acbde 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -376,4 +376,20 @@ mod tests { let tick = s.tempo.samples_to_ticks(at, s.sample_rate); assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); } + + /// Changing the initial meter keeps a later meter change at its bar (issue #250). + #[test] + fn earlier_meter_change_keeps_later_meter_on_its_bar() { + use soundcraft_time::BarBeat; + let mut e = crate::Engine::default(); + assert_eq!(e.session().sample_rate.hz(), 48_000); + e.execute("event.meter", &json!({"numerator": 3, "denominator": 4, "at": 192_000})).unwrap(); + e.execute("event.meter", &json!({"numerator": 6, "denominator": 4, "at": 0})).unwrap(); + let map = &e.session().tempo; + let later = map.meters().get(1).copied().unwrap(); + assert_eq!((later.numerator, later.denominator), (3, 4)); + assert_eq!(map.bar_beat_at_tick(later.tick), BarBeat { bar: 3, beat: 1, tick: 0 }); + let bar2_beat3 = BarBeat { bar: 2, beat: 3, tick: 0 }; + assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(bar2_beat3)), bar2_beat3); + } } diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 5b87a3a..b179a6d 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -96,13 +96,31 @@ impl TempoMap { } /// Insert or replace the meter at `tick` (snapped to the bar line at or before it). + /// Later meter changes keep their bar numbers, so they stay on bar lines. pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> { if !(1..=64).contains(&numerator) || ![1, 2, 4, 8, 16, 32, 64].contains(&denominator) { return Err(TimeError::Meter(numerator, denominator)); } let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) }; - let ev = MeterEvent { tick, numerator, denominator }; - match self.meters.binary_search_by(|e| e.tick.cmp(&tick)) { + let later = self.take_meters_after(tick); + self.put_meter(MeterEvent { tick, numerator, denominator }); + self.restore_meters_at_bars(later); + Ok(()) + } + + /// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers. + pub fn remove_meter(&mut self, tick: i64) -> bool { + if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) { + return false; + } + let later = self.take_meters_after(tick); + self.meters.retain(|e| e.tick != tick); + self.restore_meters_at_bars(later); + true + } + + fn put_meter(&mut self, ev: MeterEvent) { + match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) { Ok(i) => { if let Some(e) = self.meters.get_mut(i) { *e = ev; @@ -110,16 +128,21 @@ impl TempoMap { } Err(i) => self.meters.insert(i, ev), } - Ok(()) } - pub fn remove_meter(&mut self, tick: i64) -> bool { - if tick == 0 { - return false; + /// Remove the meters after `tick`, returning each with the bar it starts. + fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> { + let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect(); + self.meters.retain(|e| e.tick <= tick); + later + } + + /// Put meters back at the start of their bars under the current earlier meters, in bar order. + fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) { + for (bar, ev) in later { + let tick = self.bar_start_tick(bar); + self.put_meter(MeterEvent { tick, ..ev }); } - let before = self.meters.len(); - self.meters.retain(|e| e.tick != tick); - before != self.meters.len() } /// Tempo in effect at `tick`. diff --git a/crates/time/tests/meter_grid_contract.rs b/crates/time/tests/meter_grid_contract.rs new file mode 100644 index 0000000..5d22432 --- /dev/null +++ b/crates/time/tests/meter_grid_contract.rs @@ -0,0 +1,72 @@ +//! Meter changes stay on bar lines when an earlier meter is edited (issue #250). + +use soundcraft_time::{BarBeat, MeterEvent, SampleRate, TempoMap}; + +fn assert_positions_round_trip(map: &TempoMap) { + for bar in 1..=5 { + let meter = map.meter_at_tick(map.bar_start_tick(bar)); + for beat in 1..=i64::from(meter.numerator) { + let position = BarBeat { bar, beat, tick: 0 }; + assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(position)), position); + } + } +} + +fn meter_bars(map: &TempoMap) -> Vec<(i64, u32, u32)> { + map.meters() + .iter() + .map(|m| { + let bb = map.bar_beat_at_tick(m.tick); + assert_eq!((bb.beat, bb.tick), (1, 0), "meter at tick {} is off the bar line", m.tick); + (bb.bar, m.numerator, m.denominator) + }) + .collect() +} + +#[test] +fn earlier_meter_edit_preserves_bar_positions() { + let mut map = TempoMap::default(); + let at = map.samples_to_ticks(192_000, SampleRate::HZ_48000); + assert_eq!(at, 7680); + map.set_meter(at, 3, 4).unwrap(); + assert_positions_round_trip(&map); + map.set_meter(0, 6, 4).unwrap(); + assert_positions_round_trip(&map); + // The 3/4 change stays at bar 3, now two 6/4 bars in. + assert_eq!(meter_bars(&map), vec![(1, 6, 4), (3, 3, 4)]); + assert_eq!(map.meters().get(1).map(|m| m.tick), Some(2 * 5760)); +} + +#[test] +fn inserting_and_removing_meters_keeps_later_bars() { + let mut map = TempoMap::default(); + map.set_meter(map.bar_start_tick(5), 5, 4).unwrap(); + map.set_meter(map.bar_start_tick(3), 3, 4).unwrap(); + assert_eq!(meter_bars(&map), vec![(1, 4, 4), (3, 3, 4), (5, 5, 4)]); + // Inserting 7/8 at bar 2 keeps 3/4 at bar 3 and 5/4 at bar 5. + map.set_meter(map.bar_start_tick(2), 7, 8).unwrap(); + assert_eq!(meter_bars(&map), vec![(1, 4, 4), (2, 7, 8), (3, 3, 4), (5, 5, 4)]); + assert_positions_round_trip(&map); + // Removing 3/4 leaves 5/4 at bar 5. + let three_four = map.meters().iter().find(|m| m.numerator == 3).map(|m| m.tick).unwrap(); + assert!(map.remove_meter(three_four)); + assert_eq!(meter_bars(&map), vec![(1, 4, 4), (2, 7, 8), (5, 5, 4)]); + assert_positions_round_trip(&map); +} + +#[test] +fn initial_meter_first_and_restoring_controls() { + // Setting the initial meter first, then the later change at the same raw tick, snaps to a bar line. + let mut map = TempoMap::default(); + map.set_meter(0, 6, 4).unwrap(); + map.set_meter(7680, 3, 4).unwrap(); + assert_eq!(map.meters().get(1), Some(&MeterEvent { tick: 5760, numerator: 3, denominator: 4 })); + assert_positions_round_trip(&map); + // Restoring 4/4 after the edit puts the 3/4 change back where it started. + let mut map = TempoMap::default(); + map.set_meter(7680, 3, 4).unwrap(); + map.set_meter(0, 6, 4).unwrap(); + map.set_meter(0, 4, 4).unwrap(); + assert_eq!(map.meters().get(1), Some(&MeterEvent { tick: 7680, numerator: 3, denominator: 4 })); + assert_positions_round_trip(&map); +} From 6fd17d7c0e573106e30bdc93584df1bb13b344fd Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:16:11 +0100 Subject: [PATCH 129/137] Keep shortcuts working after Tab With no widget focused, egui answers Tab by giving keyboard focus to the first widget in the window, and Shift+Tab to the last one. shortcuts::handle ignores every key while a widget has focus, so after Tab (to the next clip boundary) R/T zoom and every other shortcut did nothing until a click elsewhere or Escape. Space and Enter clicked the focused widget instead: on macOS that is the toolbar's Shuffle button, so Space switched the session to Shuffle; on Windows and Linux it opened the SoundCraft menu. While the shortcuts own the keyboard (nothing focused, no dialog open), handle() now cancels egui's focus move for the frame. Tab still moves between fields once one has focus, and inside dialogs. --- crates/ui-egui/src/shortcuts.rs | 2 ++ crates/ui-egui/tests/shortcuts.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..5bfa286 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -86,6 +86,8 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { let _ = app.run(c.0, json!({})); } } + // Tab moves the insertion point here, so egui must not also hand focus to the first widget: that would block every shortcut. + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> { diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index 5d04d47..190cd40 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -25,6 +25,37 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { } } +fn type_in_app(ctx: &egui::Context, app: &mut SoundApp, keys: &[(Key, Modifiers)]) { + let events = keys + .iter() + .flat_map(|&(key, modifiers)| [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })) + .collect(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +#[test] +fn tab_leaves_the_keys_to_the_shortcuts() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + for _ in 0..3 { + type_in_app(&ctx, &mut app, &[]); + } + for tab in [Modifiers::NONE, Modifiers::SHIFT] { + type_in_app(&ctx, &mut app, &[(Key::Tab, tab)]); + assert_eq!(ctx.memory(|m| m.focused()), None, "{tab:?}"); + let zoom = app.engine.session().edit.zoom.samples_per_px; + type_in_app(&ctx, &mut app, &[(Key::T, Modifiers::NONE)]); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, zoom / 2.0, "{tab:?}"); + type_in_app(&ctx, &mut app, &[(Key::R, Modifiers::NONE)]); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, zoom, "{tab:?}"); + } +} + #[test] fn cmd_equals_toggles_mix_and_edit_without_zooming() { let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; From eada1c4cfe27b8b83c98b6dfc7911c8656425205 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:19:06 +0100 Subject: [PATCH 130/137] Fit the section names in narrow Mix strips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A narrow Mix strip's sections are 56 pt wide, and INSERTS A-E (63 pt), INSERTS F-J, SENDS A-E, SENDS F-J and INSTRUMENT (67 pt) ran into the neighbouring strips. Pro Tools' Narrow Mix shortens them instead. - A section name that doesn't fit with 4 pt to spare on each side uses Pro Tools' short form: I A-E, I F-J, S A-E, S F-J, and INST. - The group selector says "nogrp", as Pro Tools' does, instead of "no gr…". - Narrow strips leave the empty comments box without its hint, which wrapped as "comme / nts". Normal strips are unchanged. tests/mix_narrow_headers.rs checks both widths, including that no label in a narrow strip is cut short; it fails on main. --- crates/ui-egui/src/mix_window.rs | 26 ++++++++-- crates/ui-egui/tests/mix_narrow_headers.rs | 57 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/mix_narrow_headers.rs diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..5cb9043 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -75,8 +75,26 @@ fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec &str { + match text { + "INSERTS A-E" => "I A-E", + "INSERTS F-J" => "I F-J", + "SENDS A-E" => "S A-E", + "SENDS F-J" => "S F-J", + "INSTRUMENT" => "INST", + _ => text, + } +} + fn section_label(ui: &Ui, r: Rect, text: &str) { - ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Tokens::current().section_label); + let p = ui.painter(); + let color = Tokens::current().section_label; + let mut galley = p.layout_no_wrap(text.to_owned(), bold(10.0), color); + if galley.size().x > r.width() - 8.0 { + galley = p.layout_no_wrap(short_label(text).to_owned(), bold(10.0), color); + } + p.galley(Align2::CENTER_CENTER.anchor_size(pos2(r.center().x, r.min.y + 7.0), galley.size()).min, galley, color); } fn route_name(app: &SoundApp, r: &Route) -> String { @@ -208,7 +226,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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)); - if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text("comments").font(regular(10.0))).changed() { + let hint = if narrow { "" } else { "comments" }; + if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text(hint).font(regular(10.0))).changed() { let _ = app.engine.execute_merged("track.comments", &json!({"track": id.0, "comments": c}), &format!("comments:{}", id.0)); } y = sec.max.y + 4.0; @@ -258,7 +277,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh y = sec.max.y + 4.0; } // Group selector. - let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or("no group".to_string(), |g| g.name.clone()); + 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()); let gr = Rect::from_min_size(pos2(x0 + 2.0, y), vec2(inner_w - 4.0, 16.0)); let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr)); let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim); diff --git a/crates/ui-egui/tests/mix_narrow_headers.rs b/crates/ui-egui/tests/mix_narrow_headers.rs new file mode 100644 index 0000000..5b19c91 --- /dev/null +++ b/crates/ui-egui/tests/mix_narrow_headers.rs @@ -0,0 +1,57 @@ +use egui::{Shape, vec2}; +use serde_json::json; +use soundcraft_ui_egui::mix_window::strip_width; +use soundcraft_ui_egui::{Services, SoundApp}; + +const FULL: [&str; 7] = ["INSERTS A-E", "INSERTS F-J", "SENDS A-E", "SENDS F-J", "INSTRUMENT", "comments", "no group"]; +const SHORT: [&str; 6] = ["I A-E", "I F-J", "S A-E", "S F-J", "INST", "nogrp"]; + +fn painted_labels(narrow: bool) -> Vec<(String, f32, bool)> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("window.mix", json!({})).unwrap(); + app.run("view.mix_section", json!({"section": "all"})).unwrap(); + app.run("view.mix_section", json!({"section": "instruments"})).unwrap(); + app.ui.narrow_mix = narrow; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + harness.step(); + } + harness + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if FULL.contains(&t.galley.job.text.as_str()) || SHORT.contains(&t.galley.job.text.as_str()) => { + Some((t.galley.job.text.clone(), t.visual_bounding_rect().width(), t.galley.elided)) + } + _ => None, + }) + .collect() +} + +#[test] +fn narrow_strips_use_short_section_names_that_fit() { + let labels = painted_labels(true); + for name in SHORT { + assert!(labels.iter().any(|(text, ..)| text == name), "{name} missing in {labels:?}"); + } + for (text, width, elided) in &labels { + assert!(SHORT.contains(&text.as_str()), "{text} painted in a narrow strip, where it doesn't fit"); + assert!(*width <= strip_width(true) - 16.0 && !elided, "{text} is {width} pt wide, elided: {elided}"); + } +} + +#[test] +fn normal_strips_keep_the_full_section_names() { + let labels = painted_labels(false); + for name in FULL { + assert!(labels.iter().any(|(text, ..)| text == name), "{name} missing in {labels:?}"); + } + assert!(labels.iter().all(|(text, ..)| FULL.contains(&text.as_str())), "{labels:?}"); +} From 40c5e9b42a970f25cf94bf645c8f42df9d80a028 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 07:06:22 +0200 Subject: [PATCH 131/137] VST3: hand host-set parameter values to the controller before saving state (fixes #257) Plugin::set_param only queues the value for the processor, so a parameter that lives in the controller alone (no processor state) was saved with the controller's old value and came back wrong after load_state. set_param may run on the audio thread, so it now just marks the value; save_state, which already calls into the controller, pushes the marked values with setParamNormalized before taking the snapshot. --- crates/vst3-host/src/ffi.rs | 9 +++++++++ crates/vst3-host/src/plugin.rs | 20 ++++++++++++++++++++ crates/vst3-host/tests/fixture_e2e.rs | 25 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 341ad53..884eb52 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1239,6 +1239,15 @@ impl Instance { v.is_finite().then(|| v.clamp(0.0, 1.0)) } + /// Tells the controller about a value the host set (`setParamNormalized`). + pub fn set_normalized(&self, id: u32, n: f64) { + let Some(c) = &self.controller else { return }; + if n.is_finite() { + // SAFETY: valid controller. + unsafe { c.setParamNormalized(id, n.clamp(0.0, 1.0)) }; + } + } + pub fn value_text(&self, id: u32, normalized: f64) -> Option { let c = self.controller.as_ref()?; let mut s: sv::String128 = [0; 128]; diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 254671f..ba30610 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -33,6 +33,10 @@ pub struct Vst3Plugin { values: Vec, /// Normalized values waiting for the next block (coalesced per id; bounded). pending: Vec<(u32, f64)>, + /// Host-set values the controller has not seen yet (same order as `ids`). `set_param` may + /// run on the audio thread, so it never calls the controller; `save_state`, which already + /// does, hands them over first. + unsynced: Vec, /// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`. notes: Vec, inputs: BusBuffers, @@ -76,6 +80,7 @@ impl Vst3Plugin { info, values: Vec::with_capacity(ids.len()), pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)), + unsynced: vec![false; ids.len()], notes: Vec::with_capacity(EVENT_CAP), inputs: BusBuffers::new(&[], 0), outputs: BusBuffers::new(&[], 0), @@ -145,6 +150,7 @@ impl Vst3Plugin { } } self.pending.clear(); + self.unsynced.fill(false); } /// Inserts keeping the queue time-sorted; drops the event when the queue is full (never @@ -205,6 +211,9 @@ impl Plugin for Vst3Plugin { if let Some(slot) = self.values.get_mut(i) { *slot = v; } + if let Some(u) = self.unsynced.get_mut(i) { + *u = true; + } let Some(n) = self.normalized_from_plain(i, v) else { return true }; if let Some(e) = self.pending.iter_mut().find(|(pid, _)| *pid == vid) { e.1 = n; @@ -319,6 +328,17 @@ impl Plugin for Vst3Plugin { } fn save_state(&mut self) -> Option> { + // A parameter only the controller stores (no processor state) would otherwise be saved + // with the controller's stale value. + for i in 0..self.ids.len() { + if !self.unsynced.get(i).copied().unwrap_or(false) { + continue; + } + if let (Some(&id), Some(n)) = (self.ids.get(i), self.values.get(i).and_then(|&v| self.normalized_from_plain(i, v))) { + self.inst.set_normalized(id, n); + } + } + self.unsynced.fill(false); self.inst.save_state() } diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7..b36e9bb 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -208,6 +208,31 @@ fn state_saves_component_and_controller_and_restores_them() { assert_eq!(q.param("7"), Some(1.0)); } +#[test] +fn host_set_controller_param_survives_state_round_trip() { + // Freq lives only in the controller (the processor ignores it), so processing never carries + // the host's value there: the state save has to. + let id = id_of("Fixture Gain"); + let mut p = soundcraft_vst3_host::create(&id).unwrap(); + p.prepare(48_000.0, 64, 2); + assert!(p.set_param("7", 1.5)); + assert!(p.set_param("12", 200.0)); + let mut p = std::thread::spawn(move || { + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| (x - 0.75).abs() < 1e-4)); + p + }) + .join() + .unwrap(); + assert!((p.param("12").unwrap() - 200.0).abs() < 0.01); + let blob = p.save_state().expect("state"); + let mut q = soundcraft_vst3_host::create(&id).unwrap(); + assert!(q.load_state(&blob)); + assert!((q.param("7").unwrap() - 1.5).abs() < 1e-4, "{:?}", q.param("7")); + assert!((q.param("12").unwrap() - 200.0).abs() < 0.01, "{:?}", q.param("12")); +} + #[test] fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); From bee999584f76ebd7adfcc272a770b93d4b615c3c Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 12 Oct 2026 23:54:33 -0600 Subject: [PATCH 132/137] Make clip.unloop recognise loops made by clip.loop, even after edits clip.unloop found loop copies only by comparing content of back-to-back clips. As soon as the source clip's notes were edited, its content no longer matched the copies and unloop answered "the selection is not a looped clip" for the very loop that clip.loop had just created. clip.loop now tags the origin and its copies with a shared `loop_id` (new optional Clip field, serde default so old sessions load), and unloop treats equal-length adjacent clips with the same loop_id as one loop. The content comparison stays as a fallback for loops without a tag. unloop with mode=flatten clears the tag so the copies become ordinary independent clips. Propagating edits from the source to the copies is not part of this change. --- crates/engine/src/cmd/clip.rs | 9 +++++- crates/engine/src/cmd/clip_more.rs | 50 ++++++++++++++++++++++++++---- crates/model/src/clip.rs | 5 +++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e537..e568c1b 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -288,8 +288,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut made = 0; for id in ids { - let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; + let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000); + if reps > 0 && c.loop_id.is_none() { + let lid = s.alloc(); + if let Some(src) = s.find_clip_mut(id) { + src.loop_id = Some(lid); + } + c.loop_id = Some(lid); + } let mut copies = Vec::new(); for k in 1..=reps { let mut nc = c.clone(); diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 74dfd8d..b135583 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -101,12 +101,13 @@ fn regroup(e: &mut Engine, p: &Value) -> Result { fn same_material(a: &Clip, b: &Clip) -> bool { a.length == b.length - && match (&a.content, &b.content) { - (ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, - (ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2, - (ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, - _ => false, - } + && (a.loop_id.is_some() && a.loop_id == b.loop_id + || match (&a.content, &b.content) { + (ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, + (ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2, + (ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, + _ => false, + }) } /// The loop a clip belongs to: back-to-back copies of the same material. Origin first. @@ -160,6 +161,7 @@ fn unloop(e: &mut Engine, p: &Value) -> Result { } else { for c in pl.clips.iter_mut().filter(|c| chain.contains(&c.id)) { c.group = None; + c.loop_id = None; } } } @@ -304,6 +306,42 @@ mod tests { assert!(e.execute("clip.unloop", &json!({"clip": ids[3].0})).is_err()); } + /// A 100-sample MIDI clip with one note, on a fresh instrument-less MIDI track. + fn midi_engine() -> (Engine, TrackId, ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let id = s.new_clip_id(); + let mut seq = soundcraft_midi::Sequence::default(); + seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 240 }); + crate::edit::place_clip(&mut s, t, Clip::midi(id, "m", 0, 100, seq)); + s.edit.selected_tracks = vec![t]; + (Engine::new(s), t, id) + } + + #[test] + fn unloop_recognises_loops_after_source_edit() { + let (mut e, t, id) = midi_engine(); + assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "count": 4})).unwrap()["loops"], 3); + // Editing the source note makes the copies' content differ from it. + if let Some(c) = e.session_mut().find_clip_mut(id) + && let ClipContent::Midi { sequence } = &mut c.content + { + sequence.notes[0].length = 1100; + } + e.execute("clip.unloop", &json!({"clip": id.0})).unwrap(); + let left: Vec = e.session().track(t).unwrap().clips().iter().map(|c| c.id).collect(); + assert_eq!(left, vec![id]); + } + + #[test] + fn unloop_flatten_turns_loop_into_independent_clips() { + let (mut e, t, id) = midi_engine(); + e.execute("clip.loop", &json!({"clip": id.0, "count": 3})).unwrap(); + e.execute("clip.unloop", &json!({"clip": id.0, "mode": "flatten"})).unwrap(); + assert_eq!(e.session().track(t).unwrap().clips().len(), 3); + assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none())); + } + #[test] fn regroup_reuses_group() { let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]); diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 4d30bea..2433dfb 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -105,6 +105,10 @@ pub struct Clip { /// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews). #[serde(default)] pub group: Option, + /// Marks the clips made by `clip.loop` (the origin and its copies share one id), so + /// `clip.unloop` still recognises the loop after the source's content is edited. + #[serde(default)] + pub loop_id: Option, } fn one() -> f64 { @@ -131,6 +135,7 @@ impl Clip { color: None, stretch: 1.0, group: None, + loop_id: None, } } From 165fa98a90c0e62367dcff1575b8fa70ef1ed4d5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:21:20 -0400 Subject: [PATCH 133/137] Count tracks, not lanes, in track.clear_trim_automation's result Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/track_more.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index e14e067..db62780 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -455,7 +455,9 @@ pub(super) fn clear_trim(e: &mut Engine, p: &Value) -> Result { let Some(tr) = s.track_mut(*t) else { continue }; let before = tr.automation.len(); tr.automation.retain(|l| l.param != trim_param()); - n += before - tr.automation.len(); + if tr.automation.len() < before { + n += 1; + } } Ok(json!({"tracks": n})) } From ad8beac9b4003a82a1981dd7bb8198d38d0af5f0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:21:20 -0400 Subject: [PATCH 134/137] Look up the MIDI track offset without allocating on the audio thread schedule_notes ran format!() every block for every instrument track; scan the edit values by prefix instead. Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 064116e..869a304 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1305,12 +1305,26 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut } } +/// 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. +fn midi_offset(s: &Session, t: &Track) -> i64 { + const PREFIX: &str = "midi.offset."; + s.edit + .values + .range::((std::ops::Bound::Included(PREFIX), std::ops::Bound::Unbounded)) + .take_while(|(k, _)| k.starts_with(PREFIX)) + .find(|(k, _)| k.get(PREFIX.len()..).and_then(|id| id.parse::().ok()) == Some(t.id.0)) + .map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .map_or(0, |v| v as i64) +} + /// Send note on/offs for MIDI clips overlapping the block. fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec) { let sr = s.sample_rate; // MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so // the block looks that far back into the clips. - let offset = if s.edit.values.is_empty() { 0 } else { s.edit.value(&format!("midi.offset.{}", t.id.0), 0.0) as i64 }; + let offset = midi_offset(s, t); let pos = pos.saturating_sub(offset); let end = pos.saturating_add(frames as i64); for clip in t.clips() { From 5183bc4dba29e0068d92588987ac1df37347098e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:22:21 -0400 Subject: [PATCH 135/137] rustfmt Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft-web/src/web.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 452e807..90477cc 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -67,9 +67,8 @@ enum Purpose { } const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION]; -const AUDIO_EXTS: &[&str] = &[ - "wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm", -]; +const AUDIO_EXTS: &[&str] = + &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"]; const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"]; impl Purpose { From 8222048c10c049d1d869ac64d5fe1bbc542a9cf6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:30:30 -0400 Subject: [PATCH 136/137] Pass the Feet+Frames start to parse_position in the bar-offset test (after #294) Co-Authored-By: Claude Opus 5.5 --- crates/time/src/format.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index 7838757..e193151 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -155,7 +155,7 @@ mod tests { #[test] fn minimum_bar_parses_without_overflow() { let (sr, map, rate) = ctx(); - let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0); + let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0); assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0)); assert_eq!(p("2|3|000").ok(), Some(144_000)); assert_eq!(p("0|1|000").ok(), Some(-96_000)); From 391a0aa66c50764dba6acbe3c0496408d312587c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:49:31 -0400 Subject: [PATCH 137/137] Drop track.timebase and track.elastic from the unreachable-commands list Both gained a UI path from PRs that landed today, so the reachability ratchet from #358 now fails on main. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/command_reachability.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/tests/command_reachability.rs b/crates/ui-egui/tests/command_reachability.rs index 5768ed0..384401e 100644 --- a/crates/ui-egui/tests/command_reachability.rs +++ b/crates/ui-egui/tests/command_reachability.rs @@ -32,10 +32,8 @@ const KNOWN_UNREACHED: &[&str] = &[ "setup.hardware", // menu opens the Playback Engine window, which doesn't run it "setup.playback_engine", // same "setup.reset_edit_state", - "track.elastic", "track.load_preset", "track.pin", - "track.timebase", "track.trim_automation_write", "track.vca", "transport.pause",