From 4fd015f836c2d409a83515adbcfec1a219ef9212 Mon Sep 17 00:00:00 2001 From: SteamSmith <341182916+SteamSmith@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:13:09 -0400 Subject: [PATCH 001/154] control server: bound request lines and concurrent connections Request lines were read with BufRead::lines, which buffers a whole line before anything looks at its length, and every accepted connection got its own thread with no limit. - Read each line with a bounded read: a line longer than MAX_LINE (256 MiB) is refused with an error reply and the connection closed, without buffering the rest of it. - Serve at most MAX_CONNECTIONS (16) connections at once; further ones get an error line and are closed. A slot is released when its connection ends, including when its thread can't start or panics. Request handling is otherwise unchanged. Adds tests for the line limit, CRLF and final-line handling, an end-to-end request over loopback, and the connection cap. Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft/src/control_server.rs | 160 ++++++++++++++++++++++++-- 1 file changed, 149 insertions(+), 11 deletions(-) diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs index 51758b9..46061a4 100644 --- a/apps/soundcraft/src/control_server.rs +++ b/apps/soundcraft/src/control_server.rs @@ -1,13 +1,24 @@ //! Loopback JSON-lines control server: one request per line, one reply per line. //! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps. +//! +//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without +//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]). use serde_json::{Value, json}; use soundcraft_ui_egui::ControlRequest; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; +/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry +/// large payloads (base64 data) still fit. +const MAX_LINE: usize = 256 * 1024 * 1024; +/// Connections served at once; further ones get an error line and are closed. +const MAX_CONNECTIONS: usize = 16; + pub fn start(port: u16, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); let listener = match TcpListener::bind(("127.0.0.1", port)) { @@ -20,28 +31,85 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port); log::info!("control server listening on 127.0.0.1:{actual}"); println!("SOUNDCRAFT_CONTROL_PORT={actual}"); - let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || { - for stream in listener.incoming().flatten() { - let tx = tx.clone(); - let ctx = ctx.clone(); - let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || serve(stream, tx, ctx)); - } - }); + let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx)); if let Err(e) = spawned { log::error!("control thread failed: {e}"); } rx } +/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when +/// its thread can't be started, or panics). +struct Slot(Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { + let open = Arc::new(AtomicUsize::new(0)); + for mut stream in listener.incoming().flatten() { + if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + open.fetch_sub(1, Ordering::SeqCst); + let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); + continue; + } + let slot = Slot(Arc::clone(&open)); + let tx = tx.clone(); + let ctx = ctx.clone(); + let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || { + let _slot = slot; + serve(stream, tx, ctx); + }); + } +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). +/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. +fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { + buf.clear(); + // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + if n == 0 { + return Ok(false); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(true); + } + if buf.len() > max { + return Err(format!("request line longer than {max} bytes; closing the connection")); + } + // Last line without a newline, then end of stream. + Ok(true) +} + fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - for line in BufReader::new(read).lines() { - let Ok(line) = line else { break }; + let mut reader = BufReader::new(read); + let mut buf = Vec::new(); + loop { + match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { + Ok(true) => {} + Ok(false) => break, + Err(e) => { + let _ = writeln!(out, "{}", json!({"ok": false, "error": e})); + break; + } + } + // Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did. + let Ok(line) = std::str::from_utf8(&buf) else { break }; if line.trim().is_empty() { continue; } - let reply = match serde_json::from_str::(&line) { + let reply = match serde_json::from_str::(line) { Ok(msg) => { let id = msg.get("id").cloned().unwrap_or(Value::Null); let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); @@ -64,3 +132,73 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_are_read_up_to_the_limit() { + let mut buf = Vec::new(); + let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(buf, b"bb", "CRLF is stripped"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(buf, b"cc", "a last line without a newline is still read"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream"); + } + + #[test] + fn an_overlong_line_is_refused_without_reading_the_rest() { + let mut buf = Vec::new(); + let long = [b'x'; 100]; + let mut input: &[u8] = &long; + assert!(read_bounded_line(&mut input, &mut buf, 10).is_err()); + assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len()); + assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread"); + } + + /// The control server on a loopback port, with a stand-in UI that echoes each method. + fn serve_echo() -> std::net::SocketAddr { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true, "method": req.method})); + } + }); + addr + } + + #[test] + fn requests_are_answered_with_their_ids() { + let addr = serve_echo(); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap(); + let mut r = BufReader::new(c); + for (id, method) in [(3, "ping"), (4, "pong")] { + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}"); + } + } + + #[test] + fn extra_connections_are_refused() { + let addr = serve_echo(); + // Idle connections are accepted in order and held open. + let held: Vec = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect(); + let mut extra = TcpStream::connect(addr).unwrap(); + extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut line = String::new(); + BufReader::new(&mut extra).read_line(&mut line).unwrap(); + assert!(line.contains("too many control connections"), "{line}"); + drop(held); + } +} From e26a5f5f427b1ee42fd822d94fa55935c665a715 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:22:03 +0100 Subject: [PATCH 002/154] Zoom Toggle: E zooms in on the edit selection, E again zooms back out In Pro Tools, E (with Commands Keyboard Focus on) zooms the Edit window to the edit selection and fits the selected tracks to the window, and pressing it again brings back the view from before. SoundCraft had no Zoom Toggle. view.zoom_toggle, in its own command module, stores the zoom, the scroll and the selected tracks' heights in the session's new edit.zoom_toggle, so the view to go back to is saved with the session. It then zooms so the selection fills the timeline and gives the selected tracks equal shares of the track area: Pro Tools' Fit To Window height. Hidden tracks are left alone. Run again, it puts all of that back. With only an insertion point the zoom stays and its tracks are fitted. The UI passes the track area's size; agents can pass width_px and height_px. TrackHeight::Fit, which nothing could reach and which drew 88 points tall, now carries its height in points, so every place that sizes a track row reads it unchanged. The Edit window scrolls the first fitted track to the top when Zoom Toggle turns on, and back to where it was when it turns off, whichever way it was toggled (key, button, control channel, undo). As in Pro Tools, E works in the Edit window while Commands Keyboard Focus (the a-z button) is on, on every OS. A Zoom Toggle button left of the Zoomer lights while it is on and toggles it too. This follows Pro Tools' Zoom Toggle settings for audio editing: Selection for both zooms, Fit To Window, no track view change. Its other preferences, Ctrl+E (Pro Tools' key with the focus off), Alt+Shift+E (cancel) and following the edit selection are left out. Tests cover the zoom, the fitted heights, going back, insertion points, hostile sizes and stored views, and E and the toolbar button on macOS, Windows and Linux. --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/zoom_toggle.rs | 88 ++++++++++++++++++++++++++++ crates/model/src/lib.rs | 2 +- crates/model/src/session.rs | 11 ++++ crates/model/src/track.rs | 7 ++- crates/ui-egui/src/edit_window.rs | 25 ++++++++ crates/ui-egui/src/icons.rs | 6 ++ crates/ui-egui/src/shortcuts.rs | 4 ++ crates/ui-egui/src/toolbar.rs | 5 +- crates/ui-egui/tests/zoom_toggle.rs | 43 ++++++++++++++ 10 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/zoom_toggle.rs create mode 100644 crates/ui-egui/tests/zoom_toggle.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b72971..f5088a7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -33,6 +33,7 @@ mod video; mod view; mod view_more; mod vst3; +mod zoom_toggle; pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. @@ -124,6 +125,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(plugin_state::specs()); v.extend(instrument::specs()); v.extend(video::specs()); + v.extend(zoom_toggle::specs()); v }) } diff --git a/crates/engine/src/cmd/zoom_toggle.rs b/crates/engine/src/cmd/zoom_toggle.rs new file mode 100644 index 0000000..d91d515 --- /dev/null +++ b/crates/engine/src/cmd/zoom_toggle.rs @@ -0,0 +1,88 @@ +//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{TrackHeight, ZoomToggle}; + +pub fn specs() -> Vec { + vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)] +} + +fn toggle(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + if let Some(back) = s.edit.zoom_toggle.take() { + s.edit.zoom.samples_per_px = back.samples_per_px.clamp(0.25, 1_000_000.0); + s.edit.zoom.scroll = back.scroll.clamp(0, MAX_POSITION); + for t in &mut s.tracks { + t.height = back.heights.iter().find(|(id, _)| *id == t.id).map_or(t.height, |(_, h)| *h); + } + return Ok(json!({"on": false})); + } + let heights: Vec<_> = s.tracks.iter().filter(|t| !t.hidden && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.height)).collect(); + // Rows sit 1 point apart. + let each = f64_or(p, "height_px", 600.0) / heights.len().max(1) as f64 - 1.0; + let fit = TrackHeight::Fit(each.clamp(16.0, f64::from(u16::MAX)) as u16); + for t in s.tracks.iter_mut().filter(|t| heights.iter().any(|(id, _)| *id == t.id)) { + t.height = fit; + } + let (r, z) = (s.edit.selection, &mut s.edit.zoom); + s.edit.zoom_toggle = Some(ZoomToggle { samples_per_px: z.samples_per_px, scroll: z.scroll, heights }); + if !r.is_empty() { + z.samples_per_px = (r.len() as f64 / f64_or(p, "width_px", 1200.0).max(100.0)).clamp(0.25, 1_000_000.0); + z.scroll = r.start; + } + Ok(json!({"on": true})) +} + +#[cfg(test)] +mod tests { + use super::*; + use TrackHeight::{Fit, Large, Medium}; + + fn engine(select: Value) -> Engine { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 3})).unwrap(); + e.execute("view.zoom_set", &json!({"samples_per_px": 500.0})).unwrap(); + e.execute("view.scroll", &json!({"to": 1000})).unwrap(); + e.execute("edit.select", &select).unwrap(); + e + } + + fn view(e: &Engine) -> (f64, Samples, Vec) { + let s = e.session(); + (s.edit.zoom.samples_per_px, s.edit.zoom.scroll, s.tracks.iter().map(|t| t.height).collect()) + } + + #[test] + fn the_selection_fills_the_window_and_toggling_again_goes_back() { + let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000})); + let steps = e.undo_history().len(); + assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true})); + assert_eq!(view(&e), (48.0, 48_000, vec![Fit(249), Medium, Fit(249)])); + e.execute("view.zoom_in", &json!({})).unwrap(); + e.execute("track.height", &json!({"tracks": ["Audio 2"], "height": "large"})).unwrap(); + assert_eq!(e.execute("view.zoom_toggle", &json!({})).unwrap(), json!({"on": false})); + assert_eq!(view(&e), (500.0, 1000, vec![Medium, Large, Medium])); + assert_eq!(e.undo_history().len(), steps + 1); + e.execute("edit.select", &json!({"tracks": ["Audio 2"], "start": 5000})).unwrap(); + e.execute("view.zoom_toggle", &json!({"height_px": 300.0})).unwrap(); + assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium])); + } + + #[test] + fn hostile_sizes_and_stored_views_stay_in_range() { + let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION})); + e.execute("view.zoom_toggle", &json!({"width_px": -5.0, "height_px": 1e300})).unwrap(); + assert_eq!((view(&e).0, view(&e).2[0]), (1_000_000.0, Fit(u16::MAX))); + e.execute("view.zoom_toggle", &json!({})).unwrap(); + e.execute("view.zoom_toggle", &json!({"width_px": "wide", "height_px": -1.0})).unwrap(); + assert_eq!(view(&e).2[0], Fit(16)); + let audio_1 = e.session().tracks[0].id; + let heights = vec![(TrackId(9999), TrackHeight::Jumbo), (audio_1, Fit(0))]; + e.session_mut().edit.zoom_toggle = Some(ZoomToggle { samples_per_px: -1.0, scroll: Samples::MIN, heights }); + e.execute("view.zoom_toggle", &json!({})).unwrap(); + assert_eq!(view(&e), (0.25, 0, vec![Fit(0), Medium, Medium])); + assert_eq!(Fit(0).points(), 16.0); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 55d605d..1e1f39c 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, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState, ZoomToggle}; 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 f3537d7..7a0725f 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -148,6 +148,14 @@ impl Default for ZoomState { } } +/// The view Zoom Toggle goes back to. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ZoomToggle { + pub samples_per_px: f64, + pub scroll: Samples, + pub heights: Vec<(TrackId, crate::TrackHeight)>, +} + /// Edit-window state saved with the session (selection, tools, modes, view settings). #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EditState { @@ -169,6 +177,8 @@ pub struct EditState { pub main_counter: TimeFormat, pub sub_counter: Option, pub zoom: ZoomState, + /// Set while Zoom Toggle is on. + pub zoom_toggle: Option, /// Visible rulers. pub rulers: Vec, pub link_timeline_edit: bool, @@ -241,6 +251,7 @@ impl Default for EditState { main_counter: TimeFormat::MinSecs, sub_counter: Some(TimeFormat::Samples), zoom: ZoomState::default(), + zoom_toggle: None, rulers: vec!["bars_beats".into(), "min_secs".into(), "tempo".into(), "meter".into(), "markers".into()], link_timeline_edit: true, link_track_edit: false, diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index aa2b64f..4d0d313 100644 --- a/crates/model/src/track.rs +++ b/crates/model/src/track.rs @@ -93,7 +93,8 @@ pub enum TrackHeight { Large, Jumbo, Extreme, - Fit, + /// Fit To Window: this many points. + Fit(u16), } impl TrackHeight { @@ -115,7 +116,7 @@ impl TrackHeight { TrackHeight::Large => 132.0, TrackHeight::Jumbo => 220.0, TrackHeight::Extreme => 440.0, - TrackHeight::Fit => 88.0, + TrackHeight::Fit(points) => f32::from(points.max(16)), } } pub fn label(self) -> &'static str { @@ -127,7 +128,7 @@ impl TrackHeight { TrackHeight::Large => "large", TrackHeight::Jumbo => "jumbo", TrackHeight::Extreme => "extreme", - TrackHeight::Fit => "fit to window", + TrackHeight::Fit(_) => "fit to window", } } pub fn from_id(s: &str) -> Option { diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 906ae1e..b4850f5 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -32,6 +32,8 @@ pub struct EditLayout { pub rows: Vec<(u64, [f32; 4])>, pub scroll_y: f32, pub content_h: f32, + /// While Zoom Toggle is on: the vertical scroll to go back to. + zoom_back: Option, /// Follow-hold: the view was moved by something other than the playhead /// follow (a manual pan, scrollbar, universe jump, or programmatic /// scroll) during playback, so timeline auto-scroll stays paused until @@ -187,6 +189,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { 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)); app.edit_layout.timeline = [tl.min.x, tl.min.y, tl.max.x, tl.max.y]; + follow_zoom_toggle(app); 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)); @@ -273,6 +276,28 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { } } +/// Zoom Toggle sized to the track area on screen. +pub fn zoom_toggle(app: &mut SoundApp) { + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0})); +} + +/// Zoom Toggle scrolls the first track it fitted to the top, and back when it turns off. +fn follow_zoom_toggle(app: &mut SoundApp) { + let l = &mut app.edit_layout; + match (&app.engine.session().edit.zoom_toggle, l.zoom_back) { + (Some(z), None) => { + l.zoom_back = Some(l.scroll_y); + let content_top = l.rows.first().map_or(0.0, |(_, r)| r[1]); + if let Some((_, r)) = l.rows.iter().find(|(id, _)| z.heights.iter().any(|(t, _)| t.0 == *id)) { + l.scroll_y = r[1] - content_top; + } + } + (None, Some(y)) => (l.scroll_y, l.zoom_back) = (y, None), + _ => {} + } +} + // ---- rulers ---------------------------------------------------------------------------------- fn ruler_label(id: &str) -> &'static str { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7de4119..06a3f39 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -47,6 +47,12 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { painter.circle_stroke(p(r, 0.42, 0.4), r.width() * 0.2, s); painter.line_segment([p(r, 0.56, 0.55), p(r, 0.8, 0.8)], Stroke::new(s.width * 1.6, c)); } + "zoom_toggle" => { + for (x, y, dx, dy) in [(0.18, 0.24, 0.16, 0.14), (0.82, 0.24, -0.16, 0.14), (0.18, 0.76, 0.16, -0.14), (0.82, 0.76, -0.16, -0.14)] { + painter.add(Shape::line(vec![p(r, x + dx, y), p(r, x, y), p(r, x, y + dy)], s)); + } + painter.rect_filled(Rect::from_min_max(p(r, 0.34, 0.41), p(r, 0.66, 0.59)), 1.0, c); + } "trim" => { painter.line_segment([p(r, 0.5, 0.2), p(r, 0.5, 0.8)], s); painter.line_segment([p(r, 0.15, 0.5), p(r, 0.85, 0.5)], s); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index fb978c3..f14c62d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -215,6 +215,10 @@ fn commands_focus(app: &mut SoundApp, key: Key) -> bool { tab_clip(app, key == Key::L); return true; } + Key::E => { + crate::edit_window::zoom_toggle(app); + return true; + } _ => return false, }; let _ = app.run(id, json!({})); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..894ce6b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -121,6 +121,9 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { ui.spacing_mut().item_spacing = vec2(2.0, 3.0); let row = ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; + if icon_button(ui, vec2(36.0, 26.0), "zoom_toggle", app.engine.session().edit.zoom_toggle.is_some(), "Zoom Toggle (E)").clicked() { + crate::edit_window::zoom_toggle(app); + } for (tool, icon) in [ (Tool::Zoom, "zoom"), (Tool::Trim, "trim"), @@ -138,7 +141,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { }); // Smart tool bracket above Trim/Selector/Grabber. let rr = row.response.rect; - let bx0 = rr.min.x + 38.0; + let bx0 = rr.min.x + 76.0; let bx1 = bx0 + 38.0 * 3.0 - 2.0; let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0)); let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click()); diff --git a/crates/ui-egui/tests/zoom_toggle.rs b/crates/ui-egui/tests/zoom_toggle.rs new file mode 100644 index 0000000..d3badcf --- /dev/null +++ b/crates/ui-egui/tests/zoom_toggle.rs @@ -0,0 +1,43 @@ +use egui::os::OperatingSystem; +use egui::{Key, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn e_fits_the_selection_to_the_edit_window_and_the_button_goes_back() { + for os in [OperatingSystem::Mac, OperatingSystem::Windows, OperatingSystem::Nix] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let bass = app.engine.session().track_by_name("Bass").unwrap().id.0; + app.engine.execute("edit.select", &json!({"tracks": [bass], "start": 96_000, "end": 192_000})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_os(os).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + let view = |a: &SoundApp| (a.engine.session().edit.zoom, a.edit_layout.rows.clone(), a.edit_layout.scroll_y); + let before = view(h.state()); + h.state_mut().engine.execute("options.keyboard_focus", &json!({"focus": "none"})).unwrap(); + h.key_press(Key::E); + h.run_steps(1); + assert_eq!(view(h.state()), before, "{os:?}"); + + h.state_mut().engine.execute("options.keyboard_focus", &json!({"focus": "commands"})).unwrap(); + h.key_press(Key::E); + h.run_steps(1); + let [x0, y0, x1, y1] = h.state().edit_layout.timeline; + let zoom = h.state().engine.session().edit.zoom; + assert_eq!(zoom.scroll, 96_000, "{os:?}"); + assert!((f64::from(x1 - x0) * zoom.samples_per_px - 96_000.0).abs() < 1.0, "{os:?}"); + let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == bass).unwrap().1; + assert_eq!(row[1], y0, "{os:?}"); + assert!((row[3] + 1.0 - y1).abs() < 1.0, "{os:?}: {row:?} in {y0}..{y1}"); + + h.get_by_label("Zoom Toggle (E)").click(); + h.run_steps(1); + assert_eq!(view(h.state()), before, "{os:?}"); + } +} 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 003/154] 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 75fae5535f2c4033fd52c1ac5843b9ee6d31e7b8 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:31:02 +0100 Subject: [PATCH 004/154] Share the session with the UI instead of copying it every frame The Edit window copied the whole session once per frame for the rulers and twice for every visible track (track_row and lane_interaction), plus once more for each track shown as playlists and once for the Timecode 2 ruler. The MIDI editor copied it once per frame, and every Edit row and Mix strip copied its track. A copy includes every plugin's parameters and saved state: in a 23-track session with three plugins per track (200 parameters and 64 KB of state each), one copy took 0.2 ms and each Edit window frame 15.6 ms, which is paid on every frame of playback, dragging and scrolling. These now hold the engine's Arc (session_arc). It is still a snapshot: a command run later in the frame copies on write, as it already does to keep its undo step. timebase_ruler takes the frame rate as a parameter, so the Timecode 2 ruler no longer needs a copy with another frame rate. Median frame times in a release build, with 21 of 23 tracks on screen: - The demo session with its tracks tripled: Edit window from 1.25 to 0.68 ms. - Plus 5,000 volume automation points per audio track: from 2.03 to 0.68 ms. - Plus three plugins per track: Edit window from 15.6 to 0.69 ms, Mix window from 1.14 to 0.71 ms. The Edit window (every ruler, a playlist lane and an automation lane), the MIDI editor and the Mix window render byte-identical images before and after. --- crates/ui-egui/src/edit_window.rs | 54 ++++++++++++++++++------------- crates/ui-egui/src/midi_editor.rs | 2 +- crates/ui-egui/src/mix_window.rs | 13 ++++---- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..42e72f2 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -417,7 +417,7 @@ fn nice_step(spp: f64, sr: f64, min_px: f64) -> i64 { fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[String]) { let t = Tokens::current(); - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); ui.painter().rect_filled(area, 0.0, t.ruler_bg); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); let sr = s.sample_rate; @@ -502,9 +502,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } else { soundcraft_time::FrameRate::Fps25 }; - let mut s2 = s.clone(); - s2.frame_rate = alt; - timebase_ruler(ui, &painter, &s2, tl, row, view, TimeFormat::Timecode, i + 1 == rulers.len()); + timebase_ruler(ui, &painter, &s, tl, row, view, TimeFormat::Timecode, alt, i + 1 == rulers.len()); } "markers" | "markers2" | "markers3" | "markers4" | "markers5" | "chords" => { let lane: u8 = match id.as_str() { @@ -554,7 +552,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } _ => { if let Some(f) = fmt { - timebase_ruler(ui, &painter, &s, tl, row, view, f, i + 1 == rulers.len()); + timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len()); // Clicking a timebase ruler locates. let resp = ui.interact(row, ui.id().with(("ruler", id.as_str())), Sense::click_and_drag()); if (resp.clicked() || resp.dragged()) @@ -576,7 +574,17 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ ui.painter().line_segment([pos2(area.min.x, area.max.y - 1.0), pos2(area.max.x, area.max.y - 1.0)], Stroke::new(1.0, t.border)); } -fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: Rect, view: Range, f: TimeFormat, bottom: bool) { +fn timebase_ruler( + _ui: &Ui, + painter: &egui::Painter, + s: &Session, + tl: Rect, + row: Rect, + view: Range, + f: TimeFormat, + rate: soundcraft_time::FrameRate, + bottom: bool, +) { let t = Tokens::current(); let sr = s.sample_rate; let spp = s.edit.zoom.samples_per_px; @@ -586,7 +594,7 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: // Bars every n so labels are ≥ 60 px apart. let bar_px = { let b = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; - b.step_samples(view.start.max(0), sr, &s.tempo, s.frame_rate) as f64 / spp + b.step_samples(view.start.max(0), sr, &s.tempo, rate) as f64 / spp }; let every = [1i64, 2, 4, 8, 16, 32, 64, 128].into_iter().find(|n| bar_px * *n as f64 >= 60.0).unwrap_or(256); let first = s.tempo.bar_beat_at(view.start.max(0), sr).bar; @@ -658,10 +666,10 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: 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) + format_position(at, f, sr, &s.tempo, rate, s.timecode_start) } } - _ => format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start), + _ => format_position(at, f, sr, &s.tempo, rate, s.timecode_start), }; painter.text(pos2(x + 3.0, row.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.ruler_text); } else { @@ -677,26 +685,26 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) { let t = Tokens::current(); - let s = app.engine.session(); - let Some(track) = s.track(id).cloned() else { return }; + let s = app.engine.session_arc(); + let Some(track) = s.track(id) 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, cr); + header_column(app, ui, track, c, cr); } let main_h = track.height.points(); - let lane = lane_rect(&track, row, tl); + 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)); 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)); - track_header(app, ui, &track, head, selected); + track_header(app, ui, track, head, selected); // Lane. 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(); + let s = app.engine.session_arc(); 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); @@ -704,15 +712,15 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) if !clip.range().overlaps(&view) { continue; } - draw_clip(app, &painter, &s, &track, clip, tl, lane, auto_view.is_some()); + draw_clip(app, &painter, &s, track, clip, tl, lane, auto_view.is_some()); } - crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane); + crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); if let Some(p) = &auto_view { - draw_automation(&painter, &s, &track, p, tl, lane); + draw_automation(&painter, &s, track, p, tl, lane); } if track.kind == TrackKind::Master || track.kind == TrackKind::Aux || track.kind == TrackKind::Vca { let p = auto_view.clone().unwrap_or(AutoParam::Volume); - draw_automation(&painter, &s, &track, &p, tl, lane); + draw_automation(&painter, &s, track, &p, tl, lane); } // Selection highlight on this track. let sel = s.edit.selection; @@ -723,16 +731,16 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) painter.rect_filled(Rect::from_min_max(pos2(x0, lane.min.y), pos2(x1, lane.max.y)), 0.0, t.selection); } } - lane_interaction(app, ui, &track, lane, tl); + lane_interaction(app, ui, track, lane, tl); if track.view == "playlists" { - playlist_lanes(app, ui, &track, row, main_h, tl); + playlist_lanes(app, ui, track, row, main_h, tl); } } /// Alternate playlists as lanes under the main one; clicking a clip promotes it (comping). fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, main_h: f32, tl: Rect) { let t = Tokens::current(); - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); let mut y = row.min.y + main_h; for (i, pl) in track.playlists.iter().enumerate() { if i == track.active_playlist { @@ -1486,7 +1494,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if resp.dnd_hover_payload::().is_some() { ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside); } - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); let tool = s.edit.tool; let mods = ui.input(|i| i.modifiers); let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index e69d625..e229d39 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -105,7 +105,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { p["clips"] = json!([cid.0]); let _ = app.run(id, p); } - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); 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); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..1ff2851 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -90,7 +90,8 @@ fn route_name(app: &SoundApp, r: &Route) -> String { 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 }; + let session = app.engine.session_arc(); + let Some(track) = session.track(id) else { return }; let narrow = app.ui.narrow_mix; let w = strip_width(narrow); let h = ui.available_height().max(560.0); @@ -132,8 +133,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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))); match slot { - _ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, &track, sr), - Some(k) => insert_slot(app, ui, &track, k, sr), + _ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, track, sr), + Some(k) => insert_slot(app, ui, track, k, sr), None if track.kind == TrackKind::Midi => { ui.painter().text(sr.center(), Align2::CENTER_CENTER, "MIDI in: all", regular(9.0), t.text_dim); } @@ -168,9 +169,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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)); if is_send { - send_slot(app, ui, &track, off + k, sr); + send_slot(app, ui, track, off + k, sr); } else { - insert_slot(app, ui, &track, off + k, sr); + insert_slot(app, ui, track, off + k, sr); } } y = sec.max.y + 4.0; @@ -264,7 +265,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim); y += 22.0; // Pan: a surround panner when the output is multichannel, else the stereo knobs. - let out_fmt = output_format(app, &track); + let out_fmt = output_format(app, track); let surround_out = out_fmt.channels() > 2 && !out_fmt.is_ambisonic() && !matches!(track.kind, TrackKind::Master | TrackKind::Vca) && track.channels() <= 2; if surround_out { From 33ad8264c857f746758f7291a1579940c376896b Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:23:22 +0100 Subject: [PATCH 005/154] Give plugins the session's tempo and transport Hosted plugins never learned the session's tempo or where the song was: - CLAP got a null transport; - VST3 got a fixed 120 BPM 4/4 context that never played and counted processed samples as the song position; - Audio Units had no host callbacks. Tempo-synced delays, LFOs and arpeggiators ran at 120 BPM and ignored the transport. Before each block the mix engine now hands every insert, instrument and master-fader plugin a soundcraft_dsp::Transport (Plugin::set_transport): play and record state, tempo and meter at the block start, and the timeline position in samples, seconds and quarter notes, with the current bar. Built-in plugins ignore it. - CLAP: a clap_event_transport (fixed-point beats and seconds, bar start and number, time signature, play and record flags). - VST3: ProcessContext tempo, time signature, project time in samples and quarter notes, bar position, kPlaying and kRecording. Continuous time stays the processed-sample count. - AU: kAudioUnitProperty_HostCallbacks (beat and tempo, musical time location, transport state 1 and 2), read from atomics the instance owns and frees after disposing the unit. Loop (cycle) ranges aren't reported yet: playback keeps the loop range to itself. --- crates/au-host/src/ffi.rs | 217 ++++++++++++++++++++++++++++++++- crates/au-host/src/lib.rs | 6 +- crates/au-host/src/plugin.rs | 6 +- crates/clap-host/src/ffi.rs | 80 +++++++++++- crates/clap-host/src/plugin.rs | 10 +- crates/dsp/src/lib.rs | 20 +++ crates/mix/src/lib.rs | 81 +++++++++++- crates/vst3-host/src/ffi.rs | 53 +++++++- crates/vst3-host/src/plugin.rs | 10 +- 9 files changed, 462 insertions(+), 21 deletions(-) diff --git a/crates/au-host/src/ffi.rs b/crates/au-host/src/ffi.rs index a115244..641d16b 100644 --- a/crates/au-host/src/ffi.rs +++ b/crates/au-host/src/ffi.rs @@ -2,11 +2,14 @@ //! thin safe wrapper over one Audio Unit instance. //! //! Every pointer handed to the Audio Unit points into memory this module owns for at least as -//! long as the unit can use it: the input state the render callback reads is a leaked `Box` -//! freed only after the unit is disposed, and output buffers are re-pointed before each render. +//! long as the unit can use it: the input state the render callback reads and the host time the +//! host callbacks read are leaked `Box`es freed only after the unit is disposed, and output buffers +//! are re-pointed before each render. +use soundcraft_dsp::Transport; use std::ffi::{CStr, c_char, c_void}; use std::ptr; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; type OSStatus = i32; type AudioComponent = *mut c_void; @@ -40,6 +43,7 @@ const PROP_MAX_FRAMES: u32 = 14; const PROP_PARAMETER_VALUE_STRINGS: u32 = 16; const PROP_TAIL_TIME: u32 = 20; const PROP_SET_RENDER_CALLBACK: u32 = 23; +const PROP_HOST_CALLBACKS: u32 = 27; const SCOPE_GLOBAL: u32 = 0; const SCOPE_INPUT: u32 = 1; @@ -140,6 +144,153 @@ struct AudioBufferListHeader { first: AudioBuffer, } +type BeatAndTempoProc = unsafe extern "C" fn(*mut c_void, *mut f64, *mut f64) -> OSStatus; +type MusicalTimeProc = unsafe extern "C" fn(*mut c_void, *mut u32, *mut f32, *mut u32, *mut f64) -> OSStatus; +type TransportStateProc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus; +type TransportState2Proc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus; + +#[repr(C)] +struct HostCallbackInfo { + host_user_data: *mut c_void, + beat_and_tempo: Option, + musical_time_location: Option, + transport_state: Option, + transport_state2: Option, +} + +/// What the host callbacks report: the transport of the block being rendered. Atomics, because a +/// unit may also ask from another thread. +struct HostTime { + playing: AtomicBool, + recording: AtomicBool, + changed: AtomicBool, + samples: AtomicI64, + tempo: AtomicU64, + quarters: AtomicU64, + bar_start: AtomicU64, + numerator: AtomicU32, + denominator: AtomicU32, + sample_rate: AtomicU64, +} + +impl HostTime { + fn new() -> HostTime { + HostTime { + playing: AtomicBool::new(false), + recording: AtomicBool::new(false), + changed: AtomicBool::new(false), + samples: AtomicI64::new(0), + tempo: AtomicU64::new(120f64.to_bits()), + quarters: AtomicU64::new(0), + bar_start: AtomicU64::new(0), + numerator: AtomicU32::new(4), + denominator: AtomicU32::new(4), + sample_rate: AtomicU64::new(48_000f64.to_bits()), + } + } + + fn set(&self, t: &Transport) { + if self.playing.swap(t.playing, Ordering::Relaxed) != t.playing { + self.changed.store(true, Ordering::Relaxed); + } + self.recording.store(t.recording, Ordering::Relaxed); + self.samples.store(t.samples, Ordering::Relaxed); + self.tempo.store(t.tempo.to_bits(), Ordering::Relaxed); + self.quarters.store(t.quarters.to_bits(), Ordering::Relaxed); + self.bar_start.store(t.bar_start.to_bits(), Ordering::Relaxed); + self.numerator.store(t.numerator, Ordering::Relaxed); + self.denominator.store(t.denominator, Ordering::Relaxed); + } +} + +fn load_f64(a: &AtomicU64) -> f64 { + f64::from_bits(a.load(Ordering::Relaxed)) +} + +/// Writes through an out-pointer the unit may leave null. +/// +/// # Safety +/// `p` is null or valid for writing a `T`. +unsafe fn put(p: *mut T, v: T) { + // SAFETY: per the contract above. + if let Some(p) = unsafe { p.as_mut() } { + *p = v; + } +} + +unsafe extern "C" fn beat_and_tempo(user: *mut c_void, beat: *mut f64, tempo: *mut f64) -> OSStatus { + // SAFETY: `user` is the `HostTime` leaked by `Instance::new`, alive until after the unit is + // disposed. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(beat, load_f64(&t.quarters)); + put(tempo, load_f64(&t.tempo)); + } + 0 +} + +unsafe extern "C" fn musical_time( + user: *mut c_void, + to_next_beat: *mut u32, + numerator: *mut f32, + denominator: *mut u32, + downbeat: *mut f64, +) -> OSStatus { + // SAFETY: as in `beat_and_tempo`. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + let q = load_f64(&t.quarters); + let per_quarter = load_f64(&t.sample_rate) * 60.0 / load_f64(&t.tempo).max(1.0); + let next = ((q.ceil() - q) * per_quarter).round(); + let next = if next.is_finite() { next.clamp(0.0, f64::from(u32::MAX)) as u32 } else { 0 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(to_next_beat, next); + put(numerator, t.numerator.load(Ordering::Relaxed) as f32); + put(denominator, t.denominator.load(Ordering::Relaxed)); + put(downbeat, load_f64(&t.bar_start)); + } + 0 +} + +unsafe extern "C" fn transport_state2( + user: *mut c_void, + playing: *mut u8, + recording: *mut u8, + changed: *mut u8, + sample: *mut f64, + cycling: *mut u8, + cycle_start: *mut f64, + cycle_end: *mut f64, +) -> OSStatus { + // SAFETY: as in `beat_and_tempo`. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(playing, u8::from(t.playing.load(Ordering::Relaxed))); + put(recording, u8::from(t.recording.load(Ordering::Relaxed))); + put(changed, u8::from(t.changed.swap(false, Ordering::Relaxed))); + put(sample, t.samples.load(Ordering::Relaxed) as f64); + put(cycling, 0); + put(cycle_start, 0.0); + put(cycle_end, 0.0); + } + 0 +} + +unsafe extern "C" fn transport_state( + user: *mut c_void, + playing: *mut u8, + changed: *mut u8, + sample: *mut f64, + cycling: *mut u8, + cycle_start: *mut f64, + cycle_end: *mut f64, +) -> OSStatus { + // SAFETY: the same pointers as `transport_state2`, without the recording flag. + unsafe { transport_state2(user, playing, ptr::null_mut(), changed, sample, cycling, cycle_start, cycle_end) } +} + type AuRenderCallback = unsafe extern "C" fn( ref_con: *mut c_void, action_flags: *mut u32, @@ -361,6 +512,8 @@ pub struct Instance { unit: AudioUnit, /// Leaked `Box` read by `input_callback`; freed in `Drop` after disposal. input: *mut InputState, + /// Leaked `Box` read by the host callbacks; freed in `Drop` after disposal. + time: *mut HostTime, outputs: Vec>, abl: AudioBufferList, has_input: bool, @@ -373,7 +526,8 @@ pub struct Instance { // SAFETY: an Audio Unit instance may be used from any thread as long as calls are not // concurrent; `Instance` is only reachable through `&mut`/`&` of its owner, which the `Plugin` -// contract keeps on one thread at a time. The input state is owned exclusively by it. +// contract keeps on one thread at a time. The input state and host time are owned exclusively by +// it (the host time is all atomics). unsafe impl Send for Instance {} impl Instance { @@ -398,10 +552,12 @@ impl Instance { return Err(format!("AudioComponentInstanceNew failed ({st})")); } let input = Box::into_raw(Box::new(InputState { bufs: Vec::new(), frames: 0 })); + let time = Box::into_raw(Box::new(HostTime::new())); let empty = AudioBuffer { number_channels: 1, data_byte_size: 0, data: ptr::null_mut() }; - Ok(Instance { + let inst = Instance { unit, input, + time, outputs: Vec::new(), abl: AudioBufferList { number_buffers: 0, buffers: [empty; MAX_AU_CHANNELS] }, has_input, @@ -410,7 +566,27 @@ impl Instance { max_frames: 0, sample_rate: 48_000.0, sample_time: 0.0, - }) + }; + let callbacks = HostCallbackInfo { + host_user_data: time.cast(), + beat_and_tempo: Some(beat_and_tempo), + musical_time_location: Some(musical_time), + transport_state: Some(transport_state), + transport_state2: Some(transport_state2), + }; + // Units that never ask for host time may refuse it. + let _ = inst.set_property(PROP_HOST_CALLBACKS, SCOPE_GLOBAL, 0, &callbacks); + Ok(inst) + } + + fn time(&self) -> &HostTime { + // SAFETY: `time` is the `HostTime` leaked in `new`, freed only in `Drop`. + unsafe { &*self.time } + } + + /// The transport the host callbacks report during the next renders. + pub fn set_transport(&self, t: &Transport) { + self.time().set(t); } fn set_property(&self, id: u32, scope: u32, element: u32, value: &T) -> OSStatus { @@ -490,6 +666,7 @@ impl Instance { self.initialized = true; self.channels = ch; self.sample_rate = sample_rate; + self.time().sample_rate.store(sample_rate.to_bits(), Ordering::Relaxed); self.outputs = (0..ch).map(|_| vec![0.0; mf]).collect(); // SAFETY: the callback only runs inside `AudioUnitRender`, which is not running now // (we hold `&mut self`), so this exclusive access does not alias. @@ -736,13 +913,15 @@ impl Instance { impl Drop for Instance { fn drop(&mut self) { // SAFETY: the unit is live (disposed only here); after disposal nothing can call the - // input callback, so the leaked input state can be freed. + // input callback or the host callbacks, so the leaked input state and host time can be + // freed. unsafe { if self.initialized { AudioUnitUninitialize(self.unit); } AudioComponentInstanceDispose(self.unit); drop(Box::from_raw(self.input)); + drop(Box::from_raw(self.time)); } } } @@ -751,6 +930,31 @@ impl Drop for Instance { mod tests { use super::*; + #[test] + fn host_callbacks_report_the_transport() { + let time = HostTime::new(); + time.set(&Transport { playing: true, tempo: 120.0, numerator: 3, denominator: 4, samples: 48_000, quarters: 2.25, ..Transport::default() }); + let user = ptr::from_ref(&time).cast_mut().cast::(); + let (mut beat, mut tempo) = (0.0, 0.0); + // SAFETY: `user` points at a live `HostTime`; the out-pointers are locals. + assert_eq!(unsafe { beat_and_tempo(user, &mut beat, &mut tempo) }, 0); + assert_eq!((beat, tempo), (2.25, 120.0)); + let (mut next, mut num, mut den, mut down) = (0u32, 0f32, 0u32, -1.0); + // SAFETY: as above. + assert_eq!(unsafe { musical_time(user, &mut next, &mut num, &mut den, &mut down) }, 0); + assert_eq!((next, num, den, down), (18_000, 3.0, 4, 0.0)); + let (mut playing, mut recording, mut changed, mut sample) = (0u8, 1u8, 0u8, 0.0); + let (no_flag, no_beat) = (ptr::null_mut::(), ptr::null_mut::()); + // SAFETY: as above; the unit may pass null for what it doesn't want. + let st = unsafe { transport_state2(user, &mut playing, &mut recording, &mut changed, &mut sample, no_flag, no_beat, no_beat) }; + assert_eq!((st, playing, recording, changed, sample), (0, 1, 0, 1, 48_000.0)); + // SAFETY: as above. + unsafe { transport_state(user, &mut playing, &mut changed, no_beat, no_flag, no_beat, no_beat) }; + assert_eq!(changed, 0, "a change is reported once"); + // SAFETY: a null user pointer is refused before anything is read. + assert_eq!(unsafe { beat_and_tempo(ptr::null_mut(), &mut beat, &mut tempo) }, -50); + } + #[test] fn struct_layouts_match_the_c_headers() { assert_eq!(std::mem::size_of::(), 20); @@ -761,6 +965,7 @@ mod tests { assert_eq!(std::mem::offset_of!(AudioBufferList, buffers), 8); assert_eq!(std::mem::offset_of!(AudioBufferListHeader, first), 8); assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::size_of::(), 40); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, unit_name), 56); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, cf_name_string), 72); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, flags), 96); diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs index 7d07e15..39172c0 100644 --- a/crates/au-host/src/lib.rs +++ b/crates/au-host/src/lib.rs @@ -23,8 +23,10 @@ //! (`kAudioUnitProperty_ClassInfo`) as a binary plist; [`Plugin::load_state`] parses it back and //! sets it, then re-reads the parameter values. //! -//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, -//! parameters outside the global scope, and host callbacks (tempo, transport). +//! Host callbacks report the session's tempo, meter, position and play state. +//! +//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, and +//! parameters outside the global scope. //! //! Off macOS (and on `wasm32`) the crate compiles to stubs: scans are empty and nothing can be //! created. diff --git a/crates/au-host/src/plugin.rs b/crates/au-host/src/plugin.rs index c76aab0..6f3e373 100644 --- a/crates/au-host/src/plugin.rs +++ b/crates/au-host/src/plugin.rs @@ -1,7 +1,7 @@ //! `AuPlugin`: a loaded Audio Unit behind the safe `soundcraft_dsp::Plugin` trait. use crate::ffi::Instance; -use soundcraft_dsp::{Plugin, PluginInfo}; +use soundcraft_dsp::{Plugin, PluginInfo, Transport}; /// Most MIDI events queued between two blocks (extra events are dropped). const EVENT_CAP: usize = 1024; @@ -200,6 +200,10 @@ impl Plugin for AuPlugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.inst.set_transport(transport); + } + fn tail_samples(&self) -> usize { self.tail } diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33..28d017d 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -14,8 +14,10 @@ use crate::{ClapError, scan}; use clap_sys::audio_buffer::clap_audio_buffer; use clap_sys::entry::clap_plugin_entry; use clap_sys::events::{ - CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, clap_event_header, clap_event_midi, - clap_event_note, clap_event_param_value, clap_input_events, clap_output_events, + CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, CLAP_EVENT_TRANSPORT, + CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO, CLAP_TRANSPORT_HAS_TIME_SIGNATURE, + CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, clap_event_header, clap_event_midi, clap_event_note, clap_event_param_value, + clap_event_transport, clap_input_events, clap_output_events, }; use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports}; use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui}; @@ -27,12 +29,14 @@ use clap_sys::ext::params::{ }; use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state}; use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory}; +use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR}; use clap_sys::host::clap_host; use clap_sys::id::clap_id; use clap_sys::plugin::clap_plugin; use clap_sys::process::{CLAP_PROCESS_ERROR, clap_process}; use clap_sys::stream::{clap_istream, clap_ostream}; use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible}; +use soundcraft_dsp::Transport; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_void}; use std::path::{Path, PathBuf}; @@ -579,6 +583,40 @@ impl RawEvent { } } +fn transport_event(t: &Transport) -> clap_event_transport { + let fixed = |x: f64, factor: i64| if x.is_finite() { (x * factor as f64).round() as i64 } else { 0 }; + let mut flags = + CLAP_TRANSPORT_HAS_TEMPO | CLAP_TRANSPORT_HAS_BEATS_TIMELINE | CLAP_TRANSPORT_HAS_SECONDS_TIMELINE | CLAP_TRANSPORT_HAS_TIME_SIGNATURE; + if t.playing { + flags |= CLAP_TRANSPORT_IS_PLAYING; + } + if t.recording { + flags |= CLAP_TRANSPORT_IS_RECORDING; + } + clap_event_transport { + header: clap_event_header { + size: u32::try_from(size_of::()).unwrap_or(u32::MAX), + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_TRANSPORT, + flags: 0, + }, + flags, + song_pos_beats: fixed(t.quarters, CLAP_BEATTIME_FACTOR), + song_pos_seconds: fixed(t.seconds, CLAP_SECTIME_FACTOR), + tempo: t.tempo, + tempo_inc: 0.0, + loop_start_beats: 0, + loop_end_beats: 0, + loop_start_seconds: 0, + loop_end_seconds: 0, + bar_start: fixed(t.bar_start, CLAP_BEATTIME_FACTOR), + bar_number: i32::try_from(t.bar).unwrap_or(0), + tsig_num: u16::try_from(t.numerator).unwrap_or(4), + tsig_denom: u16::try_from(t.denominator).unwrap_or(4), + } +} + unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 { if list.is_null() { return 0; @@ -1186,6 +1224,7 @@ impl Instance { outputs: &mut PortBuffers, events: &[Event], offset: u32, + transport: Option<&Transport>, ) -> Result { if !self.active { return Err(ClapError::Process("not active".into())); @@ -1219,10 +1258,11 @@ impl Instance { self.out_params.clear(); let out_events = clap_output_events { ctx: (&mut self.out_params as *mut Vec<(u32, f64)>).cast::(), try_push: Some(out_events_try_push) }; + let transport = transport.map(transport_event); let p = clap_process { steady_time, frames_count: u32::try_from(frames).unwrap_or(0), - transport: std::ptr::null(), + transport: transport.as_ref().map_or(std::ptr::null(), std::ptr::from_ref), audio_inputs: inputs.bufs.as_ptr(), audio_outputs: outputs.bufs.as_mut_ptr(), audio_inputs_count: u32::try_from(inputs.bufs.len()).unwrap_or(0), @@ -1232,8 +1272,8 @@ impl Instance { }; // SAFETY: valid, processing instance. Every buffer pointer refers to `frames` (or more) // valid f32s owned by `inputs`/`outputs`, which are borrowed for the whole call; the event - // lists and their contexts live on this stack frame / in `self.raw`, untouched until the - // call returns. + // lists and their contexts and the transport live on this stack frame / in `self.raw`, + // untouched until the call returns. let status = unsafe { process(self.plugin, &p) }; if !self.out_params.is_empty() && let Ok(mut edits) = self.state.edits.try_lock() @@ -1264,3 +1304,33 @@ impl Drop for Instance { self.created = false; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_transport_reaches_clap_in_fixed_point() { + let t = Transport { + playing: true, + tempo: 90.0, + numerator: 3, + denominator: 4, + samples: 96_000, + seconds: 2.0, + quarters: 3.5, + bar_start: 3.0, + bar: 1, + ..Transport::default() + }; + let e = transport_event(&t); + assert_eq!((e.header.type_, e.header.size as usize), (CLAP_EVENT_TRANSPORT, size_of::())); + assert_eq!(e.flags & CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_PLAYING); + assert_eq!(e.flags & CLAP_TRANSPORT_IS_RECORDING, 0); + assert_eq!((e.song_pos_beats, e.bar_start), (7 * CLAP_BEATTIME_FACTOR / 2, 3 * CLAP_BEATTIME_FACTOR)); + assert_eq!(e.song_pos_seconds, 2 * CLAP_SECTIME_FACTOR); + assert_eq!((e.tempo, e.bar_number, e.tsig_num, e.tsig_denom), (90.0, 1, 3, 4)); + let nan = transport_event(&Transport { quarters: f64::NAN, ..t }); + assert_eq!(nan.song_pos_beats, 0); + } +} diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 6fe1abc..6adf269 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -2,7 +2,7 @@ use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers}; use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI}; -use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport}; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -38,6 +38,7 @@ pub struct ClapPlugin { max_block: usize, latency: usize, steady: i64, + transport: Option, } impl ClapPlugin { @@ -60,6 +61,7 @@ impl ClapPlugin { max_block: 0, latency: 0, steady: 0, + transport: None, } } @@ -187,7 +189,7 @@ impl Plugin for ClapPlugin { let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let events = self.queue.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset); + let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref()); self.queue.drain(..k); self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0)); match (r, self.out_main) { @@ -217,6 +219,10 @@ impl Plugin for ClapPlugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.transport = Some(*transport); + } + fn note_on(&mut self, offset: usize, note: u8, velocity: u8) { if note > 127 { return; diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 3d5bd33..09d4327 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -169,6 +169,24 @@ impl PluginInfo { } } +/// The host's transport at the start of a block, for tempo-synced plugins. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Transport { + pub playing: bool, + pub recording: bool, + /// Quarter notes per minute. + pub tempo: f64, + pub numerator: u32, + pub denominator: u32, + /// Timeline position. + pub samples: i64, + pub seconds: f64, + pub quarters: f64, + /// Start of the current bar, in quarter notes, and its number (0 for the first bar). + pub bar_start: f64, + pub bar: i64, +} + /// A realtime audio processor or instrument. /// /// Contract: `process` never allocates or panics. It processes `min(frames, io[c].len())` frames @@ -195,6 +213,8 @@ pub trait Plugin: Send { fn gain_reduction_db(&self) -> f32 { 0.0 } + /// The transport for the next block, given before `process`. + fn set_transport(&mut self, _transport: &Transport) {} /// Instruments: note events at sample offsets within the next block. fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {} fn note_off(&mut self, _offset: usize, _note: u8) {} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..8e09a52 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -52,11 +52,11 @@ #![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, SampleSource, db_to_gain}; +use soundcraft_dsp::{Plugin, SampleSource, Transport, 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 soundcraft_time::{Range, Samples, TICKS_PER_QUARTER}; use std::collections::HashMap; use std::sync::Arc; @@ -670,6 +670,7 @@ impl MixEngine { } self.order = order; // Master faders process the main mix. + let transport = transport_at(s, pos, !self.monitor_only, self.recording); for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) { if let Some(strip) = self.strips.get_mut(&t.id) { for (dst, src) in strip.buf.iter_mut().zip(self.main.iter()) { @@ -683,6 +684,7 @@ impl MixEngine { && !ins.bypass { apply_params(slot, ins, t, i, pos); + slot.plugin.set_transport(&transport); slot.plugin.process(&mut strip.buf, frames); } } @@ -814,6 +816,7 @@ fn process_strip( for c in strip.buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } + let transport = transport_at(s, pos, !live.monitor_only, live.recording); match t.kind { TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => { // Input monitoring: the track hears its hardware input instead of its clips. @@ -894,6 +897,7 @@ fn process_strip( if let Some(ins) = &t.instrument { apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); } + inst.plugin.set_transport(&transport); schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); @@ -926,6 +930,7 @@ fn process_strip( continue; } apply_params(slot, ins, t, i, pos); + slot.plugin.set_transport(&transport); slot.plugin.process(&mut strip.buf, frames); latency = latency.saturating_add(slot.plugin.latency()); if gr == 0.0 { @@ -1436,6 +1441,27 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im } } +fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport { + let sr = s.sample_rate; + let ticks = Some(s.tempo.samples_to_ticks_f(pos, sr)).filter(|t| t.is_finite()).unwrap_or(0.0); + let tick = ticks.floor() as i64; + let meter = s.tempo.meter_at_tick(tick); + let bar = s.tempo.bar_beat_at_tick(tick).bar; + let q = TICKS_PER_QUARTER as f64; + Transport { + playing, + recording, + tempo: s.tempo.tempo_at_tick(tick), + numerator: meter.numerator, + denominator: meter.denominator, + samples: pos, + seconds: pos as f64 / sr.as_f64().max(1.0), + quarters: ticks / q, + bar_start: s.tempo.bar_start_tick(bar) as f64 / q, + bar: bar - 1, + } +} + fn volume_db_at(t: &Track, pos: Samples) -> f32 { if t.mixer.automation_mode.reads() && t.mixer.automation_mode != soundcraft_model::AutomationMode::Write @@ -2168,6 +2194,57 @@ mod tests { } } + struct Clock { + inner: Box, + seen: Arc>>, + } + + impl Plugin for Clock { + 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 set_transport(&mut self, t: &Transport) { + self.seen.lock().unwrap().push(*t); + } + } + + #[test] + fn plugins_get_the_session_transport() { + let (mut s, t) = session_with_dc(0.5, 48_000 * 4); + s.tempo.set_tempo(0, 90.0).unwrap(); + s.tempo.set_meter(0, 3, 4).unwrap(); + s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.clock")); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let clock = Clock { inner: soundcraft_dsp::create("gain").unwrap(), seen: Arc::clone(&seen) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.clock".into(), ch: 1, plugin: Box::new(clock) }]); + render_block(&mut eng, &s, 96_000); + eng.monitor_only = true; + render_block(&mut eng, &s, 96_000); + let seen = seen.lock().unwrap(); + let t0 = seen[0]; + assert!(t0.playing && !t0.recording); + assert_eq!((t0.tempo, t0.numerator, t0.denominator, t0.samples, t0.bar), (90.0, 3, 4, 96_000, 1)); + assert!((t0.seconds - 2.0).abs() < 1e-9 && (t0.quarters - 3.0).abs() < 1e-9 && (t0.bar_start - 3.0).abs() < 1e-9); + assert!(!seen[1].playing); + } + 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); diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 341ad53..185fa6a 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -15,6 +15,7 @@ #![allow(clippy::unnecessary_cast)] use crate::{Vst3Error, scan}; +use soundcraft_dsp::Transport; use std::ffi::{c_char, c_int, c_void}; use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; @@ -1072,6 +1073,27 @@ pub(crate) struct Layout { pub event_input: bool, } +fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) { + use sv::ProcessContext_::StatesAndFlags_::*; + let mut state = kTempoValid | kTimeSigValid | kProjectTimeMusicValid | kBarPositionValid | kContTimeValid; + if t.playing { + state |= kPlaying; + } + if t.recording { + state |= kRecording; + } + // The flag constants are the C default enum type: i32 on Windows, u32 elsewhere. + #[allow(clippy::unnecessary_cast)] + let state = state as u32; + c.state = state; + c.tempo = t.tempo; + c.timeSigNumerator = i32::try_from(t.numerator).unwrap_or(4); + c.timeSigDenominator = i32::try_from(t.denominator).unwrap_or(4); + c.projectTimeSamples = t.samples; + c.projectTimeMusic = t.quarters; + c.barPositionMusic = t.bar_start; +} + /// A live plugin: component + audio processor (+ edit controller) and the host objects. pub(crate) struct Instance { processor: Option>, @@ -1402,6 +1424,7 @@ impl Instance { notes: &[NoteEvent], offset: u32, project_time: i64, + transport: Option<&Transport>, ) -> Result<(), Vst3Error> { if !self.active { return Err(Vst3Error::Process("not active".into())); @@ -1433,8 +1456,11 @@ impl Instance { self.out_events.with(Vec::clear); inputs.refresh(); outputs.refresh(); - self.context.projectTimeSamples = project_time; self.context.continousTimeSamples = project_time; + match transport { + Some(t) => apply_transport(&mut self.context, t), + None => self.context.projectTimeSamples = project_time, + } let mut data = sv::ProcessData { processMode: sv::ProcessModes_::kRealtime as i32, symbolicSampleSize: sv::SymbolicSampleSizes_::kSample32 as i32, @@ -1881,6 +1907,31 @@ mod window { mod tests { use super::*; + #[test] + fn the_transport_fills_the_process_context() { + use sv::ProcessContext_::StatesAndFlags_::*; + // SAFETY: `ProcessContext` is plain data (numbers and small structs); all-zero is valid. + let mut c: sv::ProcessContext = unsafe { std::mem::zeroed() }; + let t = Transport { + playing: true, + tempo: 90.0, + numerator: 3, + denominator: 4, + samples: 96_000, + quarters: 3.5, + bar_start: 3.0, + ..Transport::default() + }; + apply_transport(&mut c, &t); + let playing = kPlaying as u32; + assert_eq!(c.state & playing, playing); + assert_eq!(c.state & kRecording as u32, 0); + assert_eq!((c.tempo, c.timeSigNumerator, c.timeSigDenominator), (90.0, 3, 4)); + assert_eq!((c.projectTimeSamples, c.projectTimeMusic, c.barPositionMusic), (96_000, 3.5, 3.0)); + apply_transport(&mut c, &Transport { playing: false, ..t }); + assert_eq!(c.state & playing, 0); + } + #[test] fn state_container_parses_strictly() { let mut blob = b"SCV3".to_vec(); diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 254671f..d88f6d6 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -1,7 +1,7 @@ //! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. use crate::ffi::{BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP}; -use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Unit}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; use std::sync::Arc; use std::sync::atomic::Ordering; use vst3::ComWrapper; @@ -46,6 +46,7 @@ pub struct Vst3Plugin { latency: usize, tail: usize, position: i64, + transport: Option, } impl Vst3Plugin { @@ -88,6 +89,7 @@ impl Vst3Plugin { latency: 0, tail: 0, position: 0, + transport: None, ids, maps, inst, @@ -253,7 +255,7 @@ impl Plugin for Vst3Plugin { let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let notes = self.notes.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position); + let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position, self.transport.as_ref()); self.pending.clear(); self.notes.drain(..k); self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); @@ -284,6 +286,10 @@ impl Plugin for Vst3Plugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.transport = Some(*transport); + } + fn tail_samples(&self) -> usize { self.tail } From acd9e7d54e6a566b2f988841f2db484230f0921f Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:39:05 +0100 Subject: [PATCH 006/154] Play MIDI controllers through instruments Imported MIDI keeps its controller events: sustain pedal and other CCs, pitch bend, channel and poly pressure, program changes. Playback sent instruments only note-ons and note-offs, so a piano part lost its pedal and bends went flat. - Plugin::midi takes any other MIDI 1.0 channel message at a sample offset. The mix engine sends each clip's controllers at their sample, after the Real-Time Properties delay (and transpose, for poly pressure), on channel 1 like the notes. - After a seek, a stop or a loop wrap, an instrument first gets Reset All Controllers (CC 121), then the last value each controller had on its track before the playhead. A pedal held before the start point is still down. - CLAP: raw MIDI events when the note port speaks the MIDI dialect. - VST3: CCs, aftertouch and pitch bend become parameter changes at their offset, through the plugin's IMidiMapping. It is asked once in prepare, on the main thread, and there is one point per parameter per block. Poly pressure is a PolyPressureEvent. Program changes have no VST3 mapping and are dropped. - AU: MusicDeviceMIDIEvent for instruments and music effects. - The built-in synth follows the sustain pedal, pitch bend (2 semitones each way), Reset All Controllers and All Notes Off. - The sampler ignores the pedal, pitch bend and Reset All Controllers for now. The AU check of MIDI volume lives inside the existing DLS test. Creating two DLSMusicDevice instances at once on two threads crashes inside the unit, with or without this change. Two instances created one after another render in parallel fine. --- crates/au-host/src/plugin.rs | 7 ++ crates/au-host/tests/system_units.rs | 19 +++ crates/clap-host/src/plugin.rs | 13 +- crates/clap-host/tests/fixture/src/lib.rs | 9 +- crates/clap-host/tests/fixture_e2e.rs | 3 + crates/dsp/src/lib.rs | 3 + crates/dsp/src/plugins/drums.rs | 2 +- crates/dsp/src/plugins/sampler.rs | 1 + crates/dsp/src/plugins/synth.rs | 64 +++++++++- crates/dsp/tests/plugins.rs | 28 +++++ crates/mix/src/lib.rs | 137 +++++++++++++++++++++- crates/vst3-host/src/ffi.rs | 39 +++++- crates/vst3-host/src/plugin.rs | 46 +++++++- crates/vst3-host/tests/fixture/src/lib.rs | 36 +++++- crates/vst3-host/tests/fixture_e2e.rs | 10 ++ 15 files changed, 401 insertions(+), 16 deletions(-) diff --git a/crates/au-host/src/plugin.rs b/crates/au-host/src/plugin.rs index 6f3e373..23717aa 100644 --- a/crates/au-host/src/plugin.rs +++ b/crates/au-host/src/plugin.rs @@ -231,6 +231,13 @@ impl Plugin for AuPlugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let [status, data1, data2] = msg; + if status & 0xF0 >= 0xA0 && status & 0xF0 < 0xF0 { + self.push_event(MidiEvent { time: u32::try_from(offset).unwrap_or(u32::MAX), status, data1: data1 & 0x7F, data2: data2 & 0x7F }); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { diff --git a/crates/au-host/tests/system_units.rs b/crates/au-host/tests/system_units.rs index 2e24675..2cef419 100644 --- a/crates/au-host/tests/system_units.rs +++ b/crates/au-host/tests/system_units.rs @@ -167,4 +167,23 @@ fn dls_synth_plays_a_note() { assert!(e > 1e-3, "{e}"); p.note_off(0, 64); p.reset(); + // Controllers reach the synth: MIDI volume 0 silences a note. (In this test because creating + // two DLS instances at once on two threads crashes inside the unit.) + p.prepare(48_000.0, 512, 2); + let loudest = |p: &mut Box, volume: u8| { + p.midi(0, [0xB0, 7, volume]); + p.note_on(0, 60, 110); + let mut energy = 0.0f32; + for _ in 0..20 { + let mut io = vec![vec![0.0f32; 512]; 2]; + p.process(&mut io, 512); + energy = energy.max(rms(&io[0])); + } + p.note_off(0, 60); + p.reset(); + energy + }; + let full = loudest(&mut p, 127); + let muted = loudest(&mut p, 0); + assert!(full > 1e-3 && muted < full * 0.01, "volume 127: {full}, volume 0: {muted}"); } diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 6adf269..ec375cd 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -33,6 +33,8 @@ pub struct ClapPlugin { in_main: Option, out_main: Option, notes: NoteMode, + /// The note port takes raw MIDI (controllers, pitch bend, program changes). + midi_in: bool, held: [bool; 128], channels: usize, max_block: usize, @@ -56,6 +58,7 @@ impl ClapPlugin { in_main: None, out_main: None, notes: NoteMode::None, + midi_in: false, held: [false; 128], channels: 2, max_block: 0, @@ -116,7 +119,9 @@ impl Plugin for ClapPlugin { self.out_main = pick_main(&outs); self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::>(), mb); self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::>(), mb); - self.notes = note_mode(self.inst.note_port()); + let port = self.inst.note_port(); + self.notes = note_mode(port); + self.midi_in = port.is_some_and(|p| p.supported & CLAP_NOTE_DIALECT_MIDI != 0); self.max_block = mb; self.inst.state().restart.store(false, Ordering::Relaxed); match self.inst.activate(sr, 1, u32::try_from(mb).unwrap_or(u32::MAX)) { @@ -254,6 +259,12 @@ impl Plugin for ClapPlugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + if self.midi_in { + self.push(Event::Midi { time: u32::try_from(offset).unwrap_or(u32::MAX), data: msg }); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs index 40de13c..85a7999 100644 --- a/crates/clap-host/tests/fixture/src/lib.rs +++ b/crates/clap-host/tests/fixture/src/lib.rs @@ -3,7 +3,8 @@ //! - `org.soundcraft.test.gain`: stereo in/out, param 7 "Gain" (0..2, default 1), param 3 "Mode" //! (stepped enum A/B/C), hidden param 9, latency 3 samples. //! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal -//! to the velocity of the held note (0 when none), switching at the event's sample offset. +//! to the velocity of the held note (0 when none), switching at the event's sample offset. A +//! MIDI CC 7 (volume) sets the constant to its value / 127. //! //! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a //! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI, @@ -150,6 +151,12 @@ fn apply(f: &mut Fx, h: &clap_event_header) { f.level = e.velocity; } CLAP_EVENT_NOTE_OFF => f.level = 0.0, + CLAP_EVENT_MIDI => { + let e = unsafe { &*(h as *const clap_event_header as *const clap_event_midi) }; + if e.data[0] & 0xF0 == 0xB0 && e.data[1] == 7 { + f.level = f64::from(e.data[2]) / 127.0; + } + } _ => {} } } diff --git a/crates/clap-host/tests/fixture_e2e.rs b/crates/clap-host/tests/fixture_e2e.rs index 2216487..b1bb354 100644 --- a/crates/clap-host/tests/fixture_e2e.rs +++ b/crates/clap-host/tests/fixture_e2e.rs @@ -105,6 +105,9 @@ fn synth_plays_notes_at_their_offsets() { p.all_notes_off(); p.process(&mut io, 64); assert!(io[0].iter().all(|x| x.abs() < 1e-6)); + p.midi(20, [0xB0, 7, 64]); + p.process(&mut io, 64); + assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset"); } #[test] diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 09d4327..0308834 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -218,6 +218,9 @@ pub trait Plugin: Send { /// Instruments: note events at sample offsets within the next block. fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {} fn note_off(&mut self, _offset: usize, _note: u8) {} + /// Instruments: any other MIDI 1.0 channel message (control change, pitch bend, pressure, + /// program change) at a sample offset within the next block. + fn midi(&mut self, _offset: usize, _msg: [u8; 3]) {} 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. diff --git a/crates/dsp/src/plugins/drums.rs b/crates/dsp/src/plugins/drums.rs index 75a7976..ca3a52b 100644 --- a/crates/dsp/src/plugins/drums.rs +++ b/crates/dsp/src/plugins/drums.rs @@ -387,7 +387,7 @@ impl DrumSynth { fn apply(&mut self, kind: EventKind) { match kind { EventKind::On(n, v) => self.trigger(n, v), - EventKind::Off(_) => {} + EventKind::Off(_) | EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {} EventKind::AllOff => { let fast = decay60(30.0, self.sr); for v in &mut self.voices { diff --git a/crates/dsp/src/plugins/sampler.rs b/crates/dsp/src/plugins/sampler.rs index c181687..ee85178 100644 --- a/crates/dsp/src/plugins/sampler.rs +++ b/crates/dsp/src/plugins/sampler.rs @@ -299,6 +299,7 @@ impl Sampler { v.env.gate_off(); } } + EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {} } } diff --git a/crates/dsp/src/plugins/synth.rs b/crates/dsp/src/plugins/synth.rs index be662c6..1e0b92a 100644 --- a/crates/dsp/src/plugins/synth.rs +++ b/crates/dsp/src/plugins/synth.rs @@ -13,8 +13,15 @@ pub(crate) enum EventKind { On(u8, u8), Off(u8), AllOff, + Sustain(bool), + /// Pitch bend as a frequency ratio. + Bend(f32), + ResetControllers, } +/// Pitch bend range, semitones each way. +const BEND_RANGE: f32 = 2.0; + #[derive(Debug, Clone, Copy)] pub(crate) struct Event { pub(crate) offset: usize, @@ -212,6 +219,8 @@ struct Voice { svf: Svf, age: u64, held: bool, + /// Released while the sustain pedal was down. + sustained: bool, } impl Voice { @@ -228,6 +237,7 @@ impl Voice { svf: Svf::default(), age: 0, held: false, + sustained: false, } } @@ -250,6 +260,8 @@ pub struct SubtractiveSynth { counter: u64, level: Smoother, cutoff: Smoother, + sustain: bool, + bend: f32, } impl Default for SubtractiveSynth { @@ -277,6 +289,8 @@ impl SubtractiveSynth { counter: 0, level: Smoother::new(0.5), cutoff: Smoother::new(2000.0), + sustain: false, + bend: 1.0, }; s.update(); s @@ -337,15 +351,32 @@ impl SubtractiveSynth { v.filt.gate_on(); v.age = counter; v.held = true; + v.sustained = false; } self.last_freq = Some(freq); } fn stop_note(&mut self, note: u8) { + let sustain = self.sustain; for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) { v.held = false; - v.amp.gate_off(); - v.filt.gate_off(); + if sustain { + v.sustained = true; + } else { + v.amp.gate_off(); + v.filt.gate_off(); + } + } + } + + fn set_sustain(&mut self, on: bool) { + self.sustain = on; + if !on { + for v in self.voices.iter_mut().filter(|v| v.sustained) { + v.sustained = false; + v.amp.gate_off(); + v.filt.gate_off(); + } } } @@ -356,10 +387,17 @@ impl SubtractiveSynth { EventKind::AllOff => { for v in &mut self.voices { v.held = false; + v.sustained = false; v.amp.gate_off(); v.filt.gate_off(); } } + EventKind::Sustain(on) => self.set_sustain(on), + EventKind::Bend(ratio) => self.bend = ratio, + EventKind::ResetControllers => { + self.set_sustain(false); + self.bend = 1.0; + } } } @@ -374,6 +412,7 @@ impl SubtractiveSynth { let env_oct = self.p.v(7) / 100.0 * 6.0; let cutoff = self.cutoff.next(); let nyq = sr * 0.45; + let bend = self.bend; let mut out = 0.0; for v in &mut self.voices { if !v.active() { @@ -386,8 +425,8 @@ impl SubtractiveSynth { } let a = v.amp.next(&self.amp_rates); let fe = v.filt.next(&self.filt_rates); - let s1 = v.osc1.next(w1, v.freq / sr); - let s2 = v.osc2.next(w2, v.freq * ratio2 / sr); + let s1 = v.osc1.next(w1, v.freq * bend / sr); + let s2 = v.osc2.next(w2, v.freq * bend * ratio2 / sr); let raw = s1 * (1.0 - mix) + s2 * mix; let fc = (cutoff * 2f32.powf(env_oct * fe)).clamp(20.0, nyq); let g = (std::f32::consts::PI * fc / sr).tan(); @@ -418,6 +457,8 @@ impl Plugin for SubtractiveSynth { self.last_freq = None; self.level.snap(); self.cutoff.snap(); + self.sustain = false; + self.bend = 1.0; } fn process(&mut self, io: &mut [Vec], frames: usize) { @@ -467,6 +508,21 @@ impl Plugin for SubtractiveSynth { self.events.push(offset, EventKind::Off(note)); } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let [status, d1, d2] = msg; + let kind = match (status & 0xF0, d1) { + (0xB0, 64) => EventKind::Sustain(d2 >= 64), + (0xB0, 120 | 123) => EventKind::AllOff, + (0xB0, 121) => EventKind::ResetControllers, + (0xE0, _) => { + let v = (i32::from(d2 & 0x7F) << 7 | i32::from(d1 & 0x7F)) - 8192; + EventKind::Bend(2f32.powf(v as f32 / 8192.0 * BEND_RANGE / 12.0)) + } + _ => return, + }; + self.events.push(offset, kind); + } + fn all_notes_off(&mut self) { self.events.push(0, EventKind::AllOff); } diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/plugins.rs index 5d71f6f..048aece 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -394,6 +394,34 @@ fn synth_note_on_sounds_and_note_off_decays() { assert!(peak(&out[0][72_000..]) < 1e-6); } +#[test] +fn synth_follows_the_sustain_pedal_and_pitch_bend() { + let mut p = prepared("subtractive_synth"); + p.midi(0, [0xB0, 64, 127]); + p.note_on(0, 60, 100); + run(p.as_mut(), &vec![vec![0.0; 4800]; 2]); + p.note_off(0, 60); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(rms(&out[0][72_000..]) > 0.01, "the pedal holds the note"); + p.midi(0, [0xB0, 64, 0]); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(peak(&out[0][72_000..]) < 1e-6, "lifting the pedal releases it"); + + let rises = |x: &[f32]| x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count() as f64; + let mut p = prepared("subtractive_synth"); + p.set_param("osc1_wave", 2.0); + p.set_param("osc_mix", 0.0); + p.note_on(0, 69, 100); + let plain = run(p.as_mut(), &[vec![0.0; 48_000]]); + p.midi(0, [0xE0, 0x7F, 0x7F]); + let bent = run(p.as_mut(), &[vec![0.0; 48_000]]); + let ratio = rises(&bent[0][4800..]) / rises(&plain[0][4800..]); + assert!((ratio - 2f64.powf(2.0 / 12.0)).abs() < 0.01, "{ratio}"); + p.midi(0, [0xB0, 121, 0]); + let reset = run(p.as_mut(), &[vec![0.0; 48_000]]); + assert!((rises(&reset[0][4800..]) / rises(&plain[0][4800..]) - 1.0).abs() < 0.01); +} + #[test] fn synth_event_offsets_are_sample_accurate() { let mut p = prepared("subtractive_synth"); diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 8e09a52..bae8545 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -53,6 +53,7 @@ use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain}; +use soundcraft_midi::CtrlKind; use soundcraft_model::{ AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind, }; @@ -199,6 +200,8 @@ struct Strip { sample: Option>, /// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek. held: Vec, + /// The instrument's next block starts somewhere new: send it the controllers in effect there. + chase: bool, muted: bool, gr: f32, /// Per-clip effect chains (Clip Effects) and a scratch buffer to render clips into. @@ -273,6 +276,7 @@ impl Strip { instrument: None, sample: None, held: Vec::new(), + chase: true, muted: false, gr: 0.0, clip_fx: HashMap::new(), @@ -474,6 +478,7 @@ impl MixEngine { i.plugin.reset(); } s.held.clear(); + s.chase = true; } self.meters.clear(); self.main_meter = StripMeter::default(); @@ -576,6 +581,7 @@ impl MixEngine { i.plugin.all_notes_off(); } st.held.clear(); + st.chase = true; } } self.last_end = pos.saturating_add(frames as i64); @@ -898,6 +904,9 @@ fn process_strip( apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); } inst.plugin.set_transport(&transport); + if std::mem::take(&mut strip.chase) { + chase_controllers(s, t, pos, inst); + } schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); @@ -1310,7 +1319,7 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut } } -/// Send note on/offs for MIDI clips overlapping the block. +/// Send note on/offs and controllers 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; let end = pos.saturating_add(frames as i64); @@ -1357,6 +1366,80 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu held.retain(|p| *p != n.pitch); } } + let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1) + ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1); + for c in &sequence.ctrls { + let tick = c.tick.saturating_add(delay).max(0); + if !near.contains(&tick) { + continue; + } + let at = s.tempo.tick_to_samples(base.saturating_add(tick), sr); + if at >= pos && at < end && at < clip.end() { + inst.plugin.midi(usize::try_from(at - pos).unwrap_or(0), ctrl_message(c.kind, c.value, dp)); + } + } + } +} + +/// A controller event as a MIDI 1.0 message on channel 1 (where the notes go), with `transpose` +/// applied to a poly-pressure key. +fn ctrl_message(kind: CtrlKind, value: i32, transpose: i64) -> [u8; 3] { + let v7 = value.clamp(0, 127) as u8; + match kind { + CtrlKind::Cc(n) => [0xB0, n & 0x7F, v7], + CtrlKind::PitchBend => { + let v = value.saturating_add(8192).clamp(0, 16383); + [0xE0, (v & 0x7F) as u8, (v >> 7) as u8] + } + CtrlKind::ChannelPressure => [0xD0, v7, 0], + CtrlKind::PolyPressure(k) => [0xA0, (i64::from(k) + transpose).clamp(0, 127) as u8, v7], + CtrlKind::Program => [0xC0, v7, 0], + } +} + +/// Before an instrument plays from `pos`: reset its controllers, then send each one the last value +/// it had on this track before `pos`. +fn chase_controllers(s: &Session, t: &Track, pos: Samples, inst: &mut PluginSlot) { + inst.plugin.midi(0, [0xB0, 121, 0]); + const BEND: usize = 128; + const PRESSURE: usize = 129; + const PROGRAM: usize = 130; + let mut last = [(i64::MIN, 0i32); 131]; + let sr = s.sample_rate; + for clip in t.clips() { + let ClipContent::Midi { sequence } = &clip.content else { continue }; + if clip.muted || clip.start >= pos { + continue; + } + let base = s.tempo.samples_to_ticks(clip.start, sr); + let until = pos.min(clip.end()); + for c in &sequence.ctrls { + let slot = match c.kind { + CtrlKind::Cc(n) => usize::from(n & 0x7F), + CtrlKind::PitchBend => BEND, + CtrlKind::ChannelPressure => PRESSURE, + CtrlKind::Program => PROGRAM, + CtrlKind::PolyPressure(_) => continue, + }; + let at = s.tempo.tick_to_samples(base.saturating_add(c.tick), sr); + if at < until + && let Some(l) = last.get_mut(slot) + && at >= l.0 + { + *l = (at, c.value); + } + } + } + for (slot, &(at, value)) in last.iter().enumerate() { + let kind = match slot { + BEND => CtrlKind::PitchBend, + PRESSURE => CtrlKind::ChannelPressure, + PROGRAM => CtrlKind::Program, + n => CtrlKind::Cc(u8::try_from(n).unwrap_or(0)), + }; + if at != i64::MIN { + inst.plugin.midi(0, ctrl_message(kind, value, 0)); + } } } @@ -2245,6 +2328,58 @@ mod tests { assert!(!seen[1].playing); } + struct Ears { + inner: Box, + heard: Arc>>, + } + + impl Plugin for Ears { + 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 midi(&mut self, offset: usize, msg: [u8; 3]) { + self.heard.lock().unwrap().push((offset, msg)); + } + } + + #[test] + fn controllers_play_at_their_offsets_and_are_chased_after_a_seek() { + 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.ears")); + let cc = |tick, kind, value| soundcraft_midi::CtrlEvent { tick, channel: 0, kind, value }; + let ctrls = vec![cc(4, CtrlKind::Cc(64), 127), cc(960, CtrlKind::PitchBend, -8192), cc(1920, CtrlKind::Cc(64), 0)]; + let seq = soundcraft_midi::Sequence { ctrls, ..Default::default() }; + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000 * 4, seq)); + let heard = Arc::new(std::sync::Mutex::new(Vec::new())); + let ears = Ears { 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.ears".into(), ch: 2, plugin: Box::new(ears) }]); + render_block(&mut eng, &s, 0); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (100, [0xB0, 64, 127])]); + render_block(&mut eng, &s, 36_000); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (0, [0xB0, 64, 127]), (0, [0xE0, 0, 0])]); + render_block(&mut eng, &s, 36_512); + assert!(heard.lock().unwrap().is_empty()); + } + 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); diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 185fa6a..736117c 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -25,7 +25,7 @@ use std::time::Duration; use vst3::Steinberg::Vst::{self as sv}; use vst3::Steinberg::Vst::{ IAudioProcessorTrait, IComponentHandlerTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IEventListTrait, - IHostApplicationTrait, IParamValueQueueTrait, IParameterChangesTrait, + IHostApplicationTrait, IMidiMappingTrait, IParamValueQueueTrait, IParameterChangesTrait, }; use vst3::Steinberg::{self as sb}; use vst3::Steinberg::{FUnknown, IBStreamTrait, IPlugFrameTrait, IPlugViewTrait, IPluginBaseTrait, IPluginFactory2Trait, IPluginFactoryTrait}; @@ -82,6 +82,10 @@ fn ok(r: sb::tresult) -> bool { r == sb::kResultOk } +/// VST3's numbers for the MIDI controllers that aren't CCs. +pub(crate) const AFTERTOUCH: usize = sv::ControllerNumbers_::kAfterTouch as usize; +pub(crate) const PITCH_BEND: usize = sv::ControllerNumbers_::kPitchBend as usize; + // ---- loaded binaries ----------------------------------------------------------------------- /// One class as reported by the factory. @@ -959,17 +963,18 @@ impl IBStreamTrait for MemoryStream { pub(crate) enum NoteEvent { On { time: u32, key: u8, velocity: f32 }, Off { time: u32, key: u8 }, + Pressure { time: u32, key: u8, pressure: f32 }, } impl NoteEvent { pub fn time(&self) -> u32 { match *self { - NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => time, + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } | NoteEvent::Pressure { time, .. } => time, } } pub fn set_time(&mut self, t: u32) { match self { - NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => *time = t, + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } | NoteEvent::Pressure { time, .. } => *time = t, } } @@ -988,6 +993,10 @@ impl NoteEvent { e.r#type = sv::Event_::EventTypes_::kNoteOffEvent as u16; e.__field0.noteOff = sv::NoteOffEvent { channel: 0, pitch: i16::from(key), velocity: 0.0, noteId: -1, tuning: 0.0 }; } + NoteEvent::Pressure { key, pressure, .. } => { + e.r#type = sv::Event_::EventTypes_::kPolyPressureEvent as u16; + e.__field0.polyPressure = sv::PolyPressureEvent { channel: 0, pitch: i16::from(key), pressure, noteId: -1 }; + } } e } @@ -1261,6 +1270,20 @@ impl Instance { v.is_finite().then(|| v.clamp(0.0, 1.0)) } + /// The parameter each MIDI controller drives on channel 1 (`IMidiMapping`), indexed by VST3's + /// controller numbers: CCs 0–127, then [`AFTERTOUCH`] and [`PITCH_BEND`]. Main thread only. + pub fn midi_mapping(&self) -> Vec> { + let Some(m) = self.controller.as_ref().and_then(|c| c.cast::()) else { return Vec::new() }; + (0..=PITCH_BEND) + .map(|n| { + let mut id: sv::ParamID = 0; + let n = i16::try_from(n).unwrap_or(0); + // SAFETY: valid mapping interface on the main thread; `id` is a local. + ok(unsafe { m.getMidiControllerAssignment(0, 0, n, &mut id) }).then_some(id) + }) + .collect() + } + pub fn value_text(&self, id: u32, normalized: f64) -> Option { let c = self.controller.as_ref()?; let mut s: sv::String128 = [0; 128]; @@ -1414,13 +1437,16 @@ impl Instance { } /// Runs one `process` call over `frames` frames: `params` become one point each at offset 0, - /// `notes` (sorted by time) have their times made relative to `offset`. + /// `controls` (time, id, value) one point at their time when it falls in this call (the last + /// one wins per parameter), and `notes` (sorted by time) have their times made relative to + /// `offset`. pub fn process( &mut self, frames: usize, inputs: &mut BusBuffers, outputs: &mut BusBuffers, params: &[(u32, f64)], + controls: &[(u32, u32, f64)], notes: &[NoteEvent], offset: u32, project_time: i64, @@ -1443,6 +1469,11 @@ impl Instance { for &(id, v) in params.iter().take(PARAM_QUEUE_CAP) { self.in_params.set(id, 0, v.clamp(0.0, 1.0)); } + for &(time, id, v) in controls { + if let Some(at) = time.checked_sub(offset).filter(|&t| (t as usize) < frames) { + self.in_params.set(id, i32::try_from(at).unwrap_or(0), v.clamp(0.0, 1.0)); + } + } self.out_params.clear(); let event_in = self.event_in; self.in_events.with(|v| { diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index d88f6d6..321261e 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -1,6 +1,6 @@ //! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. -use crate::ffi::{BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP}; +use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND}; use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -35,6 +35,10 @@ pub struct Vst3Plugin { pending: Vec<(u32, f64)>, /// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`. notes: Vec, + /// The parameter each MIDI controller drives (see `Instance::midi_mapping`). + midi_map: Vec>, + /// Controller points for the next block: (time, param id, normalized value). Bounded. + controls: Vec<(u32, u32, f64)>, inputs: BusBuffers, outputs: BusBuffers, has_input: bool, @@ -78,6 +82,8 @@ impl Vst3Plugin { values: Vec::with_capacity(ids.len()), pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)), notes: Vec::with_capacity(EVENT_CAP), + midi_map: Vec::new(), + controls: Vec::with_capacity(EVENT_CAP), inputs: BusBuffers::new(&[], 0), outputs: BusBuffers::new(&[], 0), has_input: false, @@ -180,6 +186,7 @@ impl Plugin for Vst3Plugin { self.max_block = mb; self.latency = self.inst.latency() as usize; self.tail = self.inst.tail() as usize; + self.midi_map = self.inst.midi_mapping(); } Err(e) => { log::warn!("{}: {e}", self.info.id); @@ -197,6 +204,7 @@ impl Plugin for Vst3Plugin { fn reset(&mut self) { self.notes.clear(); + self.controls.clear(); self.held = [false; 128]; } @@ -239,6 +247,9 @@ impl Plugin for Vst3Plugin { e.set_time(last); } } + for c in &mut self.controls { + c.0 = c.0.min(last); + } let mut start = 0usize; while start < frames { let n = (frames - start).min(self.max_block); @@ -255,7 +266,17 @@ impl Plugin for Vst3Plugin { let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let notes = self.notes.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position, self.transport.as_ref()); + let r = self.inst.process( + n, + &mut self.inputs, + &mut self.outputs, + &self.pending, + &self.controls, + notes, + offset, + self.position, + self.transport.as_ref(), + ); self.pending.clear(); self.notes.drain(..k); self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); @@ -280,6 +301,7 @@ impl Plugin for Vst3Plugin { } start += n; } + self.controls.clear(); } fn latency(&self) -> usize { @@ -316,6 +338,26 @@ impl Plugin for Vst3Plugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let time = u32::try_from(offset).unwrap_or(u32::MAX); + let [_, d1, d2] = msg.map(|b| b & 0x7F); + let (ctrl, value) = match msg[0] & 0xF0 { + 0xB0 => (usize::from(d1), f64::from(d2) / 127.0), + 0xD0 => (AFTERTOUCH, f64::from(d1) / 127.0), + 0xE0 => (PITCH_BEND, f64::from(u16::from(d2) << 7 | u16::from(d1)) / 16383.0), + 0xA0 if self.event_input => { + self.push_note(NoteEvent::Pressure { time, key: d1, pressure: f32::from(d2) / 127.0 }); + return; + } + _ => return, + }; + if let Some(&Some(id)) = self.midi_map.get(ctrl) + && self.controls.len() < self.controls.capacity() + { + self.controls.push((time, id, value)); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 3ba07c6..ba1348a 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -9,7 +9,8 @@ //! 320×240, no real drawing) supporting NSView/HWND/X11. //! - "Fixture Synth" (`Instrument|Synth`): a single-component instrument (the component is also //! its edit controller). No audio input, one stereo output, one event input; every output -//! sample is the velocity of the held note (sample-accurate), 0 when none. +//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and +//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too. //! //! The module checks the host's module lifecycle the way real plugins depend on it (see "module //! lifecycle" below) and aborts the process when it is broken. @@ -417,7 +418,21 @@ struct Synth { } impl Class for Synth { - type Interfaces = (IComponent, IAudioProcessor, IEditController); + type Interfaces = (IComponent, IAudioProcessor, IEditController, IMidiMapping); +} + +/// The synth's hidden parameter that MIDI CC 7 drives. +const VOLUME_PARAM: ParamID = 1; + +impl IMidiMappingTrait for Synth { + unsafe fn getMidiControllerAssignment(&self, bus: i32, channel: i16, ctrl: CtrlNumber, id: *mut ParamID) -> tresult { + if bus == 0 && channel == 0 && ctrl == 7 { + unsafe { *id = VOLUME_PARAM }; + kResultOk + } else { + kResultFalse + } + } } impl IPluginBaseTrait for Synth { @@ -521,6 +536,16 @@ impl IAudioProcessorTrait for Synth { } } } + let mut volume = None; + if let Some(changes) = unsafe { ComRef::from_raw(d.inputParameterChanges) } { + for i in 0..unsafe { changes.getParameterCount() } { + let Some(q) = (unsafe { ComRef::from_raw(changes.getParameterData(i)) }) else { continue }; + let (mut at, mut v) = (0i32, 0f64); + if unsafe { q.getParameterId() } == VOLUME_PARAM && unsafe { q.getPoint(0, &mut at, &mut v) } == kResultOk { + volume = Some((at.max(0) as usize, v as f32)); + } + } + } let outs = unsafe { channels(&*d.outputs, frames) }; let mut level = f32::from_bits(self.level.load(Ordering::Relaxed)); let mut out = vec![0.0f32; frames]; @@ -530,8 +555,15 @@ impl IAudioProcessorTrait for Synth { level = unsafe { e.__field0.noteOn.velocity }; } else if e.r#type == Event_::EventTypes_::kNoteOffEvent as u16 { level = 0.0; + } else if e.r#type == Event_::EventTypes_::kPolyPressureEvent as u16 { + level = unsafe { e.__field0.polyPressure.pressure }; } } + if let Some((at, v)) = volume + && at == i + { + level = v; + } *o = level; } self.level.store(level.to_bits(), Ordering::Relaxed); diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7..86cc7a1 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -121,6 +121,16 @@ fn synth_plays_notes_at_their_offsets() { p.all_notes_off(); p.process(&mut io, 64); assert!(io[0].iter().all(|x| x.abs() < 1e-6)); + // MIDI CC 7 reaches the parameter the plugin maps it to, and poly pressure arrives as an event, + // each at its offset; an unmapped controller is dropped. + p.midi(10, [0xA0, 60, 32]); + p.midi(20, [0xB0, 7, 64]); + p.midi(30, [0xB0, 1, 127]); + p.process(&mut io, 64); + assert!(io[0][9].abs() < 1e-6 && (io[0][10] - 32.0 / 127.0).abs() < 1e-6); + assert!((io[0][20] - 64.0 / 127.0).abs() < 1e-6 && (io[0][63] - 64.0 / 127.0).abs() < 1e-6); + p.midi(0, [0xB0, 7, 0]); + p.process(&mut io, 64); // Notes in a later sub-block of a chunked call land at the right frame. p.prepare(48_000.0, 32, 2); let mut io = vec![vec![0.0f32; 96], vec![0.0f32; 96]]; From 87b348feccdbbc6d040de31ecb9d766e0257ce78 Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 23:50:06 -0300 Subject: [PATCH 007/154] Complete routing folder management --- crates/engine/src/cmd/track.rs | 247 ++++++++++++++++++++++++++++-- crates/ui-egui/src/edit_window.rs | 62 +++++++- 2 files changed, 293 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2f231f4..071a07d 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -3,7 +3,7 @@ use super::*; use crate::cmd; use serde_json::json; -use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, TrackHeight, TrackKind}; +use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind}; pub fn specs() -> Vec { vec![ @@ -45,6 +45,8 @@ pub fn specs() -> Vec { has_selection, move_to_folder ), + cmd!("track.move_to_folder", "Move to Folder", [], None, "{tracks?, folder}", has_selection, move_into_folder), + cmd!("track.remove_from_folder", "Remove from Folder", [], None, "{tracks?}", has_selection, remove_from_folder), cmd!( "track.change_width", "Change Track Width", @@ -239,6 +241,14 @@ fn new_track(e: &mut Engine, p: &Value) -> Result { t.instrument = Some(soundcraft_model::Insert::new(instrument.clone().unwrap_or_else(|| "subtractive_synth".into()))); } } + if kind == TrackKind::Folder { + let folder_name = s.track(id).map(|track| track.name.clone()).unwrap_or_else(|| "Folder".into()); + let bus_name = unique_folder_bus_name(s, &format!("{folder_name} Bus"), None); + let bus = s.add_bus(&bus_name, format); + if let Some(track) = s.track_mut(id) { + track.mixer.input = Route::Bus(bus); + } + } ids.push(id); } s.edit.selected_tracks = ids.clone(); @@ -385,6 +395,17 @@ fn flag( Ok(json!({"value": target, "tracks": tracks})) } +/// A routing folder owns its summing bus; never reuse a user bus with the same name. +fn unique_folder_bus_name(s: &Session, base: &str, own: Option) -> String { + for suffix in 1..=s.busses.len().saturating_add(2) { + let candidate = if suffix == 1 { base.to_string() } else { format!("{base} {suffix}") }; + if !s.busses.iter().any(|bus| bus.name == candidate && Some(bus.id) != own) { + return candidate; + } + } + format!("{base} {}", s.busses.len().saturating_add(3)) +} + fn move_to_folder(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.move_to_new_folder", p)?; let name = str_param(p, "name").map(str::to_string); @@ -398,7 +419,8 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result { } // A routing folder sums its members through its own bus. let fname = s.track(fid).map(|x| x.name.clone()).unwrap_or_else(|| "Folder".into()); - let bus = s.add_bus(&format!("{fname} Bus"), ChannelFormat::Stereo); + let bus_name = unique_folder_bus_name(s, &format!("{fname} Bus"), None); + let bus = s.add_bus(&bus_name, ChannelFormat::Stereo); if let Some(f) = s.track_mut(fid) { f.mixer.input = Route::Bus(bus); } @@ -410,9 +432,118 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result { } } } + place_under(s, fid, &tracks); Ok(json!({"folder": fid, "bus": bus})) } +/// Whether `track` sits inside `folder`, directly or through nested folders. +fn in_folder(s: &Session, track: TrackId, folder: TrackId) -> bool { + let mut current = s.track(track).and_then(|track| track.folder); + // The depth cap guards against a cyclic folder chain in a hostile session file. + for _ in 0..64 { + match current { + Some(parent) if parent == folder => return true, + Some(parent) => current = s.track(parent).and_then(|track| track.folder), + None => return false, + } + } + false +} + +/// Keep a folder's members together immediately below it, preserving their relative order. +fn place_under(s: &mut Session, folder: TrackId, tracks: &[TrackId]) { + // Moving a folder also moves its complete subtree as one stable block. + let moved: Vec<_> = s + .tracks + .iter() + .filter(|track| track.id != folder && (tracks.contains(&track.id) || tracks.iter().any(|root| in_folder(s, track.id, *root)))) + .cloned() + .collect(); + let moved_ids: Vec<_> = moved.iter().map(|track| track.id).collect(); + s.tracks.retain(|track| !moved_ids.contains(&track.id)); + let ids: Vec = s.tracks.iter().map(|track| track.id).collect(); + let Some(folder_index) = ids.iter().position(|track| *track == folder) else { return }; + let mut insert_at = folder_index + 1; + while ids.get(insert_at).is_some_and(|track| in_folder(s, *track, folder)) { + insert_at += 1; + } + for (offset, track) in moved.into_iter().enumerate() { + s.tracks.insert((insert_at + offset).min(s.tracks.len()), track); + } +} + +fn folder_bus(s: &Session, folder: TrackId) -> Option { + s.track(folder).map(|folder| folder.mixer.input.clone()).filter(|route| matches!(route, Route::Bus(_))) +} + +/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree. +fn root_tracks(s: &Session, tracks: Vec) -> Vec { + tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect() +} + +fn move_into_folder(e: &mut Engine, p: &Value) -> Result { + let folder = track_param(e, "track.move_to_folder", p, "folder")?.ok_or_else(|| bad("track.move_to_folder", "`folder` required"))?; + let tracks = tracks_required(e, "track.move_to_folder", p)?; + let session = e.session_mut(); + if !session.track(folder).is_some_and(Track::is_folder) { + return Err(bad("track.move_to_folder", "`folder` is not a folder track")); + } + // A folder cannot go inside itself or one of its descendants. + let tracks: Vec = tracks.into_iter().filter(|track| *track != folder && !in_folder(session, folder, *track)).collect(); + let tracks = root_tracks(session, tracks); + if tracks.is_empty() { + return Err(bad("track.move_to_folder", "nothing to move into that folder")); + } + let bus = folder_bus(session, folder); + for track in &tracks { + let old_bus = session.track(*track).and_then(|track| track.folder).and_then(|parent| folder_bus(session, parent)); + if let Some(moved) = session.track_mut(*track) { + moved.folder = Some(folder); + if let Some(bus) = &bus + && (moved.mixer.output == Route::Main || Some(&moved.mixer.output) == old_bus.as_ref()) + { + moved.mixer.output = bus.clone(); + } + } + } + place_under(session, folder, &tracks); + Ok(json!({"folder": folder, "moved": tracks.len()})) +} + +fn remove_from_folder(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.remove_from_folder", p)?; + let session = e.session_mut(); + let tracks = root_tracks(session, tracks); + let mut removed = 0; + for track in &tracks { + let Some(folder) = session.track(*track).and_then(|track| track.folder) else { continue }; + let bus = folder_bus(session, folder); + let parent = session.track(folder).and_then(|folder| folder.folder); + let parent_bus = parent.and_then(|parent| folder_bus(session, parent)); + if let Some(moved) = session.track_mut(*track) { + moved.folder = parent; + if bus.as_ref() == Some(&moved.mixer.output) { + moved.mixer.output = parent_bus.unwrap_or(Route::Main); + } + removed += 1; + } + let moved: Vec<_> = + session.tracks.iter().filter(|candidate| candidate.id == *track || in_folder(session, candidate.id, *track)).cloned().collect(); + let moved_ids: Vec<_> = moved.iter().map(|candidate| candidate.id).collect(); + session.tracks.retain(|candidate| !moved_ids.contains(&candidate.id)); + if let Some(folder_index) = session.track_index(folder) { + let mut insert_at = folder_index + 1; + while session.tracks.get(insert_at).is_some_and(|candidate| in_folder(session, candidate.id, folder)) { + insert_at += 1; + } + for (offset, moved) in moved.into_iter().enumerate() { + session.tracks.insert((insert_at + offset).min(session.tracks.len()), moved); + } + } + } + Ok(json!({"removed": removed})) +} + fn change_width(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.change_width", p)?; let f = str_param(p, "format").and_then(ChannelFormat::from_id).ok_or_else(|| bad("track.change_width", "`format` required"))?; @@ -429,16 +560,36 @@ fn change_width(e: &mut Engine, p: &Value) -> Result { fn delete(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.delete", p)?; let s = e.session_mut(); + // Promote children of deleted folders and repair only routes that used the deleted + // folder's own bus. Custom outputs remain untouched. + let mut promoted = Vec::new(); + for child in &s.tracks { + if tracks.contains(&child.id) { + continue; + } + let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue }; + let mut parent = s.track(folder).and_then(|folder| folder.folder); + for _ in 0..64 { + match parent { + Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder), + _ => break, + } + } + promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent)))); + } + for (child, old_bus, parent, parent_bus) in promoted { + if let Some(child) = s.track_mut(child) { + child.folder = parent; + if old_bus.as_ref() == Some(&child.mixer.output) { + child.mixer.output = parent_bus.unwrap_or(Route::Main); + } + } + } s.tracks.retain(|t| !tracks.contains(&t.id)); s.edit.selected_tracks.retain(|t| !tracks.contains(t)); for g in &mut s.groups { g.members.retain(|t| !tracks.contains(t)); } - for t in &mut s.tracks { - if t.folder.is_some_and(|f| tracks.contains(&f)) { - t.folder = None; - } - } Ok(json!({"deleted": tracks.len()})) } @@ -449,8 +600,19 @@ fn rename(e: &mut Engine, p: &Value) -> Result { if s.tracks.iter().any(|x| x.name == name && x.id != t) { return Err(bad("track.rename", format!("a track named `{name}` already exists"))); } - if let Some(tr) = s.track_mut(t) { - tr.name = name.chars().take(64).collect(); + let name: String = name.chars().take(64).collect(); + let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) { + (TrackKind::Folder, Route::Bus(bus)) => Some(*bus), + _ => None, + }); + if let Some(track) = s.track_mut(t) { + track.name.clone_from(&name); + } + if let Some(bus) = bus { + let bus_name = unique_folder_bus_name(s, &format!("{name} Bus"), Some(bus)); + if let Some(bus) = s.busses.iter_mut().find(|candidate| candidate.id == bus) { + bus.name = bus_name; + } } Ok(json!({"name": name})) } @@ -677,7 +839,72 @@ mod tests { assert!(r["bus"].is_u64()); let s = e.session(); let kick = s.track_by_name("Kick").unwrap(); + let snare = s.track_by_name("Snare").unwrap(); + let folder = s.tracks.iter().find(|track| track.kind == TrackKind::Folder).unwrap(); assert!(matches!(kick.mixer.output, Route::Bus(_))); - assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output)); + assert_eq!(folder.mixer.input, kick.mixer.output); + assert_eq!(folder.mixer.input, snare.mixer.output); + assert_eq!(s.track_index(folder.id).unwrap() + 1, s.track_index(kick.id).unwrap()); + assert_eq!(s.track_index(kick.id).unwrap() + 1, s.track_index(snare.id).unwrap()); + } + + #[test] + fn empty_folder_owns_a_unique_bus_and_renames_it() { + let mut e = crate::demo::demo_engine(); + let first = e.execute("track.new", &json!({"kind": "folder", "name": "Guitars"})).unwrap(); + let second = e.execute("track.new", &json!({"kind": "folder", "name": "Vocals"})).unwrap(); + let first = TrackId(first["tracks"][0].as_u64().unwrap()); + let second = TrackId(second["tracks"][0].as_u64().unwrap()); + let first_bus = match e.session().track(first).unwrap().mixer.input { + Route::Bus(bus) => bus, + _ => panic!("folder did not receive a bus"), + }; + let second_bus = match e.session().track(second).unwrap().mixer.input { + Route::Bus(bus) => bus, + _ => panic!("folder did not receive a bus"), + }; + assert_ne!(first_bus, second_bus); + e.session_mut().add_bus("Percussion Bus", ChannelFormat::Stereo); + e.execute("track.rename", &json!({"track": first.0, "name": "Percussion"})).unwrap(); + let renamed = e.session().busses.iter().find(|bus| bus.id == first_bus).unwrap(); + assert_eq!(renamed.name, "Percussion Bus 2"); + } + + #[test] + fn tracks_can_move_between_nested_folders_and_leave() { + let mut e = crate::demo::demo_engine(); + let outer = e.execute("track.new", &json!({"kind": "folder", "name": "Band"})).unwrap(); + let inner = e.execute("track.new", &json!({"kind": "folder", "name": "Drums"})).unwrap(); + let outer = TrackId(outer["tracks"][0].as_u64().unwrap()); + let inner = TrackId(inner["tracks"][0].as_u64().unwrap()); + + e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap(); + e.execute("track.move_to_folder", &json!({"tracks": [inner.0], "folder": outer.0})).unwrap(); + assert!(e.execute("track.move_to_folder", &json!({"tracks": [outer.0], "folder": inner.0})).is_err()); + + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, Some(inner)); + assert_eq!(kick.mixer.output, e.session().track(inner).unwrap().mixer.input); + assert_eq!(e.session().track_index(outer).unwrap() + 1, e.session().track_index(inner).unwrap()); + assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap()); + + e.execute("track.remove_from_folder", &json!({"tracks": ["Kick"]})).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, Some(outer)); + assert_eq!(kick.mixer.output, e.session().track(outer).unwrap().mixer.input); + + e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap(); + e.execute("track.remove_from_folder", &json!({"tracks": [inner.0]})).unwrap(); + let inner_track = e.session().track(inner).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(inner_track.folder, None); + assert_eq!(inner_track.mixer.output, Route::Main); + assert_eq!(kick.folder, Some(inner)); + assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap()); + + e.execute("track.delete", &json!({"tracks": [inner.0]})).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, None); + assert_eq!(kick.mixer.output, Route::Main); } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..7b38d0a 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -256,7 +256,32 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { } 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) + !t.hidden && !folder_collapsed(s, t) +} + +/// Hidden because a folder at any level above this track is closed. +fn folder_collapsed(s: &Session, track: &Track) -> bool { + let mut current = track.folder; + for _ in 0..64 { + let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false }; + if !folder.folder_open { + return true; + } + current = folder.folder; + } + false +} + +fn folder_depth(s: &Session, track: &Track) -> usize { + let mut current = track.folder; + let mut depth = 0; + while let Some(folder) = current.and_then(|folder| s.track(folder)) + && depth < 6 + { + depth += 1; + current = folder.folder; + } + depth } /// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one. @@ -795,7 +820,8 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } 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)); + let indent = folder_depth(app.engine.session(), track) as f32 * 10.0; + let name_r = Rect::from_min_size(pos2(x0 + indent, head.min.y + 4.0), vec2(150.0 - indent, 17.0)); ui.painter().rect( name_r, CornerRadius::same(2), @@ -869,7 +895,7 @@ 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)); + let fr = Rect::from_min_size(pos2(name_r.min.x - 12.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})); @@ -1093,21 +1119,45 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { ("Move to New Folder...", "track.move_to_new_folder"), ("Group...", "track.group"), ]; + let selection: Vec = { + let selected = &app.engine.session().edit.selected_tracks; + if selected.contains(&id) { selected.iter().map(|track| track.0).collect() } else { vec![tid] } + }; for (label, cmd) in items { if ui.button(label).clicked() { if cmd == "ui.rename_track" { let name = app.engine.session().track(id).map(|t| t.name.clone()).unwrap_or_default(); app.dialogs.open_rename_track(id, &name); } else { - let _ = app.run(cmd, json!({"tracks": [tid]})); + let _ = app.run(cmd, json!({"tracks": selection})); } } } + let folders: Vec<(u64, String)> = app + .engine + .session() + .tracks + .iter() + .filter(|track| track.is_folder() && !selection.contains(&track.id.0)) + .map(|track| (track.id.0, track.name.clone())) + .collect(); + if !folders.is_empty() { + ui.menu_button("Move to Folder", |ui| { + for (folder, name) in &folders { + if ui.button(name).clicked() { + let _ = app.run("track.move_to_folder", json!({"tracks": selection, "folder": folder})); + } + } + }); + } + if app.engine.session().track(id).is_some_and(|track| track.folder.is_some()) && ui.button("Remove from Folder").clicked() { + let _ = app.run("track.remove_from_folder", json!({"tracks": selection})); + } ui.separator(); ui.menu_button("Track Height", |ui| { for h in TrackHeight::ALL { if ui.button(h.label()).clicked() { - let _ = app.run("track.height", json!({"tracks": [tid], "height": h.label()})); + let _ = app.run("track.height", json!({"tracks": selection, "height": h.label()})); } } }); @@ -1116,7 +1166,7 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let (r, resp) = ui.allocate_exact_size(vec2(120.0, 14.0), Sense::click()); ui.painter().rect_filled(r, 2.0, rgb(*c)); if resp.clicked() { - let _ = app.run("track.color", json!({"tracks": [tid], "color": i})); + let _ = app.run("track.color", json!({"tracks": selection, "color": i})); } } }); 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 008/154] 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 009/154] 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 010/154] 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 011/154] 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 012/154] 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 013/154] 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 014/154] 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 67158020f04f56ec26c70218838b13515e8f96cd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 11:56:06 +0200 Subject: [PATCH 015/154] 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 016/154] 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 017/154] 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 018/154] 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 019/154] 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 020/154] 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 021/154] 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 022/154] 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 023/154] 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 024/154] 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 025/154] =?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 026/154] 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 027/154] 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 028/154] 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 029/154] 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 030/154] 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 031/154] 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 cf9cced90dd24c5c0cccf02a5d8125d9ac600f60 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:48:35 +0100 Subject: [PATCH 032/154] Create MIDI clips with the Pencil, Consolidate and the MIDI Editor Fixes #282: on an empty MIDI or instrument track there was no UI way to get a MIDI clip, so notes couldn't be entered by hand. `midi.new_clip` existed but had no menu item, key or tool. - Consolidate now works on MIDI and instrument tracks. The MIDI in the selection becomes one clip covering the whole selection, gaps included. It keeps the sound: muted clips stay silent, and notes are cut where their clip ended. An empty selection gives an empty clip, as in Pro Tools. Audio tracks are unchanged. - The Pencil draws a MIDI clip on an empty part of a MIDI or instrument track: a click makes one bar, a drag covers the bars it crosses, with an outline while dragging. Clips start and end on barlines, the rule Pro Tools uses for MIDI it creates. A clip stops at its neighbour rather than overwriting it, and clicks on existing clips add nothing. The new clip is selected, so the MIDI Editor shows it. - With an empty MIDI or instrument track selected, the MIDI Editor says so, and a click adds a one-bar clip at the insertion point (or a clip over the selection). --- crates/engine/src/cmd/edit.rs | 49 ++++++++- crates/engine/src/edit.rs | 29 ++++++ crates/ui-egui/src/edit_window.rs | 39 +++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/midi_editor.rs | 19 +++- crates/ui-egui/tests/new_midi_clip.rs | 137 ++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/new_midi_clip.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..f123d2e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -757,18 +757,20 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { if r.is_empty() { return Err(bad("edit.consolidate", "selection is empty")); } + let midi = tracks.iter().filter(|&&t| crate::edit::consolidate_midi(e.session_mut(), t, r)).count(); let mut n = 0; for t in tracks { if crate::io::consolidate(e, t, r)? { n += 1; } } - Ok(json!({"consolidated": n})) + Ok(json!({"consolidated": n + midi})) } #[cfg(test)] mod tests { use super::*; + use soundcraft_model::{ChannelFormat, Clip, ClipContent, TrackId, TrackKind}; #[test] fn selecting_what_is_already_selected_leaves_the_document_alone() { @@ -782,4 +784,49 @@ mod tests { e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap(); assert_ne!(e.revision, rev); } + + fn keys() -> (Engine, TrackId, impl Fn(i64) -> i64) { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let (tempo, sr) = (e.session().tempo.clone(), e.session().sample_rate); + (e, t, move |q| tempo.tick_to_samples(q * soundcraft_time::TICKS_PER_QUARTER, sr)) + } + + fn note(start: i64, length: i64, pitch: u8) -> soundcraft_midi::Note { + let q = soundcraft_time::TICKS_PER_QUARTER; + soundcraft_midi::Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start: start * q, length: length * q } + } + + #[test] + fn consolidating_an_empty_midi_selection_makes_an_empty_clip() { + let (mut e, t, at) = keys(); + e.execute("edit.select", &json!({"tracks": [t.0], "start": at(4), "end": at(12)})).unwrap(); + assert_eq!(e.execute("edit.consolidate", &json!({})).unwrap()["consolidated"], 1); + let clips = e.session().track(t).unwrap().clips().to_vec(); + assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::>(), vec![(at(4), at(12))]); + assert!(matches!(&clips[0].content, ClipContent::Midi { sequence } if sequence.notes.is_empty())); + } + + #[test] + fn consolidating_midi_merges_the_clips_as_they_sound() { + let (mut e, t, at) = keys(); + let s = e.session_mut(); + let cc = soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(1), value: 64 }; + let a = soundcraft_midi::Sequence { notes: vec![note(0, 1, 60), note(3, 2, 62), note(5, 1, 63)], ctrls: vec![] }; + let b = soundcraft_midi::Sequence { notes: vec![note(0, 1, 64)], ctrls: vec![] }; + let c = soundcraft_midi::Sequence { notes: vec![note(1, 1, 65)], ctrls: vec![cc] }; + for (start, end, seq, muted) in [(0, 4, a, false), (6, 8, b, true), (8, 12, c, false)] { + let id = s.new_clip_id(); + let mut clip = Clip::midi(id, "Keys", at(start), at(end) - at(start), seq); + clip.muted = muted; + ops::place_clip(s, t, clip); + } + e.execute("edit.select", &json!({"tracks": [t.0], "start": at(2), "end": at(10)})).unwrap(); + e.execute("edit.consolidate", &json!({})).unwrap(); + let clips = e.session().track(t).unwrap().clips().to_vec(); + assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::>(), vec![(0, at(2)), (at(2), at(10)), (at(10), at(12))]); + let ClipContent::Midi { sequence } = &clips[1].content else { panic!("not MIDI") }; + assert_eq!(sequence.notes, vec![note(1, 1, 62), note(7, 1, 65)]); + assert_eq!(sequence.ctrls, vec![soundcraft_midi::CtrlEvent { tick: 6 * soundcraft_time::TICKS_PER_QUARTER, ..cc }]); + } } diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 05cc0e6..f78460d 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -155,6 +155,35 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec { out } +/// Merge the MIDI inside `range` into one clip covering the whole range, gaps included, as it +/// sounds (muted clips stay silent). Returns false for tracks that aren't MIDI or instrument tracks. +pub fn consolidate_midi(s: &mut Session, track: TrackId, range: Range) -> bool { + let Some(name) = s.track(track).filter(|t| t.kind.is_midi()).map(|t| t.name.clone()) else { return false }; + let mut merged = soundcraft_midi::Sequence::default(); + { + let t = TimeCtx::of(s); + let base = t.ticks(range.start); + for part in copy_range(s, track, range).into_iter().filter(|c| !c.muted) { + let at = t.ticks(range.start.saturating_add(part.start)).saturating_sub(base); + let len = t.ticks(range.start.saturating_add(part.end())).saturating_sub(base).saturating_sub(at); + let ClipContent::Midi { sequence } = part.content else { continue }; + for mut n in sequence.notes.into_iter().filter(|n| (0..len).contains(&n.start)) { + n.length = n.length.min(len - n.start); + n.start += at; + merged.notes.push(n); + } + for mut c in sequence.ctrls.into_iter().filter(|c| (0..len).contains(&c.tick)) { + c.tick += at; + merged.ctrls.push(c); + } + } + } + merged.sort(); + let id = s.new_clip_id(); + place_clip(s, track, Clip::midi(id, name, range.start, range.len(), merged)); + true +} + /// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless /// `shuffle`, which first pushes later material right by `length`. pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec { diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..e0b4cc0 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1596,6 +1596,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, _ => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, }), Tool::Scrubber => Some(Gesture::Scrub { last: at }), + Tool::Pencil if auto_view.is_none() && track.kind.is_midi() => { + hit.is_none().then_some(Gesture::MidiClip { track: track.id, from: at_origin, to: at_origin }) + } Tool::Pencil => Some(Gesture::Pencil { track: track.id, points: Vec::new() }), Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; @@ -1639,6 +1642,8 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, "automation.set_point", json!({"track": track.id.0, "param": automation_id(param), "at": at, "value": lo + (hi - lo) * k}), ); + } else if track.kind.is_midi() && hit.is_none() { + new_midi_clip(app, &s, track.id, at, at); } } _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), @@ -1712,7 +1717,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { } } Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), - Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::Fade { to, .. } | Gesture::MidiClip { to, .. }) => *to = at, Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1778,6 +1783,7 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { } 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::MidiClip { track, from, to }) => new_midi_clip(app, s, track, from, to), Some(Gesture::Pencil { track, points }) => { if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) { let pid = automation_id(¶m); @@ -1831,6 +1837,30 @@ fn trim_clip(app: &mut SoundApp, clip: ClipId, track: TrackId, to: Samples, star let _ = app.engine.execute("edit.select", &json!({"tracks": tracks_before, "start": sel_before.start, "end": sel_before.end})); } +/// Whole bars from `from` to `to` (at least one), inside the gap between clips where `from` is. +fn midi_clip_span(s: &Session, track: TrackId, from: Samples, to: Samples) -> Option { + let clips = s.track(track)?.clips(); + let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; + let start = bar.line_at_or_before(from.min(to), s.sample_rate, &s.tempo, s.frame_rate); + let last = from.max(to); + let mut end = bar.line_at_or_before(last, s.sample_rate, &s.tempo, s.frame_rate); + if end < last || end <= start { + end = bar.next_line(end, s.sample_rate, &s.tempo, s.frame_rate); + } + let prev = clips.iter().map(Clip::end).filter(|&e| e <= from).max().unwrap_or(start); + let next = clips.iter().map(|c| c.start).filter(|&b| b > from).min().unwrap_or(end); + let r = Range::new(start.max(prev), end.min(next)); + (!r.is_empty()).then_some(r) +} + +fn new_midi_clip(app: &mut SoundApp, s: &Session, track: TrackId, from: Samples, to: Samples) { + if let Some(r) = midi_clip_span(s, track, from, to) + && let Ok(v) = app.run("midi.new_clip", json!({"track": track.0, "start": r.start, "end": r.end})) + { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); + } +} + fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { let Some(c) = clip else { if ui.button("Paste").clicked() { @@ -1981,6 +2011,13 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } } } + if let Some(Gesture::MidiClip { track, from, to }) = &app.gesture + && let Some(r) = midi_clip_span(s, *track, *from, *to) + && let Some((_, row)) = app.edit_layout.rows.iter().find(|(i, _)| *i == track.0) + { + let ghost = Rect::from_min_max(pos2(x_of(s, tl, r.start), row[1] + 1.0), pos2(x_of(s, tl, r.end), row[3] - 1.0)); + painter.rect_stroke(ghost, 2.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Inside); + } if let Some(Gesture::TrimStart { to, clip, .. } | Gesture::TrimEnd { to, clip, .. }) = &app.gesture && let Some((tid, _)) = s.find_clip(*clip) && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..0e36dcd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -213,6 +213,7 @@ pub enum Gesture { Fader { track: TrackId, start_db: f32 }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, + MidiClip { track: TrackId, from: Samples, to: Samples }, ZoomBox { start: f32 }, } diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index e69d625..12962d8 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -66,7 +66,24 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(header, 0.0, t.panel_bg2); ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, "MIDI EDITOR", bold(11.5), t.header_text); let Some(cid) = target_clip(app) else { - ui.painter().text(full.center(), Align2::CENTER_CENTER, "Select a MIDI clip (or a MIDI track) to edit its notes.", regular(13.0), t.text_dim); + let track = { + let s = app.engine.session(); + s.tracks.iter().find(|t| t.kind.is_midi() && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.name.clone())) + }; + let msg = match &track { + Some((_, name)) => format!("Click to add a MIDI clip to {name} at the insertion point."), + None => "Select a MIDI clip (or a MIDI track) to edit its notes.".into(), + }; + ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); + let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); + if let Some((id, _)) = track + && ui.interact(body, ui.id().with("new_clip"), Sense::click()).clicked() + { + let sel = app.engine.session().edit.selection; + if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); + } + } return; }; let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return }; diff --git a/crates/ui-egui/tests/new_midi_clip.rs b/crates/ui-egui/tests/new_midi_clip.rs new file mode 100644 index 0000000..ec3fb07 --- /dev/null +++ b/crates/ui-egui/tests/new_midi_clip.rs @@ -0,0 +1,137 @@ +//! Empty MIDI clips from the Pencil, in whole bars, and from the empty MIDI Editor (#282). + +use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_model::ClipContent; +use soundcraft_time::BarBeat; +use soundcraft_ui_egui::{Services, SoundApp, edit_window::x_of, midi_editor}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, +} + +impl Rig { + fn new(kind: &str) -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"kind": kind})).unwrap(); + engine.execute("edit.tool", &json!({"tool": "pencil"})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()) }; + 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() }; + self.ctx + .run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }) + .textures_delta + .clear(); + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + fn bar(&self, bar: i64) -> i64 { + let s = self.app.engine.session(); + s.tempo.samples_at_bar_beat(BarBeat { bar, beat: 1, tick: 0 }, s.sample_rate) + } + + fn at(&self, at: i64) -> Pos2 { + let [x0, y0, x1, y1] = self.app.edit_layout.timeline; + let x = x_of(self.app.engine.session(), Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)), at); + pos2(x, self.app.edit_layout.rows[0].1[1] + 10.0) + } + + 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: i64, to: i64) { + let (a, b) = (self.at(from), self.at(to)); + self.press(a, true); + for k in 1..=6 { + self.frame(vec![Event::PointerMoved(a.lerp(b, k as f32 / 6.0))]); + } + self.press(b, false); + self.frames(2); + } + + fn click(&mut self, at: i64) { + self.click_at(self.at(at)); + } + + fn click_at(&mut self, p: Pos2) { + self.press(p, true); + self.press(p, false); + self.frames(2); + } + + fn clips(&self) -> Vec<(i64, i64)> { + self.app.engine.session().tracks[0].clips().iter().map(|c| (c.start, c.end())).collect() + } +} + +#[test] +fn a_pencil_click_on_an_empty_instrument_track_makes_a_one_bar_clip() { + let mut r = Rig::new("instrument"); + let mid = (r.bar(3) + r.bar(4)) / 2; + r.click(mid); + assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); + let s = r.app.engine.session(); + let clip = &s.tracks[0].clips()[0]; + assert!(matches!(&clip.content, ClipContent::Midi { sequence } if sequence.notes.is_empty())); + assert_eq!(s.edit.selected_clips, vec![clip.id]); +} + +#[test] +fn a_pencil_drag_makes_a_clip_over_the_bars_it_crosses() { + let mut r = Rig::new("midi"); + let (from, to) = ((r.bar(2) + r.bar(3)) / 2, (r.bar(4) + r.bar(5)) / 2); + r.drag(from, to); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5))]); + r.app.run("edit.undo", json!({})).unwrap(); + r.drag(to, from); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5))]); +} + +#[test] +fn a_pencil_drag_stops_at_the_next_clip_and_clicks_on_clips_add_nothing() { + let mut r = Rig::new("instrument"); + r.click(r.bar(5)); + r.drag(r.bar(2), (r.bar(6) + r.bar(7)) / 2); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5)), (r.bar(5), r.bar(6))]); + r.click((r.bar(2) + r.bar(3)) / 2); + assert_eq!(r.clips().len(), 2); +} + +#[test] +fn the_pencil_makes_no_clips_on_audio_tracks() { + let mut r = Rig::new("audio"); + r.click(r.bar(3)); + r.drag(r.bar(2), r.bar(4)); + assert!(r.clips().is_empty()); +} + +#[test] +fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() { + let mut r = Rig::new("instrument"); + r.app.ui.show_midi_editor = true; + let track = r.app.engine.session().tracks[0].id.0; + r.app.run("edit.select", json!({"tracks": [track], "start": (r.bar(3) + r.bar(4)) / 2})).unwrap(); + r.frames(4); + assert_eq!(midi_editor::target_clip(&r.app), None); + let body = r.ctx.data(|d| d.get_temp::(Id::new("soundcraft-midi-body"))).unwrap(); + r.click_at(body.center()); + assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); + assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id)); +} From 4e14c980a40f2a0d3b882d72b06645422df740fa Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:50:38 +0200 Subject: [PATCH 033/154] 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 034/154] 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 035/154] 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 036/154] 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 eb5bf699c7565c18a56a2bc5b802662b4345b6ec Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:13:25 +0100 Subject: [PATCH 037/154] Bounce Mix: Browse for the file, and start in the session folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From #186: the Bounce dialog had no Browse button, so the file path had to be typed, and it always proposed the home folder. - A Browse… button opens the system save panel with an Audio filter (WAV, AIFF, FLAC). The chosen file goes in the field, and its extension sets the Format. - A saved session bounces into its own folder by default; unsaved sessions still start in the home folder. - The desktop save panel now picks its filter by purpose (bounce or session) and opens in the proposed file's folder when it exists, so Save As starts there too instead of wherever the panel was last. - The file field is 420pt wide, like the other path prompts. --- apps/soundcraft/src/main.rs | 10 ++++++-- crates/ui-egui/src/dialogs.rs | 17 +++++++++++-- crates/ui-egui/tests/bounce_dialog.rs | 36 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/bounce_dialog.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7..ac867b7 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -167,9 +167,15 @@ fn services() -> Services { }; d.pick_file().map(|p| p.to_string_lossy().into_owned()) })), - pick_save: Some(Box::new(|_purpose: &str, default: &str| { + pick_save: Some(Box::new(|purpose: &str, default: &str| { let path = std::path::Path::new(default); - let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]); + let mut d = match purpose { + "file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]), + _ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]), + }; + if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) { + d = d.set_directory(dir); + } if let Some(n) = path.file_name().and_then(|n| n.to_str()) { d = d.set_file_name(n); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..38eaa21 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -85,7 +85,8 @@ impl Dialogs { } "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, "file.bounce_mix" => { - Dialog::Bounce { path: format!("{home}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } + let dir = e.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map_or(home.clone(), |d| d.display().to_string()); + Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } } "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() }, @@ -339,7 +340,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { path, format, bit_depth, normalize } => { ui.horizontal(|ui| { ui.label("File"); - ui.add(egui::TextEdit::singleline(path).desired_width(360.0)); + ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); + if let Some(pick) = &app.services.pick_save + && ui.button("Browse…").clicked() + && let Some(p) = pick("file.bounce_mix", path) + { + *path = p; + match std::path::Path::new(path.as_str()).extension().map(|x| x.to_string_lossy().to_lowercase()).as_deref() { + Some("wav") => *format = "wav".into(), + Some("aif" | "aiff") => *format = "aiff".into(), + Some("flac") => *format = "flac".into(), + _ => {} + } + } }); ui.horizontal(|ui| { egui::ComboBox::from_label("Format").selected_text(format.to_uppercase()).show_ui(ui, |ui| { diff --git a/crates/ui-egui/tests/bounce_dialog.rs b/crates/ui-egui/tests/bounce_dialog.rs new file mode 100644 index 0000000..04ad4d6 --- /dev/null +++ b/crates/ui-egui/tests/bounce_dialog.rs @@ -0,0 +1,36 @@ +//! Bounce Mix starts in the session's folder and has a Browse button for the file (#186). +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog}; +use std::cell::RefCell; +use std::rc::Rc; + +#[test] +fn bounce_starts_in_the_session_folder_and_browse_picks_the_file_and_format() { + let asked = Rc::new(RefCell::new(Vec::new())); + let log = Rc::clone(&asked); + let pick_save: Box Option> = Box::new(move |purpose, default| { + log.borrow_mut().push((purpose.to_string(), default.to_string())); + Some("/Mixes/Final Mix.FLAC".into()) + }); + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.path = Some("/Songs/Midnight Groove/Midnight Groove.scraft".into()); + let mut app = SoundApp::new(engine, None, Services { pick_save: Some(pick_save), ..Default::default() }); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + 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.get_by_label("Browse…").click(); + h.run_steps(3); + let default = "/Songs/Midnight Groove/Midnight Groove Bounce.wav".to_string(); + assert_eq!(*asked.borrow(), vec![("file.bounce_mix".to_string(), default)]); + match &h.state().dialogs.open { + Some(Dialog::Bounce { path, format, .. }) => assert_eq!((path.as_str(), format.as_str()), ("/Mixes/Final Mix.FLAC", "flac")), + other => panic!("{other:?}"), + } +} 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 038/154] 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 039/154] 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 040/154] 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 041/154] 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 042/154] 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 043/154] 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 044/154] 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 045/154] 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 046/154] 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 047/154] 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 048/154] 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 049/154] 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 793432bbfc26ae28ebb7e72bc3462857f78ab6b3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:45:05 +0100 Subject: [PATCH 050/154] Read plugin state on the UI thread, not the audio thread Saving, bouncing and the two-minute autosave read every third-party plugin's state through the audio callback: a command made the callback call each plugin's save_state between two blocks and reply, while the UI waited for up to 2 s. So: - CLAP's clap.state and VST3's getState ran on the audio thread, although both formats want them on the main thread. - The callback allocated and waited on the plugins; a plugin with a large state (a sampler) could make the audio drop out on every save and autosave. - Without a running callback (a stalled or lost device), every save waited 2 s and then kept stale plugin states. Each instance now hands Side::prepare a state reader before it leaves for the audio thread, the way it hands over its editor handle. CLAP's GuiLink and VST3's EditorLink (which now holds the component as well) read the state under their lock; Audio Units get a StateLink that does the same. Each instance cuts its link off before it is destroyed, so a reader never calls into a dead plugin. Player::capture_states reads them on the calling (UI) thread, and the CaptureStates command is gone. Each host's tests read the state while another thread processes the instance, and get nothing once it is dropped. Playback reads states with no audio thread at all. --- crates/au-host/src/ffi.rs | 86 ++++++++++++++++++--------- crates/au-host/src/plugin.rs | 9 +-- crates/au-host/tests/system_units.rs | 29 +++++++++ crates/clap-host/src/ffi.rs | 55 +++++++++++------ crates/clap-host/src/plugin.rs | 9 +-- crates/clap-host/tests/fixture_e2e.rs | 25 ++++++++ crates/dsp/src/lib.rs | 26 ++++++-- crates/engine/src/cmd/plugin_state.rs | 4 +- crates/engine/tests/clap_e2e.rs | 8 +-- crates/mix/src/lib.rs | 43 +++----------- crates/playback/src/lib.rs | 81 ++++++++++++++----------- crates/ui-egui/src/lib.rs | 4 +- crates/vst3-host/src/ffi.rs | 66 ++++++++++++-------- crates/vst3-host/src/plugin.rs | 9 +-- crates/vst3-host/tests/fixture_e2e.rs | 24 ++++++++ 15 files changed, 315 insertions(+), 163 deletions(-) diff --git a/crates/au-host/src/ffi.rs b/crates/au-host/src/ffi.rs index a115244..f7b801e 100644 --- a/crates/au-host/src/ffi.rs +++ b/crates/au-host/src/ffi.rs @@ -369,6 +369,7 @@ pub struct Instance { max_frames: usize, sample_rate: f64, sample_time: f64, + link: std::sync::Arc, } // SAFETY: an Audio Unit instance may be used from any thread as long as calls are not @@ -405,6 +406,7 @@ impl Instance { outputs: Vec::new(), abl: AudioBufferList { number_buffers: 0, buffers: [empty; MAX_AU_CHANNELS] }, has_input, + link: std::sync::Arc::new(StateLink(std::sync::Mutex::new(LinkedUnit(Some(unit))))), initialized: false, channels: 0, max_frames: 0, @@ -678,35 +680,12 @@ impl Instance { out } - /// The unit's `ClassInfo` as a binary property list. - pub fn save_state(&self) -> Option> { - let mut plist: CFTypeRef = ptr::null(); - let mut size = std::mem::size_of::() as u32; - // SAFETY: the property is a `CFPropertyListRef` returned +1; `plist` is a local pointer. - let st = unsafe { AudioUnitGetProperty(self.unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) }; - if st != 0 || plist.is_null() { - release(plist); - return None; - } - let mut err: CFTypeRef = ptr::null(); - // SAFETY: `plist` is a live property list; `err` is a local out pointer. - let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) }; - release(err); - release(plist); - if data.is_null() { - return None; - } - // SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length. - let out = unsafe { - let len = CFDataGetLength(data); - let p = CFDataGetBytePtr(data); - if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) } - }; - release(data); - out + /// The link for state handles. + pub fn state_link(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.link) } - /// Restores a blob from [`Instance::save_state`] (hostile input: parsed by CoreFoundation, + /// Restores a blob from [`StateLink::save_state`] (hostile input: parsed by CoreFoundation, /// must be a dictionary, and the unit validates it). pub fn load_state(&self, bytes: &[u8]) -> bool { if bytes.is_empty() || bytes.len() > MAX_STATE { @@ -733,8 +712,61 @@ impl Instance { } } +struct LinkedUnit(Option); + +// SAFETY: the pointer is only used under the link's mutex, while the instance keeps the unit alive. +unsafe impl Send for LinkedUnit {} + +/// The link between an instance and its state handles: the unit's state is read on the main +/// thread while the instance renders on the audio thread (hosts make property calls from other +/// threads during rendering). The instance clears the link under its lock before it disposes of +/// the unit, so a handle never reads a dead unit. +pub(crate) struct StateLink(std::sync::Mutex); + +impl StateLink { + fn lock(&self) -> std::sync::MutexGuard<'_, LinkedUnit> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The unit's `ClassInfo` as a binary property list (`None` once the instance is gone). + pub fn save_state(&self) -> Option> { + let g = self.lock(); + let unit = g.0?; + let mut plist: CFTypeRef = ptr::null(); + let mut size = std::mem::size_of::() as u32; + // SAFETY: the unit is live while the link holds it (see the type); the property is a + // `CFPropertyListRef` returned +1; `plist` is a local pointer. + let st = unsafe { AudioUnitGetProperty(unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) }; + if st != 0 || plist.is_null() { + release(plist); + return None; + } + let mut err: CFTypeRef = ptr::null(); + // SAFETY: `plist` is a live property list; `err` is a local out pointer. + let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) }; + release(err); + release(plist); + if data.is_null() { + return None; + } + // SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length. + let out = unsafe { + let len = CFDataGetLength(data); + let p = CFDataGetBytePtr(data); + if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) } + }; + release(data); + out + } + + fn kill(&self) { + self.lock().0 = None; + } +} + impl Drop for Instance { fn drop(&mut self) { + self.link.kill(); // SAFETY: the unit is live (disposed only here); after disposal nothing can call the // input callback, so the leaked input state can be freed. unsafe { diff --git a/crates/au-host/src/plugin.rs b/crates/au-host/src/plugin.rs index c76aab0..eed7fc2 100644 --- a/crates/au-host/src/plugin.rs +++ b/crates/au-host/src/plugin.rs @@ -237,10 +237,6 @@ impl Plugin for AuPlugin { self.push_event(MidiEvent { time: 0, status: 0xB0, data1: 123, data2: 0 }); } - fn save_state(&mut self) -> Option> { - self.inst.save_state() - } - fn load_state(&mut self, data: &[u8]) -> bool { let ok = self.inst.load_state(data); if ok { @@ -248,4 +244,9 @@ impl Plugin for AuPlugin { } ok } + + fn state_reader(&mut self) -> Option> { + let link = self.inst.state_link(); + Some(Box::new(move || link.save_state())) + } } diff --git a/crates/au-host/tests/system_units.rs b/crates/au-host/tests/system_units.rs index 2e24675..85c4f08 100644 --- a/crates/au-host/tests/system_units.rs +++ b/crates/au-host/tests/system_units.rs @@ -100,6 +100,35 @@ fn lowpass_attenuates_highs() { assert!(run_tone(p.as_mut(), 100.0).is_finite()); } +#[test] +fn state_reader_reads_while_another_thread_renders() { + if !available(LOWPASS) { + return; + } + let info = soundcraft_au_host::plugin_info(LOWPASS).unwrap(); + let cutoff = info.params.iter().find(|q| q.unit == soundcraft_dsp::Unit::Hz).unwrap(); + let mut p = soundcraft_au_host::create(LOWPASS).unwrap(); + p.prepare(48_000.0, 512, 2); + assert!(p.set_param(cutoff.id, 500.0)); + let mut reader = p.state_reader().expect("every unit has a ClassInfo"); + let audio = std::thread::spawn(move || { + for _ in 0..4 { + run_tone(p.as_mut(), 100.0); + } + p + }); + while !audio.is_finished() { + assert!(reader.save_state().is_some()); + } + let p = audio.join().unwrap(); + let state = reader.save_state().expect("ClassInfo"); + drop(p); + let mut q = soundcraft_au_host::create(LOWPASS).unwrap(); + assert_eq!(reader.save_state(), None); + assert!(q.load_state(&state)); + assert!((q.param(cutoff.id).unwrap() - 500.0).abs() < 1.0, "{:?}", q.param(cutoff.id)); +} + #[test] fn delay_runs_mono_stereo_and_odd_blocks() { if !available(DELAY) { diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33..69cdeac 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -275,7 +275,7 @@ impl Bundle { inst.state_ext = inst.extension::(CLAP_EXT_STATE); let gui = inst.extension::(CLAP_EXT_GUI); inst.gui = Arc::new(GuiLink { - inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false }), + inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false }), host: Arc::clone(&inst.state), }); Ok(inst) @@ -696,17 +696,19 @@ fn window_api() -> Option<&'static CStr> { struct GuiInner { plugin: *const clap_plugin, gui: *const clap_plugin_gui, + state: *const clap_plugin_state, /// The plugin instance exists (cleared by `Instance::drop` before it destroys the plugin). alive: bool, created: bool, visible: bool, } -/// The link between an instance and its editor handles. The instance may process on the audio -/// thread while the editor is driven from the main thread: CLAP allows `clap.gui` (main-thread) -/// calls concurrently with audio-thread calls. Every GUI call happens under this lock, and the -/// instance clears `alive` (destroying any GUI) under the same lock before it destroys the -/// plugin, so a handle can never call into a destroyed plugin. +/// The link between an instance and its editor and state handles. The instance may process on +/// the audio thread while the editor is driven and the state saved from the main thread: CLAP +/// allows `clap.gui` and `clap.state` (main-thread) calls concurrently with audio-thread calls. +/// Every such call happens under this lock, and the instance clears `alive` (destroying any GUI) +/// under the same lock before it destroys the plugin, so a handle can never call into a destroyed +/// plugin. pub(crate) struct GuiLink { inner: Mutex, host: Arc, @@ -715,7 +717,14 @@ pub(crate) struct GuiLink { impl Default for GuiLink { fn default() -> Self { GuiLink { - inner: Mutex::new(GuiInner { plugin: std::ptr::null(), gui: std::ptr::null(), alive: false, created: false, visible: false }), + inner: Mutex::new(GuiInner { + plugin: std::ptr::null(), + gui: std::ptr::null(), + state: std::ptr::null(), + alive: false, + created: false, + visible: false, + }), host: Arc::new(HostState::default()), } } @@ -731,7 +740,8 @@ impl GuiLink { } pub fn available(&self) -> bool { - self.lock().alive + let g = self.lock(); + g.alive && !g.gui.is_null() } pub fn host(&self) -> &HostState { @@ -805,6 +815,22 @@ impl GuiLink { g.visible = false; } + /// `clap_plugin_state.save` into memory (`None` without the extension, on failure, or once the + /// plugin is gone). + pub fn save_state(&self) -> Option> { + let g = self.lock(); + if !g.alive { + return None; + } + // SAFETY: `state` is null or the plugin's static extension vtable, valid while `alive`. + let save = unsafe { g.state.as_ref() }?.save?; + let mut out: Vec = Vec::new(); + let stream = clap_ostream { ctx: (&mut out as *mut Vec).cast::(), write: Some(ostream_write) }; + // SAFETY: live plugin (`alive`, under the lock); the stream and its context live on this + // frame for the call. + unsafe { save(g.plugin, &stream) }.then_some(out) + } + /// Closes (destroys) the editor. pub fn close(&self) { if !on_main_thread() { @@ -1012,19 +1038,14 @@ impl Instance { self.gui.available().then(|| Arc::clone(&self.gui)) } - /// `clap_plugin_state.save` into memory (`None` without the extension or on failure). - pub fn save_state(&self) -> Option> { - // SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin. - let save = unsafe { self.state_ext.as_ref() }?.save?; - let mut out: Vec = Vec::new(); - let stream = clap_ostream { ctx: (&mut out as *mut Vec).cast::(), write: Some(ostream_write) }; - // SAFETY: valid instance; the stream and its context live on this frame for the call. - unsafe { save(self.plugin, &stream) }.then_some(out) + /// The same link, for state handles (`None` when the plugin has no `clap.state`). + pub fn state_link(&self) -> Option> { + (!self.state_ext.is_null()).then(|| Arc::clone(&self.gui)) } /// `clap_plugin_state.load` from memory. `false` without the extension or when refused. pub fn load_state(&self, data: &[u8]) -> bool { - // SAFETY: see `save_state`. + // SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin. let Some(load) = (unsafe { self.state_ext.as_ref() }).and_then(|s| s.load) else { return false }; let mut cursor = ReadCursor { data, pos: 0 }; let stream = clap_istream { ctx: (&mut cursor as *mut ReadCursor<'_>).cast::(), read: Some(istream_read) }; diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 6fe1abc..dff5d42 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -256,10 +256,6 @@ impl Plugin for ClapPlugin { } } - fn save_state(&mut self) -> Option> { - self.inst.save_state() - } - fn load_state(&mut self, data: &[u8]) -> bool { let ok = self.inst.load_state(data); if ok { @@ -283,6 +279,11 @@ impl Plugin for ClapPlugin { let link = self.inst.gui()?; Some(Box::new(ClapEditor { link, info: self.info, ids: self.ids.clone() })) } + + fn state_reader(&mut self) -> Option> { + let link = self.inst.state_link()?; + Some(Box::new(move || link.save_state())) + } } /// A CLAP plugin's floating editor, driven from the main thread (see [`GuiLink`]). diff --git a/crates/clap-host/tests/fixture_e2e.rs b/crates/clap-host/tests/fixture_e2e.rs index 2216487..10cb04b 100644 --- a/crates/clap-host/tests/fixture_e2e.rs +++ b/crates/clap-host/tests/fixture_e2e.rs @@ -146,11 +146,36 @@ fn state_saves_and_restores_into_a_new_instance() { // Plugins without clap.state. let mut s = soundcraft_clap_host::create(SYNTH).unwrap(); assert!(s.save_state().is_none()); + assert!(s.state_reader().is_none()); assert!(!s.load_state(&blob)); assert!(s.editor().is_none(), "the synth has no GUI"); assert!(s.open_editor().is_err()); } +#[test] +fn state_reader_reads_while_another_thread_processes() { + fixture_dir(); + let mut p = soundcraft_clap_host::create(GAIN).unwrap(); + p.prepare(48_000.0, 64, 2); + assert!(p.set_param("7", 1.5)); + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + let mut reader = p.state_reader().expect("the gain fixture has clap.state"); + let blob = p.save_state().unwrap(); + let audio = std::thread::spawn(move || { + for _ in 0..2_000 { + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + } + p + }); + while !audio.is_finished() { + assert_eq!(reader.save_state().as_ref(), Some(&blob)); + } + let p = audio.join().unwrap(); + assert_eq!(reader.save_state(), Some(blob)); + drop(p); + assert_eq!(reader.save_state(), None); +} + #[test] fn editors_refuse_to_open_off_the_main_thread() { fixture_dir(); diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 3d5bd33..f0ca05a 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -206,11 +206,12 @@ pub trait Plugin: Send { 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. + /// The plugin's complete internal state as an opaque blob (third-party plugins), as its + /// [`Plugin::state_reader`] reads it. `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. fn save_state(&mut self) -> Option> { - None + self.state_reader()?.save_state() } /// Restores a blob from [`Plugin::save_state`]. `false` if unsupported or the plugin rejected /// it (the plugin keeps working with its previous state). The data is hostile input. @@ -229,6 +230,10 @@ pub trait Plugin: Send { fn editor(&mut self) -> Option> { None } + /// Reads this instance's state from the UI thread (`None` when it has no state of its own). + fn state_reader(&mut self) -> Option> { + None + } } /// A plugin's editor (GUI), driven from the main (UI) thread. Obtained from @@ -249,6 +254,19 @@ pub trait PluginEditor: Send { fn set_param(&mut self, _id: &str, _value: f32) {} } +/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio +/// thread, as plugin formats allow. Obtained from [`Plugin::state_reader`]; closures are readers. +pub trait PluginStateReader: Send { + /// What [`Plugin::save_state`] would return; `None` once the instance is gone. + fn save_state(&mut self) -> Option>; +} + +impl Option> + Send> PluginStateReader for F { + fn save_state(&mut self) -> Option> { + self() + } +} + /// 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. diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs index b3ca0d3..e999c86 100644 --- a/crates/engine/src/cmd/plugin_state.rs +++ b/crates/engine/src/cmd/plugin_state.rs @@ -1,6 +1,6 @@ //! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's -//! state from the audio engine right before saving and stores it on its insert (base64), from -//! where the mixer restores it into new instances. +//! state right before saving and stores it on its insert (base64), from where the mixer restores +//! it into new instances. use super::*; use serde_json::json; diff --git a/crates/engine/tests/clap_e2e.rs b/crates/engine/tests/clap_e2e.rs index 9a559f1..404252c 100644 --- a/crates/engine/tests/clap_e2e.rs +++ b/crates/engine/tests/clap_e2e.rs @@ -92,13 +92,11 @@ fn clap_state_is_captured_stored_and_restored_before_the_first_block() { p.set_param("7", 1.5); let mut io = vec![vec![0.0f32; 64]]; p.process(&mut io, 64); + let mut reader = p.state_reader().unwrap(); eng.adopt(vec![soundcraft_mix::PreparedInstance { track: t, slot: 0, id: gain.into(), ch: 1, plugin: p }]); eng.ensure_synced(e.session()); - let states = eng.capture_states(); - assert_eq!(states.len(), 1); - let (tt, slot, blob) = &states[0]; - assert_eq!((*tt, *slot), (t, 0)); - e.execute("mix.insert_state", &json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(blob)})).unwrap(); + let blob = reader.save_state().unwrap(); + e.execute("mix.insert_state", &json!({"track": t.0, "slot": 0, "state": soundcraft_model::b64::encode(&blob)})).unwrap(); assert_eq!(eng.take_retired().len(), 0); // Offline render (synchronous creation): the state is restored before the first block. diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..87a3564 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -39,9 +39,8 @@ //! //! An instance starts from its stored state; the insert's parameter values are then applied on //! top before the first block (the session's values are kept in step with the plugin, including -//! edits made in its own editor, so both agree; the state carries everything else). -//! [`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. +//! edits made in its own editor, so both agree; the state carries everything else). A host reads +//! states back for saving through each instance's [`Plugin::state_reader`], on its UI thread. //! //! 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 @@ -71,8 +70,8 @@ pub fn main_channels(s: &Session) -> usize { s.main_format().channels().clamp(MAIN_CHANNELS, MAX_CHANNELS) } -/// Slot index used for a track's instrument plugin in [`InstanceSpec`], [`PreparedInstance`] and -/// [`MixEngine::capture_states`] (insert slots use their index). +/// Slot index used for a track's instrument plugin in [`InstanceSpec`] and [`PreparedInstance`] +/// (insert slots use their index). pub const INSTRUMENT_SLOT: usize = usize::MAX; /// Hosted third-party plugin ids (CLAP or VST3): created off the audio thread in realtime use. @@ -431,24 +430,6 @@ impl MixEngine { out } - /// The state of every live plugin that has one (third-party plugins), as - /// `(track, slot or INSTRUMENT_SLOT, state)`. Calls into the plugins and allocates. - pub fn capture_states(&mut self) -> Vec<(TrackId, usize, Vec)> { - let mut out = Vec::new(); - for (tid, st) in self.strips.iter_mut() { - for (i, slot) in st.plugins.iter_mut().enumerate() { - if let Some(data) = slot.as_mut().and_then(|s| s.plugin.save_state()) { - out.push((*tid, i, data)); - } - } - if let Some(data) = st.instrument.as_mut().and_then(|s| s.plugin.save_state()) { - out.push((*tid, INSTRUMENT_SLOT, data)); - } - } - out.sort_by_key(|(t, s, _)| (*t, *s)); - out - } - pub fn max_block(&self) -> usize { self.max_block } @@ -2136,10 +2117,9 @@ mod tests { let _ = render_range(&s, Range::new(0, 4800), 512); } - /// A built-in gain behind a third-party id, with a state blob and a log of parameter sets. + /// A built-in gain behind a third-party id, with a log of parameter sets. struct Stateful { inner: Box, - blob: Vec, sets: Arc, } @@ -2163,9 +2143,6 @@ mod tests { fn process(&mut self, io: &mut [Vec], frames: usize) { self.inner.process(io, frames); } - fn save_state(&mut self) -> Option> { - Some(self.blob.clone()) - } } fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 { @@ -2175,7 +2152,7 @@ mod tests { } #[test] - fn external_instances_are_adopted_retired_and_captured() { + fn external_instances_are_adopted_and_retired() { let (mut s, t) = session_with_dc(0.5, 48_000); let mut ins = Insert::new("clap:test.fake"); ins.params.insert("gain".into(), -6.0); @@ -2191,9 +2168,9 @@ mod tests { let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let mut gain = soundcraft_dsp::create("gain").unwrap(); gain.prepare(48_000.0, 512, 1); - let fake = Stateful { inner: gain, blob: b"live state".to_vec(), sets: Arc::clone(&sets) }; + let fake = Stateful { inner: gain, sets: Arc::clone(&sets) }; // An instance for the wrong channel count is retired, not used. - let wrong = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), blob: Vec::new(), sets: Arc::clone(&sets) }; + let wrong = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) }; eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 2, plugin: Box::new(wrong) }]); assert!(render_block(&mut eng, &s, 512) > 0.3); assert_eq!(eng.take_retired().len(), 1); @@ -2204,13 +2181,11 @@ mod tests { } assert!((wet / dry - 0.501).abs() < 0.01, "{wet} vs {dry}"); assert!(sets.load(std::sync::atomic::Ordering::Relaxed) > 0, "no stored state: session params are pushed"); - assert_eq!(eng.capture_states(), vec![(t, 0, b"live state".to_vec())]); assert!(eng.take_retired().is_empty()); // Removing the insert retires the instance instead of dropping it here. s.track_mut(t).unwrap().mixer.inserts[0] = None; render_block(&mut eng, &s, 20 * 512); assert_eq!(eng.take_retired().len(), 1); - assert!(eng.capture_states().is_empty()); // Instances nobody claims are retired too; drain_plugins empties the engine. eng.adopt(vec![PreparedInstance { track: t, slot: 3, id: "clap:x".into(), ch: 1, plugin: soundcraft_dsp::create("gain").unwrap() }]); render_block(&mut eng, &s, 21 * 512); @@ -2229,7 +2204,7 @@ mod tests { 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 fake = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), blob: Vec::new(), sets: Arc::clone(&sets) }; + let fake = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) }; eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "vst3:00000000000000000000000000000001".into(), ch: 1, plugin: Box::new(fake) }]); render_block(&mut eng, &s, 0); assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 1, "session params apply once"); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..c079ece 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -16,15 +16,14 @@ //! 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 -//! 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 +//! on a return channel and are dropped on the UI thread ([`Player::idle`]). Plugin editors are +//! driven, and plugin states for saving read ([`Player::capture_states`]), on the UI thread //! through handles taken from each instance before it leaves ([`Player::open_editor`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod record; -use soundcraft_dsp::{Plugin, PluginEditor}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginStateReader}; use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels}; use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; @@ -55,8 +54,6 @@ 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), - /// Read every plugin state and reply. - CaptureStates(Sender), Input(Arc), Recording(bool), Play { @@ -136,10 +133,6 @@ impl AudioState { match c { Cmd::Input(r) => self.input = Some(r), Cmd::Recording(r) => self.recording = r, - Cmd::CaptureStates(reply) => { - self.mix.ensure_synced(&self.session); - let _ = reply.send(self.mix.capture_states()); - } Cmd::Session(s, instances) => { changed = true; if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 { @@ -180,8 +173,8 @@ impl AudioState { } } if changed { - // Place adopted instances now (not only once playback starts), so state captures and - // editors see them; `sync` creates no third-party plugins here. + // Place adopted instances now (not only once playback starts), so editors see them; + // `sync` creates no third-party plugins here. self.mix.ensure_synced(&self.session); } self.send_garbage(); @@ -325,10 +318,16 @@ struct Side { rate: f32, /// Editor handles of the live instances. editors: HashMap<(TrackId, usize), Box>, + /// State readers of the live instances. + states: HashMap<(TrackId, usize), Box>, garbage: Receiver>>, } impl Side { + fn new(rate: f32, garbage: Receiver>>) -> Side { + Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: HashMap::new(), garbage } + } + /// 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() { @@ -338,6 +337,7 @@ impl Side { fn forget(&mut self, key: (TrackId, usize)) { self.shadow.remove(&key); + self.states.remove(&key); if let Some(mut e) = self.editors.remove(&key) { e.close(); } @@ -376,7 +376,7 @@ 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::new(session.sample_rate.as_f64() as f32, grx)); let shared = Arc::new(Shared { position: AtomicI64::new(0), playing: AtomicBool::new(false), @@ -525,22 +525,9 @@ impl Player { let _ = self.tx.send(Cmd::Session(s, instances)); } - /// Reads the state of every live third-party plugin (for saving the session). Waits briefly - /// for the audio thread to answer between two blocks; empty when it does not answer. + /// Reads the state of every live third-party plugin, for saving the session. pub fn capture_states(&self) -> PluginStates { - self.side().collect_garbage(); - #[cfg(not(target_arch = "wasm32"))] - { - let (tx, rx) = channel(); - if self.tx.send(Cmd::CaptureStates(tx)).is_err() { - return Vec::new(); - } - rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap_or_default() - } - #[cfg(target_arch = "wasm32")] - { - Vec::new() - } + self.side().capture_states() } /// Per-UI-frame housekeeping: drops retired instances and services plugin editors. Returns @@ -656,6 +643,12 @@ fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) { } impl Side { + fn capture_states(&mut self) -> PluginStates { + let mut out: PluginStates = self.states.iter_mut().filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect(); + out.sort_by_key(|(t, slot, _)| (*t, *slot)); + out + } + /// Creates the third-party instances `s` needs that the audio thread does not have yet. fn prepare(&mut self, s: &Session) -> Vec { self.collect_garbage(); @@ -686,6 +679,9 @@ impl Side { if let Some(e) = plugin.editor() { self.editors.insert(key, e); } + if let Some(r) = plugin.state_reader() { + self.states.insert(key, r); + } out.push(PreparedInstance { track: spec.track, slot: spec.slot, id: spec.id, ch: spec.ch, plugin }); } } @@ -881,12 +877,8 @@ mod tests { let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx); let plugin: Box = Box::new(Stateful(soundcraft_dsp::create("gain").unwrap())); tx.send(Cmd::Session(Arc::clone(&s), vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin }])).unwrap(); - let (rtx, rrx) = channel(); - tx.send(Cmd::CaptureStates(rtx)).unwrap(); - // Stopped: the callback only drains commands (adopting the instance) and answers. let mut out = vec![0.0f32; 256]; st.fill(&mut out, 2); - assert_eq!(rrx.try_recv().unwrap(), vec![(t, 1, b"blob".to_vec())]); assert!(grx.try_recv().is_err(), "nothing retired yet"); // A session without the insert retires the instance to the return channel. tx.send(Cmd::Session(Arc::new(soundcraft_model::Session::default()), Vec::new())).unwrap(); @@ -912,11 +904,32 @@ mod tests { assert_eq!(grx.try_recv().unwrap().len(), 1); } + struct Reads(Option<&'static [u8]>); + + impl PluginStateReader for Reads { + fn save_state(&mut self) -> Option> { + self.0.map(<[u8]>::to_vec) + } + } + + #[test] + fn plugin_states_are_read_without_the_audio_thread() { + let mut side = Side::new(48_000.0, channel().1); + let (a, b) = (TrackId(1), TrackId(2)); + side.states.insert((b, 0), Box::new(Reads(Some(b"b0")))); + side.states.insert((a, soundcraft_mix::INSTRUMENT_SLOT), Box::new(Reads(Some(b"synth")))); + side.states.insert((a, 3), Box::new(Reads(Some(b"a3")))); + side.states.insert((a, 4), Box::new(Reads(None))); + let want = vec![(a, 3, b"a3".to_vec()), (a, soundcraft_mix::INSTRUMENT_SLOT, b"synth".to_vec()), (b, 0, b"b0".to_vec())]; + assert_eq!(side.capture_states(), want); + side.forget((a, 3)); + assert_eq!(side.capture_states(), want[1..]); + } + #[test] 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::new(48_000.0, channel().1); 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")); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..096f143 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -485,8 +485,8 @@ impl SoundApp { 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. + /// Reads every live third-party plugin's state and stores it on its insert + /// (`mix.insert_state`), so saves and bounces include it. pub fn capture_plugin_states(&mut self) { let Some(p) = &self.player else { return }; for (t, slot, data) in p.capture_states() { diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 341ad53..d73d8c9 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -571,8 +571,10 @@ impl Bundle { // SAFETY: valid controller; the handler (host context) outlives it. unsafe { c.setComponentHandler(h.as_ptr()) }; } - inst.editor = Arc::new(EditorLink { inner: Mutex::new(EditorInner { controller: Some(c.clone()), ..EditorInner::default() }) }); } + inst.editor = Arc::new(EditorLink { + inner: Mutex::new(EditorInner { component: Some(inst.component.clone()), controller: inst.controller.clone(), ..EditorInner::default() }), + }); Ok(inst) } } @@ -1123,6 +1125,11 @@ impl Instance { self.editor.has_controller().then(|| Arc::clone(&self.editor)) } + /// The same link, for state handles. + pub fn state_link(&self) -> Arc { + Arc::clone(&self.editor) + } + /// Writes one state into a fresh memory stream. fn read_state(f: impl FnOnce(*mut sb::IBStream) -> sb::tresult) -> Option> { let stream = MemoryStream::new(Vec::new()); @@ -1130,26 +1137,7 @@ impl Instance { ok(f(s.as_ptr())).then(|| stream.take()) } - /// The component state plus the controller state, in SoundCraft's container (see - /// `STATE_MAGIC`). `None` when the component refuses `getState`. - pub fn save_state(&self) -> Option> { - // SAFETY: valid component; the stream lives in `read_state` for the whole call. - let comp = Instance::read_state(|s| unsafe { self.component.getState(s) })?; - let ctrl = match &self.controller { - // SAFETY: valid controller; as above. - Some(c) => Instance::read_state(|s| unsafe { c.getState(s) }).unwrap_or_default(), - None => Vec::new(), - }; - let mut out = Vec::with_capacity(12 + comp.len() + ctrl.len()); - out.extend_from_slice(STATE_MAGIC); - out.extend_from_slice(&u32::try_from(comp.len()).ok()?.to_le_bytes()); - out.extend_from_slice(&comp); - out.extend_from_slice(&u32::try_from(ctrl.len()).ok()?.to_le_bytes()); - out.extend_from_slice(&ctrl); - Some(out) - } - - /// Restores a [`Instance::save_state`] blob (or a bare component state): component + /// Restores a [`EditorLink::save_state`] blob (or a bare component state): component /// `setState`, then the controller's `setComponentState` and `setState`. `false` when the /// blob is malformed or the component refuses it. pub fn load_state(&self, data: &[u8]) -> bool { @@ -1545,17 +1533,20 @@ fn rect_size(r: &sb::ViewRect) -> (f64, f64) { #[derive(Default)] struct EditorInner { /// `None` once the instance is gone. + component: Option>, + /// `None` once the instance is gone, or without an edit controller. controller: Option>, view: Option>, frame: Option>, window: Option, } -/// The link between an instance and its editor handles. The instance may process on the audio -/// thread while the editor (the edit controller's `IPlugView`) is driven from the main thread, -/// which is VST3's own threading model (controller and views on the UI thread). All view calls -/// happen under this lock, and the instance closes the view and drops the controller reference -/// under the same lock before it terminates, so a handle never calls into a dead plugin. +/// The link between an instance and its editor and state handles. The instance may process on +/// the audio thread while the editor (the edit controller's `IPlugView`) is driven and the state +/// read from the main thread, which is VST3's own threading model (controller, views and +/// `getState` on the UI thread). All such calls happen under this lock, and the instance closes +/// the view and drops the component and controller references under the same lock before it +/// terminates, so a handle never calls into a dead plugin. #[derive(Default)] pub(crate) struct EditorLink { inner: Mutex, @@ -1657,6 +1648,28 @@ impl EditorLink { } } + /// The component state plus the controller state, in SoundCraft's container (see + /// `STATE_MAGIC`). `None` when the component refuses `getState` or the instance is gone. + pub fn save_state(&self) -> Option> { + let g = self.lock(); + let component = g.component.as_ref()?; + // SAFETY: valid component (the instance clears it under this lock before it terminates); + // the stream lives in `read_state` for the whole call. + let comp = Instance::read_state(|s| unsafe { component.getState(s) })?; + let ctrl = match &g.controller { + // SAFETY: valid controller; as above. + Some(c) => Instance::read_state(|s| unsafe { c.getState(s) }).unwrap_or_default(), + None => Vec::new(), + }; + let mut out = Vec::with_capacity(12 + comp.len() + ctrl.len()); + out.extend_from_slice(STATE_MAGIC); + out.extend_from_slice(&u32::try_from(comp.len()).ok()?.to_le_bytes()); + out.extend_from_slice(&comp); + out.extend_from_slice(&u32::try_from(ctrl.len()).ok()?.to_le_bytes()); + out.extend_from_slice(&ctrl); + Some(out) + } + /// Closes the editor. Main thread only (no-op elsewhere). pub fn close(&self) { if window::on_main_thread() { @@ -1739,6 +1752,7 @@ impl EditorLink { } } } + g.component = None; g.controller = None; leaked } diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 254671f..05a3e0e 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -318,10 +318,6 @@ impl Plugin for Vst3Plugin { } } - fn save_state(&mut self) -> Option> { - self.inst.save_state() - } - fn load_state(&mut self, data: &[u8]) -> bool { let ok = self.inst.load_state(data); if ok { @@ -344,6 +340,11 @@ impl Plugin for Vst3Plugin { let link = self.inst.editor()?; Some(Box::new(Vst3Editor { link, host: self.inst.host_ref(), info: self.info, ids: self.ids.clone(), maps: self.maps.clone() })) } + + fn state_reader(&mut self) -> Option> { + let link = self.inst.state_link(); + Some(Box::new(move || link.save_state())) + } } /// A VST3 plugin's editor (`IPlugView` in a host window), driven from the main thread. diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7..f98b37f 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -208,6 +208,30 @@ fn state_saves_component_and_controller_and_restores_them() { assert_eq!(q.param("7"), Some(1.0)); } +#[test] +fn state_reader_reads_while_another_thread_processes() { + 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)); + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + let mut reader = p.state_reader().expect("every VST3 component has a state"); + let blob = p.save_state().unwrap(); + let audio = std::thread::spawn(move || { + for _ in 0..2_000 { + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + } + p + }); + while !audio.is_finished() { + assert_eq!(reader.save_state().as_ref(), Some(&blob)); + } + let p = audio.join().unwrap(); + assert_eq!(reader.save_state(), Some(blob)); + drop(p); + assert_eq!(reader.save_state(), None); +} + #[test] fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); From 4c21ff8fe477b76e22b9bfc3098a1227a0dce184 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:47:59 +0100 Subject: [PATCH 051/154] Keep a plugin's saved settings when it refuses to load them When a third-party plugin refused its stored state (a newer plugin version that can't read it, a damaged blob), it ran with its defaults, and the next save or two-minute autosave read its state back into the session: the saved settings were gone, even after updating the plugin. create_instance now says whether the plugin refused the state (NewInstance::refused_state). Playback leaves such an instance out of the states it reads for saving, so the session keeps the stored state, until the plugin's own editor is opened: from then on its state is the user's work. Parameter values are saved apart from the state, so edits made with SoundCraft's own controls survive either way. --- crates/engine/tests/clap_e2e.rs | 13 +++- crates/mix/src/lib.rs | 22 ++++--- crates/playback/src/lib.rs | 110 +++++++++++++++----------------- 3 files changed, 79 insertions(+), 66 deletions(-) diff --git a/crates/engine/tests/clap_e2e.rs b/crates/engine/tests/clap_e2e.rs index 404252c..cd554a9 100644 --- a/crates/engine/tests/clap_e2e.rs +++ b/crates/engine/tests/clap_e2e.rs @@ -69,6 +69,17 @@ fn clap_insert_renders_through_the_mix() { assert!(e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "Gain", "value": 1.0})).is_err()); } +#[test] +fn a_refused_state_is_reported() { + install_fixture(); + let gain = "clap:org.soundcraft.test.gain"; + let make = |state: Option<&[u8]>| soundcraft_mix::create_instance(gain, state, 48_000.0, 512, 2).unwrap(); + let saved = make(None).plugin.save_state().unwrap(); + assert!(!make(None).refused_state); + assert!(!make(Some(&saved)).refused_state); + assert!(make(Some(&[0xff; 16])).refused_state, "NaN gain"); +} + #[test] fn clap_state_is_captured_stored_and_restored_before_the_first_block() { install_fixture(); @@ -88,7 +99,7 @@ fn clap_state_is_captured_stored_and_restored_before_the_first_block() { eng.set_external_instances(true); let specs = soundcraft_mix::instance_specs(e.session()); assert_eq!(specs.len(), 1); - let mut p = soundcraft_mix::create_instance(&specs[0].id, None, 48_000.0, 512, specs[0].ch).unwrap(); + let mut p = soundcraft_mix::create_instance(&specs[0].id, None, 48_000.0, 512, specs[0].ch).unwrap().plugin; p.set_param("7", 1.5); let mut io = vec![vec![0.0f32; 64]]; p.process(&mut io, 64); diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 87a3564..a145422 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -127,17 +127,23 @@ pub fn instance_specs(s: &Session) -> Vec { out } +/// A plugin made by [`create_instance`]. +pub struct NewInstance { + pub plugin: Box, + /// The plugin refused the stored state and starts from its defaults. + pub refused_state: bool, +} + /// Creates a plugin (built-in or hosted), restores `state` (if any) and prepares it. Expensive /// for third-party plugins: in realtime use, call it on the control thread. -pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option> { - let mut p = create_plugin(id)?; - if let Some(st) = state - && !p.load_state(st) - { +pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option { + let mut plugin = create_plugin(id)?; + let refused_state = state.is_some_and(|st| !plugin.load_state(st)); + if refused_state { log::warn!("{id}: the plugin did not accept its stored state"); } - p.prepare(sample_rate, max_block, ch); - Some(p) + plugin.prepare(sample_rate, max_block, ch); + Some(NewInstance { plugin, refused_state }) } /// Peak meter values for one strip (linear, per channel, max over the last block). @@ -357,7 +363,7 @@ impl SlotCtx<'_> { } } else { let state = if third { ins.state_bytes() } else { None }; - create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch) + create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin) }?; Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new() }) } diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index c079ece..6a7df8a 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -27,7 +27,7 @@ use soundcraft_dsp::{Plugin, PluginEditor, PluginStateReader}; use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels}; use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::{Arc, Mutex, PoisonError}; @@ -319,13 +319,16 @@ struct Side { /// Editor handles of the live instances. editors: HashMap<(TrackId, usize), Box>, /// State readers of the live instances. - states: HashMap<(TrackId, usize), Box>, + states: BTreeMap<(TrackId, usize), Box>, + /// Instances that refused their stored state: saves keep that state until the plugin's editor + /// is opened. + refused: HashSet<(TrackId, usize)>, garbage: Receiver>>, } impl Side { fn new(rate: f32, garbage: Receiver>>) -> Side { - Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: HashMap::new(), garbage } + Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage } } /// Drops retired instances (closing their editors first, inside the plugin's drop). @@ -338,6 +341,7 @@ impl Side { fn forget(&mut self, key: (TrackId, usize)) { self.shadow.remove(&key); self.states.remove(&key); + self.refused.remove(&key); if let Some(mut e) = self.editors.remove(&key) { e.close(); } @@ -551,10 +555,7 @@ impl Player { /// Opens the plugin's own editor window. Main thread only. pub fn open_editor(&self, track: TrackId, slot: usize) -> Result<(), String> { - match self.side().editors.get_mut(&(track, slot)) { - Some(e) => e.open(), - None => Err("this plugin has no editor (or it is not loaded)".into()), - } + self.side().open_editor((track, slot)) } pub fn close_editor(&self, track: TrackId, slot: usize) { @@ -644,9 +645,14 @@ fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) { impl Side { fn capture_states(&mut self) -> PluginStates { - let mut out: PluginStates = self.states.iter_mut().filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect(); - out.sort_by_key(|(t, slot, _)| (*t, *slot)); - out + self.states.iter_mut().filter(|(k, _)| !self.refused.contains(*k)).filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect() + } + + fn open_editor(&mut self, key: (TrackId, usize)) -> Result<(), String> { + let e = self.editors.get_mut(&key).ok_or_else(|| "this plugin has no editor (or it is not loaded)".to_string())?; + e.open()?; + self.refused.remove(&key); + Ok(()) } /// Creates the third-party instances `s` needs that the audio thread does not have yet. @@ -675,13 +681,16 @@ impl Side { self.forget(key); let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch); self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some())); - if let Some(mut plugin) = made { + if let Some(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made { if let Some(e) = plugin.editor() { self.editors.insert(key, e); } if let Some(r) = plugin.state_reader() { self.states.insert(key, r); } + if refused_state { + self.refused.insert(key); + } out.push(PreparedInstance { track: spec.track, slot: spec.slot, id: spec.id, ch: spec.ch, plugin }); } } @@ -833,32 +842,6 @@ mod tests { assert_eq!(shared.meters.lock().unwrap().main.peaks.len(), 6); } - struct Stateful(Box); - - impl Plugin for Stateful { - fn info(&self) -> &'static soundcraft_dsp::PluginInfo { - self.0.info() - } - fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { - self.0.prepare(sr, mb, ch); - } - fn reset(&mut self) { - self.0.reset(); - } - fn set_param(&mut self, id: &str, v: f32) -> bool { - self.0.set_param(id, v) - } - fn param(&self, id: &str) -> Option { - self.0.param(id) - } - fn process(&mut self, io: &mut [Vec], frames: usize) { - self.0.process(io, frames); - } - fn save_state(&mut self) -> Option> { - Some(b"blob".to_vec()) - } - } - #[test] fn instances_arrive_with_the_session_and_leave_through_the_return_channel() { use soundcraft_model::{ChannelFormat, Insert, TrackKind}; @@ -875,7 +858,7 @@ mod tests { let (tx, rx) = channel(); let (gtx, grx) = channel(); let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx); - let plugin: Box = Box::new(Stateful(soundcraft_dsp::create("gain").unwrap())); + let plugin = soundcraft_dsp::create("gain").unwrap(); tx.send(Cmd::Session(Arc::clone(&s), vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin }])).unwrap(); let mut out = vec![0.0f32; 256]; st.fill(&mut out, 2); @@ -887,13 +870,7 @@ mod tests { // A sample-rate change rebuilds the engine without dropping plugins on this thread. tx.send(Cmd::Session( Arc::clone(&s), - vec![PreparedInstance { - track: t, - slot: 1, - id: "clap:test.fake".into(), - ch: 2, - plugin: Box::new(Stateful(soundcraft_dsp::create("gain").unwrap())), - }], + vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin: soundcraft_dsp::create("gain").unwrap() }], )) .unwrap(); st.fill(&mut out, 2); @@ -904,28 +881,47 @@ mod tests { assert_eq!(grx.try_recv().unwrap().len(), 1); } - struct Reads(Option<&'static [u8]>); - - impl PluginStateReader for Reads { - fn save_state(&mut self) -> Option> { - self.0.map(<[u8]>::to_vec) - } - } - #[test] fn plugin_states_are_read_without_the_audio_thread() { let mut side = Side::new(48_000.0, channel().1); let (a, b) = (TrackId(1), TrackId(2)); - side.states.insert((b, 0), Box::new(Reads(Some(b"b0")))); - side.states.insert((a, soundcraft_mix::INSTRUMENT_SLOT), Box::new(Reads(Some(b"synth")))); - side.states.insert((a, 3), Box::new(Reads(Some(b"a3")))); - side.states.insert((a, 4), Box::new(Reads(None))); + side.states.insert((b, 0), Box::new(|| Some(b"b0".to_vec()))); + side.states.insert((a, soundcraft_mix::INSTRUMENT_SLOT), Box::new(|| Some(b"synth".to_vec()))); + side.states.insert((a, 3), Box::new(|| Some(b"a3".to_vec()))); + side.states.insert((a, 4), Box::new(|| None)); let want = vec![(a, 3, b"a3".to_vec()), (a, soundcraft_mix::INSTRUMENT_SLOT, b"synth".to_vec()), (b, 0, b"b0".to_vec())]; assert_eq!(side.capture_states(), want); side.forget((a, 3)); assert_eq!(side.capture_states(), want[1..]); } + struct Window; + + impl PluginEditor for Window { + fn open(&mut self) -> Result<(), String> { + Ok(()) + } + fn close(&mut self) {} + fn is_open(&self) -> bool { + false + } + fn idle(&mut self) -> Vec<(String, f32)> { + Vec::new() + } + } + + #[test] + fn a_refused_state_is_kept_until_the_editor_opens() { + let mut side = Side::new(48_000.0, channel().1); + let k = (TrackId(1), 0); + side.states.insert(k, Box::new(|| Some(b"defaults".to_vec()))); + side.editors.insert(k, Box::new(Window)); + side.refused.insert(k); + assert!(side.capture_states().is_empty()); + side.open_editor(k).unwrap(); + assert_eq!(side.capture_states(), vec![(TrackId(1), 0, b"defaults".to_vec())]); + } + #[test] fn side_creates_each_missing_instance_once() { use soundcraft_model::{ChannelFormat, Insert, TrackKind}; From 56ffce6decf2543a135b63a3e043c061d5a7e9e0 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:56:08 +0100 Subject: [PATCH 052/154] Take a plugin that puts out NaN out of the mix instead of silencing it One insert or instrument putting out NaN or infinity silenced the whole mix: the value spread through the busses and the main mix (NaN plus anything is NaN), and the output guard turned all of it into silence. It can also poison the state of filters, reverbs and compressors downstream, which then stay silent after the plugin has calmed down. After each insert (track or master) and each instrument processes, the engine checks its block. If the block holds NaN or infinity, the engine silences that block of the strip and skips the plugin until the next MixEngine::reset (playback starts or stops), which resets it and tries it again. Meanwhile the strip plays on without it, as if bypassed. The check costs about 60 ns per insert for a stereo 512-sample block. StripMeter::plugin_failures counts how often a strip lost a plugin and names the slot of the latest, so the UI names that plugin in the status line each time, also when it fails again after a restart. The AU host no longer zeroes non-finite output itself, so Audio Units are taken out and named like CLAP and VST3 plugins. --- crates/au-host/src/plugin.rs | 5 +- crates/mix/src/lib.rs | 137 +++++++++++++++++++++++++++++++++-- crates/playback/src/lib.rs | 2 +- crates/ui-egui/src/lib.rs | 29 ++++++++ 4 files changed, 163 insertions(+), 10 deletions(-) diff --git a/crates/au-host/src/plugin.rs b/crates/au-host/src/plugin.rs index eed7fc2..89f604e 100644 --- a/crates/au-host/src/plugin.rs +++ b/crates/au-host/src/plugin.rs @@ -170,13 +170,12 @@ impl Plugin for AuPlugin { if ch == 1 && au_ch >= 2 { if let (Some(l), Some(r)) = (self.inst.output(0, n), self.inst.output(1, n)) { for ((d, a), b) in dst.iter_mut().zip(l).zip(r) { - let v = 0.5 * (a + b); - *d = if v.is_finite() { v } else { 0.0 }; + *d = 0.5 * (a + b); } } } else if let Some(src) = self.inst.output(c.min(au_ch.saturating_sub(1)), n) { for (d, &s) in dst.iter_mut().zip(src) { - *d = if s.is_finite() { s } else { 0.0 }; + *d = s; } } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index a145422..281e519 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -155,6 +155,24 @@ pub struct StripMeter { pub peaks: Vec, /// Gain reduction of the first dynamics insert, dB. pub gain_reduction_db: f32, + pub plugin_failures: PluginFailures, +} + +/// How often a strip's plugins were taken out of the signal path for putting out NaN or infinity. +/// Each stays out until the engine is next reset (playback starts or stops). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct PluginFailures { + /// It only grows. + pub count: u32, + /// The slot of the latest: an insert index or [`INSTRUMENT_SLOT`]. + pub last: usize, +} + +impl PluginFailures { + fn record(&mut self, slot: usize) { + self.count = self.count.wrapping_add(1); + self.last = slot; + } } impl StripMeter { @@ -163,6 +181,7 @@ impl StripMeter { self.peak = o.peak; self.peaks.clone_from(&o.peaks); self.gain_reduction_db = o.gain_reduction_db; + self.plugin_failures = o.plugin_failures; } fn measure(&mut self, bufs: &[Vec], frames: usize, gr: f32) { @@ -191,6 +210,7 @@ struct PluginSlot { plugin: Box, /// Last values pushed to the plugin, in the insert's parameter-map order. applied: Vec, + failed: bool, } struct Strip { @@ -217,6 +237,7 @@ struct Strip { align_pre: DelayLine, out_extra: DelayLine, outbuf: Vec>, + failures: PluginFailures, } /// A multichannel delay line (samples), resized only when the delay changes. @@ -287,6 +308,7 @@ impl Strip { align_pre: DelayLine::default(), out_extra: DelayLine::default(), outbuf: Vec::new(), + failures: PluginFailures::default(), } } } @@ -365,7 +387,7 @@ impl SlotCtx<'_> { let state = if third { ins.state_bytes() } else { None }; create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin) }?; - Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new() }) + Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false }) } } @@ -455,10 +477,12 @@ impl MixEngine { for s in self.strips.values_mut() { for p in s.plugins.iter_mut().flatten() { p.plugin.reset(); + p.failed = false; } if let Some(i) = &mut s.instrument { i.plugin.all_notes_off(); i.plugin.reset(); + i.failed = false; } s.held.clear(); } @@ -668,9 +692,13 @@ impl MixEngine { if let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) && ins.active && !ins.bypass + && !slot.failed { apply_params(slot, ins, t, i, pos); slot.plugin.process(&mut strip.buf, frames); + if contain_failure(slot, &mut strip.buf, frames) { + strip.failures.record(i); + } } } let v0 = volume_db_at(t, pos); @@ -685,7 +713,9 @@ impl MixEngine { } } } - self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0); + let m = self.meters.entry(t.id).or_default(); + m.measure(&self.main, frames, 0.0); + m.plugin_failures = strip.failures; } } // Metronome: summed after the master fader, so it reaches the speakers without passing @@ -728,7 +758,9 @@ impl MixEngine { /// Send, meter and pan a processed strip into the busses / main mix. fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) { - self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr); + let m = self.meters.entry(t.id).or_default(); + m.measure(&strip.buf, frames, strip.gr); + m.plugin_failures = strip.failures; let src_fmt = strip_format(t, strip.buf.len()); let law = self.pan_law; for (i, snd) in t.mixer.sends.iter().enumerate() { @@ -872,7 +904,7 @@ fn process_strip( } } TrackKind::Instrument | TrackKind::Midi => { - if let Some(inst) = &mut strip.instrument { + if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) { // #152: push the instrument's stored parameter values into the live plugin // before processing, the same way inserts do below. Without this, the values // loaded from a session (or set via `mix.insert_state`) never reach the plugin @@ -895,6 +927,9 @@ fn process_strip( *x = 0.5 * (*x + *y); } } + if contain_failure(inst, &mut strip.buf, frames) { + strip.failures.record(INSTRUMENT_SLOT); + } } } _ => {} @@ -909,11 +944,15 @@ fn process_strip( let mut latency = 0usize; for (i, ins) in t.mixer.inserts.iter().enumerate() { let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue }; - if !ins.active || ins.bypass { + if !ins.active || ins.bypass || slot.failed { continue; } apply_params(slot, ins, t, i, pos); slot.plugin.process(&mut strip.buf, frames); + if contain_failure(slot, &mut strip.buf, frames) { + strip.failures.record(i); + continue; + } latency = latency.saturating_add(slot.plugin.latency()); if gr == 0.0 { gr = slot.plugin.gain_reduction_db(); @@ -966,6 +1005,20 @@ fn process_strip( } } +/// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the +/// next [`MixEngine::reset`]. True then. +fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec], frames: usize) -> bool { + // A branch-free fold vectorizes; `all` runs about 8 times slower here. + if buf.iter().all(|c| c.get(..frames).unwrap_or(c).iter().fold(true, |ok, x| ok & x.is_finite())) { + return false; + } + for c in buf.iter_mut() { + c.iter_mut().take(frames).for_each(|x| *x = 0.0); + } + slot.failed = true; + true +} + /// 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() { @@ -2005,6 +2058,11 @@ mod tests { fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); + let t = add_dc_track(&mut s, level, frames); + (s, t) + } + + fn add_dc_track(s: &mut Session, level: f32, frames: usize) -> TrackId { let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; frames]] }; s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(buf))); @@ -2012,7 +2070,7 @@ mod tests { if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { pl.clips.push(Clip::audio(id, "dc", SourceId(900), 0, 0, frames as i64)); } - (s, t) + t } #[test] @@ -2199,6 +2257,73 @@ mod tests { assert!(eng.drain_plugins().is_empty()); } + struct Nan(Box); + + impl Plugin for Nan { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.0.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.0.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.0.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.0.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.0.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + io.iter_mut().for_each(|c| c.iter_mut().take(frames).for_each(|x| *x = f32::NAN)); + } + } + + fn nan(track: TrackId, slot: usize, ch: usize) -> Vec { + vec![PreparedInstance { track, slot, id: "clap:test.nan".into(), ch, plugin: Box::new(Nan(soundcraft_dsp::create("gain").unwrap())) }] + } + + #[test] + fn an_insert_that_puts_out_nan_is_taken_out_until_the_next_reset() { + let (mut s, a) = session_with_dc(0.5, 48_000); + let b = add_dc_track(&mut s, 0.5, 48_000); + s.track_mut(a).unwrap().mixer.inserts[2] = Some(Insert::new("clap:test.nan")); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + let both = render_block(&mut eng, &s, 0); + eng.adopt(nan(a, 2, 1)); + let only_b = render_block(&mut eng, &s, 512); + assert!(only_b > 0.3 && (only_b - both / 2.0).abs() < 1e-6, "{only_b} vs {both}"); + assert_eq!(eng.meters[&a].plugin_failures, PluginFailures { count: 1, last: 2 }); + assert_eq!(eng.meters[&b].plugin_failures, PluginFailures::default()); + assert_eq!(render_block(&mut eng, &s, 1024), both); + eng.reset(); + assert_eq!(render_block(&mut eng, &s, 1536), only_b); + assert_eq!(eng.meters[&a].plugin_failures.count, 2); + } + + #[test] + fn nan_from_an_instrument_or_a_master_insert_is_contained() { + let mut s = synth_session(-6.0); + let synth = s.tracks[0].id; + s.track_mut(synth).unwrap().instrument = Some(Insert::new("clap:test.nan")); + let b = add_dc_track(&mut s, 0.5, 48_000); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(nan(synth, INSTRUMENT_SLOT, 2)); + let only_b = render_block(&mut eng, &s, 0); + assert!(only_b > 0.3, "{only_b}"); + assert_eq!(eng.meters[&synth].plugin_failures, PluginFailures { count: 1, last: INSTRUMENT_SLOT }); + assert_eq!(eng.meters[&b].plugin_failures, PluginFailures::default()); + let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None); + s.track_mut(m).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.nan")); + eng.adopt(nan(m, 0, 2)); + assert_eq!(render_block(&mut eng, &s, 512), 0.0); + assert_eq!(eng.meters[&m].plugin_failures, PluginFailures { count: 1, last: 0 }); + assert_eq!(render_block(&mut eng, &s, 1024), only_b); + } + #[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 6a7df8a..9201939 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -760,7 +760,7 @@ mod tests { let keep = TrackId(1); let stale = TrackId(2); let mut source = MeterSnapshot::default(); - source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 }); + source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0, ..StripMeter::default() }); source.main.peaks = vec![0.75, 0.5]; let mut out = MeterSnapshot::default(); out.tracks.insert(stale, StripMeter::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 096f143..ea09654 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -199,6 +199,8 @@ pub struct MeterDisplay { pub hold_age: f32, pub clip: bool, pub gr: f32, + /// [`soundcraft_mix::PluginFailures::count`] as last seen. + pub plugin_failures: u32, } /// An in-progress mouse gesture in the Edit window. @@ -755,6 +757,12 @@ impl SoundApp { for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default(); feed_meter(d, m.peak, m.gain_reduction_db, dt); + if m.plugin_failures.count != d.plugin_failures { + d.plugin_failures = m.plugin_failures.count; + if let Some(msg) = failed_plugin_status(self.engine.session(), *id, m.plugin_failures.last) { + self.ui.status = msg; + } + } } let main = snap.main.peak; feed_meter(&mut self.main_meter, main, 0.0, dt); @@ -956,6 +964,15 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { d.gr = if gr > d.gr { gr } else { d.gr * fall }; } +/// The status line naming the plugin in `slot` (an insert index or +/// [`soundcraft_mix::INSTRUMENT_SLOT`]), which the engine took out of the signal path. +fn failed_plugin_status(s: &soundcraft_model::Session, track: TrackId, slot: usize) -> Option { + let t = s.track(track)?; + let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { t.instrument.as_ref() } else { t.mixer.inserts.get(slot)?.as_ref() }?; + let name = crate::mix_window::plugin_info(&ins.plugin).map_or(ins.plugin.as_str(), |p| p.name); + Some(format!("{name} on {} put out invalid audio, so it is bypassed until playback stops or starts again.", t.name)) +} + #[cfg(test)] mod tests { use super::{Services, SoundApp, automation_step_due}; @@ -988,6 +1005,18 @@ mod tests { } } + #[test] + fn the_status_names_a_plugin_the_engine_took_out() { + use soundcraft_model::{ChannelFormat, Insert, TrackKind}; + let mut s = soundcraft_model::Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Vox")); + s.track_mut(t).unwrap().mixer.inserts[3] = Some(Insert::new("gain")); + let msg = super::failed_plugin_status(&s, t, 3).unwrap(); + assert!(msg.starts_with("Gain on Vox put out invalid audio"), "{msg}"); + assert_eq!(super::failed_plugin_status(&s, t, 0), None); + assert_eq!(super::failed_plugin_status(&s, t, soundcraft_mix::INSTRUMENT_SLOT), None); + } + #[test] fn new_session_cancels_play_queued_before_the_next_frame() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); 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 053/154] 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 054/154] 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 9f4335388e664a6eeaa91d5dd9bb5e5676aaf430 Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 13 Oct 2026 04:34:49 +1300 Subject: [PATCH 055/154] 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 056/154] 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 057/154] 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 058/154] 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 059/154] 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 060/154] 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 061/154] 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 062/154] 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 063/154] 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 064/154] 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 065/154] 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 066/154] 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 067/154] 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 5026b0655d69eea9ee790ed38d4d60de24bc1bad Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:38:17 +0100 Subject: [PATCH 068/154] Signal Generator fills the selection with a new clip Fixes #220: Render in the Signal Generator window did nothing on a time selection over an empty part of a track. AudioSuite only processed the clips under the selection, so with none it stopped with "select audio clips first", and that went to the status line only. - A generator (soundcraft_dsp::is_generator, today only the Signal Generator) ignores its input, so it no longer needs clips. With a time selection it renders a new clip over exactly the selection on each selected audio track, over whatever was there, as Pro Tools' Signal Generator does. Before, it replaced whole clips under the selection and left the empty parts empty. - Other tracks in the selection are skipped. With no audio track in it, or no time selection and no clips, it reports why. - Explicit `clips` still process those clips. Effects behave as before. - The AudioSuite window's hint says what Render will do for a generator. --- crates/dsp/src/lib.rs | 5 ++ crates/dsp/tests/plugins.rs | 4 +- crates/engine/src/cmd/audiosuite.rs | 76 ++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 7 ++- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 3d5bd33..62142b2 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -301,6 +301,11 @@ pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { plugins::REGISTRY.iter().copied().find(|i| i.id == id) } +/// Whether a plugin makes sound instead of processing its input (it ignores the input). +pub fn is_generator(id: &str) -> bool { + id == "signal_generator" +} + /// Silence floor used by the dB helpers. pub const MIN_DB: f32 = -144.0; diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/plugins.rs index 5d71f6f..8287f16 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use std::f32::consts::TAU; -use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, plugin_info, plugins}; +use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, is_generator, plugin_info, plugins}; const SR: f32 = 48_000.0; const BLOCK: usize = 512; @@ -45,7 +45,7 @@ fn prepared(id: &str) -> Box { } fn is_source(id: &str) -> bool { - plugin_info(id).unwrap().is_instrument || id == "signal_generator" + plugin_info(id).unwrap().is_instrument || is_generator(id) } #[test] diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index e2abfca..87f3bf5 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -4,7 +4,7 @@ use super::*; use crate::cmd; use serde_json::json; use soundcraft_audio_io::{AudioBuffer, FileFormat}; -use soundcraft_model::{Clip, ClipContent}; +use soundcraft_model::{Clip, ClipContent, TrackKind}; pub fn specs() -> Vec { vec![cmd!( @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "AudioSuite Process", [], None, - "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?}", + "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)", has_selection, process )] @@ -35,6 +35,12 @@ fn process(e: &mut Engine, p: &Value) -> Result { if !known { return Err(bad("audiosuite.process", format!("unknown process `{name}`"))); } + if soundcraft_dsp::is_generator(&name) && p.get("clips").is_none() && p.get("clip").is_none() { + let r = range_param(e, "audiosuite.process", p)?; + if !r.is_empty() { + return generate(e, p, &name, ¶ms, r); + } + } let ids = clip_ids_param(e, p); if ids.is_empty() { return Err(bad("audiosuite.process", "select audio clips first")); @@ -86,12 +92,7 @@ fn process(e: &mut Engine, p: &Value) -> Result { 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)); - for (k, v) in ¶ms { - pl.set_param(k, *v); - } - soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf); + apply(plugin, ¶ms, &mut audio, srf)?; audio } }; @@ -122,6 +123,41 @@ fn process(e: &mut Engine, p: &Value) -> Result { Ok(json!({"processed": done, "process": name})) } +/// Fill `r` on each selected audio track with a new clip from the generator `name`, over +/// whatever was there, like Pro Tools' Signal Generator. +fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result { + let sr = e.session().sample_rate; + let mut clips = Vec::new(); + for track in tracks_param(e, "audiosuite.process", p)? { + let Some(t) = e.session().track(track).filter(|t| t.kind == TrackKind::Audio) else { continue }; + let channels = t.channels().max(1); + let frames = usize::try_from(r.len()).unwrap_or(0).min(soundcraft_mix::MAX_RENDER_SAMPLES / channels); + let clip_name = format!("{}-{}", t.name, short(name)); + let mut audio = vec![vec![0.0; frames]; channels]; + apply(name, params, &mut audio, sr.as_f64() as f32)?; + let s = e.session_mut(); + let src = crate::io::add_source(s, &clip_name, AudioBuffer { sample_rate: sr.hz(), channels: audio }, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(s, track, Clip::audio(id, &clip_name, src, 0, r.start, i64::try_from(frames).unwrap_or(0))); + clips.push(id.0); + } + if clips.is_empty() { + return Err(bad("audiosuite.process", "select an audio track to generate on")); + } + Ok(json!({"processed": clips.len(), "process": name, "clips": clips})) +} + +/// Run plugin `id` with `params` over `audio`, in place. +fn apply(id: &str, params: &[(String, f32)], audio: &mut [Vec], srf: f32) -> Result<()> { + let Some(mut pl) = soundcraft_dsp::create(id) else { return Err(bad("audiosuite.process", format!("cannot create `{id}`"))) }; + pl.prepare(srf, 1024, audio.len().max(1)); + for (k, v) in params { + pl.set_param(k, *v); + } + soundcraft_dsp::offline::apply_plugin(audio, pl.as_mut(), srf); + Ok(()) +} + fn short(n: &str) -> String { let s: String = n.split('_').map(|w| w.chars().take(4).collect::()).collect::>().join(""); s.chars().take(8).collect() @@ -165,4 +201,28 @@ mod tests { let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}"); } + + #[test] + fn signal_generator_fills_the_selection_with_a_clip() { + let (mut e, _) = gained_clip(); + let tone = e.session().tracks[0].id; + let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Empty")); + e.execute("edit.select", &json!({"tracks": [t.0], "start": 48_000, "end": 96_000})).unwrap(); + assert!(matches!(e.execute("audiosuite.process", &json!({"process": "normalize"})), Err(crate::EngineError::BadParams(..)))); + + let generator = json!({"process": "signal_generator", "params": {"freq": 440.0, "level": -6.0}}); + assert_eq!(e.execute("audiosuite.process", &generator).unwrap()["processed"], 1); + let c = e.session().tracks[1].clips()[0].clone(); + assert_eq!((c.start, c.length), (48_000, 48_000)); + let buf = &e.session().pool.get(c.source().unwrap()).unwrap().buffer; + assert_eq!(buf.channels.len(), 2); + let peak = buf.channels[1].iter().fold(0.0f32, |m, x| m.max(x.abs())); + assert!((peak - 0.5).abs() < 0.01, "-6 dB sine peaks near 0.5, got {peak}"); + + // Over existing audio it replaces just the selection. + e.execute("edit.select", &json!({"tracks": [tone.0], "start": 32, "end": 96})).unwrap(); + e.execute("audiosuite.process", &generator).unwrap(); + let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect(); + assert_eq!(ranges, vec![(0, 32), (32, 64)]); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..9ca4b04 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -657,7 +657,12 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { } } ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone())); - ui.label(egui::RichText::new("Processes the selected clips and replaces them with rendered audio.").small()); + let hint = if soundcraft_dsp::is_generator(&process) { + "Fills the selection on the selected tracks with a new clip." + } else { + "Processes the selected clips and replaces them with rendered audio." + }; + ui.label(egui::RichText::new(hint).small()); if ui.button(egui::RichText::new("Render").strong()).clicked() { let p: serde_json::Map = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect(); let _ = app.run("audiosuite.process", json!({"process": process, "params": p})); 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 069/154] 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 070/154] =?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 071/154] 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 072/154] 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 073/154] 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 074/154] 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 075/154] 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 076/154] 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 077/154] 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 078/154] 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 079/154] 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 080/154] 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 081/154] 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 1617733a98f5b7f58c19de8a6907d704d8ea7cef Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:38:52 +0100 Subject: [PATCH 082/154] Repair a session's sample rate and tempo map when it loads A session file with a sample rate of 0 or a tempo of 4e9 BPM opened fine, but Strip Silence on the first and Extract Chords on the second never finished (one also climbed past 1 GiB). Session::sanitize, which repairs everything else read from a file, skipped both fields. On load, a sample rate outside what sessions accept becomes 48 kHz, and the tempo map keeps only the tempos and meters that set_tempo and set_meter accept, in tick order, with one of each at tick 0. --- crates/model/src/session.rs | 13 ++++++++++++ crates/time/src/tempo.rs | 41 ++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 5956796..9c7e882 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -570,6 +570,10 @@ impl Session { if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 { self.edit.zoom.samples_per_px = 1024.0; } + if SampleRate::new(self.sample_rate.hz()).is_err() { + self.sample_rate = SampleRate::default(); + } + self.tempo.sanitize(); } } @@ -641,6 +645,15 @@ mod tests { assert_eq!(c.source_offset(), 580); } + #[test] + fn a_bad_sample_rate_or_tempo_is_repaired_on_load() { + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["sample_rate"] = serde_json::json!(0); + v["tempo"]["tempos"] = serde_json::json!([{"tick": 0, "bpm": 4e9}]); + let s = Session::from_json(&v.to_string()).unwrap(); + assert_eq!((s.sample_rate, s.tempo), (SampleRate::HZ_48000, TempoMap::default())); + } + #[test] fn rejects_garbage_and_future_versions() { assert!(Session::from_json("not json").is_err()); diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 5b87a3a..72936d8 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -55,6 +55,10 @@ fn valid_bpm(bpm: f64) -> Result { if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) } } +fn valid_meter(numerator: u32, denominator: u32) -> bool { + (1..=64).contains(&numerator) && [1, 2, 4, 8, 16, 32, 64].contains(&denominator) +} + impl TempoMap { pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result { let mut m = TempoMap::default(); @@ -70,6 +74,25 @@ impl TempoMap { &self.meters } + /// Repair a map read from a file: only valid tempos and meters, in tick order, with one of each + /// at tick 0. + pub fn sanitize(&mut self) { + self.tempos.retain(|e| e.tick >= 0 && valid_bpm(e.bpm).is_ok()); + self.tempos.sort_by_key(|e| e.tick); + self.tempos.dedup_by_key(|e| e.tick); + if self.tempos.first().is_none_or(|e| e.tick > 0) { + let bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + self.tempos.insert(0, TempoEvent { tick: 0, bpm }); + } + self.meters.retain(|m| m.tick >= 0 && valid_meter(m.numerator, m.denominator)); + self.meters.sort_by_key(|m| m.tick); + self.meters.dedup_by_key(|m| m.tick); + if self.meters.first().is_none_or(|m| m.tick > 0) { + let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator)); + self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator }); + } + } + /// Insert or replace the tempo at `tick`. pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> { let bpm = valid_bpm(bpm)?; @@ -97,7 +120,7 @@ impl TempoMap { /// Insert or replace the meter at `tick` (snapped to the bar line at or before it). 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) { + if !valid_meter(numerator, 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) }; @@ -317,6 +340,22 @@ mod tests { assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 }); } + #[test] + fn sanitize_keeps_what_the_setters_accept() { + let tempo = |tick, bpm| TempoEvent { tick, bpm }; + let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator }; + let mut m = TempoMap { + tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)], + meters: vec![meter(0, 0, 4), meter(3840, 3, 4)], + }; + m.sanitize(); + assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]); + assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]); + let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() }; + empty.sanitize(); + assert_eq!(empty, TempoMap::default()); + } + #[test] fn rejects_bad_values() { let mut m = TempoMap::default(); From 7f4ab5c76bccbe6788bbe3a0184f9f1669db09a0 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:42:09 +0100 Subject: [PATCH 083/154] Refuse devices and pipes where a file is expected A session whose audio file path pointed at /dev/zero filled 18 GB of memory while opening, and one pointing at a named pipe waited forever. The same went for a path given to the import commands, the sampler, Import Session Data, clip group and track preset files, and the session file itself, all of which read whatever the path named to its end. Those reads now go through io::read_file / read_text, which refuse anything but a regular file. A session's missing audio is reported as missing, as before; an import fails with "not a regular file". --- crates/engine/src/clip_group_file.rs | 4 +- crates/engine/src/cmd/file.rs | 6 +-- 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/io.rs | 58 +++++++++++++++++++++++++--- 6 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index c889c99..8f657fd 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -82,7 +82,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 +98,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).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?; 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..4fa6353 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) } ), @@ -231,7 +231,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/io.rs b/crates/engine/src/io.rs index 108330e..7b770a0 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -598,7 +598,7 @@ 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"); - if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) { + if !overwrite && read_file(&p).is_ok_and(|old| old != text.as_bytes()) { back_up(&p); } replace_file(&tmp, &p, text.as_bytes())?; @@ -706,15 +706,32 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> { Err(EngineError::Io(format!("{}: no free file name for {stem}", dir.display()))) } +/// Read a whole file. Only a regular file qualifies: a device or a pipe named by a session or a +/// command would never end or never answer. Errors name the file. +pub fn read_file(path: impl AsRef) -> Result> { + let path = path.as_ref(); + let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display())); + if !std::fs::metadata(path).map_err(err)?.is_file() { + return Err(EngineError::Io(format!("{}: not a regular file", path.display()))); + } + std::fs::read(path).map_err(err) +} + +/// [`read_file`] as UTF-8 text. +pub fn read_text(path: impl AsRef) -> Result { + let path = path.as_ref(); + String::from_utf8(read_file(path)?).map_err(|err| EngineError::Io(format!("{}: {err}", path.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}")))?; + 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) }; - match std::fs::read(&p) { + match read_file(&p) { 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())); @@ -932,8 +949,9 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu let before = e.session_arc(); let mut out = Vec::new(); 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 read = + read_file(&path).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))); + let Ok((_, buf)) = read.inspect_err(|err| e.message(err.to_string())) else { continue }; let s = e.session_mut(); if s.track(tid).is_none() { continue; @@ -1214,6 +1232,36 @@ mod save_tests { } } +#[cfg(all(test, unix))] +mod pipe_tests { + use super::*; + + #[test] + fn a_pipe_named_as_media_is_refused_not_waited_on() { + let dir = std::env::temp_dir().join(format!("soundcraft-pipe-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let fifo = dir.join("pipe.wav"); + assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success()); + let mut s = Session::default(); + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 10]] }; + add_source(&mut s, "pipe", audio, Some(&fifo.to_string_lossy()), FileFormat::Wav); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + std::fs::write(&path, s.to_json().unwrap()).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let pipe = fifo.to_string_lossy().into_owned(); + std::thread::spawn(move || { + let mut e = Engine::default(); + let imported = e.execute("file.import_audio", &json!({"path": pipe})).is_ok(); + let _ = tx.send((imported, open_session(&mut e, &path).map_err(|err| err.to_string()))); + }); + let (imported, opened) = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert!(!imported); + assert_eq!(opened.unwrap(), [fifo.display().to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod stem_tests { #[test] From d81d5756d421b104443ae17f4738657ef31aa590 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:44:35 +0100 Subject: [PATCH 084/154] Check an import's size at the session's sample rate An 80 KB WAV with a sample rate of 1 Hz froze the app on import: the size limit was checked before conversion to the session rate, which then made it 48,000 times longer, past 2 GiB of memory and more than a minute of work (4.5 GiB and 75 s in an earlier measurement). Opening a session, Import Session Data, the sampler and clip group files converted without any limit. io::at_rate checks the size the audio will have at the session's rate before converting it. Imports keep their limit (2^30 samples, about 4 GiB) and their message; the sampler keeps its own limit; a session's source that is too large is reported like other unreadable media. --- crates/engine/src/clip_group_file.rs | 3 ++ crates/engine/src/cmd/sampler.rs | 5 ++-- crates/engine/src/cmd/setup_more.rs | 8 ++---- crates/engine/src/io.rs | 41 ++++++++++++++++++++++------ 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 8f657fd..6ffd7fb 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -93,6 +93,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); // Decode audio first so a bad file changes nothing. let mut decoded = BTreeMap::new(); + let rate = e.session().sample_rate.hz(); for (old, rel) in &file.audio { let full = dir.join(rel); if !full.starts_with(&dir) || rel.contains("..") { @@ -100,6 +101,8 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - } let bytes = crate::io::read_file(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?; let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; + let buf = + crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?; decoded.insert(*old, (rel.clone(), buf)); } let src_rate = f64::from(file.sample_rate.max(1)); diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs index 7bac7e8..e467234 100644 --- a/crates/engine/src/cmd/sampler.rs +++ b/crates/engine/src/cmd/sampler.rs @@ -62,9 +62,8 @@ fn load(e: &mut Engine, p: &Value) -> Result { 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 buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES) + .ok_or_else(|| 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); diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 8fd8809..e896394 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -405,12 +405,8 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result { 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 = 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); - buf.sample_rate = sr; - } - Some(buf) + let (_, buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?; + crate::io::at_rate(buf, sr, crate::io::MAX_IMPORT_SAMPLES) } fn import_session_data(e: &mut Engine, p: &Value) -> Result { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7b770a0..f20dd07 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -9,7 +9,21 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. -const MAX_IMPORT_SAMPLES: usize = 1 << 30; +pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30; + +/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a +/// tiny sample rate grows by orders of magnitude. +pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option { + let from = buf.sample_rate.max(1); + if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 { + return None; + } + if from != rate { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate); + buf.sample_rate = rate; + } + Some(buf) +} /// A relative `Audio Files/` path no other source in `s` already uses. fn unique_audio_rel(s: &Session, stem: &str) -> String { @@ -58,9 +72,8 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; - if buf.frames().saturating_mul(buf.num_channels().max(1)) > MAX_IMPORT_SAMPLES { - return Err(EngineError::Io(format!("{name}: file is too large to import"))); - } + let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES) + .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?; if buf.frames() == 0 { return Err(EngineError::Io(format!("{name}: no audio"))); } @@ -752,11 +765,8 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { /// 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(); - } + let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?; + let buf = at_rate(buf, s.sample_rate.hz(), MAX_IMPORT_SAMPLES).ok_or("too large to load")?; s.pool.insert(id, Arc::new(SourceAudio::new(buf))); Ok(()) } @@ -1232,6 +1242,19 @@ mod save_tests { } } +#[cfg(test)] +mod rate_tests { + use super::*; + + #[test] + fn a_tiny_sample_rate_is_refused_before_it_is_converted() { + let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] }; + assert!(at_rate(buf(1), 48_000, 1_000_000).is_none()); + let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap(); + assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600)); + } +} + #[cfg(all(test, unix))] mod pipe_tests { use super::*; From a8a36540d68ce64c8cb43d88203f7462f01093e3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:29:37 +0100 Subject: [PATCH 085/154] Name thousands of same-named tracks and files in linear time Finding a free name tried "Name 1", "Name 2" and so on, rescanning every track (or every source path) for each try, so each new name cost time proportional to the square of the session and a bulk import the cube. Importing a 119 KB MIDI file with 4,095 tracks all named "Piano" took 28 seconds; a clip group file with 8,000 empty lanes or 8,000 audio files of one name ran for over a minute, and 100,000 lanes never finished. Import Session Data from a session with 2,000 same-named tracks took 7 seconds. unique_track_name and the new audio file name now collect the numbers already in use once and take the first free one, which gives the same names. A clip group file also counts each empty lane and each audio file against its limit of 10,000. The same imports now take 1 second (MIDI), 2 seconds (8,000 lanes) and a third of a second (Import Session Data); 100,000 lanes are refused. --- crates/engine/src/clip_group_file.rs | 4 +-- crates/engine/src/io.rs | 41 ++++++++++++++++++++++------ crates/model/src/session.rs | 28 +++++++++++++------ 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 6ffd7fb..66735f9 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; pub const EXTENSION: &str = "scgrp"; const FORMAT: &str = "soundcraft.clipgroup"; -/// Refuse files that would place absurd numbers of clips. +/// Refuse files that would place absurd numbers of clips, tracks or audio files. const MAX_CLIPS: usize = 10_000; #[derive(Debug, Serialize, Deserialize)] @@ -87,7 +87,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - if file.format != FORMAT { return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into())); } - if file.lanes.iter().map(Vec::len).sum::() > MAX_CLIPS { + if file.lanes.iter().map(|l| l.len().max(1)).sum::() > MAX_CLIPS || file.audio.len() > MAX_CLIPS { return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into())); } let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index f20dd07..f1bf6a4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -28,16 +28,22 @@ pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option String { let stem = sanitize_name(stem); - let mut path = format!("Audio Files/{stem}.wav"); - let mut n = 2u32; - while s.sources.iter().any(|src| src.path == path) { - path = format!("Audio Files/{stem}-{n}.wav"); - n = n.saturating_add(1); - if n > 10_000 { - break; - } + let (plain, prefix) = (format!("Audio Files/{stem}.wav"), format!("Audio Files/{stem}-")); + let used: std::collections::HashSet = s + .sources + .iter() + .filter_map(|src| { + if src.path == plain { + return Some(1); + } + let n = src.path.strip_prefix(&prefix)?.strip_suffix(".wav")?; + n.parse().ok().filter(|i: &u64| *i > 1 && i.to_string() == n) + }) + .collect(); + match (1..).find(|i| !used.contains(i)) { + Some(n) if n > 1 => format!("{prefix}{n}.wav"), + _ => plain, } - path } /// Register decoded audio as a new source. Resamples to the session rate if needed. @@ -1255,6 +1261,23 @@ mod rate_tests { } } +#[cfg(test)] +mod name_tests { + use super::*; + + #[test] + fn new_sources_take_the_first_free_file_name() { + let mut s = Session::default(); + let buf = || AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 4]] }; + add_source(&mut s, "k", buf(), Some("Audio Files/k-02.wav"), FileFormat::Wav); + let mut path = || { + let id = add_source(&mut s, "k", buf(), None, FileFormat::Wav); + s.source(id).map(|x| x.path.clone()).unwrap_or_default() + }; + assert_eq!([path(), path(), path()], ["Audio Files/k.wav", "Audio Files/k-2.wav", "Audio Files/k-3.wav"]); + } +} + #[cfg(all(test, unix))] mod pipe_tests { use super::*; diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 9c7e882..9d384f5 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -432,14 +432,15 @@ impl Session { if !base.is_empty() && !taken(base) && !matches!(base, "Audio" | "Aux" | "MIDI" | "Inst" | "VCA" | "Folder" | "Master") { return base.to_string(); } - let mut i = 1; - loop { - let cand = format!("{base} {i}"); - if !taken(&cand) { - return cand; - } - i += 1; - } + let prefix = format!("{base} "); + let used: std::collections::HashSet = self + .tracks + .iter() + .filter_map(|t| t.name.strip_prefix(&prefix)) + .filter_map(|n| n.parse().ok().filter(|i: &u64| i.to_string() == n)) + .collect(); + let i = (1..).find(|i| !used.contains(i)).unwrap_or(1); + format!("{base} {i}") } /// Add a track with defaults; returns its id. @@ -645,6 +646,17 @@ mod tests { assert_eq!(c.source_offset(), 580); } + #[test] + fn unique_track_names_take_the_first_free_number() { + let mut s = Session::default(); + for name in ["Piano", "Piano 1", "Piano 3", "Piano 02", "Pianos 2"] { + s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(name)); + } + assert_eq!(s.unique_track_name("Piano"), "Piano 2"); + assert_eq!(s.unique_track_name("Organ"), "Organ"); + assert_eq!(s.unique_track_name("Audio"), "Audio 1"); + } + #[test] fn a_bad_sample_rate_or_tempo_is_repaired_on_load() { let mut v = serde_json::to_value(Session::default()).unwrap(); From 7ca978fdb95cfc27cae47e7eadbad5db3b2a1c70 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:36:00 +0100 Subject: [PATCH 086/154] Cut names from files to a length that can be drawn A MIDI file whose track name was a million characters long imported fine, and the next frame crashed the app: drawing that name needed a 320 MB vertex buffer, past wgpu's 256 MB limit, and the panic comes from painting, outside anything that catches it. Saved, the session crashed again on every open. A 4 MB status line in ui.json did the same at every launch. Names read from files are now cut to 256 characters and comments to 4096: everything Session::sanitize repairs on load (session, track, playlist, clip, marker, bus, output, group, source, video and key names), MIDI track names on import and clip names from clip group files. The status bar draws at most 1024 characters. --- crates/engine/src/clip_group_file.rs | 1 + crates/engine/src/io.rs | 14 ++++++++- crates/model/src/lib.rs | 5 ++- crates/model/src/session.rs | 46 ++++++++++++++++++++++++++++ crates/ui-egui/src/edit_window.rs | 3 +- 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 66735f9..e60beed 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -144,6 +144,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - continue; } c.id = s.new_clip_id(); + soundcraft_model::truncate_chars(&mut c.name, soundcraft_model::MAX_NAME_CHARS); c.start = at.saturating_add(scale(c.start.max(0))); c.length = scale(c.length).max(1); c.group = Some(group_id); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index f1bf6a4..a99f7cc 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -203,7 +203,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b if tr.sequence.notes.is_empty() { continue; } - let name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() }; + let mut name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() }; + soundcraft_model::truncate_chars(&mut name, soundcraft_model::MAX_NAME_CHARS); let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name)); let drums = tr.sequence.notes.iter().all(|n| n.channel == 9); if let Some(t) = s.track_mut(id) { @@ -1265,6 +1266,17 @@ mod rate_tests { mod name_tests { use super::*; + #[test] + fn a_midi_track_name_is_cut_to_a_length_that_can_be_drawn() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: "n".repeat(1_000_000), sequence }], ..Default::default() }; + let mut e = Engine::default(); + let ids = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap(); + let track = e.session().track(ids[0]).unwrap(); + assert_eq!((track.name.len(), track.clips()[0].name.len()), (soundcraft_model::MAX_NAME_CHARS, soundcraft_model::MAX_NAME_CHARS)); + } + #[test] fn new_sources_take_the_first_free_file_name() { let mut s = Session::default(); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 55d605d..ec8aaa2 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -22,7 +22,10 @@ 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, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState, + truncate_chars, +}; 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 9d384f5..a3f906c 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -575,6 +575,39 @@ impl Session { self.sample_rate = SampleRate::default(); } self.tempo.sanitize(); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + truncate_chars(&mut self.comments, MAX_COMMENT_CHARS); + for t in &mut self.tracks { + truncate_chars(&mut t.name, MAX_NAME_CHARS); + truncate_chars(&mut t.comments, MAX_COMMENT_CHARS); + for p in &mut t.playlists { + truncate_chars(&mut p.name, MAX_NAME_CHARS); + p.clips.iter_mut().for_each(|c| truncate_chars(&mut c.name, MAX_NAME_CHARS)); + } + } + for m in &mut self.markers { + truncate_chars(&mut m.name, MAX_NAME_CHARS); + truncate_chars(&mut m.comments, MAX_COMMENT_CHARS); + } + self.key_signatures.iter_mut().for_each(|(_, k)| truncate_chars(k, MAX_NAME_CHARS)); + self.busses.iter_mut().for_each(|b| truncate_chars(&mut b.name, MAX_NAME_CHARS)); + self.outputs.iter_mut().for_each(|o| truncate_chars(&mut o.name, MAX_NAME_CHARS)); + self.groups.iter_mut().for_each(|g| truncate_chars(&mut g.name, MAX_NAME_CHARS)); + self.sources.iter_mut().for_each(|s| truncate_chars(&mut s.name, MAX_NAME_CHARS)); + self.videos.iter_mut().for_each(|v| truncate_chars(&mut v.name, MAX_NAME_CHARS)); + } +} + +/// The most characters kept of a name read from a file: longer ones can't be shown, and drawing +/// a name of a million characters overflows the renderer's buffers. +pub const MAX_NAME_CHARS: usize = 256; +/// The most characters kept of a comment read from a file. +pub const MAX_COMMENT_CHARS: usize = 4096; + +/// Cut `s` to at most `max` characters. +pub fn truncate_chars(s: &mut String, max: usize) { + if let Some((end, _)) = s.char_indices().nth(max) { + s.truncate(end); } } @@ -657,6 +690,19 @@ mod tests { assert_eq!(s.unique_track_name("Audio"), "Audio 1"); } + #[test] + fn names_from_a_file_are_cut_to_a_length_that_can_be_drawn() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(tr) = s.track_mut(t) { + tr.name = "é".repeat(1_000); + } + s.comments = "c".repeat(10_000); + let back = Session::from_json(&s.to_json().unwrap()).unwrap(); + assert_eq!(back.track(t).map(|tr| tr.name.chars().count()), Some(MAX_NAME_CHARS)); + assert_eq!(back.comments.len(), MAX_COMMENT_CHARS); + } + #[test] fn a_bad_sample_rate_or_tempo_is_repaired_on_load() { let mut v = serde_json::to_value(Session::default()).unwrap(); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..dc2453b 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -157,7 +157,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let r = ui.max_rect(); let s = app.engine.session(); - let left = format!( + let mut left = format!( "{} · {} Hz · {} · {} tracks · {}", s.name, s.sample_rate.hz(), @@ -169,6 +169,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { s.tracks.len(), app.ui.status ); + soundcraft_model::truncate_chars(&mut left, 4 * soundcraft_model::MAX_NAME_CHARS); ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, left, regular(11.0), t.text_dim); let undo = app.engine.undo_label().map_or(String::new(), |l| format!("Undo {l}")); ui.painter().text( From 1b8baa10afbac50d4710ba05cb4c8fb5e4676518 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:40:08 +0100 Subject: [PATCH 087/154] Keep Extract Chords to a bounded walk over the selection Extract Chords walked the selection bar by bar, looking at every note of the selected tracks in each bar, until it had 2000 chords or reached the end. After Select All over a session file with a clip at the far end of the timeline (2^50 samples) it ran for ever over empty bars, and a 317-byte MIDI file with one note held for 8.5 years kept it busy for millions of bars that never form a chord. It now takes the notes in time order, keeps only those sounding in the bar it looks at, jumps over bars where nothing sounds, and stops after 100,000 bars that hold notes (27 hours at 120 BPM) or 10 million sounding notes. The chords it finds are the same. --- crates/engine/src/cmd/event_more.rs | 40 ++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs index 3dc634c..c0780a5 100644 --- a/crates/engine/src/cmd/event_more.rs +++ b/crates/engine/src/cmd/event_more.rs @@ -555,6 +555,10 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option { Some(name) } +/// The most bars Extract Chords looks at (55 hours of 4/4 at 120 BPM), and notes sounding in them. +const MAX_CHORD_BARS: usize = 100_000; +const MAX_CHORD_NOTES: usize = 10_000_000; + fn extract_chords(e: &mut Engine, p: &Value) -> Result { let id = "event.extract_chords"; let (_, t0, t1) = selection_ticks(e, p, id)?; @@ -580,20 +584,36 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result { } } } - let first_bar = s.tempo.bar_beat_at_tick(t0).bar; - let mut bar = first_bar; + notes.sort_unstable_by_key(|n| n.0); + let mut bar = s.tempo.bar_beat_at_tick(t0).bar; + let mut sounding: Vec<(i64, i64, u8)> = Vec::new(); + let (mut next, mut bars, mut heard) = (0, 0, 0); let mut chords: Vec = Vec::new(); let mut marks: Vec<(Samples, String)> = Vec::new(); let mut last = String::new(); - while chords.len() < 2000 { + while chords.len() < 2000 && bars < MAX_CHORD_BARS && heard < MAX_CHORD_NOTES { let a = s.tempo.bar_start_tick(bar).max(t0); let b = s.tempo.bar_start_tick(bar + 1).min(t1); if a >= t1 { break; } + while let Some(&n) = notes.get(next).filter(|n| n.0 < b) { + sounding.push(n); + next += 1; + } + sounding.retain(|n| n.1 > a); + if sounding.is_empty() { + match notes.get(next) { + Some(n) => bar = (bar + 1).max(s.tempo.bar_beat_at_tick(n.0).bar), + None => break, + } + continue; + } + bars += 1; + heard += sounding.len(); let mut w = [0.0f64; 12]; let mut bass: Option = None; - for (na, nb, pitch) in ¬es { + for (na, nb, pitch) in &sounding { let ov = (*nb).min(b) - (*na).max(a); if ov > 0 { if let Some(x) = w.get_mut(usize::from(*pitch) % 12) { @@ -901,6 +921,18 @@ mod tests { assert!(e.session().markers.iter().any(|m| m.ruler == 2)); } + #[test] + fn chords_far_down_the_timeline_are_found_without_walking_every_bar() { + let mut e = crate::demo::demo_engine(); + let keys = e.session().track_by_name("Keys").unwrap().id; + let far = 1i64 << 39; + if let Some(p) = e.session_mut().track_mut(keys).and_then(soundcraft_model::Track::playlist_mut) { + p.clips.iter_mut().for_each(|c| c.start += far); + } + let r = e.execute("event.extract_chords", &json!({"tracks": [keys.0], "start": 0, "end": far + 48_000 * 600})).unwrap(); + assert!(!r["chords"].as_array().unwrap().is_empty(), "{r}"); + } + #[test] fn beat_detective_finds_demo_tempo() { let mut e = crate::demo::demo_engine(); From fe1d7e4c589cbcd5a5014a03131a959f4ac6574a Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:40:08 +0100 Subject: [PATCH 088/154] Refuse MIDI files that run longer than a day A MIDI file of 317 bytes, with one note whose end lies 41 maximal deltas after its start, imported as an 8.5-year clip. Everything that then rendered or walked that clip (Track Bounce, Commit, Separate at Transients, Extract Chords) worked for seconds to forever and used up to 3 GB. Importing now refuses a MIDI track that ends more than 24 hours after the import point; its end is computed with saturation, so a huge one can't wrap around and slip past the check. A file with more than 10,000 meter changes is refused too: placing each one walks those before it, so 200,000 of them (1.8 MB) took 57 s and fit easily within a day. --- crates/engine/src/io.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index a99f7cc..1cbe7a7 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -185,11 +185,18 @@ pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec { missing } +/// Most meter changes a MIDI file may bring into the tempo map; scores have a handful. +const MAX_MIDI_METERS: usize = 10_000; + /// Import a Standard MIDI File as MIDI tracks (one per SMF track). pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result> { let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?; let s = e.session_mut(); if tempo_map { + // Each meter is placed by walking the ones before it. + if smf.meters.len() > MAX_MIDI_METERS { + return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes"))); + } for (tick, bpm) in &smf.tempos { let _ = s.tempo.set_tempo(*tick, *bpm); } @@ -210,8 +217,11 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b if let Some(t) = s.track_mut(id) { t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" })); } - let end_tick = base_tick + tr.sequence.end_tick().max(1); - let len = s.tempo.tick_to_samples(end_tick, s.sample_rate) - at; + let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(1)); + let len = s.tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at); + if len > s.sample_rate.samples(86_400.0) { + return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1))); + } let cid = s.new_clip_id(); crate::edit::place_clip(s, id, Clip::midi(cid, name, at, len.max(1), tr.sequence.clone())); out.push(id); @@ -1277,6 +1287,25 @@ mod name_tests { assert_eq!((track.name.len(), track.clips()[0].name.len()), (soundcraft_model::MAX_NAME_CHARS, soundcraft_model::MAX_NAME_CHARS)); } + #[test] + fn midi_longer_than_a_day_is_refused() { + let held = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: (1 << 28) - 1 }; + let sequence = soundcraft_midi::Sequence { notes: vec![held], ..Default::default() }; + let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], ..Default::default() }; + let err = import_midi_bytes(&mut Engine::default(), &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap_err(); + assert!(err.to_string().contains("longer than 24 hours"), "{err}"); + } + + #[test] + fn midi_with_more_meter_changes_than_scores_have_is_refused() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let meters = (0..=MAX_MIDI_METERS as i64).map(|bar| (bar * 3840, 4, 4)).collect(); + let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], meters, ..Default::default() }; + let err = import_midi_bytes(&mut Engine::default(), &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap_err(); + assert!(err.to_string().contains("meter changes"), "{err}"); + } + #[test] fn new_sources_take_the_first_free_file_name() { let mut s = Session::default(); From 328760fd703d5b150c38242c10548fa18c2d2e38 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:11:03 +0100 Subject: [PATCH 089/154] Keep positions from files within the range commands use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands clamp every position they take to ±2^40 samples so the arithmetic after them can't overflow, but session and clip group files skipped that. A clip, MIDI note, marker, automation point or selection near i64::MAX made Duplicate, Insert Silence, Insert Time, Heal, Trim to File Boundaries, Quantize to Grid, Separate on Grid, Thin Automation, Copy, Move Selection, Extract Chords and Track Commit overflow: a panic in debug builds, wrapped positions in release. MAX_POSITION moves to soundcraft-time so the model can use it. Loading a session clamps clip starts, lengths, offsets, sync points, gain envelopes and MIDI notes, automation points, markers, key changes, the selection, playhead, pre/post-roll, scroll and timecode start. A clip's repairs are now Clip::sanitize, which clip group import uses too, and Session::sanitize visits each collection once. The Edit window adds a clip's offset to its file's time stamp without overflowing. --- crates/engine/src/clip_group_file.rs | 27 ++++++++-- crates/engine/src/cmd/mod.rs | 4 +- crates/engine/tests/hostile_sessions.rs | 37 ++++++++++++++ crates/model/src/clip.rs | 37 +++++++++++++- crates/model/src/session.rs | 68 ++++++++++++++----------- crates/time/src/lib.rs | 4 ++ crates/ui-egui/src/edit_window.rs | 11 +++- 7 files changed, 149 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index e60beed..37f95d9 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -144,11 +144,10 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - continue; } c.id = s.new_clip_id(); - soundcraft_model::truncate_chars(&mut c.name, soundcraft_model::MAX_NAME_CHARS); c.start = at.saturating_add(scale(c.start.max(0))); - c.length = scale(c.length).max(1); + c.length = scale(c.length); c.group = Some(group_id); - c.clamp_fades(); + c.sanitize(); placed.push(c.id); crate::edit::place_clip(s, target, c); } @@ -194,6 +193,28 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn positions_in_a_group_file_are_kept_within_reach() { + let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join("far.scgrp"); + let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": i64::MAX, "length": i64::MAX}); + let clip = + json!({"id": 1, "name": "far", "start": i64::MAX, "length": i64::MAX, "content": {"Midi": {"sequence": {"notes": [note], "ctrls": []}}}}); + let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1, "lanes": [[clip]], "audio": {}}); + std::fs::write(&p, file.to_string()).unwrap(); + let mut e = crate::demo::demo_engine(); + let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap(); + let id = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap()); + let c = e.session().find_clip(id).unwrap().1; + assert!(c.start <= crate::cmd::MAX_POSITION && c.length <= crate::cmd::MAX_POSITION); + e.execute("edit.select_all", &json!({})).unwrap(); + for cmd in ["edit.duplicate", "event.extract_chords"] { + assert!(!matches!(e.execute(cmd, &json!({})), Err(crate::EngineError::Internal(..))), "{cmd}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn rejects_foreign_files_and_escaping_paths() { let dir = std::env::temp_dir().join(format!("sc-grp-bad-{}", std::process::id())); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..6e3e362 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -238,9 +238,7 @@ pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result> if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) } } -/// Largest position accepted from parameters: 2^40 samples (about 265 days at 48 kHz). Clamping -/// here keeps every later addition and subtraction on positions far away from overflow. -pub const MAX_POSITION: Samples = 1 << 40; +pub use soundcraft_time::MAX_POSITION; /// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format. /// Values are clamped to ±[`MAX_POSITION`]. diff --git a/crates/engine/tests/hostile_sessions.rs b/crates/engine/tests/hostile_sessions.rs index 5a98b02..06b785e 100644 --- a/crates/engine/tests/hostile_sessions.rs +++ b/crates/engine/tests/hostile_sessions.rs @@ -26,6 +26,43 @@ fn demo_json() -> Value { serde_json::to_value(e.session()).unwrap() } +#[test] +fn positions_at_the_ends_of_the_range_do_not_overflow_commands() { + let (max, min) = (serde_json::json!(i64::MAX), serde_json::json!(i64::MIN)); + let kick = "/tracks/0/playlists/0/clips/0"; + let cases = [ + (format!("{kick}/start"), &max, "edit.duplicate"), + (format!("{kick}/start"), &max, "edit.insert_silence"), + (format!("{kick}/length"), &max, "edit.heal"), + (format!("{kick}/length"), &max, "edit.trim_to_file_boundaries"), + (format!("{kick}/content/Audio/offset"), &min, "edit.trim_to_file_boundaries"), + (format!("{kick}/sync_point"), &max, "clip.quantize_to_grid"), + (format!("{kick}/gain_env"), &serde_json::json!([[i64::MIN, 0.0]]), "edit.separate_on_grid"), + ("/tracks/4/automation/0/points/0/at".into(), &min, "automation.thin_all"), + ("/tracks/5/playlists/0/clips/0/content/Midi/sequence/notes/0/start".into(), &max, "event.extract_chords"), + ("/markers/0/start".into(), &max, "event.insert_time"), + ("/markers/1/end".into(), &min, "edit.copy"), + ("/edit/selection/start".into(), &min, "edit.move_selection_left"), + ]; + let (demo, base) = (soundcraft_engine::demo::demo_engine(), demo_json()); + let failed: Vec = cases + .into_iter() + .filter_map(|(path, value, cmd)| { + let mut v = base.clone(); + *v.pointer_mut(&path).unwrap() = value.clone(); + let mut s = soundcraft_model::Session::from_json(&v.to_string()).unwrap(); + s.pool = demo.session().pool.clone(); + let mut e = soundcraft_engine::Engine::new(s); + if !path.starts_with("/edit") { + e.execute("edit.select_all", &serde_json::json!({})).unwrap(); + } + let r = e.execute(cmd, &serde_json::json!({})); + matches!(r, Err(soundcraft_engine::EngineError::Internal(..))).then(|| format!("{path} = {value}, {cmd}: {r:?}")) + }) + .collect(); + assert!(failed.is_empty(), "{failed:#?}"); +} + proptest! { #![proptest_config(ProptestConfig { cases: 96, .. ProptestConfig::default() })] #[test] diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 4d30bea..71853ab 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -1,8 +1,8 @@ //! Clips: windows onto audio sources or MIDI sequences, placed on a playlist. -use crate::{ClipId, SourceId}; +use crate::{ClipId, MAX_NAME_CHARS, SourceId, truncate_chars}; use soundcraft_midi::Sequence; -use soundcraft_time::{Range, Samples}; +use soundcraft_time::{MAX_POSITION, Range, Samples}; /// Fade curve shapes (Pro Tools offers standard, equal-power, S-curve and custom presets). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] @@ -230,6 +230,39 @@ impl Clip { self.fade_in.len = self.fade_in.len.clamp(0, self.length); self.fade_out.len = self.fade_out.len.clamp(0, self.length); } + + /// Repair a clip read from a file: positions and lengths within ±[`MAX_POSITION`], as commands + /// keep theirs, fades within the clip, a name that can be drawn, and a usable gain and stretch. + pub fn sanitize(&mut self) { + bound(&mut self.start); + self.length = self.length.clamp(1, MAX_POSITION); + bound(&mut self.sync_point); + self.gain_env.iter_mut().for_each(|(at, _)| bound(at)); + match &mut self.content { + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => bound(offset), + ClipContent::Midi { sequence } => bound_sequence(sequence), + } + self.clamp_fades(); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + if !self.gain_db.is_finite() { + self.gain_db = 0.0; + } + if !self.stretch.is_finite() || self.stretch <= 0.0 { + self.stretch = 1.0; + } + } +} + +pub(crate) fn bound(at: &mut Samples) { + *at = (*at).clamp(-MAX_POSITION, MAX_POSITION); +} + +pub(crate) fn bound_sequence(s: &mut Sequence) { + for n in &mut s.notes { + bound(&mut n.start); + n.length = n.length.clamp(1, MAX_POSITION); + } + s.ctrls.iter_mut().for_each(|c| bound(&mut c.tick)); } fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index a3f906c..653e369 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -1,5 +1,6 @@ //! The session: the whole document. +use crate::clip::{bound, bound_sequence}; use crate::{ Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool, TRACK_COLORS, Track, TrackId, TrackKind, VideoSource, @@ -529,8 +530,12 @@ impl Session { .max() .unwrap_or(0); self.next_id = self.next_id.max(max_id.saturating_add(1)); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + truncate_chars(&mut self.comments, MAX_COMMENT_CHARS); for t in &mut self.tracks { t.mixer.sanitize(); + truncate_chars(&mut t.name, MAX_NAME_CHARS); + truncate_chars(&mut t.comments, MAX_COMMENT_CHARS); if t.playlists.is_empty() { t.playlists.push(crate::Playlist::new(t.name.clone())); } @@ -538,24 +543,42 @@ impl Session { t.active_playlist = 0; } for p in &mut t.playlists { + truncate_chars(&mut p.name, MAX_NAME_CHARS); p.clips.retain(|c| c.length > 0); - for c in &mut p.clips { - c.fade_in.len = c.fade_in.len.clamp(0, c.length); - c.fade_out.len = c.fade_out.len.clamp(0, c.length); - if !c.gain_db.is_finite() { - c.gain_db = 0.0; - } - if !c.stretch.is_finite() || c.stretch <= 0.0 { - c.stretch = 1.0; - } - } + p.clips.iter_mut().for_each(Clip::sanitize); p.sort(); } for l in &mut t.automation { l.points.retain(|p| p.value.is_finite()); + l.points.iter_mut().for_each(|p| bound(&mut p.at)); l.points.sort_by_key(|p| p.at); } } + for m in &mut self.markers { + bound(&mut m.start); + bound(&mut m.end); + truncate_chars(&mut m.name, MAX_NAME_CHARS); + truncate_chars(&mut m.comments, MAX_COMMENT_CHARS); + } + for (at, key) in &mut self.key_signatures { + bound(at); + truncate_chars(key, MAX_NAME_CHARS); + } + self.midi_originals.values_mut().for_each(bound_sequence); + let e = &mut self.edit; + for at in [ + &mut e.selection.start, + &mut e.selection.end, + &mut e.timeline_selection.start, + &mut e.timeline_selection.end, + &mut e.playhead, + &mut e.pre_roll, + &mut e.post_roll, + &mut e.zoom.scroll, + &mut self.timecode_start, + ] { + bound(at); + } for v in &mut self.videos { if !v.frame_rate.is_finite() || v.frame_rate < 0.0 { v.frame_rate = 0.0; @@ -563,7 +586,12 @@ impl Session { if !v.duration.is_finite() || v.duration < 0.0 { v.duration = 0.0; } + truncate_chars(&mut v.name, MAX_NAME_CHARS); } + self.busses.iter_mut().for_each(|b| truncate_chars(&mut b.name, MAX_NAME_CHARS)); + self.outputs.iter_mut().for_each(|o| truncate_chars(&mut o.name, MAX_NAME_CHARS)); + self.groups.iter_mut().for_each(|g| truncate_chars(&mut g.name, MAX_NAME_CHARS)); + self.sources.iter_mut().for_each(|s| truncate_chars(&mut s.name, MAX_NAME_CHARS)); // Drop performances whose clip no longer exists anywhere (any playlist). let clip_ids: std::collections::BTreeSet = self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect(); @@ -575,26 +603,6 @@ impl Session { self.sample_rate = SampleRate::default(); } self.tempo.sanitize(); - truncate_chars(&mut self.name, MAX_NAME_CHARS); - truncate_chars(&mut self.comments, MAX_COMMENT_CHARS); - for t in &mut self.tracks { - truncate_chars(&mut t.name, MAX_NAME_CHARS); - truncate_chars(&mut t.comments, MAX_COMMENT_CHARS); - for p in &mut t.playlists { - truncate_chars(&mut p.name, MAX_NAME_CHARS); - p.clips.iter_mut().for_each(|c| truncate_chars(&mut c.name, MAX_NAME_CHARS)); - } - } - for m in &mut self.markers { - truncate_chars(&mut m.name, MAX_NAME_CHARS); - truncate_chars(&mut m.comments, MAX_COMMENT_CHARS); - } - self.key_signatures.iter_mut().for_each(|(_, k)| truncate_chars(k, MAX_NAME_CHARS)); - self.busses.iter_mut().for_each(|b| truncate_chars(&mut b.name, MAX_NAME_CHARS)); - self.outputs.iter_mut().for_each(|o| truncate_chars(&mut o.name, MAX_NAME_CHARS)); - self.groups.iter_mut().for_each(|g| truncate_chars(&mut g.name, MAX_NAME_CHARS)); - self.sources.iter_mut().for_each(|s| truncate_chars(&mut s.name, MAX_NAME_CHARS)); - self.videos.iter_mut().for_each(|v| truncate_chars(&mut v.name, MAX_NAME_CHARS)); } } diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 4b68934..7e9d4b9 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -19,6 +19,10 @@ pub use timecode::{FrameRate, Timecode}; /// A position or length in samples. pub type Samples = i64; +/// Largest position accepted from parameters and files: 2^40 samples (about 265 days at 48 kHz). +/// Clamping there keeps every later addition and subtraction on positions far away from overflow. +pub const MAX_POSITION: Samples = 1 << 40; + /// Errors from parsing or converting time values. #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum TimeError { diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index dc2453b..11a502d 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1330,7 +1330,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { clip.source().and_then(|src| s.source(src)).map(|src| { format_position( - i64::try_from(src.time_reference).unwrap_or(0) + clip.source_offset(), + i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()), TimeFormat::Timecode, s.sample_rate, &s.tempo, @@ -2069,6 +2069,15 @@ mod tests { h } + #[test] + fn a_file_time_stamp_at_the_end_of_the_range_draws() { + let mut engine = soundcraft_engine::demo::demo_engine(); + let s = engine.session_mut(); + s.edit.flags.insert("view.clip.time.original".into()); + s.sources.iter_mut().for_each(|src| src.time_reference = i64::MAX as u64); + harness_with(engine).run_steps(2); + } + fn smart_demo() -> Harness<'static, SoundApp> { let mut engine = soundcraft_engine::demo::demo_engine(); engine.execute("edit.tool", &json!({"tool": "smart"})).unwrap(); From 6b3c73b7ff871a7a1c929cff620a47c15cfc8d48 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:15:15 +0100 Subject: [PATCH 090/154] Decode each audio file once when a session names it many times Opening a session decoded every source entry on its own, so a 400 KB session listing one 11 MB WAV 2,000 times took 16 s and 22 GB, and 20,000 entries never finished. Import Session Data and clip group files did the same. All three now decode a file the first time it is named, by its canonical path, and share the decoded audio with later sources that name it; the pool's audio is never changed in place, so sharing it is safe. Saving keeps sources that play one file on that file, and an autosave or a copy writes shared audio once instead of once per source. That session now opens in 0.3 s at 110 MB, and a normal session bounces to the same bytes as before. --- crates/engine/src/clip_group_file.rs | 54 +++++++++--- crates/engine/src/cmd/setup_more.rs | 31 +++---- crates/engine/src/io.rs | 119 ++++++++++++++++++++++----- 3 files changed, 149 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 37f95d9..d520342 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -91,29 +91,41 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into())); } let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); - // Decode audio first so a bad file changes nothing. - let mut decoded = BTreeMap::new(); + // Decode audio first so a bad file changes nothing, and each file once however many ids name it. + let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); let rate = e.session().sample_rate.hz(); for (old, rel) in &file.audio { let full = dir.join(rel); if !full.starts_with(&dir) || rel.contains("..") { return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); } - let bytes = crate::io::read_file(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?; - let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; - let buf = - crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?; - decoded.insert(*old, (rel.clone(), buf)); + let key = std::fs::canonicalize(&full).unwrap_or_else(|_| full.clone()); + let n = match index.get(&key) { + Some(n) => *n, + None => { + let bytes = crate::io::read_file(&full)?; + let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; + let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES) + .ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?; + decoded.push((rel.clone(), buf)); + index.insert(key, decoded.len() - 1); + decoded.len() - 1 + } + }; + file_of.insert(*old, n); } let src_rate = f64::from(file.sample_rate.max(1)); let s = e.session_mut(); let ratio = f64::from(s.sample_rate.hz()) / src_rate; let scale = |v: Samples| -> Samples { (v as f64 * ratio).round() as Samples }; - let mut map: BTreeMap = BTreeMap::new(); - for (old, (rel, buf)) in decoded { - let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); - map.insert(old, crate::io::add_source(s, &name, buf, None, FileFormat::Wav)); - } + let sources: Vec = decoded + .into_iter() + .map(|(rel, buf)| { + let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); + crate::io::add_source(s, &name, buf, None, FileFormat::Wav) + }) + .collect(); + let map: BTreeMap = file_of.into_iter().filter_map(|(old, n)| Some((old, *sources.get(n)?))).collect(); let start_idx = first.and_then(|t| s.tracks.iter().position(|x| x.id == t)); let mut placed = Vec::new(); let group_id = s.alloc(); @@ -215,6 +227,24 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_file_named_by_many_ids_becomes_one_source() { + let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let audio = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] }; + std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap(); + let clip = |id: u64| json!({"id": id, "name": "k", "start": id * 1000, "length": 480, "content": {"Audio": {"source": id, "offset": 0}}}); + let audio_paths = json!({"1": "k.wav", "2": "./k.wav", "3": "k.wav"}); + let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 4000, "lanes": [[clip(1), clip(2), clip(3)]], "audio": audio_paths}); + let p = dir.join("k.scgrp"); + std::fs::write(&p, file.to_string()).unwrap(); + let mut e = crate::demo::demo_engine(); + let before = e.session().sources.len(); + let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap(); + assert_eq!((r["clips"].as_array().unwrap().len(), e.session().sources.len()), (3, before + 1)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn rejects_foreign_files_and_escaping_paths() { let dir = std::env::temp_dir().join(format!("sc-grp-bad-{}", std::process::id())); diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index e896394..ba5c3fc 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -5,10 +5,9 @@ use super::more_util::{set_text, store_value, text, toggle_flag, values_with_pre use super::*; use crate::cmd; use serde_json::json; -use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind}; use soundcraft_time::Timecode; use std::path::{Path, PathBuf}; -use std::sync::Arc; macro_rules! option { ($id:literal, $label:literal, $flag:literal) => { @@ -401,14 +400,6 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result { Ok(json!({"enabled": enabled})) } -/// 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 = crate::io::read_file(&p).ok()?; - let (_, buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?; - crate::io::at_rate(buf, sr, crate::io::MAX_IMPORT_SAMPLES) -} - fn import_session_data(e: &mut Engine, p: &Value) -> 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(); @@ -437,31 +428,29 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result { needed.sort_by_key(|x| x.0); needed.dedup(); let sr = e.session().sample_rate.hz(); - let decoded: Vec<(Source, Option)> = - needed.iter().filter_map(|sid| other.source(*sid).cloned()).map(|src| (src.clone(), load_source(&dir, &src, sr))).collect(); let s = e.session_mut(); + let mut files = crate::io::DecodedFiles::new(); let mut map: std::collections::BTreeMap = Default::default(); let mut missing = Vec::new(); - for (src, buf) in decoded { - let nid = SourceId(s.alloc()); + for src in needed.iter().filter_map(|sid| other.source(*sid)) { let abs = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; let mut ns = src.clone(); - ns.id = nid; + ns.id = SourceId(s.alloc()); ns.path = abs.to_string_lossy().into_owned(); - match buf { - Some(b) => { - ns.frames = b.frames() as u64; + match crate::io::decode_file(&mut files, &abs, sr) { + Ok(audio) => { + ns.frames = audio.buffer.frames() as u64; ns.sample_rate = sr; ns.unsaved = true; - s.pool.insert(nid, Arc::new(SourceAudio::new(b))); + s.pool.insert(ns.id, audio); } - None => { + Err(_) => { ns.unsaved = false; missing.push(ns.path.clone()); } } + map.insert(src.id.0, ns.id); s.sources.push(ns); - map.insert(src.id.0, nid); } let mut out = Vec::new(); for mut tr in picked { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 1cbe7a7..a1d371e 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -570,6 +570,7 @@ pub fn save_session_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)> { + use std::collections::{HashMap, HashSet}; let p = session_file(path); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let audio_dir = dir.join("Audio Files"); @@ -577,8 +578,16 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess let from = s.folder.clone(); let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir)); let mut written = 0; - let shared_paths: Vec = - s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect(); + // A file can stay put for all the sources that name it, unless their audio differs. + let shared_paths: Vec = { + let mut audio_by_path: HashMap<&str, HashSet>> = HashMap::new(); + for src in &s.sources { + audio_by_path.entry(&src.path).or_default().insert(s.pool.get(src.id).map(Arc::as_ptr)); + } + s.sources.iter().map(|src| audio_by_path.get(src.path.as_str()).is_some_and(|a| a.len() > 1)).collect() + }; + // Files written for audio during this save: sources playing the same audio share one. + let mut files: HashMap<*const SourceAudio, String> = HashMap::new(); for (i, src) in s.sources.iter_mut().enumerate() { if !same_dir && s.pool.get(src.id).is_none() @@ -598,6 +607,12 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess continue; } let Some(audio) = s.pool.get(src.id) else { continue }; + if let Some(path) = files.get(&Arc::as_ptr(audio)) { + src.path.clone_from(path); + src.unsaved = false; + src.format = FileFormat::Wav; + continue; + } std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; let mut stem = sanitize_name(&src.name); if overwrite { @@ -606,6 +621,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess if audio_dir.join(format!("{stem}.wav")).exists() { src.path = format!("Audio Files/{stem}.wav"); src.unsaved = false; + files.insert(Arc::as_ptr(audio), src.path.clone()); continue; } } @@ -615,6 +631,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess src.path = format!("Audio Files/{name}"); src.unsaved = false; src.format = FileFormat::Wav; + files.insert(Arc::as_ptr(audio), src.path.clone()); written += usize::from(new); } if written > 0 { @@ -759,15 +776,12 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { 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 mut files = DecodedFiles::new(); + for src in &s.sources { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; - match read_file(&p) { - 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(_) => missing.push(p.display().to_string()), + match decode_file(&mut files, &p, s.sample_rate.hz()) { + Ok(audio) => s.pool.insert(src.id, audio), + Err(err) => missing.push(err), } } missing.extend(relink_videos(&mut s, &dir)); @@ -780,12 +794,26 @@ 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> { +/// Decode one source's audio file at the session rate. +fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result, String> { let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?; - let buf = at_rate(buf, s.sample_rate.hz(), MAX_IMPORT_SAMPLES).ok_or("too large to load")?; - s.pool.insert(id, Arc::new(SourceAudio::new(buf))); - Ok(()) + let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?; + Ok(Arc::new(SourceAudio::new(buf))) +} + +/// Audio files decoded so far, by path: a session may name one file thousands of times. +pub(crate) type DecodedFiles = std::collections::HashMap, String>>; + +/// The audio of the file at `p` at `rate`, decoded the first time it is asked for. The error +/// names the file. +pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std::result::Result, String> { + files + .entry(std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())) + .or_insert_with(|| { + let bytes = read_file(p).map_err(|_| p.display().to_string())?; + decode_source(&bytes, p.extension().and_then(|x| x.to_str()), rate).map_err(|err| format!("{}: {err}", p.display())) + }) + .clone() } // ─── Hosts without a filesystem (the browser) ───────────────────────────────────────────────── @@ -888,14 +916,12 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result { - 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)); - } - } + Some(audio) => match decode_source(&audio, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) { + Ok(audio) => s.pool.insert(src.id, audio), + Err(err) => missing.push(format!("{}: {err}", src.path)), + }, None => missing.push(src.path.clone()), } } @@ -1272,6 +1298,55 @@ mod rate_tests { } } +#[cfg(test)] +mod open_tests { + use super::*; + + #[test] + fn a_file_named_by_many_sources_is_decoded_once() { + let dir = std::env::temp_dir().join(format!("soundcraft-shared-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] }; + std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap(); + let mut s = Session::default(); + for _ in 0..3 { + add_source(&mut s, "k", audio.clone(), Some("k.wav"), FileFormat::Wav); + } + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + std::fs::write(&path, s.to_json().unwrap()).unwrap(); + let mut e = Engine::default(); + assert!(open_session(&mut e, &path).unwrap().is_empty()); + let (sources, pool) = (&e.session().sources, &e.session().pool); + let first = pool.get(sources[0].id).unwrap(); + assert!(sources.iter().all(|src| pool.get(src.id).is_some_and(|a| Arc::ptr_eq(a, first)))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn sources_playing_one_file_share_it_when_saved() { + let dir = std::env::temp_dir().join(format!("soundcraft-shared-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Audio Files")).unwrap(); + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] }; + std::fs::write(dir.join("Audio Files/k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap(); + let mut s = Session::default(); + for _ in 0..3 { + add_source(&mut s, "k", audio.clone(), Some("Audio Files/k.wav"), FileFormat::Wav); + } + s.sources.iter_mut().for_each(|src| src.unsaved = false); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + std::fs::write(&path, s.to_json().unwrap()).unwrap(); + let mut e = Engine::default(); + open_session(&mut e, &path).unwrap(); + assert_eq!(save_session(&mut e, &path).unwrap(), 0); + assert!(e.session().sources.iter().all(|src| src.path == "Audio Files/k.wav")); + assert_eq!(save_copy(&e, &dir.join("copy/Song.scraft").to_string_lossy()).unwrap(), 1); + assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod name_tests { use super::*; 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 091/154] 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 092/154] 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 093/154] 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 094/154] 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 095/154] 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 096/154] 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 d9922c47a630d510f4fe30334986f18acf43f61b Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:16:30 +0800 Subject: [PATCH 097/154] Fix clip fade drag bounds and targeted fade creation --- crates/engine/src/cmd/edit.rs | 115 ++++++++++++++++- crates/ui-egui/src/edit_window.rs | 205 ++++++++++++++++++++++++++++-- docs/ui-parity.md | 4 +- 3 files changed, 307 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..b7532ca 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use super::*; use crate::edit as ops; use crate::{Clipboard, cmd}; use serde_json::json; -use soundcraft_model::{EditMode, FadeShape, Session}; +use soundcraft_model::{EditMode, Fade, FadeShape, Session}; pub fn specs() -> Vec { vec![ @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Create...", ["Edit", "Fades"], Some("Cmd+F"), - "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?}", + "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?}", has_range, fades_create ), @@ -671,11 +671,43 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { } fn fades_create(e: &mut Engine, p: &Value) -> Result { - let tracks = tracks_required(e, "edit.fades_create", p)?; - let r = range_param(e, "edit.fades_create", p)?; let shape = str_param(p, "shape") .map_or(Some(FadeShape::EqualPower), FadeShape::from_id) .ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?; + if p.get("clip").is_some() || p.get("side").is_some() { + if ["track", "tracks", "clips", "start", "end"].iter().any(|key| p.get(key).is_some()) { + return Err(bad("edit.fades_create", "clip fades cannot also specify tracks or an edit range")); + } + let id = p.get("clip").and_then(Value::as_u64).map(soundcraft_model::ClipId).ok_or_else(|| bad("edit.fades_create", "clip must be an id"))?; + let fade_in = match str_param(p, "side") { + Some("in") => true, + Some("out") => false, + _ => return Err(bad("edit.fades_create", "side must be in or out")), + }; + let length = p + .get("length") + .and_then(Value::as_i64) + .filter(|len| *len > 0) + .ok_or_else(|| bad("edit.fades_create", "length must be a positive sample count"))?; + let (_, clip) = e.session().find_clip(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?; + let available = clip.length.min(clip.end().saturating_sub(clip.start)); + if available <= 0 { + return Err(bad("edit.fades_create", "clip has no length")); + } + if p.get("shape").is_some_and(|v| !v.is_string()) { + return Err(bad("edit.fades_create", "shape must be a fade shape name")); + } + let fade = Fade { len: length.min(available), shape }; + let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?; + if fade_in { + clip.fade_in = fade; + } else { + clip.fade_out = fade; + } + return Ok(json!({"fades": 1})); + } + let tracks = tracks_required(e, "edit.fades_create", p)?; + let r = range_param(e, "edit.fades_create", p)?; let s = e.session_mut(); let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum(); if n == 0 { @@ -770,6 +802,81 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn targeted_fades_fill_only_the_requested_side_and_undo_once() { + for side in ["in", "out"] { + let mut e = crate::demo::demo_engine(); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.select_none", &json!({})).unwrap(); + let before = e.session().clone(); + let undo_len = e.undo_history().len(); + assert_eq!( + e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": i64::MAX, "shape": "s-curve"})).unwrap(), + json!({"fades": 1}) + ); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + let fade = Fade { len: clip.length, shape: FadeShape::SCurve }; + if side == "in" { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + assert_eq!(e.session(), &expected); + assert_eq!(e.undo_history().len(), undo_len + 1); + assert!(e.undo()); + assert_eq!(e.session(), &before); + assert!(e.redo()); + assert_eq!(e.session(), &expected); + } + } + + #[test] + fn targeted_fades_reject_invalid_parameters_without_an_edit() { + let mut e = crate::demo::demo_engine(); + let id = e.session().track_by_name("Kick").unwrap().clips()[0].id.0; + for params in [ + json!({"clip": id, "side": "in"}), + json!({"clip": id, "length": 10}), + json!({"side": "out", "length": 10}), + json!({"clip": "bad", "side": "in", "length": 10}), + json!({"clip": u64::MAX, "side": "in", "length": 10}), + json!({"clip": id, "side": "bad", "length": 10}), + json!({"clip": id, "side": "in", "length": 0}), + json!({"clip": id, "side": "in", "length": -1}), + json!({"clip": id, "side": "in", "length": 1.5}), + json!({"clip": id, "side": "in", "length": "10"}), + json!({"clip": id, "side": "in", "length": u64::MAX}), + json!({"clip": id, "side": "in", "length": 10, "shape": "bad"}), + json!({"clip": id, "side": "in", "length": 10, "shape": null}), + json!({"clip": id, "side": "in", "length": 10, "start": 0}), + json!({"clip": id, "side": "in", "length": 10, "end": 100}), + json!({"clip": id, "side": "in", "length": 10, "tracks": []}), + ] { + let before = e.session().clone(); + let history = e.undo_history(); + assert!(e.execute("edit.fades_create", ¶ms).is_err(), "{params}"); + assert_eq!(e.session(), &before, "{params}"); + assert_eq!(e.undo_history(), history, "{params}"); + } + e.session_mut().find_clip_mut(soundcraft_model::ClipId(id)).unwrap().length = 0; + let before = e.session().clone(); + assert!(e.execute("edit.fades_create", &json!({"clip": id, "side": "in", "length": 1})).is_err()); + assert_eq!(e.session(), &before); + } + + #[test] + fn targeted_fades_support_one_sample_clips() { + let mut e = crate::demo::demo_engine(); + let id = e.session().track_by_name("Kick").unwrap().clips()[0].id; + e.session_mut().find_clip_mut(id).unwrap().length = 1; + for side in ["in", "out"] { + e.execute("edit.fades_create", &json!({"clip": id.0, "side": side, "length": 1})).unwrap(); + let clip = e.session().find_clip(id).unwrap().1; + assert_eq!(if side == "in" { clip.fade_in.len } else { clip.fade_out.len }, 1); + } + } + #[test] fn selecting_what_is_already_selected_leaves_the_document_alone() { let mut e = Engine::default(); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..e75e5d4 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1582,7 +1582,12 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } 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 }); + app.gesture = fade_drag_range(c, fade_in, at).map(|range| Gesture::Fade { + clip: c.id, + track: track.id, + fade_in, + to: if fade_in { range.end } else { range.start }, + }); } else if resp.drag_started() { app.gesture = match eff { Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at_origin), tracks: vec![track.id] }), @@ -1672,6 +1677,19 @@ fn place_insertion(app: &mut SoundApp, ui: &Ui, s: &Session, track: TrackId, at: } } +/// The same bounded range drives the preview and the committed fade. +fn fade_drag_range(clip: &Clip, fade_in: bool, to: Samples) -> Option { + let end = clip.end(); + if clip.length <= 0 || end <= clip.start { + return None; + } + Some(if fade_in { + Range::new(clip.start, to.clamp(clip.start.saturating_add(1), end)) + } else { + Range::new(to.clamp(clip.start, end.saturating_sub(1)), end) + }) +} + /// 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 { .. })) { @@ -1689,6 +1707,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { 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)); + let mut invalid_fade = false; match &mut app.gesture { Some(Gesture::Select { track, tracks, .. }) => { let row_of = |t: TrackId| rows.iter().position(|(i, _)| *i == t.0); @@ -1712,7 +1731,13 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { } } Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), - Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::Fade { clip, fade_in, to, .. }) => { + if let Some(range) = s.find_clip(*clip).and_then(|(_, c)| fade_drag_range(c, *fade_in, at)) { + *to = if *fade_in { range.end } else { range.start }; + } else { + invalid_fade = true; + } + } Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1739,6 +1764,9 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { } _ => {} } + if invalid_fade { + app.gesture = None; + } 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 { @@ -1769,11 +1797,13 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { 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::Fade { clip, fade_in, to, .. }) => { + let to = p.map_or(to, |p| sample_at(s, tl, p.x).max(0)); + if let Some(range) = s.find_clip(clip).and_then(|(_, c)| fade_drag_range(c, fade_in, to)) { + let _ = app.run( + "edit.fades_create", + json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": range.end.saturating_sub(range.start), "shape": "s-curve"}), + ); } } Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), @@ -1990,13 +2020,16 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } if let Some(Gesture::Fade { clip, fade_in, to, .. }) = &app.gesture && let Some((tid, c)) = s.find_clip(*clip) + && let Some(track) = s.track(tid) && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) + && let Some(range) = fade_drag_range(c, *fade_in, *to) { - let (a, b) = if *fade_in { (c.start, *to) } else { (*to, c.end()) }; - let (x0, x1) = (x_of(s, tl, a), x_of(s, tl, b)); - let (y0, y1) = (r[1] + 2.0, r[3] - 2.0); + let lane = lane_rect(track, rect(*r), tl); + let clip_rect = Rect::from_min_max(pos2(x_of(s, tl, c.start), lane.min.y + 1.0), pos2(x_of(s, tl, c.end()), lane.max.y - 1.0)); + let (x0, x1) = (x_of(s, tl, range.start), x_of(s, tl, range.end)); + let (y0, y1) = (clip_rect.min.y + 1.0, clip_rect.max.y - 1.0); let line = if *fade_in { [pos2(x0, y1), pos2(x1, y0)] } else { [pos2(x0, y0), pos2(x1, y1)] }; - painter.line_segment(line, Stroke::new(2.0, Color32::WHITE)); + painter.with_clip_rect(clip_rect.intersect(tl)).line_segment(line, Stroke::new(2.0, Color32::WHITE)); } if let Some(at) = scroll_to { let _ = app.engine.execute("view.scroll", &json!({"to": at})); @@ -2055,7 +2088,9 @@ mod tests { 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( + let builder = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0); + let builder = if std::env::var_os("SOUNDCRAFT_FADE_SHOTS").is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( |ui, app: &mut SoundApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -2325,6 +2360,147 @@ mod tests { assert_eq!(after.start - clip.start, by, "the clip trails the pointer"); } + #[test] + fn fade_drag_ranges_handle_empty_short_and_saturated_clips() { + let mut c = Clip::audio(ClipId(1), "test", soundcraft_model::SourceId(1), 0, 100, 1); + for side in [true, false] { + for to in [i64::MIN, 100, 101, i64::MAX] { + assert_eq!(fade_drag_range(&c, side, to), Some(Range::new(100, 101))); + } + c.length = 0; + assert_eq!(fade_drag_range(&c, side, 100), None); + c.length = -1; + assert_eq!(fade_drag_range(&c, side, 100), None); + c.length = 1; + } + c.start = i64::MAX - 1; + c.length = 10; + for side in [true, false] { + assert_eq!(fade_drag_range(&c, side, i64::MAX), Some(Range::new(i64::MAX - 1, i64::MAX))); + c.start = i64::MAX; + assert_eq!(fade_drag_range(&c, side, i64::MAX), None); + c.start = i64::MAX - 1; + } + } + + fn fade_shot(h: &mut Harness, name: &str) { + if let Some(dir) = std::env::var_os("SOUNDCRAFT_FADE_SHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("{name}.png"))).unwrap(); + } + } + + #[test] + fn fade_drag_previews_and_commits_stay_inside_the_clip() { + for fade_in in [true, false] { + for release_outside in [false, true] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let s = h.state().engine.session(); + let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); + let corner = pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0); + let before = s.clone(); + let undo_len = h.state().engine.undo_history().len(); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { fade_in: side, .. }) if side == fade_in)); + let name = if fade_in { "fade-in" } else { "fade-out" }; + for (label, x) in [("left", xs - 60.0), ("right", xe + 60.0), ("inside", (xs + xe) / 2.0)] { + h.hover_at(pos2(x, corner.y)); + h.step(); + let s = h.state().engine.session(); + let range = fade_drag_range(&clip, fade_in, sample_at(s, tl, x)).unwrap(); + let (points, clip_rect) = h + .output() + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::LineSegment { points, stroke } if stroke.width == 2.0 && stroke.color == Color32::WHITE => { + Some((*points, shape.clip_rect)) + } + _ => None, + }) + .expect("fade preview missing"); + assert_eq!(points[0].x, x_of(s, tl, range.start)); + assert_eq!(points[1].x, x_of(s, tl, range.end)); + assert!(points.iter().all(|p| p.x >= xs && p.x <= xe && lane.contains(*p))); + assert!(clip_rect.min.x >= xs && clip_rect.max.x <= xe); + assert_eq!(h.state().engine.session(), &before, "drag must only preview"); + if !release_outside { + fade_shot(&mut h, &format!("{name}-{label}")); + } + } + // Releasing at a new point without an intervening move must use that point. + let release = pos2(if release_outside { if fade_in { xe + 60.0 } else { xs - 60.0 } } else { xs + (xe - xs) * 0.3 }, corner.y); + h.drop_at(release); + h.step(); + assert!(h.state().gesture.is_none()); + let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, release.x)).unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve }; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + if release_outside { + assert_eq!(fade.len, clip.length); + fade_shot(&mut h, &format!("{name}-committed")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &expected); + } + } + } + + #[test] + fn a_fade_drag_handles_its_clip_shrinking_to_one_sample() { + for fade_in in [true, false] { + 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 < tl.max.x - 20.0 && xe - xs > 100.0); + let s = h.state().engine.session(); + let corner = pos2(x_of(s, tl, if fade_in { clip.start } else { clip.end() }) + if fade_in { 6.0 } else { -6.0 }, lane.min.y + 4.0); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { .. }))); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.length = 1; + c.clamp_fades(); + let undo_len = h.state().engine.undo_history().len(); + h.drop_at(corner + vec2(40.0, 0.0)); + h.step(); + let c = h.state().engine.session().find_clip(clip.id).unwrap().1; + assert_eq!(if fade_in { c.fade_in.len } else { c.fade_out.len }, 1); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(h.state().gesture.is_none()); + } + } + + #[test] + fn a_fade_drag_cancels_if_its_clip_disappears() { + 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 { .. }))); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + h.state_mut().engine.session_mut().track_mut(tid).unwrap().playlist_mut().unwrap().clips.retain(|c| c.id != clip.id); + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(corner + vec2(40.0, 0.0)); + h.step(); + h.drop_at(corner + vec2(40.0, 0.0)); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + } + #[test] fn a_fade_drag_survives_its_clip_shrinking_to_nothing() { let mut h = smart_demo(); @@ -2336,9 +2512,14 @@ mod tests { if let Some(c) = h.state_mut().engine.session_mut().find_clip_mut(clip.id) { c.length = 0; } + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(corner + vec2(20.0, 0.0)); + h.step(); + assert!(h.state().gesture.is_none()); h.drop_at(corner + vec2(20.0, 0.0)); h.step(); assert!(h.state().gesture.is_none()); + assert_eq!(h.state().engine.undo_history().len(), undo_len); } #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80ff25b..9fff7ce 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first interaction checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Smart-tool fade drags stay within their clip) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -16,6 +16,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Feature | Status | Notes | |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | +| Smart-tool Fade In / Fade Out drag bounds | ✅ | Preview stops at the clip boundary; release applies the same length, including a full-clip fade, to only the dragged side | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -64,4 +65,5 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Bounded Smart-tool fade previews and commits; full-clip fades target only the dragged clip and side. Readiness estimate unchanged | | 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 | From db23f873fd2549e728aa9763dc94d4448d65ca22 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:27:49 +0800 Subject: [PATCH 098/154] Match fade drag previews to the committed curve --- crates/ui-egui/src/edit_window.rs | 133 ++++++++++++++++++++---------- docs/ui-parity.md | 5 +- 2 files changed, 94 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index e75e5d4..3bd269b 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1287,28 +1287,34 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col); painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); } - // Fades. - let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); - if clip.fade_in.len > 0 { - let fx = x_of(s, tl, clip.start + clip.fade_in.len); - let pts: Vec = (0..=16) - .map(|i| { - let k = i as f32 / 16.0; - pos2(x0 + (fx - x0) * k, body_r.max.y - body_r.height() * clip.fade_in.shape.gain(k)) - }) - .collect(); - painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); - painter.line_segment([pos2(x0, body_r.min.y), pos2(fx, body_r.min.y)], Stroke::new(1.0, fade_col)); + // Preview replaces the dragged fade in the same renderer used after release. + let (mut fade_in, mut fade_out) = (clip.fade_in, clip.fade_out); + if !dim + && let Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) = &app.gesture + && *id == clip.id + && let Some(range) = fade_drag_range(clip, *side, *to) + { + let fade = soundcraft_model::Fade { len: range.end.saturating_sub(range.start), shape: soundcraft_model::FadeShape::SCurve }; + if *side { + fade_in = fade; + } else { + fade_out = fade; + } } - if clip.fade_out.len > 0 { - let fx = x_of(s, tl, clip.end() - clip.fade_out.len); + let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); + for (side, fade) in [(true, fade_in), (false, fade_out)] { + if fade.len <= 0 { + continue; + } + let (a, b) = + if side { (x0, x_of(s, tl, clip.start.saturating_add(fade.len))) } else { (x_of(s, tl, clip.end().saturating_sub(fade.len)), x1) }; let pts: Vec = (0..=16) .map(|i| { let k = i as f32 / 16.0; - pos2(fx + (x1 - fx) * k, body_r.max.y - body_r.height() * clip.fade_out.shape.gain(1.0 - k)) + pos2(a + (b - a) * k, body_r.max.y - body_r.height() * fade.shape.gain(if side { k } else { 1.0 - k })) }) .collect(); - painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); + painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); } // Optional clip overlays (View › Clip). let ov = Color32::from_rgb(16, 16, 16); @@ -2018,19 +2024,6 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { let x = x_of(s, tl, *to); painter.line_segment([pos2(x, r[1]), pos2(x, r[3])], Stroke::new(2.0, Color32::WHITE)); } - if let Some(Gesture::Fade { clip, fade_in, to, .. }) = &app.gesture - && let Some((tid, c)) = s.find_clip(*clip) - && let Some(track) = s.track(tid) - && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) - && let Some(range) = fade_drag_range(c, *fade_in, *to) - { - let lane = lane_rect(track, rect(*r), tl); - let clip_rect = Rect::from_min_max(pos2(x_of(s, tl, c.start), lane.min.y + 1.0), pos2(x_of(s, tl, c.end()), lane.max.y - 1.0)); - let (x0, x1) = (x_of(s, tl, range.start), x_of(s, tl, range.end)); - let (y0, y1) = (clip_rect.min.y + 1.0, clip_rect.max.y - 1.0); - let line = if *fade_in { [pos2(x0, y1), pos2(x1, y0)] } else { [pos2(x0, y0), pos2(x1, y1)] }; - painter.with_clip_rect(clip_rect.intersect(tl)).line_segment(line, Stroke::new(2.0, Color32::WHITE)); - } if let Some(at) = scroll_to { let _ = app.engine.execute("view.scroll", &json!({"to": at})); } @@ -2391,6 +2384,22 @@ mod tests { } } + fn fade_curves(h: &Harness, clip_rect: Rect) -> Vec<(egui::epaint::PathShape, Rect)> { + h.output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Path(path) + if path.stroke == egui::epaint::PathStroke::new(1.2, Color32::from_rgba_unmultiplied(255, 255, 255, 90)) + && path.points.iter().all(|point| clip_rect.contains(*point)) => + { + Some((path.clone(), shape.clip_rect)) + } + _ => None, + }) + .collect() + } + #[test] fn fade_drag_previews_and_commits_stay_inside_the_clip() { for fade_in in [true, false] { @@ -2411,19 +2420,12 @@ mod tests { h.step(); let s = h.state().engine.session(); let range = fade_drag_range(&clip, fade_in, sample_at(s, tl, x)).unwrap(); - let (points, clip_rect) = h - .output() - .shapes - .iter() - .find_map(|shape| match &shape.shape { - Shape::LineSegment { points, stroke } if stroke.width == 2.0 && stroke.color == Color32::WHITE => { - Some((*points, shape.clip_rect)) - } - _ => None, - }) - .expect("fade preview missing"); - assert_eq!(points[0].x, x_of(s, tl, range.start)); - assert_eq!(points[1].x, x_of(s, tl, range.end)); + let curves = fade_curves(&h, Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y))); + assert_eq!(curves.len(), 1, "only one fade curve should be painted"); + let (curve, clip_rect) = &curves[0]; + let points = &curve.points; + assert_eq!(points.first().unwrap().x, x_of(s, tl, range.start)); + assert_eq!(points.last().unwrap().x, x_of(s, tl, range.end)); assert!(points.iter().all(|p| p.x >= xs && p.x <= xe && lane.contains(*p))); assert!(clip_rect.min.x >= xs && clip_rect.max.x <= xe); assert_eq!(h.state().engine.session(), &before, "drag must only preview"); @@ -2459,6 +2461,53 @@ mod tests { } } + #[test] + fn a_fade_drag_replaces_the_old_curve_and_matches_the_released_curve() { + for fade_in in [true, false] { + for show_name in [true, false] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_in = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear }; + c.fade_out = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::EqualPower }; + h.step(); + let s = h.state().engine.session(); + let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); + let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)); + let original = fade_curves(&h, clip_rect); + assert_eq!(original.len(), 2); + let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; + let before = s.clone(); + let corner = pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0); + start_drag(&mut h, corner); + let to = pos2(if fade_in { xs + (xe - xs) * 0.65 } else { xs + (xe - xs) * 0.35 }, corner.y); + h.hover_at(to); + h.step(); + let preview = fade_curves(&h, clip_rect); + assert_eq!(preview.len(), 2, "one curve per side, without the old curve underneath"); + assert_ne!(preview[edited], original[edited]); + assert_eq!(preview[opposite], original[opposite]); + assert_eq!(h.state().engine.session(), &before); + assert!(!h.output().shapes.iter().any(|shape| { + matches!(&shape.shape, Shape::LineSegment { points, stroke } if stroke.width == 2.0 && stroke.color == Color32::WHITE && points.iter().all(|p| clip_rect.contains(*p))) + }), "the straight preview must be gone"); + let side = if fade_in { "in" } else { "out" }; + if show_name { + fade_shot(&mut h, &format!("fade-{side}-curve-dragging")); + } + h.drop_at(to); + h.step(); + assert_eq!(fade_curves(&h, clip_rect), preview, "curve geometry, thickness and colour must be identical after release"); + if show_name { + fade_shot(&mut h, &format!("fade-{side}-curve-released")); + } + } + } + } + #[test] fn a_fade_drag_handles_its_clip_shrinking_to_one_sample() { for fade_in in [true, false] { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9fff7ce..4841827 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Smart-tool fade drags stay within their clip) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (fade drag previews match the released curve) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -16,7 +16,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Feature | Status | Notes | |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | -| Smart-tool Fade In / Fade Out drag bounds | ✅ | Preview stops at the clip boundary; release applies the same length, including a full-clip fade, to only the dragged side | +| Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -65,5 +65,6 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Fade drags use the same thin S-curve renderer as committed fades, replacing the dragged side's old curve. Readiness estimate unchanged | | 2026-10-13 | minor | Bounded Smart-tool fade previews and commits; full-clip fades target only the dragged clip and side. Readiness estimate unchanged | | 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 | From d5e6fa32efe6e8fc7b1797ca03739985072ed9c5 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:40:05 +0800 Subject: [PATCH 099/154] Enable dragging existing fades from their curve endpoints --- crates/ui-egui/src/edit_window.rs | 175 +++++++++++++++++++++++++++--- docs/ui-parity.md | 5 +- 2 files changed, 162 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 3bd269b..3f4247a 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1146,6 +1146,10 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { } } +fn clip_name_height(s: &Session, lane: Rect) -> f32 { + if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 } +} + 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); @@ -1163,7 +1167,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let body = if s.edit.flag("view.clip.transparency") { body.gamma_multiply(0.55) } else { body }; painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); - let name_h = if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 }; + let name_h = clip_name_height(s, lane); if name_h > 0.0 { let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h)); painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar }); @@ -1439,6 +1443,26 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { track.clips().iter().rev().find(|c| c.range().contains(at)) } +/// The unity-gain end of each existing fade, at the top of the clip body. +fn fade_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Option { + if clip.length <= 0 || clip.end() <= clip.start { + return None; + } + let y = lane.min.y + 1.0 + clip_name_height(s, lane); + [(true, clip.fade_in), (false, clip.fade_out)] + .into_iter() + .filter(|(_, fade)| fade.len > 0) + .filter_map(|(fade_in, fade)| { + let len = fade.len.min(clip.length); + let at = if fade_in { clip.start.saturating_add(len).min(clip.end()) } else { clip.end().saturating_sub(len).max(clip.start) }; + let head = pos2(x_of(s, tl, at), y); + let delta = p - head; + (lane.contains(head) && delta.x.abs() <= 8.0 && delta.y.abs() <= 8.0).then_some((fade_in, delta.length_sq())) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(fade_in, _)| fade_in) +} + pub const CLIP_FADER_TRAVEL: f32 = 200.0; /// Bottom-left of an audio clip's visible part, when Clip Gain Info is on or the clip has gain and it fits. @@ -1499,7 +1523,21 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let at = sample_at(&s, tl, p.x).max(0); let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; let at_origin = sample_at(&s, tl, origin.x).max(0); - let hit = clip_at(track, at_origin).cloned(); + let auto_view = AutoParam::parse(&track.view); + let body_hit = clip_at(track, at_origin); + // Check fade endpoints before clip bodies: a full-length fade can end exactly + // at the next clip's start, where `clip_at` would otherwise choose that neighbour. + let fade_handle = if tool == Tool::Smart && auto_view.is_none() { + track + .clips() + .iter() + .rev() + .filter(|c| body_hit.is_none_or(|front| front.id == c.id || front.start == c.end())) + .find_map(|c| fade_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + } else { + None + }; + let hit = fade_handle.map(|(c, _)| c).or(body_hit).cloned(); let upper = origin.y < lane.center().y; let near_edge = |c: &Clip| -> Option { let xs = x_of(&s, tl, c.start); @@ -1540,21 +1578,22 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, }; ui.ctx().set_cursor_icon(icon); } - let auto_view = AutoParam::parse(&track.view); let fade_corner = if tool == Tool::Smart { - hit.as_ref().and_then(|c| { - let xs = x_of(&s, tl, c.start); - let xe = x_of(&s, tl, c.end()); - let top = origin.y < lane.min.y + lane.height() * 0.28; - if !top { - None - } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { - Some(true) - } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { - Some(false) - } else { - None - } + fade_handle.map(|(_, side)| side).or_else(|| { + hit.as_ref().and_then(|c| { + let xs = x_of(&s, tl, c.start); + let xe = x_of(&s, tl, c.end()); + let top = origin.y < lane.min.y + lane.height() * 0.28; + if !top { + None + } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { + Some(true) + } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { + Some(false) + } else { + None + } + }) }) } else { None @@ -2027,6 +2066,9 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { if let Some(at) = scroll_to { let _ = app.engine.execute("view.scroll", &json!({"to": at})); } + if matches!(app.gesture, Some(Gesture::Fade { .. })) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } } #[cfg(test)] @@ -2508,6 +2550,107 @@ mod tests { } } + #[test] + fn existing_fades_drag_from_their_upper_curve_endpoints() { + for fade_in in [true, false] { + for show_name in [true, false] { + for full_length in [false, true] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let len = if full_length { clip.length } else { clip.length * 2 / 3 }; + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len, shape: soundcraft_model::FadeShape::SCurve }; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + h.step(); + let s = h.state().engine.session(); + let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); + let curves = fade_curves(&h, Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y))); + assert_eq!(curves.len(), 1); + let curve = &curves[0].0; + // Take the handle from the painted curve, rather than duplicating hit geometry. + let head = *if fade_in { curve.points.last() } else { curve.points.first() }.unwrap(); + let press = head + vec2(3.0, 2.0); + if fade_in && full_length { + let (tid, _) = s.find_clip(clip.id).unwrap(); + assert_ne!( + clip_at(s.track(tid).unwrap(), sample_at(s, tl, press.x)).unwrap().id, + clip.id, + "the pointer should be over the adjacent clip" + ); + } + let before = s.clone(); + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(press); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + let side = if fade_in { "in" } else { "out" }; + if show_name && !full_length { + fade_shot(&mut h, &format!("fade-{side}-endpoint-hover")); + } + h.drag_at(press); + let to = head + vec2(if fade_in { -35.0 } else { 35.0 }, 0.0); + h.hover_at(to); + h.step(); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { clip: id, fade_in: side, .. }) if id == clip.id && side == fade_in)); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + assert_eq!(h.state().engine.session(), &before, "pressing the handle must not move the edit selection"); + if show_name && !full_length { + fade_shot(&mut h, &format!("fade-{side}-endpoint-dragging")); + } + h.drop_at(to); + h.step(); + let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, to.x)).unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve }; + assert!(fade.len < len); + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + } + } + } + } + + #[test] + fn existing_fade_handles_preserve_selector_and_other_smart_tool_zones() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_out = soundcraft_model::Fade { len: c.length / 2, shape: soundcraft_model::FadeShape::SCurve }; + h.step(); + let s = h.state().engine.session(); + let rect = Rect::from_min_max(pos2(x_of(s, tl, clip.start), lane.min.y), pos2(x_of(s, tl, clip.end()), lane.max.y)); + let curves = fade_curves(&h, rect); + let head = *curves[0].0.points.first().unwrap(); + h.hover_at(head + vec2(0.0, 16.0)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Text); + h.state_mut().engine.execute("edit.tool", &json!({"tool": "selector"})).unwrap(); + h.step(); + h.hover_at(head); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Text); + start_drag(&mut h, head); + assert!(matches!(h.state().gesture, Some(Gesture::Select { .. }))); + h.drop_at(head + vec2(20.0, 0.0)); + h.step(); + } + #[test] fn a_fade_drag_handles_its_clip_shrinking_to_one_sample() { for fade_in in [true, false] { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4841827..3e8549c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (fade drag previews match the released curve) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (existing fades can be dragged from their upper endpoints) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -16,7 +16,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Feature | Status | Notes | |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | -| Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades | +| Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades. Hovering the curve's upper endpoint shows the fade cursor and lets the existing fade be dragged | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -65,6 +65,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Existing fades have draggable upper endpoints with matching hover/drag cursors, including at adjacent clip boundaries. Readiness estimate unchanged | | 2026-10-13 | minor | Fade drags use the same thin S-curve renderer as committed fades, replacing the dragged side's old curve. Readiness estimate unchanged | | 2026-10-13 | minor | Bounded Smart-tool fade previews and commits; full-clip fades target only the dragged clip and side. Readiness estimate unchanged | | 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 | From 4ac47b3a7fc6765eb828f3eb12d30427be986194 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:08:51 +0800 Subject: [PATCH 100/154] Add draggable midpoint curvature for clip fades --- crates/engine/src/cmd/edit.rs | 56 ++++++- crates/engine/src/demo.rs | 2 +- crates/engine/src/edit.rs | 8 +- crates/mix/src/lib.rs | 30 +++- crates/model/src/clip.rs | 79 +++++++++- crates/model/src/session.rs | 2 + crates/ui-egui/src/edit_window.rs | 245 ++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + docs/file-format-parity.md | 5 +- docs/ui-parity.md | 4 +- 10 files changed, 400 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b7532ca..3dbde46 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Create...", ["Edit", "Fades"], Some("Cmd+F"), - "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?}", + "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?, curvature?: -1..1}", has_range, fades_create ), @@ -675,6 +675,13 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result { .map_or(Some(FadeShape::EqualPower), FadeShape::from_id) .ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?; if p.get("clip").is_some() || p.get("side").is_some() { + let curvature = match p.get("curvature") { + None => 0.0, + Some(value) => value + .as_f64() + .filter(|v| v.is_finite() && (-1.0..=1.0).contains(v)) + .ok_or_else(|| bad("edit.fades_create", "curvature must be a finite number from -1 to 1"))? as f32, + }; if ["track", "tracks", "clips", "start", "end"].iter().any(|key| p.get(key).is_some()) { return Err(bad("edit.fades_create", "clip fades cannot also specify tracks or an edit range")); } @@ -697,7 +704,7 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result { if p.get("shape").is_some_and(|v| !v.is_string()) { return Err(bad("edit.fades_create", "shape must be a fade shape name")); } - let fade = Fade { len: length.min(available), shape }; + let fade = Fade { len: length.min(available), shape, curvature }; let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?; if fade_in { clip.fade_in = fade; @@ -706,6 +713,9 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result { } return Ok(json!({"fades": 1})); } + if p.get("curvature").is_some() { + return Err(bad("edit.fades_create", "curvature requires a clip and side")); + } let tracks = tracks_required(e, "edit.fades_create", p)?; let r = range_param(e, "edit.fades_create", p)?; let s = e.session_mut(); @@ -802,6 +812,40 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn targeted_fade_curvature_saves_reopens_and_undoes_without_changing_lengths() { + for side in ["in", "out"] { + let mut e = crate::demo::demo_engine(); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve"})).unwrap(); + let before = e.session().clone(); + let undo_len = e.undo_history().len(); + e.execute( + "edit.fades_create", + &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve", "curvature": -0.65}), + ) + .unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + if side == "in" { + c.fade_in.curvature = -0.65; + } else { + c.fade_out.curvature = -0.65; + } + assert_eq!(e.session(), &expected); + let reopened = Session::from_json(&e.session().to_json().unwrap()).unwrap(); + let reopened = reopened.find_clip(clip.id).unwrap().1; + let stored = expected.find_clip(clip.id).unwrap().1; + assert_eq!(reopened.fade_in, stored.fade_in); + assert_eq!(reopened.fade_out, stored.fade_out); + assert_eq!(e.undo_history().len(), undo_len + 1); + assert!(e.undo()); + assert_eq!(e.session(), &before); + assert!(e.redo()); + assert_eq!(e.session(), &expected); + } + } + #[test] fn targeted_fades_fill_only_the_requested_side_and_undo_once() { for side in ["in", "out"] { @@ -816,7 +860,7 @@ mod tests { ); let mut expected = before.clone(); let c = expected.find_clip_mut(clip.id).unwrap(); - let fade = Fade { len: clip.length, shape: FadeShape::SCurve }; + let fade = Fade { len: clip.length, shape: FadeShape::SCurve, curvature: 0.0 }; if side == "in" { c.fade_in = fade; } else { @@ -852,6 +896,12 @@ mod tests { json!({"clip": id, "side": "in", "length": 10, "start": 0}), json!({"clip": id, "side": "in", "length": 10, "end": 100}), json!({"clip": id, "side": "in", "length": 10, "tracks": []}), + json!({"clip": id, "side": "in", "length": 10, "curvature": 2}), + json!({"clip": id, "side": "in", "length": 10, "curvature": -2}), + json!({"clip": id, "side": "in", "length": 10, "curvature": 1e100}), + json!({"clip": id, "side": "in", "length": 10, "curvature": null}), + json!({"clip": id, "side": "in", "length": 10, "curvature": "0.5"}), + json!({"start": 0, "end": 100, "curvature": 0.5}), ] { let before = e.session().clone(); let history = e.undo_history(); diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 1e7daa0..bdefafa 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -228,7 +228,7 @@ pub fn demo_session() -> Session { let len = total as i64 - start; let cid = s.new_clip_id(); let mut clip = Clip::audio(cid, name, src, start, start, len - (srf * 1.5) as i64); - clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve }; + clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve, curvature: 0.0 }; crate::edit::place_clip(s, t, clip); t }; diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 05cc0e6..b8bce21 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -348,12 +348,12 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh let ext_b = before.min(b_handle).max(0); if let Some(ca) = pl.clips.get_mut(i) { ca.length += ext_a; - ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape }; + ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape, curvature: 0.0 }; } if let Some(cb) = pl.clips.get_mut(i + 1) { let old = cb.start; cb.trim_start_to(old - ext_b); - cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape }; + cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape, curvature: 0.0 }; } made += 1; xfaded.push(a.id); @@ -362,10 +362,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh } for c in pl.clips.iter_mut().filter(|c| !xfaded.contains(&c.id)) { if range.start <= c.start && range.end > c.start && range.end < c.end() { - c.fade_in = Fade { len: range.end - c.start, shape }; + c.fade_in = Fade { len: range.end - c.start, shape, curvature: 0.0 }; made += 1; } else if range.end >= c.end() && range.start > c.start && range.start < c.end() { - c.fade_out = Fade { len: c.end() - range.start, shape }; + c.fade_out = Fade { len: c.end() - range.start, shape, curvature: 0.0 }; made += 1; } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..18995b8 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -2063,12 +2063,40 @@ mod tests { assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4); } + #[test] + fn fade_curvature_reaches_the_rendered_audio() { + for fade_in in [true, false] { + for curvature in [-0.5, 0.0, 0.5] { + let (mut s, t) = session_with_dc(1.0, 8192); + s.track_mut(t).unwrap().mixer.pan = vec![-1.0]; + let fade = soundcraft_model::Fade { len: 8192, shape: soundcraft_model::FadeShape::SCurve, curvature }; + let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0]; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + let out = render_range(&s, Range::new(0, 8192), 512); + let expected = if curvature < 0.0 { + 0.2 + } else if curvature > 0.0 { + 0.8 + } else { + 0.5 + }; + assert!((out[0][4096] - expected).abs() < 1e-5, "{fade_in}, {curvature}: {} vs {expected}", out[0][4096]); + assert!(out[1][4096].abs() < 1e-6); + assert!(out[0][if fade_in { 0 } else { 8191 }].abs() < 1e-4); + } + } + } + #[test] fn fades_and_clip_gain_apply() { let (mut s, t) = session_with_dc(1.0, 48_000); { let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0]; - c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear }; + c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear, curvature: 0.0 }; c.gain_db = -6.0; } let out = render_range(&s, Range::new(0, 2000), 512); diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 4d30bea..054cb66 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -47,10 +47,37 @@ impl FadeShape { } /// A fade at a clip boundary, `len` samples long. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)] pub struct Fade { pub len: Samples, pub shape: FadeShape, + /// Continuous bend (-1..1); zero preserves the original shape exactly. + #[serde(default)] + pub curvature: f32, +} + +impl Fade { + /// The envelope used by both rendering and audio, with fixed zero/unity endpoints. + pub fn gain(self, t: f32) -> f32 { + let base = self.shape.gain(t); + let curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + if curvature == 0.0 || base <= 0.0 || base >= 1.0 { + return base; + } + let weighted = base * (curvature * 4.0).exp2(); + weighted / (weighted + 1.0 - base) + } + + /// Invert the bend at the midpoint so a vertical drag follows the pointer. + pub fn curvature_for_midpoint_gain(self, gain: f32) -> f32 { + let gain = if gain.is_finite() { gain.clamp(0.001, 0.999) } else { self.gain(0.5) }; + let base = self.shape.gain(0.5); + (((gain / (1.0 - gain)).log2() - (base / (1.0 - base)).log2()) / 4.0).clamp(-1.0, 1.0) + } + + pub fn sanitize(&mut self) { + self.curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + } } /// What a clip plays. @@ -195,11 +222,11 @@ impl Clip { } let mut g = db_to_gain(db); if self.fade_in.len > 0 && rel < self.fade_in.len { - g *= self.fade_in.shape.gain(rel as f32 / self.fade_in.len as f32); + g *= self.fade_in.gain(rel as f32 / self.fade_in.len as f32); } let from_end = self.length - rel; if self.fade_out.len > 0 && from_end <= self.fade_out.len { - g *= self.fade_out.shape.gain(from_end as f32 / self.fade_out.len as f32); + g *= self.fade_out.gain(from_end as f32 / self.fade_out.len as f32); } g } @@ -264,11 +291,51 @@ mod tests { } } + #[test] + fn fade_curvature_is_bounded_monotonic_and_inverts_at_the_midpoint() { + for shape in FadeShape::ALL { + let original = Fade { len: 1000, shape, curvature: 0.0 }; + for i in 0..=128 { + let t = i as f32 / 128.0; + assert_eq!(original.gain(t), shape.gain(t), "legacy curves must be unchanged"); + } + for curvature in [-1.0, -0.5, 0.0, 0.5, 1.0] { + let fade = Fade { curvature, ..original }; + assert_eq!(fade.gain(0.0), 0.0); + assert_eq!(fade.gain(1.0), 1.0); + let mut previous = 0.0; + for i in 0..=128 { + let gain = fade.gain(i as f32 / 128.0); + assert!(gain.is_finite() && (0.0..=1.0).contains(&gain)); + assert!(gain >= previous, "{shape:?}, {curvature}"); + previous = gain; + } + assert!((fade.curvature_for_midpoint_gain(fade.gain(0.5)) - curvature).abs() < 1e-5); + } + assert!((Fade { curvature: 0.5, ..original }).gain(0.5) > original.gain(0.5)); + assert!((Fade { curvature: -0.5, ..original }).gain(0.5) < original.gain(0.5)); + } + } + + #[test] + fn fade_curvature_loads_legacy_data_and_sanitizes_hostile_values() { + let fade: Fade = serde_json::from_str(r#"{"len":100,"shape":"SCurve"}"#).unwrap(); + assert_eq!(fade.curvature, 0.0); + for curvature in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -10.0, 10.0] { + let mut fade = Fade { curvature, ..fade }; + assert!(fade.gain(0.5).is_finite()); + fade.sanitize(); + assert!(fade.curvature.is_finite() && (-1.0..=1.0).contains(&fade.curvature)); + let roundtrip: Fade = serde_json::from_str(&serde_json::to_string(&fade).unwrap()).unwrap(); + assert_eq!(roundtrip, fade); + } + } + #[test] fn gain_with_fades() { let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000); - c.fade_in = Fade { len: 100, shape: FadeShape::Linear }; - c.fade_out = Fade { len: 100, shape: FadeShape::Linear }; + c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; assert!(c.gain_at(0).abs() < 1e-6); assert!((c.gain_at(50) - 0.5).abs() < 1e-6); assert!((c.gain_at(500) - 1.0).abs() < 1e-6); @@ -293,7 +360,7 @@ mod tests { assert_eq!(c.length, 1); // Regression: trimming the start must not leave a fade-out longer than the clip. let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 1000); - c.fade_out = Fade { len: 800, shape: FadeShape::Linear }; + c.fade_out = Fade { len: 800, shape: FadeShape::Linear, curvature: 0.0 }; c.trim_start_to(900); assert!(c.fade_out.len <= c.length); } diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 5956796..51240be 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -541,6 +541,8 @@ impl Session { for c in &mut p.clips { c.fade_in.len = c.fade_in.len.clamp(0, c.length); c.fade_out.len = c.fade_out.len.clamp(0, c.length); + c.fade_in.sanitize(); + c.fade_out.sanitize(); if !c.gain_db.is_finite() { c.gain_db = 0.0; } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 3f4247a..474d32c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1296,15 +1296,24 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track if !dim && let Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) = &app.gesture && *id == clip.id - && let Some(range) = fade_drag_range(clip, *side, *to) + && let Some(fade) = fade_drag_preview(clip, *side, *to) { - let fade = soundcraft_model::Fade { len: range.end.saturating_sub(range.start), shape: soundcraft_model::FadeShape::SCurve }; if *side { fade_in = fade; } else { fade_out = fade; } } + if !dim + && let Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) = &app.gesture + && *id == clip.id + { + if *side { + fade_in.curvature = *curvature; + } else { + fade_out.curvature = *curvature; + } + } let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); for (side, fade) in [(true, fade_in), (false, fade_out)] { if fade.len <= 0 { @@ -1315,10 +1324,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let pts: Vec = (0..=16) .map(|i| { let k = i as f32 / 16.0; - pos2(a + (b - a) * k, body_r.max.y - body_r.height() * fade.shape.gain(if side { k } else { 1.0 - k })) + pos2(a + (b - a) * k, body_r.max.y - body_r.height() * fade.gain(if side { k } else { 1.0 - k })) }) .collect(); painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); + let midpoint = pos2((a + b) * 0.5, body_r.max.y - body_r.height() * fade.gain(0.5)); + let active = matches!(app.gesture, Some(Gesture::FadeCurve { clip: id, fade_in, .. }) if id == clip.id && fade_in == side); + let hovered = painter.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|p| near_fade_midpoint(p, midpoint)); + if !dim && s.edit.tool == Tool::Smart && AutoParam::parse(&track.view).is_none() && b - a >= 32.0 && (active || hovered) { + painter.with_clip_rect(vis).circle_filled(midpoint, 3.0, fade_col); + } } // Optional clip overlays (View › Clip). let ov = Color32::from_rgb(16, 16, 16); @@ -1463,6 +1478,32 @@ fn fade_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Op .map(|(fade_in, _)| fade_in) } +fn near_fade_midpoint(p: Pos2, midpoint: Pos2) -> bool { + let delta = p - midpoint; + delta.x.abs() <= 8.0 && delta.y.abs() <= 8.0 +} + +fn fade_midpoint(s: &Session, tl: Rect, lane: Rect, clip: &Clip, fade_in: bool) -> Option { + let fade = if fade_in { clip.fade_in } else { clip.fade_out }; + if fade.len <= 0 || clip.length <= 0 { + return None; + } + let len = fade.len.min(clip.length); + let (a, b) = if fade_in { (clip.start, clip.start.saturating_add(len)) } else { (clip.end().saturating_sub(len), clip.end()) }; + let (a, b) = (x_of(s, tl, a), x_of(s, tl, b)); + if b - a < 32.0 { + return None; + } + let bottom = lane.max.y - 1.0; + let height = lane.height() - 2.0 - clip_name_height(s, lane); + let p = pos2((a + b) * 0.5, bottom - height * fade.gain(0.5)); + lane.contains(p).then_some(p) +} + +fn fade_curve_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Option { + [true, false].into_iter().find(|side| fade_midpoint(s, tl, lane, clip, *side).is_some_and(|midpoint| near_fade_midpoint(p, midpoint))) +} + pub const CLIP_FADER_TRAVEL: f32 = 200.0; /// Bottom-left of an audio clip's visible part, when Clip Gain Info is on or the clip has gain and it fits. @@ -1537,7 +1578,12 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } else { None }; - let hit = fade_handle.map(|(c, _)| c).or(body_hit).cloned(); + let curve_handle = if tool == Tool::Smart && auto_view.is_none() && fade_handle.is_none() { + body_hit.and_then(|c| fade_curve_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + } else { + None + }; + let hit = fade_handle.or(curve_handle).map(|(c, _)| c).or(body_hit).cloned(); let upper = origin.y < lane.center().y; let near_edge = |c: &Clip| -> Option { let xs = x_of(&s, tl, c.start); @@ -1601,6 +1647,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if fade_corner.is_some() && resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); } + if curve_handle.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } let gain_icon_at = |q: Pos2| { let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| auto_view.is_none())?; clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) @@ -1608,7 +1657,7 @@ 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 selector_press = eff == Tool::Selector && fade_corner.is_none() && gain_icon_at(origin).is_none(); + let selector_press = eff == Tool::Selector && fade_corner.is_none() && curve_handle.is_none() && gain_icon_at(origin).is_none(); if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { place_insertion(app, ui, &s, track.id, at, mods.shift); } @@ -1624,6 +1673,14 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, 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: (origin.y, pos), fine, fader_bottom }); + } else if resp.drag_started() + && let Some((c, fade_in)) = curve_handle + { + let fade = if fade_in { c.fade_in } else { c.fade_out }; + let anchor = (origin.y, fade.gain(0.5)); + let height = (lane.height() - 2.0 - clip_name_height(&s, lane)).max(1.0); + app.gesture = Some(Gesture::FadeCurve { clip: c.id, fade_in, curvature: fade_curve_drag_value(fade, anchor, height, p.y), anchor, height }); + ui.ctx().request_repaint(); } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { @@ -1668,7 +1725,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } - if resp.clicked() { + if resp.clicked() && curve_handle.is_none() { match eff { Tool::Grabber => { if let Some(c) = &hit { @@ -1696,6 +1753,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } } if resp.double_clicked() + && curve_handle.is_none() && eff != Tool::Pencil && let Some(c) = &hit { @@ -1735,6 +1793,20 @@ fn fade_drag_range(clip: &Clip, fade_in: bool, to: Samples) -> Option { }) } +fn fade_drag_preview(clip: &Clip, fade_in: bool, to: Samples) -> Option { + let range = fade_drag_range(clip, fade_in, to)?; + let original = if fade_in { clip.fade_in } else { clip.fade_out }; + Some(soundcraft_model::Fade { + len: range.end.saturating_sub(range.start), + shape: if original.len > 0 { original.shape } else { soundcraft_model::FadeShape::SCurve }, + curvature: if original.len > 0 { original.curvature } else { 0.0 }, + }) +} + +fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32, f32), height: f32, y: f32) -> f32 { + fade.curvature_for_midpoint_gain(anchor.1 + (anchor.0 - y) / height.max(1.0)) +} + /// 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 { .. })) { @@ -1783,6 +1855,18 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { invalid_fade = true; } } + Some(Gesture::FadeCurve { clip, fade_in, curvature, anchor, height }) => { + if let Some((_, c)) = s.find_clip(*clip).filter(|(_, c)| c.length > 0) { + let fade = if *fade_in { c.fade_in } else { c.fade_out }; + if fade.len > 0 { + *curvature = fade_curve_drag_value(fade, *anchor, *height, p.y); + } else { + invalid_fade = true; + } + } else { + invalid_fade = true; + } + } Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1844,13 +1928,25 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { } Some(Gesture::Fade { clip, fade_in, to, .. }) => { let to = p.map_or(to, |p| sample_at(s, tl, p.x).max(0)); - if let Some(range) = s.find_clip(clip).and_then(|(_, c)| fade_drag_range(c, fade_in, to)) { + if let Some(fade) = s.find_clip(clip).and_then(|(_, c)| fade_drag_preview(c, fade_in, to)) { let _ = app.run( "edit.fades_create", - json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": range.end.saturating_sub(range.start), "shape": "s-curve"}), + json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": fade.len, "shape": fade.shape.label(), "curvature": fade.curvature}), ); } } + Some(Gesture::FadeCurve { clip, fade_in, curvature, anchor, height }) => { + if let Some((_, c)) = s.find_clip(clip).filter(|(_, c)| c.length > 0) { + let fade = if fade_in { c.fade_in } else { c.fade_out }; + let curvature = p.map_or(curvature, |p| fade_curve_drag_value(fade, anchor, height, p.y)); + if fade.len > 0 && (fade.curvature - curvature).abs() > 1e-6 { + let _ = app.run( + "edit.fades_create", + json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": fade.len, "shape": fade.shape.label(), "curvature": curvature}), + ); + } + } + } 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 }) => { @@ -2068,6 +2164,8 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } if matches!(app.gesture, Some(Gesture::Fade { .. })) { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } else if matches!(app.gesture, Some(Gesture::FadeCurve { .. })) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } } @@ -2483,7 +2581,7 @@ mod tests { let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, release.x)).unwrap(); let mut expected = before.clone(); let c = expected.find_clip_mut(clip.id).unwrap(); - let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve }; + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; if fade_in { c.fade_in = fade; } else { @@ -2513,8 +2611,8 @@ mod tests { let tl = timeline(&h); let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - c.fade_in = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear }; - c.fade_out = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::EqualPower }; + c.fade_in = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.0 }; + c.fade_out = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::EqualPower, curvature: 0.0 }; h.step(); let s = h.state().engine.session(); let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); @@ -2550,6 +2648,125 @@ mod tests { } } + #[test] + fn fade_midpoints_drag_vertically_without_moving_the_endpoints() { + for fade_in in [true, false] { + for show_name in [true, false] { + for dy in [-16.0, 16.0] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let target = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: -0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let clip_rect = + Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); + let original = fade_curves(&h, clip_rect); + let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; + let midpoint = original[edited].0.points[8]; + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(midpoint); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + click(&mut h, midpoint); + assert_eq!(h.state().engine.session(), &before, "clicking the curve control should not change the selection"); + h.drag_at(midpoint); + let to = midpoint + vec2(0.0, dy); + h.hover_at(to); + h.step(); + h.step(); // Paint the frame requested when the drag gesture starts. + let curvature = match h.state().gesture { + Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if id == clip.id && side == fade_in => curvature, + _ => panic!("the midpoint must start a curve gesture"), + }; + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + assert!(if dy < 0.0 { curvature > 0.0 } else { curvature < 0.0 }); + let preview = fade_curves(&h, clip_rect); + assert_eq!(preview.len(), 2); + assert_eq!(preview[opposite], original[opposite]); + assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); + assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); + assert_eq!(preview[edited].0.points[8].x, midpoint.x); + assert!( + (preview[edited].0.points[8].y - (midpoint.y + dy)).abs() < 0.001, + "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", + preview[edited].0.points[8], + h.state().gesture + ); + assert_eq!(h.state().engine.session(), &before); + let side = if fade_in { "in" } else { "out" }; + let direction = if dy < 0.0 { "up" } else { "down" }; + if show_name { + fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-preview")); + } + h.drop_at(to); + h.step(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + if fade_in { + c.fade_in.curvature = curvature; + } else { + c.fade_out.curvature = curvature; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(fade_curves(&h, clip_rect), preview, "the released envelope must match the preview"); + let rel = if fade_in { target.len / 2 } else { clip.length - target.len / 2 }; + let previous_gain = before.find_clip(clip.id).unwrap().1.gain_at(rel); + let new_gain = expected.find_clip(clip.id).unwrap().1.gain_at(rel); + assert!(if dy < 0.0 { new_gain > previous_gain } else { new_gain < previous_gain }); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + if show_name { + fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &expected); + } + } + } + } + + #[test] + fn fade_curve_drags_cancel_safely_or_return_to_the_original_curve() { + for invalidate in [0, 1, 2] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_out = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + h.step(); + let midpoint = + fade_midpoint(h.state().engine.session(), tl, lane, h.state().engine.session().find_clip(clip.id).unwrap().1, false).unwrap(); + let before = h.state().engine.session().clone(); + let undo_len = h.state().engine.undo_history().len(); + start_drag(&mut h, midpoint); + h.hover_at(midpoint - vec2(0.0, 16.0)); + h.step(); + assert!(matches!(h.state().gesture, Some(Gesture::FadeCurve { .. }))); + if invalidate == 1 { + h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap().length = 0; + } else if invalidate == 2 { + let (tid, _) = before.find_clip(clip.id).unwrap(); + h.state_mut().engine.session_mut().track_mut(tid).unwrap().playlist_mut().unwrap().clips.retain(|c| c.id != clip.id); + } + h.hover_at(midpoint); + h.step(); + h.drop_at(midpoint); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + if invalidate == 0 { + assert_eq!(h.state().engine.session(), &before); + } + } + } + #[test] fn existing_fades_drag_from_their_upper_curve_endpoints() { for fade_in in [true, false] { @@ -2562,7 +2779,7 @@ mod tests { let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); let len = if full_length { clip.length } else { clip.length * 2 / 3 }; let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - let fade = soundcraft_model::Fade { len, shape: soundcraft_model::FadeShape::SCurve }; + let fade = soundcraft_model::Fade { len, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.35 }; if fade_in { c.fade_in = fade; } else { @@ -2609,7 +2826,7 @@ mod tests { let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, to.x)).unwrap(); let mut expected = before.clone(); let c = expected.find_clip_mut(clip.id).unwrap(); - let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve }; + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.35 }; assert!(fade.len < len); if fade_in { c.fade_in = fade; @@ -2631,7 +2848,7 @@ mod tests { let tl = timeline(&h); let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - c.fade_out = soundcraft_model::Fade { len: c.length / 2, shape: soundcraft_model::FadeShape::SCurve }; + c.fade_out = soundcraft_model::Fade { len: c.length / 2, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; h.step(); let s = h.state().engine.session(); let rect = Rect::from_min_max(pos2(x_of(s, tl, clip.start), lane.min.y), pos2(x_of(s, tl, clip.end()), lane.max.y)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..9a63a53 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -209,6 +209,7 @@ pub enum Gesture { TrimStart { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, TrimEnd { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples }, + FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 }, Scrub { last: Samples }, Fader { track: TrackId, start_db: f32 }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5149502..0b13427 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first format inventory against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its @@ -23,7 +23,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | OMF | import/export | ❌ | ❌ | Legacy, still delivered by some editors | | Media Composer–compatible session | export | ❌ | ❌ | AAF variant | | ADM BWF (Dolby Atmos master) | import/export | ❌ | ❌ | G-12 | -| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded | +| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded. Fades save optional `curvature` (-1..1); missing values default to 0 and preserve legacy envelopes | | Import Session Data (from another session) | Pro Tools sessions | 🟡 | — | From `.scraft` only | | Session info as text | export | — | ✅ | `file.export_session_text` | | Clip groups `.rgrp` / ours `.scgrp` | read/write | 🟡 | 🟡 | Our own `.scgrp` format; Pro Tools' clip-group files not read | @@ -80,4 +80,5 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged | | 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3e8549c..5ca585f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (existing fades can be dragged from their upper endpoints) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (fade midpoints adjust continuous curvature) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -17,6 +17,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | | Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades. Hovering the curve's upper endpoint shows the fade cursor and lets the existing fade be dragged | +| Fade midpoint curvature | ✅ | Hovering the middle of a visible fade shows a small handle and vertical resize cursor. Dragging up/down bends the envelope without changing its length; preview, playback and bounce share the curve. Length drags retain the chosen curvature | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -65,6 +66,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Continuous fade curvature from a midpoint drag, shared UI/audio envelope, one-step undo and saved session data. Readiness estimate unchanged | | 2026-10-13 | minor | Existing fades have draggable upper endpoints with matching hover/drag cursors, including at adjacent clip boundaries. Readiness estimate unchanged | | 2026-10-13 | minor | Fade drags use the same thin S-curve renderer as committed fades, replacing the dragged side's old curve. Readiness estimate unchanged | | 2026-10-13 | minor | Bounded Smart-tool fade previews and commits; full-clip fades target only the dragged clip and side. Readiness estimate unchanged | From 79e30c39d8e4974e53007009b0ca60cf13262a8e Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:07:13 +0800 Subject: [PATCH 101/154] Smooth fade curves and retain draggable midpoint controls --- crates/ui-egui/src/edit_window.rs | 190 +++++++++++++++++++++++++++--- docs/ui-parity.md | 5 +- 2 files changed, 177 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 474d32c..8fd5f65 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1150,6 +1150,28 @@ fn clip_name_height(s: &Session, lane: Rect) -> f32 { if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 } } +fn fade_curve_points(fade: soundcraft_model::Fade, fade_in: bool, a: f32, b: f32, body: Rect, pixels_per_point: f32) -> Vec { + let scale = if pixels_per_point.is_finite() { pixels_per_point.clamp(0.5, 4.0) } else { 1.0 }; + let extent = (b - a).abs().max(body.height().abs()) * scale; + let extent = if extent.is_finite() { extent.clamp(64.0, 4096.0) } else { 64.0 }; + // Physical-pixel sampling avoids visible corners, even for steep bends. + // Keep an exact midpoint and cap allocations for extreme zoom/clip sizes. + let segments = (extent.ceil() as usize).div_ceil(2) * 2; + (0..=segments) + .map(|i| { + let k = i as f32 / segments as f32; + pos2(a + (b - a) * k, body.max.y - body.height() * fade.gain(if fade_in { k } else { 1.0 - k })) + }) + .collect() +} + +fn fade_control_position(fade: soundcraft_model::Fade, a: f32, b: f32, body: Rect) -> Pos2 { + let padding = body.height().clamp(0.0, 8.0) * 0.5; + let y = body.max.y - body.height() * fade.gain(0.5); + // Keep the whole handle inside the body when a bend approaches an edge. + pos2((a + b) * 0.5, y.max(body.min.y + padding).min(body.max.y - padding)) +} + 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); @@ -1321,18 +1343,14 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let (a, b) = if side { (x0, x_of(s, tl, clip.start.saturating_add(fade.len))) } else { (x_of(s, tl, clip.end().saturating_sub(fade.len)), x1) }; - let pts: Vec = (0..=16) - .map(|i| { - let k = i as f32 / 16.0; - pos2(a + (b - a) * k, body_r.max.y - body_r.height() * fade.gain(if side { k } else { 1.0 - k })) - }) - .collect(); + let pts = fade_curve_points(fade, side, a, b, body_r, painter.ctx().pixels_per_point()); painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); - let midpoint = pos2((a + b) * 0.5, body_r.max.y - body_r.height() * fade.gain(0.5)); + let midpoint = fade_control_position(fade, a, b, body_r); let active = matches!(app.gesture, Some(Gesture::FadeCurve { clip: id, fade_in, .. }) if id == clip.id && fade_in == side); let hovered = painter.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|p| near_fade_midpoint(p, midpoint)); - if !dim && s.edit.tool == Tool::Smart && AutoParam::parse(&track.view).is_none() && b - a >= 32.0 && (active || hovered) { - painter.with_clip_rect(vis).circle_filled(midpoint, 3.0, fade_col); + if !dim && s.edit.tool == Tool::Smart && AutoParam::parse(&track.view).is_none() && b - a >= 32.0 && vis.contains(midpoint) { + let col = Color32::from_rgba_unmultiplied(255, 255, 255, if active || hovered { 230 } else { 200 }); + painter.with_clip_rect(vis).circle(midpoint, 3.5, body, Stroke::new(1.0, col)); } } // Optional clip overlays (View › Clip). @@ -1494,9 +1512,8 @@ fn fade_midpoint(s: &Session, tl: Rect, lane: Rect, clip: &Clip, fade_in: bool) if b - a < 32.0 { return None; } - let bottom = lane.max.y - 1.0; - let height = lane.height() - 2.0 - clip_name_height(s, lane); - let p = pos2((a + b) * 0.5, bottom - height * fade.gain(0.5)); + let body = Rect::from_min_max(pos2(a, lane.min.y + 1.0 + clip_name_height(s, lane)), pos2(b, lane.max.y - 1.0)); + let p = fade_control_position(fade, a, b, body); lane.contains(p).then_some(p) } @@ -2648,6 +2665,146 @@ mod tests { } } + #[test] + fn fade_curve_sampling_keeps_steep_bends_below_a_quarter_pixel_error() { + for (width, height) in [(420.0, 180.0), (75.0, 320.0), (10_000.0, 80.0)] { + let body = Rect::from_min_size(Pos2::ZERO, vec2(width, height)); + for scale in [1.0, 2.0, 4.0] { + for shape in soundcraft_model::FadeShape::ALL { + for curvature in [-1.0, 1.0] { + for fade_in in [true, false] { + let fade = soundcraft_model::Fade { len: 1000, shape, curvature }; + let points = fade_curve_points(fade, fade_in, 0.0, width, body, scale); + assert!(points.len() > 17 && points.len() <= 4097); + assert_eq!((points.len() - 1) % 2, 0); + let segments = (points.len() - 1) as f32; + for (i, pair) in points.windows(2).enumerate() { + let line = pair[1] - pair[0]; + for fraction in [0.25, 0.5, 0.75] { + let t = (i as f32 + fraction) / segments; + let actual = pos2(width * t, height - height * fade.gain(if fade_in { t } else { 1.0 - t })); + let along = ((actual - pair[0]).dot(line) / line.length_sq()).clamp(0.0, 1.0); + let distance = actual.distance(pair[0] + line * along) * scale; + assert!( + distance < 0.25, + "{width}x{height}, scale={scale}, {shape:?}, curve={curvature}, side={fade_in}, error={distance}" + ); + } + } + } + } + } + } + } + let body = Rect::from_min_size(Pos2::ZERO, vec2(1e20, 1e20)); + for scale in [f32::NAN, f32::INFINITY, 1.0] { + let points = fade_curve_points(soundcraft_model::Fade::default(), true, 0.0, 1e20, body, scale); + assert!(points.len() <= 4097); + } + } + + fn painted_fade_control(h: &Harness, clip_rect: Rect) -> Pos2 { + let controls: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Circle(circle) if circle.radius == 3.5 && clip_rect.contains(circle.center) => { + let radius = circle.radius + circle.stroke.width * 0.5; + for offset in [vec2(radius, 0.0), vec2(-radius, 0.0), vec2(0.0, radius), vec2(0.0, -radius)] { + assert!(shape.clip_rect.contains(circle.center + offset), "the complete control must remain visible"); + } + Some(circle.center) + } + _ => None, + }) + .collect(); + assert_eq!(controls.len(), 1, "the midpoint must stay visible without hover or a held mouse button"); + controls[0] + } + + #[test] + fn fade_midpoint_handles_remain_visible_and_can_be_grabbed_again() { + for fade_in in [true, false] { + for show_name in [true, false] { + for shape in [soundcraft_model::FadeShape::SCurve, soundcraft_model::FadeShape::Exponential] { + for height in ["micro", "jumbo"] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, _) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + let spp = h.state().engine.session().edit.zoom.samples_per_px * 0.5; + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.state_mut().engine.execute("track.height", &json!({"tracks": [tid.0], "height": height})).unwrap(); + h.state_mut().engine.execute("view.zoom_set", &json!({"samples_per_px": spp})).unwrap(); + h.state_mut().engine.execute("view.scroll", &json!({"to": clip.start - clip.length / 5})).unwrap(); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len: c.length * 2 / 3, shape, curvature: 0.0 }; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + h.step(); + let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == tid.0).unwrap().1; + let lane = lane_rect(h.state().engine.session().track(tid).unwrap(), rect(row), tl); + let s = h.state().engine.session(); + let clip_rect = Rect::from_min_max(pos2(x_of(s, tl, clip.start), lane.min.y), pos2(x_of(s, tl, clip.end()), lane.max.y)); + let control = painted_fade_control(&h, clip_rect); + let undo_len = h.state().engine.undo_history().len(); + start_drag(&mut h, control); + let outside = control + vec2(0.0, 500.0); + h.hover_at(outside); + h.step(); + h.drop_at(outside); + h.step(); + let c = h.state().engine.session().find_clip(clip.id).unwrap().1; + assert_eq!(if fade_in { c.fade_in.curvature } else { c.fade_out.curvature }, -1.0); + assert!(h.state().gesture.is_none()); + painted_fade_control(&h, clip_rect); + let side = if fade_in { "in" } else { "out" }; + let screenshot = show_name && height == "jumbo" && shape == soundcraft_model::FadeShape::SCurve; + if screenshot { + fade_shot(&mut h, &format!("fade-{side}-smooth-released")); + } + for iteration in 0..2 { + let before = h.state().engine.session().clone(); + let control = painted_fade_control(&h, clip_rect); + h.hover_at(control); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + start_drag(&mut h, control); + let to = control - vec2(0.0, (lane.height() - 2.0 - clip_name_height(&before, lane)) * 0.1); + h.hover_at(to); + h.step(); + assert!( + matches!(h.state().gesture, Some(Gesture::FadeCurve { clip: id, fade_in: side, .. }) if id == clip.id && side == fade_in) + ); + h.drop_at(to); + h.step(); + let c = h.state().engine.session().find_clip(clip.id).unwrap().1; + let current = if fade_in { c.fade_in } else { c.fade_out }; + let previous = before.find_clip(clip.id).unwrap().1; + let previous = if fade_in { previous.fade_in } else { previous.fade_out }; + assert!(current.curvature > previous.curvature); + assert_eq!((current.len, current.shape), (previous.len, previous.shape)); + painted_fade_control(&h, clip_rect); + assert_eq!(h.state().engine.undo_history().len(), undo_len + iteration + 2); + if screenshot && iteration == 1 { + fade_shot(&mut h, &format!("fade-{side}-smooth-regrabbed")); + } + let after = h.state().engine.session().clone(); + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &after); + } + } + } + } + } + } + #[test] fn fade_midpoints_drag_vertically_without_moving_the_endpoints() { for fade_in in [true, false] { @@ -2668,7 +2825,8 @@ mod tests { Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); let original = fade_curves(&h, clip_rect); let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; - let midpoint = original[edited].0.points[8]; + let middle = original[edited].0.points.len() / 2; + let midpoint = original[edited].0.points[middle]; let undo_len = h.state().engine.undo_history().len(); h.hover_at(midpoint); h.step(); @@ -2691,11 +2849,11 @@ mod tests { assert_eq!(preview[opposite], original[opposite]); assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); - assert_eq!(preview[edited].0.points[8].x, midpoint.x); + assert_eq!(preview[edited].0.points[middle].x, midpoint.x); assert!( - (preview[edited].0.points[8].y - (midpoint.y + dy)).abs() < 0.001, + (preview[edited].0.points[middle].y - (midpoint.y + dy)).abs() < 0.001, "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", - preview[edited].0.points[8], + preview[edited].0.points[middle], h.state().gesture ); assert_eq!(h.state().engine.session(), &before); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5ca585f..2effb36 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (fade midpoints adjust continuous curvature) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (smooth fade curves and persistent midpoint controls) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -17,7 +17,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | | Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades. Hovering the curve's upper endpoint shows the fade cursor and lets the existing fade be dragged | -| Fade midpoint curvature | ✅ | Hovering the middle of a visible fade shows a small handle and vertical resize cursor. Dragging up/down bends the envelope without changing its length; preview, playback and bounce share the curve. Length drags retain the chosen curvature | +| Fade midpoint curvature | ✅ | Visible fades retain a small midpoint handle in Smart mode, including after release and extreme bends. Hover shows the vertical resize cursor; repeated drags change curvature without changing length. Curves sample at physical-pixel resolution; preview, playback and bounce share the envelope. Length drags retain curvature | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -66,6 +66,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Fade curves use bounded physical-pixel sampling; midpoint handles stay visible and inside the clip, with repeat-drag coverage after extreme bends. Readiness estimate unchanged | | 2026-10-13 | minor | Continuous fade curvature from a midpoint drag, shared UI/audio envelope, one-step undo and saved session data. Readiness estimate unchanged | | 2026-10-13 | minor | Existing fades have draggable upper endpoints with matching hover/drag cursors, including at adjacent clip boundaries. Readiness estimate unchanged | | 2026-10-13 | minor | Fade drags use the same thin S-curve renderer as committed fades, replacing the dragged side's old curve. Readiness estimate unchanged | From 5ec8c21b19ee55a2628e0102bfeda9350cbb90a9 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:43:47 +0800 Subject: [PATCH 102/154] Preview waveform amplitudes during fade drags --- crates/model/src/clip.rs | 49 +++++++- crates/ui-egui/src/edit_window.rs | 193 ++++++++++++++++++++++++++---- docs/ui-parity.md | 4 +- 3 files changed, 215 insertions(+), 31 deletions(-) diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 054cb66..de3fc11 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -216,17 +216,23 @@ impl Clip { /// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades. pub fn gain_at(&self, rel: Samples) -> f32 { + self.gain_at_with_fades(rel, self.fade_in, self.fade_out) + } + + /// Evaluate temporary fades while retaining clip gain and its envelope. + /// UI previews can use this without modifying the clip or rebuilding media. + pub fn gain_at_with_fades(&self, rel: Samples, fade_in: Fade, fade_out: Fade) -> f32 { let mut db = self.gain_db; if !self.gain_env.is_empty() { db += env_value(&self.gain_env, rel); } let mut g = db_to_gain(db); - if self.fade_in.len > 0 && rel < self.fade_in.len { - g *= self.fade_in.gain(rel as f32 / self.fade_in.len as f32); + if fade_in.len > 0 && rel < fade_in.len { + g *= fade_in.gain(rel as f32 / fade_in.len as f32); } - let from_end = self.length - rel; - if self.fade_out.len > 0 && from_end <= self.fade_out.len { - g *= self.fade_out.gain(from_end as f32 / self.fade_out.len as f32); + let from_end = self.length.saturating_sub(rel); + if fade_out.len > 0 && from_end <= fade_out.len { + g *= fade_out.gain(from_end as f32 / fade_out.len as f32); } g } @@ -264,7 +270,11 @@ fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { for &(t, v) in env { if t >= at { return match prev { - Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + Some((pt, pv)) if t > pt => { + let elapsed = i128::from(at).saturating_sub(i128::from(pt)); + let span = i128::from(t).saturating_sub(i128::from(pt)); + pv + (v - pv) * (elapsed as f32 / span as f32) + } _ => v, }; } @@ -331,6 +341,33 @@ mod tests { } } + #[test] + fn temporary_fades_keep_clip_gain_and_envelope_without_mutating_the_clip() { + let mut clip = Clip::audio(ClipId(1), "preview", SourceId(1), 0, 0, 1000); + clip.gain_db = -6.0; + clip.gain_env = vec![(0, 0.0), (1000, 6.0)]; + clip.fade_in = Fade { len: 100, shape: FadeShape::SCurve, curvature: 0.25 }; + clip.fade_out = Fade { len: 100, shape: FadeShape::Logarithmic, curvature: -0.1 }; + let before = clip.clone(); + let fade_in = Fade { len: 1000, shape: FadeShape::Linear, curvature: 0.5 }; + let fade_out = Fade { len: 1000, shape: FadeShape::Linear, curvature: -0.5 }; + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + let mut committed = clip.clone(); + committed.fade_in = fade_in; + committed.fade_out = fade_out; + for rel in [0, 50, 500, 999, 1000] { + assert_eq!(clip.gain_at_with_fades(rel, fade_in, fade_out), committed.gain_at(rel)); + } + for rel in [i64::MIN, i64::MAX] { + assert!(clip.gain_at(rel).is_finite()); + assert!(clip.gain_at_with_fades(rel, fade_in, fade_out).is_finite()); + } + assert_eq!(clip, before); + clip.gain_env = vec![(i64::MIN, 0.0), (i64::MAX, 6.0)]; + assert!((clip.gain_at(500) - db_to_gain(-3.0)).abs() < 1e-6); + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + } + #[test] fn gain_with_fades() { let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 8fd5f65..4df4b8c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1172,6 +1172,30 @@ fn fade_control_position(fade: soundcraft_model::Fade, a: f32, b: f32, body: Rec pos2((a + b) * 0.5, y.max(body.min.y + padding).min(body.max.y - padding)) } +fn clip_preview_fades(clip: &Clip, gesture: Option<&Gesture>) -> (soundcraft_model::Fade, soundcraft_model::Fade) { + let (mut fade_in, mut fade_out) = (clip.fade_in, clip.fade_out); + match gesture { + Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) if *id == clip.id => { + if let Some(fade) = fade_drag_preview(clip, *side, *to) { + if *side { + fade_in = fade; + } else { + fade_out = fade; + } + } + } + Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if *id == clip.id => { + if *side { + fade_in.curvature = *curvature; + } else { + fade_out.curvature = *curvature; + } + } + _ => {} + } + (fade_in, fade_out) +} + 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); @@ -1204,6 +1228,8 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max); let vis = body_r.intersect(lane); + // The waveform and fade curve share the same temporary envelope every frame. + let (fade_in, fade_out) = clip_preview_fades(clip, app.gesture.as_ref().filter(|_| !dim)); match &clip.content { ClipContent::Audio { source, offset } => { if let Some(audio) = s.pool.get(*source) { @@ -1226,7 +1252,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track for (i, (mn, mx)) in colsv.iter().enumerate() { let x = vis.min.x + i as f32; let rel = sample_at(s, tl, x) - clip.start; - let g = clip.gain_at(rel.clamp(0, clip.length)); + let g = clip.gain_at_with_fades(rel.clamp(0, clip.length), fade_in, fade_out); let (mut top, mut bot) = if rectified { let a = (mx.abs().max(mn.abs()) * g * zoom).clamp(0.0, 1.0); let b = cy + half; @@ -1313,29 +1339,6 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col); painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); } - // Preview replaces the dragged fade in the same renderer used after release. - let (mut fade_in, mut fade_out) = (clip.fade_in, clip.fade_out); - if !dim - && let Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) = &app.gesture - && *id == clip.id - && let Some(fade) = fade_drag_preview(clip, *side, *to) - { - if *side { - fade_in = fade; - } else { - fade_out = fade; - } - } - if !dim - && let Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) = &app.gesture - && *id == clip.id - { - if *side { - fade_in.curvature = *curvature; - } else { - fade_out.curvature = *curvature; - } - } let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); for (side, fade) in [(true, fade_in), (false, fade_out)] { if fade.len <= 0 { @@ -1707,6 +1710,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, fade_in, to: if fade_in { range.end } else { range.start }, }); + ui.ctx().request_repaint(); } else if resp.drag_started() { app.gesture = match eff { Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at_origin), tracks: vec![track.id] }), @@ -2557,6 +2561,147 @@ mod tests { .collect() } + fn waveform_geometry(h: &Harness, clip_rect: Rect) -> Vec> { + h.output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Mesh(mesh) if !mesh.vertices.is_empty() && mesh.vertices.iter().all(|vertex| clip_rect.contains(vertex.pos)) => { + Some(mesh.vertices.iter().map(|vertex| vertex.pos).collect()) + } + _ => None, + }) + .collect() + } + + #[test] + fn waveforms_follow_fade_drags_before_release_and_match_the_committed_waveform() { + for channels in [1, 2] { + for fade_in in [true, false] { + for bend in [false, true] { + for mode in ["peak", "power", "rectified", "outlines"] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + let neighbour = h + .state() + .engine + .session() + .track(tid) + .unwrap() + .clips() + .iter() + .find(|c| c.id != clip.id && c.start >= clip.end()) + .unwrap() + .clone(); + let source = clip.source().unwrap(); + if channels == 2 { + let audio = h.state().engine.session().pool.get(source).unwrap().clone(); + let left = audio.buffer.channels[0].clone(); + let right = left.iter().map(|sample| sample * 0.5).collect(); + let audio = soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { + sample_rate: audio.buffer.sample_rate, + channels: vec![left, right], + }); + let s = h.state_mut().engine.session_mut(); + s.pool.insert(source, std::sync::Arc::new(audio)); + s.track_mut(tid).unwrap().format = soundcraft_model::ChannelFormat::Stereo; + } + if mode == "power" { + h.state_mut().engine.execute("view.waveform_power", &json!({})).unwrap(); + } + h.state_mut().engine.execute("view.waveform_rectified", &json!({"value": mode == "rectified"})).unwrap(); + h.state_mut().engine.execute("view.waveform_outlines", &json!({"value": mode == "outlines"})).unwrap(); + h.state_mut().engine.execute("view.waveform_zoom", &json!({"value": 1.4})).unwrap(); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.gain_db = -3.0; + c.gain_env = vec![(0, -3.0), (c.length, 3.0)]; + let target = soundcraft_model::Fade { + len: if bend { c.length * 2 / 3 } else { c.length / 4 }, + shape: soundcraft_model::FadeShape::SCurve, + curvature: 0.0, + }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let undo_len = h.state().engine.undo_history().len(); + let audio = before.pool.get(source).unwrap().clone(); + let (xs, xe) = (x_of(&before, tl, clip.start), x_of(&before, tl, clip.end())); + let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)).intersect(tl); + let neighbour_rect = Rect::from_min_max( + pos2(x_of(&before, tl, neighbour.start), lane.min.y), + pos2(x_of(&before, tl, neighbour.end()), lane.max.y), + ) + .intersect(tl); + let original = waveform_geometry(&h, clip_rect); + let neighbour_wave = waveform_geometry(&h, neighbour_rect); + assert_eq!(original.len(), channels); + let side = if fade_in { "in" } else { "out" }; + let operation = if bend { "bend" } else { "length" }; + let screenshot = channels == 1 && mode == "peak"; + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-before")); + } + let (press, to) = if bend { + let c = before.find_clip(clip.id).unwrap().1; + let midpoint = fade_midpoint(&before, tl, lane, c, fade_in).unwrap(); + (midpoint, midpoint - vec2(0.0, 16.0)) + } else { + ( + pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0), + pos2(xs + (xe - xs) * if fade_in { 0.7 } else { 0.3 }, lane.min.y + 4.0), + ) + }; + h.hover_at(press); + h.drag_at(press); + h.hover_at(to); + h.step(); + h.step(); + assert!(if bend { + matches!(h.state().gesture, Some(Gesture::FadeCurve { .. })) + } else { + matches!(h.state().gesture, Some(Gesture::Fade { .. })) + }); + let preview = waveform_geometry(&h, clip_rect); + assert_eq!(preview.len(), channels); + for channel in 0..channels { + assert_ne!( + preview[channel], original[channel], + "the waveform must respond while the button is held: {side}, {operation}, {mode}, channel={channel}" + ); + } + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.session(), &before); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + assert!( + std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio), + "preview must retain cached media and peaks" + ); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-preview")); + } + h.drop_at(to); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(waveform_geometry(&h, clip_rect), preview, "release must not cause a waveform jump"); + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio)); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + h.step(); + assert_eq!(waveform_geometry(&h, clip_rect), original); + } + } + } + } + } + #[test] fn fade_drag_previews_and_commits_stay_inside_the_clip() { for fade_in in [true, false] { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2effb36..4d6bb09 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (smooth fade curves and persistent midpoint controls) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (waveforms preview fades during drag) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -18,6 +18,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | | Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades. Hovering the curve's upper endpoint shows the fade cursor and lets the existing fade be dragged | | Fade midpoint curvature | ✅ | Visible fades retain a small midpoint handle in Smart mode, including after release and extreme bends. Hover shows the vertical resize cursor; repeated drags change curvature without changing length. Curves sample at physical-pixel resolution; preview, playback and bounce share the envelope. Length drags retain curvature | +| Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -66,6 +67,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged | | 2026-10-13 | minor | Fade curves use bounded physical-pixel sampling; midpoint handles stay visible and inside the clip, with repeat-drag coverage after extreme bends. Readiness estimate unchanged | | 2026-10-13 | minor | Continuous fade curvature from a midpoint drag, shared UI/audio envelope, one-step undo and saved session data. Readiness estimate unchanged | | 2026-10-13 | minor | Existing fades have draggable upper endpoints with matching hover/drag cursors, including at adjacent clip boundaries. Readiness estimate unchanged | From 312315acb911c149cb59d2992db9b9829661afbc Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:48:26 +0100 Subject: [PATCH 103/154] Type to search as soon as a plugin or bus menu opens Finding a plugin for an insert took three submenus (format, vendor, name). The send and I/O bus menus had a search field, but it had to be clicked before typing, and it kept the last search. - The send and I/O bus menus take typing as soon as they open, as Pro Tools' path selectors do. The search starts empty each time; Up/Down move through the matches, Enter picks one, Escape closes the menu. - Insert and instrument menus start with the same search field. Typing lists the plugins whose name or vendor matches: built-in ones first, then Audio Units and whichever of CLAP and VST3 have been scanned, with a Scan button for each of the others. Typing never starts a scan (`cached()` in the CLAP and VST3 hosts returns the last scan without scanning); opening a format's submenu still scans it. - A click inside the insert and instrument menus no longer closes them, so the field can be clicked; picking still does, and errors from Bypass and "no insert" now reach the status line. The send bus menu is no longer 600 points wide. - Insert, send, instrument and I/O slots in the Mix window get accessibility labels, which the tests use. --- crates/clap-host/src/lib.rs | 11 ++ crates/clap-host/src/registry.rs | 4 + crates/ui-egui/src/edit_window.rs | 4 +- crates/ui-egui/src/mix_window.rs | 230 ++++++++++++++++++++++------ crates/ui-egui/tests/menu_search.rs | 171 +++++++++++++++++++++ crates/vst3-host/src/lib.rs | 11 ++ crates/vst3-host/src/registry.rs | 4 + 7 files changed, 385 insertions(+), 50 deletions(-) create mode 100644 crates/ui-egui/tests/menu_search.rs diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index 2740f49..15da961 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -88,6 +88,9 @@ pub enum ClapError { mod api { use super::*; + pub fn cached() -> Option> { + registry::cached() + } pub fn scan() -> Vec { registry::scan() } @@ -115,6 +118,9 @@ mod api { mod api { use super::*; + pub fn cached() -> Option> { + Some(Vec::new()) + } pub fn scan() -> Vec { Vec::new() } @@ -161,6 +167,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec { api::scan_paths(dirs) } +/// The plugins of the last scan, without scanning: `None` until something has scanned. +pub fn cached() -> Option> { + api::cached() +} + /// The plugins in one `.clap` file or bundle. pub fn load_bundle(path: &Path) -> Result, ClapError> { api::load_bundle(path) diff --git a/crates/clap-host/src/registry.rs b/crates/clap-host/src/registry.rs index 32eedce..89169e5 100644 --- a/crates/clap-host/src/registry.rs +++ b/crates/clap-host/src/registry.rs @@ -83,6 +83,10 @@ pub fn scan() -> Vec { ensure_scanned(&mut r).clone() } +pub fn cached() -> Option> { + registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone() +} + pub fn rescan() -> Vec { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); r.scanned = None; diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..f42a34c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -893,7 +893,9 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele 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)); + egui::Popup::menu(&resp) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| crate::mix_window::instrument_menu(app, ui, id)); } } if h < 30.0 { diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..6d44188 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -223,9 +223,11 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Input")); 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); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Output")); 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)); @@ -423,6 +425,7 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { 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()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Instrument")); let fill = if info.is_some() { t.insert_on } else { t.slot_bg }; ui.painter().rect( r, @@ -438,6 +441,7 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { } let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() }; let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) }; + let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); popup.show(|ui| instrument_menu(app, ui, id)); } @@ -446,6 +450,7 @@ fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: R 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()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Insert {}", (b'A' + slot as u8) as char))); let fill = match &ins { Some(i) if i.bypass => t.insert_bypass, Some(_) => t.insert_on, @@ -469,16 +474,26 @@ fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: R } let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text("Click: open plugin · right-click: change") }; let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) }; + let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some())); } +/// An insert's menu: a search field that takes typing at once, then (until something is typed) +/// Bypass and removal and the plugins by category and format. pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, occupied: bool) { + let search = MenuSearch::show(ui, egui::Id::new(("plugin-search", id.0, slot)), "Search plugins…"); + if search.typing() { + if let Some(plugin) = plugin_search(ui, &search, false) { + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})); + } + return; + } if occupied { if ui.button("Bypass").clicked() { - let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot})); + run_pick(app, ui, "mix.insert_bypass", json!({"track": id.0, "slot": slot})); } if ui.button("no insert").clicked() { - let _ = app.run("mix.insert_remove", json!({"track": id.0, "slot": slot})); + run_pick(app, ui, "mix.insert_remove", json!({"track": id.0, "slot": slot})); } ui.separator(); } @@ -492,22 +507,28 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc ui.menu_button(format!("{c:?}"), |ui| { for p in soundcraft_dsp::plugins().iter().filter(|p| p.category == c && !p.is_instrument) { if ui.button(p.name).clicked() { - let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id})); + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id})); } } }); } - if let Some(plugin) = hosted_menus(ui, false) - && let Err(e) = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) - { - app.ui.status = e; + if let Some(plugin) = hosted_menus(ui, false) { + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})); } } -/// The instrument picker of an instrument track: its instrument's editor, then the built-in, -/// CLAP, VST3 and Audio Units instruments. +/// The instrument picker of an instrument track: a search field that takes typing at once, then +/// (until something is typed) its instrument's editor and the built-in, CLAP, VST3 and Audio Units +/// instruments. pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let slot = soundcraft_mix::INSTRUMENT_SLOT; + let search = MenuSearch::show(ui, egui::Id::new(("instrument-search", id.0)), "Search instruments…"); + if search.typing() { + if let Some(plugin) = plugin_search(ui, &search, true) { + run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin})); + } + return; + } let current = app.engine.session().track(id).and_then(|t| t.instrument.clone()); if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) { ui.label(egui::RichText::new(info.name).strong()); @@ -517,11 +538,13 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { if p.editor_open(id, slot) { if ui.button("Close Plugin Editor").clicked() { p.close_editor(id, slot); + ui.close(); } - } else if ui.button("Open Plugin Editor").clicked() - && let Err(e) = p.open_editor(id, slot) - { - app.ui.status = format!("{}: {e}", info.name); + } else if ui.button("Open Plugin Editor").clicked() { + if let Err(e) = p.open_editor(id, slot) { + app.ui.status = format!("{}: {e}", info.name); + } + ui.close(); } } ui.separator(); @@ -537,11 +560,136 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { if let Some(p) = hosted_menus(ui, true) { picked = Some(p); } - if let Some(plugin) = picked - && let Err(e) = app.run("mix.instrument", json!({"track": id.0, "plugin": plugin})) - { + if let Some(plugin) = picked { + run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin})); + } +} + +/// Runs a menu's pick, shows its error in the status line and closes the menu. +fn run_pick(app: &mut SoundApp, ui: &Ui, cmd: &str, params: serde_json::Value) { + if let Err(e) = app.run(cmd, params) { app.ui.status = e; } + ui.close(); +} + +/// The plugins whose name or vendor holds the search text: the built-in ones, then Audio Units +/// (listing them creates no instances) and whichever of CLAP and VST3 have been scanned, with a +/// button to scan each of the others. Returns the id picked. +fn plugin_search(ui: &mut Ui, search: &MenuSearch, instruments: bool) -> Option { + let mut built_in: Vec<&soundcraft_dsp::PluginInfo> = + soundcraft_dsp::plugins().iter().copied().filter(|p| p.is_instrument == instruments && search.matches(p.name)).collect(); + built_in.sort_by_cached_key(|p| p.name.to_lowercase()); + let (clap, vst3) = (soundcraft_clap_host::cached(), soundcraft_vst3_host::cached()); + let mut hosted: Vec<(MenuPlugin, &str)> = Vec::new(); + for d in clap.iter().flatten().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id.clone(), name: d.name.clone(), vendor: d.vendor.clone() }, "CLAP")); + } + for d in vst3.iter().flatten().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id.clone(), name: d.name.clone(), vendor: d.vendor.clone() }, "VST3")); + } + for d in soundcraft_au_host::scan().into_iter().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }, "AU")); + } + hosted.retain(|(p, _)| search.matches(&p.name) || search.matches(&p.vendor)); + hosted.sort_by_cached_key(|(p, format)| (p.name.to_lowercase(), *format)); + let n = built_in.len() + hosted.len(); + let picked = search.rows(ui, n, 320.0, |ui, i, highlighted| match built_in.get(i) { + Some(p) => menu_row(ui, highlighted, p.name), + None => { + let (p, format) = &hosted[i - built_in.len()]; + let label = if p.vendor.is_empty() { format!("{} ({format})", p.name) } else { format!("{} — {} ({format})", p.name, p.vendor) }; + menu_row(ui, highlighted, &label) + } + }); + if n == 0 { + ui.weak("No matches"); + } + if clap.is_none() || vst3.is_none() { + ui.separator(); + ui.horizontal(|ui| { + if clap.is_none() && ui.small_button("Scan CLAP").clicked() { + soundcraft_clap_host::scan(); + } + if vst3.is_none() && ui.small_button("Scan VST3").clicked() { + soundcraft_vst3_host::scan(); + } + }); + } + picked.map(|i| built_in.get(i).map_or_else(|| hosted[i - built_in.len()].0.id.clone(), |p| p.id.to_string())) +} + +/// A picker menu's search field. It has the keyboard as soon as the menu opens and starts empty +/// each time; Up/Down move the highlight through the matches and Enter picks it. +struct MenuSearch { + id: egui::Id, + query: String, + highlight: usize, + keys: [bool; 3], +} + +impl MenuSearch { + fn show(ui: &mut Ui, id: egui::Id, hint: &str) -> Self { + let pass = ui.ctx().cumulative_pass_nr(); + // Kept only while the menu stays open: one not shown last pass has just opened. + let kept = ui.data(|d| d.get_temp::<(String, usize, u64)>(id)).filter(|s| s.2 + 1 >= pass); + let opened = kept.is_none(); + let (mut text, mut highlight, _) = kept.unwrap_or_default(); + let field = ui.add(egui::TextEdit::singleline(&mut text).id(id.with("field")).hint_text(hint).return_key(None)); + if opened { + field.request_focus(); + } + if field.changed() { + highlight = 0; + } + // egui's focus, as typing uses: `has_focus` also wants the window focused, which scripted input lacks. + let focused = ui.memory(|m| m.has_focus(field.id)); + let keys = [egui::Key::ArrowUp, egui::Key::ArrowDown, egui::Key::Enter].map(|k| focused && ui.input(|i| i.key_pressed(k))); + ui.data_mut(|d| d.insert_temp(id, (text.clone(), highlight, pass))); + Self { id, query: text.trim().to_lowercase(), highlight, keys } + } + + fn typing(&self) -> bool { + !self.query.is_empty() + } + + fn matches(&self, text: &str) -> bool { + text.to_lowercase().contains(&self.query) + } + + /// `n` matches in a scrolling list, each drawn by `row` (told whether it is highlighted); + /// returns the one clicked, or the highlighted one on Enter. + fn rows(&self, ui: &mut Ui, n: usize, max_height: f32, mut row: impl FnMut(&mut Ui, usize, bool) -> bool) -> Option { + let [up, down, enter] = self.keys; + let typing = self.typing(); + let highlight = match (up, down) { + (_, true) => self.highlight + 1, + (true, _) => self.highlight.saturating_sub(1), + _ => self.highlight, + } + .min(n.saturating_sub(1)); + ui.data_mut(|d| d.get_temp_mut_or_default::<(String, usize, u64)>(self.id).1 = highlight); + let mut picked = (typing && enter && n > 0).then_some(highlight); + let row_h = ui.spacing().interact_size.y; + let step = row_h + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().id_salt(self.id.with("rows")).max_height(max_height).show_rows(ui, row_h, n, |ui, range| { + if typing && (up || down) { + let top = ui.max_rect().top() + (highlight as f32 - range.start as f32) * step; + ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(1.0, row_h)), None); + } + for i in range { + if row(ui, i, typing && i == highlight) { + picked = Some(i); + } + } + }); + picked + } +} + +fn menu_row(ui: &mut Ui, selected: bool, text: &str) -> bool { + let text = if selected { egui::RichText::new(text).color(Color32::WHITE) } else { egui::RichText::new(text) }; + ui.add(egui::Button::selectable(selected, text).min_size(vec2(0.0, ui.spacing().interact_size.y))).clicked() } /// A hosted plugin as the plugin menus list it. @@ -625,6 +773,7 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec 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()); + route_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {}", (b'A' + slot as u8) as char))); match &snd { Some(s) => { let name = route_name(app, &s.target); @@ -857,26 +1006,16 @@ fn send_level_scale(ui: &mut Ui, db: f32) { 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(); - } - } - }, - ); + let search = MenuSearch::show(ui, egui::Id::new(("send-bus-search", id.0, slot)), "Search buses…"); + let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect(); + let picked = search.rows(ui, visible.len(), 260.0, |ui, i, highlighted| { + let current = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == visible[i].id); + menu_row(ui, highlighted || (current && !search.typing()), &visible[i].name) + }); + if let Some(i) = picked { + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": visible[i].name})); + ui.close(); + } ui.separator(); if ui.button("new bus...").clicked() { let mut n = busses.len() + 1; @@ -911,19 +1050,12 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { ui.separator(); let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); 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(); - } - } - }); + let search = MenuSearch::show(ui, egui::Id::new(("route-bus-search", id.0, input)), "Search buses…"); + let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect(); + if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) { + let _ = app.run(cmd, json!({"track": id.0, key: visible[i]})); + ui.close(); + } 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() { diff --git a/crates/ui-egui/tests/menu_search.rs b/crates/ui-egui/tests/menu_search.rs new file mode 100644 index 0000000..0f2c7f5 --- /dev/null +++ b/crates/ui-egui/tests/menu_search.rs @@ -0,0 +1,171 @@ +//! Opening an insert, instrument, send or I/O menu gives its search field the keyboard: typing +//! filters at once, Up/Down move through the matches and Enter picks one. + +use egui::accesskit::Role; +use egui::{Key, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_model::Route; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness(engine: soundcraft_engine::Engine) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(engine, None, Services::default()); + app.ui.window = MainWindow::Mix; + let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn open(h: &mut Harness, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +fn type_text(h: &mut Harness, text: &str) { + h.event(egui::Event::Text(text.into())); + h.run_steps(2); +} + +fn press(h: &mut Harness, key: Key) { + h.key_press(key); + h.run_steps(2); +} + +/// Built-in plugins whose name holds `text`, as the search lists them. +fn built_in(instruments: bool, text: &str) -> Vec<&'static soundcraft_dsp::PluginInfo> { + let mut found: Vec<_> = + soundcraft_dsp::plugins().iter().copied().filter(|p| p.is_instrument == instruments && p.name.to_lowercase().contains(text)).collect(); + found.sort_by_key(|p| p.name.to_lowercase()); + found +} + +fn vox() -> soundcraft_engine::Engine { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"name": "Vox"})).unwrap(); + engine +} + +#[test] +fn an_insert_menu_searches_as_soon_as_it_opens() { + let reverbs = built_in(false, "reverb"); + assert!(reverbs.len() >= 2, "the test needs two built-in reverbs"); + let mut h = harness(vox()); + open(&mut h, "Insert A"); + type_text(&mut h, "reverb"); + assert!(h.query_by_label(reverbs[0].name).is_some(), "typing went to the search"); + assert!(h.query_by_label_contains("Dynamics").is_none(), "the matches replace the categories"); + + h.get_by_role(Role::TextInput).click(); + h.run_steps(3); + assert!(h.query_by_label(reverbs[0].name).is_some(), "a click in the field keeps the menu open"); + + press(&mut h, Key::ArrowUp); + press(&mut h, Key::ArrowDown); + press(&mut h, Key::Enter); + let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter inserts the highlighted plugin"); + assert_eq!(insert.plugin, reverbs[1].id); + assert!(h.query_by_label(reverbs[0].name).is_none(), "picking closes the menu"); +} + +#[test] +fn a_menu_opens_with_an_empty_search() { + let mut h = harness(vox()); + open(&mut h, "Insert B"); + type_text(&mut h, "reverb"); + press(&mut h, Key::Escape); + assert!(h.query_by_label(built_in(false, "reverb")[0].name).is_none(), "Escape closes the menu"); + + open(&mut h, "Insert B"); + let category = format!("{:?}", soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap().category); + assert!(h.query_by_label_contains(&category).is_some(), "the old search is gone, so the categories show"); + type_text(&mut h, "zzzz"); + assert!(h.query_by_label("No matches").is_some()); + press(&mut h, Key::Enter); + assert!(h.state().engine.session().tracks[0].mixer.inserts.iter().all(Option::is_none), "Enter without a match picks nothing"); +} + +#[test] +fn picks_without_the_search_still_close_the_menu() { + let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); + let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap(); + let category = format!("{:?} ⏵", first.category); + let mut h = harness(vox()); + for (plugin, click) in [(first, egui::PointerButton::Primary), (other, egui::PointerButton::Secondary)] { + h.get_by_label("Insert C").click_button(click); + h.run_steps(3); + h.get_by_label(&category).hover(); + h.run_steps(3); + h.get_by_label(plugin.name).click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().unwrap().plugin, plugin.id); + assert!(h.query_by_label(&category).is_none(), "a plugin from a category closes the menu"); + } + + for (item, bypassed) in [("Bypass", Some(true)), ("no insert", None)] { + h.get_by_label("Insert C").click_secondary(); + h.run_steps(3); + h.get_by_label(item).click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().map(|i| i.bypass), bypassed); + assert!(h.query_by_label(item).is_none(), "{item} closes the menu"); + } +} + +#[test] +fn an_instrument_menu_searches_as_soon_as_it_opens() { + let drums = built_in(true, "drum"); + assert!(!drums.is_empty(), "the test needs a built-in drum instrument"); + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"kind": "instrument", "name": "Keys"})).unwrap(); + let mut h = harness(engine); + h.state_mut().ui.mix_views.push("instruments".into()); + h.run_steps(2); + open(&mut h, "Instrument"); + type_text(&mut h, "drum"); + press(&mut h, Key::Enter); + let instrument = h.state().engine.session().tracks[0].instrument.clone().unwrap(); + assert_eq!(instrument.plugin, drums[0].id); + assert!(h.query_by_label(drums[0].name).is_none(), "picking closes the menu"); +} + +#[test] +fn send_and_output_menus_search_buses_as_soon_as_they_open() { + let mut engine = vox(); + 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})).unwrap(); + } + let mut h = harness(engine); + let bus = |h: &Harness, name: &str| h.state().engine.session().busses.iter().find(|b| b.name == name).unwrap().id; + + open(&mut h, "Send C"); + type_text(&mut h, "del"); + assert!(h.query_by_label("Verb").is_none()); + press(&mut h, Key::Enter); + let send = h.state().engine.session().tracks[0].mixer.sends[2].clone().expect("Enter picks the bus"); + assert_eq!(send.target, Route::Bus(bus(&h, "Delay"))); + + open(&mut h, "Output"); + type_text(&mut h, "ver"); + press(&mut h, Key::Enter); + assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb"))); +} + +#[test] +fn scripted_keys_reach_the_search_with_the_window_in_the_background() { + let plates = built_in(false, "plate"); + let mut h = harness(vox()); + h.input_mut().focused = false; + open(&mut h, "Insert A"); + type_text(&mut h, "plate"); + press(&mut h, Key::Enter); + let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter works without window focus, as typing does"); + assert_eq!(insert.plugin, plates[0].id); +} diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 0cfd3ab..2748bc7 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -114,6 +114,9 @@ pub enum Vst3Error { mod api { use super::*; + pub fn cached() -> Option> { + registry::cached() + } pub fn scan() -> Vec { registry::scan() } @@ -144,6 +147,9 @@ mod api { mod api { use super::*; + pub fn cached() -> Option> { + Some(Vec::new()) + } pub fn scan() -> Vec { Vec::new() } @@ -191,6 +197,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec { api::scan_paths(dirs) } +/// The plugins of the last scan, without scanning: `None` until something has scanned. +pub fn cached() -> Option> { + api::cached() +} + /// Shuts plugin hosting down: runs the module exit of every loaded binary that has no live /// instance left, each on the thread that loaded it; later loads fail. Call it on the main /// thread once the plugin instances are gone, before the process exits. Without it the exits diff --git a/crates/vst3-host/src/registry.rs b/crates/vst3-host/src/registry.rs index 453fbe3..629b8f3 100644 --- a/crates/vst3-host/src/registry.rs +++ b/crates/vst3-host/src/registry.rs @@ -87,6 +87,10 @@ pub fn scan() -> Vec { ensure_scanned(&mut r).clone() } +pub fn cached() -> Option> { + registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone() +} + pub fn rescan() -> Vec { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); r.scanned = None; 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 104/154] 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 105/154] 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 106/154] 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 107/154] 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 108/154] 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 a47ce1cee5d3ac05a05fa0c5c4b70680b4ab48bf Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:18:25 +0100 Subject: [PATCH 109/154] Waveforms like Pro Tools': exact per-pixel peaks and working view options Waveforms looked blocky and jagged next to Pro Tools': each column was a point wide (two pixels on Retina screens), and the overview only knew 32-sample blocks, so zooming in showed stair steps and, at the sample level, wide flat bars instead of the signal. Several View > Waveforms options did nothing or drew something else. - Peaks keeps one tree of blocks (min, max and sum of squares). Its queries read the partial blocks at a range's edges from the samples, so every answer is exact and still O(log n). `Peaks::spans` gives each column the (min, max) of the line through the samples over its span; neighbouring spans share their boundary, so the waveform stays connected at any zoom. - The Edit window draws a column per physical pixel, each over a 1-point window anchored to absolute sample positions (no shimmer while scrolling), with anti-aliased ends, in one triangle strip where runs of equal columns such as silence share vertices. At the sample level (a sample per pixel or less) the waveform is a 1-point line through the samples, without Rectified, Power or Outlines, as in Pro Tools. - Outlines draws an edge around a lighter fill, as Pro Tools does; it used to replace the fill with a line of dots along the tops. - Power shows the RMS, which is how Pro Tools calculates it (`Peaks::rms_spans`). Ours squashed the peak heights with a square root, which drew loud material bigger than Peak view. Power draws +/-RMS around the centre, or 0..RMS above the base line when Rectified. - Dark theme clips have a dark body tinted with the clip colour and a light waveform in that colour, as in Pro Tools, instead of black waveforms on a mid-tone body; a selected clip inverts to a bright body with a dark waveform, as before. MIDI notes on unselected clips take the waveform colour, with velocity as opacity. The Light theme is unchanged. - Blocks view draws clips as empty blocks with their names, and in Playlists view the alternate playlists' lanes draw their clips like the main lane, waveforms included, instead of empty boxes. - Overlapped Crossfades draws the outgoing clip's waveform, translucent, over the crossfade, so both clips' audio shows inside it. The outgoing clip is found by binary search in the clip's own playlist. A crossfade also shows both fade curves: the outgoing clip's fade-out used to be hidden under the incoming clip. Tests: the peaks unit tests check every range, span and RMS against the samples; tests/waveform.rs checks the sample-level line, a one-sample spike zoomed out, Blocks and playlist lanes, and Overlapped Crossfades. --- crates/audio-io/src/peaks.rs | 296 +++++++++++++++++++----------- crates/ui-egui/src/edit_window.rs | 99 +++++----- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/theme.rs | 20 +- crates/ui-egui/src/waveform.rs | 186 +++++++++++++++++++ crates/ui-egui/tests/waveform.rs | 91 +++++++++ 6 files changed, 514 insertions(+), 179 deletions(-) create mode 100644 crates/ui-egui/src/waveform.rs create mode 100644 crates/ui-egui/tests/waveform.rs diff --git a/crates/audio-io/src/peaks.rs b/crates/audio-io/src/peaks.rs index d13c1ea..9553665 100644 --- a/crates/audio-io/src/peaks.rs +++ b/crates/audio-io/src/peaks.rs @@ -1,48 +1,62 @@ -//! Multi-resolution min/max overview for waveform drawing. +//! Multi-resolution overview for waveform drawing. //! -//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges -//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine -//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of -//! zoom. +//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following +//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range +//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks +//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom. /// Samples per level-0 block. pub const BLOCK: usize = 32; /// Blocks of level `n` merged into one block of level `n + 1`. pub const FANOUT: usize = 4; -/// Upper bound on the number of columns [`Peaks::columns`] will produce. +/// Upper bound on the number of spans [`Peaks::spans`] will produce. pub const MAX_COLUMNS: usize = 1 << 20; -/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4. +/// Multi-resolution overview of one channel for waveform drawing. #[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Peaks { len: usize, - levels: Vec>, + levels: Vec>, } -const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY); - -fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) { - (a.0.min(b.0), a.1.max(b.1)) +/// What a run of samples sums up to; NaN samples count for nothing. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +struct Block { + min: f32, + max: f32, + squares: f32, } -fn finish(acc: (f32, f32)) -> (f32, f32) { - if acc.0 > acc.1 { (0.0, 0.0) } else { acc } +impl Block { + const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 }; + + fn point(s: f32) -> Block { + Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } } + } + + fn of(samples: &[f32]) -> Block { + samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s))) + } + + fn merge(self, o: Block) -> Block { + Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares } + } + + /// (min, max), or (0, 0) when nothing was summed up. + fn range(self) -> (f32, f32) { + if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) } + } } impl Peaks { /// Build the overview of `samples` (one channel). NaN samples are ignored. pub fn build(samples: &[f32]) -> Peaks { - let mut levels = Vec::new(); - let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect(); - if level0.is_empty() { - return Peaks { len: 0, levels }; + if samples.is_empty() { + return Peaks::default(); } - levels.push(level0); - while let Some(last) = levels.last() { - if last.len() <= 1 { - break; - } - let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect(); + let mut levels: Vec> = vec![samples.chunks(BLOCK).map(Block::of).collect()]; + while let Some(last) = levels.last().filter(|l| l.len() > 1) { + let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect(); levels.push(next); } Peaks { len: samples.len(), levels } @@ -58,65 +72,84 @@ impl Peaks { self.levels.len() } - /// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends - /// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges - /// contribute their whole block's extremes, so the result always contains the true range. - /// Returns `(0.0, 0.0)` for an empty range. - pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) { - let end = end.min(self.len); - if start >= end { - return (0.0, 0.0); + /// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the + /// data the overview was built from. Returns `(0.0, 0.0)` for an empty range. + pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) { + self.sum(samples, start, end).range() + } + + fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block { + let end = end.min(self.len).min(samples.len()); + let (first, last) = (start.div_ceil(BLOCK), end / BLOCK); + let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default()); + if first >= last { + return raw(start, end); } - let mut lo = start / BLOCK; - let mut hi = end.div_ceil(BLOCK); - let mut acc = EMPTY; - let mut lvl = 0usize; - while lo < hi { - let Some(level) = self.levels.get(lvl) else { break }; - let has_parent = lvl + 1 < self.levels.len(); - if !has_parent || hi - lo < 2 * FANOUT { - for b in level.get(lo..hi.min(level.len())).unwrap_or_default() { - acc = merge(acc, *b); - } + raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last)) + } + + /// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit. + fn blocks(&self, mut lo: usize, mut hi: usize) -> Block { + let mut acc = Block::EMPTY; + for (lvl, level) in self.levels.iter().enumerate() { + let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY)); + if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT { + (lo..hi).for_each(add); break; } - while !lo.is_multiple_of(FANOUT) && lo < hi { - acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY)); + while !lo.is_multiple_of(FANOUT) { + add(lo); lo += 1; } - while !hi.is_multiple_of(FANOUT) && hi > lo { + while !hi.is_multiple_of(FANOUT) { hi -= 1; - acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY)); + add(hi); } lo /= FANOUT; hi /= FANOUT; - lvl += 1; } - finish(acc) + acc } - /// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range - /// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at - /// least one sample, so zoomed-in views repeat the containing sample's block. - pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> { - let cols = cols.min(MAX_COLUMNS); - let mut out = vec![(0.0, 0.0); cols]; - if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 { - return out; - } - let span = (end - start) / cols as f64; - let len = self.len as f64; - for (c, slot) in out.iter_mut().enumerate() { - let s = start + span * c as f64; - let e = start + span * (c + 1) as f64; - if e <= 0.0 || s >= len { - continue; - } - let si = s.max(0.0).floor(); - let ei = e.min(len).ceil().max(si + 1.0); - *slot = self.minmax(si as usize, ei as usize); - } - out + /// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that + /// reach the data, clamped to it. + fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator> { + let n = self.len.min(samples.len()); + let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0; + let last = n.saturating_sub(1) as f64; + (0..cols.min(MAX_COLUMNS)).map(move |c| { + let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap); + (valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last))) + }) + } + + /// (min, max) of the line through `samples` over each of `cols` spans of sample positions + /// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate + /// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from + /// them stays connected at any zoom. `None` for spans outside the data. + pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec> { + let at = |t: f64| { + let i = t.floor(); + let a = samples.get(i as usize).copied().unwrap_or(0.0); + let b = samples.get(i as usize + 1).copied().unwrap_or(a); + Block::point(a + (b - a) * (t - i) as f32) + }; + self.windows(samples, start, step, overlap, cols) + .map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range())) + .collect() + } + + /// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just + /// outside a span's ends. + pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec> { + self.windows(samples, start, step, overlap, cols) + .map(|w| { + w.map(|(a, b)| { + let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len())); + (self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt() + }) + }) + .collect() } } @@ -134,29 +167,19 @@ mod tests { } #[test] - fn aligned_ranges_are_exact() { + fn every_range_is_exact() { let x = signal(100_003); let p = Peaks::build(&x); - for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] { - assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}"); + for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] { + assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}"); } - } - - #[test] - fn unaligned_ranges_contain_truth_and_match_block_cover() { - let x = signal(50_000); - let p = Peaks::build(&x); let mut seed = 12345u64; for _ in 0..2000 { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let a = (seed >> 33) as usize % 50_000; + let a = (seed >> 33) as usize % 100_003; seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let b = a + 1 + (seed >> 33) as usize % (50_000 - a); - let got = p.minmax(a, b); - let truth = brute(&x, a, b); - assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}"); - let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len())); - assert_eq!(got, cover, "{a}..{b}"); + let b = a + 1 + (seed >> 33) as usize % (100_003 - a); + assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}"); } } @@ -164,34 +187,87 @@ mod tests { fn degenerate_inputs() { let p = Peaks::build(&[]); assert_eq!(p.len_samples(), 0); - assert_eq!(p.minmax(0, 10), (0.0, 0.0)); - assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]); - let p = Peaks::build(&[0.5, -0.25]); - assert_eq!(p.minmax(0, 2), (-0.25, 0.5)); - assert_eq!(p.minmax(5, 3), (0.0, 0.0)); - assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5)); - assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0))); - assert_eq!(p.columns(0.0, 1.0, 0).len(), 0); - let p = Peaks::build(&[f32::NAN, 1.0]); - assert_eq!(p.minmax(0, 2), (1.0, 1.0)); + assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0)); + assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]); + let x = [0.5, -0.25]; + let p = Peaks::build(&x); + assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5)); + assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0)); + assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5)); + assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5)); + for (start, step, overlap) in [ + (f64::NAN, 1.0, 0.0), + (0.0, 0.0, 0.0), + (0.0, -1.0, 0.0), + (0.0, f64::INFINITY, 0.0), + (f64::NEG_INFINITY, 1.0, 0.0), + (0.0, 1.0, -0.5), + (0.0, 1.0, f64::NAN), + ] { + assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]); + } + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS); + let x = [f32::NAN, 1.0]; + let p = Peaks::build(&x); + assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0)); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]); + assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite)); } #[test] - fn columns_cover_range() { + fn rms_spans_match_the_samples() { + let x = signal(20_000); + let p = Peaks::build(&x); + let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::() / (e - s) as f64).sqrt() as f32; + for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] { + let cols = ((19_999.0 - overlap) / step) as usize; + for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() { + let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0)); + let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000))); + assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}"); + } + } + let sine: Vec = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect(); + let p = Peaks::build(&sine); + let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap(); + assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}"); + assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]); + assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]); + } + + #[test] + fn spans_cover_their_samples_and_touch_their_neighbours() { let x = signal(10_000); let p = Peaks::build(&x); - let cols = p.columns(0.0, 10_000.0, 100); - assert_eq!(cols.len(), 100); - for (c, &(lo, hi)) in cols.iter().enumerate() { - let truth = brute(&x, c * 100, c * 100 + 100); - assert!(lo <= truth.0 && hi >= truth.1); + for step in [0.3, 1.0, 7.5, 100.0, 333.3] { + let cols = (9_999.0 / step) as usize; + let spans = p.spans(&x, 0.0, step, 0.0, cols); + for (c, s) in spans.iter().enumerate() { + let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}")); + let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize); + if a <= b { + let truth = brute(&x, a, b + 1); + assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}"); + } + if let Some(Some((nlo, nhi))) = spans.get(c + 1) { + assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1); + } + } + let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1))); + assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}"); } - let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1))); - assert_eq!(all, brute(&x, 0, 10_000)); - // Past the end of the data. - let cols = p.columns(9_000.0, 11_000.0, 2); - assert_eq!(cols[1], (0.0, 0.0)); - // Zoomed in further than one sample per column. - assert_eq!(p.columns(0.0, 4.0, 8).len(), 8); + } + + #[test] + fn spans_follow_the_line_between_samples() { + let x = [0.0, 1.0, -1.0, 0.5]; + let p = Peaks::build(&x); + assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]); + assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]); + let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None]; + assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans); + assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]); } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..bbd5dc6 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -744,9 +744,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); for c in &pl.clips { - let r = Rect::from_min_max(pos2(x_of(&s, tl, c.start), lane.min.y + 3.0), pos2(x_of(&s, tl, c.end()), lane.max.y - 3.0)); - painter.rect(r, 2.0, rgb(track.color).gamma_multiply(0.45), Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); - painter.text(pos2(r.min.x + 4.0, r.min.y + 7.0), Align2::LEFT_CENTER, &c.name, regular(9.5), t.text); + draw_clip(app, &painter, &s, track, c, tl, lane, false); } let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() @@ -1179,58 +1177,15 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max); let vis = body_r.intersect(lane); match &clip.content { - ClipContent::Audio { source, offset } => { + _ if track.view == "blocks" => {} + ClipContent::Audio { source, .. } => { if let Some(audio) = s.pool.get(*source) { - let nch = track.channels().min(audio.peaks.len().max(1)).max(1); - let ch_h = body_r.height() / nch as f32; - let zoom = s.edit.zoom.waveform_zoom; - let rectified = s.edit.flag("waveform.rectified"); - let power = s.edit.flag("waveform.power"); - let outlines = s.edit.flag("waveform.outlines"); - let cols = vis.width().max(0.0) as usize; - if cols > 0 && vis.height() > 2.0 { - let a = (offset + sample_at(s, tl, vis.min.x) - clip.start) as f64; - let b = (offset + sample_at(s, tl, vis.max.x) - clip.start) as f64; - for ch in 0..nch { - let Some(pk) = audio.peaks.get(ch % audio.peaks.len().max(1)) else { continue }; - let cy = body_r.min.y + ch_h * (ch as f32 + 0.5); - let half = ch_h * 0.46; - let colsv = pk.columns(a, b, cols); - let mut mesh = egui::Mesh::default(); - for (i, (mn, mx)) in colsv.iter().enumerate() { - let x = vis.min.x + i as f32; - let rel = sample_at(s, tl, x) - clip.start; - let g = clip.gain_at(rel.clamp(0, clip.length)); - let (mut top, mut bot) = if rectified { - let a = (mx.abs().max(mn.abs()) * g * zoom).clamp(0.0, 1.0); - let b = cy + half; - (b - a * half * 2.0, b) - } else { - (cy - (mx * g * zoom).clamp(-1.0, 1.0) * half, cy - (mn * g * zoom).clamp(-1.0, 1.0) * half) - }; - if power { - // Power view: compress peaks logarithmically around the centre. - let k = |y: f32| cy + (y - cy).signum() * ((y - cy).abs() / half).sqrt() * half; - top = k(top); - bot = k(bot); - } - if outlines { - bot = top + 1.0; - } - let bot = if (bot - top) < 1.0 { top + 1.0 } else { bot }; - let idx = mesh.vertices.len() as u32; - mesh.colored_vertex(pos2(x, top), wave); - mesh.colored_vertex(pos2(x + 1.0, top), wave); - mesh.colored_vertex(pos2(x + 1.0, bot), wave); - mesh.colored_vertex(pos2(x, bot), wave); - mesh.add_triangle(idx, idx + 1, idx + 2); - mesh.add_triangle(idx, idx + 2, idx + 3); - } - painter.with_clip_rect(vis).add(Shape::mesh(mesh)); - if nch > 1 && ch + 1 < nch { - let y = body_r.min.y + ch_h * (ch as f32 + 1.0); - painter.line_segment([pos2(vis.min.x, y), pos2(vis.max.x, y)], Stroke::new(1.0, body.gamma_multiply(0.7))); - } + if vis.width() > 0.0 && vis.height() > 2.0 { + crate::waveform::draw(&painter.with_clip_rect(vis), s, tl, clip, audio, track.channels(), body_r, wave); + let n = track.channels().min(audio.peaks.len()).max(1); + for k in 1..n { + let y = body_r.min.y + body_r.height() / n as f32 * k as f32; + painter.line_segment([pos2(vis.min.x, y), pos2(vis.max.x, y)], Stroke::new(1.0, body.gamma_multiply(0.7))); } } } else { @@ -1254,7 +1209,11 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let y = body_r.max.y - 3.0 - (f32::from(n.pitch.saturating_sub(lo)) / span) * (body_r.height() - 6.0); let nh = ((body_r.height() - 6.0) / span).clamp(1.5, 6.0); - let vcol = Color32::from_rgb(20, 20 + n.velocity, 40); + let vcol = if crate::theme::is_light() { + Color32::from_rgb(20, 20 + n.velocity, 40) + } else { + wave.gamma_multiply(0.5 + f32::from(n.velocity) / 254.0) + }; p.rect_filled( Rect::from_min_max(pos2(nx0, y - nh * 0.5), pos2(nx1, y + nh * 0.5)), 1.0, @@ -1263,6 +1222,21 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } } } + let prev = crossfade_partner(track, clip); + if s.edit.flag("waveform.overlapped_crossfades") + && track.view != "blocks" + && let Some(prev) = prev + && let ClipContent::Audio { source, .. } = &prev.content + && let Some(audio) = s.pool.get(*source) + { + let xf = Rect::from_min_max(pos2(x0, body_r.min.y), pos2(x_of(s, tl, prev.end()).min(x1), body_r.max.y)).intersect(vis); + let tail = if prev.muted || dim { + Color32::from_rgb(70, 70, 70) + } else { + clip_colors(prev.color.unwrap_or(track.color), false).2.gamma_multiply(0.6) + }; + crate::waveform::draw(&painter.with_clip_rect(xf), s, tl, prev, audio, track.channels(), body_r, tail); + } // Clip gain line. let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain"); if show_gain && body_r.height() > 12.0 { @@ -1300,12 +1274,12 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); painter.line_segment([pos2(x0, body_r.min.y), pos2(fx, body_r.min.y)], Stroke::new(1.0, fade_col)); } - if clip.fade_out.len > 0 { - let fx = x_of(s, tl, clip.end() - clip.fade_out.len); + for c in std::iter::once(clip).chain(prev).filter(|c| c.fade_out.len > 0) { + let (fx, ex) = (x_of(s, tl, c.end() - c.fade_out.len), x_of(s, tl, c.end())); let pts: Vec = (0..=16) .map(|i| { let k = i as f32 / 16.0; - pos2(fx + (x1 - fx) * k, body_r.max.y - body_r.height() * clip.fade_out.shape.gain(1.0 - k)) + pos2(fx + (ex - fx) * k, body_r.max.y - body_r.height() * c.fade_out.shape.gain(1.0 - k)) }) .collect(); painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); @@ -1364,6 +1338,15 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let _ = app; } +/// The earlier clip of a crossfade pair: the one before `clip` in its playlist, overlapping its start. +fn crossfade_partner<'a>(track: &'a Track, clip: &Clip) -> Option<&'a Clip> { + let (p, i) = track.playlists.iter().find_map(|p| { + let i = p.clips.partition_point(|c| c.start < clip.start); + p.clips.get(i).is_some_and(|c| c.id == clip.id).then_some((p, i)) + })?; + p.clips.get(i.checked_sub(1)?).filter(|c| c.end() > clip.start) +} + fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) { let t = Tokens::current(); let (lo, hi, _) = p.range(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..4336cf6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod theme; pub mod toolbar; pub mod video_track; pub mod video_window; +pub mod waveform; pub mod widgets; pub mod windows; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c667e6f..37aae95 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -347,20 +347,18 @@ pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; - let k = if selected { 0.95 } else { 0.62 }; - let body = Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); - let bar = Color32::from_rgb((f32::from(r) * 0.9) as u8, (f32::from(g) * 0.9) as u8, (f32::from(b) * 0.9) as u8); + let shade = |k: f32| Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); + let tint = |k: f32| { + let t = |c: u8| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8; + Color32::from_rgb(t(r), t(g), t(b)) + }; + let bar = shade(0.9); if is_light() { // A paler body and a waveform in a deep shade of the clip's own colour. - let tint = |c: u8, k: f32| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8; - let k = if selected { 0.2 } else { 0.45 }; - let body = Color32::from_rgb(tint(r, k), tint(g, k), tint(b, k)); - let d = if selected { 0.22 } else { 0.32 }; - let wave = Color32::from_rgb((f32::from(r) * d) as u8, (f32::from(g) * d) as u8, (f32::from(b) * d) as u8); - return (body, bar, wave); + return if selected { (tint(0.2), bar, shade(0.22)) } else { (tint(0.45), bar, shade(0.32)) }; } - let wave = if selected { Color32::from_rgb(20, 20, 24) } else { Color32::from_rgb(12, 12, 14) }; - (body, bar, wave) + // A dark body and a light waveform in the clip's own colour; selection inverts them. + if selected { (shade(0.7), bar, Color32::from_rgb(16, 16, 18)) } else { (shade(0.34), bar, tint(0.35)) } } pub fn rgb(c: [u8; 3]) -> Color32 { diff --git a/crates/ui-egui/src/waveform.rs b/crates/ui-egui/src/waveform.rs new file mode 100644 index 0000000..2378f07 --- /dev/null +++ b/crates/ui-egui/src/waveform.rs @@ -0,0 +1,186 @@ +//! Clip waveforms, drawn per physical pixel, or as a line through the samples at the sample level. + +use egui::{Color32, Mesh, Painter, Rect, Shape, Stroke, pos2, vec2}; +use soundcraft_model::{Clip, Session, SourceAudio}; +use soundcraft_time::Samples; + +/// Draw a clip's audio across `body`, one band per channel, `channels` of them at most. +pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) { + let n = channels.min(audio.peaks.len()).max(1); + let h = body.height() / n as f32; + for ch in 0..n { + channel(painter, s, tl, clip, audio, ch, Rect::from_min_size(pos2(body.min.x, body.min.y + h * ch as f32), vec2(body.width(), h)), wave); + } +} + +fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) { + let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return }; + let Some(g) = Grid::new(painter, s, tl, band) else { return }; + let (rectified, power, outlines) = (s.edit.flag("waveform.rectified"), s.edit.flag("waveform.power"), s.edit.flag("waveform.outlines")); + let origin = clip.start.saturating_sub(clip.source_offset()) as f64; + let gain = |pos: f64| clip.gain_at((pos as Samples).saturating_sub(clip.start).min(clip.length).max(0)) * s.edit.zoom.waveform_zoom; + if g.step <= 1.0 { + let first = (g.pos_at(g.vis.min.x) - origin).floor().max(0.0) as usize; + let last = (g.pos_at(g.vis.max.x) - origin).ceil() as usize; + let from = first.saturating_sub(1); + let points = samples.get(from..samples.len().min(last.saturating_add(2))).unwrap_or_default().iter().zip(from..).map(|(&v, i)| { + let pos = origin + i as f64; + pos2(g.x_at(pos), g.cy - level(v * gain(pos)) * g.half) + }); + painter.add(Shape::line(points.collect(), Stroke::new(g.weight, wave))); + return; + } + let (start, overlap) = (g.start() - origin, g.overlap()); + let spans = if power { + peaks.rms_spans(samples, start, g.step, overlap, g.cols).into_iter().map(|r| r.map(|r| (-r, r))).collect() + } else { + peaks.spans(samples, start, g.step, overlap, g.cols) + }; + let base = g.cy + g.half; + let ys: Vec> = spans + .into_iter() + .enumerate() + .map(|(c, span)| { + let (lo, hi) = span?; + let k = gain((g.k0 + c as f64 + 0.5) * g.step); + Some(if rectified { + (base - level(lo.abs().max(hi.abs()) * k) * g.half * 2.0, base) + } else { + (g.cy - level(hi * k) * g.half, g.cy - level(lo * k) * g.half) + }) + }) + .collect(); + g.paint(painter, &ys, wave, outlines, rectified); +} + +fn level(v: f32) -> f32 { + if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 } +} + +/// One column per physical pixel across the visible part of a channel's band, each summing up a +/// one-point window of timeline samples anchored to absolute positions, so scrolling doesn't +/// change what a column shows. +struct Grid { + vis: Rect, + tl_x: f32, + scroll: f64, + /// Timeline samples per point, and per column. + spp: f64, + step: f64, + px: f32, + /// A one-point line, in whole physical pixels. + weight: f32, + cy: f32, + half: f32, + /// The first column's physical pixel and absolute window index, and the column count. + p0: f32, + k0: f64, + cols: usize, +} + +impl Grid { + fn new(painter: &Painter, s: &Session, tl: Rect, band: Rect) -> Option { + let vis = painter.clip_rect().intersect(band); + if !vis.is_positive() { + return None; + } + let ppp = painter.pixels_per_point(); + let px = 1.0 / ppp; + let weight = ppp.round().max(1.0) * px; + let spp = s.edit.zoom.samples_per_px.max(0.01); + let step = spp * f64::from(px); + let scroll = s.edit.zoom.scroll as f64; + let p0 = (vis.min.x * ppp).floor(); + let k0 = ((scroll + f64::from((p0 + 0.5) * px - tl.min.x) * spp) / step).floor(); + let cy = ((band.center().y - weight * 0.5) * ppp).round() * px + weight * 0.5; + let cols = ((vis.max.x * ppp).ceil() - p0).max(0.0) as usize; + Some(Grid { vis, tl_x: tl.min.x, scroll, spp, step, px, weight, cy, half: band.height() * 0.46, p0, k0, cols }) + } + + fn pos_at(&self, x: f32) -> f64 { + self.scroll + f64::from(x - self.tl_x) * self.spp + } + + fn x_at(&self, pos: f64) -> f32 { + self.tl_x + ((pos - self.scroll) / self.spp) as f32 + } + + /// How far each column's window reaches into its neighbours': a column is `step` apart but sums + /// up a whole point. + fn overlap(&self) -> f64 { + (self.spp - self.step).max(0.0) + } + + /// The timeline position where the first column's window starts. + fn start(&self) -> f64 { + self.k0 * self.step - self.overlap() * 0.5 + } + + /// Fill the columns' (top, bottom) spans; with `outlines`, edge them in `wave` around a lighter + /// fill, along the top only when `rectified`. + fn paint(&self, painter: &Painter, ys: &[Option<(f32, f32)>], wave: Color32, outlines: bool, rectified: bool) { + let mut mesh = Mesh::default(); + self.strip(&mut mesh, ys, if outlines { wave.gamma_multiply(0.55) } else { wave }); + let edges: &[fn((f32, f32)) -> f32] = match (outlines, rectified) { + (false, _) => &[], + (true, true) => &[|y| y.0], + (true, false) => &[|y| y.0, |y| y.1], + }; + for edge in edges { + let line: Vec> = ys + .iter() + .enumerate() + .map(|(c, y)| { + let y = edge((*y)?); + let near = |i: Option| i.and_then(|i| ys.get(i).copied().flatten()).map_or(y, |n| (edge(n) + y) * 0.5); + let (a, b) = (near(c.checked_sub(1)), near(Some(c + 1))); + Some((y.min(a).min(b) - self.weight * 0.5, y.max(a).max(b) + self.weight * 0.5)) + }) + .collect(); + self.strip(&mut mesh, &line, wave); + } + painter.add(Shape::mesh(mesh)); + } + + /// Fill each column's (top, bottom) span, at least `weight` tall, joined to its neighbours in + /// one triangle strip and anti-aliased at both ends. Runs of equal spans share vertices. + fn strip(&self, mesh: &mut Mesh, spans: &[Option<(f32, f32)>], color: Color32) { + let half = self.px * 0.5; + let thick = |(top, bot): (f32, f32)| { + if bot - top < self.weight { ((top + bot - self.weight) * 0.5, (top + bot + self.weight) * 0.5) } else { (top, bot) } + }; + let push = |mesh: &mut Mesh, prev: &mut Option, x: f32, (top, bot): (f32, f32)| { + let i = mesh.vertices.len() as u32; + mesh.colored_vertex(pos2(x, top - half), Color32::TRANSPARENT); + mesh.colored_vertex(pos2(x, top + half), color); + mesh.colored_vertex(pos2(x, bot - half), color); + mesh.colored_vertex(pos2(x, bot + half), Color32::TRANSPARENT); + if let Some(j) = *prev { + for k in 0..3 { + mesh.add_triangle(j + k, j + k + 1, i + k); + mesh.add_triangle(j + k + 1, i + k + 1, i + k); + } + } + *prev = Some(i); + }; + let at = |c: Option| c.and_then(|c| spans.get(c).copied().flatten()).map(thick); + let mut prev = None; + for c in 0..spans.len() { + let Some(span) = at(Some(c)) else { + prev = None; + continue; + }; + let x = (self.p0 + c as f32 + 0.5) * self.px; + let (before, after) = (at(c.checked_sub(1)), at(Some(c + 1))); + if before.is_none() { + push(mesh, &mut prev, x - half, span); + } + if before != Some(span) || after != Some(span) { + push(mesh, &mut prev, x, span); + } + if after.is_none() { + push(mesh, &mut prev, x + half, span); + } + } + } +} diff --git a/crates/ui-egui/tests/waveform.rs b/crates/ui-egui/tests/waveform.rs new file mode 100644 index 0000000..4726f23 --- /dev/null +++ b/crates/ui-egui/tests/waveform.rs @@ -0,0 +1,91 @@ +use egui::{Color32, Pos2, RawInput, Rect, Shape, vec2}; +use serde_json::json; +use soundcraft_audio_io::AudioBuffer; +use soundcraft_model::{Clip, ClipId, Session, SourceAudio, SourceId}; +use soundcraft_time::SampleRate; +use soundcraft_ui_egui::{Services, SoundApp, waveform}; + +const WAVE: Color32 = Color32::from_rgb(12, 12, 14); + +fn draw(samples: Vec, samples_per_px: f64) -> Vec { + let mut s = Session::new("t", SampleRate::HZ_48000); + s.edit.zoom.samples_per_px = samples_per_px; + let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); + let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); + let band = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0)); + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| { + waveform::draw(ui.painter(), &s, band, &clip, &audio, 1, band, WAVE); + }); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).filter(|s| matches!(s, Shape::Path(_) | Shape::Mesh(_))).collect() +} + +#[test] +fn at_the_sample_level_the_waveform_is_a_line_through_the_samples() { + let x: Vec = (0..200).map(|i| (i as f32 * 0.3).sin() * 0.8).collect(); + let shapes = draw(x.clone(), 0.25); + let [Shape::Path(line)] = shapes.as_slice() else { panic!("{shapes:?}") }; + assert!(line.points.len() > 100); + for p in &line.points { + let i = (p.x / 4.0).round() as usize; + assert!((p.x - i as f32 * 4.0).abs() < 1e-3 && (p.y - (50.0 - x[i] * 46.0)).abs() < 1e-3, "{p:?}"); + } +} + +#[test] +fn zoomed_out_a_one_sample_spike_stays_a_point_wide() { + let mut x = vec![0.0f32; 4800]; + x[1600] = 1.0; + let shapes = draw(x, 8.0); + let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") }; + let tall: Vec = mesh.vertices.iter().filter(|v| v.pos.y < 40.0).map(|v| v.pos.x).collect(); + let (left, right) = tall.iter().fold((f32::MAX, f32::MIN), |(a, b), &x| (a.min(x), b.max(x))); + assert!(right - left <= 1.0 && (199.0..=201.0).contains(&left), "spike drawn from {left} to {right}"); + assert!(mesh.vertices.iter().any(|v| v.pos.y < 5.0 && v.color == WAVE)); +} + +/// Waveform meshes drawn in a track's row, from `below` its top, once the app has settled. +fn waveforms(ctx: &egui::Context, app: &mut SoundApp, name: &str, below: f32) -> usize { + let mut shapes = Vec::new(); + for _ in 0..2 { + 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(); + shapes = out.shapes; + } + let id = app.engine.session().track_by_name(name).unwrap().id.0; + let row = app.edit_layout.rows.iter().find(|(i, _)| *i == id).unwrap().1; + let [x0, _, x1, _] = app.edit_layout.timeline; + let lane = Rect::from_min_max(egui::pos2(x0, row[1] + below), egui::pos2(x1, row[3])); + shapes.iter().filter(|c| matches!(&c.shape, Shape::Mesh(m) if m.vertices.iter().any(|v| lane.contains(v.pos)))).count() +} + +#[test] +fn blocks_hide_waveforms_and_playlist_lanes_show_them() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("track.playlist_duplicate", &json!({"track": "Bass"})).unwrap(); + app.engine.execute("track.view", &json!({"tracks": ["Bass"], "view": "playlists"})).unwrap(); + app.engine.execute("track.view", &json!({"tracks": ["Snare"], "view": "blocks"})).unwrap(); + let main = app.engine.session().track_by_name("Bass").unwrap().height.points(); + let ctx = egui::Context::default(); + assert!(waveforms(&ctx, &mut app, "Kick", 0.0) > 0); + assert_eq!(waveforms(&ctx, &mut app, "Snare", 0.0), 0); + assert!(waveforms(&ctx, &mut app, "Bass", main) > 0, "the alternate playlist's lane"); +} + +#[test] +fn overlapped_crossfades_show_the_outgoing_clip_too() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("edit.fades_create", &json!({"tracks": ["Bass"], "start": {"seconds": 18.2}, "end": {"seconds": 18.7}})).unwrap(); + app.engine.execute("view.zoom_set", &json!({"samples_per_px": 64})).unwrap(); + app.engine.execute("view.scroll", &json!({"to": {"seconds": 18.0}})).unwrap(); + let ctx = egui::Context::default(); + let plain = waveforms(&ctx, &mut app, "Bass", 0.0); + app.engine.execute("view.waveform_overlapped_crossfades", &json!({"value": true})).unwrap(); + assert_eq!(waveforms(&ctx, &mut app, "Bass", 0.0), plain + 1); +} 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 110/154] 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 111/154] 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 112/154] 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 113/154] 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 114/154] =?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 72288fc77eeb7153d9fe7407e6e0d36a67412cb4 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:20:48 +0100 Subject: [PATCH 115/154] Make the Shuffle, Slip, Spot and Grid edit modes work like Pro Tools The edit modes only partly worked. Shuffle changed Cut, Clear and Paste but not moves or trims. Spot froze drags and had no dialog. Grid snapped relative to whichever clip was under the pointer, and Relative Grid acted like Absolute. Behaviour now follows the Edit Modes chapter of the Pro Tools Reference Guide. Shuffle - Grabber drags lift the clips out (later clips close the gap) and land them at the nearest clip boundary, pushing later clips right; an empty track takes them at the session start. The drag outline shows where each track's clips will land. - Trims move the later clips (a trimmed start stays put and the material slides under it). Duplicate, Duplicate and Extend, Repeat and Clip List drops make room, and Paste over a selection replaces it. - Moves lift each clip by id and close the hole it leaves, so crossfade partners and unselected clips keep their audio, and a crossfaded pair moved together stays a crossfade. Ripples never push clips past the session start, never split a crossfade (refused with a reason), and drops never land inside a clip or crossfade. - A time- or edit-locked clip and everything after it stay put when Shuffle closes or opens gaps. Edits that would push into one are refused ("not enough room before a locked clip"), and trims stop short of it. Insert Time and Cut Time are timeline operations and still move everything. - With Automation Follows Edit, automation moves with every ripple, up to a locked clip, and travels with moved clips; with it off, Shuffle edits leave automation alone. Shuffle Clear used to move the clips but leave their automation behind (#232). - Shuffle Lock: Cmd-click (Ctrl-click on Windows and Linux) the Shuffle button while in another mode. While it's on, the button shows a padlock and every way into Shuffle (keys, button, commands) is refused. - A drag works out every lift and landing point once per target track (`lift_plan`, `shuffle_slots`, and a `DragPlan` kept for the drag). In a release build, dragging 100 clips on a track of 20,000 took 29 ms a frame and a 135 ms drop; now it's under a microsecond a frame and a 5 ms drop. Slip, Spot and Grid - Slip: trims stop at the neighbouring clips instead of overlapping them. - Spot: a Grabber click or drag, a Trim click or drag and a Clip List drop open a Spot dialog: Start, Sync Point and End in any time scale, the Duration, and the clip's Original Time Stamp. Trim clicks pick the nearer edge. - Grid: a drag snaps the dragged clip's start (its sync point, when it has one inside the clip) to the grid. Relative Grid moves by whole grid steps and keeps the clip's offset from the grid; a cut or copy pasted in Relative Grid lands as far after a grid line as the copied range started after one. Holding Cmd (Ctrl on Windows and Linux) while dragging or dropping from the Clip List swaps Grid and Slip. Grid lines no longer fall a step short at tempos where a line isn't a whole number of samples. - Choosing Grid from another mode picks whichever of Absolute and Relative Grid was used last; F4 in Grid toggles them, like clicking the Grid button again. Options > Edit/Tool Mode Keyboard Lock, which did nothing, now stops F4 and Alt+4 from switching them (toolbar clicks still do). - Snap to Grid in Shuffle, Slip and Spot: Shift+F4, Shift-click on the Grid button or on another mode's button, or hold F4 with F1-F3. The Edit cursor and selections snap to the grid while clip moves and trims follow the main mode; the Grid button is outlined while it's on. - Option+1-4 (Alt+1-4 on Windows and Linux) work like F1-F4, and the ` key cycles Shuffle, Slip, Spot and Grid, skipping a locked Shuffle. - Insert Silence pushes later material only in Shuffle; in the other modes it clears the selection, as in Pro Tools. Also - Dragging clips from several tracks keeps each track's distance from the grabbed clip's track. Drops past the last track, or onto a track that can't play the clips (audio onto MIDI, an aux or the master), are refused; clips moved onto an aux used to vanish into its unused playlist. - Undo and Redo keep the Edit mode, Shuffle Lock, Snap to Grid and the Grid kind. Start trims keep a clip's sync point on its audio, or drop it. Imported BWF time stamps are converted from the file's rate (read from its header) to the session's, and the Original Time Stamp no longer adds the session start twice. Typed positions that overflow are ignored instead of panicking. Commands: `edit.move_clips` follows the mode, and its `track` moves the first clip's track there and keeps the others' distances. `edit.trim_clip`, `edit.snap_to_grid` (Shift+F4) and `edit.shuffle_lock` are new, and `edit.mode` takes `snap_to_grid`. Mode buttons expose their names to accessibility tools. Tests: crates/{engine,ui-egui}/tests/edit_modes.rs and the op tests in edit.rs; the random-edits property test also toggles clip locks. --- crates/engine/src/cmd/audio_midi.rs | 2 +- crates/engine/src/cmd/clip.rs | 18 +- crates/engine/src/cmd/edit.rs | 187 +++++++-- crates/engine/src/cmd/edit_more.rs | 9 +- crates/engine/src/cmd/event.rs | 3 +- crates/engine/src/cmd/midi.rs | 4 - crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/cmd/track.rs | 2 +- crates/engine/src/edit.rs | 595 +++++++++++++++++++++++++--- crates/engine/src/io.rs | 13 +- crates/engine/src/lib.rs | 28 ++ crates/engine/tests/edit_modes.rs | 415 +++++++++++++++++++ crates/engine/tests/random_edits.rs | 4 +- crates/model/src/clip.rs | 12 + crates/time/src/format.rs | 12 + crates/time/src/grid.rs | 23 +- crates/time/src/tempo.rs | 2 +- crates/time/src/timecode.rs | 10 +- crates/ui-egui/src/dialogs.rs | 10 + crates/ui-egui/src/edit_window.rs | 183 ++++++--- crates/ui-egui/src/icons.rs | 7 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/shortcuts.rs | 82 +++- crates/ui-egui/src/spot.rs | 173 ++++++++ crates/ui-egui/src/toolbar.rs | 55 ++- crates/ui-egui/tests/edit_modes.rs | 293 ++++++++++++++ 26 files changed, 1955 insertions(+), 192 deletions(-) create mode 100644 crates/engine/tests/edit_modes.rs create mode 100644 crates/ui-egui/src/spot.rs create mode 100644 crates/ui-egui/tests/edit_modes.rs diff --git a/crates/engine/src/cmd/audio_midi.rs b/crates/engine/src/cmd/audio_midi.rs index 786e66d..218e395 100644 --- a/crates/engine/src/cmd/audio_midi.rs +++ b/crates/engine/src/cmd/audio_midi.rs @@ -40,7 +40,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result { seq.sort(); let count = seq.notes.len(); let clip = Clip::midi(ClipId(0), "Audio as MIDI", 0, r.len(), seq); - e.clipboard = Clipboard { length: r.len(), tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::default() }; + e.clipboard = Clipboard { tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::of_range(e.session(), r) }; Ok(json!({"notes": count, "length": r.len()})) } diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e537..ec4b47a 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -153,8 +153,13 @@ pub fn specs() -> Vec { Some(_) => return Err(bad("clip.place_source", "audio files go on audio tracks")), None => s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::for_channels(chans.min(2)), Some(&name)), }; + let (sh, len) = (s.edit.edit_mode == soundcraft_model::EditMode::Shuffle, frames.max(1)); + let at = if sh { crate::edit::shuffle_slot(s, t, at, &[]) } else { at }; + if sh { + crate::edit::make_room(s, t, at, len).map_err(|why| bad("clip.place_source", why))?; + } let id = s.new_clip_id(); - crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, frames.max(1))); + crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, len)); Ok(json!({"clip": id, "track": t})) } ), @@ -297,7 +302,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result { copies.push((c.start + c.length * k, nc)); } for (at, nc) in copies { - crate::edit::paste(s, tid, at, &[nc], c.length, false); + let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false); made += 1; } } @@ -341,14 +346,11 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result { fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let s = e.session(); - let moves: Vec<(soundcraft_model::ClipId, i64)> = ids - .iter() - .filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point))) - .map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor)) - .collect(); + let moves: Vec<(soundcraft_model::ClipId, i64)> = + ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect(); let s = e.session_mut(); for (id, d) in &moves { - crate::edit::move_clips(s, &[*id], *d, None); + crate::edit::move_clips(s, &[*id], *d, &[]); } Ok(json!({"clips": moves.len()})) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..2d6fddf 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,5 +1,6 @@ //! Edit menu commands. +use super::more_util::toggle_flag; use super::*; use crate::edit as ops; use crate::{Clipboard, cmd}; @@ -126,11 +127,33 @@ pub fn specs() -> Vec { 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!(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; - Ok(json!({"mode": m})) - }), + cmd!( + noundo "edit.mode", + "Edit Mode", + [], + Some("F1-F4, Alt+1-4, `"), + "{mode: shuffle|slip|spot|grid|relative_grid, snap_to_grid?: bool (Shuffle, Slip and Spot)}", + always, + set_mode + ), + cmd!( + noundo "edit.snap_to_grid", + "Snap to Grid", + [], + Some("Shift+F4"), + "{value?: bool} (toggles when omitted) — in Shuffle, Slip and Spot the Edit cursor and selections snap to the grid", + always, + snap_to_grid + ), + cmd!( + noundo "edit.shuffle_lock", + "Shuffle Lock", + [], + None, + "{value?: bool} (toggles when omitted) — keeps Shuffle mode from being chosen", + always, + shuffle_lock + ), cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| { let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?; e.session_mut().edit.tool = t; @@ -141,10 +164,19 @@ pub fn specs() -> Vec { "Move Clips", [], None, - "{clips?: [id], by?: samples, to?: position (first clip start), track?: target}", + "{clips?: [id], by?: samples, to?: position for the first clip that can move (Grid modes snap its sync point), track?: where the first clip's track goes; the others keep their distance}", has_selection, move_clips ), + cmd!( + "edit.trim_clip", + "Trim Clip", + [], + None, + "{clip: id, start?: position, end?: position} — moves the clip's edges like the Trim tool, in the current Edit mode", + always, + trim_clip + ), cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate), ] } @@ -153,6 +185,35 @@ fn shuffle(s: &Session) -> bool { s.edit.edit_mode == EditMode::Shuffle } +fn set_mode(e: &mut Engine, p: &Value) -> Result { + 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"))?; + if m == EditMode::Shuffle && e.session().edit.flag(ops::SHUFFLE_LOCK) { + return Err(bad("edit.mode", "Shuffle is locked")); + } + let snap = bool_or(p, "snap_to_grid", false) && !m.is_grid(); + let ed = &mut e.session_mut().edit; + ed.edit_mode = m; + ed.set_flag(ops::SNAP_TO_GRID, snap); + if m.is_grid() { + ed.set_flag(ops::RELATIVE_GRID, m == EditMode::GridRelative); + } + Ok(json!({"mode": m, "snap_to_grid": snap})) +} + +fn snap_to_grid(e: &mut Engine, p: &Value) -> Result { + if e.session().edit.edit_mode.is_grid() { + return Err(bad("edit.snap_to_grid", "Grid mode always snaps")); + } + toggle_flag(e, p, ops::SNAP_TO_GRID) +} + +fn shuffle_lock(e: &mut Engine, p: &Value) -> Result { + if shuffle(e.session()) && bool_or(p, "value", true) { + return Err(bad("edit.shuffle_lock", "leave Shuffle mode before locking it")); + } + toggle_flag(e, p, ops::SHUFFLE_LOCK) +} + fn restore_selection(e: &mut Engine, _: &Value) -> Result { Ok(json!({"selection": e.session().edit.selection})) } @@ -163,7 +224,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { if range.is_empty() { return Err(bad(id, "selection is empty")); } - let mut cb = Clipboard { length: range.len(), ..Clipboard::default() }; + let mut cb = Clipboard::of_range(e.session(), range); let follow = e.session().edit.automation_follows_edit; for t in &tracks { cb.tracks.push(ops::copy_range(e.session(), *t, range)); @@ -205,15 +266,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { let sh = shuffle(e.session()); let s = e.session_mut(); for t in &tracks { - ops::clear_range(s, *t, range, sh); - if follow && let Some(tr) = s.track_mut(*t) { - for l in &mut tr.automation { - l.clear_range(range.start, range.end); - if sh { - l.shift_from(range.end, -range.len()); - } - } - } + cut_track(s, *t, range, sh, follow); } s.edit.selection = soundcraft_time::Range::point(range.start); } @@ -222,17 +275,39 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { Ok(json!({"clips": n, "length": range.len()})) } +/// Remove `r` from a track, closing the gap in Shuffle mode; automation follows when `follow`. +fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, follow: bool) { + if follow && let Some(tr) = s.track_mut(t) { + for l in &mut tr.automation { + l.clear_range(r.start, r.end); + } + } + ops::clear_range(s, t, r, sh); +} + fn paste(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.paste", p)?; - let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start); + let given = position_param(e, "edit.paste", p, "at")?; let cb = e.clipboard.clone(); - let sh = shuffle(e.session()); - let follow = e.session().edit.automation_follows_edit; + let s = e.session(); + let at = match given { + Some(at) => at, + None if s.edit.edit_mode == EditMode::GridRelative => { + s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset) + } + None => s.edit.selection.start, + }; + let sh = shuffle(s) && !cb.automation_only; + let follow = s.edit.automation_follows_edit; + let replace = Some(s.edit.selection).filter(|r| sh && given.is_none() && !r.is_empty()); let s = e.session_mut(); let mut pasted = 0; for (i, t) in tracks.iter().enumerate() { let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue }; - pasted += ops::paste(s, *t, at, clips, cb.length, sh).len(); + if let Some(r) = replace { + cut_track(s, *t, r, true, follow); + } + pasted += ops::paste(s, *t, at, clips, cb.length, sh).map_err(|why| bad("edit.paste", why))?.len(); if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) { for l in lanes { let dst = tr.lane_mut(&l.param); @@ -329,7 +404,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result { c }) .collect(); - ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false); + let _ = ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false); } } at += len; @@ -453,12 +528,14 @@ fn duplicate(e: &mut Engine, p: &Value) -> Result { if range.is_empty() { return Err(bad("edit.duplicate", "selection is empty")); } + let sh = shuffle(e.session()); + let follow = e.session().edit.automation_follows_edit; let s = e.session_mut(); let mut n = 0; for t in &tracks { let clips = ops::copy_range(s, *t, range); - n += ops::paste(s, *t, range.end, &clips, range.len(), false).len(); - if let Some(tr) = s.track_mut(*t) { + n += ops::paste(s, *t, range.end, &clips, range.len(), sh).map_err(|why| bad("edit.duplicate", why))?.len(); + if follow && let Some(tr) = s.track_mut(*t) { for l in &mut tr.automation { let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect(); for pt in pts { @@ -478,11 +555,13 @@ fn repeat(e: &mut Engine, p: &Value) -> Result { if range.is_empty() { return Err(bad("edit.repeat", "selection is empty")); } + let sh = shuffle(e.session()); let s = e.session_mut(); for t in &tracks { let clips = ops::copy_range(s, *t, range); for k in 1..=count { - ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false); + let at = range.start.saturating_add(range.len().saturating_mul(k)); + ops::paste(s, *t, at, &clips, range.len(), sh).map_err(|why| bad("edit.repeat", why))?; } } Ok(json!({"repeats": count})) @@ -493,19 +572,25 @@ fn shift(e: &mut Engine, p: &Value) -> Result { let later = bool_or(p, "later", true); let ids = clip_ids_param(e, p); let s = e.session_mut(); - let n = ops::move_clips(s, &ids, if later { by } else { -by }, None); + let n = ops::move_clips(s, &ids, if later { by } else { -by }, &[]); Ok(json!({"moved": n})) } fn insert_silence(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.insert_silence", p)?; let range = range_param(e, "edit.insert_silence", p)?; + let sh = shuffle(e.session()); + let follow = e.session().edit.automation_follows_edit; let s = e.session_mut(); for t in &tracks { - ops::insert_silence(s, *t, range); - if let Some(tr) = s.track_mut(*t) { - for l in &mut tr.automation { - l.shift_from(range.start, range.len()); + if sh { + ops::make_room(s, *t, range.start, range.len()).map_err(|why| bad("edit.insert_silence", why))?; + } else { + ops::clear_range(s, *t, range, false); + if follow && let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + l.clear_range(range.start, range.end); + } } } } @@ -718,7 +803,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result { 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 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) }; + let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, &[]) }; s.edit.selection = s.edit.selection.shifted(step); if s.edit.selection.start < 0 { s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start); @@ -731,26 +816,48 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result { if ids.is_empty() { return Err(bad("edit.move_clips", "no clips")); } - let to_track = track_param(e, "edit.move_clips", p, "track")?; - let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0); + let s = e.session(); + let dest = match track_param(e, "edit.move_clips", p, "track")? { + Some(to) => ids + .iter() + .find_map(|c| s.find_clip(*c)) + .and_then(|(from, _)| ops::destinations(s, &ids, from, to)) + .ok_or_else(|| bad("edit.move_clips", "the clips can't land there: past the last track, or on a track that can't hold them"))?, + None => Vec::new(), + }; + let first = ops::first_movable(s, &ids); let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) { (Some(by), _) => by, - (None, Some(to)) => to - first, + (None, Some(to)) => to.saturating_sub(first.map_or(0, |c| c.start)), _ => 0, }; - // Grid mode snaps the destination. - let s = e.session(); - let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() { - s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first - } else { - delta + let mode = s.edit.edit_mode; + let delta = match first { + Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative), + _ => delta, }; let s = e.session_mut(); - let n = ops::move_clips(s, &ids, delta, to_track); + let n = if mode == EditMode::Shuffle { + ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))? + } else { + ops::move_clips(s, &ids, delta, &dest) + }; s.edit.selected_clips = ids; Ok(json!({"moved": n, "by": delta})) } +fn trim_clip(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "edit.trim_clip")?; + let start = position_param(e, "edit.trim_clip", p, "start")?; + let end = position_param(e, "edit.trim_clip", p, "end")?; + if start.is_none() && end.is_none() { + return Err(bad("edit.trim_clip", "pass `start` and/or `end`")); + } + let sh = shuffle(e.session()); + let c = ops::trim_clip(e.session_mut(), id, start, end, sh).ok_or_else(|| bad("edit.trim_clip", "no such clip, or it is edit-locked"))?; + Ok(json!({"start": c.start, "end": c.end()})) +} + fn consolidate(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.consolidate", p)?; let r = range_param(e, "edit.consolidate", p)?; diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index c5c1082..25dcde0 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -284,7 +284,7 @@ pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result { fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result { let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; - let mut cb = Clipboard { length: r.len(), ..Clipboard::default() }; + let mut cb = Clipboard { automation_only: true, ..Clipboard::of_range(e.session(), r) }; let mut lanes_n = 0; for t in &tracks { cb.tracks.push(Vec::new()); @@ -545,12 +545,13 @@ fn duplicate_extend(e: &mut Engine, p: &Value) -> Result { let id = "edit.duplicate_extend"; let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; + let (sh, follow) = (shuffle(e.session()), e.session().edit.automation_follows_edit); let s = e.session_mut(); let mut n = 0; for t in &tracks { let clips = ops::copy_range(s, *t, r); - n += ops::paste(s, *t, r.end, &clips, r.len(), false).len(); - if let Some(tr) = s.track_mut(*t) { + n += ops::paste(s, *t, r.end, &clips, r.len(), sh).map_err(|why| bad(id, why))?.len(); + if follow && let Some(tr) = s.track_mut(*t) { for l in &mut tr.automation { let pts: Vec<_> = l.points.iter().filter(|pt| r.contains(pt.at)).copied().collect(); for pt in pts { @@ -677,7 +678,7 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result { others.iter().filter(|x| x.end() <= c.start).map(Clip::end).max().map(|en| en - c.start) }; if let Some(d) = delta.filter(|d| *d != 0) { - moved += ops::move_clips(s, &[id], d, None); + moved += ops::move_clips(s, &[id], d, &[]); } } Ok(json!({"moved": moved})) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 34fb355..ba63c3f 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -152,7 +152,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect(); for t in ids { - crate::edit::clear_range(s, t, r, true); + crate::edit::clear_range(s, t, r, false); + crate::edit::shift_from(s, t, r.end, -r.len()); if let Some(tr) = s.track_mut(t) { for l in &mut tr.automation { l.clear_range(r.start, r.end); diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index fe9fdc2..1a369a8 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -26,10 +26,6 @@ pub fn specs() -> Vec { ] } -fn clip_id(p: &Value, cmd: &str) -> Result { - p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required")) -} - fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> { match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) { Some(ClipContent::Midi { sequence }) => Ok(sequence), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..6286804 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -297,6 +297,10 @@ pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result { } } +pub fn clip_id(p: &Value, cmd: &str) -> Result { + p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required")) +} + pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec { if let Some(arr) = p.get("clips").and_then(Value::as_array) { return arr.iter().filter_map(Value::as_u64).map(ClipId).collect(); diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2f231f4..fb60d57 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -174,7 +174,7 @@ pub fn specs() -> Vec { tr.active_playlist = active; } let _ = src; - let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false); + let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false).unwrap_or_default(); Ok(json!({"clips": ids})) } ), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 05cc0e6..b852c50 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -5,8 +5,16 @@ //! Placing a clip overwrites whatever is under it (like the incumbent's Slip mode); Shuffle //! mode also ripples later clips. -use soundcraft_model::{Clip, ClipContent, ClipId, Fade, FadeShape, Session, TrackId}; +use soundcraft_model::{AutoParam, AutomationPoint, Clip, ClipContent, ClipId, Fade, FadeShape, Session, Track, TrackId}; use soundcraft_time::{Range, SampleRate, Samples, TempoMap}; +use std::collections::{HashMap, HashSet}; + +/// Edit-state flag: the Edit cursor and selections snap to the grid in Shuffle, Slip and Spot. +pub const SNAP_TO_GRID: &str = "edit.snap_to_grid"; +/// Edit-state flag: Shuffle mode can't be chosen. +pub const SHUFFLE_LOCK: &str = "edit.shuffle_lock"; +/// Edit-state flag: the Grid mode last used was Relative, so choosing Grid again picks it. +pub const RELATIVE_GRID: &str = "edit.relative_grid"; /// Context needed to split MIDI clips in musical time. #[derive(Clone, Copy)] @@ -101,8 +109,9 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize { count } -/// Remove material inside `range` on a track. In shuffle mode later clips move left. -/// Returns the removed material as clips positioned relative to `range.start`. +/// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the +/// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative +/// to `range.start`. pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) -> Vec { if range.is_empty() { return Vec::new(); @@ -122,14 +131,9 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) } }); if shuffle { - let len = range.len(); - for c in &mut pl.clips { - if c.start >= range.end { - c.start -= len; - } - } + let moved = ripple(s, track, range.end, -range.len()); + follow_automation(s, track, range.end, moved); } - pl.sort(); removed } @@ -156,18 +160,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec { } /// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless -/// `shuffle`, which first pushes later material right by `length`. -pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec { +/// `shuffle`, which first makes room for `length` (see [`make_room`], whose refusal it returns). +pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Result, &'static str> { let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0)); if shuffle { - separate_at(s, track, at); - if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { - for c in &mut pl.clips { - if c.start >= at { - c.start += length; - } - } - } + make_room(s, track, at, length)?; } let mut ids = Vec::new(); for c in clips { @@ -177,7 +174,7 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt ids.push(nc.id); place_clip(s, track, nc); } - ids + Ok(ids) } /// Place one clip, overwriting whatever it covers. @@ -189,21 +186,146 @@ pub fn place_clip(s: &mut Session, track: TrackId, clip: Clip) { } } -/// Insert silence: shift material at/after `range.start` right by the range length. +/// Insert silence: shift material at/after `range.start` right by the range length (Insert Time; +/// Shuffle mode edits use [`make_room`]). pub fn insert_silence(s: &mut Session, track: TrackId, range: Range) { if range.is_empty() { return; } separate_at(s, track, range.start); + shift_from(s, track, range.start, range.len()); +} + +/// Move every clip that starts at or after `from` by `by`, locked or not. +pub fn shift_from(s: &mut Session, track: TrackId, from: Samples, by: Samples) { if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { - for c in &mut pl.clips { - if c.start >= range.start { - c.start += range.len(); - } + for c in pl.clips.iter_mut().filter(|c| c.start >= from) { + c.start = c.start.saturating_add(by); } } } +/// Time- or edit-locked: the clip doesn't move, and Shuffle stops at it. +pub fn locked(c: &Clip) -> bool { + c.time_locked || c.edit_locked +} + +/// A clip's place on its track, for working out Shuffle layouts without touching the clips. +#[derive(Clone, Copy)] +struct Span { + id: ClipId, + start: Samples, + end: Samples, + locked: bool, +} + +fn spans(clips: &[Clip]) -> Vec { + clips.iter().map(|c| Span { id: c.id, start: c.start, end: c.end(), locked: locked(c) }).collect() +} + +/// Shuffle mode's ripple: the spans from `from` on move by `by`, up to the first locked one, which +/// stays put with everything after it. A move left stops at the session start, keeping their +/// spacing. Returns how far they moved and where the lock is (`Samples::MAX` when none). +fn ripple_spans(spans: &mut [Span], from: Samples, by: Samples) -> (Samples, Samples) { + let lock = spans.iter().filter(|c| c.locked && c.start >= from).map(|c| c.start).min().unwrap_or(Samples::MAX); + let moves = |c: &Span| c.start >= from && c.start < lock; + let floor = spans.iter().filter(|c| moves(c)).map(|c| c.start).min().unwrap_or(0).max(0); + let by = by.max(-floor); + for c in spans.iter_mut().filter(|c| moves(c)) { + c.start = c.start.saturating_add(by); + c.end = c.end.saturating_add(by); + } + (by, lock) +} + +/// [`ripple_spans`] on a track's clips. +fn ripple(s: &mut Session, track: TrackId, from: Samples, by: Samples) -> (Samples, Samples) { + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return (0, Samples::MAX) }; + let mut sp = spans(&pl.clips); + let moved = ripple_spans(&mut sp, from, by); + for (c, n) in pl.clips.iter_mut().zip(&sp) { + c.start = n.start; + } + pl.sort(); + moved +} + +/// Automation Follows Edit for a Shuffle ripple (`moved` from [`ripple`]): the points from `from` +/// up to the lock move with the clips, and when material closes up the points in the span that +/// goes away go too. +fn follow_automation(s: &mut Session, track: TrackId, from: Samples, (by, lock): (Samples, Samples)) { + if !s.edit.automation_follows_edit || by == 0 { + return; + } + let Some(t) = s.track_mut(track) else { return }; + for l in &mut t.automation { + if by < 0 { + l.clear_range(from.saturating_add(by), from); + } + for p in l.points.iter_mut().filter(|p| p.at >= from && p.at < lock) { + p.at = p.at.saturating_add(by); + } + l.points.sort_by_key(|p| p.at); + l.points.dedup_by_key(|p| p.at); + } +} + +/// With Automation Follows Edit, take the points in `[start, end)` off a track, timed from `origin`. +fn take_automation(s: &mut Session, track: TrackId, start: Samples, end: Samples, origin: Samples) -> Vec<(AutoParam, AutomationPoint)> { + let mut out = Vec::new(); + if !s.edit.automation_follows_edit || end <= start { + return out; + } + let Some(t) = s.track_mut(track) else { return out }; + for l in &mut t.automation { + let taken = l.points.iter().filter(|p| p.at >= start && p.at < end); + out.extend(taken.map(|p| (l.param.clone(), AutomationPoint { at: p.at.saturating_sub(origin), value: p.value }))); + l.clear_range(start, end); + } + out +} + +fn put_automation(s: &mut Session, track: TrackId, origin: Samples, points: &[(AutoParam, AutomationPoint)]) { + let Some(t) = s.track_mut(track) else { return }; + for (param, p) in points { + t.lane_mut(param).set_point(origin.saturating_add(p.at), p.value); + } +} + +/// How far Shuffle mode can push the material from `at` on before it runs into a locked clip +/// (`Samples::MAX` when none follows). +pub fn shuffle_room(s: &Session, track: TrackId, at: Samples) -> Samples { + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + let Some(lock) = clips.iter().filter(|c| locked(c) && c.end() > at).map(|c| c.start).min() else { return Samples::MAX }; + let end = clips.iter().filter(|c| c.end() > at && c.start < lock).map(Clip::end).fold(at, Samples::max); + lock.saturating_sub(end) +} + +/// Why Shuffle can't make room: a locked clip follows too closely. +pub const NO_ROOM: &str = "not enough room before a locked clip"; +/// Why Shuffle can't make room: the point is inside a crossfade, which can't be pulled apart. +pub const IN_CROSSFADE: &str = "can't make room inside a crossfade"; + +/// Shuffle mode's room for `len` at `at`: later clips (and with Automation Follows Edit their +/// automation) move right. Refused, with nothing changed, when a locked clip is in the way or +/// `at` is inside a crossfade. +pub fn make_room(s: &mut Session, track: TrackId, at: Samples, len: Samples) -> Result<(), &'static str> { + if len <= 0 { + return Ok(()); + } + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + if clips.iter().filter(|c| c.start <= at && at < c.end()).count() > 1 { + return Err(IN_CROSSFADE); + } + if shuffle_room(s, track, at) < len { + return Err(NO_ROOM); + } + separate_at(s, track, at); + let moved = ripple(s, track, at, len); + follow_automation(s, track, at, moved); + Ok(()) +} + /// Trim clips to keep only material inside `range`. pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { let tempo = s.tempo.clone(); @@ -279,14 +401,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { healed } -/// Move clips by `delta` (and optionally to another track), overwriting what they land on. -pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Option) -> usize { +/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them +/// elsewhere (see [`destinations`]). +pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize { + let ids: HashSet = ids.iter().copied().collect(); let mut moving: Vec<(TrackId, Clip)> = Vec::new(); for t in &mut s.tracks { let tid = t.id; if let Some(pl) = t.playlist_mut() { pl.clips.retain(|c| { - if ids.contains(&c.id) && !c.time_locked && !c.edit_locked { + if ids.contains(&c.id) && !locked(c) { moving.push((tid, c.clone())); false } else { @@ -296,13 +420,298 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Opt } } let n = moving.len(); - for (tid, mut c) in moving { + let placed = moving.into_iter().map(|(tid, mut c)| { c.start = c.start.saturating_add(delta).max(0); - place_clip(s, to_track.unwrap_or(tid), c); - } + (lands(dest, tid), c) + }); + put_clips(s, placed.collect()); n } +/// Place moved clips, overwriting what they cover but not each other, so a crossfaded pair +/// moved together stays a crossfade. +fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) { + for (t, c) in &placed { + clear_range(s, *t, c.range(), false); + } + for (t, c) in placed { + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.push(c); + pl.sort(); + } + } +} + +/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder. +pub fn shown(s: &Session, t: &Track) -> bool { + !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open) +} + +/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance +/// from it among the tracks the Edit window shows, or among all tracks when a hidden one is +/// involved. None when one would land past either end, or on a track that can't hold its clips. +pub fn destinations(s: &Session, ids: &[ClipId], from: TrackId, to: TrackId) -> Option> { + let ids: HashSet = ids.iter().copied().collect(); + let holds = |t: &Track| t.clips().iter().any(|c| ids.contains(&c.id)); + let visible = s.tracks.iter().filter(|t| t.id == from || t.id == to || holds(t)).all(|t| shown(s, t)); + let rows: Vec<&Track> = s.tracks.iter().filter(|t| !visible || shown(s, t)).collect(); + let row = |id: TrackId| rows.iter().position(|t| t.id == id).and_then(|i| isize::try_from(i).ok()); + let shift = row(to)? - row(from)?; + s.tracks + .iter() + .filter(|t| holds(t)) + .map(|t| { + let d = rows.get(usize::try_from(row(t.id)? + shift).ok()?)?; + (d.kind.has_playlist() && (d.kind == t.kind || d.kind.is_midi() && t.kind.is_midi())).then_some((t.id, d.id)) + }) + .collect() +} + +/// The track that clips on `t` land on under a [`destinations`] map. +pub fn lands(dest: &[(TrackId, TrackId)], t: TrackId) -> TrackId { + dest.iter().find(|(from, _)| *from == t).map_or(t, |(_, d)| *d) +} + +/// The earliest of clips `ids` that can move (neither time- nor edit-locked). +pub fn first_movable<'a>(s: &'a Session, ids: &[ClipId]) -> Option<&'a Clip> { + ids.iter().filter_map(|id| s.find_clip(*id)).map(|(_, c)| c).filter(|c| !locked(c)).min_by_key(|c| c.start) +} + +/// One Shuffle lift: where the hole the clip leaves starts and how long it is, how far the clips +/// after it actually close up (none past a lock, none before the session start) and where the +/// first locked clip after it is. +#[derive(Clone, Copy)] +struct Lift { + id: ClipId, + start: Samples, + from: Samples, + shift: Samples, + by: Samples, + lock: Samples, +} + +/// Lifting the movable `moving` clips off a track's `spans` the Shuffle way, right to left, in +/// one pass: the lifts in that order, and the spans that stay at the starts they close up to. +/// A hole starts where the clips before it end (a crossfade partner keeps its material) and +/// closes up to the next clip, so silence after the lifted clip stays. Clips are ordered by +/// start, then id, so of two stacked clips the later one counts as after the other. +fn lift_plan(mut sp: Vec, moving: &HashSet) -> (Vec, Vec) { + sp.sort_by_key(|c| (c.start, c.id)); + let lifted = |c: &Span| !c.locked && moving.contains(&c.id); + let reach: Vec = std::iter::once(Samples::MIN) + .chain(sp.iter().scan(Samples::MIN, |far, c| { + *far = (*far).max(c.end); + Some(*far) + })) + .collect(); + let mut lifts = Vec::new(); + let (mut next, mut next_moves, mut lock) = (None, false, Samples::MAX); + for (i, c) in sp.iter().enumerate().rev() { + if !lifted(c) { + (next, next_moves) = (Some(c.start), !c.locked); + if c.locked { + lock = c.start; + } + continue; + } + let from = reach.get(i).map_or(c.start, |far| c.start.max(*far)); + let shift = next.unwrap_or(c.end).min(c.end).saturating_sub(from).max(0); + let by = if next_moves { -shift.min(next.unwrap_or(0).max(0)) } else { 0 }; + next = next.map(|n| n.saturating_add(by)); + lifts.push(Lift { id: c.id, start: c.start, from, shift, by, lock }); + } + let (mut by, mut pending) = (0, lifts.iter().rev()); + let mut staying = Vec::with_capacity(sp.len() - lifts.len()); + for c in sp { + if lifted(&c) { + by += pending.next().map_or(0, |l| l.by); + } else if c.locked { + by = 0; + staying.push(c); + } else { + staying.push(Span { start: c.start.saturating_add(by), end: c.end.saturating_add(by), ..c }); + } + } + (lifts, staying) +} + +/// Where Shuffle can land material on `track` once the movable `moving` clips have left it (later +/// clips close each gap): the clip boundaries, never inside a clip or a crossfade, sorted. Empty +/// for an empty track. +pub fn shuffle_slots(s: &Session, track: TrackId, moving: &[ClipId]) -> Vec { + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + let (_, mut sp) = lift_plan(spans(clips), &moving.iter().copied().collect()); + sp.sort_by_key(|c| c.start); + let reach: Vec = sp + .iter() + .scan(Samples::MIN, |far, c| { + *far = (*far).max(c.end); + Some(*far) + }) + .collect(); + let inside = |b: Samples| reach.get(sp.partition_point(|c| c.start < b).wrapping_sub(1)).is_some_and(|far| *far > b); + let mut slots: Vec = sp.iter().flat_map(|c| [c.start, c.end]).filter(|b| !inside(*b)).collect(); + slots.sort_unstable(); + slots.dedup(); + slots +} + +/// The slot nearest `at` (the earlier one on a tie), or the session start when there are none. +pub fn nearest_slot(slots: &[Samples], at: Samples) -> Samples { + let i = slots.partition_point(|b| *b < at); + [i.checked_sub(1), Some(i)].into_iter().flatten().filter_map(|i| slots.get(i)).copied().min_by_key(|b| b.abs_diff(at)).unwrap_or(0) +} + +/// Where Shuffle mode lands material dropped at `at` on `track` (see [`shuffle_slots`]). +pub fn shuffle_slot(s: &Session, track: TrackId, at: Samples, moving: &[ClipId]) -> Samples { + nearest_slot(&shuffle_slots(s, track, moving), at) +} + +/// Lift the movable `moving` clips off a track the Shuffle way (see [`lift_plan`]); with +/// Automation Follows Edit their automation leaves with them. Returns the clips, each with that +/// automation timed from its start. +fn lift_all(s: &mut Session, track: TrackId, moving: &HashSet) -> Vec<(Clip, Vec<(AutoParam, AutomationPoint)>)> { + let Some(clips) = s.track(track).map(|t| spans(t.clips())) else { return Vec::new() }; + let (lifts, staying) = lift_plan(clips, moving); + let mut points: HashMap> = HashMap::new(); + for l in &lifts { + points.insert(l.id, take_automation(s, track, l.from, l.from.saturating_add(l.shift), l.start)); + follow_automation(s, track, l.from.saturating_add(l.shift), (l.by, l.lock)); + } + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return Vec::new() }; + let starts: HashMap = staying.iter().map(|c| (c.id, c.start)).collect(); + let (out, kept): (Vec, Vec) = std::mem::take(&mut pl.clips).into_iter().partition(|c| points.contains_key(&c.id)); + pl.clips = kept; + for c in &mut pl.clips { + if let Some(start) = starts.get(&c.id) { + c.start = *start; + } + } + pl.sort(); + out.into_iter() + .map(|c| { + let p = points.remove(&c.id).unwrap_or_default(); + (c, p) + }) + .collect() +} + +/// The movable clips `ids` grouped by the track they land on (per `dest`): each destination and +/// the start of its earliest clip, which Shuffle lands on a slot; the others keep their distance. +pub fn shuffle_groups(s: &Session, ids: &[ClipId], dest: &[(TrackId, TrackId)]) -> Vec<(TrackId, Samples)> { + let ids: HashSet = ids.iter().copied().collect(); + let mut groups: Vec<(TrackId, Samples)> = Vec::new(); + for t in &s.tracks { + for c in t.clips().iter().filter(|c| ids.contains(&c.id) && !locked(c)) { + let d = lands(dest, t.id); + match groups.iter_mut().find(|g| g.0 == d) { + Some(g) => g.1 = g.1.min(c.start), + None => groups.push((d, c.start)), + } + } + } + groups +} + +/// Move clips in Shuffle mode: each leaves its track (later clips close the gap, see +/// [`lift_plan`]), and the clips bound for each track land together, the earliest on the +/// [`shuffle_slot`] nearest `delta` away, pushing later clips right. With Automation Follows Edit +/// their automation travels with them. Refused, with the reason, when a locked clip or a +/// crossfade leaves no room. +pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> Result { + let groups: Vec<(TrackId, Samples, Samples)> = + shuffle_groups(s, ids, dest).into_iter().map(|(d, first)| (d, first, shuffle_slot(s, d, first.saturating_add(delta), ids))).collect(); + let moving: HashSet = ids.iter().copied().collect(); + let sources: Vec = s.tracks.iter().filter(|t| t.clips().iter().any(|c| moving.contains(&c.id) && !locked(c))).map(|t| t.id).collect(); + let mut lifted = Vec::new(); + for t in sources { + lifted.extend(lift_all(s, t, &moving).into_iter().map(|(c, points)| (lands(dest, t), c, points))); + } + for (d, first, at) in groups { + let block: Vec<&(TrackId, Clip, Vec<(AutoParam, AutomationPoint)>)> = lifted.iter().filter(|l| l.0 == d).collect(); + let len = block.iter().map(|l| l.1.end()).max().unwrap_or(first).saturating_sub(first); + make_room(s, d, at, len)?; + let mut placed = Vec::new(); + for (_, c, points) in block { + let mut c = c.clone(); + c.start = at.saturating_add(c.start.saturating_sub(first)); + put_automation(s, d, c.start, points); + placed.push(c); + } + if let Some(pl) = s.track_mut(d).and_then(|t| t.playlist_mut()) { + pl.clips.extend(placed); + pl.sort(); + } + } + Ok(lifted.len()) +} + +/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its +/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole +/// grid steps, keeping its offset from the grid. +pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples { + let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate); + let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 }; + let anchor = clip.start.saturating_add(sync); + let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor }; + g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from) +} + +/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In +/// Shuffle mode the start stays put (the material slides under it) and later clips follow the +/// change, up to a locked clip; otherwise a trim stops at the neighbouring clips. +pub fn trim_clip(s: &mut Session, id: ClipId, start: Option, end: Option, shuffle: bool) -> Option { + let (track, old) = s.find_clip(id).filter(|(_, c)| !c.edit_locked).map(|(t, c)| (t, c.clone()))?; + let (mut lo, mut hi) = (Samples::MIN, Samples::MAX); + if shuffle { + let room = shuffle_room(s, track, old.end()).max(0); + lo = old.start.saturating_sub(room); + hi = old.end().saturating_add(room); + } else { + let others = s.track(track).map(|t| t.clips()).unwrap_or_default().iter().filter(|c| c.id != id); + if let Some(prev) = others.clone().filter(|c| c.start < old.start).map(Clip::end).max() { + lo = lo.max(prev.min(old.start)); + } + if let Some(next) = others.filter(|c| c.start > old.start).map(|c| c.start).min() { + hi = hi.min(next.max(old.end())); + } + lo = lo.max(0); + } + let file = |c: &Clip| { + let start = c.start.saturating_sub(c.source_offset()); + material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end()))) + }; + let t = TimeCtx::of(s); + let mut c = old.clone(); + if let Some(at) = start { + c.trim_start_to(at.max(file(&c).map_or(lo, |(f, _)| lo.max(f)))); + rebase_midi_start(&mut c, old.start, t); + if shuffle { + c.start = old.start; + } + } + if let Some(at) = end { + c.trim_end_to(at.min(file(&c).map_or(hi, |(_, f)| hi.min(f)))); + } + let pl = s.track_mut(track).and_then(|t| t.playlist_mut())?; + *pl.clip_mut(id)? = c.clone(); + pl.sort(); + if shuffle { + let moved = ripple(s, track, old.start.saturating_add(1), c.end().saturating_sub(old.end())); + follow_automation(s, track, old.end(), moved); + } + Some(c) +} + +/// Length of the file or movie under a clip; None for MIDI, which has no end. +fn material_len(s: &Session, c: &Clip) -> Option { + match c.content { + ClipContent::Audio { source, .. } => s.source(source).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)), + ClipContent::Video { source, .. } => s.video(source).map(|v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))), + ClipContent::Midi { .. } => None, + } +} + /// Create fades from a selection (the incumbent's Edit › Fades › Create semantics): /// a range across a clip boundary makes a crossfade; at a clip start a fade-in; at its end a fade-out. pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeShape) -> usize { @@ -311,23 +720,7 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh } let handles: Vec<(ClipId, Samples)> = { let Some(tr) = s.track(track) else { return 0 }; - tr.clips() - .iter() - .map(|c| { - let avail = match c.content { - ClipContent::Audio { source, offset } => { - let total = s.source(source).map_or(0, |src| i64::try_from(src.frames).unwrap_or(i64::MAX)); - total.saturating_sub(offset).saturating_sub(c.length) - } - ClipContent::Video { source, offset } => { - let total = s.video(source).map_or(0, |v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))); - total.saturating_sub(offset).saturating_sub(c.length) - } - ClipContent::Midi { .. } => 0, - }; - (c.id, avail) - }) - .collect() + tr.clips().iter().map(|c| (c.id, material_len(s, c).map_or(0, |n| n.saturating_sub(c.source_offset()).saturating_sub(c.length)))).collect() }; let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; pl.sort(); @@ -460,7 +853,7 @@ mod tests { let copied = copy_range(&s, t, Range::new(1500, 2500)); assert_eq!(copied.len(), 1); assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500)); - paste(&mut s, t, 4000, &copied, 1000, false); + paste(&mut s, t, 4000, &copied, 1000, false).unwrap(); assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]); } @@ -468,7 +861,7 @@ mod tests { fn paste_shuffle_ripples() { let (mut s, t) = session_with_clip(); let copied = copy_range(&s, t, Range::new(1000, 2000)); - paste(&mut s, t, 1000, &copied, 1000, true); + paste(&mut s, t, 1000, &copied, 1000, true).unwrap(); assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]); } @@ -500,13 +893,109 @@ mod tests { assert_eq!((c[0].end(), c[1].start), (3000, 3000)); } + fn three_clips() -> (Session, TrackId, [ClipId; 3]) { + let (mut s, t) = session_with_clip(); + let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id()]; + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips = vec![ + Clip::audio(ids[0], "a", SourceId(500), 0, 0, 1000), + Clip::audio(ids[1], "b", SourceId(500), 1000, 1000, 2000), + Clip::audio(ids[2], "c", SourceId(500), 3000, 5000, 1000), + ]; + } + (s, t, ids) + } + + #[test] + fn shuffle_moves_reorder_clips_and_keep_the_silence() { + let (mut s, t, [_, b, _]) = three_clips(); + assert_eq!(shuffle_clips(&mut s, &[b], -900, &[]), Ok(1)); + assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]); + let (mut s, t, [a, ..]) = three_clips(); + assert_eq!(shuffle_slot(&s, t, 900, &[a]), 0); + assert_eq!(shuffle_slot(&s, t, 1100, &[a]), 2000); + shuffle_clips(&mut s, &[a], 2500, &[]).unwrap(); + assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]); + } + + #[test] + fn shuffling_onto_an_empty_track_starts_it_at_zero() { + let (mut s, t, [_, _, c]) = three_clips(); + let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + shuffle_clips(&mut s, &[c], 40_000, &[(t, other)]).unwrap(); + assert_eq!(clips(&s, other), vec![(0, 1000, 3000)]); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000)]); + } + + #[test] + fn shuffle_trims_move_later_clips_and_slip_trims_stop_at_neighbours() { + let (mut s, t, [a, b, c]) = three_clips(); + trim_clip(&mut s, b, None, Some(2500), true); + trim_clip(&mut s, b, Some(1500), None, true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 1000, 1500), (4000, 1000, 3000)]); + let (mut s, t, _) = three_clips(); + trim_clip(&mut s, a, None, Some(2500), false); + trim_clip(&mut s, c, Some(2000), None, false); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000), (3000, 3000, 1000)]); + trim_clip(&mut s, c, None, Some(99_999), false); + assert_eq!(clips(&s, t)[2], (3000, 9000, 1000)); + } + + #[test] + fn shuffle_leaves_locked_clips_and_what_follows_them_in_place() { + let (mut s, t, [a, b, c]) = three_clips(); + s.find_clip_mut(c).unwrap().time_locked = true; + clear_range(&mut s, t, Range::new(1000, 3000), true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (5000, 1000, 3000)]); + let (mut s, t, [a2, ..]) = three_clips(); + s.find_clip_mut(c).unwrap().edit_locked = true; + assert_eq!(shuffle_room(&s, t, 0), 2000); + assert_eq!(make_room(&mut s, t, 0, 2500), Err(NO_ROOM)); + assert_eq!(make_room(&mut s, t, 0, 2000), Ok(())); + assert_eq!(clips(&s, t), vec![(2000, 1000, 0), (3000, 2000, 1000), (5000, 1000, 3000)]); + assert_eq!(shuffle_clips(&mut s, &[a2], 2500, &[]), Ok(1)); + assert_eq!(clips(&s, t)[2], (5000, 1000, 3000)); + let (mut s, t, _) = three_clips(); + s.find_clip_mut(c).unwrap().time_locked = true; + trim_clip(&mut s, b, None, Some(9000), true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 4000, 1000), (5000, 1000, 3000)]); + assert_eq!(shuffle_slot(&s, t, 4000, &[a]), 4000); + } + + #[test] + fn multi_track_moves_keep_the_tracks_apart() { + let (mut s, t1, [a, ..]) = three_clips(); + let t2 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let t3 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let m = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let d = s.new_clip_id(); + s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(500), 0, 0, 500)); + let dest = destinations(&s, &[a, d], t1, t2); + assert_eq!(dest, Some(vec![(t1, t2), (t2, t3)])); + assert_eq!(destinations(&s, &[a, d], t1, m), None); + assert_eq!(destinations(&s, &[a], t1, m), None); + assert_eq!(move_clips(&mut s, &[a, d], 0, &dest.unwrap()), 2); + assert_eq!(clips(&s, t2), vec![(0, 1000, 0)]); + assert_eq!(clips(&s, t3), vec![(0, 500, 0)]); + } + + #[test] + fn grid_moves_snap_the_clip_start_or_keep_its_offset() { + let (s, t) = session_with_clip(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + let c = s.track(t).unwrap().clips()[0].clone(); + assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step); + assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000); + assert_eq!(grid_delta(&s, &c, step / 3, true), 0); + } + #[test] fn moves_overwrite_and_respect_locks() { let (mut s, t) = session_with_clip(); let id = s.track(t).unwrap().clips()[0].id; - assert_eq!(move_clips(&mut s, &[id], 500, None), 1); + assert_eq!(move_clips(&mut s, &[id], 500, &[]), 1); assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]); s.find_clip_mut(id).unwrap().time_locked = true; - assert_eq!(move_clips(&mut s, &[id], 500, None), 0); + assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..aab3043 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -54,6 +54,15 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio id } +/// A BWF time stamp (samples at the file's rate) at the session's rate, as the audio is resampled. +fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 { + if file_rate == 0 || file_rate == session_rate { + return stamp; + } + let (from, to) = (u128::from(file_rate), u128::from(session_rate)); + u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX) +} + /// Decode and place an audio file. With no target track, a new track of matching width is made. pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); @@ -65,10 +74,12 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option return Err(EngineError::Io(format!("{name}: no audio"))); } let channels = buf.num_channels(); + let file_rate = info.sample_rate; let s = e.session_mut(); + let session_rate = s.sample_rate.hz(); let src = add_source(s, name, buf, path, info.format); if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) { - sx.time_reference = bwf.time_reference; + sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate); } let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0)); let track = match target { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534..173262d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -88,6 +88,18 @@ pub struct Clipboard { pub clip_effects: Vec<(String, f64)>, /// Markers inside the copied range (starts relative to it), for Paste Special › Merge Markers. pub markers: Vec, + /// How far the copied range started after a grid line; Relative Grid pastes keep it. + pub grid_offset: Samples, + /// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too. + pub automation_only: bool, +} + +impl Clipboard { + /// An empty clipboard for material copied from `range`. + pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard { + let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate); + Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() } + } } #[derive(Debug, Clone)] @@ -223,6 +235,7 @@ impl Engine { pub fn undo(&mut self) -> bool { let Some(h) = self.undo.pop() else { return false }; let cur = std::mem::replace(&mut self.doc, h.doc); + self.keep_edit_mode(&cur); self.redo.push(HistoryEntry { label: h.label, doc: cur }); self.revision = self.revision.wrapping_add(1); self.dirty = true; @@ -232,12 +245,27 @@ impl Engine { pub fn redo(&mut self) -> bool { let Some(h) = self.redo.pop() else { return false }; let cur = std::mem::replace(&mut self.doc, h.doc); + self.keep_edit_mode(&cur); self.undo.push(HistoryEntry { label: h.label, doc: cur }); self.revision = self.revision.wrapping_add(1); self.dirty = true; true } + /// Undo and Redo never change the Edit mode or its settings (Shuffle Lock above all). + fn keep_edit_mode(&mut self, from: &Session) { + use edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID}; + let flags = [SNAP_TO_GRID, SHUFFLE_LOCK, RELATIVE_GRID]; + let ed = &self.doc.edit; + if ed.edit_mode != from.edit.edit_mode || flags.iter().any(|f| ed.flag(f) != from.edit.flag(f)) { + let ed = &mut Arc::make_mut(&mut self.doc).edit; + ed.edit_mode = from.edit.edit_mode; + for f in flags { + ed.set_flag(f, from.edit.flag(f)); + } + } + } + /// Record an undo point manually (for interactive gestures committed by the UI). pub fn push_undo(&mut self, label: &str, before: Arc) { if Arc::ptr_eq(&before, &self.doc) { diff --git a/crates/engine/tests/edit_modes.rs b/crates/engine/tests/edit_modes.rs new file mode 100644 index 0000000..27d4a64 --- /dev/null +++ b/crates/engine/tests/edit_modes.rs @@ -0,0 +1,415 @@ +use serde_json::{Value, json}; +use soundcraft_engine::Engine; +use soundcraft_engine::edit::SHUFFLE_LOCK; +use soundcraft_model::{AutoParam, Clip, ClipId, EditMode, Source, SourceId, TrackId}; + +fn source(id: u64, frames: u64) -> Source { + Source { + id: SourceId(id), + name: "src".into(), + path: "src.wav".into(), + channels: 1, + frames, + sample_rate: 48_000, + format: soundcraft_audio_io::FileFormat::Wav, + time_reference: 0, + unsaved: false, + } +} + +fn new_track(e: &mut Engine) -> TrackId { + TrackId(e.execute("track.new", &json!({"count": 1})).unwrap()["tracks"][0].as_u64().unwrap()) +} + +fn engine(mode: &str) -> (Engine, TrackId, [ClipId; 2]) { + let mut e = Engine::default(); + let t = new_track(&mut e); + let s = e.session_mut(); + s.sources.push(source(7, 10_000)); + let ids = [s.new_clip_id(), s.new_clip_id()]; + s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips = + vec![Clip::audio(ids[0], "a", SourceId(7), 0, 0, 1000), Clip::audio(ids[1], "b", SourceId(7), 1000, 2000, 1000)]; + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + (e, t, ids) +} + +/// A track of clips (start, length, offset) on a long file, source 8. +fn long(mode: &str, spans: &[(i64, i64, i64)]) -> (Engine, TrackId, Vec) { + let mut e = Engine::default(); + let t = new_track(&mut e); + let s = e.session_mut(); + s.sources.push(source(8, 1_000_000)); + let ids: Vec = spans.iter().map(|_| s.new_clip_id()).collect(); + s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips = + spans.iter().zip(&ids).map(|(&(start, len, offset), id)| Clip::audio(*id, "c", SourceId(8), offset, start, len)).collect(); + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + (e, t, ids) +} + +fn clips(e: &Engine, t: TrackId) -> Vec<(i64, i64)> { + e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect() +} + +/// Each clip's id, length and file offset, in id order: what a move must never change. +fn material(e: &Engine, t: TrackId) -> Vec<(u64, i64, i64)> { + let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.id.0, c.length, c.source_offset())).collect(); + v.sort_unstable(); + v +} + +fn start(e: &Engine, id: ClipId) -> i64 { + e.session().find_clip(id).unwrap().1.start +} + +fn points(e: &Engine, t: TrackId) -> Vec { + e.session().track(t).unwrap().lane(&AutoParam::Volume).map(|l| l.points.iter().map(|p| p.at).collect()).unwrap_or_default() +} + +fn set_points(e: &mut Engine, t: TrackId, at: &[i64]) { + for a in at { + run(e, "automation.set_point", json!({"track": t.0, "param": "volume", "at": a, "value": -6.0})); + } +} + +fn run(e: &mut Engine, id: &str, p: Value) { + e.execute(id, &p).unwrap(); +} + +#[test] +fn insert_silence_pushes_in_shuffle_and_clears_otherwise() { + for (mode, want) in [("shuffle", vec![(0, 500), (700, 500), (2200, 1000)]), ("slip", vec![(0, 500), (700, 300), (2000, 1000)])] { + let (mut e, t, _) = engine(mode); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 500, "end": 700})); + assert_eq!(clips(&e, t), want, "{mode}"); + } +} + +#[test] +fn duplicate_and_repeat_make_room_in_shuffle() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (3000, 1000)]); + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.repeat", json!({"tracks": [t.0], "start": 0, "end": 1000, "count": 2})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000), (4000, 1000)]); + let (mut e, t, _) = engine("slip"); + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); +} + +#[test] +fn shuffle_paste_replaces_the_selection() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.copy", json!({"tracks": [t.0], "start": 0, "end": 1000})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 2000, "end": 2500})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 1000), (3000, 500)]); +} + +#[test] +fn clip_list_drops_land_on_a_clip_boundary_in_shuffle() { + let (mut e, t, [_, b]) = engine("shuffle"); + run(&mut e, "clip.place_source", json!({"source": 7, "track": t.0, "at": 1900})); + assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 10_000), (12_000, 1000)]); + e.session_mut().find_clip_mut(b).unwrap().time_locked = true; + assert!(e.execute("clip.place_source", &json!({"source": 7, "track": t.0, "at": 0})).is_err()); + assert_eq!(clips(&e, t).len(), 3); +} + +#[test] +fn grid_moves_to_a_position_snap_or_keep_their_offset() { + let (mut e, t, [_, b]) = engine("grid"); + let s = e.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": step + 1000})); + assert_eq!(clips(&e, t)[1].0, step); + run(&mut e, "edit.mode", json!({"mode": "relative_grid"})); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 2 * step + 2000})); + assert_eq!(clips(&e, t)[1].0, 2 * step); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "by": 200})); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 3 * step})); + assert_eq!(clips(&e, t)[1].0, 3 * step + 200); +} + +#[test] +fn clips_cannot_move_onto_tracks_without_a_playlist() { + let (mut e, t, [a, _]) = engine("slip"); + let aux = e.execute("track.new", &json!({"kind": "aux"})).unwrap()["tracks"][0].clone(); + assert!(e.execute("edit.move_clips", &json!({"clips": [a.0], "by": 0, "track": aux})).is_err()); + assert_eq!(clips(&e, t).len(), 2); +} + +#[test] +fn trim_clip_needs_an_edge_and_an_unlocked_clip() { + let (mut e, t, [a, b]) = engine("slip"); + assert!(e.execute("edit.trim_clip", &json!({"clip": a.0})).is_err()); + e.session_mut().find_clip_mut(b).unwrap().edit_locked = true; + assert!(e.execute("edit.trim_clip", &json!({"clip": b.0, "end": 2500})).is_err()); + run(&mut e, "edit.trim_clip", json!({"clip": a.0, "end": 5000})); + assert_eq!(clips(&e, t), vec![(0, 2000), (2000, 1000)]); +} + +#[test] +fn shuffle_lock_keeps_shuffle_mode_out() { + let (mut e, _, _) = engine("slip"); + run(&mut e, "edit.shuffle_lock", json!({})); + assert!(e.execute("edit.mode", &json!({"mode": "shuffle"})).is_err()); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + run(&mut e, "edit.shuffle_lock", json!({"value": false})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + assert!(e.execute("edit.shuffle_lock", &json!({})).is_err()); +} + +#[test] +fn snap_to_grid_rides_along_with_the_other_modes() { + let (mut e, _, _) = engine("slip"); + let snap = |e: &Engine| e.session().edit.flag(soundcraft_engine::edit::SNAP_TO_GRID); + run(&mut e, "edit.snap_to_grid", json!({})); + assert!(snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "spot", "snap_to_grid": true})); + assert!(snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + assert!(!snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "grid", "snap_to_grid": true})); + assert!(!snap(&e)); + assert!(e.execute("edit.snap_to_grid", &json!({})).is_err()); +} + +#[test] +fn shuffle_edits_that_would_push_a_locked_clip_are_refused() { + let (mut e, t, [_, b]) = engine("shuffle"); + e.session_mut().find_clip_mut(b).unwrap().time_locked = true; + for id in ["edit.insert_silence", "edit.duplicate"] { + let err = e.execute(id, &json!({"tracks": [t.0], "start": 0, "end": 1500})).unwrap_err().to_string(); + assert!(err.contains("locked clip"), "{id}: {err}"); + } + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(1000, 1000), (2000, 1000)]); + run(&mut e, "event.cut_time", json!({"start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]); +} + +#[test] +fn clips_dragged_from_several_tracks_keep_their_tracks_apart() { + let (mut e, t, [a, _]) = engine("slip"); + let new = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + let [t2, t3] = [0, 1].map(|i| TrackId(new[i].as_u64().unwrap())); + let s = e.session_mut(); + let d = s.new_clip_id(); + s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(7), 0, 5000, 500)); + run(&mut e, "edit.move_clips", json!({"clips": [a.0, d.0], "by": 0, "track": t2.0})); + assert_eq!(clips(&e, t), vec![(2000, 1000)]); + assert_eq!(clips(&e, t2), vec![(0, 1000)]); + assert_eq!(clips(&e, t3), vec![(5000, 500)]); + assert!(e.execute("edit.move_clips", &json!({"clips": [a.0, d.0], "by": 0, "track": t3.0})).is_err()); +} + +#[test] +fn relative_grid_pastes_keep_the_offset_from_the_grid() { + let (mut e, t, [_, b]) = engine("relative_grid"); + let s = e.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + run(&mut e, "edit.select", json!({"clips": [b.0]})); + run(&mut e, "edit.copy", json!({})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 3 * step})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t).last(), Some(&(3 * step + 2000, 1000))); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 5 * step})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000))); +} + +#[test] +fn shuffle_moves_keep_crossfade_partners_whole() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let whole = material(&e, t); + let [a, b, c] = [ids[0], ids[1], ids[2]]; + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 5000})); + assert_eq!(material(&e, t), whole); + assert_eq!([start(&e, b), start(&e, c), start(&e, a)], [0, 1100, 2100]); + let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + for by in [2500, -2500] { + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": by})); + assert_eq!(material(&e, t), whole, "{by}"); + assert_eq!(start(&e, ids[1]) - start(&e, ids[0]), 900, "{by}"); + } + assert_eq!([start(&e, ids[0]), start(&e, ids[2])], [0, 2000]); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": 5000})); + assert_eq!(material(&e, t), whole); +} + +#[test] +fn pasting_copied_automation_in_shuffle_leaves_the_clips_alone() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + set_points(&mut e, t, &[200]); + run(&mut e, "edit.copy_all_automation", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + run(&mut e, "edit.select", json!({"clips": [ids[1].0]})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); +} + +#[test] +fn automation_follows_shuffle_moves_trims_and_drops() { + let setup = || { + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + set_points(&mut e, t, &[500, 1500, 2500]); + (e, t, ids) + }; + let (mut e, t, ids) = setup(); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 3000})); + assert_eq!(points(&e, t), [500, 1500, 2500]); + assert_eq!([start(&e, ids[1]), start(&e, ids[2]), start(&e, ids[0])], [0, 1000, 2000]); + let (mut e, t, ids) = setup(); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 500})); + assert_eq!(points(&e, t), [1000, 2000]); + let (mut e, t, _) = setup(); + run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 1000})); + assert_eq!(points(&e, t), [500, 1_001_500, 1_002_500]); +} + +#[test] +fn shuffle_automation_stops_at_locked_clips_and_needs_automation_follows_edit() { + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (3000, 1000, 0)]); + e.session_mut().find_clip_mut(ids[2]).unwrap().time_locked = true; + set_points(&mut e, t, &[3500]); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500})); + assert_eq!((start(&e, ids[2]), points(&e, t)), (3000, vec![3500])); + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + set_points(&mut e, t, &[300, 1500, 5000]); + e.session_mut().edit.automation_follows_edit = false; + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 100})); + assert_eq!(points(&e, t), [300, 1500, 5000]); +} + +#[test] +fn shuffle_clear_moves_automation_with_the_clips() { + let (mut e, t, _) = long("shuffle", &[(0, 10_000, 0), (20_000, 10_000, 0)]); + set_points(&mut e, t, &[0, 9_000, 20_000, 29_000]); + run(&mut e, "edit.clear", json!({"track": t.0, "start": 4_000, "end": 6_000})); + assert_eq!(clips(&e, t), [(0, 4_000), (4_000, 4_000), (18_000, 10_000)]); + assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]); +} + +#[test] +fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() { + let (mut e, t, [a, _]) = engine("relative_grid"); + run(&mut e, "event.tempo", json!({"bpm": 90, "at": 0})); + run(&mut e, "edit.select", json!({"clips": [a.0]})); + run(&mut e, "edit.copy", json!({})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 128_000})); + run(&mut e, "edit.paste", json!({})); + assert!(clips(&e, t).contains(&(128_000, 1000)), "{:?}", clips(&e, t)); + run(&mut e, "edit.paste", json!({"tracks": [t.0], "at": 50_000})); + assert!(clips(&e, t).contains(&(50_000, 1000)), "{:?}", clips(&e, t)); +} + +#[test] +fn start_trims_keep_the_sync_point_on_its_audio() { + let (mut e, t, ids) = long("slip", &[(0, 10_000, 0)]); + let sync = |e: &Engine| e.session().find_clip(ids[0]).unwrap().1.sync_point; + e.session_mut().find_clip_mut(ids[0]).unwrap().sync_point = 5000; + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 2000})); + assert_eq!(sync(&e), 3000); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 8000})); + assert_eq!(sync(&e), 0); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "to": 30_000})); + assert_eq!(clips(&e, t), vec![(30_000, 2000)]); +} + +#[test] +fn imported_time_stamps_count_at_the_session_rate() { + use soundcraft_audio_io::{AudioBuffer, BwfInfo, EncodeOptions, FileFormat}; + let mut e = Engine::default(); + let bwf = BwfInfo { time_reference: 1_587_600_000, ..BwfInfo::default() }; + let opts = EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }; + let bytes = soundcraft_audio_io::encode(&AudioBuffer::new(44_100, 1, 441), &opts).unwrap(); + soundcraft_engine::io::import_audio_bytes(&mut e, "stamped.wav", &bytes, None, None, 0).unwrap(); + assert_eq!(e.session().sources[0].time_reference, 1_728_000_000); +} + +#[test] +fn shuffle_trims_stop_at_a_locked_crossfade_and_stay_inside_the_file() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 500), (1000, 1000, 5000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 950, "end": 1050})); + e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true; + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let before = material(&e, t); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 1500})); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 0})); + assert_eq!(material(&e, t), before); + let (mut e, _, [_, b]) = engine("shuffle"); + run(&mut e, "edit.trim_clip", json!({"clip": b.0, "start": 2500, "end": 20_000})); + let c = e.session().find_clip(b).unwrap().1; + assert_eq!((c.source_offset(), c.source_offset() + c.length), (1500, 10_000)); +} + +#[test] +fn duplicate_and_extend_makes_room_in_shuffle() { + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + run(&mut e, "edit.duplicate_extend", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true; + assert!(e.execute("edit.duplicate_extend", &json!({"tracks": [t.0], "start": 0, "end": 1000})).is_err()); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]); +} + +#[test] +fn undo_leaves_the_edit_mode_and_shuffle_lock_alone() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.shuffle_lock", json!({})); + for step in [Engine::undo, Engine::redo] { + assert!(step(&mut e)); + assert_eq!((e.session().edit.edit_mode, e.session().edit.flag(SHUFFLE_LOCK)), (EditMode::Slip, true)); + } +} + +#[test] +fn shuffle_cuts_reaching_before_the_session_start_keep_the_spacing() { + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": -5000, "end": 500})); + assert_eq!(clips(&e, t), vec![(0, 500), (500, 1000), (1500, 1000)]); +} + +#[test] +fn shuffle_never_pulls_a_crossfade_apart() { + let (mut e, t, ids) = long("slip", &[(0, 9000, 0), (9000, 11_000, 100_000), (20_000, 5000, 200_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 8000, "end": 10_000})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let err = e.execute("edit.duplicate", &json!({"tracks": [t.0], "start": 5000, "end": 9000})).unwrap_err().to_string(); + assert!(err.contains("crossfade"), "{err}"); + run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 7900})); + assert_eq!(clips(&e, t)[0], (0, 1_000_000)); + assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000_000, 1_008_000]); +} + +#[test] +fn clips_move_to_and_from_hidden_tracks() { + let (mut e, t, [a, _]) = engine("slip"); + let hidden = new_track(&mut e); + run(&mut e, "track.hide", json!({"tracks": [hidden.0], "hidden": true})); + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": hidden.0})); + assert_eq!(clips(&e, hidden), vec![(0, 1000)]); + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": t.0})); + assert_eq!(clips(&e, t).len(), 2); +} + +#[test] +fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() { + let (mut e, t, _) = engine("shuffle"); + assert!(e.execute("edit.shuffle_lock", &json!({"value": true, "tracks": [t.0]})).is_err()); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err()); +} diff --git a/crates/engine/tests/random_edits.rs b/crates/engine/tests/random_edits.rs index fc89179..8d90ccf 100644 --- a/crates/engine/tests/random_edits.rs +++ b/crates/engine/tests/random_edits.rs @@ -54,6 +54,8 @@ const CMDS: &[&str] = &[ "event.beat_detective", "automation.thin", "clip.remove_warp", + "clip.time_lock", + "clip.edit_lock", ]; fn params(i: usize, a: i64, b: i64, track: &str) -> Value { @@ -119,7 +121,7 @@ fn check_invariants(e: &Engine) -> Result<(), String> { proptest! { #![proptest_config(ProptestConfig { cases: 48, .. ProptestConfig::default() })] #[test] - fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..48, -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) { + fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..CMDS.len(), -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) { let mut e = soundcraft_engine::demo::demo_engine(); let original = serde_json::to_value(e.session()).unwrap(); let names = ["Kick", "Snare", "Bass", "Pad", "Keys"]; diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 4d30bea..eb8c8a9 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -214,6 +214,10 @@ impl Clip { // MIDI note ticks are re-anchored by the engine, which knows the tempo map. self.length -= delta; self.start = new_start; + if self.sync_point != 0 { + self.sync_point = self.sync_point.saturating_sub(delta); + } + self.drop_stray_sync(); self.clamp_fades(); self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect(); } @@ -222,6 +226,7 @@ impl Clip { pub fn trim_end_to(&mut self, new_end: Samples) { let new_end = new_end.max(self.start + 1); self.length = new_end - self.start; + self.drop_stray_sync(); self.clamp_fades(); } @@ -230,6 +235,13 @@ impl Clip { self.fade_in.len = self.fade_in.len.clamp(0, self.length); self.fade_out.len = self.fade_out.len.clamp(0, self.length); } + + /// A sync point (0 means none) that a trim left outside the clip no longer marks anything. + fn drop_stray_sync(&mut self) { + if !(0..self.length).contains(&self.sync_point) { + self.sync_point = 0; + } + } } fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index a5f4a1a..b10f82b 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -133,6 +133,18 @@ mod tests { (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30) } + #[test] + fn huge_typed_positions_parse_without_overflowing() { + let (sr, map, rate) = ctx(); + for (text, fmt) in [ + ("999999999999999999:0", TimeFormat::Timecode), + ("100000000000000:00:00:00", TimeFormat::Timecode), + ("-9223372036854775808", TimeFormat::BarsBeats), + ] { + let _ = parse_position(text, fmt, sr, &map, rate, 0); + } + } + #[test] fn formats_like_pro_tools_counters() { let (sr, map, rate) = ctx(); diff --git a/crates/time/src/grid.rs b/crates/time/src/grid.rs index 30e8f63..e7c391e 100644 --- a/crates/time/src/grid.rs +++ b/crates/time/src/grid.rs @@ -123,7 +123,13 @@ impl GridValue { pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { match *self { GridValue::Note { value, dotted, triplet } => { - let t = map.samples_to_ticks_f(pos, sr).floor() as i64; + // Float ticks can land a hair either side of a line's own tick. + let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64; + if map.tick_to_samples(t, sr) > pos { + t = t.saturating_sub(1); + } else if map.tick_to_samples(t.saturating_add(1), sr) <= pos { + t = t.saturating_add(1); + } // Grid lines restart at each bar line so odd meters stay aligned. let bb = map.bar_beat_at_tick(t); let bar_tick = map.bar_start_tick(bb.bar); @@ -133,7 +139,7 @@ impl GridValue { } _ => { let step = self.step_samples(pos, sr, map, rate); - pos.div_euclid(step) * step + pos.div_euclid(step).saturating_mul(step) } } } @@ -167,6 +173,19 @@ impl GridValue { mod tests { use super::*; + #[test] + fn grid_lines_are_their_own_line_at_odd_tempos() { + let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30); + for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] { + let map = TempoMap::new(bpm, 4, 4).unwrap(); + let mut line = 0; + for _ in 0..64 { + assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm"); + line = g.next_line(line, sr, &map, rate); + } + } + } + #[test] fn sixteenth_grid_at_120() { let g = GridValue::default(); diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 5b87a3a..22b3d82 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); diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs index c0db20f..0b34329 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,11 +153,11 @@ 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 = self.hours.saturating_mul(60).saturating_add(self.minutes); + let mut f = total_min.saturating_mul(60).saturating_add(self.seconds).saturating_mul(base).saturating_add(self.frames); if rate.is_drop() { - let drop = if base >= 60 { 4 } else { 2 }; - f -= drop * (total_min - total_min / 10); + let drop: i64 = if base >= 60 { 4 } else { 2 }; + f = f.saturating_sub(drop.saturating_mul(total_min - total_min / 10)); } if self.negative { -f } else { f } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..36ae7d5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -19,6 +19,7 @@ pub enum Dialog { 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 + Spot(crate::spot::Spot), Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, @@ -46,6 +47,7 @@ impl Dialogs { Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", Dialog::TempoChange { .. } => "tempo_change", + Dialog::Spot(_) => "spot", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { .. } => "group", @@ -264,6 +266,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", + Dialog::Spot(_) => "Spot", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { .. } => "Create Group", @@ -445,6 +448,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(tempo_change_action(*at, *bpm)); } } + Dialog::Spot(d) => { + crate::spot::fields(ui, app.engine.session(), d); + if buttons(ui, "OK", enter) { + keep = false; + action = d.command(app.engine.session()); + } + } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL { ui.radio_value(shape, s.label().to_lowercase(), s.label()); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..2dc63f3 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1,12 +1,17 @@ //! The Edit window: toolbar, rulers, track headers and playlists. +use crate::dialogs::Dialog; +use crate::spot::Spot; 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 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_engine::edit as ops; +use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, EditMode, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position}; +use std::collections::HashSet; +use std::sync::Arc; pub const HEADER_W: f32 = 236.0; pub const COLUMN_W: f32 = 112.0; @@ -256,7 +261,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { } 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) + ops::shown(s, t) } /// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one. @@ -1329,7 +1334,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { clip.source().and_then(|src| s.source(src)).map(|src| { format_position( - i64::try_from(src.time_reference).unwrap_or(0) + clip.source_offset(), + i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start), TimeFormat::Timecode, s.sample_rate, &s.tempo, @@ -1426,7 +1431,8 @@ fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) { // ---- interaction ----------------------------------------------------------------------------- fn snap(s: &Session, at: Samples) -> Samples { - if s.edit.edit_mode.is_grid() { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } + let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID); + if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } } fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { @@ -1480,8 +1486,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, && let Some(p) = ui.ctx().pointer_latest_pos() { let s = app.engine.session(); - let at = snap(s, sample_at(s, tl, p.x).max(0)); - let _ = app.run("clip.place_source", json!({"source": src.0, "track": track.id.0, "at": at})); + let at = drag_snap(s, sample_at(s, tl, p.x).max(0), ui.input(|i| i.modifiers.command)); + let spot = Spot::source(s, soundcraft_model::SourceId(src.0), track.id, at); + spot_or_run(app, spot); } if resp.dnd_hover_payload::().is_some() { ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside); @@ -1625,6 +1632,15 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let mut ids = if mods.shift { s.edit.selected_clips.iter().map(|x| x.0).collect::>() } else { Vec::new() }; ids.push(c.id.0); let _ = app.run("edit.select", json!({"clips": ids})); + if s.edit.edit_mode == EditMode::Spot { + app.dialogs.open = Spot::clips(&s, vec![c.id], None, 0).map(Dialog::Spot); + } + } + } + Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => { + if let Some(c) = &hit { + let start = at - c.start < c.end() - at; + app.dialogs.open = Spot::trim(&s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot); } } Tool::Zoom => { @@ -1696,22 +1712,14 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { *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, *grab_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; - } + Some(Gesture::MoveClips { clips, grab_at, delta, from_track, to_track: tt }) => { if let Some(t2) = to_track { *tt = t2; } + let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at); + *delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command)); } - Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), + Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)), Some(Gesture::Fade { to, .. }) => *to = at, Some(Gesture::Scrub { last }) => { *last = at; @@ -1759,16 +1767,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { 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::MoveClips { clips, delta, from_track, to_track, .. }) => drop_clips(app, clips, delta, from_track, to_track), 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. @@ -1776,8 +1775,8 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { 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::TrimStart { clip, to, .. }) => trim_clip(app, clip, to, true), + Some(Gesture::TrimEnd { clip, to, .. }) => trim_clip(app, clip, 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); @@ -1813,22 +1812,96 @@ fn automation_id(p: &AutoParam) -> String { } } -fn trim_clip(app: &mut SoundApp, clip: ClipId, track: TrackId, to: Samples, start: bool) { - let cmd = if start { "edit.trim_start_to_insertion" } else { "edit.trim_end_to_insertion" }; - // Trims through the command layer: select the clip's track and position the insertion. - let sel_before = app.engine.session().edit.selection; - let tracks_before: Vec = app.engine.session().edit.selected_tracks.iter().map(|x| x.0).collect(); - let c = app.engine.session().find_clip(clip).map(|(_, c)| c.clone()); - if let Some(c) = c { - if start && to < c.start || !start && to > c.end() { - // Extending: use trim to fill selection with handles. - let r = if start { Range::new(to, c.end()) } else { Range::new(c.start, to) }; - let _ = app.run("edit.trim_to_fill_selection", json!({"clips": [clip.0], "start": r.start, "end": r.end})); - } else { - let _ = app.run(cmd, json!({"tracks": [track.0], "at": to})); - } +/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip. +fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples { + if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } +} + +/// What a clip drag works out once per target track rather than every frame: where the tracks go +/// (None when the clips can't land there), each destination's earliest clip and Shuffle slots, +/// the clips that move (track, start, length) and the clip Grid snaps. +struct DragPlan { + dest: Option>, + groups: Vec<(TrackId, Samples, Vec)>, + moving: Vec<(TrackId, Samples, Samples)>, + grabbed: Option, +} + +/// The [`DragPlan`] for `clips` grabbed at `grab_at` on track `from` and dragged onto `to`, kept +/// until the document, the Edit mode or the target changes. +fn drag_plan(ctx: &egui::Context, s: &Session, revision: u64, clips: &[ClipId], (from, to): (TrackId, TrackId), grab_at: Samples) -> Arc { + type Key = (u64, EditMode, Vec, TrackId, TrackId, Samples); + let id = egui::Id::new("clip_drag_plan"); + let key: Key = (revision, s.edit.edit_mode, clips.to_vec(), from, to, grab_at); + if let Some((k, plan)) = ctx.data(|d| d.get_temp::<(Key, Arc)>(id)) + && k == key + { + return plan; + } + let dest = if to == from { Some(Vec::new()) } else { ops::destinations(s, clips, from, to) }; + let groups = match &dest { + Some(d) if s.edit.edit_mode == EditMode::Shuffle => { + ops::shuffle_groups(s, clips, d).into_iter().map(|(t, first)| (t, first, ops::shuffle_slots(s, t, clips))).collect() + } + _ => Vec::new(), + }; + let ids: HashSet = clips.iter().copied().collect(); + let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c); + let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect(); + let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default(); + let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone()); + let plan = Arc::new(DragPlan { dest, groups, moving, grabbed }); + ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan)))); + plan +} + +/// How far a Grabber drag by `raw` from track `from` takes the clips in the current Edit mode. +/// Shuffle lands the earliest clip bound for the target track on a slot there, as +/// `edit.move_clips` does; Grid snaps the grabbed clip. +fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples { + let mode = s.edit.edit_mode; + if mode == EditMode::Shuffle { + let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from)); + plan.groups + .iter() + .find(|g| g.0 == to) + .map_or(raw, |(_, first, slots)| ops::nearest_slot(slots, first.saturating_add(raw)).saturating_sub(*first)) + } else if mode.is_grid() != flip + && let Some(c) = &plan.grabbed + { + ops::grid_delta(s, c, raw, mode == EditMode::GridRelative) + } else { + raw + } +} + +/// The dragged clips with one from the grabbed track first: `edit.move_clips` sends that track to +/// the target and the others along with it. +fn grabbed_first(s: &Session, clips: &[ClipId], from: TrackId) -> Vec { + let mut v = clips.to_vec(); + v.sort_by_key(|c| s.find_clip(*c).is_none_or(|(t, _)| t != from)); + v +} + +fn drop_clips(app: &mut SoundApp, clips: Vec, delta: Samples, from: TrackId, to: TrackId) { + let s = app.engine.session(); + let spot = Spot::clips(s, grabbed_first(s, &clips, from), (to != from).then_some(to), delta); + spot_or_run(app, spot); +} + +fn trim_clip(app: &mut SoundApp, clip: ClipId, to: Samples, start: bool) { + let spot = Spot::trim(app.engine.session(), clip, start, to); + spot_or_run(app, spot); +} + +/// Spot mode asks for exact positions first; the other modes make the edit straight away. +fn spot_or_run(app: &mut SoundApp, spot: Option) { + let Some(spot) = spot else { return }; + if app.engine.session().edit.edit_mode == EditMode::Spot { + app.dialogs.open = Some(Dialog::Spot(spot)); + } else if let Some((id, params)) = spot.command(app.engine.session()) { + let _ = app.run(&id, params); } - let _ = app.engine.execute("edit.select", &json!({"tracks": tracks_before, "start": sel_before.start, "end": sel_before.end})); } fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { @@ -1966,12 +2039,20 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { scroll_to = follow_to; } // Ghost of a clip move. - if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture { - let row = app.edit_layout.rows.iter().find(|(i, _)| *i == to_track.0).map(|(_, r)| *r); - for c in clips { - if let (Some((_, clip)), Some(r)) = (s.find_clip(*c), row) { - let x0 = x_of(s, tl, clip.start + delta); - let x1 = x_of(s, tl, clip.end() + delta); + if let Some(Gesture::MoveClips { clips, delta, from_track, to_track, grab_at }) = &app.gesture + && let plan = drag_plan(ui.ctx(), s, app.engine.revision, clips, (*from_track, *to_track), *grab_at) + && let Some(dest) = &plan.dest + { + for &(t, start, len) in &plan.moving { + let d = ops::lands(dest, t); + let start = plan + .groups + .iter() + .find(|g| g.0 == d) + .map_or(start + delta, |(_, first, slots)| ops::nearest_slot(slots, first + delta) + (start - first)); + if let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == d.0) { + let x0 = x_of(s, tl, start); + let x1 = x_of(s, tl, start + len); painter.rect_stroke( Rect::from_min_max(pos2(x0, r[1] + 1.0), pos2(x1, r[3] - 1.0)), 2.0, diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7de4119..914061d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -140,6 +140,13 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c)); } } + "lock" => { + painter.rect_filled(Rect::from_min_max(p(r, 0.22, 0.46), p(r, 0.78, 0.88)), r.width() * 0.08, c); + let (x, y, w) = (r.center().x, r.min.y + r.height() * 0.46, r.width() * 0.19); + let arc: Vec = + (0..=12).map(|i| std::f32::consts::PI * i as f32 / 12.0).map(|a| pos2(x - w * a.cos(), y - w * 1.4 * a.sin())).collect(); + painter.add(Shape::line(arc, s)); + } "wave" => { let n = 24; let pts: Vec = (0..=n) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..b2309be 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod palette; pub mod panels; pub mod score_editor; pub mod shortcuts; +pub mod spot; pub mod theme; pub mod toolbar; pub mod video_track; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..6f1525a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -121,13 +121,21 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { true } Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => { - let mode = match key { - Key::F1 => "shuffle", - Key::F2 => "slip", - Key::F3 => "spot", - _ => "grid", + edit_mode_key(app, key); + true + } + Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 if m.alt && !m.command && !m.ctrl && !m.shift => { + let f = match key { + Key::Num1 => Key::F1, + Key::Num2 => Key::F2, + Key::Num3 => Key::F3, + _ => Key::F4, }; - let _ = app.run("edit.mode", json!({"mode": mode})); + edit_mode_key(app, f); + true + } + Key::Backtick if plain => { + cycle_edit_mode(app); true } Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => { @@ -198,6 +206,68 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } } +/// F1–F4 (and Alt+1–4) choose Shuffle, Slip, Spot and Grid; F4 in Grid switches between Absolute and +/// Relative unless Edit/Tool Mode Keyboard Lock is on. Holding F4 with F1–F3, in either order, adds Snap to Grid. +fn edit_mode_key(app: &mut SoundApp, pressed: Key) { + let ctx = app.docking_context.clone(); + let held = |k: Key| ctx.as_ref().is_some_and(|c| c.input(|i| i.key_down(k))); + let (key, snap) = match [Key::F1, Key::F2, Key::F3].into_iter().find(|k| *k != pressed && held(*k)) { + Some(k) if pressed == Key::F4 => (k, true), + _ => (pressed, pressed != Key::F4 && held(Key::F4)), + }; + let ed = &app.engine.session().edit; + let keep_grid = ed.edit_mode.is_grid() && ed.flag("options.edit_tool_mode_keyboard_lock"); + // F4 pressed first in an F4+F1–F3 chord switched the Grid mode on its own; put that back. + let flipped = egui::Id::new("edit_mode_key_grid_before_f4"); + let before = + (ed.edit_mode.is_grid() && !keep_grid).then(|| if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) { "relative_grid" } else { "grid" }); + let restore = match &ctx { + Some(c) if pressed == Key::F4 => { + c.data_mut(|d| d.insert_temp(flipped, before)); + None + } + Some(c) if snap => c.data_mut(|d| d.remove_temp::>(flipped)).flatten(), + _ => None, + }; + let mode = match key { + Key::F1 => "shuffle", + Key::F2 => "slip", + Key::F3 => "spot", + _ if keep_grid => return, + _ => grid_mode(app), + }; + if let Some(grid) = restore { + let _ = app.run("edit.mode", json!({"mode": grid})); + } + let _ = app.run("edit.mode", json!({"mode": mode, "snap_to_grid": snap})); +} + +/// What choosing Grid picks: in Grid the other Grid mode, from another mode the one used last. +pub(crate) fn grid_mode(app: &SoundApp) -> &'static str { + use soundcraft_model::EditMode; + let ed = &app.engine.session().edit; + match ed.edit_mode { + EditMode::Grid => "relative_grid", + EditMode::GridRelative => "grid", + _ if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) => "relative_grid", + _ => "grid", + } +} + +/// ` steps through the Edit modes in F-key order, past a locked Shuffle. +fn cycle_edit_mode(app: &mut SoundApp) { + use soundcraft_model::EditMode; + let ed = &app.engine.session().edit; + let mode = match ed.edit_mode { + EditMode::Shuffle => "slip", + EditMode::Slip => "spot", + EditMode::Spot => grid_mode(app), + _ if ed.flag(soundcraft_engine::edit::SHUFFLE_LOCK) => "slip", + _ => "shuffle", + }; + let _ = app.run("edit.mode", json!({"mode": mode})); +} + /// Single-key commands. Returns true when `key` was handled. fn commands_focus(app: &mut SoundApp, key: Key) -> bool { let id = match key { diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs new file mode 100644 index 0000000..77c4c37 --- /dev/null +++ b/crates/ui-egui/src/spot.rs @@ -0,0 +1,173 @@ +//! The Spot dialog: exact positions for clips in Spot mode. + +use crate::theme::mono; +use egui::{Id, RichText, TextEdit, Ui, vec2}; +use serde_json::{Value, json}; +use soundcraft_engine::cmd::MAX_POSITION; +use soundcraft_engine::edit::first_movable; +use soundcraft_model::{ClipId, Session, SourceId, TrackId}; +use soundcraft_time::{Samples, TimeFormat, format_length, format_position, parse_position}; + +#[derive(Debug, Clone)] +pub enum Target { + /// Clips moving together; the fields follow the earliest. + Clips { clips: Vec, track: Option }, + /// The edge of a clip the Trim tool took. + Trim { clip: ClipId, start: bool }, + /// An audio file dropped from the Clip List. + Source { source: SourceId, track: TrackId }, +} + +#[derive(Debug, Clone)] +pub struct Spot { + pub target: Target, + pub start: Samples, + pub len: Samples, + sync: Samples, + stamp: Option, + format: TimeFormat, + text: [String; 3], + fresh: bool, +} + +const FIELDS: [&str; 3] = ["Start", "Sync Point", "End"]; + +impl Spot { + fn new(s: &Session, target: Target, start: Samples, len: Samples, sync: Samples, stamp: Option) -> Spot { + Spot { target, start, len, sync, stamp, format: s.edit.main_counter, text: Default::default(), fresh: true } + } + + /// Clicked or dragged clips, moved `delta` so far. + pub fn clips(s: &Session, clips: Vec, track: Option, delta: Samples) -> Option { + let c = first_movable(s, &clips)?; + let stamp = c.source().and_then(|id| s.source(id)).map(|src| recorded_at(s, src.time_reference).saturating_add(c.source_offset())); + Some(Spot::new(s, Target::Clips { clips, track }, c.start.saturating_add(delta), c.length, c.sync_point, stamp)) + } + + /// A clip's start or end edge, dragged to `to`. + pub fn trim(s: &Session, clip: ClipId, start: bool, to: Samples) -> Option { + let (_, c) = s.find_clip(clip)?; + let (a, b) = if start { (to.min(c.end().saturating_sub(1)), c.end()) } else { (c.start, to.max(c.start.saturating_add(1))) }; + Some(Spot::new(s, Target::Trim { clip, start }, a, b.saturating_sub(a), 0, None)) + } + + /// An audio file from the Clip List, dropped at `at`. + pub fn source(s: &Session, source: SourceId, track: TrackId, at: Samples) -> Option { + let src = s.source(source)?; + let len = i64::try_from(src.frames).unwrap_or(0); + Some(Spot::new(s, Target::Source { source, track }, at, len, 0, Some(recorded_at(s, src.time_reference)))) + } + + fn value(&self, field: usize) -> Samples { + match field { + 0 => self.start, + 1 => self.start.saturating_add(self.sync), + _ => self.start.saturating_add(self.len), + } + } + + fn set(&mut self, field: usize, v: Samples) { + match (&self.target, field) { + (Target::Trim { start: true, .. }, _) => { + self.len = self.start.saturating_add(self.len).saturating_sub(v); + self.start = v; + } + (Target::Trim { .. }, _) => self.len = v.saturating_sub(self.start), + (_, 0) => self.start = v, + (_, 1) => self.start = v.saturating_sub(self.sync), + _ => self.start = v.saturating_sub(self.len), + } + } + + fn editable(&self, field: usize) -> bool { + match self.target { + Target::Trim { start, .. } => field == if start { 0 } else { 2 }, + _ => true, + } + } + + /// The command OK runs; None when nothing would change. + pub fn command(&self, s: &Session) -> Option<(String, Value)> { + Some(match &self.target { + Target::Clips { clips, track } => { + let first = first_movable(s, clips)?.start; + let by = self.start.saturating_sub(first); + if by == 0 && track.is_none() { + return None; + } + let mut p = json!({"clips": clips.iter().map(|c| c.0).collect::>(), "by": by}); + if let Some(t) = track { + p["track"] = json!(t.0); + } + ("edit.move_clips".into(), p) + } + Target::Trim { clip, start: true } => ("edit.trim_clip".into(), json!({"clip": clip.0, "start": self.start})), + Target::Trim { clip, .. } => ("edit.trim_clip".into(), json!({"clip": clip.0, "end": self.value(2)})), + Target::Source { source, track } => ("clip.place_source".into(), json!({"source": source.0, "track": track.0, "at": self.start})), + }) + } +} + +/// Timeline position of a BWF time stamp (samples since midnight). +fn recorded_at(s: &Session, time_reference: u64) -> Samples { + i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start) +} + +/// The dialog's fields; the caller adds OK and Cancel. +pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { + let show = |at: Samples, f: TimeFormat| format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label("Time Scale"); + egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { + for f in TimeFormat::ALL { + ui.selectable_value(&mut d.format, f, f.label()); + } + }); + ui.end_row(); + for (i, label) in FIELDS.into_iter().enumerate() { + if i == 1 && matches!(d.target, Target::Trim { .. }) { + continue; + } + ui.label(label); + if d.editable(i) { + let id = Id::new(("spot_field", i)); + if !ui.memory(|m| m.has_focus(id)) { + d.text[i] = show(d.value(i), d.format); + } + let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)); + if r.changed() + && let Ok(v) = parse_position(&d.text[i], d.format, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) + && (-MAX_POSITION..=MAX_POSITION).contains(&v) + { + d.set(i, v); + } + if std::mem::take(&mut d.fresh) { + r.request_focus(); + select_all(ui, id, &d.text[i]); + } + } else { + ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0))); + } + ui.end_row(); + } + ui.label("Duration"); + ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0))); + ui.end_row(); + if let Some(at) = d.stamp { + ui.label("Original Time Stamp"); + ui.horizontal(|ui| { + ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0))); + if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() { + d.start = at; + } + }); + ui.end_row(); + } + }); +} + +fn select_all(ui: &Ui, id: Id, text: &str) { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::default(), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..d0a809f 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -5,6 +5,7 @@ use crate::widgets::icon_button; use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; +use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID}; use soundcraft_model::{EditMode, Tool}; use soundcraft_time::{TimeFormat, format_length, format_position}; @@ -39,7 +40,9 @@ 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; + let ed = &app.engine.session().edit; + let (cur, snap, locked) = (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)); + let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac); group(ui, 112.0, 52.0, |ui, r| { let inner = r.shrink(6.0); let cw = inner.width() / 2.0; @@ -55,23 +58,49 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { 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 }); - 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() { - let id = match m { - EditMode::Grid if cur == EditMode::Grid => "relative_grid", - EditMode::Shuffle => "shuffle", - EditMode::Spot => "spot", - EditMode::Slip => "slip", - _ => "grid", - }; - let _ = app.run("edit.mode", json!({"mode": id})); + let fg = if on { t.mode_on_text } else { t.mode_off_text }; + if m == EditMode::Grid && snap { + ui.painter().rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside); } - resp.on_hover_text(format!("{} mode ({key})", label.to_lowercase())); + if m == EditMode::Shuffle && locked { + icons::draw(ui.painter(), Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg); + } else { + 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), fg); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); + if resp.clicked() { + mode_click(app, m, ui.input(|i| i.modifiers)); + } + resp.on_hover_text(match m { + EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"), + EditMode::Shuffle => format!("Shuffle mode ({key}); {cmd}-click locks it out"), + EditMode::Grid => format!("Grid mode ({key}; again for Relative); Shift-click: Snap to Grid in the other modes"), + _ => format!("{} mode ({key}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }), + }); } }); } +/// Shift adds Snap to Grid to Shuffle, Slip and Spot (on Grid it toggles it for the current mode); +/// Cmd/Ctrl on Shuffle toggles Shuffle Lock. +fn mode_click(app: &mut SoundApp, m: EditMode, mods: egui::Modifiers) { + let cur = app.engine.session().edit.edit_mode; + let _ = if m == EditMode::Shuffle && mods.command { + app.run("edit.shuffle_lock", json!({})) + } else if m == EditMode::Grid && mods.shift && !cur.is_grid() { + app.run("edit.snap_to_grid", json!({})) + } else { + let id = match m { + EditMode::Shuffle => "shuffle", + EditMode::Spot => "spot", + EditMode::Slip => "slip", + _ => crate::shortcuts::grid_mode(app), + }; + app.run("edit.mode", json!({"mode": id, "snap_to_grid": mods.shift})) + }; +} + fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { group(ui, 128.0, 52.0, |ui, _| { ui.vertical(|ui| { diff --git a/crates/ui-egui/tests/edit_modes.rs b/crates/ui-egui/tests/edit_modes.rs new file mode 100644 index 0000000..ab7e05e --- /dev/null +++ b/crates/ui-egui/tests/edit_modes.rs @@ -0,0 +1,293 @@ +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_engine::edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID}; +use soundcraft_model::{Clip, ClipId, EditMode, Source, SourceId, TrackId}; +use soundcraft_ui_egui::{Services, SoundApp, edit_window}; + +const SEC: i64 = 48_000; + +fn frame(ctx: &egui::Context, 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(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn key(key: Key) -> Vec { + chord(key, Modifiers::NONE) +} + +fn chord(key: Key, modifiers: Modifiers) -> Vec { + [true, false].map(|pressed| key_event(key, pressed, modifiers)).to_vec() +} + +fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn app(mode: &str, tool: &str, spans: &[(i64, i64)]) -> (SoundApp, egui::Context, TrackId, Vec) { + let (app, ctx, tracks, ids) = tracks(mode, tool, &[spans]); + (app, ctx, tracks[0], ids) +} + +fn tracks(mode: &str, tool: &str, lanes: &[&[(i64, i64)]]) -> (SoundApp, egui::Context, Vec, Vec) { + let mut e = Engine::default(); + let made = e.execute("track.new", &json!({"count": lanes.len()})).unwrap()["tracks"].clone(); + let tracks: Vec = (0..lanes.len()).map(|i| TrackId(made[i].as_u64().unwrap())).collect(); + let s = e.session_mut(); + s.sources.push(Source { + id: SourceId(7), + name: "src".into(), + path: "src.wav".into(), + channels: 1, + frames: 20 * SEC as u64, + sample_rate: 48_000, + format: soundcraft_audio_io::FileFormat::Wav, + time_reference: 0, + unsaved: false, + }); + let mut ids = Vec::new(); + for (t, spans) in tracks.iter().zip(lanes) { + for &(start, len) in *spans { + let id = s.new_clip_id(); + s.track_mut(*t).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(id, "c", SourceId(7), start, start, len)); + ids.push(id); + } + } + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + e.execute("edit.tool", &json!({"tool": tool})).unwrap(); + e.execute("view.zoom_set", &json!({"samples_per_px": 400.0})).unwrap(); + let mut app = SoundApp::new(e, None, Services::default()); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + (app, ctx, tracks, ids) +} + +fn point(app: &SoundApp, t: TrackId, at: i64) -> Pos2 { + let s = app.engine.session(); + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let row = app.edit_layout.rows.iter().find(|(id, _)| *id == t.0).unwrap().1; + let x = edit_window::x_of(s, Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)), at); + pos2(x, row[1] + s.track(t).unwrap().height.points() * 0.75) +} + +fn drag(ctx: &egui::Context, app: &mut SoundApp, t: TrackId, from: i64, to: i64) { + let (from, to) = (point(app, t, from), point(app, t, to)); + drag_between(ctx, app, from, to); +} + +fn drag_between(ctx: &egui::Context, app: &mut SoundApp, from: Pos2, to: Pos2) { + frame(ctx, app, vec![Event::PointerMoved(from)]); + frame(ctx, app, vec![button(from, true)]); + let started = from + vec2(7.0_f32.copysign(to.x - from.x), 0.0); + for k in 0..=5 { + frame(ctx, app, vec![Event::PointerMoved(started + (to - started) * (k as f32 / 5.0))]); + } + frame(ctx, app, vec![button(to, false)]); + frame(ctx, app, vec![]); +} + +fn click(ctx: &egui::Context, app: &mut SoundApp, at: Pos2) { + frame(ctx, app, vec![Event::PointerMoved(at)]); + frame(ctx, app, vec![button(at, true)]); + frame(ctx, app, vec![button(at, false)]); +} + +fn starts(app: &SoundApp, ids: &[ClipId]) -> Vec { + ids.iter().map(|id| app.engine.session().find_clip(*id).unwrap().1.start).collect() +} + +#[test] +fn shuffle_drags_swap_clips() { + let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 2, SEC / 2 - 2000); + assert_eq!(starts(&app, &ids), vec![SEC, 0]); +} + +#[test] +fn grid_drags_snap_the_dragged_clip_not_the_one_under_the_pointer() { + let (mut app, ctx, t, ids) = app("grid", "grabber", &[(1000, SEC), (3 * SEC + 777, SEC)]); + let s = app.engine.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + drag(&ctx, &mut app, t, SEC / 2, 3 * SEC + SEC / 2); + assert_eq!(starts(&app, &ids[..1])[0] % step, 0); +} + +#[test] +fn spot_clicks_open_the_spot_dialog() { + let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]); + let at = point(&app, t, SEC * 3 / 2); + click(&ctx, &mut app, at); + frame(&ctx, &mut app, vec![]); + assert_eq!(app.dialogs.open_name(), Some("spot")); + frame(&ctx, &mut app, vec![Event::Text("4.5".into())]); + frame(&ctx, &mut app, key(Key::Enter)); + assert!(app.dialogs.open.is_none()); + assert_eq!(starts(&app, &ids), vec![SEC * 9 / 2]); +} + +#[test] +fn spot_drags_ask_where_before_moving() { + let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 2, SEC * 5 / 2); + assert_eq!(starts(&app, &ids), vec![SEC]); + assert_eq!(app.dialogs.open_name(), Some("spot")); + frame(&ctx, &mut app, key(Key::Enter)); + assert!((starts(&app, &ids)[0] - 2 * SEC).abs() < 4000); +} + +#[test] +fn slip_trims_stop_at_the_next_clip() { + let (mut app, ctx, t, ids) = app("slip", "trim", &[(0, SEC), (2 * SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 4, 4 * SEC); + let s = app.engine.session(); + assert_eq!(s.find_clip(ids[0]).unwrap().1.end(), 2 * SEC); + assert_eq!(starts(&app, &ids), vec![0, 2 * SEC]); +} + +#[test] +fn f4_toggles_absolute_and_relative_grid_and_comes_back_to_the_last_one() { + let (mut app, ctx, _, _) = app("slip", "grabber", &[]); + let mode = |app: &SoundApp| app.engine.session().edit.edit_mode; + let mut seen = Vec::new(); + for k in [Key::F4, Key::F4, Key::F4, Key::F4, Key::F2, Key::F4, Key::F1, Key::Backtick, Key::Backtick, Key::Backtick] { + frame(&ctx, &mut app, key(k)); + seen.push(mode(&app)); + } + use EditMode::*; + assert_eq!(seen, [Grid, GridRelative, Grid, GridRelative, Slip, GridRelative, Shuffle, Slip, Spot, GridRelative]); + app.engine.execute("options.keyboard_lock", &json!({})).unwrap(); + frame(&ctx, &mut app, key(Key::F4)); + frame(&ctx, &mut app, chord(Key::Num4, Modifiers::ALT)); + assert_eq!(mode(&app), GridRelative); +} + +#[test] +fn snap_to_grid_snaps_the_edit_cursor_while_slip_moves_stay_free() { + let (mut app, ctx, t, ids) = app("slip", "grabber", &[(1000, SEC)]); + app.engine.execute("edit.snap_to_grid", &json!({})).unwrap(); + let s = app.engine.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + drag(&ctx, &mut app, t, SEC / 2, SEC / 2 + 10_000); + let moved = starts(&app, &ids)[0]; + assert!(moved > 1000 && moved % step != 0, "{moved}"); + app.engine.execute("edit.tool", &json!({"tool": "selector"})).unwrap(); + let at = point(&app, t, 3 * SEC + 1234); + click(&ctx, &mut app, at); + assert_eq!(app.engine.session().edit.selection.start % step, 0); +} + +#[test] +fn mode_keys_add_snap_to_grid_and_cycle_past_a_locked_shuffle() { + let (mut app, ctx, _, _) = app("slip", "grabber", &[]); + let state = |app: &SoundApp| (app.engine.session().edit.edit_mode, app.engine.session().edit.flag(SNAP_TO_GRID)); + frame(&ctx, &mut app, chord(Key::F4, Modifiers::SHIFT)); + assert_eq!(state(&app), (EditMode::Slip, true)); + let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE); + frame(&ctx, &mut app, vec![f4(true)]); + assert_eq!(state(&app), (EditMode::Grid, false)); + frame(&ctx, &mut app, key(Key::F1)); + frame(&ctx, &mut app, vec![f4(false)]); + assert_eq!(state(&app), (EditMode::Shuffle, true)); + frame(&ctx, &mut app, chord(Key::Num3, Modifiers::ALT)); + assert_eq!(state(&app), (EditMode::Spot, false)); + app.engine.execute("edit.shuffle_lock", &json!({})).unwrap(); + let mut seen = Vec::new(); + for _ in 0..4 { + frame(&ctx, &mut app, key(Key::Backtick)); + seen.push(state(&app).0); + } + assert_eq!(seen, [EditMode::Grid, EditMode::Slip, EditMode::Spot, EditMode::Grid]); + frame(&ctx, &mut app, key(Key::F1)); + assert_eq!(state(&app).0, EditMode::Grid); +} + +#[test] +fn toolbar_clicks_add_snap_to_grid_lock_shuffle_and_switch_grid_modes_despite_the_keyboard_lock() { + let mut e = Engine::default(); + e.execute("edit.mode", &json!({"mode": "slip"})).unwrap(); + let app = SoundApp::new(e, None, Services::default()); + let mut h = egui_kittest::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, + ); + h.run_steps(3); + let click = |h: &mut egui_kittest::Harness<'_, SoundApp>, label: &str, modifiers: Modifiers| { + h.get_by_label(label).click_modifiers(modifiers); + h.step(); + }; + let state = |h: &egui_kittest::Harness<'_, SoundApp>| { + let ed = &h.state().engine.session().edit; + (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)) + }; + click(&mut h, "GRID", Modifiers::SHIFT); + assert_eq!(state(&h), (EditMode::Slip, true, false)); + click(&mut h, "SPOT", Modifiers::NONE); + assert_eq!(state(&h), (EditMode::Spot, false, false)); + click(&mut h, "SHUFFLE", Modifiers::COMMAND); + click(&mut h, "SHUFFLE", Modifiers::NONE); + assert_eq!(state(&h), (EditMode::Spot, false, true)); + assert!(h.state().ui.status.contains("locked"), "{}", h.state().ui.status); + click(&mut h, "SLIP", Modifiers::SHIFT); + assert_eq!(state(&h), (EditMode::Slip, true, true)); + h.state_mut().engine.execute("options.keyboard_lock", &json!({})).unwrap(); + for (label, want) in [("GRID", EditMode::Grid), ("GRID", EditMode::GridRelative), ("SLIP", EditMode::Slip), ("GRID", EditMode::GridRelative)] { + click(&mut h, label, Modifiers::NONE); + assert_eq!(state(&h).0, want, "{label}"); + } +} + +#[test] +fn dragging_clips_on_two_tracks_moves_both_down_a_track() { + let (mut app, ctx, t, ids) = tracks("slip", "grabber", &[&[(0, SEC)], &[(2 * SEC, SEC)], &[]]); + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + let (from, to) = (point(&app, t[0], SEC / 2), point(&app, t[1], SEC / 2)); + drag_between(&ctx, &mut app, from, to); + let s = app.engine.session(); + assert_eq!(s.find_clip(ids[0]).map(|(t, _)| t), Some(t[1])); + assert_eq!(s.find_clip(ids[1]).map(|(t, _)| t), Some(t[2])); +} + +#[test] +fn shuffle_drags_of_clips_on_two_tracks_land_on_the_grabbed_tracks_boundaries() { + let lanes: [&[(i64, i64)]; 2] = [&[(4 * SEC, SEC), (5 * SEC, 3 * SEC), (17 * SEC / 2, SEC / 2)], &[(0, SEC), (SEC, 6 * SEC)]]; + let (mut app, ctx, t, ids) = tracks("shuffle", "grabber", &lanes); + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[3].0]})).unwrap(); + let before = starts(&app, &ids); + drag(&ctx, &mut app, t[0], 9 * SEC / 2, 9 * SEC / 2 + 3 * SEC / 10); + assert_eq!(starts(&app, &ids), before); +} + +#[test] +fn shuffle_drags_leave_locked_clips_in_the_selection_out_of_it() { + let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (3 * SEC, SEC)]); + app.engine.session_mut().find_clip_mut(ids[0]).unwrap().time_locked = true; + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + drag(&ctx, &mut app, t, 7 * SEC / 2, 2 * SEC); + assert_eq!(starts(&app, &ids), vec![0, SEC]); +} + +#[test] +fn f4_pressed_before_f1_leaves_the_kind_of_grid_alone() { + let (mut app, ctx, _, _) = app("grid", "grabber", &[]); + let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE); + frame(&ctx, &mut app, vec![f4(true)]); + frame(&ctx, &mut app, key(Key::F1)); + frame(&ctx, &mut app, vec![f4(false)]); + let ed = &app.engine.session().edit; + assert_eq!((ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(RELATIVE_GRID)), (EditMode::Shuffle, true, false)); +} 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 116/154] 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 117/154] 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 118/154] 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 119/154] 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 1953f2ecc6ecf93e186a9b72cdbd5402c5f86494 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:11:34 +0100 Subject: [PATCH 120/154] Track height: resize live while dragging, Pro Tools modifiers and Ctrl+Up/Down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a track's bottom edge showed nothing until release; then the track jumped to the nearest height preset. Pro Tools' track height modifiers and keys were missing too. Dragging: - The edge follows the pointer as in Pro Tools: the track steps through Micro…Extreme while the pointer moves, and the tracks below move with it. The whole drag is one undo step (execute_merged, ended on release like fader moves). - Cmd (Ctrl on Windows and Linux) drags to any height from Micro to Extreme instead of snapping. The model gains TrackHeight::Custom (points), and track.height takes a number of points as well as a preset name. - Alt resizes every track the Edit window shows; Alt+Shift resizes the selected tracks too. The track menu's Track Height items follow the same rule. - The drag is measured from where it started, not from the edge's current place: when the tracks above the dragged one resize they move its edge, and edge-relative math flipped between two heights on every frame. This also fixes rows showing playlist lanes, which a plain click on the edge used to make taller. Keys: - New commands track.height_up and track.height_down move each track one preset taller or shorter; a track already at Extreme or Micro is left alone, with no undo step. Control+Up/Down runs them on macOS, as in Pro Tools. Windows and Linux use Ctrl+Up/Down, because the Start key Pro Tools uses there isn't available. Alt steps every shown track, Alt+Shift the selected ones. - Ctrl+Up/Down used to run Extend Edit Up/Down, keys Pro Tools doesn't use for them. Those commands now have Pro Tools' Control+Shift+P and Control+Shift+; on macOS (macOS sends the latter as Control+:). Windows and Linux reach them from Edit > Selection. - Off macOS, menus no longer show Mac Control chords that can't be pressed there (Time Lock's Ctrl+Alt+L, Toggle Recent Playlist's Ctrl+Alt+Shift+Left, the new Extend Edit keys); the arrow chords that fixed() maps to Ctrl keep their labels. Track headers show each row of controls only once it fits: the keys from 46 points and the view and automation selectors from 86, where each row ends. The old 30 and 60 point cutoffs suited the presets only and cut controls off at heights in between. Every preset draws exactly as before. Tests: the model's custom heights, steps and JSON; the engine's point heights and steps, with no undo step when nothing changes; in the UI, the live drag and its single undo step, Cmd, Alt and Alt+Shift drags that hold steady while the pointer does, the height keys on macOS, Windows and Linux, and the Extend Edit keys on macOS. --- crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/track.rs | 57 ++++++++++- crates/model/src/track.rs | 32 +++++++ crates/ui-egui/src/edit_window.rs | 36 +++++-- crates/ui-egui/src/shortcuts.rs | 26 ++++- crates/ui-egui/tests/track_height.rs | 138 +++++++++++++++++++++++++++ 6 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/tests/track_height.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..aeffa78 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -72,8 +72,8 @@ pub fn specs() -> Vec { cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)), cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)), cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)), - cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, -1)), - cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, 1)), + cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], Some("Ctrl+Shift+P"), "{}", has_tracks, |e, _| extend_tracks(e, -1)), + cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], Some("Ctrl+Shift+;"), "{}", has_tracks, |e, _| extend_tracks(e, 1)), cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| { let s = e.session_mut(); if !s.edit.selected_tracks.is_empty() { diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2f231f4..e9f528a 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -58,7 +58,21 @@ pub fn specs() -> Vec { cmd!("track.rename", "Rename Track", [], None, "{track, name}", has_tracks, rename), cmd!("track.comments", "Track Comments", [], None, "{track, comments}", has_tracks, comments), cmd!("track.color", "Track Color", [], None, "{tracks?, color: [r,g,b] | palette index}", has_selection, color), - cmd!("track.height", "Track Height", [], None, "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme}", has_selection, height), + cmd!( + "track.height", + "Track Height", + [], + None, + "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme | points}", + has_selection, + height + ), + cmd!("track.height_up", "Increase Track Height", [], Some("Ctrl+Up"), "{tracks?} — one preset taller", has_selection, |e, p| height_step( + e, p, true + )), + cmd!("track.height_down", "Decrease Track Height", [], Some("Ctrl+Down"), "{tracks?} — one preset shorter", has_selection, |e, p| { + height_step(e, p, false) + }), cmd!("track.view", "Track View", [], None, "{tracks?, view: waveform|blocks|notes|volume|pan|mute|}", has_selection, view), cmd!("track.hide", "Hide Track", [], None, "{tracks?, hidden?: bool}", has_selection, |e, p| flag( e, @@ -485,7 +499,10 @@ fn color(e: &mut Engine, p: &Value) -> Result { fn height(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.height", p)?; - let h = str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?; + let h = match p.get("height").and_then(Value::as_f64) { + Some(points) => TrackHeight::from_points(points as f32), + None => str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?, + }; let s = e.session_mut(); for t in &tracks { if let Some(tr) = s.track_mut(*t) { @@ -495,6 +512,26 @@ fn height(e: &mut Engine, p: &Value) -> Result { Ok(json!({"height": h})) } +fn height_step(e: &mut Engine, p: &Value, taller: bool) -> Result { + let tracks = tracks_required(e, if taller { "track.height_up" } else { "track.height_down" }, p)?; + let mut steps = Vec::new(); + for tr in tracks.iter().filter_map(|t| e.session().track(*t)) { + let h = tr.height.step(taller); + if h != tr.height { + steps.push((tr.id, h)); + } + } + if !steps.is_empty() { + let s = e.session_mut(); + for (t, h) in &steps { + if let Some(tr) = s.track_mut(*t) { + tr.height = *h; + } + } + } + Ok(json!({"changed": steps.len()})) +} + fn view(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.view", p)?; let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string(); @@ -680,4 +717,20 @@ 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 heights_take_points_and_step_through_the_presets() { + let mut e = crate::demo::demo_engine(); + let height = |e: &Engine, name: &str| e.session().track_by_name(name).map(|t| t.height); + e.execute("track.height", &json!({"tracks": ["Kick"], "height": 57})).unwrap(); + assert_eq!(height(&e, "Kick"), Some(TrackHeight::Custom(57))); + e.execute("track.height_up", &json!({"tracks": ["Kick", "Snare"]})).unwrap(); + assert_eq!((height(&e, "Kick"), height(&e, "Snare")), (Some(TrackHeight::Medium), Some(TrackHeight::Large))); + e.execute("track.height", &json!({"tracks": ["Kick"], "height": "extreme"})).unwrap(); + let steps = e.undo_history().len(); + assert_eq!(e.execute("track.height_up", &json!({"tracks": ["Kick"]})).unwrap()["changed"], 0); + assert_eq!(e.undo_history().len(), steps); + e.execute("track.height_down", &json!({"tracks": ["Kick"]})).unwrap(); + assert_eq!(height(&e, "Kick"), Some(TrackHeight::Jumbo)); + } } diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index aa2b64f..7a2f0a0 100644 --- a/crates/model/src/track.rs +++ b/crates/model/src/track.rs @@ -85,6 +85,8 @@ impl TrackKind { /// Track heights (View › Track Height presets). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] pub enum TrackHeight { + /// Any height from Micro to Extreme, in points. + Custom(u16), Micro, Mini, Small, @@ -108,6 +110,7 @@ impl TrackHeight { ]; pub fn points(self) -> f32 { match self { + TrackHeight::Custom(points) => f32::from(points).clamp(TrackHeight::Micro.points(), TrackHeight::Extreme.points()), TrackHeight::Micro => 16.0, TrackHeight::Mini => 24.0, TrackHeight::Small => 46.0, @@ -120,6 +123,7 @@ impl TrackHeight { } pub fn label(self) -> &'static str { match self { + TrackHeight::Custom(_) => "custom", TrackHeight::Micro => "micro", TrackHeight::Mini => "mini", TrackHeight::Small => "small", @@ -133,6 +137,18 @@ impl TrackHeight { pub fn from_id(s: &str) -> Option { TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s)) } + /// The preset this many points tall, else a custom height clamped to Micro..Extreme. + pub fn from_points(points: f32) -> TrackHeight { + let p = points.max(TrackHeight::Micro.points()).min(TrackHeight::Extreme.points()).round() as u16; + TrackHeight::ALL.into_iter().find(|h| h.points() == f32::from(p)).unwrap_or(TrackHeight::Custom(p)) + } + /// The next preset taller (or shorter) than this height; Extreme (or Micro) stays. + pub fn step(self, taller: bool) -> TrackHeight { + let p = self.points(); + let next = + if taller { TrackHeight::ALL.into_iter().find(|h| h.points() > p) } else { TrackHeight::ALL.into_iter().rev().find(|h| h.points() < p) }; + next.unwrap_or(self) + } } /// The default track colour palette (original, chosen for SoundCraft). @@ -337,4 +353,20 @@ mod tests { assert_eq!(TrackKind::from_id(k.id()), Some(k)); } } + + #[test] + fn custom_heights_round_to_presets_and_step_through_them() { + assert_eq!(TrackHeight::from_points(88.0), TrackHeight::Medium); + assert_eq!(TrackHeight::from_points(57.4), TrackHeight::Custom(57)); + assert_eq!(TrackHeight::from_points(9999.0), TrackHeight::Extreme); + assert_eq!(TrackHeight::from_points(f32::NAN), TrackHeight::Micro); + assert_eq!(TrackHeight::Custom(u16::MAX).points(), 440.0); + assert_eq!(TrackHeight::Custom(57).step(true), TrackHeight::Medium); + assert_eq!(TrackHeight::Custom(57).step(false), TrackHeight::Small); + assert_eq!(TrackHeight::Extreme.step(true), TrackHeight::Extreme); + assert_eq!(TrackHeight::Micro.step(false), TrackHeight::Micro); + let json = serde_json::to_string(&TrackHeight::Custom(57)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), TrackHeight::Custom(57)); + assert_eq!(serde_json::from_str::("\"Large\"").unwrap(), TrackHeight::Large); + } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..5bb1956 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -259,6 +259,12 @@ 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' rule for track height: Alt reaches every shown track, Alt+Shift the selected ones too. +pub(crate) fn height_targets(s: &Session, m: egui::Modifiers, own: &[TrackId]) -> Vec { + let extra = |t: &Track| if m.shift { s.edit.selected_tracks.contains(&t.id) } else { in_edit_window(s, t) }; + s.tracks.iter().filter(|t| own.contains(&t.id) || (m.alt && extra(t))).map(|t| t.id.0).collect() +} + /// 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(); @@ -777,13 +783,26 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele if eresp.hovered() || eresp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - if eresp.drag_stopped() + if eresp.drag_started() { + ui.data_mut(|d| d.insert_temp(eresp.id, track.height)); + } + if eresp.dragged() && let Some(p) = ui.ctx().pointer_latest_pos() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()).filter(|o| o.y != p.y) { - let want = (p.y - head.min.y).max(10.0); - let best = TrackHeight::ALL.into_iter().min_by_key(|h| ((h.points() - want).abs() * 10.0) as i64).unwrap_or(TrackHeight::Medium); - if best != track.height { - let _ = app.run("track.height", json!({"tracks": [id.0], "height": best.label()})); + let start = ui.data(|d| d.get_temp::(eresp.id)).unwrap_or(track.height); + let want = start.points() + p.y - origin.y; + let m = ui.input(|i| i.modifiers); + let best = if m.command { + TrackHeight::from_points(want) + } else { + TrackHeight::ALL.into_iter().min_by_key(|h| ((h.points() - want).abs() * 10.0) as i64).unwrap_or(start) + }; + let s = app.engine.session(); + let tracks = height_targets(s, m, &[id]); + if tracks.iter().any(|t| s.track(TrackId(*t)).is_some_and(|tr| tr.height != best)) { + let _ = app.engine.execute_merged("track.height", &json!({"tracks": tracks, "height": best.points()}), &format!("track_height:{}", id.0)); + ui.ctx().request_repaint(); } } let x0 = head.min.x + 12.0; @@ -896,7 +915,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele egui::Popup::menu(&resp).show(|ui| crate::mix_window::instrument_menu(app, ui, id)); } } - if h < 30.0 { + if h < 46.0 { return; } // Buttons row. @@ -926,7 +945,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let _ = app.run("mix.mute", json!({"track": id.0})); } } - if h < 60.0 { + if h < 86.0 { return; } // View selector. @@ -1107,7 +1126,8 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { ui.menu_button("Track Height", |ui| { for h in TrackHeight::ALL { if ui.button(h.label()).clicked() { - let _ = app.run("track.height", json!({"tracks": [tid], "height": h.label()})); + let tracks = height_targets(app.engine.session(), ui.input(|i| i.modifiers), &[id]); + let _ = app.run("track.height", json!({"tracks": tracks, "height": h.label()})); } } }); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..0112239 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -175,9 +175,17 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } 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!({})); + // Pro Tools' Control+Up/Down track height, on Ctrl off macOS too (its Start key isn't available there). + Key::ArrowUp | Key::ArrowDown if m.ctrl && (m.alt || !m.shift) => { + let s = app.engine.session(); + let tracks = crate::edit_window::height_targets(s, m, &s.edit.selected_tracks); + let id = if key == Key::ArrowUp { "track.height_up" } else { "track.height_down" }; + let _ = app.run(id, if tracks.is_empty() { json!({}) } else { json!({"tracks": tracks}) }); + true + } + // Extend Edit Down is Control+Shift+;, which macOS sends as Control+:. + Key::Semicolon | Key::Colon if mac && m.ctrl && m.shift && !m.alt && !m.command => { + let _ = app.run("edit.extend_selection_down", json!({})); true } // Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key). @@ -258,15 +266,19 @@ fn tab_clip(app: &mut SoundApp, back: bool) { /// 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 { - if mac || !s.split('+').any(|p| p == "Cmd") { + let has = |m: &str| s.split('+').any(|p| p == m); + if mac || (!has("Cmd") && !has("Ctrl")) || PC_CTRL_CHORDS.contains(&s) { return s.to_string(); } - if s.split('+').any(|p| p == "Ctrl") { + if has("Ctrl") { return String::new(); } s.split('+').map(|p| if p == "Cmd" { "Ctrl" } else { p }).collect::>().join("+") } +/// Mac Control chords that `fixed` also maps to Ctrl off macOS. +const PC_CTRL_CHORDS: [&str; 4] = ["Ctrl+Up", "Ctrl+Down", "Ctrl+Shift+Up", "Ctrl+Shift+Down"]; + #[cfg(test)] mod label_tests { use super::shortcut_label; @@ -280,6 +292,10 @@ mod label_tests { assert_eq!(shortcut_label("Shift+R", false), "Shift+R"); assert_eq!(shortcut_label("Cmd+Ctrl+S", false), ""); assert_eq!(shortcut_label("", false), ""); + assert_eq!(shortcut_label("Ctrl+Up", false), "Ctrl+Up"); + assert_eq!(shortcut_label("Ctrl+Shift+Down", false), "Ctrl+Shift+Down"); + assert_eq!(shortcut_label("Ctrl+Shift+P", false), ""); + assert_eq!(shortcut_label("Ctrl+Alt+L", false), ""); } #[test] diff --git a/crates/ui-egui/tests/track_height.rs b/crates/ui-egui/tests/track_height.rs new file mode 100644 index 0000000..027dad9 --- /dev/null +++ b/crates/ui-egui/tests/track_height.rs @@ -0,0 +1,138 @@ +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_model::TrackHeight; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +fn frame(ctx: &egui::Context, 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(); +} + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn rig() -> (egui::Context, SoundApp) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + (ctx, app) +} + +fn id(app: &SoundApp, name: &str) -> u64 { + app.engine.session().track_by_name(name).unwrap().id.0 +} + +fn height(app: &SoundApp, name: &str) -> TrackHeight { + app.engine.session().track_by_name(name).unwrap().height +} + +fn row(app: &SoundApp, name: &str) -> [f32; 4] { + let id = id(app, name); + app.edit_layout.rows.iter().find(|(t, _)| *t == id).unwrap().1 +} + +fn select(app: &mut SoundApp, names: &[&str]) { + let ids: Vec = names.iter().map(|n| id(app, n)).collect(); + app.engine.execute("edit.select", &json!({"tracks": ids})).unwrap(); +} + +/// Drags `name`'s bottom edge by `dy` with `m` held, holding still for a few frames before letting go. +fn drag_edge(ctx: &egui::Context, app: &mut SoundApp, name: &str, dy: f32, m: Modifiers) { + let [x, _, _, bottom] = row(app, name); + let at = pos2(x + 40.0, bottom - 1.0); + frame(ctx, app, vec![Event::ModifiersChanged(m), Event::PointerMoved(at)]); + frame(ctx, app, vec![button(at, true)]); + frame(ctx, app, vec![Event::PointerMoved(at + vec2(0.0, dy))]); + let held: Vec = app.engine.session().tracks.iter().map(|t| t.height).collect(); + for _ in 0..3 { + frame(ctx, app, vec![]); + assert!(app.engine.session().tracks.iter().map(|t| t.height).eq(held.iter().copied()), "the heights hold while the pointer does"); + } + frame(ctx, app, vec![button(at + vec2(0.0, dy), false), Event::ModifiersChanged(Modifiers::NONE)]); + frame(ctx, app, vec![]); +} + +#[test] +fn dragging_a_track_edge_resizes_it_live_in_one_undo_step() { + let (ctx, mut app) = rig(); + assert_eq!(height(&app, "Kick"), TrackHeight::Medium); + let [x, _, _, bottom] = row(&app, "Kick"); + let at = |dy: f32| pos2(x + 40.0, bottom + dy); + + frame(&ctx, &mut app, vec![Event::PointerMoved(at(-1.0))]); + frame(&ctx, &mut app, vec![button(at(-1.0), true)]); + for (dy, want) in [(20.0, TrackHeight::Medium), (44.0, TrackHeight::Large), (132.0, TrackHeight::Jumbo), (40.0, TrackHeight::Large)] { + frame(&ctx, &mut app, vec![Event::PointerMoved(at(dy))]); + frame(&ctx, &mut app, vec![]); + let [_, y0, _, y1] = row(&app, "Kick"); + assert_eq!((height(&app, "Kick"), y1 - y0), (want, want.points()), "{dy}"); + } + frame(&ctx, &mut app, vec![button(at(40.0), false)]); + frame(&ctx, &mut app, vec![]); + + app.engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(height(&app, "Kick"), TrackHeight::Medium); +} + +#[test] +fn cmd_drags_to_any_height_alt_resizes_every_track_and_alt_shift_the_selected_ones() { + let (ctx, mut app) = rig(); + drag_edge(&ctx, &mut app, "Kick", 20.0, Modifiers::COMMAND); + assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Custom(108), TrackHeight::Medium)); + + drag_edge(&ctx, &mut app, "Snare", 44.0, Modifiers::ALT); + assert!( + app.edit_layout.rows.iter().all(|(t, _)| app.engine.session().track(soundcraft_model::TrackId(*t)).unwrap().height == TrackHeight::Large) + ); + + select(&mut app, &["Hats", "Bass"]); + drag_edge(&ctx, &mut app, "Kick", -86.0, Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + let small = [TrackHeight::Small, TrackHeight::Large, TrackHeight::Small, TrackHeight::Small]; + assert_eq!(["Kick", "Snare", "Hats", "Bass"].map(|n| height(&app, n)), small); + + app.engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(["Kick", "Snare", "Hats", "Bass"].map(|n| height(&app, n)), [TrackHeight::Large; 4]); +} + +#[test] +fn ctrl_arrows_step_the_selected_tracks_and_ctrl_alt_arrows_every_track() { + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let (ctx, mut app) = rig(); + ctx.set_os(os); + select(&mut app, &["Kick"]); + press(&ctx, &mut app, Key::ArrowUp, ctrl); + assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Large, TrackHeight::Medium), "{os:?}"); + press(&ctx, &mut app, Key::ArrowDown, ctrl); + press(&ctx, &mut app, Key::ArrowDown, ctrl); + assert_eq!(height(&app, "Kick"), TrackHeight::Small, "{os:?}"); + press(&ctx, &mut app, Key::ArrowUp, Modifiers { alt: true, ..ctrl }); + assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Medium, TrackHeight::Large), "{os:?}"); + assert_eq!(app.engine.session().edit.selected_tracks.len(), 1, "{os:?}"); + } +} + +#[test] +fn control_shift_p_and_semicolon_extend_the_edit_on_macos() { + let (ctx, mut app) = rig(); + ctx.set_os(OperatingSystem::Mac); + select(&mut app, &["Snare"]); + let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE }; + press(&ctx, &mut app, Key::P, ctrl_shift); + press(&ctx, &mut app, Key::Colon, ctrl_shift); + let selected: Vec = app.engine.session().edit.selected_tracks.iter().map(|t| t.0).collect(); + assert_eq!(selected, ["Kick", "Snare", "Hats"].map(|n| id(&app, n))); +} 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 121/154] 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 122/154] 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 123/154] 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 b5ea46527477c426b47241058d48f6f2e5a078a0 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:36:15 +0100 Subject: [PATCH 124/154] Autosave each session to its own folder and offer unsaved work at launch Autosave wrote every session called "Untitled" to the same Autosave/Untitled folder and reused audio files there by source id, so a second untitled session, or a take recorded after an undo, could be autosaved with another take's audio. Its first run also copied all of the session's audio again. Nothing ever offered an autosave back (latest_autosave had no callers), so after a crash the work could only be found by digging in the settings folder. Each open session now autosaves to a folder of its own, locked while the session is open. Audio already saved with the session is referenced where it is; audio that exists only in memory is copied once per recording or render. Saving, closing the session, opening another and quitting with SoundCraft's Quit delete the folder. A crash, or an exit that skips Quit with unsaved changes, leaves it behind, after one last autosave when SoundCraft gets the chance. At the next launch, SoundCraft offers each kept folder, newest first, to recover or discard. Recovered work opens as an unsaved version of the session it came from, so Save writes it back there; unsaved changes in the session it replaces are kept the same way. Folders older than their session file are dropped. --- apps/soundcraft/src/main.rs | 2 + crates/engine/src/autosave.rs | 268 +++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/ui-egui/src/lib.rs | 74 ++++++++- crates/ui-egui/src/recovery.rs | 48 ++++++ crates/ui-egui/tests/recovery.rs | 103 ++++++++++++ 6 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/autosave.rs create mode 100644 crates/ui-egui/src/recovery.rs create mode 100644 crates/ui-egui/tests/recovery.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7..65db539 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -88,6 +88,7 @@ impl eframe::App for App { self.0.ui(ui); } fn on_exit(&mut self) { + self.0.close_autosave(); save_prefs(&self.0.ui); if let Some(logger) = self.1 { logger.report_audio_thread(); @@ -258,6 +259,7 @@ fn main() -> eframe::Result { let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) }; let mut app = SoundApp::new(engine, player, services()); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); + app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default(); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); app.extra.ui_scale = prefs.ui_scale; app.ui = prefs; diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 0000000..a989718 --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -0,0 +1,268 @@ +//! Crash-recovery copies of open sessions, one folder each, and the recovery of the ones left +//! behind with unsaved work. + +use crate::io::{open_session, sanitize_name}; +use crate::{Engine, EngineError, Result}; +use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; +use soundcraft_model::{Session, SourceAudio, SourceId}; +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; +use std::time::SystemTime; + +const SESSION: &str = "session.scraft"; +const ORIGIN: &str = "origin"; +const LOCK: &str = "lock"; + +/// An open session's autosave folder, locked while the session is open so a later launch can tell +/// it from one a crash left behind. +pub struct Autosave { + dir: PathBuf, + lock: Option, + copies: BTreeMap, String)>, + next: u64, +} + +impl Autosave { + /// A new folder under `root` for the session called `name`. + pub fn create(root: &Path, name: &str) -> Result { + let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); + let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); + let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; + lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; + Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1 }) + } + + /// Write the session as it is now. Audio saved with it is referenced where it is; audio that + /// exists only in memory is copied, once per recording or render. + pub fn write(&mut self, e: &Engine) -> Result<()> { + let base = e.path.as_deref().and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); + let audio_dir = self.dir.join("Audio Files"); + let mut s = e.session().clone(); + let mut copies = BTreeMap::new(); + for src in &mut s.sources { + let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) }; + let audio = s.pool.get(src.id); + if let Some(f) = &file + && (!src.unsaved && f.is_file() || audio.is_none()) + { + src.path = f.to_string_lossy().into_owned(); + continue; + } + let Some(audio) = audio else { continue }; + let name = match self.copies.remove(&src.id) { + Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name, + _ => { + let name = format!("{} {}.wav", sanitize_name(&src.name), self.next); + self.next += 1; + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: 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::create_dir_all(&audio_dir) + .and_then(|()| write_synced(&audio_dir.join(&name), &bytes)) + .map_err(|err| io_error(&audio_dir, err))?; + name + } + }; + src.path = audio_dir.join(&name).to_string_lossy().into_owned(); + src.unsaved = true; + copies.insert(src.id, (Arc::downgrade(audio), name)); + } + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + write_atomically(&self.dir.join(ORIGIN), e.path.as_deref().unwrap_or_default().as_bytes())?; + write_atomically(&self.dir.join(SESSION), text.as_bytes())?; + for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() { + let _ = std::fs::remove_file(audio_dir.join(name)); + } + Ok(()) + } + + /// Delete the folder: the session closed with nothing left to recover. + pub fn discard(mut self) { + drop(self.lock.take()); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Unsaved work left behind in an autosave folder. +#[derive(Debug, Clone, PartialEq)] +pub struct Leftover { + pub dir: PathBuf, + /// The session's name. + pub name: String, + /// The session file it was autosaved from, if it had one. + pub origin: Option, + pub saved: SystemTime, +} + +/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the +/// session file they came from. Empty and outdated ones are deleted. +pub fn leftovers(root: &Path) -> Vec { + let mut out = Vec::new(); + for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) { + let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue }; + if lock.try_lock().is_err() { + continue; + } + let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty()); + let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok()); + let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified()); + drop(lock); + match saved { + Ok(saved) if origin_saved.is_none_or(|o| saved > o) => { + let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default(); + let named = serde_json::from_str::(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string)); + let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned()); + out.push(Leftover { dir, name, origin, saved }); + } + _ => { + let _ = std::fs::remove_dir_all(&dir); + } + } + } + out.sort_by_key(|l| std::cmp::Reverse(l.saved)); + out +} + +/// Open `left` as an unsaved version of the session it came from, so Save writes it back there. +/// Returns the media that couldn't be loaded. +pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { + let mut r = Engine::default(); + let missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; + let mut s = r.session().clone(); + if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) { + for src in s.sources.iter_mut().filter(|src| !src.unsaved) { + if let Ok(rel) = Path::new(&src.path).strip_prefix(home) { + src.path = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + } + } + } + e.replace_session(Session::default()); + *e.session_mut() = s; + e.path = left.origin.clone(); + Ok(missing) +} + +/// Delete a leftover autosave. +pub fn discard(left: &Leftover) { + let _ = std::fs::remove_dir_all(&left.dir); +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut f = std::fs::File::create(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> { + let tmp = path.with_extension("tmp"); + write_synced(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|err| { + let _ = std::fs::remove_file(&tmp); + io_error(path, err) + }) +} + +fn io_error(path: &Path, err: std::io::Error) -> EngineError { + EngineError::Io(format!("{}: {err}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + + fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn record(e: &mut Engine, level: f32) { + let before = e.session_arc(); + let s = e.session_mut(); + let t = match s.tracks.first() { + Some(t) => t.id, + None => s.add_track(TrackKind::Audio, ChannelFormat::Mono, None), + }; + let src = + crate::io::add_source(s, "Audio 1_01", 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_01", src, 0, 0, 4_800)); + e.push_undo("Record", before); + } + + fn levels(e: &Engine) -> Vec { + let s = e.session(); + s.sources.iter().map(|src| s.pool.get(src.id).map_or(f32::NAN, |a| a.buffer.channels[0][0])).collect() + } + + #[test] + fn saved_audio_is_referenced_and_takes_are_copied_once() { + let dir = folder("autosave-copies"); + let path = dir.join("Song/Song.scraft"); + let mut e = Engine::default(); + record(&mut e, 0.5); + crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); + record(&mut e, -0.25); + let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); + a.write(&e).unwrap(); + a.write(&e).unwrap(); + assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1); + drop(a); + let left = leftovers(&dir.join("Autosave")); + let mut r = Engine::default(); + assert_eq!(recover(&mut r, &left[0]).unwrap(), Vec::::new()); + let paths: Vec<_> = r.session().sources.iter().map(|s| (s.path.starts_with("Audio Files/"), s.unsaved)).collect(); + assert_eq!(paths, [(true, false), (false, true)]); + assert_eq!(levels(&r), [0.5, -0.25]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_take_recorded_after_undo_gets_its_own_copy() { + let dir = folder("autosave-undo"); + let mut e = Engine::default(); + record(&mut e, 0.5); + let mut a = Autosave::create(&dir, "Untitled").unwrap(); + a.write(&e).unwrap(); + e.execute("edit.undo", &json!({})).unwrap(); + record(&mut e, -0.25); + a.write(&e).unwrap(); + drop(a); + let mut r = Engine::default(); + recover(&mut r, &leftovers(&dir)[0]).unwrap(); + assert_eq!(levels(&r), [-0.25]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unsaved_work_is_offered_once_its_session_closes() { + let dir = folder("autosave-leftovers"); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let root = dir.join("Autosave"); + std::fs::create_dir_all(&dir).unwrap(); + let mut e = Engine::default(); + crate::io::save_session(&mut e, &path).unwrap(); + record(&mut e, 0.5); + let mut a = Autosave::create(&root, "Song").unwrap(); + a.write(&e).unwrap(); + assert!(leftovers(&root).is_empty()); + drop(a); + let left = leftovers(&root); + assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Song", Some(path.as_str()))); + let mut r = Engine::default(); + recover(&mut r, &left[0]).unwrap(); + assert!(r.is_dirty()); + r.execute("session.save", &json!({})).unwrap(); + assert!(leftovers(&root).is_empty()); + assert!(!left[0].dir.exists()); + let mut reopened = Engine::default(); + open_session(&mut reopened, &path).unwrap(); + assert_eq!(levels(&reopened), [0.5]); + Autosave::create(&root, "Song").unwrap().discard(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534..c2a0b2d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5,6 +5,7 @@ //! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod autosave; pub mod catalog; pub mod catalog_more; pub mod clip_group_file; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..30ea7f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod mix_window; pub mod ops_windows; pub mod palette; pub mod panels; +pub mod recovery; pub mod score_editor; pub mod shortcuts; pub mod theme; @@ -232,6 +233,10 @@ pub struct SoundApp { pub native_menu_bar: bool, /// Folder for crash-recovery autosaves (native apps set it). pub autosave_dir: Option, + /// This session's autosave folder, once it has been autosaved. + autosaved: Option, + /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. + pub leftovers: Vec, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, last_autosave: Option, @@ -296,6 +301,8 @@ impl SoundApp { services, native_menu_bar: false, autosave_dir: None, + autosaved: None, + leftovers: Vec::new(), preset_dir: None, last_autosave: None, dialogs: dialogs::Dialogs::default(), @@ -343,8 +350,12 @@ impl SoundApp { // Third-party plugins keep state the session can't see: store it first. self.capture_plugin_states(); } + let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert"); match self.engine.execute(id, ¶ms) { Ok(v) => { + if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) { + self.release_autosave(false); + } if id == "app.quit" { self.quit_requested = true; } @@ -520,14 +531,62 @@ impl SoundApp { } 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")); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + self.autosave_to(&dir); + } + + /// Write the session to its own folder under `root`; false when that failed. + fn autosave_to(&mut self, root: &std::path::Path) -> bool { + let written = match &mut self.autosaved { + Some(a) => a.write(&self.engine), + None => soundcraft_engine::autosave::Autosave::create(root, &self.engine.session().name) + .and_then(|mut a| a.write(&self.engine).map(|()| self.autosaved = Some(a))), + }; + if let Err(e) = &written { + log::warn!("autosave failed: {e}"); + self.ui.status = format!("Autosave failed: {e}"); } - match soundcraft_engine::io::save_copy(&self.engine, &path.to_string_lossy()) { - Ok(_) => log::info!("autosaved to {}", path.display()), - Err(e) => log::warn!("autosave failed: {e}"), + written.is_ok() + } + + /// Autosave now, if the app has an autosave folder. + fn write_autosave(&mut self) -> bool { + self.autosave_dir.clone().is_some_and(|root| self.autosave_to(&root)) + } + + /// Done with this session's autosave folder: keep it for the next launch, or delete it. + fn release_autosave(&mut self, keep: bool) { + if let Some(a) = self.autosaved.take() + && !keep + { + a.discard(); + } + } + + /// Done with the open session. Unsaved changes stay in its autosave folder for the next + /// launch, unless the user quit with SoundCraft's Quit. + pub fn close_autosave(&mut self) { + let keep = self.engine.is_dirty() && !self.quit_requested; + if keep { + self.capture_plugin_states(); + self.write_autosave(); + } + self.release_autosave(keep); + } + + /// Replace the open session with unsaved work an earlier run left behind. + pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) { + self.close_autosave(); + match soundcraft_engine::autosave::recover(&mut self.engine, left) { + Ok(missing) => { + if self.write_autosave() { + soundcraft_engine::autosave::discard(left); + } + self.ui.status = match missing.len() { + 0 => format!("Recovered “{}”. Save it to keep the changes.", left.name), + n => format!("Recovered “{}”, but {n} audio file(s) are missing. Save it to keep the changes.", left.name), + }; + } + Err(e) => self.ui.status = format!("Couldn't recover “{}”: {e}", left.name), } } @@ -899,6 +958,7 @@ impl SoundApp { video_window::show(self, &ctx); ops_windows::show(self, &ctx); extra_windows::show(self, &ctx); + recovery::show(self, &ctx); score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs new file mode 100644 index 0000000..be2810c --- /dev/null +++ b/crates/ui-egui/src/recovery.rs @@ -0,0 +1,48 @@ +//! The offer to recover unsaved work an earlier run left in the autosave folder. + +use crate::SoundApp; +use crate::theme::{Tokens, bold}; +use egui::{Align2, vec2}; + +/// Offer the newest leftover autosave, one at a time. +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + let Some(left) = app.leftovers.first().cloned() else { return }; + let mut open = true; + let mut recover = None; + egui::Window::new(egui::RichText::new("Recover Unsaved Work").font(bold(13.0))) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) + .show(ctx, |ui| { + let age = left.saved.elapsed().map_or_else(|_| "recently".into(), |d| ago(d.as_secs())); + ui.label(format!("“{}” has unsaved changes from an earlier run, autosaved {age}.", left.name)); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Discard").clicked() { + recover = Some(false); + } + if ui.add(egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent)).clicked() { + recover = Some(true); + } + }); + }); + if !open || recover.is_some() { + app.leftovers.retain(|l| *l != left); + } + match recover { + Some(true) => app.recover(&left), + Some(false) => soundcraft_engine::autosave::discard(&left), + None => {} + } +} + +fn ago(secs: u64) -> String { + let (n, unit) = match secs { + 0..60 => return "less than a minute ago".into(), + 60..3_600 => (secs / 60, "minute"), + 3_600..86_400 => (secs / 3_600, "hour"), + _ => (secs / 86_400, "day"), + }; + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) +} diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs new file mode 100644 index 0000000..73e0367 --- /dev/null +++ b/crates/ui-egui/tests/recovery.rs @@ -0,0 +1,103 @@ +//! Unsaved work outlives a crash or an exit without Quit and is offered at the next launch. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_engine::autosave::leftovers; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +/// One test at a time: a child process another test starts (fontconfig's `fc-match`) holds copies +/// of this process's open files until it execs, so a released autosave lock can still look taken. +fn one_at_a_time() -> MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn launch(root: &Path) -> SoundApp { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.autosave_dir = Some(root.to_path_buf()); + app.leftovers = leftovers(root); + app +} + +#[test] +fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() { + let _one = one_at_a_time(); + let dir = folder("recovery-quit"); + let root = dir.join("Autosave"); + let path = dir.join("Song.scraft"); + let mut first = launch(&root); + first.run("session.save_as", json!({"path": path})).unwrap(); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + drop(first); + + 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); + }, + launch(&root), + ); + h.run_steps(3); + h.get_by_label("Recover").click(); + h.run_steps(3); + let e = &h.state().engine; + assert!(e.session().track_by_name("Vocal").is_some() && e.is_dirty()); + assert_eq!(e.path, Some(path.to_string_lossy().into_owned())); + assert!(leftovers(&root).is_empty()); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); + h.state_mut().run("session.save", json!({})).unwrap(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn recovering_keeps_the_unsaved_work_it_replaces() { + let _one = one_at_a_time(); + let dir = folder("recovery-replace"); + let root = dir.join("Autosave"); + let mut first = launch(&root); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + let left = leftovers(&root); + assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Untitled", None)); + let mut next = launch(&root); + next.run("track.new", json!({"name": "Drums"})).unwrap(); + next.recover(&left[0]); + assert!(next.engine.session().track_by_name("Vocal").is_some() && next.engine.path.is_none()); + let kept = leftovers(&root); + assert_eq!(kept.len(), 1); + let mut third = launch(&root); + third.recover(&kept[0]); + assert!(third.engine.session().track_by_name("Drums").is_some()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn closing_the_session_or_quitting_drops_its_autosave() { + let _one = one_at_a_time(); + let dir = folder("recovery-close"); + let root = dir.join("Autosave"); + for end in ["session.close", "app.quit"] { + let mut first = launch(&root); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + let mut next = launch(&root); + let left = next.leftovers[0].clone(); + next.recover(&left); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1, "{end}"); + next.run(end, json!({})).unwrap(); + next.close_autosave(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0, "{end}"); + } + let _ = std::fs::remove_dir_all(&dir); +} From 5fff2e8c78c7302bf898f73435c03c4b493b0e85 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:37:20 +0100 Subject: [PATCH 125/154] Keep working when the interface hits a bug Nothing caught a panic outside a command: one in the interface's frame code ended the process in that frame (exit code 101), losing every unsaved change, and the log got a single line with no backtrace. Each frame's logic and drawing now run guarded. A panic there autosaves the session (once per change, so a bug that repeats every frame doesn't flood the disk), says so in the status line and lets the app carry on. If the app goes down after all, the autosave is offered at the next launch. The panic hook now logs a backtrace. --- apps/soundcraft/src/main.rs | 4 ++-- crates/engine/src/lib.rs | 2 +- crates/ui-egui/src/lib.rs | 18 ++++++++++++++++++ crates/ui-egui/tests/recovery.rs | 21 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 65db539..a04353f 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -44,7 +44,7 @@ impl eframe::App for App { if let Some(menu) = &mut self.2 { menu.process(&mut self.0); } - self.0.logic(ctx); + self.0.guarded(|app| app.logic(ctx)); #[cfg(target_os = "macos")] if let Some(menu) = &mut self.2 { menu.sync(&self.0); @@ -85,7 +85,7 @@ impl eframe::App for App { self.0.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.0.guarded(|app| app.ui(ui)); } fn on_exit(&mut self) { self.0.close_autosave(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2a0b2d..66c8794 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -375,7 +375,7 @@ fn install_panic_hook() { ONCE.call_once(|| { let prev = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - log::error!("panic: {info}"); + log::error!("panic: {info}\n{}", std::backtrace::Backtrace::force_capture()); prev(info); })); }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30ea7f0..b338902 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -237,6 +237,8 @@ pub struct SoundApp { autosaved: Option, /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. pub leftovers: Vec, + /// The document revision last autosaved after a caught panic. + panic_rev: u64, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, last_autosave: Option, @@ -303,6 +305,7 @@ impl SoundApp { autosave_dir: None, autosaved: None, leftovers: Vec::new(), + panic_rev: 0, preset_dir: None, last_autosave: None, dialogs: dialogs::Dialogs::default(), @@ -573,6 +576,21 @@ impl SoundApp { self.release_autosave(keep); } + /// Run part of a frame. If it panics, the session is autosaved (once per change) and the app + /// carries on instead of quitting. + pub fn guarded(&mut self, f: impl FnOnce(&mut SoundApp)) { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))).is_ok() || self.panic_rev == self.engine.revision { + return; + } + self.panic_rev = self.engine.revision; + let saved = self.engine.is_dirty() && self.write_autosave(); + self.ui.status = if saved { + "SoundCraft hit an internal error (see the log). Your changes were autosaved: save them, then restart SoundCraft.".into() + } else { + "SoundCraft hit an internal error (see the log). Restart SoundCraft to be safe.".into() + }; + } + /// Replace the open session with unsaved work an earlier run left behind. pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) { self.close_autosave(); diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 73e0367..69b8b73 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -101,3 +101,24 @@ fn closing_the_session_or_quitting_drops_its_autosave() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_panic_in_a_frame_autosaves_and_the_app_carries_on() { + let _one = one_at_a_time(); + let dir = folder("recovery-panic"); + let root = dir.join("Autosave"); + let mut app = launch(&root); + app.run("track.new", json!({"name": "Vocal"})).unwrap(); + app.guarded(|_| std::panic::panic_any("a bug in a frame")); + assert!(app.ui.status.contains("autosaved"), "{}", app.ui.status); + app.guarded(|app| { + app.run("track.new", json!({"name": "Bass"})).unwrap(); + }); + assert!(app.engine.session().track_by_name("Bass").is_some()); + drop(app); + let left = leftovers(&root); + let mut next = launch(&root); + next.recover(&left[0]); + assert!(next.engine.session().track_by_name("Vocal").is_some()); + let _ = std::fs::remove_dir_all(&dir); +} From 98d543f0d816d426e94e3036da6d5fbd90199dc3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:40:21 +0100 Subject: [PATCH 126/154] Go silent instead of crashing when the audio engine hits a bug Nothing caught a panic in the audio output callback. On macOS it unwinds into CoreAudio's thread, which most likely aborts the app; on Windows and Linux the stream thread dies while the transport keeps showing playback with no sound. A panic in the silent clock that runs without an audio device stopped the clock for good. Each block is now filled under a guard. A panic makes that block and every later one silent and marks the engine failed. The app then stops the transport, keeping what was recorded, and says to reconnect the audio device or restart. --- crates/playback/src/lib.rs | 61 ++++++++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 +++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..54d6dc6 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -76,6 +76,30 @@ struct Shared { meters: Mutex, } +/// Set once a block panicked: output stays silent from then on. +#[derive(Default)] +struct Fault { + failed: AtomicBool, + /// The UI has been told. + reported: AtomicBool, +} + +impl Fault { + fn newly_failed(&self) -> bool { + self.failed.load(Ordering::Relaxed) && !self.reported.swap(true, Ordering::Relaxed) + } +} + +/// Fill one block with `fill`. If it panics, the block and every later one are silent and the +/// transport stops, rather than the panic taking the app down. +fn guarded_block(fault: &Fault, shared: &Shared, out: &mut [f32], fill: impl FnOnce(&mut [f32])) { + if fault.failed.load(Ordering::Relaxed) || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fill(out))).is_err() { + fault.failed.store(true, Ordering::Relaxed); + shared.playing.store(false, Ordering::Relaxed); + out.fill(0.0); + } +} + /// State owned by the audio callback. struct AudioState { rx: Receiver, @@ -347,6 +371,7 @@ impl Side { /// The transport and audio device. pub struct Player { shared: Arc, + fault: Arc, tx: Sender, side: Mutex, #[cfg(not(target_arch = "wasm32"))] @@ -370,6 +395,11 @@ impl Player { p } + /// True once after the audio engine hit an internal error and went silent. + pub fn newly_failed(&self) -> bool { + self.fault.newly_failed() + } + fn side(&self) -> std::sync::MutexGuard<'_, Side> { self.side.lock().unwrap_or_else(PoisonError::into_inner) } @@ -391,6 +421,8 @@ impl Player { let rate = config.sample_rate.0; let channels = usize::from(config.channels); let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); + let fault = Arc::new(Fault::default()); + let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared)); // cpal may call this on the audio thread (on ALSA in a loop while the device // stays broken): marked, so the logger hands the record off instead of writing it. let err_fn = |e: cpal::StreamError| { @@ -402,7 +434,7 @@ impl Player { &config, move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { mark_audio_thread(); - state.fill(data, channels); + guarded_block(&failed, &guard, data, |d| state.fill(d, channels)); }, err_fn, None, @@ -414,6 +446,7 @@ impl Player { log::info!("audio device: {name} @ {rate} Hz, {channels} ch"); Player { shared, + fault, tx, side, _stream: Some(stream), @@ -465,6 +498,8 @@ impl Player { ) -> Player { let rate = session.sample_rate.hz(); let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); + let fault = Arc::new(Fault::default()); + let failed = Arc::clone(&fault); let spawn = std::thread::Builder::new().name("soundcraft-clock".into()).spawn(move || { let mut buf = vec![0.0f32; 4096]; let mut last = std::time::Instant::now(); @@ -475,7 +510,8 @@ impl Player { last = now; let n = (frames * 2).min(buf.len()); if let Some(b) = buf.get_mut(..n) { - state.fill(b, 2); + let shared = Arc::clone(&state.shared); + guarded_block(&failed, &shared, b, |b| state.fill(b, 2)); } if Arc::strong_count(&state.shared) <= 1 { break; @@ -487,6 +523,7 @@ impl Player { } Player { shared, + fault, tx, side, _stream: None, @@ -508,6 +545,7 @@ impl Player { ) -> Player { Player { shared, + fault: Arc::default(), tx, side, _stream: None, @@ -765,6 +803,25 @@ mod tests { assert_eq!(out.main, source.main); } + #[test] + fn a_block_that_panics_goes_silent_and_is_reported_once() { + let shared = Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(true), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }; + let fault = Fault::default(); + let mut out = [0.5f32; 8]; + guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); + assert_eq!((out, fault.newly_failed()), ([0.25; 8], false)); + guarded_block(&fault, &shared, &mut out, |_| std::panic::panic_any("a bug in the mix")); + guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); + assert_eq!(out, [0.0; 8]); + assert!(!shared.playing.load(Ordering::Relaxed)); + assert_eq!((fault.newly_failed(), fault.newly_failed()), (true, false)); + } + #[test] fn audio_state_plays_and_stops_at_end() { let s = Arc::new(soundcraft_model::Session::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b338902..2632ba0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -894,6 +894,10 @@ impl SoundApp { self.engine.end_merge(); } } + if self.player.as_ref().is_some_and(Player::newly_failed) { + self.stop_play(); + self.ui.status = "The audio engine hit an internal error (see the log) and went silent. Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.".into(); + } // Stop the old playback before sending a replacement session to the audio thread. self.handle_transport(dt); if self.engine.revision != self.last_rev { From 334cf40506aa9bcbb4a6f109f6c0267790681180 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:18:15 +0100 Subject: [PATCH 127/154] Autosave after every change, on a thread of its own Autosave ran every two minutes and not at all while recording, so a crash could lose minutes of work. It also wrote on the UI thread: the first autosave after a recording copied the take, which could freeze the window for seconds. The open session now goes to a writer thread after every change, recording or not. The first change after a pause is written at once; while changes keep coming, as in a fader drag, the newest is written at most once a second. The UI waits for the writer only where it needs the result: after a caught panic, when recovering and when quitting. A bug in a write is caught and reported, and the next change tries again. Plugins' own state still reaches the session every two minutes and not while recording, since plugins save it on the audio thread. --- crates/engine/src/autosave.rs | 208 ++++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 54 +++++--- crates/ui-egui/tests/recovery.rs | 38 +++++- 3 files changed, 267 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index a989718..063380b 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -1,5 +1,5 @@ -//! Crash-recovery copies of open sessions, one folder each, and the recovery of the ones left -//! behind with unsaved work. +//! Crash-recovery copies of open sessions, one folder each, written in the background, and the +//! recovery of the ones left behind with unsaved work. use crate::io::{open_session, sanitize_name}; use crate::{Engine, EngineError, Result}; @@ -8,16 +8,162 @@ use soundcraft_model::{Session, SourceAudio, SourceId}; use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Weak}; -use std::time::SystemTime; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::time::{Duration, Instant, SystemTime}; const SESSION: &str = "session.scraft"; const ORIGIN: &str = "origin"; const LOCK: &str = "lock"; +/// While changes keep coming, the newest is written at most this often. +const INTERVAL: Duration = Duration::from_secs(1); +const STOPPED: &str = "the autosave thread stopped"; + +/// Writes autosaves on a thread of its own, so the UI never waits on the disk. +pub struct Autosaver { + jobs: Option>, + thread: Option>, + failure: Arc>>, +} + +enum Job { + Write(Arc, Option), + /// Done with the session's folder: keep it for the next launch, or delete it. + Close(bool), + Flush(Sender>), +} + +impl Autosaver { + /// A writer for autosave folders under `root`. + pub fn new(root: PathBuf) -> Autosaver { + let (tx, rx) = channel(); + let failure = Arc::new(Mutex::new(None)); + let writer = Writer { root, failure: Arc::clone(&failure), folder: None, pending: None, last: None, written: Ok(()) }; + let thread = std::thread::Builder::new().name("soundcraft-autosave".into()).spawn(move || writer.run(&rx)); + if let Err(e) = &thread { + report(&failure, format!("{STOPPED}: {e}")); + } + Autosaver { jobs: Some(tx), thread: thread.ok(), failure } + } + + /// Autosave `session`, opened from the session file `origin`, soon. + pub fn write(&self, session: Arc, origin: Option) { + self.send(Job::Write(session, origin)); + } + + /// Done with the open session's folder: keep it for the next launch, or delete it. + pub fn close(&self, keep: bool) { + self.send(Job::Close(keep)); + } + + /// Wait until everything handed over so far is done. A failure returned here isn't reported + /// again by `failure`. + pub fn flush(&self) -> Result<()> { + let (tx, rx) = channel(); + self.send(Job::Flush(tx)); + let done = rx.recv().unwrap_or_else(|_| Err(STOPPED.into())); + if done.is_err() { + self.failure(); + } + done.map_err(EngineError::Io) + } + + /// The newest failure since the last call. + pub fn failure(&self) -> Option { + self.failure.lock().unwrap_or_else(PoisonError::into_inner).take() + } + + fn send(&self, job: Job) { + if self.jobs.as_ref().is_none_or(|tx| tx.send(job).is_err()) { + report(&self.failure, STOPPED.into()); + } + } +} + +impl Drop for Autosaver { + /// What was handed over is written, and an open folder stays for the next launch. + fn drop(&mut self) { + drop(self.jobs.take()); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// The writer thread. +struct Writer { + root: PathBuf, + failure: Arc>>, + /// The open session's folder. + folder: Option, + /// The newest session not written yet. + pending: Option<(Arc, Option)>, + last: Option, + /// How the newest write into the open folder went, for `Flush`. + written: std::result::Result<(), String>, +} + +impl Writer { + fn run(mut self, jobs: &Receiver) { + loop { + let wait = match (&self.pending, self.last) { + (None, _) => Duration::MAX, + (Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())), + }; + match jobs.recv_timeout(wait) { + Ok(Job::Write(session, origin)) => self.pending = Some((session, origin)), + Ok(Job::Flush(reply)) => { + self.write_pending(); + let _ = reply.send(self.written.clone()); + } + Ok(Job::Close(keep)) => { + if keep { + self.write_pending(); + } + self.pending = None; + self.written = Ok(()); + if let Some(a) = self.folder.take() + && !keep + { + a.discard(); + } + } + Err(RecvTimeoutError::Timeout) => self.write_pending(), + Err(RecvTimeoutError::Disconnected) => { + self.write_pending(); + return; + } + } + } + } + + fn write_pending(&mut self) { + let Some((session, origin)) = self.pending.take() else { return }; + self.last = Some(Instant::now()); + let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { + if self.folder.is_none() { + self.folder = Some(Autosave::create(&self.root, &session.name)?); + } + self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref())) + })); + self.written = match written { + Ok(written) => written.map_err(|e| e.to_string()), + Err(_) => Err("internal error (see the log)".into()), + }; + if let Err(e) = &self.written { + report(&self.failure, e.clone()); + } + } +} + +fn report(failure: &Mutex>, e: String) { + log::warn!("autosave failed: {e}"); + *failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(e); +} /// An open session's autosave folder, locked while the session is open so a later launch can tell /// it from one a crash left behind. -pub struct Autosave { +struct Autosave { dir: PathBuf, lock: Option, copies: BTreeMap, String)>, @@ -26,7 +172,7 @@ pub struct Autosave { impl Autosave { /// A new folder under `root` for the session called `name`. - pub fn create(root: &Path, name: &str) -> Result { + fn create(root: &Path, name: &str) -> Result { let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; @@ -34,12 +180,12 @@ impl Autosave { Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1 }) } - /// Write the session as it is now. Audio saved with it is referenced where it is; audio that - /// exists only in memory is copied, once per recording or render. - pub fn write(&mut self, e: &Engine) -> Result<()> { - let base = e.path.as_deref().and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); + /// Write `session`, opened from the session file `origin`. Audio saved with it is referenced + /// where it is; audio that exists only in memory is copied, once per recording or render. + fn write(&mut self, session: &Session, origin: Option<&str>) -> Result<()> { + let base = origin.and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); let audio_dir = self.dir.join("Audio Files"); - let mut s = e.session().clone(); + let mut s = session.clone(); let mut copies = BTreeMap::new(); for src in &mut s.sources { let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) }; @@ -69,7 +215,7 @@ impl Autosave { copies.insert(src.id, (Arc::downgrade(audio), name)); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - write_atomically(&self.dir.join(ORIGIN), e.path.as_deref().unwrap_or_default().as_bytes())?; + write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?; write_atomically(&self.dir.join(SESSION), text.as_bytes())?; for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() { let _ = std::fs::remove_file(audio_dir.join(name)); @@ -78,7 +224,7 @@ impl Autosave { } /// Delete the folder: the session closed with nothing left to recover. - pub fn discard(mut self) { + fn discard(mut self) { drop(self.lock.take()); let _ = std::fs::remove_dir_all(&self.dir); } @@ -207,8 +353,8 @@ mod tests { crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); record(&mut e, -0.25); let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); - a.write(&e).unwrap(); - a.write(&e).unwrap(); + a.write(e.session(), e.path.as_deref()).unwrap(); + a.write(e.session(), e.path.as_deref()).unwrap(); assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1); drop(a); let left = leftovers(&dir.join("Autosave")); @@ -226,10 +372,10 @@ mod tests { let mut e = Engine::default(); record(&mut e, 0.5); let mut a = Autosave::create(&dir, "Untitled").unwrap(); - a.write(&e).unwrap(); + a.write(e.session(), None).unwrap(); e.execute("edit.undo", &json!({})).unwrap(); record(&mut e, -0.25); - a.write(&e).unwrap(); + a.write(e.session(), None).unwrap(); drop(a); let mut r = Engine::default(); recover(&mut r, &leftovers(&dir)[0]).unwrap(); @@ -237,6 +383,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn the_writer_saves_the_newest_change_and_drops_the_folder_when_done() { + let root = folder("autosave-writer"); + let w = Autosaver::new(root.clone()); + let mut e = Engine::default(); + for _ in 0..5 { + record(&mut e, 0.5); + w.write(e.session_arc(), None); + } + w.flush().unwrap(); + let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::>(); + let mut r = Engine::default(); + open_session(&mut r, &file[0].to_string_lossy()).unwrap(); + assert_eq!((file.len(), r.session().sources.len()), (1, 5)); + w.close(false); + w.flush().unwrap(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + record(&mut e, 0.5); + w.write(e.session_arc(), None); + drop(w); + let mut r = Engine::default(); + recover(&mut r, &leftovers(&root)[0]).unwrap(); + assert_eq!(r.session().sources.len(), 6); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn unsaved_work_is_offered_once_its_session_closes() { let dir = folder("autosave-leftovers"); @@ -247,7 +419,7 @@ mod tests { crate::io::save_session(&mut e, &path).unwrap(); record(&mut e, 0.5); let mut a = Autosave::create(&root, "Song").unwrap(); - a.write(&e).unwrap(); + a.write(e.session(), e.path.as_deref()).unwrap(); assert!(leftovers(&root).is_empty()); drop(a); let left = leftovers(&root); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2632ba0..b20e122 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -233,8 +233,10 @@ pub struct SoundApp { pub native_menu_bar: bool, /// Folder for crash-recovery autosaves (native apps set it). pub autosave_dir: Option, - /// This session's autosave folder, once it has been autosaved. - autosaved: Option, + /// Writes the open session's autosaves in the background, once there is something to write. + autosaver: Option, + /// The document revision last handed to the autosaver. + autosaved_rev: u64, /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. pub leftovers: Vec, /// The document revision last autosaved after a caught panic. @@ -303,7 +305,8 @@ impl SoundApp { services, native_menu_bar: false, autosave_dir: None, - autosaved: None, + autosaver: None, + autosaved_rev: 0, leftovers: Vec::new(), panic_rev: 0, preset_dir: None, @@ -537,15 +540,36 @@ impl SoundApp { self.autosave_to(&dir); } - /// Write the session to its own folder under `root`; false when that failed. + /// Autosave a modified session within a second of every change, also while recording. The + /// plugins' own state reaches the session every two minutes (`autosave`). + fn autosave_changes(&mut self) { + if let Some(e) = self.autosaver.as_ref().and_then(soundcraft_engine::autosave::Autosaver::failure) { + self.ui.status = format!("Autosave failed: {e}"); + } + if self.autosave_dir.is_none() || self.engine.revision == self.autosaved_rev { + return; + } + self.autosaved_rev = self.engine.revision; + if !self.engine.is_dirty() { + self.release_autosave(false); + } else if let Some(root) = self.autosave_dir.clone() { + self.queue_autosave(root); + } + } + + /// Hand the session to the autosaver for its folder under `root`. + fn queue_autosave(&mut self, root: std::path::PathBuf) { + self.autosaved_rev = self.engine.revision; + let w = self.autosaver.get_or_insert_with(|| soundcraft_engine::autosave::Autosaver::new(root)); + w.write(self.engine.session_arc(), self.engine.path.clone()); + } + + /// Write the session to its own folder under `root` and wait for it; false when that failed. fn autosave_to(&mut self, root: &std::path::Path) -> bool { - let written = match &mut self.autosaved { - Some(a) => a.write(&self.engine), - None => soundcraft_engine::autosave::Autosave::create(root, &self.engine.session().name) - .and_then(|mut a| a.write(&self.engine).map(|()| self.autosaved = Some(a))), - }; + self.queue_autosave(root.to_path_buf()); + let Some(w) = &self.autosaver else { return false }; + let written = w.flush(); if let Err(e) = &written { - log::warn!("autosave failed: {e}"); self.ui.status = format!("Autosave failed: {e}"); } written.is_ok() @@ -558,10 +582,8 @@ impl SoundApp { /// Done with this session's autosave folder: keep it for the next launch, or delete it. fn release_autosave(&mut self, keep: bool) { - if let Some(a) = self.autosaved.take() - && !keep - { - a.discard(); + if let Some(w) = &self.autosaver { + w.close(keep); } } @@ -574,6 +596,9 @@ impl SoundApp { self.write_autosave(); } self.release_autosave(keep); + if let Some(w) = &self.autosaver { + let _ = w.flush(); + } } /// Run part of a frame. If it panics, the session is autosaved (once per change) and the app @@ -906,6 +931,7 @@ impl SoundApp { p.update_session(self.engine.session_arc()); } } + self.autosave_changes(); self.write_automation(ctx); self.autosave(); if self.recorder.is_none() diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 69b8b73..4efe596 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -21,6 +21,22 @@ fn folder(name: &str) -> PathBuf { dir } +/// Autosaves are written in the background. +fn eventually(done: impl Fn() -> bool) -> bool { + for _ in 0..300 { + if done() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + done() +} + +fn autosaved(root: &Path) -> String { + let folders = std::fs::read_dir(root).into_iter().flatten().flatten(); + folders.map(|d| std::fs::read_to_string(d.path().join("session.scraft")).unwrap_or_default()).collect() +} + fn launch(root: &Path) -> SoundApp { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); app.autosave_dir = Some(root.to_path_buf()); @@ -56,7 +72,27 @@ fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() { assert!(leftovers(&root).is_empty()); assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); h.state_mut().run("session.save", json!({})).unwrap(); - assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + assert!(eventually(|| std::fs::read_dir(&root).unwrap().count() == 0)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn every_change_is_autosaved_within_a_second() { + let _one = one_at_a_time(); + let dir = folder("recovery-changes"); + let root = dir.join("Autosave"); + 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); + }, + launch(&root), + ); + for name in ["Vocal", "Bass"] { + h.state_mut().run("track.new", json!({"name": name})).unwrap(); + h.run_steps(1); + assert!(eventually(|| autosaved(&root).contains(name)), "{name}"); + } let _ = std::fs::remove_dir_all(&dir); } From ddb9c9543d88fafb04d0e3a97ddcc3858faac796 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:39:03 +0100 Subject: [PATCH 128/154] Stop recording and keep the take when the input or the disk fails A failing input only reached the log. When the interface was unplugged mid-take (on macOS its callbacks simply stop), when writing a take failed (a full disk), or when the input callback panicked, recording went on with the transport running and the take silently ended early. A panic in the callback could also take the input stream down for good. The recorder now reports trouble: the device was disconnected, it sent nothing for two seconds while recording, its callback hit a bug (caught, after which it does nothing more), or writing a take failed. The app stops recording at once, keeps the take up to that point and says what happened and what to do. Input lost to a slow disk is reported while recording instead of only at Stop. Reconnect audio device now reopens the input as well as the output, so monitoring works again afterwards. --- crates/playback/src/record.rs | 162 ++++++++++++++++++++++++++++++---- crates/ui-egui/src/lib.rs | 47 ++++++++++ crates/ui-egui/src/windows.rs | 2 +- 3 files changed, 195 insertions(+), 16 deletions(-) diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index ec784ac..0039c95 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -8,8 +8,8 @@ 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::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use soundcraft_model::TrackId; @@ -18,6 +18,19 @@ use soundcraft_model::TrackId; pub type Takes = (Vec<(TrackId, PathBuf)>, Option); const RING_SECONDS: usize = 4; +/// How long a recording input may go without a callback before it counts as gone. +const STALL: Duration = Duration::from_secs(2); +const GONE: u8 = 1; +const BROKEN: u8 = 2; + +/// Why the input can't go on. +#[derive(Debug, Clone, PartialEq)] +pub enum Trouble { + /// The device went away or stopped sending audio, or its callback hit a bug. + Device(String), + /// Writing a take failed (a full disk, say). + Disk(String), +} /// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots /// are atomics, so it needs no lock and no `unsafe`. @@ -62,9 +75,29 @@ struct Shared { ring: CaptureRing, /// Samples lost while the ring was full. dropped: AtomicUsize, + /// Input callbacks so far, to tell a device that stopped sending. + beats: AtomicU64, + /// Set from the input stream when it can't go on: `GONE` or `BROKEN`. + fault: AtomicU8, + /// The first failure writing a take, until it's reported. + failed: Mutex>, } impl Shared { + fn new(ring: CaptureRing) -> Shared { + let (beats, fault, failed) = (AtomicU64::new(0), AtomicU8::new(0), Mutex::new(None)); + Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0), beats, fault, failed } + } + + /// Run the input callback's `work`. After a panic in it, the input is broken and does + /// nothing more. + fn guarded(&self, work: impl FnOnce()) { + self.beats.fetch_add(1, Ordering::Relaxed); + if self.fault.load(Ordering::Relaxed) != BROKEN && std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).is_err() { + self.fault.store(BROKEN, Ordering::Relaxed); + } + } + /// 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) { @@ -85,6 +118,8 @@ pub struct Recorder { pub sample_rate: u32, pub channels: usize, writer: Option>, + /// The callback count last seen, and since when. + seen: (u64, Instant), } impl Recorder { @@ -103,8 +138,8 @@ impl Recorder { let config = cfg.config(); 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 shared = Arc::new(Shared::new(ring)); + let (sh, errors) = (Arc::clone(&shared), Arc::clone(&shared)); let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); let mon = Arc::clone(&monitor); let mut owed = 0; @@ -113,23 +148,29 @@ impl Recorder { &config, move |data: &[f32], _: &cpal::InputCallbackInfo| { crate::mark_audio_thread(); - mon.push(data); - if sh.armed.load(Ordering::Acquire) { - sh.capture(data, &mut owed); - } else { - owed = 0; - } + sh.guarded(|| { + mon.push(data); + if sh.armed.load(Ordering::Acquire) { + sh.capture(data, &mut owed); + } else { + owed = 0; + } + }); }, - |e| { + move |e| { // Possibly the audio thread (see `crate::mark_audio_thread`). crate::mark_audio_thread(); + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed); + } log::warn!("input stream error: {e}"); }, None, ) .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, writer: None }) + let seen = (0, Instant::now()); + Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen }) } #[cfg(target_os = "freebsd")] @@ -147,6 +188,7 @@ impl Recorder { 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.failed.lock().unwrap_or_else(PoisonError::into_inner) = None; 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)) { @@ -159,6 +201,31 @@ impl Recorder { Ok(()) } + /// Why the input can't go on, if it can't: the device went away or, while recording, stopped + /// sending audio; its callback hit a bug; or writing a take failed (reported once). + pub fn trouble(&mut self) -> Option { + if let Some(e) = self.shared.failed.lock().unwrap_or_else(PoisonError::into_inner).take() { + return Some(Trouble::Disk(e)); + } + let name = &self.device_name; + match self.shared.fault.load(Ordering::Relaxed) { + GONE => return Some(Trouble::Device(format!("“{name}” was disconnected"))), + BROKEN => return Some(Trouble::Device("the audio input hit an internal error (see the log)".into())), + _ => {} + } + let beats = self.shared.beats.load(Ordering::Relaxed); + if beats != self.seen.0 || !self.shared.armed.load(Ordering::Relaxed) { + self.seen = (beats, Instant::now()); + } + (self.seen.1.elapsed() >= STALL).then(|| Trouble::Device(format!("“{name}” stopped sending audio"))) + } + + /// Input the current take has lost to a slow disk so far, in milliseconds. + pub fn lost_ms(&self) -> u64 { + let frames = self.shared.dropped.load(Ordering::Relaxed) / self.channels.max(1); + (frames as u64).saturating_mul(1000) / u64::from(self.sample_rate.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(); @@ -176,7 +243,7 @@ impl Drop for Recorder { } fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> Takes { - let (mut block, mut header_written) = (Vec::new(), Instant::now()); + let (mut block, mut header_written, mut reported) = (Vec::new(), Instant::now(), false); loop { let stopping = !shared.armed.load(Ordering::Acquire); // The ring holds interleaved samples and a pop can end mid-frame: write whole frames and @@ -185,6 +252,10 @@ fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> let whole = block.len() - block.len() % channels.max(1); takes.iter_mut().for_each(|t| t.write(&block[..whole], channels)); block.drain(..whole); + if !reported && let Some(e) = takes.iter().find_map(|t| t.error.clone()) { + *shared.failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(e); + reported = true; + } if stopping { break; } @@ -269,7 +340,7 @@ mod tests { #[test] 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 shared = Shared::new(CaptureRing::new(8)); let (mut owed, mut out) = (0, Vec::new()); shared.capture(&[1.0; 6], &mut owed); shared.capture(&[2.0; 4], &mut owed); @@ -280,6 +351,66 @@ mod tests { assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2)); } + fn recorder(shared: &Arc) -> Recorder { + Recorder { + shared: Arc::clone(shared), + monitor: InputRing::new(1, 48_000), + #[cfg(not(target_os = "freebsd"))] + _stream: None, + device_name: "Mic In".into(), + sample_rate: 48_000, + channels: 1, + writer: None, + seen: (0, Instant::now()), + } + } + + #[test] + fn a_bug_a_stall_or_a_lost_device_is_trouble() { + let shared = Arc::new(Shared::new(CaptureRing::new(64))); + let mut r = recorder(&shared); + shared.armed.store(true, Ordering::Release); + assert_eq!(r.trouble(), None); + r.seen.1 = Instant::now().checked_sub(STALL).unwrap(); + assert_eq!(r.trouble(), Some(Trouble::Device("“Mic In” stopped sending audio".into()))); + shared.guarded(|| {}); + assert_eq!(r.trouble(), None, "it sent again"); + shared.armed.store(false, Ordering::Release); + r.seen.1 = Instant::now().checked_sub(STALL).unwrap(); + assert_eq!(r.trouble(), None, "a quiet input that isn't recording is fine"); + shared.guarded(|| std::panic::panic_any("a bug in the input")); + let mut ran = false; + shared.guarded(|| ran = true); + assert!(!ran); + assert_eq!(r.trouble(), Some(Trouble::Device("the audio input hit an internal error (see the log)".into()))); + let gone = Arc::new(Shared::new(CaptureRing::new(64))); + gone.fault.store(GONE, Ordering::Relaxed); + assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” was disconnected".into()))); + } + + #[test] + fn a_failed_write_is_trouble_while_still_recording() { + let dir = std::env::temp_dir().join(format!("soundcraft-failed-take-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut take = TakeWriter::create(TrackId(1), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap(); + take.file = File::open(&take.path).unwrap(); + let shared = Arc::new(Shared::new(CaptureRing::new(1 << 12))); + shared.armed.store(true, Ordering::Release); + let mut r = recorder(&shared); + let sh = Arc::clone(&shared); + let writer = std::thread::spawn(move || write_takes(&sh, vec![take], 1)); + shared.capture(&[0.5; 480], &mut 0); + let trouble = (0..300).find_map(|_| { + std::thread::sleep(Duration::from_millis(10)); + r.trouble() + }); + assert!(matches!(&trouble, Some(Trouble::Disk(e)) if e.contains("Vox_01.wav")), "{trouble:?}"); + assert_eq!(r.trouble(), None, "reported once"); + shared.armed.store(false, Ordering::Release); + assert!(writer.join().unwrap().1.is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn takes_stream_into_their_files() { let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id())); @@ -292,7 +423,8 @@ mod tests { 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 shared = Arc::new(Shared::new(CaptureRing::new(1 << 16))); + shared.armed.store(true, Ordering::Release); 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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b20e122..f4214e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ use serde_json::{Value, json}; use soundcraft_engine::{Engine, TransportRequest}; use soundcraft_model::TrackId; use soundcraft_playback::Player; +use soundcraft_playback::record::Trouble; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; use std::sync::mpsc::Receiver; @@ -226,6 +227,8 @@ pub struct SoundApp { /// Punch range to trim the take to (pre/post-roll recordings). punch: Option, recorder_failed: bool, + /// Input this take lost to a slow disk, as last reported (ms). + lost_shown: u64, pub ui: UiState, pub audio_health_report: Option>, pub services: Services, @@ -300,6 +303,7 @@ impl SoundApp { record_start: 0, punch: None, recorder_failed: false, + lost_shown: 0, ui: UiState::default(), audio_health_report: None, services, @@ -460,6 +464,7 @@ impl SoundApp { self.ui.status = format!("Cannot record: {err}"); return; } + self.lost_shown = 0; if let Some(p) = &self.player { p.set_recording(true); } @@ -676,6 +681,47 @@ impl SoundApp { } } + /// Stop recording, keeping the take, when the input device or the disk fails, and say as soon + /// as a slow disk loses input. + fn watch_input(&mut self) { + let recording = self.engine.transport.recording; + let Some(r) = &mut self.recorder else { return }; + let Some(trouble) = r.trouble() else { + let lost = r.lost_ms(); + if recording && lost > self.lost_shown { + self.lost_shown = lost; + self.ui.status = format!("The disk is too slow: this take has lost {lost} ms of input so far."); + } + return; + }; + if recording { + self.stop_play(); + } + let (what, then) = match trouble { + Trouble::Device(what) => { + self.recorder = None; + self.recorder_failed = true; + (what, "Reconnect it, then use Reconnect audio device in Setup ▸ Playback Engine.") + } + Trouble::Disk(what) => (what, "Check the disk, then record again."), + }; + self.ui.status = if recording { + format!("Recording stopped: {what}. The take is kept up to there. {then}") + } else { + format!("Audio input: {what}. {then}") + }; + } + + /// Reopen the audio devices, keeping a take in progress. + pub fn reconnect_audio(&mut self) { + if self.is_playing() || self.engine.transport.recording { + self.stop_play(); + } + self.recorder = None; + self.recorder_failed = false; + self.player = Some(Player::new(self.engine.session_arc())); + } + fn finish_recording(&mut self) { self.engine.transport.recording = false; if let Some(p) = &self.player { @@ -923,6 +969,7 @@ impl SoundApp { self.stop_play(); self.ui.status = "The audio engine hit an internal error (see the log) and went silent. Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.".into(); } + self.watch_input(); // Stop the old playback before sending a replacement session to the audio thread. self.handle_transport(dt); if self.engine.revision != self.last_rev { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..72c5d01 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -432,7 +432,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { let _ = app.run("options.delay_compensation", json!({"value": dc})); } if ui.button("Reconnect audio device").clicked() { - app.player = Some(soundcraft_playback::Player::new(app.engine.session_arc())); + app.reconnect_audio(); } }); app.ui.show_playback_engine = open; From 0b9ead430f4da9275c905744be95f88db7771767 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:42:39 +0100 Subject: [PATCH 129/154] Stop the transport when the audio output device goes away Losing the output device only reached the log: the transport kept "playing" a frozen playhead, and a recording ran on with nobody told. The output now reports trouble once: the device was disconnected, or it stopped asking for audio for two seconds while playing (not checked in the web build, which has no clock for it), or the engine hit a bug as before. The app stops the transport, keeps a take in progress, and says what happened and how to reconnect. --- crates/playback/src/lib.rs | 100 +++++++++++++++++++++++++++++++++---- crates/ui-egui/src/lib.rs | 35 +++++++++++-- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 54d6dc6..cb94dab 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -76,23 +76,66 @@ struct Shared { meters: Mutex, } -/// Set once a block panicked: output stays silent from then on. +/// What stopped the audio output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputTrouble { + /// A block panicked; the output stays silent. + Bug, + /// The device went away. + Gone, + /// The device stopped asking for audio while playing. + Stalled, +} + +/// How the audio output is doing, for the UI. #[derive(Default)] struct Fault { + /// A block panicked: output stays silent from then on. failed: AtomicBool, + gone: AtomicBool, + /// Blocks so far, to tell a device that stopped asking for audio. + beats: std::sync::atomic::AtomicU64, /// The UI has been told. reported: AtomicBool, } impl Fault { - fn newly_failed(&self) -> bool { - self.failed.load(Ordering::Relaxed) && !self.reported.swap(true, Ordering::Relaxed) + /// What stopped the output, once. + fn trouble(&self, stalled: bool) -> Option { + let trouble = if self.failed.load(Ordering::Relaxed) { + OutputTrouble::Bug + } else if self.gone.load(Ordering::Relaxed) { + OutputTrouble::Gone + } else if stalled { + OutputTrouble::Stalled + } else { + return None; + }; + (!self.reported.swap(true, Ordering::Relaxed)).then_some(trouble) + } +} + +/// How long a playing output may go without a block before it counts as stalled. +#[cfg(not(target_arch = "wasm32"))] +const STALL: std::time::Duration = std::time::Duration::from_secs(2); + +/// True once the block count `beats` has stood still for `STALL` while playing; `seen` is the +/// count last seen and since when. +#[cfg(not(target_arch = "wasm32"))] +fn stalled(seen: &mut Option<(u64, std::time::Instant)>, beats: u64, playing: bool) -> bool { + match *seen { + Some((b, since)) if playing && b == beats => since.elapsed() >= STALL, + _ => { + *seen = Some((beats, std::time::Instant::now())); + false + } } } /// Fill one block with `fill`. If it panics, the block and every later one are silent and the /// transport stops, rather than the panic taking the app down. fn guarded_block(fault: &Fault, shared: &Shared, out: &mut [f32], fill: impl FnOnce(&mut [f32])) { + fault.beats.fetch_add(1, Ordering::Relaxed); if fault.failed.load(Ordering::Relaxed) || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fill(out))).is_err() { fault.failed.store(true, Ordering::Relaxed); shared.playing.store(false, Ordering::Relaxed); @@ -372,6 +415,9 @@ impl Side { pub struct Player { shared: Arc, fault: Arc, + /// The block count last seen while playing, and since when. + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex>, tx: Sender, side: Mutex, #[cfg(not(target_arch = "wasm32"))] @@ -395,9 +441,17 @@ impl Player { p } - /// True once after the audio engine hit an internal error and went silent. - pub fn newly_failed(&self) -> bool { - self.fault.newly_failed() + /// What stopped the audio output, once: a bug in the engine, or the device went away or + /// stopped asking for audio while playing. + pub fn trouble(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let stalled = { + let playing = self.shared.playing.load(Ordering::Relaxed) && !self.silent; + stalled(&mut self.watch.lock().unwrap_or_else(PoisonError::into_inner), self.fault.beats.load(Ordering::Relaxed), playing) + }; + #[cfg(target_arch = "wasm32")] + let stalled = false; + self.fault.trouble(stalled) } fn side(&self) -> std::sync::MutexGuard<'_, Side> { @@ -425,8 +479,12 @@ impl Player { let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared)); // cpal may call this on the audio thread (on ALSA in a loop while the device // stays broken): marked, so the logger hands the record off instead of writing it. - let err_fn = |e: cpal::StreamError| { + let gone = Arc::clone(&fault); + let err_fn = move |e: cpal::StreamError| { mark_audio_thread(); + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + gone.gone.store(true, Ordering::Relaxed); + } log::warn!("audio stream error: {e}"); }; use cpal::traits::{DeviceTrait, StreamTrait}; @@ -447,6 +505,8 @@ impl Player { Player { shared, fault, + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex::new(None), tx, side, _stream: Some(stream), @@ -524,6 +584,7 @@ impl Player { Player { shared, fault, + watch: Mutex::new(None), tx, side, _stream: None, @@ -814,12 +875,33 @@ mod tests { let fault = Fault::default(); let mut out = [0.5f32; 8]; guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); - assert_eq!((out, fault.newly_failed()), ([0.25; 8], false)); + assert_eq!((out, fault.trouble(false)), ([0.25; 8], None)); guarded_block(&fault, &shared, &mut out, |_| std::panic::panic_any("a bug in the mix")); guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); assert_eq!(out, [0.0; 8]); assert!(!shared.playing.load(Ordering::Relaxed)); - assert_eq!((fault.newly_failed(), fault.newly_failed()), (true, false)); + assert_eq!((fault.trouble(false), fault.trouble(false)), (Some(OutputTrouble::Bug), None)); + assert_eq!(fault.beats.load(Ordering::Relaxed), 3); + } + + #[test] + fn a_lost_or_stalled_device_is_reported_once() { + let ago = |seen: &mut Option<(u64, std::time::Instant)>| { + if let Some(s) = seen { + s.1 = std::time::Instant::now().checked_sub(STALL).unwrap(); + } + }; + let mut seen = None; + assert!(!stalled(&mut seen, 5, true)); + ago(&mut seen); + assert!(stalled(&mut seen, 5, true)); + assert!(!stalled(&mut seen, 6, true), "it asked again"); + ago(&mut seen); + assert!(!stalled(&mut seen, 6, false), "stopped, so nothing to ask for"); + assert_eq!(Fault::default().trouble(true), Some(OutputTrouble::Stalled)); + let fault = Fault::default(); + fault.gone.store(true, Ordering::Relaxed); + assert_eq!((fault.trouble(false), fault.trouble(true)), (Some(OutputTrouble::Gone), None)); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f4214e0..259b9fd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,8 +31,8 @@ pub mod windows; use serde_json::{Value, json}; use soundcraft_engine::{Engine, TransportRequest}; use soundcraft_model::TrackId; -use soundcraft_playback::Player; use soundcraft_playback::record::Trouble; +use soundcraft_playback::{OutputTrouble, Player}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; use std::sync::mpsc::Receiver; @@ -681,6 +681,34 @@ impl SoundApp { } } + /// Stop the transport, keeping a take in progress, when the audio output fails. + fn watch_output(&mut self) { + let Some(p) = &self.player else { return }; + let Some(trouble) = p.trouble() else { return }; + let name = p.device_name.clone(); + let (cause, then) = match trouble { + OutputTrouble::Bug => ( + "the audio engine hit an internal error (see the log)".to_string(), + "Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.", + ), + OutputTrouble::Gone => { + (format!("“{name}” was disconnected"), "Plug it back in, then use Reconnect audio device in Setup ▸ Playback Engine.") + } + OutputTrouble::Stalled => (format!("“{name}” stopped asking for audio"), "Use Reconnect audio device in Setup ▸ Playback Engine."), + }; + let (recording, playing) = (self.engine.transport.recording, self.engine.transport.playing); + if recording || playing { + self.stop_play(); + } + self.ui.status = if recording { + format!("Recording stopped: {cause}. The take is kept up to there. {then}") + } else if playing { + format!("Playback stopped: {cause}. {then}") + } else { + format!("Audio output: {cause}. {then}") + }; + } + /// Stop recording, keeping the take, when the input device or the disk fails, and say as soon /// as a slow disk loses input. fn watch_input(&mut self) { @@ -965,10 +993,7 @@ impl SoundApp { self.engine.end_merge(); } } - if self.player.as_ref().is_some_and(Player::newly_failed) { - self.stop_play(); - self.ui.status = "The audio engine hit an internal error (see the log) and went silent. Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.".into(); - } + self.watch_output(); self.watch_input(); // Stop the old playback before sending a replacement session to the audio thread. self.handle_transport(dt); From 62cb92c4dfac2e66374cd352b65606f1b77ba483 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:45:14 +0100 Subject: [PATCH 130/154] Never replace or quit a session while it records Opening, creating, closing or reverting a session in the middle of a take dropped the recorder: the take was written to disk but never added to any session. Quitting mid-take did the same, and on_exit didn't finish the take either. New, Open, Close, Revert and Quit now wait for the take: the engine refuses them with "stop recording first", and their menu items are greyed out while recording, as is Recover in the recovery window. Any exit that still happens mid-take (closing the window, the system quitting the app, a remote quit) finishes the take first and keeps the session, take included, in the autosave folder for the next launch. --- crates/engine/src/lib.rs | 9 +++++++++ crates/engine/tests/recording.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 13 +++++++++++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/recovery.rs | 3 ++- crates/ui-egui/tests/recovery.rs | 16 ++++++++++++++++ 6 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/recording.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66c8794..657f495 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -252,10 +252,19 @@ impl Engine { } } + /// Whether command `id` must wait for the take being recorded: nothing may replace the + /// session or quit under it. + pub fn blocked_while_recording(&self, id: &str) -> bool { + self.transport.recording && matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit") + } + /// 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 { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if self.blocked_while_recording(id) { + return Err(EngineError::Disabled(id.to_string(), "stop recording first".into())); + } 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. diff --git a/crates/engine/tests/recording.rs b/crates/engine/tests/recording.rs new file mode 100644 index 0000000..fc754fc --- /dev/null +++ b/crates/engine/tests/recording.rs @@ -0,0 +1,21 @@ +//! A take being recorded is never replaced or quit away from. +use serde_json::json; +use soundcraft_engine::{Engine, EngineError}; + +#[test] +fn nothing_replaces_or_quits_a_session_while_it_records() { + let mut e = Engine::default(); + e.transport.recording = true; + for (id, params) in [ + ("session.new", json!({})), + ("session.open", json!({"path": "Song.scraft"})), + ("session.close", json!({})), + ("session.revert", json!({})), + ("app.quit", json!({})), + ] { + assert!(matches!(e.execute(id, ¶ms), Err(EngineError::Disabled(_, why)) if why == "stop recording first"), "{id}"); + } + assert!(e.execute("track.new", &json!({})).is_ok(), "editing goes on"); + e.transport.recording = false; + assert!(e.execute("session.close", &json!({})).is_ok()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 259b9fd..16cd755 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -593,9 +593,14 @@ impl SoundApp { } /// Done with the open session. Unsaved changes stay in its autosave folder for the next - /// launch, unless the user quit with SoundCraft's Quit. + /// launch, unless the user quit with SoundCraft's Quit; a take still recording is finished + /// and always stays. pub fn close_autosave(&mut self) { - let keep = self.engine.is_dirty() && !self.quit_requested; + let recording = self.engine.transport.recording; + if recording { + self.stop_play(); + } + let keep = self.engine.is_dirty() && (recording || !self.quit_requested); if keep { self.capture_plugin_states(); self.write_autosave(); @@ -623,6 +628,10 @@ impl SoundApp { /// Replace the open session with unsaved work an earlier run left behind. pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) { + if self.engine.transport.recording { + self.ui.status = "Stop recording before recovering other work.".into(); + return; + } self.close_autosave(); match soundcraft_engine::autosave::recover(&mut self.engine, left) { Ok(missing) => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..0aa46ea 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -289,7 +289,7 @@ pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { true } - Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), + Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)), None => false, }; (enabled, checked_state(app, path, id)) diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index be2810c..838dd6d 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -22,7 +22,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if ui.button("Discard").clicked() { recover = Some(false); } - if ui.add(egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent)).clicked() { + let recover_button = egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent); + if ui.add_enabled(!app.engine.transport.recording, recover_button).on_disabled_hover_text("Stop recording first").clicked() { recover = Some(true); } }); diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 4efe596..00ea265 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -138,6 +138,22 @@ fn closing_the_session_or_quitting_drops_its_autosave() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn quitting_while_recording_keeps_the_take_for_the_next_launch() { + let _one = one_at_a_time(); + let dir = folder("recovery-recording"); + let root = dir.join("Autosave"); + let mut app = launch(&root); + app.run("track.new", json!({"name": "Vocal"})).unwrap(); + app.engine.transport.recording = true; + assert!(app.run("app.quit", json!({})).is_err(), "Quit waits for the take"); + app.quit_requested = true; + app.close_autosave(); + assert!(!app.engine.transport.recording); + assert_eq!(leftovers(&root).len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_panic_in_a_frame_autosaves_and_the_app_carries_on() { let _one = one_at_a_time(); From 30122bcea190bcffaa500016fce5b0cc9fe3362f Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:51:51 +0100 Subject: [PATCH 131/154] Recover the takes of a recording a crash interrupted Takes stream to disk while recording, but they only join the session at Stop. After a crash the files were on disk with nothing pointing at them and no record of where they started, and the header of each was up to a second behind its audio. When recording starts, its plan (each track's take file, where the takes start, the loop length and punch range) is autosaved with the session, and every autosave until Stop keeps it there. Stop places the takes from that same plan. Recovering a folder that still holds a plan first repairs each take's header to cover all of its audio, then places the takes as Stop would have, skipping any the session already has. The recovery window says when there's an unfinished recording, and such a folder is kept even if its session file was saved later. --- crates/engine/src/autosave.rs | 129 ++++++++++++++++++++++++++------- crates/engine/src/io.rs | 31 ++++++++ crates/ui-egui/src/lib.rs | 69 ++++++++++++------ crates/ui-egui/src/recovery.rs | 3 +- 4 files changed, 183 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 063380b..9af2924 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -1,7 +1,7 @@ //! Crash-recovery copies of open sessions, one folder each, written in the background, and the //! recovery of the ones left behind with unsaved work. -use crate::io::{open_session, sanitize_name}; +use crate::io::{Recording, open_session, sanitize_name}; use crate::{Engine, EngineError, Result}; use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; use soundcraft_model::{Session, SourceAudio, SourceId}; @@ -15,6 +15,8 @@ use std::time::{Duration, Instant, SystemTime}; const SESSION: &str = "session.scraft"; const ORIGIN: &str = "origin"; const LOCK: &str = "lock"; +/// The plan of a recording still running when the folder was written. +const RECORDING: &str = "recording.json"; /// While changes keep coming, the newest is written at most this often. const INTERVAL: Duration = Duration::from_secs(1); const STOPPED: &str = "the autosave thread stopped"; @@ -27,7 +29,7 @@ pub struct Autosaver { } enum Job { - Write(Arc, Option), + Write(Arc, Option, Option), /// Done with the session's folder: keep it for the next launch, or delete it. Close(bool), Flush(Sender>), @@ -46,9 +48,10 @@ impl Autosaver { Autosaver { jobs: Some(tx), thread: thread.ok(), failure } } - /// Autosave `session`, opened from the session file `origin`, soon. - pub fn write(&self, session: Arc, origin: Option) { - self.send(Job::Write(session, origin)); + /// Autosave `session`, opened from the session file `origin`, soon, with the plan of the + /// recording running in it. + pub fn write(&self, session: Arc, origin: Option, recording: Option) { + self.send(Job::Write(session, origin, recording)); } /// Done with the open session's folder: keep it for the next launch, or delete it. @@ -97,7 +100,7 @@ struct Writer { /// The open session's folder. folder: Option, /// The newest session not written yet. - pending: Option<(Arc, Option)>, + pending: Option<(Arc, Option, Option)>, last: Option, /// How the newest write into the open folder went, for `Flush`. written: std::result::Result<(), String>, @@ -111,7 +114,7 @@ impl Writer { (Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())), }; match jobs.recv_timeout(wait) { - Ok(Job::Write(session, origin)) => self.pending = Some((session, origin)), + Ok(Job::Write(session, origin, recording)) => self.pending = Some((session, origin, recording)), Ok(Job::Flush(reply)) => { self.write_pending(); let _ = reply.send(self.written.clone()); @@ -138,13 +141,13 @@ impl Writer { } fn write_pending(&mut self) { - let Some((session, origin)) = self.pending.take() else { return }; + let Some((session, origin, recording)) = self.pending.take() else { return }; self.last = Some(Instant::now()); let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { if self.folder.is_none() { self.folder = Some(Autosave::create(&self.root, &session.name)?); } - self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref())) + self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref(), recording.as_ref())) })); self.written = match written { Ok(written) => written.map_err(|e| e.to_string()), @@ -168,6 +171,8 @@ struct Autosave { lock: Option, copies: BTreeMap, String)>, next: u64, + /// A recording's plan is in the folder. + recording: bool, } impl Autosave { @@ -177,12 +182,13 @@ impl Autosave { let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; - Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1 }) + Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false }) } - /// Write `session`, opened from the session file `origin`. Audio saved with it is referenced - /// where it is; audio that exists only in memory is copied, once per recording or render. - fn write(&mut self, session: &Session, origin: Option<&str>) -> Result<()> { + /// Write `session`, opened from the session file `origin`, and the plan of the `recording` + /// running in it. Audio saved with it is referenced where it is; audio that exists only in + /// memory is copied, once per recording or render. + fn write(&mut self, session: &Session, origin: Option<&str>, recording: Option<&Recording>) -> Result<()> { let base = origin.and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); let audio_dir = self.dir.join("Audio Files"); let mut s = session.clone(); @@ -220,6 +226,13 @@ impl Autosave { for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() { let _ = std::fs::remove_file(audio_dir.join(name)); } + if let Some(r) = recording { + let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?; + write_atomically(&self.dir.join(RECORDING), plan.as_bytes())?; + } else if self.recording { + let _ = std::fs::remove_file(self.dir.join(RECORDING)); + } + self.recording = recording.is_some(); Ok(()) } @@ -239,10 +252,12 @@ pub struct Leftover { /// The session file it was autosaved from, if it had one. pub origin: Option, pub saved: SystemTime, + /// A recording was still running in it. + pub recording: bool, } /// Leftover autosaves under `root`, newest first: folders no open session holds, written after the -/// session file they came from. Empty and outdated ones are deleted. +/// session file they came from or holding a recording. Empty and outdated ones are deleted. pub fn leftovers(root: &Path) -> Vec { let mut out = Vec::new(); for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) { @@ -253,13 +268,14 @@ pub fn leftovers(root: &Path) -> Vec { let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty()); let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok()); let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified()); + let recording = dir.join(RECORDING).is_file(); drop(lock); match saved { - Ok(saved) if origin_saved.is_none_or(|o| saved > o) => { + Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => { let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default(); let named = serde_json::from_str::(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string)); let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned()); - out.push(Leftover { dir, name, origin, saved }); + out.push(Leftover { dir, name, origin, saved, recording }); } _ => { let _ = std::fs::remove_dir_all(&dir); @@ -270,12 +286,14 @@ pub fn leftovers(root: &Path) -> Vec { out } -/// Open `left` as an unsaved version of the session it came from, so Save writes it back there. -/// Returns the media that couldn't be loaded. +/// Open `left` as an unsaved version of the session it came from, so Save writes it back there, +/// with the takes of a recording that never finished placed as Stop would have. Returns the +/// media that couldn't be loaded. pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { let mut r = Engine::default(); let missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; let mut s = r.session().clone(); + let unfinished = unfinished_takes(&left.dir, &s); if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) { for src in s.sources.iter_mut().filter(|src| !src.unsaved) { if let Ok(rel) = Path::new(&src.path).strip_prefix(home) { @@ -286,9 +304,38 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { e.replace_session(Session::default()); *e.session_mut() = s; e.path = left.origin.clone(); + if let Some((plan, takes)) = unfinished { + crate::io::place_recording(e, &plan, takes); + } Ok(missing) } +/// The takes of the recording `dir` holds the plan of, repaired, minus those already in `s`. +fn unfinished_takes(dir: &Path, s: &Session) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> { + let plan: Recording = serde_json::from_str(&std::fs::read_to_string(dir.join(RECORDING)).ok()?).ok()?; + let known: Vec = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect(); + let takes = plan + .takes + .iter() + .filter(|t| std::fs::canonicalize(&t.path).is_ok_and(|p| !known.contains(&p))) + .filter(|t| repair_take(&t.path, t.channels, plan.rate).is_ok_and(|frames| frames > 0)) + .map(|t| (t.track, t.path.clone())) + .collect(); + Some((plan, takes)) +} + +/// Make a take file's header cover all the audio in it; returns its frames. The recorder +/// rewrites the header only every second, so a crash leaves up to a second uncovered. +fn repair_take(path: &Path, channels: usize, rate: u32) -> std::io::Result { + let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other); + let audio = std::fs::metadata(path)?.len().saturating_sub(header(0)?.len() as u64); + let frames = audio / (channels.max(1) as u64 * 4); + let mut f = std::fs::OpenOptions::new().write(true).open(path)?; + f.write_all(&header(frames)?)?; + f.sync_all()?; + Ok(frames) +} + /// Delete a leftover autosave. pub fn discard(left: &Leftover) { let _ = std::fs::remove_dir_all(&left.dir); @@ -353,8 +400,8 @@ mod tests { crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); record(&mut e, -0.25); let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); - a.write(e.session(), e.path.as_deref()).unwrap(); - a.write(e.session(), e.path.as_deref()).unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1); drop(a); let left = leftovers(&dir.join("Autosave")); @@ -372,10 +419,10 @@ mod tests { let mut e = Engine::default(); record(&mut e, 0.5); let mut a = Autosave::create(&dir, "Untitled").unwrap(); - a.write(e.session(), None).unwrap(); + a.write(e.session(), None, None).unwrap(); e.execute("edit.undo", &json!({})).unwrap(); record(&mut e, -0.25); - a.write(e.session(), None).unwrap(); + a.write(e.session(), None, None).unwrap(); drop(a); let mut r = Engine::default(); recover(&mut r, &leftovers(&dir)[0]).unwrap(); @@ -390,7 +437,7 @@ mod tests { let mut e = Engine::default(); for _ in 0..5 { record(&mut e, 0.5); - w.write(e.session_arc(), None); + w.write(e.session_arc(), None, None); } w.flush().unwrap(); let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::>(); @@ -401,7 +448,7 @@ mod tests { w.flush().unwrap(); assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); record(&mut e, 0.5); - w.write(e.session_arc(), None); + w.write(e.session_arc(), None, None); drop(w); let mut r = Engine::default(); recover(&mut r, &leftovers(&root)[0]).unwrap(); @@ -409,6 +456,38 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn a_take_still_recording_at_a_crash_is_placed_on_recovery() { + use crate::io::Take; + let dir = folder("autosave-take"); + let root = dir.join("Autosave"); + let mut e = Engine::default(); + let track = + soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); + let take = dir.join("Audio Files/Vox_01.wav"); + std::fs::create_dir_all(take.parent().unwrap()).unwrap(); + let audio: Vec = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect(); + std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap(); + let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] }; + let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::>(); + let w = Autosaver::new(root.clone()); + w.write(e.session_arc(), None, Some(plan.clone())); + drop(w); + let left = leftovers(&root); + assert!(left[0].recording); + let mut r = Engine::default(); + recover(&mut r, &left[0]).unwrap(); + assert_eq!(clips(&r), [(48_000, 480)], "all of the take, its last second included"); + discard(&left[0]); + let w = Autosaver::new(root.clone()); + w.write(r.session_arc(), None, Some(plan)); + drop(w); + let mut again = Engine::default(); + recover(&mut again, &leftovers(&root)[0]).unwrap(); + assert_eq!((clips(&again), again.session().sources.len()), (vec![(48_000, 480)], 1), "placed once"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn unsaved_work_is_offered_once_its_session_closes() { let dir = folder("autosave-leftovers"); @@ -419,7 +498,7 @@ mod tests { crate::io::save_session(&mut e, &path).unwrap(); record(&mut e, 0.5); let mut a = Autosave::create(&root, "Song").unwrap(); - a.write(e.session(), e.path.as_deref()).unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); assert!(leftovers(&root).is_empty()); drop(a); let left = leftovers(&root); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..7b3d636 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -925,6 +925,37 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec< .collect() } +/// A recording's plan, made when it starts: each record-enabled track's take file, where the +/// takes start on the timeline, the loop length they split into when loop recording, and the +/// punch range they're trimmed to. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Recording { + pub start: Samples, + pub pass: Option, + pub punch: Option, + /// The input's sample rate, which the take files are written at. + pub rate: u32, + pub takes: Vec, +} + +/// One track's take file. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Take { + pub track: TrackId, + pub path: PathBuf, + pub channels: usize, +} + +/// Place recorded take files as `plan` says, as one undo step ("Record"). +pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec { + let ids = add_recording(e, plan.start, takes, plan.pass); + if let Some(p) = plan.punch { + let clips: Vec = ids.iter().map(|c| c.0).collect(); + let _ = e.execute_merged("edit.trim_to_fill_selection", &json!({"clips": clips, "start": p.start, "end": p.end}), "record"); + } + ids +} + /// 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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 16cd755..2661072 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -229,6 +229,8 @@ pub struct SoundApp { recorder_failed: bool, /// Input this take lost to a slow disk, as last reported (ms). lost_shown: u64, + /// Where the takes being recorded go, autosaved with the session so a crash can't lose them. + take_plan: Option, pub ui: UiState, pub audio_health_report: Option>, pub services: Services, @@ -238,7 +240,7 @@ pub struct SoundApp { pub autosave_dir: Option, /// Writes the open session's autosaves in the background, once there is something to write. autosaver: Option, - /// The document revision last handed to the autosaver. + /// The document revision last handed to the autosaver (0 once that autosave is stale). autosaved_rev: u64, /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. pub leftovers: Vec, @@ -304,6 +306,7 @@ impl SoundApp { punch: None, recorder_failed: false, lost_shown: 0, + take_plan: None, ui: UiState::default(), audio_health_report: None, services, @@ -460,10 +463,13 @@ impl SoundApp { return; } self.ensure_input(); - if let Err(err) = self.arm_recorder() { - self.ui.status = format!("Cannot record: {err}"); - return; - } + let takes = match self.arm_recorder() { + Ok(takes) => takes, + Err(err) => { + self.ui.status = format!("Cannot record: {err}"); + return; + } + }; self.lost_shown = 0; if let Some(p) = &self.player { p.set_recording(true); @@ -474,6 +480,7 @@ impl SoundApp { // Punch in on the fly at the current playhead. self.record_start = self.position(); self.punch = None; + self.plan_recording(takes, None); return; } let e = self.engine.session().edit.clone(); @@ -482,6 +489,7 @@ impl SoundApp { self.record_start = sel.start; self.punch = None; self.start_play(sel.start, None, Some(sel)); + self.plan_recording(takes, Some(sel.len())); } else { // Record into the selection when there is one (punch), with pre/post-roll if enabled; // the take is trimmed back to the punch range afterwards. @@ -491,12 +499,23 @@ impl SoundApp { self.punch = (!sel.is_empty()).then_some(sel); let end = (!sel.is_empty()).then_some(sel.end + post); self.start_play(from, end, None); + self.plan_recording(takes, None); + } + } + + /// Remember where the armed `takes` go (split into passes of `pass` when loop recording) and + /// autosave that plan with the session. + fn plan_recording(&mut self, takes: Vec, pass: Option) { + let rate = self.recorder.as_ref().map_or(0, |r| r.sample_rate); + self.take_plan = Some(soundcraft_engine::io::Recording { start: self.record_start, pass, punch: self.punch, rate, takes }); + if let Some(root) = self.autosave_dir.clone() { + self.queue_autosave(root); } } /// Arm the input for the record-enabled tracks; a session never saved records into its - /// autosave folder. - fn arm_recorder(&mut self) -> Result<(), String> { + /// autosave folder. Returns the takes it records. + 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()) }; @@ -504,7 +523,12 @@ impl SoundApp { if plan.is_empty() { return Err("no audio track is record-enabled".into()); } - r.arm(plan) + let takes = plan + .iter() + .map(|(track, inputs, path)| soundcraft_engine::io::Take { track: *track, path: path.clone(), channels: inputs.len() }) + .collect(); + r.arm(plan)?; + Ok(takes) } /// Reads every live third-party plugin's state from the audio engine and stores it on its @@ -566,7 +590,7 @@ impl SoundApp { fn queue_autosave(&mut self, root: std::path::PathBuf) { self.autosaved_rev = self.engine.revision; let w = self.autosaver.get_or_insert_with(|| soundcraft_engine::autosave::Autosaver::new(root)); - w.write(self.engine.session_arc(), self.engine.path.clone()); + w.write(self.engine.session_arc(), self.engine.path.clone(), self.take_plan.clone()); } /// Write the session to its own folder under `root` and wait for it; false when that failed. @@ -764,23 +788,22 @@ impl SoundApp { if let Some(p) = &self.player { p.set_recording(false); } + let plan = self.take_plan.take(); 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; - 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", - ); - } - self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) }; + let plan = plan.unwrap_or_else(|| soundcraft_engine::io::Recording { + start: self.record_start, + pass: None, + punch: self.punch, + rate: 0, + takes: Vec::new(), + }); + self.punch = None; + self.autosaved_rev = 0; + let ids = soundcraft_engine::io::place_recording(&mut self.engine, &plan, takes); + self.ui.status = + if plan.pass.is_some() { 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); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 838dd6d..bd66e29 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -16,7 +16,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) .show(ctx, |ui| { let age = left.saved.elapsed().map_or_else(|_| "recently".into(), |d| ago(d.as_secs())); - ui.label(format!("“{}” has unsaved changes from an earlier run, autosaved {age}.", left.name)); + let what = if left.recording { "unsaved changes and an unfinished recording" } else { "unsaved changes" }; + ui.label(format!("“{}” has {what} from an earlier run, autosaved {age}.", left.name)); ui.separator(); ui.horizontal(|ui| { if ui.button("Discard").clicked() { From e44c49cada6f8557aeeb40586ab3cf09622f0a59 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:26:14 +0100 Subject: [PATCH 132/154] Record NaN and infinite input samples as silence A device or driver that sends NaN or infinite samples got them written into the take, where they poison whatever the clip is mixed with, and into the input monitor. Both paths now turn them into silence before they are queued, so a take keeps its length and timing. --- crates/playback/src/record.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index 0039c95..0f75aa5 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -70,6 +70,11 @@ impl CaptureRing { } } +/// An input sample, or silence for NaN and infinities, which would poison the take and the mix. +fn finite(x: f32) -> f32 { + if x.is_finite() { x } else { 0.0 } +} + struct Shared { armed: AtomicBool, ring: CaptureRing, @@ -102,7 +107,7 @@ impl Shared { /// 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 }; + let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().map_or(0.0, finite)) } else { 0 }; *owed = owed.saturating_add(data.len() - kept); self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed); } @@ -351,6 +356,15 @@ mod tests { assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2)); } + #[test] + fn nan_and_infinite_input_records_as_silence() { + let shared = Shared::new(CaptureRing::new(8)); + let (mut owed, mut out) = (0, Vec::new()); + shared.capture(&[0.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5], &mut owed); + shared.ring.pop_all(&mut out); + assert_eq!(out, [0.5, 0.0, 0.0, 0.0, -0.5]); + } + fn recorder(shared: &Arc) -> Recorder { Recorder { shared: Arc::clone(shared), @@ -474,7 +488,7 @@ impl InputRing { pub fn push(&self, samples: &[f32]) { if let Ok(mut s) = self.state.try_lock() { - s.data.extend(samples.iter().copied()); + s.data.extend(samples.iter().copied().map(finite)); while s.data.len() > self.cap { s.data.pop_front(); } @@ -569,4 +583,14 @@ mod ring_tests { ring.pop_into(&mut out, 16, 48_000); assert!(out[0].iter().all(|v| *v == 0.0)); } + + /// NaN and infinities from the device monitor as silence. + #[test] + fn monitor_silences_nan_and_infinite_input() { + let ring = InputRing::new(1, 48_000); + ring.push(&[0.5, f32::NAN, f32::INFINITY, -0.5, 0.0, 0.0, 0.0, 0.0]); + let mut out = vec![vec![1.0f32; 4]]; + ring.pop_into(&mut out, 4, 48_000); + assert_eq!(out[0], [0.5, 0.0, 0.0, -0.5]); + } } From 7f9b629964e123bdf48edf5809c69bbdaac4b028 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:43:58 +0100 Subject: [PATCH 133/154] Draw each take in the Edit window while it records Pro Tools draws a take's waveform as it records; ours showed nothing until Stop. Each record-enabled track now shows its take growing as a red clip with its waveform, drawn from the take's live summary through the same column grid and channel bands as clips (Peak view, as Pro Tools always shows while recording). - The take writer keeps, per take channel, the (min, max) of every 128 frames it writes (at most 2^22 blocks, three hours at 48 kHz; the file records on regardless). Recorder::live_take hands the Edit window what a track's take holds so far, while the writer runs. The blocks sit behind a mutex the writer thread and the UI lock briefly; the input callback is untouched. - io::live_span places the take as Stop will: from the record start, only the pass under way when loop recording, and within the punch range. Tests: the take writer's summary (record.rs), live_span with loop and punch (io.rs) and draw_live (tests/waveform.rs). --- crates/engine/src/io.rs | 20 ++++++++++ crates/playback/src/record.rs | 63 ++++++++++++++++++++++++++++--- crates/ui-egui/src/edit_window.rs | 19 ++++++++++ crates/ui-egui/src/waveform.rs | 31 +++++++++++++-- crates/ui-egui/tests/waveform.rs | 26 ++++++++++--- 5 files changed, 143 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..4f14495 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -967,6 +967,15 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu out } +/// Where a take recording from `start` shows while it holds `len` samples, as it will be placed: +/// just the pass under way when loop recording in passes of `pass`, within the `punch` range. +/// Returns the timeline position of its first sample and the span it covers. +pub fn live_span(start: Samples, pass: Option, punch: Option, len: Samples) -> Option<(Samples, Range)> { + let origin = start.saturating_sub(pass.filter(|p| *p > 0).map_or(0, |p| len / p * p)); + let span = Range::new(start, origin.saturating_add(len)); + Some((origin, span.intersect(&punch.unwrap_or(span))?)) +} + #[cfg(test)] mod record_tests { use super::*; @@ -1029,6 +1038,17 @@ mod record_tests { assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() { + assert_eq!(live_span(1_000, None, None, 0), None); + assert_eq!(live_span(1_000, None, None, 500), Some((1_000, Range::new(1_000, 1_500)))); + assert_eq!(live_span(1_000, Some(400), None, 900), Some((200, Range::new(1_000, 1_100)))); + assert_eq!(live_span(1_000, Some(400), None, 800), None); + let punch = Some(Range::new(1_200, 1_400)); + assert_eq!(live_span(1_000, None, punch, 150), None); + assert_eq!(live_span(1_000, None, punch, 500), Some((1_000, Range::new(1_200, 1_400)))); + } } #[cfg(test)] diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index ec784ac..04ee521 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -9,7 +9,7 @@ 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::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; use soundcraft_model::TrackId; @@ -17,6 +17,15 @@ 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); +/// Frames each block of a [`LiveTake`] sums up. +pub const LIVE_BLOCK: usize = 128; +/// Blocks a live take keeps per channel at most (three hours at 48 kHz); its file records on. +const LIVE_BLOCKS: usize = 1 << 22; + +/// A take while it records, summed up for drawing: per channel, the (min, max) of each +/// [`LIVE_BLOCK`] frames so far. +pub type LiveTake = Vec>; + const RING_SECONDS: usize = 4; /// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots @@ -84,7 +93,8 @@ pub struct Recorder { pub device_name: String, pub sample_rate: u32, pub channels: usize, - writer: Option>, + /// The take writer, and what each track's take holds so far. + writer: Option<(std::thread::JoinHandle, Vec<(TrackId, Arc>)>)>, } impl Recorder { @@ -145,12 +155,13 @@ impl Recorder { } let takes = takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::, _>>()?; + let live = takes.iter().map(|t| (t.id, Arc::clone(&t.live))).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), + Ok(w) => self.writer = Some((w, live)), Err(e) => { self.shared.armed.store(false, Ordering::Release); return Err(e.to_string()); @@ -159,9 +170,15 @@ impl Recorder { Ok(()) } + /// What the take recording onto `track` holds so far. + pub fn live_take(&self, track: TrackId) -> Option> { + let (_, take) = self.writer.as_ref()?.1.iter().find(|(id, _)| *id == track)?; + Some(take.lock().unwrap_or_else(PoisonError::into_inner)) + } + 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 (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)); @@ -216,13 +233,15 @@ struct TakeWriter { rate: u32, frames: u64, error: Option, + live: Arc>, } 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 }; + let live = Arc::new(Mutex::new(vec![Vec::new(); inputs.len()])); + let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None, live }; take.write_header(); take.error.take().map_or(Ok(take), Err) } @@ -238,11 +257,30 @@ impl TakeWriter { .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, + Ok(()) => { + self.summarize(block, channels); + self.frames += (block.len() / channels) as u64; + } Err(e) => self.fail(e), } } + /// Add this take's channels of `block` to its live take. + fn summarize(&self, block: &[f32], channels: usize) { + let mut live = self.live.lock().unwrap_or_else(PoisonError::into_inner); + for (f, n) in block.chunks_exact(channels).zip(self.frames..) { + let at = (n / LIVE_BLOCK as u64) as usize; + for (blocks, &i) in live.iter_mut().zip(&self.inputs) { + let v = f.get(i).copied().unwrap_or(0.0); + match blocks.get_mut(at) { + Some(b) => *b = (b.0.min(v), b.1.max(v)), + None if at < LIVE_BLOCKS => blocks.push((v, v)), + None => {} + } + } + } + } + fn write_header(&mut self) { let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) { Ok(h) => h, @@ -306,6 +344,19 @@ mod tests { 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); } + + #[test] + fn a_take_sums_itself_up_for_drawing_while_it_records() { + let dir = std::env::temp_dir().join(format!("soundcraft-live-take-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut take = TakeWriter::create(TrackId(4), vec![2, 0], dir.join("Gtr_01.wav"), 48_000).unwrap(); + let input: Vec = (0..300).flat_map(|f| [f as f32, -1.0, -(f as f32)]).collect(); + take.write(&input[..3 * 100], 3); + take.write(&input[3 * 100..], 3); + let live = take.live.lock().unwrap().clone(); + assert_eq!(live, [[(-127.0, 0.0), (-255.0, -128.0), (-299.0, -256.0)], [(0.0, 127.0), (128.0, 255.0), (256.0, 299.0)]]); + let _ = std::fs::remove_dir_all(&dir); + } } /// Ring state behind the lock: interleaved input frames at the input device's rate, plus the diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index bbd5dc6..e346c3c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -706,6 +706,7 @@ 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()); } + draw_live_take(app, &painter, &s, &track, tl, lane); crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane); if let Some(p) = &auto_view { draw_automation(&painter, &s, &track, p, tl, lane); @@ -1144,6 +1145,24 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { } } +/// The take `track` is recording, drawn in red as it grows, as in Pro Tools. +fn draw_live_take(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, tl: Rect, lane: Rect) { + let Some(rec) = &app.recorder else { return }; + let Some(take) = rec.live_take(track.id) else { return }; + let block_len = soundcraft_playback::record::LIVE_BLOCK as f64 * s.sample_rate.as_f64() / f64::from(rec.sample_rate.max(1)); + let len = soundcraft_time::to_samples(take.first().map_or(0, Vec::len) as f64 * block_len); + let sel = s.edit.selection; + let (start, pass) = if s.edit.loop_record && !sel.is_empty() { (sel.start, Some(sel.len())) } else { (app.record_start, None) }; + let Some((origin, span)) = soundcraft_engine::io::live_span(start, pass, app.punch, len) else { return }; + let r = Rect::from_min_max(pos2(x_of(s, tl, span.start), lane.min.y + 1.0), pos2(x_of(s, tl, span.end), lane.max.y - 1.0)); + let t = Tokens::current(); + let (body, _, wave) = clip_colors([t.rec.r(), t.rec.g(), t.rec.b()], false); + painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); + if track.view != "blocks" { + crate::waveform::draw_live(&painter.with_clip_rect(r.intersect(lane)), s, tl, origin as f64, block_len, &take, r, wave); + } +} + 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/src/waveform.rs b/crates/ui-egui/src/waveform.rs index 2378f07..0a2ccd4 100644 --- a/crates/ui-egui/src/waveform.rs +++ b/crates/ui-egui/src/waveform.rs @@ -6,13 +6,16 @@ use soundcraft_time::Samples; /// Draw a clip's audio across `body`, one band per channel, `channels` of them at most. pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) { - let n = channels.min(audio.peaks.len()).max(1); - let h = body.height() / n as f32; - for ch in 0..n { - channel(painter, s, tl, clip, audio, ch, Rect::from_min_size(pos2(body.min.x, body.min.y + h * ch as f32), vec2(body.width(), h)), wave); + for (ch, band) in bands(body, channels.min(audio.peaks.len()).max(1)).enumerate() { + channel(painter, s, tl, clip, audio, ch, band, wave); } } +fn bands(body: Rect, n: usize) -> impl Iterator { + let h = body.height() / n as f32; + (0..n).map(move |k| Rect::from_min_size(pos2(body.min.x, body.min.y + h * k as f32), vec2(body.width(), h))) +} + fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) { let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return }; let Some(g) = Grid::new(painter, s, tl, band) else { return }; @@ -53,6 +56,26 @@ fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &Source g.paint(painter, &ys, wave, outlines, rectified); } +/// Draw a take while it records across `body`, one band per channel: `blocks` holds each channel's +/// (min, max) per block of input frames, the blocks `block_len` timeline samples long from +/// timeline position `origin`. +pub fn draw_live(painter: &Painter, s: &Session, tl: Rect, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) { + let zoom = s.edit.zoom.waveform_zoom; + for (blocks, band) in blocks.iter().zip(bands(body, blocks.len())) { + let Some(g) = Grid::new(painter, s, tl, band) else { continue }; + let ys: Vec> = (0..g.cols) + .map(|c| { + let a = g.start() + c as f64 * g.step - origin; + let (i, j) = ((a / block_len).floor().max(0.0) as usize, ((a + g.step + g.overlap()) / block_len).ceil().max(0.0) as usize); + let (lo, hi) = + blocks.get(i..j.min(blocks.len()))?.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), b| (lo.min(b.0), hi.max(b.1))); + (lo <= hi).then(|| (g.cy - level(hi * zoom) * g.half, g.cy - level(lo * zoom) * g.half)) + }) + .collect(); + g.paint(painter, &ys, wave, false, false); + } +} + fn level(v: f32) -> f32 { if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 } } diff --git a/crates/ui-egui/tests/waveform.rs b/crates/ui-egui/tests/waveform.rs index 4726f23..2724ec0 100644 --- a/crates/ui-egui/tests/waveform.rs +++ b/crates/ui-egui/tests/waveform.rs @@ -7,21 +7,35 @@ use soundcraft_ui_egui::{Services, SoundApp, waveform}; const WAVE: Color32 = Color32::from_rgb(12, 12, 14); -fn draw(samples: Vec, samples_per_px: f64) -> Vec { +/// The lines and meshes `f` paints across a 400×100 band on a 2x screen. +fn paint(samples_per_px: f64, f: impl Fn(&egui::Painter, &Session, Rect)) -> Vec { let mut s = Session::new("t", SampleRate::HZ_48000); s.edit.zoom.samples_per_px = samples_per_px; - let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); - let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); let band = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0)); let ctx = egui::Context::default(); ctx.set_pixels_per_point(2.0); - let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| { - waveform::draw(ui.painter(), &s, band, &clip, &audio, 1, band, WAVE); - }); + let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| f(ui.painter(), &s, band)); out.textures_delta.clear(); out.shapes.into_iter().map(|c| c.shape).filter(|s| matches!(s, Shape::Path(_) | Shape::Mesh(_))).collect() } +fn draw(samples: Vec, samples_per_px: f64) -> Vec { + let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); + let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); + paint(samples_per_px, |p, s, band| waveform::draw(p, s, band, &clip, &audio, 1, band, WAVE)) +} + +#[test] +fn a_take_draws_its_overview_while_it_records() { + let blocks = vec![vec![(-0.5, 0.5); 100]]; + let shapes = paint(128.0, |p, s, band| waveform::draw_live(p, s, band, 1280.0, 128.0, &blocks, band, WAVE)); + let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") }; + let span = |f: fn(&Pos2) -> f32| mesh.vertices.iter().map(|v| f(&v.pos)).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(v), b.max(v))); + let ((left, right), (top, bottom)) = (span(|p| p.x), span(|p| p.y)); + assert!((left - 10.0).abs() <= 1.0 && (right - 110.0).abs() <= 1.0, "drawn from {left} to {right}"); + assert!((top - 27.0).abs() <= 1.0 && (bottom - 73.0).abs() <= 1.0, "drawn from {top} to {bottom}"); +} + #[test] fn at_the_sample_level_the_waveform_is_a_line_through_the_samples() { let x: Vec = (0..200).map(|i| (i as f32 * 0.3).sin() * 0.8).collect(); From 5755795663ec034a94a4a81e2ff4605243c89954 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:42:25 +0100 Subject: [PATCH 134/154] Stream bounces to disk instead of rendering them in memory A bounce rendered the whole mix (or bus) into memory and then encoded a second copy, and the render silently stopped at 2^29 samples: about 93 minutes of stereo at 48 kHz, 31 of 5.1, 15 of 7.1.4. Longer bounces came out cut short while file.bounce_mix still reported the requested length. - audio-io: FileEncoder writes WAV/BWF (RF64 above 4 GiB), AIFF and FLAC block by block into any Write + Seek, byte for byte what encode writes for the whole buffer. check_room fails up front when an AIFF (4 GiB) or a FLAC (2^36 frames) can't hold the bounce. AIFF and FLAC encode run on the same writers; their output is unchanged. - mix: RangeRender renders the main mix or one bus over a range of any length, a block at a time; render_range and render_bus (in memory, still capped) are built on it. - engine: io::Bounce streams the main mix or a bus, or its stereo fold-down, into ".part" next to the target, syncs it and renames it into place, so a failed bounce leaves no partial file and keeps the old one. In the browser it encodes into memory (failing instead of aborting when memory runs out) and delivers the file as before. Normalize renders twice (peak, then the scaled write); levels are measured as blocks are written. Stems stream the same way and share the 4-hour limit. A 100-minute bounce of the demo session now writes all 288,000,000 frames with 83 MB peak memory. --- crates/audio-io/src/aiff.rs | 107 +++++++++---- crates/audio-io/src/flac.rs | 154 +++++++++++++----- crates/audio-io/src/lib.rs | 60 +++++++ crates/audio-io/src/pcm.rs | 57 ++++++- crates/audio-io/src/wav.rs | 104 ++++++++---- crates/audio-io/tests/stream.rs | 64 ++++++++ crates/dsp/src/offline.rs | 3 +- crates/engine/src/cmd/file.rs | 14 +- crates/engine/src/io.rs | 272 +++++++++++++++++++++++++------- crates/engine/tests/bounce.rs | 63 ++++++++ crates/mix/src/lib.rs | 176 +++++++++++++-------- docs/architecture.md | 7 +- 12 files changed, 848 insertions(+), 233 deletions(-) create mode 100644 crates/audio-io/tests/stream.rs create mode 100644 crates/engine/tests/bounce.rs diff --git a/crates/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs index dfa7623..d8462c0 100644 --- a/crates/audio-io/src/aiff.rs +++ b/crates/audio-io/src/aiff.rs @@ -1,8 +1,9 @@ //! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the //! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate). -use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer}; +use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved}; use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat}; +use std::io::{Cursor, Seek, SeekFrom, Write}; /// AIFF-C version 1 timestamp for the FVER chunk. const AIFC_VERSION_1: u32 = 0xA280_5140; @@ -162,25 +163,82 @@ fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { /// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32). pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { validate_buffer(buf)?; - let channels = buf.channels.len(); - let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?; let frames = buf.frames(); + let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len(); + let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames); + let total = head_len.saturating_add(data_len).saturating_add(1); + let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?; + let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) +} + +/// An AIFF written block by block: a placeholder header first, then the real one (the same +/// length) written over it by [`Writer::finish`]. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + depth: BitDepth, + quantizers: Vec, + frames: u64, + bytes: Vec, +} + +impl Writer { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result { + validate_layout(channels, sample_rate)?; + let start = out.stream_position()?; + out.write_all(&header(channels, sample_rate, depth, 0)?)?; + let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels); + Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new() }) + } + + /// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + header(self.channels, self.sample_rate, self.depth, total)?; + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence), unless they don't + /// fit (see [`Writer::check_room`]). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true)?; + self.frames = total; + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result { + let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames); + if data_len % 2 == 1 { + self.out.write_all(&[0])?; + } + let head = header(self.channels, self.sample_rate, self.depth, self.frames)?; + self.out.seek(SeekFrom::Start(self.start))?; + self.out.write_all(&head)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + +/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't +/// depend on `frames`. +fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result> { + let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?; let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?; - let (bits, is_float): (u16, bool) = match depth { - BitDepth::Int16 => (16, false), - BitDepth::Int24 => (24, false), - BitDepth::Int32 => (32, false), - BitDepth::Float32 => (32, true), - }; - let bytes = usize::from(bits / 8); - let data_len = - bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; + let (bits, is_float) = sample_format(depth); + let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames); let mut comm = Vec::with_capacity(40); comm.extend_from_slice(&channels_i16.to_be_bytes()); comm.extend_from_slice(&frames_u32.to_be_bytes()); comm.extend_from_slice(&bits.to_be_bytes()); - comm.extend_from_slice(&u32_to_extended(buf.sample_rate)); + comm.extend_from_slice(&u32_to_extended(sample_rate)); if is_float { comm.extend_from_slice(b"fl32"); let name = b"32-bit float"; @@ -204,29 +262,10 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result head.extend_from_slice(&ssnd_len.to_be_bytes()); head.extend_from_slice(&[0u8; 8]); // offset, blockSize - let total = head.len().saturating_add(data_len).saturating_add(data_len & 1); + let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1); let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?; if let Some(slot) = head.get_mut(4..8) { slot.copy_from_slice(&form_len.to_be_bytes()); } - - let mut out = Vec::new(); - out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?; - out.extend_from_slice(&head); - let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); - for f in 0..frames { - for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { - let s = ch.get(f).copied().unwrap_or(0.0); - if is_float { - out.extend_from_slice(&clamp_float(s).to_be_bytes()); - } else { - let v = q.quantize(s).to_be_bytes(); - out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default()); - } - } - } - if data_len % 2 == 1 { - out.push(0); - } - Ok(out) + Ok(head) } diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 2f5550b..8545183 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -4,6 +4,7 @@ use crate::pcm::{Quantizer, validate_buffer}; use crate::{AudioBuffer, AudioError, BitDepth, Result}; +use std::io::{Cursor, Seek, SeekFrom, Write}; const BLOCK_SIZE: usize = 4096; const MAX_PARTITION_ORDER: u32 = 6; @@ -269,31 +270,24 @@ fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) { } } -pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { - validate_buffer(buf)?; - let channels = buf.channels.len(); +/// Checks that FLAC can hold the layout; returns the bits per sample. +fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result { + crate::pcm::validate_layout(channels, sample_rate)?; if channels > 8 { return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}"))); } - if buf.sample_rate > 655_350 { - return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate))); + if sample_rate > 655_350 { + return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz"))); } - let bps: u32 = match depth { - BitDepth::Int16 => 16, - BitDepth::Int24 => 24, - BitDepth::Int32 => 32, - BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), - }; - let frames = buf.frames(); - let total = frames as u64; - if total >= 1u64 << 36 { - return Err(AudioError::TooLarge("too many frames for FLAC".into())); + match depth { + BitDepth::Int16 => Ok(16), + BitDepth::Int24 => Ok(24), + BitDepth::Int32 => Ok(32), + BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), } +} - let mut out = Vec::new(); - out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2) - .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; - out.extend_from_slice(b"fLaC"); +fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec { let mut si = BitWriter::new(); si.write(1, 1); // last metadata block si.write(0, 7); // STREAMINFO @@ -302,7 +296,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result si.write(BLOCK_SIZE as u64, 16); si.write(0, 24); si.write(0, 24); - si.write(u64::from(buf.sample_rate), 20); + si.write(u64::from(sample_rate), 20); si.write(channels as u64 - 1, 3); si.write(u64::from(bps - 1), 5); si.write(total >> 32, 4); @@ -310,25 +304,87 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result for _ in 0..4 { si.write(0, 32); // MD5 unknown } - out.extend_from_slice(&si.into_bytes()); + si.into_bytes() +} - let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate); - let mut quantizers = Quantizer::per_channel(bps, dither, channels); - let mut block: Vec = Vec::with_capacity(BLOCK_SIZE); - let mut frame_no: u64 = 0; - let mut start = 0usize; - while start < frames { - let len = (frames - start).min(BLOCK_SIZE); +/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + bps: u32, + quantizers: Vec, + pending: Vec>, + frames: u64, + frame_no: u64, +} + +impl Writer { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result { + let bps = check(channels, sample_rate, depth)?; + let start = out.stream_position()?; + out.write_all(b"fLaC")?; + out.write_all(&stream_info(channels, sample_rate, bps, 0))?; + Ok(Writer { + out, + start, + channels, + sample_rate, + bps, + quantizers: Quantizer::per_channel(bps, dither, channels), + pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(), + frames: 0, + frame_no: 0, + }) + } + + /// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + if total >= 1u64 << 36 { + return Err(AudioError::TooLarge("too many frames for FLAC".into())); + } + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence), unless they don't + /// fit (see [`Writer::check_room`]). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + let mut done = 0; + while done < frames { + let held = self.pending.first().map_or(0, Vec::len); + let n = (BLOCK_SIZE - held).min(frames - done); + for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() { + let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default(); + p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0))))); + } + done += n; + if held + n == BLOCK_SIZE { + self.write_frame()?; + } + } + self.frames = total; + Ok(()) + } + + fn write_frame(&mut self) -> Result<()> { + let len = self.pending.first().map_or(0, Vec::len); + if len == 0 { + return Ok(()); + } + let (sr_code, sr_extra) = sample_rate_code(self.sample_rate); let mut w = BitWriter::new(); w.write(0x3FFE, 14); w.write(0, 1); w.write(0, 1); // fixed blocksize stream w.write(7, 4); // 16-bit (blocksize-1) follows w.write(sr_code, 4); - w.write(channels as u64 - 1, 4); - w.write(bps_code(bps), 3); + w.write(self.channels as u64 - 1, 4); + w.write(bps_code(self.bps), 3); w.write(0, 1); - write_utf8_number(&mut w, frame_no); + write_utf8_number(&mut w, self.frame_no); w.write(len as u64 - 1, 16); if let Some((v, n)) = sr_extra { w.write(v, n); @@ -339,19 +395,39 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result w.write(u64::from(b), 8); } w.write(u64::from(crc8(&header)), 8); - for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) { - block.clear(); - block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s)))); - write_subframe(&mut w, &block, bps); + for p in &mut self.pending { + write_subframe(&mut w, p, self.bps); + p.clear(); } let mut frame = w.into_bytes(); let crc = crc16(&frame); frame.extend_from_slice(&crc.to_be_bytes()); - out.extend_from_slice(&frame); - start += len; - frame_no += 1; + self.out.write_all(&frame)?; + self.frame_no += 1; + Ok(()) } - Ok(out) + + pub(crate) fn finish(mut self) -> Result { + self.write_frame()?; + let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames); + self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?; + self.out.write_all(&info)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + +pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { + validate_buffer(buf)?; + let bps = check(buf.channels.len(), buf.sample_rate, depth)?; + let frames = buf.frames(); + let mut out = Vec::new(); + out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2) + .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; + let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) } #[cfg(test)] diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 87241c6..ac8ad60 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -10,6 +10,8 @@ //! 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. +//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file +//! of any length never has to fit in memory. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod aiff; @@ -21,6 +23,7 @@ mod symph; mod wav; pub use buffer::AudioBuffer; +use std::io::{Seek, Write}; /// Errors from probing, decoding and encoding. #[derive(Debug, thiserror::Error)] @@ -37,6 +40,9 @@ pub enum AudioError { /// Encoding failed (e.g. an empty buffer). #[error("audio encode failed: {0}")] Encode(String), + /// Writing the output failed. + #[error("audio write failed: {0}")] + Io(#[from] std::io::Error), } /// Result alias for this crate. @@ -286,6 +292,60 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u } } +/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing +/// the same bytes [`encode_with_channel_mask`] would for the whole buffer. +pub struct FileEncoder(Writer); + +enum Writer { + Wav(wav::Writer), + Aiff(aiff::Writer), + Flac(flac::Writer), +} + +impl FileEncoder { + /// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask + /// derived from the channel count. + pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option) -> Result { + let (depth, dither) = (opts.bit_depth, opts.dither); + Ok(FileEncoder(match opts.format { + FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?), + FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?), + FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?), + other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))), + })) + } + + /// Fails, without writing anything, when `frames` more frames would not fit the format (an + /// AIFF holds 4 GiB, a FLAC 2^36 frames). + pub fn check_room(&self, frames: u64) -> Result<()> { + match &self.0 { + Writer::Wav(w) => w.check_room(frames), + Writer::Aiff(w) => w.check_room(frames), + Writer::Flac(w) => w.check_room(frames), + } + .map(drop) + } + + /// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are + /// silence), unless they don't fit (see [`FileEncoder::check_room`]). + pub fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + match &mut self.0 { + Writer::Wav(w) => w.write(block, frames), + Writer::Aiff(w) => w.write(block, frames), + Writer::Flac(w) => w.write(block, frames), + } + } + + /// Completes the headers and hands back `out`, positioned at the end of the file. + pub fn finish(self) -> Result { + match self.0 { + Writer::Wav(w) => w.finish(), + Writer::Aiff(w) => w.finish(), + Writer::Flac(w) => w.finish(), + } + } +} + /// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one. pub fn wav_channel_mask(bytes: &[u8]) -> Option { wav::read_channel_mask(bytes) diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs index a1fe894..c256ea8 100644 --- a/crates/audio-io/src/pcm.rs +++ b/crates/audio-io/src/pcm.rs @@ -1,7 +1,8 @@ //! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample //! decoding to `f32`, and quantization (with optional TPDF dither) for encoding. -use crate::{AudioError, Result}; +use crate::{AudioError, BitDepth, Result}; +use std::io::Write; /// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output. pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4; @@ -156,6 +157,49 @@ impl Quantizer { } } +/// Bits per sample at `depth`, and whether they are float. +pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) { + match depth { + BitDepth::Int16 => (16, false), + BitDepth::Int24 => (24, false), + BitDepth::Int32 => (32, false), + BitDepth::Float32 => (32, true), + } +} + +/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit +/// samples and writes them to `out`, a few thousand frames at a time through `scratch`. +pub(crate) fn write_interleaved( + out: &mut impl Write, + scratch: &mut Vec, + block: &[Vec], + frames: usize, + quantizers: &mut [Quantizer], + (bits, is_float): (u16, bool), + big_endian: bool, +) -> std::io::Result<()> { + let size = usize::from(bits / 8); + let mut start = 0; + while start < frames { + let end = frames.min(start + 4096); + scratch.clear(); + for f in start..end { + for (c, q) in quantizers.iter_mut().enumerate() { + let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0); + match (is_float, big_endian) { + (true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()), + (true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()), + (false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()), + (false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()), + } + } + } + out.write_all(scratch)?; + start = end; + } + Ok(()) +} + /// Clamp a float sample to [-1, 1], mapping NaN to 0. pub(crate) fn clamp_float(x: f32) -> f32 { if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) } @@ -163,13 +207,18 @@ pub(crate) fn clamp_float(x: f32) -> f32 { /// Check that a buffer is encodable: at least one channel and a non-zero sample rate. pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> { - if buf.channels.is_empty() { + validate_layout(buf.channels.len(), buf.sample_rate) +} + +/// Check that a stream is encodable: at least one channel and a non-zero sample rate. +pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> { + if channels == 0 { return Err(AudioError::Encode("buffer has no channels".into())); } - if buf.channels.len() > usize::from(u16::MAX) { + if channels > usize::from(u16::MAX) { return Err(AudioError::Encode("too many channels".into())); } - if buf.sample_rate == 0 { + if sample_rate == 0 { return Err(AudioError::Encode("sample rate is 0".into())); } Ok(()) diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index df56010..43574a8 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -1,8 +1,9 @@ //! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia //! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306). -use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer}; +use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved}; use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat}; +use std::io::{Cursor, Seek, SeekFrom, Write}; const WAVE_FORMAT_PCM: u16 = 1; const WAVE_FORMAT_IEEE_FLOAT: u16 = 3; @@ -255,6 +256,74 @@ fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { } } +/// A WAV written block by block: a placeholder header first, then the real one (the same length, +/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`]. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + format: (u16, bool), + bwf: Option, + mask: Option, + force_rf64: bool, + quantizers: Vec, + frames: u64, + bytes: Vec, +} + +impl Writer { + pub(crate) fn new( + mut out: W, + channels: usize, + sample_rate: u32, + depth: BitDepth, + dither: bool, + bwf: Option<&BwfInfo>, + mask: Option, + force_rf64: bool, + ) -> Result { + validate_layout(channels, sample_rate)?; + let format = sample_format(depth); + let start = out.stream_position()?; + out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?; + let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels); + Ok(Writer { out, start, channels, sample_rate, format, bwf: bwf.cloned(), mask, force_rf64, quantizers, frames: 0, bytes: Vec::new() }) + } + + fn header(&self, frames: u64) -> Result> { + header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64) + } + + /// The frame count after `frames` more (RF64 has room for any bounce). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + self.header(total)?; + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false)?; + self.frames = total; + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result { + let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames); + if data_len % 2 == 1 { + self.out.write_all(&[0])?; + } + let head = self.header(self.frames)?; + self.out.seek(SeekFrom::Start(self.start))?; + self.out.write_all(&head)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + /// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests); /// it is used automatically when the file would exceed 4 GiB. /// `mask` overrides the speaker mask derived from the channel count. @@ -269,36 +338,15 @@ pub(crate) fn encode( validate_buffer(buf)?; let channels = buf.channels.len(); let frames = buf.frames(); - let (bits, is_float): (u16, bool) = match depth { - BitDepth::Int16 => (16, false), - BitDepth::Int24 => (24, false), - BitDepth::Int32 => (32, false), - BitDepth::Float32 => (32, true), - }; - let bytes = usize::from(bits / 8); - 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 format = sample_format(depth); + let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?; + let data_len = (usize::from(format.0 / 8) * 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)) .map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?; - out.extend_from_slice(&head); - let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); - for f in 0..frames { - for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { - let s = ch.get(f).copied().unwrap_or(0.0); - if is_float { - out.extend_from_slice(&clamp_float(s).to_le_bytes()); - } else { - let v = q.quantize(s).to_le_bytes(); - out.extend_from_slice(v.get(..bytes).unwrap_or_default()); - } - } - } - if data_len % 2 == 1 { - out.push(0); - } - Ok(out) + let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) } /// Everything before the sample data of a WAV holding `frames` frames. diff --git a/crates/audio-io/tests/stream.rs b/crates/audio-io/tests/stream.rs new file mode 100644 index 0000000..61a2894 --- /dev/null +++ b/crates/audio-io/tests/stream.rs @@ -0,0 +1,64 @@ +use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask}; +use std::io::Cursor; + +fn tone(channels: usize, frames: usize) -> AudioBuffer { + let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect(); + AudioBuffer { sample_rate: 44_100, channels } +} + +fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option) -> Vec { + let mut out = Cursor::new(b"pre".to_vec()); + out.set_position(3); + let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap(); + let mut start = 0; + for len in [1, 7, 4095, 4097, 333].into_iter().cycle() { + if start >= buf.frames() { + break; + } + let len = len.min(buf.frames() - start); + let block: Vec> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect(); + enc.write(&block, len).unwrap(); + start += len; + } + enc.finish().unwrap().into_inner() +} + +#[test] +fn streamed_files_match_whole_buffer_encodes() { + let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() }; + let cases = [ + (FileFormat::Wav, BitDepth::Int24, true, None, None), + (FileFormat::Wav, BitDepth::Float32, false, None, None), + (FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)), + (FileFormat::Aiff, BitDepth::Int24, true, None, None), + (FileFormat::Aiff, BitDepth::Float32, false, None, None), + (FileFormat::Flac, BitDepth::Int16, true, None, None), + (FileFormat::Flac, BitDepth::Int24, false, None, None), + ]; + for (format, bit_depth, dither, bwf, mask) in cases { + let opts = EncodeOptions { format, bit_depth, dither, bwf }; + for channels in [1, 6] { + let buf = tone(channels, 20_001); + let whole = match mask { + Some(m) => encode_with_channel_mask(&buf, &opts, m), + None => encode(&buf, &opts), + } + .unwrap(); + assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels"); + } + } +} + +#[test] +fn blocks_past_a_format_limit_are_refused_before_anything_is_written() { + let buf = tone(2, 100); + for (format, limit) in [(FileFormat::Aiff, 1usize << 30), (FileFormat::Flac, 1 << 36)] { + let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None }; + let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap(); + enc.write(&buf.channels, 100).unwrap(); + assert!(enc.check_room(limit as u64 / 2).is_ok(), "{format:?}"); + assert!(matches!(enc.check_room(limit as u64), Err(AudioError::TooLarge(_))), "{format:?}"); + assert!(matches!(enc.write(&[], limit), Err(AudioError::TooLarge(_))), "{format:?}"); + assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap()); + } +} diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index b9a5eac..859871b 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -52,7 +52,8 @@ fn max_len(ch: &[Vec]) -> usize { ch.iter().map(Vec::len).max().unwrap_or(0) } -fn peak(ch: &[Vec]) -> f32 { +/// The largest finite sample magnitude in any channel. +pub fn peak(ch: &[Vec]) -> f32 { ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs())) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf8..6a6f5f7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -292,11 +292,15 @@ 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 bus = bounce_bus(e, p)?; - let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?; - 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}), - ) + let done = crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)?.run()?; + Ok(json!({ + "path": path, + "bytes": done.bytes, + "seconds": e.session().sample_rate.seconds(r.len()), + "peak_db": done.peak_db, + "lufs": done.lufs, + "true_peak_db": done.true_peak_db, + })) } /// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`). diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..56e3d2f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -237,61 +237,220 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { soundcraft_midi::write_smf(&smf).map_err(|err| EngineError::Io(err.to_string())) } -/// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS). -pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec, (f32, f32, f32))> { - bounce_bytes_with(e, r, opts, normalize, false) -} - -/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV -/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`. -pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> { - bounce_source(e, r, opts, normalize, fold_stereo, None) -} - -/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named. -pub fn bounce_source( - e: &Engine, - r: Range, - opts: &EncodeOptions, - normalize: bool, - fold_stereo: bool, +/// A bounce of the main mix, or of one bus, over a range, written to its file block by block so no +/// bounce has to fit in memory. The file appears at its path only once complete; a bounce that +/// fails leaves any older file there alone. +pub struct Bounce { + session: Arc, + path: PathBuf, + part: PathBuf, + range: Range, bus: Option, -) -> Result<(Vec, (f32, f32, f32))> { - let s = e.session(); - if r.len() > s.sample_rate.samples(4.0 * 3600.0) { - return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); - } - let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus); - if normalize { - soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); - } - let peak = soundcraft_dsp::offline::peak_db(&ch); - let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, ch.len()); - let frames = ch.first().map_or(0, Vec::len); - lm.process(&ch, frames); - let lufs = lm.integrated_lufs(); - let true_peak = lm.true_peak_db(); - 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()))?; - Ok((bytes, (peak, lufs, true_peak))) + fold: Option, + /// Measuring the peak to normalize to, the first of two passes. + measuring: bool, + render: soundcraft_mix::RangeRender, + block: Vec>, + gain: f32, + peak: f32, + meter: soundcraft_dsp::meter::LoudnessMeter, + enc: Option>, } -/// The main mix over `r` and its format (folded to stereo on request). -fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) { - render_bounce(s, r, fold_stereo, None) +/// A finished bounce: the size of its file and its levels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bounced { + pub bytes: u64, + pub peak_db: f32, + pub lufs: f32, + pub true_peak_db: f32, } -/// The main mix, or one bus, over `r`. -fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option) -> (Vec>, ChannelFormat) { - let (ch, fmt) = if let Some(id) = bus { - let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo); - (soundcraft_mix::render_bus(s, id, r, 1024), fmt) - } else { - let ch = soundcraft_mix::render_range(s, r, 1024); - let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; - (ch, fmt) +impl Bounce { + /// Starts a bounce of `s`'s main mix, or of `bus`, over `r` to `path`: every channel (SMPTE/WAV + /// order, with the WAV speaker mask of its format), or an ITU stereo fold-down when + /// `fold_stereo`, peak-normalized to -0.1 dBFS when `normalize` (which renders twice). + pub fn new( + s: Arc, + path: &Path, + r: Range, + opts: &EncodeOptions, + bus: Option, + normalize: bool, + fold_stereo: bool, + ) -> Result { + if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) { + return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); + } + let (fmt, channels, fold) = bounce_layout(&s, bus, fold_stereo); + let name = path.file_name().map_or_else(|| "bounce".into(), |n| n.to_string_lossy().into_owned()); + let part = path.with_file_name(format!("{name}.part")); + let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> { + let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?; + enc.check_room(u64::try_from(r.len()).unwrap_or(0))?; + Ok(enc) + })() + .map_err(|err| { + let _ = std::fs::remove_file(&part); + EngineError::Io(format!("{}: {err}", path.display())) + })?; + let render = soundcraft_mix::RangeRender::new(&s, bus, r, 1024); + let meter = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, channels); + Ok(Bounce { + session: s, + path: path.to_path_buf(), + part, + range: r, + bus, + fold, + measuring: normalize, + render, + block: Vec::new(), + gain: 1.0, + peak: 0.0, + meter, + enc: Some(enc), + }) + } + + /// Renders the whole bounce and completes its file. + pub fn run(mut self) -> Result { + loop { + match self.render.next_block(&self.session) { + Some((chunk, n)) => { + fill(&mut self.block, chunk, n, self.fold, self.gain); + self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block)); + if !self.measuring { + self.meter.process(&self.block, n); + if let Some(enc) = &mut self.enc { + enc.write(&self.block, n).map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?; + } + } + } + None if self.measuring => { + self.gain = if self.peak > 0.0 { soundcraft_dsp::db_to_gain(-0.1) / self.peak } else { 1.0 }; + self.peak = 0.0; + self.measuring = false; + self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024); + } + None => return self.finish(), + } + } + } + + fn finish(&mut self) -> Result { + let bytes = match self.enc.take() { + Some(enc) => enc.finish().map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?.complete(&self.part, &self.path)?, + None => 0, + }; + Ok(Bounced { + bytes, + peak_db: soundcraft_dsp::gain_to_db(self.peak), + lufs: self.meter.integrated_lufs(), + true_peak_db: self.meter.true_peak_db(), + }) + } +} + +impl Drop for Bounce { + /// Removes the part file unless the bounce completed (which renamed it). + fn drop(&mut self) { + drop(self.enc.take()); + let _ = std::fs::remove_file(&self.part); + } +} + +/// Where a bounce is written: its part file, or memory in the browser, which has no file system. +enum Out { + File(std::io::BufWriter), + Memory(std::io::Cursor>), +} + +impl Out { + fn create(part: &Path) -> std::io::Result { + if cfg!(target_arch = "wasm32") { + return Ok(Out::Memory(std::io::Cursor::new(Vec::new()))); + } + Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?))) + } + + /// Puts the finished file at `path` and returns its size: the part file is synced and renamed + /// over it; in the browser the bytes are delivered ([`write_file`]). + fn complete(self, part: &Path, path: &Path) -> Result { + match self { + Out::File(out) => { + let mut len = 0; + let synced = out.into_inner().map_err(std::io::IntoInnerError::into_error).and_then(|f| { + f.sync_all()?; + len = f.metadata()?.len(); + Ok(()) + }); + replace_with(part, path, synced).map(|()| len) + } + Out::Memory(m) => { + let bytes = m.into_inner(); + write_file(&path.to_string_lossy(), &bytes)?; + Ok(bytes.len() as u64) + } + } + } +} + +impl std::io::Write for Out { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Out::File(f) => f.write(buf), + Out::Memory(m) => { + // A long bounce may not fit in the browser's memory: fail instead of aborting. + let end = usize::try_from(m.position()).unwrap_or(usize::MAX).saturating_add(buf.len()); + let v = m.get_mut(); + v.try_reserve(end.saturating_sub(v.len())).map_err(|_| std::io::ErrorKind::OutOfMemory)?; + m.write(buf) + } + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Out::File(f) => f.flush(), + Out::Memory(_) => Ok(()), + } + } +} + +impl std::io::Seek for Out { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + match self { + Out::File(f) => f.seek(pos), + Out::Memory(m) => m.seek(pos), + } + } +} + +const MAX_BOUNCE_SECS: f64 = 4.0 * 3600.0; + +/// The format, channel count and fold-down source of a bounce of the main mix or `bus`: every +/// channel (a mono or stereo main mix as stereo), or an ITU stereo fold-down of a surround source +/// when `fold_stereo`. +fn bounce_layout(s: &Session, bus: Option, fold_stereo: bool) -> (ChannelFormat, usize, Option) { + let nch = soundcraft_mix::source_channels(s, bus); + let fmt = match bus { + Some(id) => s.bus(id).map_or(ChannelFormat::Stereo, |b| b.format), + None if nch <= 2 => ChannelFormat::Stereo, + None => s.main_format(), }; - if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) } + if fold_stereo && nch > 2 { (ChannelFormat::Stereo, 2, Some(fmt)) } else { (fmt, nch, None) } +} + +/// The first `n` frames of `chunk`, folded down from `fold` when set and scaled by `gain`. +fn fill(block: &mut Vec>, chunk: &[Vec], n: usize, fold: Option, gain: f32) { + let folded = fold.map(|from| soundcraft_mix::fold_down_stereo(from, chunk)); + let src = folded.as_deref().unwrap_or(chunk); + block.resize_with(src.len(), Vec::new); + for (d, c) in block.iter_mut().zip(src) { + d.clear(); + d.extend(c.iter().take(n).map(|v| v * gain)); + } } /// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included) @@ -303,6 +462,9 @@ 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(); + if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) { + return Err(EngineError::BadParams("file.bounce_stems".into(), "bounces are limited to 4 hours".into())); + } std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; let ext = match opts.format { FileFormat::Aiff => "aif", @@ -321,11 +483,8 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, for t in &mut solo.tracks { t.mixer.solo = t.id == id; } - let (ch, fmt) = render_main(&solo, r, fold_stereo); - 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())))?; + Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?; out.push(path.to_string_lossy().into_owned()); } Ok(out) @@ -608,7 +767,12 @@ 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<()> { - if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) { + replace_with(tmp, file, write_synced(tmp, bytes)) +} + +/// Renames `tmp` over `file` once it is `written`, or removes it when writing or renaming failed. +fn replace_with(tmp: &Path, file: &Path, written: std::io::Result<()>) -> Result<()> { + if let Err(err) = written.and_then(|()| std::fs::rename(tmp, file)) { let _ = std::fs::remove_file(tmp); return Err(EngineError::Io(format!("{}: {err}", file.display()))); } diff --git a/crates/engine/tests/bounce.rs b/crates/engine/tests/bounce.rs new file mode 100644 index 0000000..9f0b814 --- /dev/null +++ b/crates/engine/tests/bounce.rs @@ -0,0 +1,63 @@ +//! Bounces stream the main mix or a bus to their file, exactly as rendered in memory. + +use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; +use soundcraft_engine::io::{Bounce, bounce_stems}; +use soundcraft_time::Range; +use std::path::PathBuf; + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn bounces_write_exactly_the_mix_and_report_its_levels() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let dir = scratch("bounce-exact"); + let path = dir.join("Mix.wav"); + let r = Range::new(s.sample_rate.samples(10.0) + 7, s.sample_rate.samples(13.0)); + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let verb = s.bus_by_name("Verb").unwrap().id; + for bus in [None, Some(verb)] { + let mut mix = match bus { + Some(b) => soundcraft_mix::render_bus(s, b, r, 1024), + None => soundcraft_mix::render_range(s, r, 1024), + }; + assert!(mix.iter().flatten().any(|v| v.abs() > 1e-3), "{bus:?}"); + for normalize in [false, true] { + let done = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap().run().unwrap(); + if normalize { + soundcraft_dsp::offline::normalize(&mut mix, -0.1, false); + } + let bytes = std::fs::read(&path).unwrap(); + let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap(); + assert_eq!(file.channels, mix, "{bus:?} normalize: {normalize}"); + let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, mix.len()); + lm.process(&mix, mix[0].len()); + let levels = (soundcraft_dsp::offline::peak_db(&mix), lm.integrated_lufs(), lm.true_peak_db()); + assert_eq!((done.bytes, (done.peak_db, done.lufs, done.true_peak_db)), (bytes.len() as u64, levels)); + } + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let dir = scratch("bounce-too-big"); + let path = dir.join("Mix.aif"); + std::fs::write(&path, b"old").unwrap(); + let opts = EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let err = Bounce::new(e.session_arc(), &path, Range::new(0, s.sample_rate.samples(3.9 * 3600.0)), &opts, None, false, false).err().unwrap(); + assert!(err.to_string().contains("4 GiB"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let err = bounce_stems(&e, &dir.to_string_lossy(), Range::new(0, s.sample_rate.samples(4.1 * 3600.0)), &opts).unwrap_err(); + assert!(err.to_string().contains("4 hours"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..20d9f8a 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1734,88 +1734,103 @@ fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matri } } -/// Ceiling on one offline render (samples summed over channels): 2^29 f32 = 2 GiB, about 93 -/// minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory. +/// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about +/// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory; +/// [`RangeRender`] streams any length. pub const MAX_RENDER_SAMPLES: usize = 1 << 29; /// Offline render of the main mix over `range` (planar, [`main_channels`] channels in SMPTE/WAV /// order). The mix's delay-compensation latency is removed, so the result lines up with the /// timeline. pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { - let nch = main_channels(s); - let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1)); - // Probe the latency with one block, then render `len + latency` and drop the head. - let latency = { - let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = probe.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - probe.render(s, range.start, b.min(len.max(1)), &mut tmp); - probe.latency - }; - let total = len.saturating_add(latency); - let mut out = vec![vec![0.0f32; total]; nch]; - let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = eng.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - let mut done = 0usize; - while done < total { - let n = (total - done).min(b); - eng.render(s, range.start + done as i64, n, &mut tmp); - for (o, t) in out.iter_mut().zip(tmp.iter()) { - if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { - d.copy_from_slice(sr); - } - } - done += n; - } - if latency > 0 { - for c in &mut out { - c.drain(..latency.min(c.len())); - } - } - out + render_in_memory(s, None, range, block) } /// Offline render of one bus over `range` (planar, the bus format's channel count). /// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline. /// An unknown bus is silent. pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> { - let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0); - if nch == 0 { - return Vec::new(); - } - let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch); - let latency = { - let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = probe.max_block(); - probe.render(s, range.start, b.min(len.max(1)), &mut []); - probe.track_latency - }; - let total = len.saturating_add(latency); - let mut out = vec![vec![0.0f32; total]; nch]; - let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = eng.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - let mut done = 0usize; - while done < total { - let n = (total - done).min(b); - eng.render(s, range.start + done as i64, n, &mut []); - eng.copy_bus(bus, n, &mut tmp); - for (o, t) in out.iter_mut().zip(tmp.iter()) { - if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { - d.copy_from_slice(sr); - } - } - done += n; - } - if latency > 0 { - for c in &mut out { - c.drain(..latency.min(c.len())); + render_in_memory(s, Some(bus), range, block) +} + +fn render_in_memory(s: &Session, bus: Option, range: Range, block: usize) -> Vec> { + let nch = source_channels(s, bus); + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1)); + let mut out: Vec> = (0..nch).map(|_| Vec::with_capacity(len)).collect(); + let mut render = RangeRender::new(s, bus, Range::new(range.start, range.start + len as i64), block); + while let Some((chunk, n)) = render.next_block(s) { + for (o, c) in out.iter_mut().zip(chunk) { + o.extend_from_slice(c.get(..n).unwrap_or_default()); } } out } +/// Channels of the main mix, or of `bus` (none for an unknown bus). +pub fn source_channels(s: &Session, bus: Option) -> usize { + match bus { + Some(id) => s.bus(id).map_or(0, |b| b.format.channels()), + None => main_channels(s), + } +} + +/// [`render_range`] or [`render_bus`] of any length, a block at a time. +pub struct RangeRender { + eng: MixEngine, + bus: Option, + tmp: Vec>, + start: Samples, + done: usize, + total: usize, + latency: usize, +} + +impl RangeRender { + /// Renders the main mix over `range`, or `bus` when one is named. + pub fn new(s: &Session, bus: Option, range: Range, block: usize) -> Self { + let nch = source_channels(s, bus); + let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let tmp = vec![vec![0.0f32; eng.max_block()]; nch]; + let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) }; + RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 } + } + + fn render(&mut self, s: &Session, pos: Samples, n: usize) { + match self.bus { + Some(id) => { + self.eng.render(s, pos, n, &mut []); + self.eng.copy_bus(id, n, &mut self.tmp); + } + None => self.eng.render(s, pos, n, &mut self.tmp), + } + } + + /// The next stretch of the mix of `s` (the session given to [`RangeRender::new`]) as + /// `(channels, n)`: the first `n` samples of each channel. `None` once the range is done. + pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec], usize)> { + while self.done < self.total { + let n = (self.total - self.done).min(self.eng.max_block()); + self.render(s, self.start + self.done as i64, n); + if self.done == 0 { + // The first block tells the mix's latency: render that much more and drop the head. + self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency }; + self.total = self.total.saturating_add(self.latency); + } + let skip = self.latency.saturating_sub(self.done).min(n); + self.done += n; + if skip < n { + if skip > 0 { + for c in &mut self.tmp { + c.copy_within(skip..n, 0); + } + } + return Some((&self.tmp, n - skip)); + } + } + None + } +} + /// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped; /// Ambisonics play their W channel). Mono/stereo input is returned unchanged. pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { @@ -2334,6 +2349,37 @@ mod tests { assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0])); } + #[test] + fn offline_renders_line_up_with_the_timeline_in_any_block_size() { + let mut s = Session::default(); + let mut imp = vec![0.0f32; 48_000]; + imp[1000] = 1.0; + s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![imp] }))); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let bus = s.add_bus("Stem", ChannelFormat::Stereo); + let cid = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::audio(cid, "i", SourceId(900), 0, 0, 48_000)); + tr.mixer.inserts[0] = Some(Insert::new("maximizer")); + let mut send = soundcraft_model::SendSlot::new(Route::Bus(bus)); + send.level_db = 0.0; + tr.mixer.sends[0] = Some(send); + assert!(soundcraft_dsp::create("maximizer").unwrap().latency() > 16); + for source in [None, Some(bus)] { + for block in [16, 100, 512] { + let mut got = Vec::new(); + let mut render = RangeRender::new(&s, source, Range::new(900, 2900), block); + while let Some((chunk, n)) = render.next_block(&s) { + assert!(n <= block); + got.extend_from_slice(&chunk[0][..n]); + } + assert_eq!(got.len(), 2000, "{source:?} block {block}"); + let peak = got.iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|(i, _)| i); + assert_eq!(peak, Some(100), "{source:?} block {block}"); + } + } + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); diff --git a/docs/architecture.md b/docs/architecture.md index a041434..5bb0b21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate diagram brought up to date: video, vst3-host, au-host; device I/O limits) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (bounces stream to disk) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced. @@ -48,8 +48,9 @@ report. fades, clip effects) → trim → inserts → pre-fader sends → fader/mute (automation, VCA, trim automation) → post-fader sends → pan → busses → aux inputs → master faders. Independent strips process in parallel; plugin delay compensation aligns every path. The same engine renders -bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns -it on the audio thread and receives new session snapshots through a channel). +bounces offline (`RangeRender`, streamed to the file block by block) and realtime playback +(`soundcraft_playback::Player`, which owns it on the audio thread and receives new session +snapshots through a channel). Device I/O today is the system's *default* output and *default* input device, opened as two separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when From 52e19a871a357ee29dd8d65476f5705deb84b149 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:01:14 +0100 Subject: [PATCH 135/154] Keep the app responsive while bouncing Bounce Mix rendered the whole file inside one UI frame: the window stopped drawing and nothing could cancel the bounce until the file was written, which takes minutes for long or plugin-heavy sessions. - engine: io::Bounce renders a slice at a time (step) and reports its progress; dropping an unfinished one cancels it, removing the partial file and keeping any older one. cmd::bounce_job builds it from file.bounce_mix's params; the command and stems still run it to completion. - ui: the Bounce dialog starts the job and logic() renders it for 30 ms per call (32 blocks in the browser, where Instant panics), requesting another call each time, so it carries on while the window is hidden (eframe then runs logic alone). A modal shows the file, a progress bar and Cancel (or Esc); the status line reports the result, and the Task Manager lists the running bounce. - It stays on the UI thread because hosted plugins must be created there. --- crates/engine/src/cmd/file.rs | 25 +++++---- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/io.rs | 48 +++++++++++++++-- crates/engine/tests/bounce.rs | 26 ++++++++- crates/ui-egui/src/bounce.rs | 75 ++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 10 ++-- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/windows.rs | 14 +++-- crates/ui-egui/tests/bounce.rs | 97 ++++++++++++++++++++++++++++++++++ 9 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 crates/ui-egui/src/bounce.rs create mode 100644 crates/ui-egui/tests/bounce.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6a6f5f7..72a2480 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -275,6 +275,21 @@ fn fold_down_param(p: &Value, id: &str) -> Result { } fn bounce(e: &mut Engine, p: &Value) -> Result { + let job = bounce_job(e, p)?; + let (path, seconds) = (job.path().to_string_lossy().into_owned(), e.session().sample_rate.seconds(job.range().len())); + let done = job.run()?; + Ok(json!({ + "path": path, + "bytes": done.bytes, + "seconds": seconds, + "peak_db": done.peak_db, + "lufs": done.lufs, + "true_peak_db": done.true_peak_db, + })) +} + +/// The bounce `file.bounce_mix` runs for `p`, not rendered yet. +pub fn bounce_job(e: &Engine, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let r = range_param(e, "file.bounce_mix", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; @@ -292,15 +307,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 bus = bounce_bus(e, p)?; - let done = crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)?.run()?; - Ok(json!({ - "path": path, - "bytes": done.bytes, - "seconds": e.session().sample_rate.seconds(r.len()), - "peak_db": done.peak_db, - "lufs": done.lufs, - "true_peak_db": done.true_peak_db, - })) + crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold) } /// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..5137fc0 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 use file::bounce_job; + 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/io.rs b/crates/engine/src/io.rs index 56e3d2f..f11c0ce 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -238,8 +238,9 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { } /// A bounce of the main mix, or of one bus, over a range, written to its file block by block so no -/// bounce has to fit in memory. The file appears at its path only once complete; a bounce that -/// fails leaves any older file there alone. +/// bounce has to fit in memory, and rendered a slice at a time ([`Bounce::step`]) so a UI can show +/// progress. The file appears at its path only once complete; a bounce that fails, or is dropped +/// unfinished, leaves any older file there alone. pub struct Bounce { session: Arc, path: PathBuf, @@ -247,10 +248,12 @@ pub struct Bounce { range: Range, bus: Option, fold: Option, + normalize: bool, /// Measuring the peak to normalize to, the first of two passes. measuring: bool, render: soundcraft_mix::RangeRender, block: Vec>, + done: usize, gain: f32, peak: f32, meter: soundcraft_dsp::meter::LoudnessMeter, @@ -303,9 +306,11 @@ impl Bounce { range: r, bus, fold, + normalize, measuring: normalize, render, block: Vec::new(), + done: 0, gain: 1.0, peak: 0.0, meter, @@ -313,12 +318,32 @@ impl Bounce { }) } - /// Renders the whole bounce and completes its file. - pub fn run(mut self) -> Result { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn range(&self) -> Range { + self.range + } + + /// How far along the bounce is, from 0 to 1. + pub fn progress(&self) -> f32 { + let pass = (self.done as f64 / self.range.len().max(1) as f64).min(1.0) as f32; + match (self.normalize, self.measuring) { + (false, _) => pass, + (true, true) => pass / 2.0, + (true, false) => 0.5 + pass / 2.0, + } + } + + /// Renders blocks until `more` returns false (at least one per call). Returns the finished + /// bounce once its file is complete. + pub fn step(&mut self, mut more: impl FnMut() -> bool) -> Result> { loop { match self.render.next_block(&self.session) { Some((chunk, n)) => { fill(&mut self.block, chunk, n, self.fold, self.gain); + self.done += n; self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block)); if !self.measuring { self.meter.process(&self.block, n); @@ -330,10 +355,23 @@ impl Bounce { None if self.measuring => { self.gain = if self.peak > 0.0 { soundcraft_dsp::db_to_gain(-0.1) / self.peak } else { 1.0 }; self.peak = 0.0; + self.done = 0; self.measuring = false; self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024); } - None => return self.finish(), + None => return self.finish().map(Some), + } + if !more() { + return Ok(None); + } + } + } + + /// Renders the whole bounce (see [`Bounce::step`]). + pub fn run(mut self) -> Result { + loop { + if let Some(done) = self.step(|| true)? { + return Ok(done); } } } diff --git a/crates/engine/tests/bounce.rs b/crates/engine/tests/bounce.rs index 9f0b814..ea4cf12 100644 --- a/crates/engine/tests/bounce.rs +++ b/crates/engine/tests/bounce.rs @@ -28,7 +28,16 @@ fn bounces_write_exactly_the_mix_and_report_its_levels() { }; assert!(mix.iter().flatten().any(|v| v.abs() > 1e-3), "{bus:?}"); for normalize in [false, true] { - let done = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap().run().unwrap(); + let mut bounce = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap(); + let mut progress = vec![bounce.progress()]; + let done = loop { + if let Some(done) = bounce.step(|| false).unwrap() { + break done; + } + progress.push(bounce.progress()); + }; + let last = progress[progress.len() - 1]; + assert!(progress.len() > 100 && progress.is_sorted() && progress[0] < 0.01 && last > 0.99 && last <= 1.0, "{progress:?}"); if normalize { soundcraft_dsp::offline::normalize(&mut mix, -0.1, false); } @@ -61,3 +70,18 @@ fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() { assert!(err.to_string().contains("4 hours"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() { + let e = soundcraft_engine::demo::demo_engine(); + let dir = scratch("bounce-cancel"); + let path = dir.join("Mix.wav"); + std::fs::write(&path, b"old").unwrap(); + let mut bounce = Bounce::new(e.session_arc(), &path, Range::new(0, 48_000), &EncodeOptions::default(), None, false, false).unwrap(); + assert_eq!(bounce.step(|| false).unwrap(), None); + assert!(dir.join("Mix.wav.part").exists()); + drop(bounce); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs new file mode 100644 index 0000000..7049cdf --- /dev/null +++ b/crates/ui-egui/src/bounce.rs @@ -0,0 +1,75 @@ +//! Bounce Mix renders a slice per frame behind a progress window, so the app keeps drawing and the +//! bounce can be cancelled. + +use crate::SoundApp; +use crate::theme::{Tokens, bold}; +use serde_json::Value; + +/// Starts the bounce `file.bounce_mix` runs for `params`; [`step`] renders it. +pub fn start(app: &mut SoundApp, params: &Value) { + if app.bounce.is_some() { + app.ui.status = "A bounce is already running".into(); + return; + } + // Third-party plugins keep state the session can't see: store it first. + app.capture_plugin_states(); + match soundcraft_engine::cmd::bounce_job(&app.engine, params) { + Ok(job) => app.bounce = Some(job), + Err(e) => app.ui.status = e.to_string(), + } +} + +/// Renders the running bounce for a moment. Called from `logic`, which eframe keeps calling while +/// the window is hidden as long as a repaint is requested, so a bounce doesn't pause then. +pub fn step(app: &mut SoundApp, ctx: &egui::Context) { + let Some(job) = &mut app.bounce else { return }; + app.ui.status = match job.step(slice()) { + Ok(None) => { + ctx.request_repaint(); + return; + } + Ok(Some(done)) => { + format!("Bounced {} (peak {:.1} dBFS, {:.1} LUFS)", job.path().file_name().unwrap_or_default().to_string_lossy(), done.peak_db, done.lufs) + } + Err(e) => e.to_string(), + }; + app.bounce = None; +} + +/// Whether to render another block in this call: for 30 ms. +#[cfg(not(target_arch = "wasm32"))] +fn slice() -> impl FnMut() -> bool { + let until = std::time::Instant::now() + std::time::Duration::from_millis(30); + move || std::time::Instant::now() < until +} + +/// The browser has no `Instant` (it panics there), so a call renders 32 blocks. +#[cfg(target_arch = "wasm32")] +fn slice() -> impl FnMut() -> bool { + let mut blocks = 32; + move || { + blocks -= 1; + blocks > 0 + } +} + +/// Shows how far the running bounce got, with Cancel (or Esc). +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + let Some(job) = &app.bounce else { return }; + let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + egui::Modal::new(egui::Id::new("bounce_progress")).show(ctx, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + ui.set_width(320.0); + let name = job.path().file_name().unwrap_or_default().to_string_lossy(); + ui.label(egui::RichText::new(format!("Bouncing {name}")).font(bold(13.0))); + ui.add(egui::ProgressBar::new(job.progress())); + ui.horizontal(|ui| { + cancel |= ui.button("Cancel").clicked(); + ui.label(egui::RichText::new(format!("{:.0}%", job.progress() * 100.0)).color(Tokens::current().text_dim)); + }); + }); + if cancel { + app.bounce = None; + app.ui.status = "Bounce cancelled".into(); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..b8478bc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -250,6 +250,7 @@ fn default_dir() -> String { /// Show the open dialog, if any. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); + crate::bounce::show(app, ctx); let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; @@ -361,10 +362,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); - action = Some(( - "file.bounce_mix".into(), - json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), - )); + crate::bounce::start( + app, + &json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), + ); + keep = false; } } Dialog::RenameTrack { id, name } => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..67dbe7a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,6 +4,7 @@ //! executing commands. It can be replaced without touching anything below it. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod bounce; pub mod control; pub mod credits; pub mod dialogs; @@ -270,6 +271,8 @@ pub struct SoundApp { last_meter_poll: f64, /// Movies for the Video window and Video track (decoded in the background). pub video: video_track::VideoPool, + /// The Bounce Mix in progress, rendered a slice per frame. + pub bounce: Option, } /// Whether another automation-write pass is due: the transport moved at least @@ -326,6 +329,7 @@ impl SoundApp { last_frame: None, last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), + bounce: None, } } @@ -808,6 +812,7 @@ impl SoundApp { self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; control::drain(self, ctx); self.apply_editor_edits(); + bounce::step(self, ctx); if !ctx.input(|i| i.pointer.any_down()) { if matches!(self.gesture, Some(Gesture::Fader { .. })) { self.gesture = None; diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..f96d79d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -178,11 +178,17 @@ fn system_usage(app: &mut SoundApp, ctx: &egui::Context) { fn task_manager(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_task_manager; win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| { - ui.label("No background tasks are running."); + match &app.bounce { + Some(b) => { + ui.label(format!("Bouncing {} ({:.0}%)", b.path().display(), b.progress() * 100.0)); + ui.add(egui::ProgressBar::new(b.progress())); + } + None => { + ui.label("No background tasks are running."); + } + } ui.label( - egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.") - .small() - .color(Tokens::current().text_dim), + egui::RichText::new("Renders and AudioSuite processes run to completion before returning.").small().color(Tokens::current().text_dim), ); }); app.ui.show_task_manager = open; diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/bounce.rs new file mode 100644 index 0000000..32f8f69 --- /dev/null +++ b/crates/ui-egui/tests/bounce.rs @@ -0,0 +1,97 @@ +//! Bounce Mix renders across frames behind a progress window instead of freezing the app. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::path::{Path, PathBuf}; + +fn harness() -> Harness<'static, SoundApp> { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(1_000).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-ui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn bounce_from_dialog(h: &mut Harness<'_, SoundApp>, file: &Path) { + let app = h.state_mut(); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + if let Some(Dialog::Bounce { path, .. }) = &mut app.dialogs.open { + *path = file.to_string_lossy().into_owned(); + } + h.run_steps(2); + h.get_by_label("Bounce").click(); + h.run_steps(3); + assert!(h.state().bounce.is_some(), "{}", h.state().ui.status); + assert!(h.query_by_label("Bounce Mix").is_none()); +} + +#[test] +fn a_bounce_renders_across_frames_with_progress_until_its_file_is_written() { + let dir = scratch("bounce-frames"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Mix.wav")); + let frames = h.state().bounce.as_ref().map_or(0, |b| b.range().len()); + let mut progress = Vec::new(); + for _ in 0..4 { + progress.push(h.state().bounce.as_ref().map_or(1.0, |b| b.progress())); + h.get_by_label("Cancel"); + h.step(); + } + assert!(progress.is_sorted() && progress[0] < progress[3] && progress[3] < 1.0, "{progress:?}"); + h.run(); + assert!(h.state().bounce.is_none()); + assert!(h.state().ui.status.starts_with("Bounced Mix.wav (peak"), "{}", h.state().ui.status); + let (_, file) = soundcraft_audio_io::decode(&std::fs::read(dir.join("Mix.wav")).unwrap(), None).unwrap(); + assert_eq!(file.frames() as i64, frames); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_bounce_keeps_going_while_the_window_is_hidden() { + let dir = scratch("bounce-hidden"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Hidden.wav")); + let ctx = h.ctx.clone(); + let mut calls = 0; + while h.state().bounce.is_some() { + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx)); + assert!(h.state().bounce.is_none() || ctx.has_requested_repaint()); + calls += 1; + assert!(calls < 10_000); + } + assert!(calls > 3, "{calls}"); + assert!(h.state().ui.status.starts_with("Bounced Hidden.wav"), "{}", h.state().ui.status); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() { + let dir = scratch("bounce-cancel"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Clicked.wav")); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().bounce.is_none()); + assert_eq!(h.state().ui.status, "Bounce cancelled"); + bounce_from_dialog(&mut h, &dir.join("Escaped.wav")); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().bounce.is_none()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} From d19d44cd1bc4f90c7130007af1143d4873763e84 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:03:05 +0100 Subject: [PATCH 136/154] Cap bogus plugin latency so offline renders can't stall CLAP and VST3 plugins report their latency unchecked (up to u32::MAX samples), and an offline render skips that many samples before its first block, inside one call. A plugin reporting garbage made a bounce render about 25 hours of audio before producing anything, with the UI stuck in that call: no progress, no Cancel. Freeze and Commit stalled the same way. - A strip reports at most 2^20 samples of latency (about 22 s at 48 kHz), the most delay compensation could align anyway (MAX_LATENCY, shared with the delay lines). That holds for the latency an aux's inserts report before the aux is processed, too. - RangeRender::next_block renders one block per call, returning no frames while it skips the latency, so a bounce yields to the UI there too. --- crates/mix/src/lib.rs | 95 ++++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 20d9f8a..6cf9e94 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -214,6 +214,10 @@ struct Strip { outbuf: Vec>, } +/// Most latency delay compensation handles: 2^20 samples, about 22 s at 48 kHz. A strip reports +/// at most this, whatever its plugins claim, so a bogus value can't stall offline renders. +const MAX_LATENCY: usize = 1 << 20; + /// A multichannel delay line (samples), resized only when the delay changes. #[derive(Default)] struct DelayLine { @@ -224,7 +228,7 @@ struct DelayLine { impl DelayLine { fn set(&mut self, len: usize, ch: usize) { - let len = len.min(1 << 20); + let len = len.min(MAX_LATENCY); if len != self.len || self.bufs.len() != ch { self.len = len; self.idx = 0; @@ -933,7 +937,7 @@ fn process_strip( } } strip.gr = gr; - strip.latency = latency; + strip.latency = latency.min(MAX_LATENCY); let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5); let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); strip.muted = t.mixer.mute || auto_mute || soloed_out; @@ -1400,7 +1404,7 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { apply_params(slot, ins, t, i, pos); latency = latency.saturating_add(slot.plugin.latency()); } - strip.latency = latency; + strip.latency = latency.min(MAX_LATENCY); } fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { @@ -1805,29 +1809,28 @@ impl RangeRender { } } - /// The next stretch of the mix of `s` (the session given to [`RangeRender::new`]) as - /// `(channels, n)`: the first `n` samples of each channel. `None` once the range is done. + /// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`]) and + /// returns it as `(channels, n)`: the next `n` samples of the range lead each channel (none + /// while the mix's latency is still being skipped). `None` once the range is done. pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec], usize)> { - while self.done < self.total { - let n = (self.total - self.done).min(self.eng.max_block()); - self.render(s, self.start + self.done as i64, n); - if self.done == 0 { - // The first block tells the mix's latency: render that much more and drop the head. - self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency }; - self.total = self.total.saturating_add(self.latency); - } - let skip = self.latency.saturating_sub(self.done).min(n); - self.done += n; - if skip < n { - if skip > 0 { - for c in &mut self.tmp { - c.copy_within(skip..n, 0); - } - } - return Some((&self.tmp, n - skip)); + if self.done >= self.total { + return None; + } + let n = (self.total - self.done).min(self.eng.max_block()); + self.render(s, self.start + self.done as i64, n); + if self.done == 0 { + // The first block tells the mix's latency: render that much more and drop the head. + self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency }; + self.total = self.total.saturating_add(self.latency); + } + let skip = self.latency.saturating_sub(self.done).min(n); + self.done += n; + if skip > 0 && skip < n { + for c in &mut self.tmp { + c.copy_within(skip..n, 0); } } - None + Some((&self.tmp, n - skip)) } } @@ -2364,22 +2367,66 @@ mod tests { let mut send = soundcraft_model::SendSlot::new(Route::Bus(bus)); send.level_db = 0.0; tr.mixer.sends[0] = Some(send); - assert!(soundcraft_dsp::create("maximizer").unwrap().latency() > 16); + let latency = soundcraft_dsp::create("maximizer").unwrap().latency(); + assert!(latency > 16); for source in [None, Some(bus)] { for block in [16, 100, 512] { let mut got = Vec::new(); + let mut skipped = 0; let mut render = RangeRender::new(&s, source, Range::new(900, 2900), block); while let Some((chunk, n)) = render.next_block(&s) { assert!(n <= block); + skipped += usize::from(n == 0); got.extend_from_slice(&chunk[0][..n]); } assert_eq!(got.len(), 2000, "{source:?} block {block}"); + assert_eq!(skipped, latency / block, "{source:?} block {block}"); let peak = got.iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|(i, _)| i); assert_eq!(peak, Some(100), "{source:?} block {block}"); } } } + #[test] + fn a_plugins_bogus_latency_is_capped() { + struct Latent(Box); + impl Plugin for Latent { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.0.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.0.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.0.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.0.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.0.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.0.process(io, frames); + } + fn latency(&self) -> usize { + u32::MAX as usize + } + } + let (mut s, t) = session_with_dc(0.5, 48_000); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Mono, None); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + for track in [t, aux] { + s.track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.latent")); + let mut gain = soundcraft_dsp::create("gain").unwrap(); + gain.prepare(48_000.0, 512, 1); + eng.adopt(vec![PreparedInstance { track, slot: 0, id: "clap:test.latent".into(), ch: 1, plugin: Box::new(Latent(gain)) }]); + } + render_block(&mut eng, &s, 0); + assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY)); + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); From 8f8884ec05252ee3b6b7283f1e7c3d5a1afa3a8e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:34:40 +0100 Subject: [PATCH 137/154] Refuse to replace the session while bouncing A bounce keeps its own snapshot of the session, decoded audio included. The progress window blocks the mouse, but shortcuts, the macOS menu bar, dropped files and the control channel still reach SoundApp::run, so a second session could be opened beside the one bouncing; two large ones could exhaust memory, which aborts the app. New, Open, Close and Revert now fail with "Finish or cancel the bounce first" while a bounce runs, and so do sessions opened or dropped in the browser. --- apps/soundcraft-web/src/web.rs | 3 +++ crates/ui-egui/src/bounce.rs | 9 +++++++++ crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/bounce.rs | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 02f1e13..8891074 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -150,6 +150,9 @@ impl WebShell { } fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) { + if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) { + return; + } let e = &mut self.app.engine; let at = e.session().edit.selection.start.max(0); let result = match purpose { diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs index 7049cdf..4368b4e 100644 --- a/crates/ui-egui/src/bounce.rs +++ b/crates/ui-egui/src/bounce.rs @@ -36,6 +36,15 @@ pub fn step(app: &mut SoundApp, ctx: &egui::Context) { app.bounce = None; } +/// Refuses to replace the session while a bounce holds its own copy: two large sessions could run +/// out of memory. True when refused; the status line says why. +pub fn refuses_session_switch(app: &mut SoundApp) -> bool { + if app.bounce.is_some() { + app.ui.status = "Finish or cancel the bounce first".into(); + } + app.bounce.is_some() +} + /// Whether to render another block in this call: for 30 ms. #[cfg(not(target_arch = "wasm32"))] fn slice() -> impl FnMut() -> bool { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 67dbe7a..35e0a10 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -340,6 +340,9 @@ impl SoundApp { /// Run a command; errors go to the status line. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Result { + if matches!(id, "session.new" | "session.open" | "session.close" | "session.revert") && bounce::refuses_session_switch(self) { + return Err(self.ui.status.clone()); + } if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/bounce.rs index 32f8f69..9745ae4 100644 --- a/crates/ui-egui/tests/bounce.rs +++ b/crates/ui-egui/tests/bounce.rs @@ -1,6 +1,7 @@ //! Bounce Mix renders across frames behind a progress window instead of freezing the app. use egui::vec2; use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; use soundcraft_ui_egui::dialogs::Dialog; use soundcraft_ui_egui::{Services, SoundApp}; use std::path::{Path, PathBuf}; @@ -79,6 +80,24 @@ fn a_bounce_keeps_going_while_the_window_is_hidden() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn the_session_cannot_be_replaced_while_it_bounces() { + let dir = scratch("bounce-replace"); + let mut h = harness(); + let tracks = h.state().engine.session().tracks.len(); + bounce_from_dialog(&mut h, &dir.join("Mix.wav")); + for id in ["session.new", "session.open", "session.close", "session.revert"] { + let err = h.state_mut().run(id, json!({"path": dir.join("Other.scraft").to_string_lossy()})).unwrap_err(); + assert!(err.contains("bounce"), "{id}: {err}"); + } + assert_eq!(h.state().engine.session().tracks.len(), tracks); + assert!(h.state().bounce.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state_mut().run("session.close", json!({})).is_ok()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() { let dir = scratch("bounce-cancel"); 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 138/154] 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 139/154] 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 140/154] 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 141/154] 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 142/154] 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 143/154] 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 dac86131eeb25dec9a8e0118eba9c800dd2105c6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:28:36 -0400 Subject: [PATCH 144/154] Recovery repairs only take files in the folders recordings write to recording.json names the take files whose headers recovery rewrites. Before writing, check that each is a regular file (not a symlink) directly in the Audio Files folder a recording of that session writes into (next to the session file, or in its folder under the autosave root) and that it starts with the float WAV header the recorder writes. Anything else is left untouched, logged and listed with the media recovery couldn't load. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/autosave.rs | 130 +++++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 9af2924..d42e81f 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -291,9 +291,9 @@ pub fn leftovers(root: &Path) -> Vec { /// media that couldn't be loaded. pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { let mut r = Engine::default(); - let missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; + let mut missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; let mut s = r.session().clone(); - let unfinished = unfinished_takes(&left.dir, &s); + let unfinished = unfinished_takes(left, &s, &mut missing); if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) { for src in s.sources.iter_mut().filter(|src| !src.unsaved) { if let Ok(rel) = Path::new(&src.path).strip_prefix(home) { @@ -310,32 +310,83 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { Ok(missing) } -/// The takes of the recording `dir` holds the plan of, repaired, minus those already in `s`. -fn unfinished_takes(dir: &Path, s: &Session) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> { - let plan: Recording = serde_json::from_str(&std::fs::read_to_string(dir.join(RECORDING)).ok()?).ok()?; +/// The takes of the recording `left` holds the plan of, repaired, minus those already in `s`. +/// A take the plan names that isn't a take file SoundCraft recorded, in a folder it records into, +/// is left untouched and added to `skipped`. +fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> { + let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?; let known: Vec = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect(); - let takes = plan - .takes - .iter() - .filter(|t| std::fs::canonicalize(&t.path).is_ok_and(|p| !known.contains(&p))) - .filter(|t| repair_take(&t.path, t.channels, plan.rate).is_ok_and(|frames| frames > 0)) - .map(|t| (t.track, t.path.clone())) - .collect(); + let folders = take_folders(left); + let mut takes = Vec::new(); + for t in &plan.takes { + if std::fs::canonicalize(&t.path).is_ok_and(|p| known.contains(&p)) { + continue; + } + match repair_take(&t.path, &folders, t.channels, plan.rate) { + Ok(frames) if frames > 0 => takes.push((t.track, t.path.clone())), + Ok(_) => {} + Err(err) => { + log::warn!("recovery skipped the take {}: {err}", t.path.display()); + skipped.push(format!("{}: {err}", t.path.display())); + } + } + } Some((plan, takes)) } +/// The folders a recording in `left`'s session writes its takes into: the `Audio Files` next to +/// the session file, or, never saved, the one in its folder under the autosave root. +fn take_folders(left: &Leftover) -> Vec { + let saved = left.origin.as_deref().and_then(|o| Path::new(o).parent()).map(|d| d.join("Audio Files")); + let unsaved = left.dir.parent().map(|root| root.join(sanitize_name(&left.name)).join("Audio Files")); + saved.into_iter().chain(unsaved).filter_map(|d| std::fs::canonicalize(d).ok()).collect() +} + /// Make a take file's header cover all the audio in it; returns its frames. The recorder -/// rewrites the header only every second, so a crash leaves up to a second uncovered. -fn repair_take(path: &Path, channels: usize, rate: u32) -> std::io::Result { +/// rewrites the header only every second, so a crash leaves up to a second uncovered. Only a +/// regular file in one of `folders` that starts with the header the recorder writes is touched. +fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) -> std::io::Result { + let refuse = |why: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, why.to_string()); + if !std::fs::symlink_metadata(path)?.file_type().is_file() { + return Err(refuse("not a regular file")); + } + let folder = path.parent().and_then(|d| std::fs::canonicalize(d).ok()); + if !folder.is_some_and(|d| folders.contains(&d)) { + return Err(refuse("not in a folder SoundCraft records into")); + } let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other); - let audio = std::fs::metadata(path)?.len().saturating_sub(header(0)?.len() as u64); + let expected = header(0)?; + let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?; + let mut head = vec![0u8; expected.len()]; + if std::io::Read::read_exact(&mut f, &mut head).is_err() || !is_take_header(&head, &expected) { + return Err(refuse("not a take SoundCraft recorded")); + } + let audio = f.metadata()?.len().saturating_sub(expected.len() as u64); let frames = audio / (channels.max(1) as u64 * 4); - let mut f = std::fs::OpenOptions::new().write(true).open(path)?; + std::io::Seek::rewind(&mut f)?; f.write_all(&header(frames)?)?; f.sync_all()?; Ok(frames) } +/// `head` is a take header like `expected` (the recorder's header for no frames yet): RIFF or +/// RF64 WAVE, the same format chunk at the same place, and the data chunk right after it all. +/// Only the sizes and frame counts may differ. +fn is_take_header(head: &[u8], expected: &[u8]) -> bool { + let magic = matches!(head.get(..4), Some(b"RIFF" | b"RF64")) && head.get(8..12) == Some(b"WAVE"); + let fmt = expected.windows(4).position(|w| w == b"fmt "); + let fmt_len = fmt.and_then(|at| expected.get(at + 4..at + 8)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let same_fmt = match (fmt, fmt_len) { + (Some(at), Some(n)) => { + let end = at.saturating_add(8).saturating_add(n as usize); + expected.get(at..end).is_some_and(|chunk| head.get(at..end) == Some(chunk)) + } + _ => false, + }; + let data = expected.len().checked_sub(8).and_then(|at| head.get(at..at + 4)) == Some(b"data"); + magic && same_fmt && data +} + /// Delete a leftover autosave. pub fn discard(left: &Leftover) { let _ = std::fs::remove_dir_all(&left.dir); @@ -464,7 +515,7 @@ mod tests { let mut e = Engine::default(); let track = soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); - let take = dir.join("Audio Files/Vox_01.wav"); + let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav"); std::fs::create_dir_all(take.parent().unwrap()).unwrap(); let audio: Vec = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect(); std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap(); @@ -488,6 +539,51 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn recovery_never_writes_a_file_that_isnt_a_take_in_a_take_folder() { + use crate::io::Take; + let dir = folder("autosave-hostile-take"); + let root = dir.join("Autosave"); + let mut e = Engine::default(); + let track = + soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); + let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files"); + std::fs::create_dir_all(&takes).unwrap(); + let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat(); + let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4); + let files = + [(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())]; + for (path, bytes) in &files { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + let mut paths: Vec = files.iter().map(|(p, _)| p.clone()).collect(); + #[cfg(unix)] + { + let link = takes.join("Vox_03.wav"); + std::os::unix::fs::symlink(&files[1].0, &link).unwrap(); + paths.push(link); + } + let plan = Recording { + start: 0, + pass: None, + punch: None, + rate: 48_000, + takes: paths.iter().map(|p| Take { track, path: p.clone(), channels: 1 }).collect(), + }; + let w = Autosaver::new(root.clone()); + w.write(e.session_arc(), None, Some(plan)); + drop(w); + let mut r = Engine::default(); + let skipped = recover(&mut r, &leftovers(&root)[0]).unwrap(); + assert_eq!(skipped.len(), paths.len(), "{skipped:?}"); + assert!(r.session().sources.is_empty()); + for (path, bytes) in &files { + assert_eq!(&std::fs::read(path).unwrap(), bytes, "{} untouched", path.display()); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn unsaved_work_is_offered_once_its_session_closes() { let dir = folder("autosave-leftovers"); From 8222048c10c049d1d869ac64d5fe1bbc542a9cf6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:30:30 -0400 Subject: [PATCH 145/154] 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 285d9fecd52595f44b96665660bdadb8315e6829 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:31:47 -0400 Subject: [PATCH 146/154] Bounce Mix: a session path without a folder starts in the default one A relative session path has an empty parent, which made the default "/ Bounce.wav". Ignore an empty parent so it falls back to the default folder, as for a session never saved. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 7 ++++++- crates/ui-egui/tests/bounce_dialog.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 38eaa21..67f23d4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -85,7 +85,12 @@ impl Dialogs { } "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, "file.bounce_mix" => { - let dir = e.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map_or(home.clone(), |d| d.display().to_string()); + let dir = e + .path + .as_deref() + .and_then(|p| std::path::Path::new(p).parent()) + .filter(|d| !d.as_os_str().is_empty()) + .map_or(home.clone(), |d| d.display().to_string()); Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } } "file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() }, diff --git a/crates/ui-egui/tests/bounce_dialog.rs b/crates/ui-egui/tests/bounce_dialog.rs index 04ad4d6..e3a5499 100644 --- a/crates/ui-egui/tests/bounce_dialog.rs +++ b/crates/ui-egui/tests/bounce_dialog.rs @@ -34,3 +34,21 @@ fn bounce_starts_in_the_session_folder_and_browse_picks_the_file_and_format() { other => panic!("{other:?}"), } } + +#[test] +fn bounce_of_a_session_with_a_relative_path_starts_in_the_default_folder() { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.path = Some("Midnight Groove.scraft".into()); + let name = engine.session().name.clone(); + let mut app = SoundApp::new(engine, None, Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + let mut fresh = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + assert!(fresh.dialogs.open_for_command(&fresh.engine, "file.bounce_mix")); + match (&app.dialogs.open, &fresh.dialogs.open) { + (Some(Dialog::Bounce { path, .. }), Some(Dialog::Bounce { path: unsaved, .. })) => { + assert_ne!(path.as_str(), format!("/{name} Bounce.wav")); + assert_eq!(path, unsaved, "the same folder as a session never saved"); + } + other => panic!("{other:?}"), + } +} From 41c8561d5119f08ebbe0c045d82dac096868c972 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:33:35 -0400 Subject: [PATCH 147/154] Signal Generator refuses a selection longer than one render It cut the new clip at MAX_RENDER_SAMPLES / channels without a word, after allocating up to 2 GiB for it. Refuse such a selection up front, as #326 does for other renders, before anything is generated. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/audiosuite.rs | 9 +++++++-- crates/engine/tests/long_renders.rs | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 3fff024..585f2be 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -136,11 +136,16 @@ fn process(e: &mut Engine, p: &Value) -> Result { /// whatever was there, like Pro Tools' Signal Generator. fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result { let sr = e.session().sample_rate; + let tracks = tracks_param(e, "audiosuite.process", p)?; + // Refuse before generating anything, rather than cut the clip short. + for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) { + crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?; + } let mut clips = Vec::new(); - for track in tracks_param(e, "audiosuite.process", p)? { + for track in tracks { let Some(t) = e.session().track(track).filter(|t| t.kind == TrackKind::Audio) else { continue }; let channels = t.channels().max(1); - let frames = usize::try_from(r.len()).unwrap_or(0).min(soundcraft_mix::MAX_RENDER_SAMPLES / channels); + let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels)); let clip_name = format!("{}-{}", t.name, short(name)); let mut audio = vec![vec![0.0; frames]; channels]; apply(name, params, &mut audio, sr.as_f64() as f32)?; diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/long_renders.rs index c2c9fce..5e00f65 100644 --- a/crates/engine/tests/long_renders.rs +++ b/crates/engine/tests/long_renders.rs @@ -47,3 +47,11 @@ fn audiosuite_refuses_results_longer_than_one_render() { refused(&mut e, "audiosuite.process", &json!({"process": process, "params": params, "clips": [id.0]})); } } + +#[test] +fn signal_generator_refuses_a_selection_longer_than_one_render() { + let (mut e, _) = long_clip(48_000); + let end = (MAX_RENDER_SAMPLES / 2 + 1) as i64; + e.execute("edit.select", &json!({"tracks": ["Talk"], "start": 0, "end": end})).unwrap(); + refused(&mut e, "audiosuite.process", &json!({"process": "signal_generator"})); +} From 74b3278a19625e15ca986aecde6006355b76d365 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:37:04 -0400 Subject: [PATCH 148/154] control server: cap request lines at about 86 MiB, not 256 MiB 256 MiB per line times 16 connections let a peer make the app buffer about 4 GiB. 16 MiB would break a legitimate request: mix.insert_state carries a plugin's state as base64 inline, and the engine accepts states of up to b64::MAX_DECODED_BYTES (64 MiB), about 85.3 MiB of base64. Nothing else sent to the app is that large (screenshots are replies, which this doesn't bound). So the cap is that base64 length plus 1 MiB for the JSON around it, derived from the same constant so the two stay in step; a compile-time check keeps it under 96 MiB, about 1.5 GiB across all 16 connections. Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft/src/control_server.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs index 46061a4..abff7bb 100644 --- a/apps/soundcraft/src/control_server.rs +++ b/apps/soundcraft/src/control_server.rs @@ -13,9 +13,13 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; -/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry -/// large payloads (base64 data) still fit. -const MAX_LINE: usize = 256 * 1024 * 1024; +/// Longest accepted request line (bytes, without the newline). The largest payload a request +/// carries is a plugin state for `mix.insert_state`: base64 of at most `b64::MAX_DECODED_BYTES` +/// (64 MiB, so about 85 MiB of text). Room for that and 1 MiB of JSON around it; replies +/// (screenshots) aren't bounded by this. +const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4 + (1 << 20); +// With MAX_CONNECTIONS, at most 1.5 GiB of request lines are buffered at once. +const _: () = assert!(MAX_LINE <= 96 << 20); /// Connections served at once; further ones get an error line and are closed. const MAX_CONNECTIONS: usize = 16; @@ -160,6 +164,13 @@ mod tests { assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread"); } + #[test] + fn the_largest_plugin_state_fits_in_a_line() { + let state = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4; + let request = json!({"id": 1, "method": "engine.execute", "params": {"command": "mix.insert_state", "params": {"track": "Audio 1", "slot": 0, "state": ""}}}); + assert!(state + request.to_string().len() <= MAX_LINE); + } + /// The control server on a loopback port, with a stand-in UI that echoes each method. fn serve_echo() -> std::net::SocketAddr { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); From 391a0aa66c50764dba6acbe3c0496408d312587c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:49:31 -0400 Subject: [PATCH 149/154] 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", From 3cc0dc77cbbc915502465c18c033fd6b2282f1bf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:56:26 -0400 Subject: [PATCH 150/154] Fix build after merging main into #368 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_access.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index be4ea9d..b0f02fd 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -443,8 +443,9 @@ mod tests { /// 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. + /// check of their own. Plug-in scans live in the host crates. The one engine module that reads + /// and writes a fixed folder is `autosave.rs`: crash-recovery copies in the app's own data + /// folder, which no command names, so it is skipped too. #[test] fn commands_do_not_touch_the_file_system_themselves() { fn walk(dir: &Path, files: &mut Vec) { @@ -464,8 +465,9 @@ mod tests { 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") { + // `io` and this module are the checked gateway; `autosave` writes the app's own + // recovery folder; `tests*.rs` are test-only modules. + if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "autosave.rs") || name.starts_with("tests") { continue; } let text = std::fs::read_to_string(&path).unwrap(); From bb3a6bdd9aef57ba8816285bba7ccaa194b70c7a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:17:58 -0400 Subject: [PATCH 151/154] Fix build after merging main into #365 Co-Authored-By: Claude Opus 5.5 --- crates/time/src/format.rs | 2 +- crates/ui-egui/src/spot.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index 29f30a0..bdd2fcb 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -150,7 +150,7 @@ mod tests { ("100000000000000:00:00:00", TimeFormat::Timecode), ("-9223372036854775808", TimeFormat::BarsBeats), ] { - let _ = parse_position(text, fmt, sr, &map, rate, 0); + let _ = parse_position(text, fmt, sr, &map, rate, 0, 0); } } diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs index 77c4c37..c9b4102 100644 --- a/crates/ui-egui/src/spot.rs +++ b/crates/ui-egui/src/spot.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use soundcraft_engine::cmd::MAX_POSITION; use soundcraft_engine::edit::first_movable; use soundcraft_model::{ClipId, Session, SourceId, TrackId}; -use soundcraft_time::{Samples, TimeFormat, format_length, format_position, parse_position}; +use soundcraft_time::{Samples, TimeFormat, format_length}; #[derive(Debug, Clone)] pub enum Target { @@ -115,7 +115,7 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples { /// The dialog's fields; the caller adds OK and Cancel. pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { - let show = |at: Samples, f: TimeFormat| format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let show = |at: Samples, f: TimeFormat| s.format_position(at, f); egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label("Time Scale"); egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { @@ -136,7 +136,7 @@ pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { } let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)); if r.changed() - && let Ok(v) = parse_position(&d.text[i], d.format, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) + && let Ok(v) = s.parse_position(&d.text[i], d.format) && (-MAX_POSITION..=MAX_POSITION).contains(&v) { d.set(i, v); From e927e295bb034cea174ad9f9516fe35b4eaedec5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:24:58 -0400 Subject: [PATCH 152/154] Fix build after merging main into #360 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 4 ++-- crates/ui-egui/src/mix_window.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 08ede70..7d1813f 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -805,8 +805,8 @@ 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()); } - draw_live_take(app, &painter, &s, &track, tl, lane); - crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane); + draw_live_take(app, &painter, &s, track, tl, lane); + crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); if s.edit.grid_lines { grid_lines(&painter, &s, tl, lane); } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 92261ea..5622f74 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -256,7 +256,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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 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_name(app, &track), t.text); + 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); From ca1a6d443e9ff2d1dbca362d2a9b4689bf39c59c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:31:48 -0400 Subject: [PATCH 153/154] Fix build after merging main into #306 Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 18 ++++++++++++------ crates/playback/src/lib.rs | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 12784e0..ee40bda 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -2414,6 +2414,7 @@ mod tests { /// A built-in gain behind a third-party id, with a log of parameter sets. struct Stateful { inner: Box, + blob: Vec, sets: Arc, } @@ -2437,6 +2438,10 @@ mod tests { fn process(&mut self, io: &mut [Vec], frames: usize) { self.inner.process(io, frames); } + fn state_reader(&mut self) -> Option> { + let blob = self.blob.clone(); + Some(Box::new(move || Some(blob.clone()))) + } } struct Clock { @@ -2614,9 +2619,9 @@ mod tests { let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let mut gain = soundcraft_dsp::create("gain").unwrap(); gain.prepare(48_000.0, 512, 1); - let fake = Stateful { inner: gain, sets: Arc::clone(&sets) }; + let fake = Stateful { inner: gain, blob: Vec::new(), sets: Arc::clone(&sets) }; // An instance for the wrong channel count is retired, not used. - let wrong = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) }; + let wrong = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), blob: Vec::new(), sets: Arc::clone(&sets) }; eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 2, plugin: Box::new(wrong) }]); assert!(render_block(&mut eng, &s, 512) > 0.3); assert_eq!(eng.take_retired().len(), 1); @@ -2724,11 +2729,12 @@ mod tests { }; 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())]); + assert!(eng.take_retired().is_empty()); 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"); + let mut retired = eng.take_retired(); + assert_eq!(retired.len(), 1, "the old instance is retired"); + assert_eq!(retired[0].save_state(), Some(b"old".to_vec()), "the new instance stays"); } #[test] @@ -2742,7 +2748,7 @@ mod tests { 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 fake = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) }; + let fake = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), blob: Vec::new(), sets: Arc::clone(&sets) }; eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "vst3:00000000000000000000000000000001".into(), ch: 1, plugin: Box::new(fake) }]); render_block(&mut eng, &s, 0); assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 1, "session params apply once"); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 73d8394..f52d3c9 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -1118,7 +1118,7 @@ mod tests { 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 }; + let mut side = Side::new(48_000.0, grx); side.return_old_sessions(&tx); let mut out = vec![0.0f32; 256]; let mut watched = Vec::new(); From 6409a8cbbd8d1cf6e99739417bfa42a931ace948 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:58:28 -0400 Subject: [PATCH 154/154] Fix build after merging main into #335 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_window.rs | 3 ++- crates/ui-egui/src/theme.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f425066..65e31ca 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -3042,7 +3042,8 @@ mod tests { .shapes .iter() .filter_map(|shape| match &shape.shape { - Shape::Mesh(mesh) if !mesh.vertices.is_empty() && mesh.vertices.iter().all(|vertex| clip_rect.contains(vertex.pos)) => { + // The waveform's columns snap to physical pixels and fade out over half of one. + Shape::Mesh(mesh) if !mesh.vertices.is_empty() && mesh.vertices.iter().all(|vertex| clip_rect.expand(1.0).contains(vertex.pos)) => { Some(mesh.vertices.iter().map(|vertex| vertex.pos).collect()) } _ => None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e88e4fa..0cf5b9b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -502,7 +502,8 @@ mod tests { 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"); + // Other tests compete for the lock too, and some hold it for seconds: allow for a queue. + assert!(rx.recv_timeout(std::time::Duration::from_secs(120)).is_ok(), "the palette lock must be released when every holder drops it"); } #[test]