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 001/101] Consolidate covers the whole edit selection Consolidate clamped its range to the track's last clip end plus one second, so on an empty track a 0-5 s selection made a 1 s clip and a 2-7 s selection made nothing. It now renders, clears and places exactly the selection, and refuses a selection too long to render instead of placing a shorter clip. Prepare DPE keeps the old per-track bound. Fixes #164 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/setup_more.rs | 4 +- crates/engine/src/io.rs | 23 ++-- crates/engine/tests/consolidate_selection.rs | 124 +++++++++++++++++++ 4 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/consolidate_selection.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 776fe02..d904ea2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -759,7 +759,7 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { } let mut n = 0; for t in tracks { - if crate::io::consolidate(e, t, r)? { + if crate::io::consolidate(e, "edit.consolidate", t, r)? { n += 1; } } diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 7a936af..4e952a9 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result { let r = soundcraft_time::Range::new(0, end); let mut n = 0; for t in tracks { - if crate::io::consolidate(e, t, r)? { + // Each track keeps its own length (its material plus a second), as before. + let tr = crate::io::bounded_to_track(e.session(), t, r); + if crate::io::consolidate(e, id, t, tr)? { n += 1; } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..0076e28 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -387,18 +387,27 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S keep.len() } -/// Consolidate the selection on a track into one new clip backed by a rendered source. -pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { +/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the +/// whole range, silence included; a range too long to render is refused rather than shortened. +pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result { let s = e.session(); - if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) { + let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) }; + let start = r.start.max(0); + let len = r.end.saturating_sub(start); + if len <= 0 { return Ok(false); } - let r = bounded_to_track(s, t, r); - if r.is_empty() { - return Ok(false); + let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1); + if usize::try_from(len).map_or(true, |n| n > max) { + let secs = max as f64 / s.sample_rate.as_f64(); + return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name))); } + let r = Range::new(start, start + len); let audio = soundcraft_mix::render_clips(s, t, r); - let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str())); + if audio.iter().any(|ch| ch.len() as i64 != len) { + return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into())); + } + let name = format!("{}_consolidated", tr.name); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; let s = e.session_mut(); let src = add_source(s, &name, buf, None, FileFormat::Wav); diff --git a/crates/engine/tests/consolidate_selection.rs b/crates/engine/tests/consolidate_selection.rs new file mode 100644 index 0000000..39af2f7 --- /dev/null +++ b/crates/engine/tests/consolidate_selection.rs @@ -0,0 +1,124 @@ +//! Consolidate makes one clip per track that covers exactly the edit selection (#164). + +use serde_json::json; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_engine::{Engine, EngineError}; +use soundcraft_model::{Clip, TrackId}; +use soundcraft_time::Range; + +fn stereo_tracks(n: u64) -> Engine { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": n, "format": "stereo"})).unwrap(); + e.session_mut().edit.selected_tracks = e.session().tracks.iter().map(|t| t.id).collect(); + e +} + +fn sr(e: &Engine) -> i64 { + e.session().sample_rate.samples(1.0) +} + +/// Put a constant-level clip of `secs` seconds at the start of track `i`. +fn add_clip(e: &mut Engine, i: usize, secs: i64) -> TrackId { + let frames = usize::try_from(secs * sr(e)).unwrap(); + let t = e.session().tracks[i].id; + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; frames]; 2] }; + let src = soundcraft_engine::io::add_source(e.session_mut(), "tone", buf, None, FileFormat::Wav); + let id = e.session_mut().new_clip_id(); + soundcraft_engine::edit::place_clip(e.session_mut(), t, Clip::audio(id, "tone", src, 0, 0, frames as i64)); + t +} + +fn clips(e: &Engine, i: usize) -> Vec<(i64, i64)> { + e.session().tracks[i].clips().iter().map(|c| (c.start, c.end())).collect() +} + +/// Frames in the source behind the only clip on track `i`, and its first sample. +fn source_of_only_clip(e: &Engine, i: usize) -> (usize, f32) { + let s = e.session(); + let c = &s.tracks[i].clips()[0]; + let audio = s.pool.get(c.source().unwrap()).unwrap(); + (audio.buffer.frames(), audio.buffer.channels[0][0]) +} + +fn assert_each_track_has(e: &Engine, start: i64, end: i64) { + for (i, t) in e.session().tracks.iter().enumerate() { + assert_eq!(clips(e, i), vec![(start, end)], "track {}", t.name); + assert_eq!(source_of_only_clip(e, i).0 as i64, end - start, "track {}", t.name); + } +} + +#[test] +fn consolidating_silence_from_zero_covers_the_whole_selection() { + let mut e = stereo_tracks(5); + let sr = sr(&e); + e.session_mut().edit.selection = Range::new(0, 5 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 0, 5 * sr); +} + +#[test] +fn consolidating_silence_later_in_the_timeline_covers_the_whole_selection() { + let mut e = stereo_tracks(5); + let sr = sr(&e); + e.session_mut().edit.selection = Range::new(2 * sr, 7 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 2 * sr, 7 * sr); +} + +#[test] +fn consolidating_past_the_end_of_a_clip_keeps_the_trailing_silence() { + let mut e = stereo_tracks(1); + let sr = sr(&e); + add_clip(&mut e, 0, 1); + e.session_mut().edit.selection = Range::new(0, 5 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 0, 5 * sr); + assert_eq!(source_of_only_clip(&e, 0).1, 0.25); +} + +#[test] +fn multi_track_consolidate_is_one_undo_step_and_fills_empty_tracks() { + let mut e = stereo_tracks(3); + let sr = sr(&e); + add_clip(&mut e, 1, 2); + let before = e.session().clone(); + let undo_depth = e.undo_history().len(); + e.session_mut().edit.selection = Range::new(sr, 4 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + // The clip on track 2 is split at the selection start; the head stays put. + assert_eq!(clips(&e, 0), vec![(sr, 4 * sr)]); + assert_eq!(clips(&e, 1), vec![(0, sr), (sr, 4 * sr)]); + assert_eq!(clips(&e, 2), vec![(sr, 4 * sr)]); + assert_eq!(e.undo_history().len(), undo_depth + 1); + assert!(e.undo()); + for (a, b) in e.session().tracks.iter().zip(before.tracks.iter()) { + assert_eq!(a.clips(), b.clips(), "track {}", a.name); + } +} + +#[test] +fn a_selection_too_long_to_render_is_refused_not_shortened() { + let mut e = stereo_tracks(2); + add_clip(&mut e, 0, 1); + let before: Vec<_> = (0..2).map(|i| clips(&e, i)).collect(); + // A stereo track renders at most MAX_RENDER_SAMPLES / 2 frames (about 93 minutes at 48 kHz). + let max = i64::try_from(soundcraft_mix::MAX_RENDER_SAMPLES / 2).unwrap(); + let err = e.execute("edit.consolidate", &json!({"start": 0, "end": max + 1})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams(..)), "{err}"); + assert!(err.to_string().contains("too long"), "{err}"); + let after: Vec<_> = (0..2).map(|i| clips(&e, i)).collect(); + assert_eq!(after, before); +} + +#[test] +fn prepare_dpe_still_consolidates_each_track_to_its_own_material() { + let mut e = stereo_tracks(2); + let sr = sr(&e); + let a = add_clip(&mut e, 0, 1); + let b = add_clip(&mut e, 1, 3); + e.execute("options.prepare_dpe_tracks", &json!({"tracks": [a.0, b.0]})).unwrap(); + // Unchanged from before #164: each track runs to its last clip plus a second, capped at the session end. + assert_eq!(clips(&e, 0), vec![(0, 2 * sr)]); + assert_eq!(clips(&e, 1), vec![(0, 3 * sr)]); + assert_eq!(source_of_only_clip(&e, 0), (2 * sr as usize, 0.25)); +} From cfce845f706219cd24cc6ca63fec4723a8476995 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:52:59 +0200 Subject: [PATCH 002/101] 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 003/101] 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 004/101] 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 005/101] 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 006/101] 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 007/101] 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 008/101] 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 009/101] 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 010/101] 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 011/101] 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 012/101] 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 013/101] 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 014/101] 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 015/101] 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 016/101] 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 017/101] 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 018/101] 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 019/101] =?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 020/101] 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 021/101] 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 022/101] 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 023/101] 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 024/101] 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 025/101] fix(event): Constant tempo applies only over its range (#238) --- crates/engine/src/cmd/event.rs | 45 +++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 34fb355..ed2cf6f 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { e.session_mut().tempo.scale_tempos(f).map_err(|err| bad("event.tempo_scale", err.to_string()))?; Ok(json!({})) }), - cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo), + cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo_constant), cmd!("event.insert_time", "Insert Time...", ["Event", "Time Operations"], None, "{start?, length | end}", has_tracks, insert_time), cmd!("event.cut_time", "Cut Time...", ["Event", "Time Operations"], None, "{start?, end?}", has_tracks, cut_time), cmd!( @@ -110,6 +110,30 @@ fn tempo(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tick": tick, "bpm": bpm})) } +fn tempo_constant(e: &mut Engine, p: &Value) -> Result { + let id = "event.tempo_constant"; + let r = range_param(e, id, p)?; + let s = e.session(); + let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate)); + if t1 <= t0 { + return tempo(e, p); + } + let bpm = f64_or(p, "bpm", f64::NAN); + let s = e.session_mut(); + // Read before the edit: the end is a musical position, and the new tempo moves it in samples. + let after = s.tempo.tempo_at_tick(t1); + let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1); + s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?; + let inside: Vec = s.tempo.tempos().iter().map(|x| x.tick).filter(|t| *t > t0 && *t < t1).collect(); + for t in inside { + s.tempo.remove_tempo(t); + } + if !had_end { + s.tempo.set_tempo(t1, after).map_err(|err| bad(id, err.to_string()))?; + } + Ok(json!({"tick": t0, "end_tick": t1, "bpm": bpm})) +} + fn meter(e: &mut Engine, p: &Value) -> Result { let num = i64_or(p, "numerator", 4).clamp(1, 64) as u32; let den = i64_or(p, "denominator", 4).clamp(1, 64) as u32; @@ -376,4 +400,23 @@ mod tests { let tick = s.tempo.samples_to_ticks(at, s.sample_rate); assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); } + + /// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it. + #[test] + fn tempo_constant_is_bounded_by_its_range() { + let mut e = crate::Engine::default(); + e.execute("event.tempo", &json!({"bpm": 100.0, "at": 0})).unwrap(); + e.execute("event.tempo", &json!({"bpm": 140.0, "at": 48_000})).unwrap(); + let s = e.session(); + let (t0, t1) = (s.tempo.samples_to_ticks(24_000, s.sample_rate), s.tempo.samples_to_ticks(96_000, s.sample_rate)); + let mid = s.tempo.samples_to_ticks(72_000, s.sample_rate); + let after = s.tempo.tempo_at_tick(t1); + e.execute("event.tempo_constant", &json!({"bpm": 90.0, "start": 24_000, "end": 96_000})).unwrap(); + let tempo = &e.session().tempo; + assert_eq!(tempo.tempo_at_tick(t0 - 1), 100.0, "before the range"); + assert_eq!(tempo.tempo_at_tick(t0), 90.0); + assert_eq!(tempo.tempo_at_tick(mid), 90.0, "the interior change is replaced"); + assert_eq!(tempo.tempo_at_tick(t1), after, "the old tempo resumes at the end"); + assert_eq!(after, 140.0); + } } From 4e14c980a40f2a0d3b882d72b06645422df740fa Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 15:50:38 +0200 Subject: [PATCH 026/101] 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 027/101] 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 028/101] 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 029/101] Rename a marker by double-clicking it in the ruler From #186 ("markers created using Numpad Enter cannot be renamed"): double-clicking a marker in a marker ruler added a second marker on top of it, whose label overprinted the first. Renaming was only possible from the Memory Locations list. - Double-clicking a marker's flag now opens a Rename Marker dialog, like double-clicking a marker in Pro Tools opens its memory location. The field opens with the old name selected, as Rename Track and Rename Clip do, and OK runs `markers.edit`. - Double-clicking empty ruler space still adds a marker, and a single click on a flag still recalls it. --- crates/ui-egui/src/dialogs.rs | 13 +++++ crates/ui-egui/src/edit_window.rs | 21 ++++--- crates/ui-egui/tests/marker_rename.rs | 84 +++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/tests/marker_rename.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..2ca955d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,7 @@ pub enum Dialog { Bounce { path: String, format: String, bit_depth: String, normalize: bool }, RenameTrack { id: TrackId, name: String }, RenameClip { id: ClipId, name: String }, + RenameMarker { id: soundcraft_model::MarkerId, name: String }, ClearClips { clips: Vec, sources: Vec, message: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, @@ -42,6 +43,7 @@ impl Dialogs { Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", Dialog::RenameClip { .. } => "rename_clip", + Dialog::RenameMarker { .. } => "rename_marker", Dialog::ClearClips { .. } => "clear_clips", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", @@ -63,6 +65,10 @@ impl Dialogs { self.open = Some(Dialog::RenameClip { id, name: name.to_string() }); } + pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) { + self.open = Some(Dialog::RenameMarker { id, name: name.to_string() }); + } + /// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear. pub fn open_clear(&mut self, e: &Engine, clips: Vec, sources: Vec) -> bool { let Some(message) = clear_message(e.session(), &clips, &sources) else { return false }; @@ -261,6 +267,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", + Dialog::RenameMarker { .. } => "Rename Marker", Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", @@ -379,6 +386,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } } + Dialog::RenameMarker { id, name } => { + name_field(ui, name); + if buttons(ui, "OK", enter) { + action = Some(("markers.edit".into(), json!({"id": id.0, "rename": name}))); + } + } Dialog::ClearClips { clips, sources, message } => { ui.set_max_width(340.0); ui.label(message.as_str()); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..95db9c8 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -535,18 +535,23 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ )); painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), t.marker_text); } - // Click on the marker ruler: recall a marker; double-click adds one. + // Click on the marker ruler: recall a marker; double-click renames it, or adds one. let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click()); if let Some(p) = resp.interact_pointer_pos() { + let hit = s + .markers + .iter() + .filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0)) + .find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0); if resp.double_clicked() { - let at = sample_at(&s, tl, p.x).max(0); - let _ = app.run("markers.add", json!({"at": at, "ruler": lane})); + match hit { + Some(m) => app.dialogs.open_rename_marker(m.id, &m.name), + None => { + let _ = app.run("markers.add", json!({"at": sample_at(&s, tl, p.x).max(0), "ruler": lane})); + } + } } else if resp.clicked() - && let Some(m) = s - .markers - .iter() - .filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0)) - .find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0) + && let Some(m) = hit { let _ = app.run("markers.recall", json!({"number": m.number})); } diff --git a/crates/ui-egui/tests/marker_rename.rs b/crates/ui-egui/tests/marker_rename.rs new file mode 100644 index 0000000..10ec422 --- /dev/null +++ b/crates/ui-egui/tests/marker_rename.rs @@ -0,0 +1,84 @@ +//! Double-clicking a marker in the ruler renames it; elsewhere it still adds one (#186). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, +} + +impl Rig { + fn new() -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({})).unwrap(); + engine.execute("markers.add", &json!({"name": "Verse", "at": 96_000})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + r.frames(4); + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + /// The flag of the ruler marker named `name` (its label starts 8pt to the right). + fn flag(&self, name: &str) -> Pos2 { + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == name => Some(pos2(t.pos.x - 8.0, t.galley.rect.translate(t.pos.to_vec2()).center().y)), + _ => None, + }) + .expect("marker not on the ruler") + } + + fn double_click(&mut self, at: Pos2) { + for pressed in [true, false, true, false] { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + self.frames(2); + } + + fn names(&self) -> Vec { + self.app.engine.session().markers.iter().map(|m| m.name.clone()).collect() + } +} + +#[test] +fn double_clicking_a_ruler_marker_renames_it() { + let mut r = Rig::new(); + let flag = r.flag("Verse"); + r.double_click(flag); + assert_eq!(r.app.dialogs.open_name(), Some("rename_marker")); + r.frame(vec![Event::Text("Chorus".into())]); + r.frame(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + r.frames(2); + assert_eq!(r.names(), vec!["Chorus".to_string()]); +} + +#[test] +fn double_clicking_the_empty_ruler_still_adds_a_marker() { + let mut r = Rig::new(); + let flag = r.flag("Verse"); + r.double_click(flag + vec2(200.0, 0.0)); + assert_eq!(r.app.dialogs.open_name(), None); + assert_eq!(r.names().len(), 2); +} From dabcb4840a07b01cfb5c2302f4829f7fa2a35ca4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:27:57 +0200 Subject: [PATCH 030/101] 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 031/101] 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 032/101] 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 033/101] 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 034/101] 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 035/101] 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 036/101] 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 037/101] 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 038/101] 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 039/101] 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 040/101] 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 041/101] Docs: document --automation-read-root and --automation-write-root for mcp --- docs/mcp.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index 7102a23..d31712b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6,6 +6,7 @@ Claude and other agents can make music with it. ```sh soundcraft-cli mcp # headless engine (no window, no audio device) soundcraft-cli mcp --demo # headless, starting from the demo session +soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # headless, confined to folders soundcraft --control 7801 & # the desktop app… soundcraft-cli mcp --connect 7801 # …driven over MCP: same tools plus screenshots and clicks ``` @@ -16,6 +17,73 @@ Claude Code: claude mcp add soundcraft -- soundcraft-cli mcp --demo ``` +## Confining file access + +```sh +soundcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"soundcraft": {"command": "soundcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft and the other Craft apps take the same ones, so one client config +works across the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_session`, `session.open`, + `import_audio` and `file.import_audio`, `file.import_midi`, `file.import_video`, + `file.import_clip_groups`, `file.import_session_data`, `track.load_preset` and + `mix.sampler_load`. The media a session file names is read too, so an opened session whose + audio paths lead outside the read root loads without that audio and lists it under `missing`. +- Every file a command writes must lie inside the **write root**: `save_session`, `session.save`, + `session.save_as`, `session.save_copy`, `session.save_template`, `bounce_mix` and + `file.bounce_mix`, `file.bounce_stems`, `file.export_clips`, `file.export_midi`, + `file.export_session_text`, `file.export_selected_tracks_as_session`, `file.export_sibelius`, + `file.send_to_sibelius`, `file.print_score`, `file.export_clip_groups` and + `track.save_preset`, together with what a session save writes beside the session file + (`Audio Files/`, `Backups/`) and its temporary file. A score command given no `path` writes to + the temp folder, which is outside the write root unless you made it the root, so it is refused. +- Read and write access are separate. Give the same folder twice for both; a root left out grants + none of its access (with only `--automation-read-root`, nothing can be written to disk). Without + either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + its nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.wav` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.wav`. + A refused save path is reported as refused, never as "already exists". +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`), and a misspelled `--automation-...` flag is an + error rather than an unconfined server. + +The flags confine the in-process server, so `--connect` together with them is an error: the server +would otherwise talk to an app that isn't confined. SoundCraft's desktop app has no equivalent flags +yet, so a running app (`soundcraft --control`) is not confined. `--demo` is the operator's own +choice and is built before the roots apply. + +Playback and bounce need no check of their own. SoundCraft decodes audio into the session when it +enters it (import, open, sampler load), so rendering reads nothing from disk afterwards. Movies are +the exception: a session only references them by path and the desktop app decodes pictures from +that file, but the headless server never does, and the movie's path is checked when it is imported +and when a session naming it is opened. + +What the roots do not cover: commands that take no path still read and change the open session, +and `inspect_session` returns it. Plug-in discovery scans the system's plug-in folders and loads +plug-in binaries, which are fixed locations and native code, not agent paths. Recording is only +reachable in the desktop app. Anything that reads or writes a path goes through one place in the +engine (`io::read_file`, `io::write_file` and the helpers beside them in `crates/engine/src/io.rs`); +a test keeps the rest of the engine from touching the file system on its own. + +The checks hold for every thread of the server process. A path is checked, then opened: another +program that swaps a link in between can still win that race, so the roots keep an agent to the +files it was given rather than guarding against other software on the computer. + ## Tools | Tool | Purpose | From 2d42cf278c47d86aaa8ab179a0157bf0efeb379a Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:55:32 +0100 Subject: [PATCH 042/101] 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 043/101] Look up clip effects and Real-Time Properties without allocating Once a session has any edit.values entry (click settings, Real-Time Properties, clip effects), MixEngine::render allocated every block, although the crate docs say steady-state rendering doesn't: every audio clip formatted two Strings and collected a Vec to look up its clip effects, and every MIDI clip formatted four keys for its Real-Time Properties. Strips render on rayon's workers while the audio callback waits for them, or on the audio thread itself below 4 strips. Lookups now format their keys on the stack (Key) and range over edit.values with &str bounds. A new test checks that Real-Time Properties still reach the instrument. A counting allocator on all threads, over 1000 blocks of the demo session with a clip effect on every clip and Real-Time Properties on every track, saw 10 to 43 allocations per block before and none after, apart from rayon's job queue (a new chunk every 63 blocks) and the one-time setup of each clip's effects when it first plays. --- crates/mix/src/lib.rs | 120 +++++++++++++++++++++++++++++++++++------- 1 file changed, 100 insertions(+), 20 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..a0a3e4b 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -58,6 +58,7 @@ use soundcraft_model::{ }; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; +use std::ops::Bound; use std::sync::Arc; /// Default (stereo) main mix width. The actual width is [`main_channels`]. @@ -852,7 +853,7 @@ fn process_strip( } render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch); let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch)); - fx.configure(¶ms); + fx.configure(params); fx.process(&mut strip.clipbuf, frames); for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) { for (x, y) in d.iter_mut().zip(c.iter()).take(frames) { @@ -980,19 +981,49 @@ fn process_strip( } /// Clip-effect parameters of a clip, or None when it has none or they are bypassed. -fn clip_fx_params(s: &Session, clip: u64) -> Option> { - if s.edit.values.is_empty() { +fn clip_fx_params(s: &Session, clip: u64) -> Option + Clone> { + if s.edit.values.is_empty() || Key::new(format_args!("clip_fx.bypass.{clip}")).is_none_or(|k| s.edit.flag(k.as_str())) { return None; } - let pre = format!("clip_fx.{clip}."); - let v: Vec<(String, f32)> = s + let pre = Key::new(format_args!("clip_fx.{clip}."))?; + let mut params = s .edit .values - .range(pre.clone()..) - .take_while(|(k, _)| k.starts_with(&pre)) - .map(|(k, v)| (k.get(pre.len()..).unwrap_or("").to_string(), *v as f32)) - .collect(); - if v.is_empty() || s.edit.flag(&format!("clip_fx.bypass.{clip}")) { None } else { Some(v) } + .range::((Bound::Included(pre.as_str()), Bound::Unbounded)) + .take_while(move |(k, _)| k.starts_with(pre.as_str())) + .map(move |(k, v)| (k.get(pre.len..).unwrap_or(""), *v as f32)) + .peekable(); + params.peek()?; + Some(params) +} + +/// A short key formatted on the stack: lookups made on the audio thread must not allocate. +#[derive(Clone, Copy)] +struct Key { + buf: [u8; 64], + len: usize, +} + +impl Key { + /// None when the key doesn't fit. + fn new(args: std::fmt::Arguments<'_>) -> Option { + let mut k = Key { buf: [0; 64], len: 0 }; + std::fmt::Write::write_fmt(&mut k, args).ok()?; + Some(k) + } + + fn as_str(&self) -> &str { + self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("") + } +} + +impl std::fmt::Write for Key { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?; + self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes()); + self.len = end; + Ok(()) + } } impl ClipFx { @@ -1006,10 +1037,10 @@ impl ClipFx { }) } /// Modules exist only when one of their parameters is set. - fn configure(&mut self, params: &[(String, f32)]) { + fn configure<'a>(&mut self, params: impl Iterator + Clone) { self.gain_db = 0.0; - let has_eq = params.iter().any(|(k, _)| k.starts_with("eq.")); - let has_comp = params.iter().any(|(k, _)| k.starts_with("comp.")); + let has_eq = params.clone().any(|(k, _)| k.starts_with("eq.")); + let has_comp = params.clone().any(|(k, _)| k.starts_with("comp.")); if has_eq && self.eq.is_none() { self.eq = self.make("eq_7band"); } else if !has_eq { @@ -1023,18 +1054,18 @@ impl ClipFx { for (k, v) in params { if let Some(p) = k.strip_prefix("eq.") { if let Some(eq) = &mut self.eq - && eq.param(p) != Some(*v) + && eq.param(p) != Some(v) { - eq.set_param(p, *v); + eq.set_param(p, v); } } else if let Some(p) = k.strip_prefix("comp.") { if let Some(c) = &mut self.comp - && c.param(p) != Some(*v) + && c.param(p) != Some(v) { - c.set_param(p, *v); + c.set_param(p, v); } } else if k == "gain" { - self.gain_db = *v; + self.gain_db = v; } } } @@ -1319,7 +1350,7 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } let base = s.tempo.samples_to_ticks(clip.start, sr); // MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay). - let rtp = |name: &str, d: f64| s.edit.value(&format!("rtp.{}.{name}", t.id.0), d); + let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d)); let (dv, dp, dur, delay) = if s.edit.values.is_empty() { (0i64, 0i64, 1.0f64, 0i64) } else { @@ -1862,7 +1893,7 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> let mut tmp = vec![vec![0.0f32; len]; out.len()]; render_clip(s, c, range.start, len, &mut tmp, &mut scratch); let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len()); - fx.configure(¶ms); + fx.configure(params); let mut done = 0; while done < len { let n = (len - done).min(4096); @@ -2168,6 +2199,55 @@ mod tests { } } + struct Keys { + inner: Box, + heard: Arc>>, + } + + impl Plugin for Keys { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.inner.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.inner.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.inner.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.inner.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.inner.process(io, frames); + } + fn note_on(&mut self, _offset: usize, note: u8, velocity: u8) { + self.heard.lock().unwrap().push((note, velocity)); + } + } + + #[test] + fn real_time_properties_reach_the_instrument() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None); + s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys")); + let mut seq = soundcraft_midi::Sequence::default(); + seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 }); + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000, seq)); + s.edit.values.insert(format!("rtp.{}.transpose", t.0), 12.0); + s.edit.values.insert(format!("rtp.{}.velocity", t.0), -20.0); + let heard = Arc::new(std::sync::Mutex::new(Vec::new())); + let keys = Keys { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(keys) }]); + render_block(&mut eng, &s, 0); + assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]); + } + fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 { let mut out = vec![vec![0.0f32; 512]; 2]; eng.render(s, pos, 512, &mut out); From 6cf896e76c0ec82a11bf2494f209cfe7c8507f74 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:11:17 -0400 Subject: [PATCH 044/101] Release 0.6.1: rebuild with the fixed shared release workflow The previous release build failed in craft-common-actions, not in this repo: riscv64 packaging got --formats deb,rpm,tar (fixed in craft-common-actions#3), Linux jobs lacked the ALSA headers (craft-common-actions#5), and Windows signing needed the AZURE_* secrets listed in the caller. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 153861a..3228925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3424,7 +3424,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.6.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "serde", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3464,7 +3464,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "serde_json", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap-sys", "libloading", @@ -3486,7 +3486,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3508,7 +3508,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3530,7 +3530,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3540,7 +3540,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "rayon", @@ -3556,7 +3556,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3569,7 +3569,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.6.0" +version = "0.6.1" dependencies = [ "cpal", "log", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3592,7 +3592,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.6.0" +version = "0.6.1" dependencies = [ "craft-ui", "egui", @@ -3618,7 +3618,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.6.0" +version = "0.6.1" dependencies = [ "image", "proptest", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "libloading", "log", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.6.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 02b36ac..a09510b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 9f4335388e664a6eeaa91d5dd9bb5e5676aaf430 Mon Sep 17 00:00:00 2001 From: pi Date: Tue, 13 Oct 2026 04:34:49 +1300 Subject: [PATCH 045/101] 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 046/101] 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 047/101] 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 048/101] 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 049/101] 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 050/101] 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 051/101] 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 052/101] 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 053/101] 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 054/101] 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 055/101] 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 056/101] 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 057/101] Refuse renders too long to finish instead of cutting the audio short One offline render holds MAX_RENDER_SAMPLES samples, about 93 minutes of stereo at 48 kHz, and the offline time stretch, pitch shift and resample stop at MAX_OFFLINE_SAMPLES frames. Longer audio was cut short without a word: Render Clip Gain and Clip Effects Render left the end of the clip silent, every AudioSuite process shortened the clip, the track Commit made fell silent at the limit, and Export Clips wrote shortened files. Above 74 kHz an hour no longer fits, so TCE, Elastic Properties and Remove Pitch Shift padded the cut end with silence. Each now checks the length before it renders and refuses with the limit in seconds, as Consolidate does in #212. AudioSuite counts what Time Stretch, Pitch Shift and Varispeed make, so a fourfold stretch is refused at a quarter of the limit. Export Clips checks every clip before it writes a file. --- crates/engine/src/cmd/audiosuite.rs | 23 +++++++++----- crates/engine/src/cmd/clip.rs | 2 +- crates/engine/src/cmd/clip_more.rs | 2 +- crates/engine/src/cmd/file.rs | 4 +++ crates/engine/src/cmd/more_util.rs | 19 ++++++++--- crates/engine/src/io.rs | 19 ++++++++++- crates/engine/tests/long_renders.rs | 49 +++++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/long_renders.rs diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index e2abfca..3cf88b2 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -40,12 +40,25 @@ fn process(e: &mut Engine, p: &Value) -> Result { return Err(bad("audiosuite.process", "select audio clips first")); } let sr = e.session().sample_rate; + let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0)); + let semitones = get("semitones", 0.0).clamp(-24.0, 24.0); + let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32; + // These processes cut their output at MAX_OFFLINE_SAMPLES frames. + let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize; + let most = match name.as_str() { + "time_stretch" => longest(ratio), + "pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)), + "varispeed" => longest(f64::from(to) / sr.as_f64()), + _ => usize::MAX, + }; let mut done = 0; for id in ids { let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; if !clip.is_audio() { continue; } + let channels = e.session().track(track).map_or(1, |t| t.channels()); + crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?; let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range()); // Render without the clip's own fades: re-render a copy without fades so they stay editable. let mut tmp = e.session().clone(); @@ -78,13 +91,9 @@ fn process(e: &mut Engine, p: &Value) -> Result { audio } "duplicate" => audio, - "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, f64::from(get("ratio", 1.0).clamp(0.25, 4.0)), srf), - "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, get("semitones", 0.0).clamp(-24.0, 24.0), srf), - "varispeed" => { - let speed = get("speed", 1.0).clamp(0.25, 4.0); - let to = (sr.as_f64() / f64::from(speed)).round().max(1.0) as u32; - soundcraft_dsp::offline::resample(&audio, sr.hz(), to) - } + "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf), + "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf), + "varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to), plugin => { let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) }; pl.prepare(srf, 1024, audio.len().max(1)); diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e537..5df1cf6 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { let ids = clip_ids_param(e, p); let mut n = 0; for id in ids { - if crate::io::render_clip_gain(e, id)? { + if crate::io::render_clip_gain(e, "clip.gain_render", id)? { n += 1 } } diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 74dfd8d..7aa7203 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -204,7 +204,7 @@ fn effects_render(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let mut n = 0; for id in &ids { - if crate::io::render_clip_gain(e, *id)? { + if crate::io::render_clip_gain(e, "clip.effects_render", *id)? { n += 1; } let s = e.session_mut(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf8..1a90f51 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -123,6 +123,10 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("file.export_clips", "no clips selected")); } + for (t, c) in ids.iter().filter_map(|id| e.session().find_clip(*id)) { + let channels = e.session().track(t).map_or(1, |x| x.channels()); + crate::io::refuse_longer(e.session(), "file.export_clips", &c.name, "export", c.length, crate::io::render_limit(channels))?; + } let n = crate::io::export_clips(e, &ids, &dir)?; Ok(json!({"written": n, "dir": dir})) }), diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs index 756106b..bf16982 100644 --- a/crates/engine/src/cmd/more_util.rs +++ b/crates/engine/src/cmd/more_util.rs @@ -132,9 +132,10 @@ pub fn set_clip_env(c: &mut Clip, mut pts: Vec<(Samples, f32)>) { } } -/// Longest audio we render in one stretch (one hour at the session rate). +/// Longest audio we render in one stretch: one hour at the session rate, and no more than +/// `time_stretch` makes. fn max_render(s: &Session) -> Samples { - s.sample_rate.samples(3600.0) + s.sample_rate.samples(3600.0).min(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples) } /// Render the audio a clip plays, time-stretched (pitch preserved) to `out_len` samples, into a new @@ -142,8 +143,10 @@ fn max_render(s: &Session) -> Samples { pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) -> Result> { let ClipContent::Audio { source, offset } = c.content else { return Ok(None) }; let Some(audio) = s.pool.get(source).cloned() else { return Ok(None) }; - if out_len <= 0 || out_len > max_render(s) { - return Err(bad(cmd, "the target length must be between 1 sample and one hour")); + let max = max_render(s); + if out_len <= 0 || out_len > max { + let secs = max as f64 / s.sample_rate.as_f64(); + return Err(bad(cmd, format!("the target length must be between 1 sample and {secs:.0} s"))); } let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; let src_len = ((c.length as f64) / stretch).round().max(1.0); @@ -202,4 +205,12 @@ mod tests { assert_eq!(env_db(&[], 50), 0.0); assert_eq!(env_db(&env, 500), -10.0); } + + #[test] + fn stretches_are_no_longer_than_time_stretch_makes() { + let mut s = Session::default(); + assert_eq!(max_render(&s), s.sample_rate.samples(3600.0)); + s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap(); + assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..fd17b36 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -408,13 +408,28 @@ pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { Ok(true) } +/// The most frames of `channels`-channel audio one offline render holds. +pub fn render_limit(channels: usize) -> usize { + soundcraft_mix::MAX_RENDER_SAMPLES / channels.max(1) +} + +/// Refuses `len` frames of `name` past `max`, where a render would cut the audio short. +pub fn refuse_longer(s: &Session, cmd: &str, name: &str, verb: &str, len: Samples, max: usize) -> Result<()> { + if len <= Samples::try_from(max).unwrap_or(Samples::MAX) { + return Ok(()); + } + let secs = max as f64 / s.sample_rate.as_f64(); + Err(crate::cmd::bad(cmd, format!("`{name}` is too long to {verb} (at most {secs:.0} s)"))) +} + /// Render clip gain into a new source and reset the clip's gain. -pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result { +pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result { let s = e.session(); let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) }; if !c.is_audio() { return Ok(false); } + refuse_longer(s, cmd, &c.name, "render", c.length, render_limit(s.track(t).map_or(1, |x| x.channels())))?; let mut c2 = c.clone(); c2.fade_in = Default::default(); c2.fade_out = Default::default(); @@ -449,6 +464,8 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { return Ok(None); } let range = Range::new(0, end + s.sample_rate.samples(2.0)); + // The track renders through a main mix of at least two channels. + refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?; let audio = soundcraft_mix::render_track_pre_fader(s, t, range); let name = format!("{}.cm", tr.name); let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2)); diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/long_renders.rs new file mode 100644 index 0000000..c2c9fce --- /dev/null +++ b/crates/engine/tests/long_renders.rs @@ -0,0 +1,49 @@ +//! Renders longer than one offline buffer holds are refused, rather than cut short and kept. + +use serde_json::{Value, json}; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES; +use soundcraft_engine::Engine; +use soundcraft_mix::MAX_RENDER_SAMPLES; +use soundcraft_model::{ChannelFormat, Clip, ClipId, Session, TrackKind}; + +/// A stereo track "Talk" with one clip `frames` long. Its source is one second: the commands +/// refuse on the clip's length before they render anything. +fn long_clip(frames: usize) -> (Engine, ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Talk")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 48_000]; 2] }; + let src = soundcraft_engine::io::add_source(&mut s, "talk", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64)); + (Engine::new(s), id) +} + +fn refused(e: &mut Engine, cmd: &str, params: &Value) { + let before = serde_json::to_value(e.session()).unwrap(); + let err = e.execute(cmd, params).expect_err(cmd).to_string(); + assert!(err.contains("is too long to"), "{cmd}: {err}"); + assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "{cmd} changed the session"); +} + +#[test] +fn clips_longer_than_one_render_are_refused() { + let (mut e, id) = long_clip(MAX_RENDER_SAMPLES / 2 + 1); + let dir = std::env::temp_dir().join(format!("soundcraft-long-renders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + refused(&mut e, "clip.gain_render", &json!({"clips": [id.0]})); + refused(&mut e, "clip.effects_render", &json!({"clips": [id.0]})); + refused(&mut e, "audiosuite.process", &json!({"process": "reverse", "clips": [id.0]})); + refused(&mut e, "track.commit", &json!({"tracks": ["Talk"]})); + refused(&mut e, "file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [id.0]})); + assert!(!dir.exists(), "Export Clips started writing before it refused"); +} + +#[test] +fn audiosuite_refuses_results_longer_than_one_render() { + let (mut e, id) = long_clip(MAX_OFFLINE_SAMPLES / 4 + 1); + let fourfold = [("time_stretch", json!({"ratio": 4.0})), ("pitch_shift", json!({"semitones": 24.0})), ("varispeed", json!({"speed": 0.25}))]; + for (process, params) in fourfold { + refused(&mut e, "audiosuite.process", &json!({"process": process, "params": params, "clips": [id.0]})); + } +} From fe3d759b53709e89620014800feb527dcf3d2179 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:52:12 +0100 Subject: [PATCH 058/101] 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 059/101] =?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 060/101] 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 061/101] 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 062/101] 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 063/101] 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 064/101] 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 065/101] 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 066/101] 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 067/101] 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 068/101] 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 069/101] 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 070/101] Check INFO entry sizes in WAV files that symphonia reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAV files that aren't plain PCM (A-law, µ-law, ADPCM) go to symphonia, which reads every entry of an INFO list into a buffer of its declared size, bounded only by the list's and file's declared lengths. A file claiming a 4 GB RIFF and a 2 GB title asked for 2 GB before failing. macOS gives that lazily; a 32-bit build or a system that can't commit it aborts. Before symphonia reads such a WAV, its INFO entries are checked against the bytes the file holds, and one that claims more is refused as malformed. Chunks are walked where symphonia reads them, not where their lengths say: A-law, µ-law and ADPCM `fmt` chunks are read by their own extra size, extensible ones by 40 bytes, and a list is padded twice. --- crates/audio-io/src/symph.rs | 1 + crates/audio-io/src/wav.rs | 51 ++++++++++++++++++++++++++++++ crates/audio-io/tests/malformed.rs | 27 ++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 5c40ecd..6169111 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -51,6 +51,7 @@ fn open(bytes: &[u8]) -> Result> { Some(b"caff") => crate::caf::check(bytes, at)?, Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?, + Some(b"RIFF") => crate::wav::check_info(bytes, at)?, _ => {} } return reader(mss, &fmt_opts).map_err(map_err); diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index df56010..d6ce7cd 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -83,6 +83,57 @@ fn parse_bext(c: &[u8]) -> Option { }) } +/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry +/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are +/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length. +pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> { + if tag(b, at + 8) != Some(*b"WAVE") { + return Ok(()); + } + let mut pos = at + 12; + while let Some(id) = tag(b, pos) { + let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break }; + let (body, pad) = (pos + 8, len & 1); + let used = match &id { + b"data" => break, + b"fmt " => fmt_used(b, body, len), + b"LIST" if len < 4 => break, + b"LIST" => { + let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 }; + // The list pads itself, then the chunk is padded again. + 4 + entries.max(len - 4) + pad + } + _ => len, + }; + pos = body.saturating_add(used).saturating_add(pad); + } + Ok(()) +} + +/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size. +fn fmt_used(b: &[u8], body: usize, len: usize) -> usize { + match le_u16(b, body) { + Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)), + Some(0xfffe) => 40, + _ => len, + } +} + +/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of +/// them, with the pad it reads after an odd entry. +fn info_entries(b: &[u8], start: usize, len: usize) -> Result { + let mut used = 0; + while used + 8 <= len { + let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break }; + if n > b.len().saturating_sub(start + used + 8) { + return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into())); + } + used += 8 + n; + used += used & 1; + } + Ok(used) +} + /// True when the bytes start like a RIFF/RF64/BW64 WAVE file. pub(crate) fn is_wav(b: &[u8]) -> bool { matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE") diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index f28e535..49062d7 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -314,6 +314,33 @@ fn an_id3_tag_before_the_audio_is_skipped_unread() { assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300); } +#[test] +fn wav_info_entries_beyond_the_file_are_refused() { + let alaw = |len: u32| { + let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat(); + riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]) + }; + assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800); + let mut f = alaw(0x7fff_fff0); + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + let list = f.windows(4).position(|w| w == b"LIST").unwrap(); + f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); +} + +#[test] +fn wav_info_entries_are_found_where_symphonia_reads_them() { + let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); + let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat()); + // A-law's extra bytes are read past its chunk; an odd list is padded twice. + let files = + [riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()]; + for mut f in files { + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; From 6d03eab566151490abc083c932086ffd91553e94 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:06:13 +0100 Subject: [PATCH 071/101] 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 072/101] 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 073/101] 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 074/101] 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 075/101] 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 076/101] Tint each Edit window track with its colour As in Pro Tools, a track's colour now runs through its whole row instead of only the strip at its left edge: the header (and any header columns) takes a muted shade of it and the lane a faint one, so clips in the full colour still stand out. - Window > Color Palette has a "Track tint in the Edit window" slider; at zero tracks look as before. The setting is saved with the UI prefs. - A selected track's header gets an accent outline, since its blue background would otherwise be lost under the tint. --- crates/ui-egui/src/edit_window.rs | 12 ++++++++---- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/theme.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/windows.rs | 2 ++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index c16b998..0ee2ae8 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -749,16 +749,19 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) } let main_h = track.height.points(); let lane = lane_rect(&track, row, tl); - // Header background. - ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 }); + let (head_bg, lane_bg) = crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint); + ui.painter().rect_filled(head, 0.0, head_bg); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border)); ui.painter().hline(head.min.x + 5.0..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light)); + if selected && app.ui.track_tint > 0.0 { + ui.painter().rect_stroke(head.with_min_x(head.min.x + 5.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } track_header(app, ui, &track, head, selected); // Lane. ui.painter().vline(tl.min.x - 0.5, row.y_range(), Stroke::new(1.0, t.border)); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); - painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg }); + painter.rect_filled(lane, 0.0, lane_bg); let s = app.engine.session().clone(); grid_lines(&painter, &s, tl, lane); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); @@ -1049,7 +1052,8 @@ fn column_ui(ui: &mut Ui, col: &str, r: Rect) -> Ui { /// One optional Edit-window column for a track (instrument, inserts, sends, I/O, comments). fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { let t = Tokens::current(); - ui.painter().rect_filled(r, 0.0, t.panel_bg); + let selected = app.engine.session().edit.selected_tracks.contains(&track.id); + ui.painter().rect_filled(r, 0.0, crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint).0); ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, t.border)); let line_h = 15.0; let rows = ((r.height() - 4.0) / line_h).floor().max(1.0) as usize; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..b307e47 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -68,6 +68,8 @@ pub struct UiState { pub mix_views: Vec, /// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color. pub edit_views: Vec, + /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip). + pub track_tint: f32, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>, pub audiosuite: Option, @@ -128,6 +130,7 @@ impl Default for UiState { narrow_mix: false, mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], edit_views: vec!["color".into()], + track_tint: 0.5, plugin_windows: Vec::new(), audiosuite: None, status: String::new(), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0c201e2..54d4afa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -348,6 +348,20 @@ pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { } } +/// A track's header and lane backgrounds in the Edit window, tinted with its colour by `tint` (0..1). +pub fn track_backgrounds(color: [u8; 3], selected: bool, tint: f32) -> (Color32, Color32) { + let t = Tokens::current(); + let k = tint.clamp(0.0, 1.0); + if k <= 0.0 { + return if selected { (t.header_selected, t.playlist_alt) } else { (t.panel_bg2, t.playlist_bg) }; + } + let c = rgb(color); + let header = t.panel_bg2.lerp_to_gamma(c, k * 0.55); + let header = if selected { crate::widgets::shade(header, 0.1) } else { header }; + let lane = if selected { t.playlist_alt } else { t.playlist_bg }.lerp_to_gamma(c, k * 0.2); + (header, lane) +} + /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; @@ -428,6 +442,12 @@ mod tests { assert_eq!(old.theme, ThemeMode::Dark); } + #[test] + fn prefs_saved_before_track_tint_get_the_default() { + let ui: UiState = serde_json::from_value(json!({"theme": "light"})).unwrap(); + assert!((ui.track_tint - 0.5).abs() < f32::EPSILON); + } + #[test] fn modes_round_trip_through_prefs() { for mode in ThemeMode::ALL { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..6c00df9 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -143,6 +143,8 @@ fn color_palette(app: &mut SoundApp, ctx: &egui::Context) { } } }); + ui.separator(); + ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text("Track tint in the Edit window")); }); app.ui.show_color_palette = open; } From c9b694907f19b6ff8beefcf6b7d81b67bff27bd5 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:27:53 +0100 Subject: [PATCH 077/101] 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 078/101] 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 079/101] 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 080/101] 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 081/101] Clicking a send's level opens its Send window with a fader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Pro Tools, clicking a send opens its Send window: the send's fader, pan, Mute and Pre/Post. SoundCraft turned the send's level readout into a text field instead, so there was no fader to grab. - A click on a send's level in the Mix window opens the Send window: the destination, Pre and Mute buttons, a pan knob, the fader (Alt- click for 0 dB, Cmd/Ctrl for fine moves, down to −∞) and the level, which takes a typed value. It stays open while you play. - One Send window at a time, as with Pro Tools' Target lit: clicking another send's level moves the window to that send, and clicking the same send again closes it. The send shows an outline while its window is open. Removing the send or its track closes the window. - Escape closes it too, unless the Escape is for something on top: an open menu, a level being typed, a dialog or the search. egui closes menus and drops text focus on Escape before the window is drawn, so that is read from the previous frame. - Dragging the level and Alt-click on it still work in the strip, and right-click still opens the options menu. - The send's level and the window's fader have accessibility labels ("Send A level", "Send level"). --- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/mix_window.rs | 117 +++++++++++++++++++++++----- crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/tests/send_window.rs | 110 ++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/tests/send_window.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..34a569b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,9 @@ pub struct UiState { pub edit_views: Vec, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>, + /// The send in the Send window (track, slot). + #[serde(skip)] + pub send_window: Option<(TrackId, usize)>, pub audiosuite: Option, pub status: String, pub show_automation: bool, @@ -129,6 +132,7 @@ impl Default for UiState { mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], edit_views: vec!["color".into()], plugin_windows: Vec::new(), + send_window: None, audiosuite: None, status: String::new(), show_automation: false, diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index a00b30b..2bdc4b4 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -644,19 +644,23 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec regular(9.0), if s.mute { t.mute } else { t.text }, ); - let (level_resp, entered) = editable_readout( - ui, - level_r, - ui.id().with(("send-value", id.0, slot)), - &format!("{} dB", db_text(s.level_db)), - &format!("{:.1}", s.level_db), - parse_db, - 0.0, - ); - if let Some(db) = entered { - let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + let letter = (b'A' + slot as u8) as char; + let shown = app.ui.send_window == Some((id, slot)); + let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag()); + level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {letter} level"))); + if shown { + ui.painter().rect_stroke(level_r.shrink(0.5), 1.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } - if level_resp.as_ref().is_some_and(egui::Response::dragged) { + let level = format!("{} dB", db_text(s.level_db)); + ui.painter().with_clip_rect(level_r).text(level_r.center(), Align2::CENTER_CENTER, level, regular(9.0), t.counter_text); + if level_resp.clicked() { + if ui.input(|i| i.modifiers.alt) { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); + } else { + app.ui.send_window = (!shown).then_some((id, slot)); + } + } + if level_resp.dragged() { // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so // applying it to the already-updated value made trackpad adjustment accelerate. let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); @@ -664,14 +668,11 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } - if let Some(resp) = level_resp { - let _ = resp.clone().on_hover_text(format!( - "Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", - (b'A' + slot as u8) as char, - db_text(s.level_db) - )); - resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); - } + let _ = level_resp.clone().on_hover_text(format!( + "Send {letter}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", + db_text(s.level_db) + )); + level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); } None => { ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); @@ -703,6 +704,82 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec } } +/// The Send window, as in Pro Tools: one send's fader, level, pan, Mute and Pre-fader. Clicking a +/// send's level opens it, moves it to that send, or closes it, and so does Escape. +pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) { + // egui closes menus and drops text focus on Escape before this runs, so whether this Escape + // was theirs comes from the last frame. A dialog or the search on top takes it too. + let busy_id = egui::Id::new("send_window_escape"); + let busy = ctx.data(|d| d.get_temp(busy_id)).unwrap_or(false) || app.dialogs.open.is_some() || app.ui.show_search; + if !busy && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.send_window = None; + } + send_window_contents(app, ctx); + let busy = egui::Popup::is_any_open(ctx) || ctx.text_edit_focused(); + ctx.data_mut(|d| d.insert_temp(busy_id, busy)); +} + +fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { + let Some((id, slot)) = app.ui.send_window else { return }; + let Some((name, send)) = app.engine.session().track(id).and_then(|t| Some((t.name.clone(), t.mixer.sends.get(slot).cloned().flatten()?))) else { + app.ui.send_window = None; + return; + }; + let t = Tokens::current(); + let mut open = true; + egui::Window::new(format!("{name} · Send {}", (b'A' + slot as u8) as char)) + .id(egui::Id::new("send_window")) + .open(&mut open) + .auto_sized() + .collapsible(false) + .default_pos(ctx.pointer_latest_pos().map_or(pos2(200.0, 200.0), |p| p + vec2(24.0, -160.0))) + .show(ctx, |ui| { + ui.set_width(150.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(route_name(app, &send.target)).color(t.counter_text)); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.add_space(((ui.available_width() - 62.0) / 2.0).max(0.0)); + if text_toggle(ui, vec2(36.0, 18.0), "PRE", send.pre_fader, t.accent, "Pre-fader").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": !send.pre_fader})); + } + if text_toggle(ui, vec2(22.0, 18.0), "M", send.mute, t.mute, "Mute send").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": !send.mute})); + } + }); + let mut pan = send.pan; + pan_knob(ui, 32.0, &mut pan, "Send pan (drag; double-click centres)"); + if (pan - send.pan).abs() > f32::EPSILON { + let _ = app.engine.execute_merged( + "mix.send_level", + &json!({"track": id.0, "slot": slot, "pan": pan}), + &format!("send-pan:{}:{slot}", id.0), + ); + } + ui.label(egui::RichText::new(pan_text(send.pan)).color(t.counter_text).small()); + let (fr, fresp) = ui.allocate_exact_size(vec2(56.0, 220.0), Sense::hover()); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Send level")); + if let Some(db) = fader(ui, fr, send.level_db, ui.id().with("send_fader")) { + let _ = app.engine.execute_merged( + "mix.send_level", + &json!({"track": id.0, "slot": slot, "db": db}), + &format!("send:{}:{slot}", id.0), + ); + } + let (rr, _) = ui.allocate_exact_size(vec2(64.0, 15.0), Sense::hover()); + ui.painter().rect_filled(rr, 1.0, t.counter_bg); + let shown = db_text(send.level_db); + let (_, typed) = editable_readout(ui, rr, ui.id().with("send_level_value"), &shown, &format!("{:.1}", send.level_db), parse_db, 0.0); + if let Some(db) = typed { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + } + }); + }); + if !open { + app.ui.send_window = None; + } +} + /// Secondary controls live behind the amount readout, separate from the bus selector like Logic. fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) { ui.set_min_width(230.0); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..038bce6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -175,6 +175,7 @@ pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { transport_window(app, ctx); big_counter(app, ctx); plugin_windows(app, ctx); + crate::mix_window::send_window(app, ctx); about(app, ctx); session_info(app, ctx); } diff --git a/crates/ui-egui/tests/send_window.rs b/crates/ui-egui/tests/send_window.rs new file mode 100644 index 0000000..908885d --- /dev/null +++ b/crates/ui-egui/tests/send_window.rs @@ -0,0 +1,110 @@ +//! A send's level opens its Send window, as in Pro Tools: a fader with Alt-click for 0 dB, moved to +//! another send by clicking that send's level, closed by clicking the same one again. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness() -> (Harness<'static, SoundApp>, soundcraft_model::TrackId) { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"name": "Vox"})).unwrap(); + let vox = engine.session().tracks[0].id; + for (slot, bus) in [(0, "Verb"), (1, "Delay")] { + engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus, "level_db": -10.0})).unwrap(); + } + let mut app = SoundApp::new(engine, None, Services::default()); + app.ui.window = MainWindow::Mix; + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, vox) +} + +fn level(h: &Harness, slot: usize) -> f32 { + h.state().engine.session().tracks[0].mixer.sends[slot].as_ref().unwrap().level_db +} + +fn press(h: &mut Harness, at: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }); + h.step(); +} + +#[test] +fn a_sends_level_opens_its_send_window() { + let (mut h, vox) = harness(); + h.get_by_label("Send A level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 0))); + assert!(h.query_by_label_contains("Vox · Send A").is_some()); + + let fader = h.get_by_label("Send level").rect(); + h.hover_at(fader.center()); + press(&mut h, fader.center(), true, Modifiers::ALT); + press(&mut h, fader.center(), false, Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(level(&h, 0), 0.0, "Alt-click on the fader gives 0 dB"); + + press(&mut h, fader.center(), true, Modifiers::NONE); + for dy in [10.0, 60.0, 150.0, 300.0] { + h.hover_at(fader.center() + vec2(0.0, dy)); + h.step(); + } + press(&mut h, fader.center() + vec2(0.0, 300.0), false, Modifiers::NONE); + assert_eq!(level(&h, 0), -144.0, "the fader goes down to −∞"); + assert_eq!(level(&h, 1), -10.0); + + h.get_by_label("Send B level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 1)), "another send's level moves the window there"); + assert!(h.query_by_label_contains("Vox · Send B").is_some()); + h.get_by_label("Send B level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, None, "clicking the same send again closes it"); + assert!(h.query_by_label_contains("Vox · Send").is_none()); +} + +#[test] +fn escape_closes_the_send_window_after_menus_and_typing() { + let (mut h, vox) = harness(); + h.get_by_label("Send A level").click(); + h.run_steps(3); + + // A right-click menu on top takes the first Escape. + let at = h.get_by_label("Send A level").rect().center(); + h.hover_at(at); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.query_by_label("Pre-fader").is_some(), "the send's options menu is open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_label("Pre-fader").is_none()); + assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only closed the menu"); + + // So does typing a level in the window. + let fader = h.get_by_label("Send level").rect(); + let field = egui::pos2(fader.center().x, fader.max.y + 10.0); + h.hover_at(field); + press(&mut h, field, true, Modifiers::NONE); + press(&mut h, field, false, Modifiers::NONE); + h.run_steps(3); + assert!(h.state().ui.send_window.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only cancelled the typing"); + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, None); + assert_eq!(level(&h, 0), -10.0); +} From 8716dc90254cf62fe60526c99e6895aa5e97f4fe Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:53:23 +0000 Subject: [PATCH 082/101] 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 083/101] 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 084/101] 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 085/101] 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 086/101] =?UTF-8?q?Add=20Clip=20=E2=80=BA=20New=20MIDI=20C?= =?UTF-8?q?lip=20menu=20entry=20and=20command=20reachability=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit midi.new_clip had no UI path, so an empty instrument/MIDI track could never get a MIDI clip (#282). The menu bar is built from the catalog, so the entry is added to menus::tree() and placed first in Clip. The new test checks every mutating command is reachable from the GUI (built menu tree, shortcut, or UI reference), with today's gaps listed. Closes #282 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/midi.rs | 2 +- crates/ui-egui/src/menus.rs | 6 + crates/ui-egui/tests/command_reachability.rs | 110 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/command_reachability.rs diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index fe9fdc2..c442ecb 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { cmd!( "midi.new_clip", "New MIDI Clip", - [], + ["Clip"], None, "{track, start?, end?|length?} (default: the edit selection, or 1 bar)", has_tracks, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..87b1e22 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -200,6 +200,12 @@ pub fn tree() -> Vec { insert_path(&mut roots, line); } insert_path(&mut roots, "Window > Reset Panel Layout"); + // Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282). + // It creates a clip rather than acting on the selected ones, so it leads the Clip menu. + insert_path(&mut roots, "Clip > New MIDI Clip"); + if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") { + clip.children.rotate_right(1); + } for (path, _) in MENU_WINDOWS { insert_path(&mut roots, path); } diff --git a/crates/ui-egui/tests/command_reachability.rs b/crates/ui-egui/tests/command_reachability.rs new file mode 100644 index 0000000..5768ed0 --- /dev/null +++ b/crates/ui-egui/tests/command_reachability.rs @@ -0,0 +1,110 @@ +//! Every user-facing engine command must be reachable from the GUI: through a menu, a shortcut, +//! or a widget that names it in `src/`. Commands that only work from the control channel / CLI / +//! MCP left users stuck (#282: `midi.new_clip` had no UI path, so an empty instrument track could +//! never get a MIDI clip). + +use soundcraft_engine::{catalog, cmd::command_specs}; +use soundcraft_ui_egui::menus::{self, MenuNode}; +use std::collections::HashSet; +use std::path::Path; + +/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today. +/// Give each one a UI path and remove it from this list; don't add to it. +const KNOWN_UNREACHED: &[&str] = &[ + "automation.clear", + "edit.paste_clip_effects", + "edit.paste_clip_gain", + "event.midi_note", + "file.bounce_stems", + "markers.next", + "markers.previous", + "mix.clear_mutes", + "mix.clear_solos", + "mix.insert_move", + "mix.instrument_param", + "mix.phase_invert", + "mix.sampler_clear", + "mix.sampler_load", + "mix.send_surround_pan", + "mix.solo_safe", + "options.post_roll", + "options.pre_roll", + "setup.hardware", // menu opens the Playback Engine window, which doesn't run it + "setup.playback_engine", // same + "setup.reset_edit_state", + "track.elastic", + "track.load_preset", + "track.pin", + "track.timebase", + "track.trim_automation_write", + "track.vca", + "transport.pause", + "transport.status", + "video.offset", + "view.sub_counter", +]; + +fn ui_source() -> String { + fn walk(dir: &Path, out: &mut String) { + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + walk(&p, out); + } else if p.extension().is_some_and(|e| e == "rs") { + out.push_str(&std::fs::read_to_string(&p).unwrap()); + } + } + } + let mut s = String::new(); + walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut s); + s +} + +/// Command ids behind the leaves of the menu bar actually built by the app. A spec's own `menu` +/// field is not enough: the bar is built from the catalog, so an off-catalog path never shows. +fn menu_commands() -> HashSet { + fn walk(nodes: &[MenuNode], extra: &[(&str, &str)], out: &mut HashSet) { + for n in nodes { + if n.children.is_empty() { + out.extend(catalog::implemented_by(&n.path, extra)); + } + walk(&n.children, extra, out); + } + } + let mut out = HashSet::new(); + walk(&menus::tree(), &menus::ui_aliases(), &mut out); + out +} + +#[test] +fn every_command_has_a_ui_path() { + let src = ui_source(); + let in_menus = menu_commands(); + let mut unreached = Vec::new(); + let mut stale = Vec::new(); + for c in command_specs() { + // Queries and view-only commands are for agents / the UI's own reads. + if !c.journal || c.view { + continue; + } + let reached = in_menus.contains(c.id) || c.shortcut.is_some() || src.contains(&format!("\"{}\"", c.id)); + let known = KNOWN_UNREACHED.contains(&c.id); + if !reached && !known { + unreached.push(c.id); + } else if reached && known { + stale.push(c.id); + } + } + assert!(unreached.is_empty(), "commands with no menu, shortcut or UI reference: {unreached:?}"); + assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}"); +} + +#[test] +fn new_midi_clip_is_in_the_clip_menu() { + let extra = menus::ui_aliases(); + assert_eq!(catalog::implemented_by("Clip > New MIDI Clip", &extra).as_deref(), Some("midi.new_clip")); + assert!(menu_commands().contains("midi.new_clip")); + let tree = menus::tree(); + let clip = tree.iter().find(|n| n.label == "Clip").unwrap(); + assert_eq!(clip.children[0].label, "New MIDI Clip", "creates rather than edits, so it comes first"); +} From d26d6fec77e81be2e4775a3f1c8b81416ba9b4c3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:31:41 +0100 Subject: [PATCH 087/101] 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 088/101] 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 089/101] 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 090/101] Keep clips in order after Trim to Selection --- crates/engine/src/edit.rs | 1 + crates/engine/tests/trim_to_selection.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 crates/engine/tests/trim_to_selection.rs diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 05cc0e6..74806d4 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -221,6 +221,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { n += 1; } } + pl.sort(); n } diff --git a/crates/engine/tests/trim_to_selection.rs b/crates/engine/tests/trim_to_selection.rs new file mode 100644 index 0000000..f632899 --- /dev/null +++ b/crates/engine/tests/trim_to_selection.rs @@ -0,0 +1,20 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, Clip, Session, SourceId, TrackKind}; + +#[test] +fn trim_to_selection_keeps_clips_sorted() { + let mut session = Session::default(); + let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let long = session.new_clip_id(); + let short = session.new_clip_id(); + session.track_mut(track).unwrap().playlist_mut().unwrap().clips = + vec![Clip::audio(long, "long", SourceId(1), 0, 0, 30), Clip::audio(short, "short", SourceId(1), 0, 5, 5)]; + let mut engine = Engine::new(session); + engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 15, "end": 20})).unwrap(); + let clips = engine.session().track(track).unwrap().clips(); + assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::>(), vec![(short, 5, 5), (long, 15, 5)]); + engine.undo(); + let clips = engine.session().track(track).unwrap().clips(); + assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::>(), vec![(long, 0, 30), (short, 5, 5)]); +} From 81ec430341f6fe7dd85501fd0a72bdaeedfe95a3 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:45:03 +0200 Subject: [PATCH 091/101] 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 092/101] 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 093/101] Keep shortcuts working after Tab With no widget focused, egui answers Tab by giving keyboard focus to the first widget in the window, and Shift+Tab to the last one. shortcuts::handle ignores every key while a widget has focus, so after Tab (to the next clip boundary) R/T zoom and every other shortcut did nothing until a click elsewhere or Escape. Space and Enter clicked the focused widget instead: on macOS that is the toolbar's Shuffle button, so Space switched the session to Shuffle; on Windows and Linux it opened the SoundCraft menu. While the shortcuts own the keyboard (nothing focused, no dialog open), handle() now cancels egui's focus move for the frame. Tab still moves between fields once one has focus, and inside dialogs. --- crates/ui-egui/src/shortcuts.rs | 2 ++ crates/ui-egui/tests/shortcuts.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..5bfa286 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -86,6 +86,8 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { let _ = app.run(c.0, json!({})); } } + // Tab moves the insertion point here, so egui must not also hand focus to the first widget: that would block every shortcut. + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> { diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index 5d04d47..190cd40 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -25,6 +25,37 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { } } +fn type_in_app(ctx: &egui::Context, app: &mut SoundApp, keys: &[(Key, Modifiers)]) { + let events = keys + .iter() + .flat_map(|&(key, modifiers)| [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })) + .collect(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +#[test] +fn tab_leaves_the_keys_to_the_shortcuts() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + for _ in 0..3 { + type_in_app(&ctx, &mut app, &[]); + } + for tab in [Modifiers::NONE, Modifiers::SHIFT] { + type_in_app(&ctx, &mut app, &[(Key::Tab, tab)]); + assert_eq!(ctx.memory(|m| m.focused()), None, "{tab:?}"); + let zoom = app.engine.session().edit.zoom.samples_per_px; + type_in_app(&ctx, &mut app, &[(Key::T, Modifiers::NONE)]); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, zoom / 2.0, "{tab:?}"); + type_in_app(&ctx, &mut app, &[(Key::R, Modifiers::NONE)]); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, zoom, "{tab:?}"); + } +} + #[test] fn cmd_equals_toggles_mix_and_edit_without_zooming() { let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; From eada1c4cfe27b8b83c98b6dfc7911c8656425205 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:19:06 +0100 Subject: [PATCH 094/101] 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 095/101] 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 096/101] 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 097/101] 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 098/101] 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 099/101] rustfmt Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft-web/src/web.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 452e807..90477cc 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -67,9 +67,8 @@ enum Purpose { } const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION]; -const AUDIO_EXTS: &[&str] = &[ - "wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm", -]; +const AUDIO_EXTS: &[&str] = + &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"]; const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"]; impl Purpose { From 8222048c10c049d1d869ac64d5fe1bbc542a9cf6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:30:30 -0400 Subject: [PATCH 100/101] Pass the Feet+Frames start to parse_position in the bar-offset test (after #294) Co-Authored-By: Claude Opus 5.5 --- crates/time/src/format.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index 7838757..e193151 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -155,7 +155,7 @@ mod tests { #[test] fn minimum_bar_parses_without_overflow() { let (sr, map, rate) = ctx(); - let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0); + let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0); assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0)); assert_eq!(p("2|3|000").ok(), Some(144_000)); assert_eq!(p("0|1|000").ok(), Some(-96_000)); From 391a0aa66c50764dba6acbe3c0496408d312587c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:49:31 -0400 Subject: [PATCH 101/101] 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",