From c4a88ee64a1f459db35181e082273eb6ed310c38 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:43 +1100 Subject: [PATCH 01/29] Keep going when the CLI's stdout is closed. outln! treated a failed write as success and returned immediately, so run and script stopped before --bounce or --save whenever a pipe reader had gone away. A write error is now ignored and the exit code comes from the work itself. --- apps/soundcraft-cli/src/main.rs | 5 +- apps/soundcraft-cli/tests/closed_stdout.rs | 124 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 apps/soundcraft-cli/tests/closed_stdout.rs diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..9c69978 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -20,11 +20,12 @@ use soundcraft_engine::Engine; use std::io::Write; use std::process::ExitCode; -/// Print without panicking on a closed pipe. +/// Print without panicking on a closed pipe. A write error must not skip later work +/// such as `--bounce` or `--save`, and must not by itself report success. macro_rules! outln { ($($t:tt)*) => {{ let mut o = std::io::stdout().lock(); - if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; } + let _ = writeln!(o, $($t)*); }}; } diff --git a/apps/soundcraft-cli/tests/closed_stdout.rs b/apps/soundcraft-cli/tests/closed_stdout.rs new file mode 100644 index 0000000..9807423 --- /dev/null +++ b/apps/soundcraft-cli/tests/closed_stdout.rs @@ -0,0 +1,124 @@ +//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`. + +use std::process::{Command, Stdio}; + +fn bin() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")); + cmd.env("SOUNDCRAFT_NO_PREFS", "1"); + cmd +} + +/// Drop the read end so the child's stdout writes fail with `EPIPE`. +fn closed_stdout() -> std::process::Stdio { + let (reader, writer) = std::io::pipe().expect("pipe"); + drop(reader); + Stdio::from(writer) +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn run_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("run"); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("run") + .arg("--cmd") + .arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file(), "bounce was skipped"); + assert!(session.is_file(), "save was skipped"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("script"); + let script = dir.join("edit.scraft.txt"); + std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap(); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("script") + .arg(&script) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bounce_then_save_with_closed_stdout_writes_the_wav() { + let dir = temp_dir("bounce"); + let wav = dir.join("mix.wav"); + let session = dir.join("Empty.scraft"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn invalid_bounce_destination_still_fails() { + let dir = temp_dir("bad-bounce"); + let missing = dir.join("no-such-dir").join("mix.wav"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&missing) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(!output.status.success(), "a bounce that cannot be written must fail"); + assert!(!missing.exists()); + let _ = std::fs::remove_dir_all(&dir); +} From 734c8e18bc73207e646b53dfe3576674fe8fdd22 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:44 +1100 Subject: [PATCH 02/29] Treat dotted command ids as engine.execute in the CLI app shorthand. app used to leave every ui., engine., session., and app. method untouched, so engine.history, ui.theme, and session.new were sent as unknown control methods. Only the real control-channel methods stay methods. Every other dotted id is engine.execute. --- apps/soundcraft-cli/src/main.rs | 63 +++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..7e61868 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -294,6 +294,39 @@ fn describe(args: &[String]) -> ExitCode { } } +/// Control-channel methods. Every other dotted id is an engine command. +fn is_control_method(method: &str) -> bool { + matches!( + method, + "engine.execute" + | "command" + | "engine.commands" + | "engine.parity" + | "session.inspect" + | "document.inspect" + | "ui.inspect" + | "ui.menu.list" + | "ui.menu.invoke" + | "ui.set" + | "ui.move" + | "ui.click" + | "ui.drag" + | "ui.key" + | "ui.text" + | "ui.screenshot" + | "app.quit" + ) +} + +/// A command id is shorthand for `engine.execute`. Documented control methods stay methods. +fn app_request(method: &str, params: Value) -> (String, Value) { + if is_control_method(method) || !method.contains('.') { + (method.to_string(), params) + } else { + ("engine.execute".to_string(), json!({"command": method, "params": params})) + } +} + fn app(args: &[String]) -> ExitCode { let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into()); let positional: Vec<&String> = args @@ -311,17 +344,8 @@ fn app(args: &[String]) -> ExitCode { None => json!({}), }; let mut r = Remote::new(&port); - // A bare command id is shorthand for engine.execute. - let (m, p) = if method.contains('.') - && !method.starts_with("ui.") - && !method.starts_with("engine.") - && !method.starts_with("session.") - && !method.starts_with("app.") - { - ("engine.execute".to_string(), json!({"command": method, "params": params})) - } else { - (method.to_string(), params) - }; + // A bare command id is shorthand for engine.execute. Control-channel methods stay methods. + let (m, p) = app_request(method, params); match r.call(&m, p) { Ok(v) => { outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -380,6 +404,23 @@ fn parity(args: &[String]) -> ExitCode { mod tests { use super::*; + #[test] + fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() { + let (m, p) = app_request("engine.history", json!({})); + assert_eq!(m, "engine.execute"); + assert_eq!(p["command"], "engine.history"); + let (m, p) = app_request("ui.theme", json!({"mode": "light"})); + assert_eq!(m, "engine.execute"); + assert_eq!(p["params"]["mode"], "light"); + let (m, _) = app_request("session.new", json!({"name": "Session A"})); + assert_eq!(m, "engine.execute"); + let (m, _) = app_request("track.new", json!({})); + assert_eq!(m, "engine.execute"); + for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] { + assert_eq!(app_request(method, json!({})).0, method); + } + } + #[test] fn missing_media_gets_one_warning_each() { assert!(missing_warnings(&[]).is_empty()); From 1aad99ff4cf8cb42dc99c022b573f353cfb8ba1d Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:53:47 +1100 Subject: [PATCH 03/29] Convert only the selected volume automation into clip gain. The command wrote the lane onto the whole clip and cleared points only inside the selection, so the old curve still played and the clip got quieter on both sides. The selection now holds the fader, with anchors so the surrounding curve stays put. Volume and clip-gain windows take their end value from the last sample inside the window, so a breakpoint on the next sample does not ramp the audio before it. --- crates/engine/src/cmd/mix.rs | 167 +++++++++++++++++++++++++++++++++-- crates/mix/src/lib.rs | 13 ++- 2 files changed, 169 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 03574b2..37e1a0f 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -534,25 +534,88 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result { Ok(json!({})) } +/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds. +fn env_db(env: &[(i64, f32)], at: i64) -> f32 { + let mut prev: Option<(i64, f32)> = None; + for &(t, v) in env { + if t >= at { + return match prev { + Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + _ => v, + }; + } + prev = Some((t, v)); + } + prev.map_or(0.0, |(_, v)| v) +} + fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?; let r = range_param(e, "automation.volume_to_clip_gain", p)?; let s = e.session_mut(); for t in &tracks { let Some(tr) = s.track_mut(*t) else { continue }; - let lane = tr.lane(&AutoParam::Volume).cloned(); + let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue }; let base = tr.mixer.volume_db; - let Some(lane) = lane else { continue }; + let delta = |at: i64| lane.value_at(at, base) - base; if let Some(pl) = tr.playlist_mut() { for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { - let mut env: Vec<(i64, f32)> = - lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect(); - env.insert(0, (0, lane.value_at(c.start, base) - base)); - env.push((c.length, lane.value_at(c.end(), base) - base)); + let sel0 = r.start.max(c.start); + let sel1 = r.end.min(c.end()); + if sel1 <= sel0 { + continue; + } + let rel = |at: i64| at - c.start; + let rel0 = rel(sel0); + let rel1 = rel(sel1); + // Later writes win, so the selection anchors replace any point they land on. + let mut pts: Vec<(i64, f32)> = + c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); + if rel0 > 0 { + pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1))); + } + pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); + for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) { + let at = rel(pt.at); + pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); + } + if sel1 - 1 > sel0 { + let at = rel(sel1 - 1); + pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); + } + if sel1 < c.end() { + pts.push((rel1, env_db(&c.gain_env, rel1))); + } + pts.sort_by_key(|p| p.0); + let mut env = Vec::new(); + for p in pts { + if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { + env.pop(); + } + env.push(p); + } c.gain_env = env; } } - tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end); + let covers_all = lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); + let lane_m = tr.lane_mut(&AutoParam::Volume); + if covers_all { + lane_m.clear_range(r.start, r.end); + } else { + // Hold the fader across the selection and anchor the samples on either side, so the + // curve outside does not interpolate through the gap or keep the old level inside. + let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base)); + let after = lane.value_at(r.end, base); + lane_m.clear_range(r.start, r.end); + if let Some(v) = before { + lane_m.set_point(r.start - 1, v); + } + lane_m.set_point(r.start, base); + if r.end - 1 > r.start { + lane_m.set_point(r.end - 1, base); + } + lane_m.set_point(r.end, after); + } } Ok(json!({})) } @@ -606,3 +669,93 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result { } Ok(json!({})) } + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; + use soundcraft_time::Range; + use std::sync::Arc; + + fn rms_db(before: &[f32], after: &[f32]) -> f32 { + let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::() / xs.len().max(1) as f32; + 10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10() + } + + fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) { + let mut s = Session::default(); + let n = 8192; + let tone: Vec = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect(); + s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] }))); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone")); + let id = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n)); + s.track_mut(t).unwrap().mixer.volume_db = -3.0; + for &(at, value) in points { + s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value); + } + s.edit.selected_tracks = vec![t]; + s.edit.selection = Range::new(2048, 6144); + (Engine::new(s), t) + } + + fn rendered(s: &Session) -> Vec { + soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default() + } + + fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) { + e.session_mut().edit.selection = Range::new(start, end); + e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap(); + } + + #[test] + fn partial_volume_to_clip_gain_keeps_each_region() { + let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)]; + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB"); + } + let tr = e.session().track(t).unwrap(); + assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5); + let lane = tr.lane(&AutoParam::Volume).unwrap(); + assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3); + assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3); + assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3); + + let (mut e, t) = tone_session(&[]); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(diff < 1e-5, "no lane changed a sample by {diff}"); + + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 0, 8193); + let after = rendered(e.session()); + let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(diff < 1e-4, "full selection changed a sample by {diff}"); + assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty())); + + for points in [ + [(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)], + [(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)], + ] { + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2048), (6144, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}"); + } + let delta = rms_db(&before[2048..6144], &after[2048..6144]); + assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}"); + } + } +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..a0297d3 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -876,8 +876,11 @@ fn process_strip( d.extend_from_slice(src.get(..frames).unwrap_or(&[])); } } + // Endpoints are the first and last sample of this block. The next block starts on its own + // first sample, so a breakpoint there does not ramp the block before it. + let last = pos.saturating_add(frames.saturating_sub(1) as i64); let mut v0 = volume_db_at(t, pos); - let mut v1 = volume_db_at(t, pos + frames as i64); + let mut v1 = volume_db_at(t, last); // Trim automation (an offset on top of the volume curve). if let Some(l) = t .automation @@ -886,14 +889,14 @@ fn process_strip( .filter(|l| !l.points.is_empty()) { v0 += l.value_at(pos, 0.0); - v1 += l.value_at(pos + frames as i64, 0.0); + v1 += l.value_at(last, 0.0); } // VCA master: its fader offsets the member's, and its mute mutes the member. if let Some(vid) = t.mixer.vca && let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca) { v0 += volume_db_at(vca, pos); - v1 += volume_db_at(vca, pos + frames as i64); + v1 += volume_db_at(vca, last); if vca.mixer.mute { strip.muted = true; } @@ -1202,7 +1205,9 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut while i < i1 { let seg_end = (i + STEP).min(i1); let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start); - let rel1 = pos.saturating_add(seg_end as i64).saturating_sub(clip.start); + // The last sample of this step, not the first sample of the next one, so a breakpoint + // on the boundary does not ramp the audio before it. + let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start); let g0 = clip.gain_at(rel0); let g1 = clip.gain_at(rel1.min(clip.length)); let n = (seg_end - i) as f32; From 35a6fbccc0a3a6417ff98a4f5275775baa83411b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:27:21 +1100 Subject: [PATCH 04/29] Restore full playback speed when transport stops. Half-Speed Playback stored 0.5 on the player, and a manual Stop left that rate in place, so the next ordinary Play stayed at half speed. Stopping now resets the player to 1.0. Half-speed itself still plays at 0.5, and a pass that ends on its own still returns to full speed. --- crates/playback/src/lib.rs | 5 +++++ crates/ui-egui/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..5d22e7a 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -622,6 +622,11 @@ impl Player { self.shared.speed.store(v, Ordering::Relaxed); } + /// Playback rate last requested, 1.0 at full speed. + pub fn speed(&self) -> f32 { + self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0 + } + pub fn position(&self) -> Samples { self.shared.position.load(Ordering::Relaxed) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..d4d8560 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -358,6 +358,8 @@ impl SoundApp { fn stop_play(&mut self) { if let Some(p) = &self.player { + // Half-speed is a separate command. Stop must not leave that rate for the next Play. + p.set_speed(1.0); p.stop(); } if self.engine.transport.recording { @@ -909,4 +911,43 @@ mod tests { fn automation_write_fires_after_loop_wrap() { assert!(automation_step_due(0, 1_000_000, 960)); } + + #[test] + fn ordinary_play_after_stop_is_full_speed() { + use serde_json::json; + let engine = soundcraft_engine::Engine::default(); + let player = soundcraft_playback::Player::new(engine.session_arc()); + let mut app = super::SoundApp::new(engine, Some(player), super::Services::default()); + app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap(); + app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap(); + app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap(); + let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0); + assert!((speed(&app) - 1.0).abs() < 1e-3); + + app.engine.execute("transport.half_speed", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(app.is_playing()); + assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback"); + + app.engine.execute("transport.stop", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(!app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed"); + + app.engine.execute("transport.play", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed"); + + app.engine.execute("transport.stop", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap(); + app.engine.execute("transport.half_speed", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!((speed(&app) - 0.5).abs() < 1e-3); + app.player.as_ref().unwrap().stop(); + app.handle_transport(1.0 / 60.0); + assert!(!app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed"); + } } From ec33d4011b0e2fd08df4c74bfb0214a4ae4b606b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:28:24 +1100 Subject: [PATCH 05/29] Keep clip gain when AudioSuite inverts or duplicates. Both processes rendered a unity-gain file and then cleared the clip's gain and envelope, so the result was quieter than the clip the user heard. Invert and Duplicate now leave that gain on the new clip. Processes that bake a new level still write a unity-gain clip. --- crates/engine/src/cmd/audiosuite.rs | 48 +++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index ac26a30..e2abfca 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -107,8 +107,12 @@ fn process(e: &mut Engine, p: &Value) -> Result { nc.name = new_name; nc.content = ClipContent::Audio { source: src, offset: 0 }; nc.length = i64::try_from(frames).unwrap_or(nc.length); - nc.gain_db = 0.0; - nc.gain_env.clear(); + // Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain, + // the same way its fades stay editable. Other processes bake a new file at unity gain. + if !matches!(name.as_str(), "invert" | "duplicate") { + nc.gain_db = 0.0; + nc.gain_env.clear(); + } if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { pl.clips.retain(|c| c.id != id); } @@ -122,3 +126,43 @@ fn short(n: &str) -> String { let s: String = n.split('_').map(|w| w.chars().take(4).collect::()).collect::>().join(""); s.chars().take(8).collect() } + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Session, TrackKind}; + + fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + let mut c = Clip::audio(id, "tone", src, 0, 0, 64); + c.gain_db = -6.0; + c.gain_env = vec![(0, 0.0), (32, -12.0)]; + crate::edit::place_clip(&mut s, t, c); + s.edit.selected_clips = vec![id]; + (crate::Engine::new(s), id) + } + + #[test] + fn invert_and_duplicate_keep_clip_gain() { + let (mut e, id) = gained_clip(); + e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db); + assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]); + let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; + assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}"); + + let (mut e, id) = gained_clip(); + e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db); + assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]); + let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; + assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}"); + } +} From abcec3938b91c5aa64b5898c5b9b4726045766a6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:41:03 +1100 Subject: [PATCH 06/29] Render a named bus when bouncing, and reject an unknown one. file.bounce_mix advertised a source but always wrote the main mix, so an empty bus and a missing bus both succeeded with the whole mix. --- crates/engine/src/cmd/file.rs | 61 ++++++++++++++++++++++++++- crates/engine/src/io.rs | 31 ++++++++++++-- crates/mix/src/lib.rs | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 796ad8f..4a1eb50 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -234,6 +234,17 @@ fn import_audio_target(e: &Engine, p: &Value) -> Result> { selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track")) } +/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error. +fn bounce_bus(e: &Engine, p: &Value) -> Result> { + let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + if name.eq_ignore_ascii_case("main") { + return Ok(None); + } + e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`"))) +} + /// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix. fn fold_down_param(p: &Value, id: &str) -> Result { match str_param(p, "fold_down").map(str::trim) { @@ -260,7 +271,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }; let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; - let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?; + let bus = bounce_bus(e, p)?; + let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?; std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; Ok( json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), @@ -365,6 +377,53 @@ mod tests { assert!(std::fs::remove_dir_all(&dir).is_ok()); } + #[test] + fn bounce_source_renders_the_named_bus() { + let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let wav = tone_wav(&dir, "tone.wav"); + let mut e = Engine::default(); + e.execute("file.import_audio", &json!({"path": wav})).unwrap(); + let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo); + e.session_mut().add_bus("Empty", ChannelFormat::Stereo); + let track = e.session().tracks[0].id; + { + let tr = e.session_mut().track_mut(track).unwrap(); + let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem)); + send.level_db = -12.0; + tr.mixer.sends[0] = Some(send); + } + let bounce = |e: &mut Engine, name: &str, source: Option<&str>| { + let path = dir.join(name); + let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false}); + if let Some(source) = source { + params["source"] = json!(source); + } + let value = e.execute("file.bounce_mix", ¶ms).unwrap(); + let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0); + (std::fs::read(&path).unwrap(), peak) + }; + let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None); + let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main")); + let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM")); + let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty")); + assert_eq!(main_bytes, named_main); + assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}"); + assert!(main_peak > -20.0, "{main_peak}"); + assert_ne!(stem_bytes, main_bytes); + assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}"); + assert_ne!(empty_bytes, main_bytes); + assert!(empty_peak < -100.0, "{empty_peak}"); + let missing = dir.join("missing.wav"); + let err = e + .execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false})) + .unwrap_err(); + assert!(err.to_string().contains("unknown bus"), "{err}"); + assert!(!missing.exists()); + assert!(std::fs::remove_dir_all(&dir).is_ok()); + } + fn remove_media(dir: &std::path::Path) { for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { let p = entry.path(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..b81894f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -3,7 +3,7 @@ use crate::{Engine, EngineError, Result}; use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; -use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; use soundcraft_time::{Range, Samples}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -217,11 +217,23 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) /// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV /// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`. pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> { + bounce_source(e, r, opts, normalize, fold_stereo, None) +} + +/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named. +pub fn bounce_source( + e: &Engine, + r: Range, + opts: &EncodeOptions, + normalize: bool, + fold_stereo: bool, + bus: Option, +) -> Result<(Vec, (f32, f32, f32))> { let s = e.session(); if r.len() > s.sample_rate.samples(4.0 * 3600.0) { return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); } - let (mut ch, fmt) = render_main(s, r, fold_stereo); + let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus); if normalize { soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); } @@ -238,8 +250,19 @@ pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: /// The main mix over `r` and its format (folded to stereo on request). fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) { - let ch = soundcraft_mix::render_range(s, r, 1024); - let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; + render_bounce(s, r, fold_stereo, None) +} + +/// The main mix, or one bus, over `r`. +fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option) -> (Vec>, ChannelFormat) { + let (ch, fmt) = if let Some(id) = bus { + let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo); + (soundcraft_mix::render_bus(s, id, r, 1024), fmt) + } else { + let ch = soundcraft_mix::render_range(s, r, 1024); + let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; + (ch, fmt) + }; if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..eb17222 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -293,6 +293,11 @@ pub struct MixEngine { order: Vec, /// Current total compensation delay (samples) of the mix. pub latency: usize, + /// Delay already applied to audio written onto busses: the slowest track insert. + /// A bus render drops this so the bus lines up with the timeline. The extra delay + /// that lines direct outputs up with aux returns stays in `latency` and is removed + /// only from the main mix. + track_latency: usize, /// Live input for the next block (planar), set by the audio host for input monitoring. pub input: Vec>, /// Transport stopped: only monitored inputs sound (no clips). @@ -363,6 +368,7 @@ impl MixEngine { synced: (0, 0, 0), order: Vec::new(), latency: 0, + track_latency: 0, input: Vec::new(), monitor_only: false, recording: false, @@ -593,6 +599,7 @@ impl MixEngine { let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; let lmax_a = if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; + self.track_latency = lmax_t; self.latency = lmax_t + lmax_a; for (t, st) in work.iter_mut() { let ch = st.buf.len(); @@ -679,6 +686,24 @@ impl MixEngine { self.main_meter.measure(&self.main, frames, 0.0); } + /// Copy one rendered block of `bus` into `out`. A missing bus stays silent. + fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec]) { + for o in out.iter_mut() { + for x in o.iter_mut().take(frames) { + *x = 0.0; + } + } + let Some(src) = self.busses.get(&bus) else { return }; + for (o, c) in out.iter_mut().zip(src.buf.iter()) { + for i in 0..frames { + let v = c.get(i).copied().unwrap_or(0.0); + if let Some(d) = o.get_mut(i) { + *d = if v.is_finite() { v } else { 0.0 }; + } + } + } + } + /// Send, meter and pan a processed strip into the busses / main mix. fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) { self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr); @@ -1640,6 +1665,46 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { out } +/// Offline render of one bus over `range` (planar, the bus format's channel count). +/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline. +/// An unknown bus is silent. +pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> { + let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0); + if nch == 0 { + return Vec::new(); + } + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch); + let latency = { + let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let b = probe.max_block(); + probe.render(s, range.start, b.min(len.max(1)), &mut []); + probe.track_latency + }; + let total = len.saturating_add(latency); + let mut out = vec![vec![0.0f32; total]; nch]; + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let b = eng.max_block(); + let mut tmp = vec![vec![0.0f32; b]; nch]; + let mut done = 0usize; + while done < total { + let n = (total - done).min(b); + eng.render(s, range.start + done as i64, n, &mut []); + eng.copy_bus(bus, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(sr); + } + } + done += n; + } + if latency > 0 { + for c in &mut out { + c.drain(..latency.min(c.len())); + } + } + out +} + /// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped; /// Ambisonics play their W channel). Mono/stereo input is returned unchanged. pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { @@ -1879,6 +1944,20 @@ mod tests { assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]); } + #[test] + fn render_bus_is_the_bus_not_the_main_mix() { + let (mut s, t) = session_with_dc(0.5, 4800); + let stem = s.add_bus("Stem", ChannelFormat::Stereo); + let empty = s.add_bus("Empty", ChannelFormat::Stereo); + s.track_mut(t).unwrap().mixer.output = Route::Bus(stem); + let main = render_range(&s, Range::new(0, 4800), 512); + let bus = render_bus(&s, stem, Range::new(0, 4800), 512); + let silent = render_bus(&s, empty, Range::new(0, 4800), 512); + assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]); + assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]); + assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6))); + } + #[test] fn volume_automation_ramps() { let (mut s, t) = session_with_dc(0.5, 48_000); From fe4f59a85644acfa5881a7a95d660deb46f8eb52 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:46:11 +1100 Subject: [PATCH 07/29] Bake varispeed into rendered clip gain, then stop stretching it again. Clip gain and clip effects renders wrote the clip as it already sounded and left the old ratio in place, so the new file was stretched a second time and its tail went silent. --- crates/engine/src/cmd/clip_more.rs | 65 ++++++++++++++++++++++++++++++ crates/engine/src/io.rs | 3 ++ 2 files changed, 68 insertions(+) diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index cca8102..74dfd8d 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -362,4 +362,69 @@ mod tests { assert_eq!(c.length, 18_000); assert_ne!(c.source(), Some(src)); } + + fn peak_from(ch: &[f32], start: usize) -> f32 { + ch.get(start..).unwrap_or(&[]).iter().fold(0.0f32, |m, x| m.max(x.abs())) + } + + fn max_abs_diff(a: &[Vec], b: &[Vec]) -> f32 { + a.iter().zip(b.iter()).flat_map(|(x, y)| x.iter().zip(y.iter()).map(|(p, q)| (p - q).abs())).fold(0.0f32, f32::max) + } + + /// Gain and effects renders bake the current varispeed. The clip must not keep stretching the result. + #[test] + fn render_preserves_stretched_audio() { + for command in ["clip.gain_render", "clip.effects_render"] { + for ratio in [0.5_f64, 1.0, 2.0] { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600)); + s.edit.selected_clips = vec![id]; + let mut e = Engine::new(s); + e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap(); + e.execute("clip.conform_to_tempo", &json!({"clip": id.0, "source_bpm": 120.0 * ratio})).unwrap(); + if command == "clip.effects_render" { + e.execute("clip.effects_set", &json!({"clip": id.0, "params": {"gain": 3.0}})).unwrap(); + } + let range = e.session().find_clip(id).unwrap().1.range(); + let before = soundcraft_mix::render_clips(e.session(), t, range); + e.execute(command, &json!({"clip": id.0})).unwrap(); + let c = e.session().find_clip(id).unwrap().1.clone(); + assert!((c.stretch - 1.0).abs() < 1e-9, "{command} ratio {ratio} left stretch {}", c.stretch); + assert_eq!(c.gain_db, 0.0); + let after = soundcraft_mix::render_clips(e.session(), t, range); + let diff = max_abs_diff(&before, &after); + assert!(diff < 1e-5, "{command} ratio {ratio} changed the audio by {diff}"); + if ratio < 1.0 { + let tail = before.first().map(|ch| peak_from(ch, ch.len() / 2)).unwrap_or(0.0); + assert!(tail > 0.02, "{command} conformed tail was silent before render: {tail}"); + } + } + } + + // Varispeed on a non-elastic track does not change the clip length. Render still matches it. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600)); + s.edit.selected_clips = vec![id]; + let mut e = Engine::new(s); + e.execute("clip.elastic_properties", &json!({"clip": id.0, "ratio": 0.5})).unwrap(); + e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap(); + let range = e.session().find_clip(id).unwrap().1.range(); + let before = soundcraft_mix::render_clips(e.session(), t, range); + e.execute("clip.gain_render", &json!({"clip": id.0})).unwrap(); + let c = e.session().find_clip(id).unwrap().1.clone(); + assert_eq!(c.length, 9_600); + assert!((c.stretch - 1.0).abs() < 1e-9); + let after = soundcraft_mix::render_clips(e.session(), t, range); + assert!(max_abs_diff(&before, &after) < 1e-5); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..73028a2 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -379,6 +379,9 @@ pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result< cl.content = ClipContent::Audio { source: src, offset: 0 }; cl.gain_db = 0.0; cl.gain_env.clear(); + // The new source is the clip as it already sounds, stretch included. + // Keeping the old ratio would play that audio through the stretch again. + cl.stretch = 1.0; } Ok(true) } From 59721f7c87e3e2c109febcbe46b058ceda8ea0d7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:46:11 +1100 Subject: [PATCH 08/29] Compensate for an aux insert's latency on the same block that measures it. The bounce probe read aux latency before the aux had processed, so a lookahead compressor shifted the export and dropped audio at the end of the range. --- crates/mix/src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..36d8b47 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -587,9 +587,17 @@ impl MixEngine { let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo)); self.busses = busses; - // Delay compensation: align every strip to the slowest; direct-to-main outputs also wait - // for the slowest aux return (aux latencies are from the previous block; they are stable). + // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait + // for the slowest aux return. Aux latency has to be read before those outputs are routed, + // or the first block (and a one-block bounce probe) still sees zero. let pdc = s.edit.delay_compensation; + if pdc { + for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) { + if let Some(st) = self.strips.get_mut(&t.id) { + note_insert_latency(t, st, pos); + } + } + } let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; let lmax_a = if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; @@ -1278,6 +1286,20 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } } +/// Record insert latency without processing audio, so this block can compensate for it. +fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { + let mut latency = 0usize; + for (i, ins) in t.mixer.inserts.iter().enumerate() { + let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue }; + if !ins.active || ins.bypass { + continue; + } + apply_params(slot, ins, t, i, pos); + latency = latency.saturating_add(slot.plugin.latency()); + } + strip.latency = latency; +} + fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { let slot_u8 = u8::try_from(i).unwrap_or(0); let automated = t.mixer.automation_mode.reads() @@ -2057,6 +2079,53 @@ mod tests { assert_ne!(argmax(&out[0]), argmax(&out[1])); } + fn impulse_at(ch: &[f32]) -> Vec { + ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect() + } + + /// An aux insert's lookahead is part of the mix latency on the first block, not the block after. + #[test] + fn delay_compensation_aligns_an_aux_insert() { + let mut s = Session::default(); + let n = 8192; + let mut left = vec![0.0f32; n]; + for at in [0usize, 2048, 8000] { + left[at] = 0.25; + } + s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] }))); + let bus = s.add_bus("Test Bus", ChannelFormat::Stereo); + let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source")); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return")); + let cid = s.new_clip_id(); + s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64)); + s.track_mut(source).unwrap().mixer.output = Route::Bus(bus); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024); + let control = render(&s, 0, n as i64); + assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]); + assert_eq!(impulse_at(&control[1]), impulse_at(&control[0])); + + let mut comp = Insert::new("compressor"); + comp.params.insert("lookahead".into(), 5.0); + comp.params.insert("ratio".into(), 1.0); + s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone()); + let on_source = render(&s, 0, n as i64); + assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0])); + + s.track_mut(source).unwrap().mixer.inserts[0] = None; + s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp); + let on_aux = render(&s, 0, n as i64); + assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce"); + assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0])); + assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]); + let partial = render(&s, 2048, 3072); + assert_eq!(impulse_at(&partial[0]), vec![0]); + + s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0); + let zero = render(&s, 0, n as i64); + assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0])); + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); From 7b25efd8cd3ed936085987ff4240b9b6419bb7ef Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:34:04 +1100 Subject: [PATCH 09/29] Credit Vikram Bhaskaran by real name and display name. The About window can show either name when the viewer picks that label. The GitHub profile name stays unused. --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 5079ae9..bc8bd31 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -35,3 +35,8 @@ sync_github_name = false [people.salignatmoandal] real_name = "Mawen Salignat-Moandal" sync_github_name = true + +[people.vbasky] +real_name = "Vikram Bhaskaran" +display_name = "Vikram Bhaskaran" +sync_github_name = false From 15fea1ed5dc0af1b8ceb39533b083f6af37b2560 Mon Sep 17 00:00:00 2001 From: Mawen Salignat-Moandal Date: Sat, 10 Oct 2026 14:23:08 +0200 Subject: [PATCH 10/29] MCP batch: set isError when any call fails, even with keep_going. Agents that only check isError were treating partial failures as success. Also reject a non-array calls value instead of silently running nothing. Fixes #145. --- crates/automation/src/mcp.rs | 17 +++++++++--- crates/automation/tests/agent_tasks.rs | 36 ++++++++++++++++++++++++++ docs/mcp.md | 2 +- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 1b38dcc..19fe91a 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -127,11 +127,20 @@ impl Server { } "batch" => { let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false); - let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default(); + let Some(calls) = args.get("calls").and_then(Value::as_array) else { + return error_result("`calls` must be an array"); + }; let mut out = Vec::new(); let mut failed = false; for c in calls.iter().take(1000) { - let cmd = c.get("command").and_then(Value::as_str).unwrap_or(""); + let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else { + out.push(json!({"command": "", "ok": false, "error": "`command` required"})); + failed = true; + if !keep { + break; + } + continue; + }; let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({}))); match r { Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})), @@ -144,7 +153,9 @@ impl Server { } } } - if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) } + // Any failure sets isError so agents that only check that flag notice; + // keep_going still runs the rest and the payload lists every call. + if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) } } "inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})), "new_session" => self.exec("session.new", args.clone()), diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 04fe256..3636cfe 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -135,3 +135,39 @@ fn errors_are_reported_not_panics() { let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}})); assert_eq!(r["result"]["isError"], true); } + +#[test] +fn batch_keep_going_still_sets_is_error_on_partial_failure() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc( + &mut s, + 1, + "tools/call", + json!({"name": "batch", "arguments": {"keep_going": true, "calls": [ + {"command": "track.new", "params": {"count": 1, "name": "Kept"}}, + {"command": "mix.volume", "params": {"track": "Nope", "db": -6}}, + {"command": "track.new", "params": {"count": 1, "name": "After"}}, + ]}}), + ); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + let results: Value = serde_json::from_str(text).unwrap(); + let arr = results.as_array().unwrap(); + assert_eq!(arr.len(), 3, "{results}"); + assert_eq!(arr[0]["ok"], true); + assert_eq!(arr[1]["ok"], false); + assert_eq!(arr[2]["ok"], true); + let sess = session(&mut s); + assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept")); + assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After")); +} + +#[test] +fn batch_rejects_non_array_calls() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("`calls` must be an array"), "{text}"); + assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0); +} diff --git a/docs/mcp.md b/docs/mcp.md index 2949871..d3ed9a9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -22,7 +22,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo |---|---| | `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). | | `execute` | Run one command with JSON params. | -| `batch` | Run several commands in order (`keep_going` optional). | +| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. | | `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). | | `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). | | `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. | From 840939faf7fff2ad2056b7579a264a2367452692 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:37:19 +0100 Subject: [PATCH 11/29] Cmd+= only switches Mix/Edit; the scale slider drags smoothly Fixes #144. Unless Options::zoom_with_keyboard is off, egui zooms the whole interface on Cmd+= (or Cmd+Plus), Cmd+- and Cmd+0. It checks for those keys at the end of every frame, after SoundCraft's shortcuts have read them without consuming them. So every Cmd+= that switched between the Mix and Edit windows also zoomed in by another 10 %, and Cmd+0 (Quantize to Grid) reset the interface scale. Ctrl did the same on Windows and Linux. Turn egui's keyboard zoom off. The Interface scale slider in Setup > Appearance is now the only thing that sizes the interface, so it always shows the real scale. Before, keyboard zoom changed the scale without moving the slider, and could go past its 75-150 % range. Dragging that slider rescaled the whole interface on every frame of the drag. Each rescale moved the slider relative to the pointer, which stays put on screen, so the next frame read another value under the pointer. The scale jumped back and forth (105, 110, 105 ... 145, 120, 150, 90 % in one drag) and the slider would not stay where it was dragged. Apply the scale once the mouse button is released. It is now applied after the window has drawn, too, so a click, Reset or an arrow key takes effect in the same frame instead of waiting for the next input. --- crates/ui-egui/src/extra_windows.rs | 5 +-- crates/ui-egui/src/shortcuts.rs | 2 ++ crates/ui-egui/tests/appearance.rs | 47 +++++++++++++++++++++++++++++ crates/ui-egui/tests/shortcuts.rs | 22 +++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/appearance.rs diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs index 71e63fa..e269573 100644 --- a/crates/ui-egui/src/extra_windows.rs +++ b/crates/ui-egui/src/extra_windows.rs @@ -52,15 +52,16 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option { } pub fn show(app: &mut SoundApp, ctx: &egui::Context) { - apply_customization(app, ctx); auto_update(app, ctx); renderer(app, ctx); ui_customization(app, ctx); + apply_customization(app, ctx); } fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) { let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion); - if app.extra.applied == Some(want) { + // Rescaling mid-drag would move the Interface scale slider out from under the pointer. + if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) { return; } // Only touch the zoom once the user has changed it, so platform scaling stays untouched. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 90a4e08..fb978c3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -55,6 +55,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool { } pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { + // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom. + ctx.options_mut(|o| o.zoom_with_keyboard = false); if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() { return; } diff --git a/crates/ui-egui/tests/appearance.rs b/crates/ui-egui/tests/appearance.rs new file mode 100644 index 0000000..87145a8 --- /dev/null +++ b/crates/ui-egui/tests/appearance.rs @@ -0,0 +1,47 @@ +use egui::accesskit::Role; +use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp}; + +const WINDOW_PX: Vec2 = vec2(1600.0, 1000.0); + +fn frame(h: &mut Harness<'_, SoundApp>, px: Pos2, pressed: Option) { + let zoom = h.ctx.zoom_factor(); + h.set_size(WINDOW_PX / zoom); + let pos = (px.to_vec2() / zoom).to_pos2(); + h.input_mut().events.push(Event::PointerMoved(pos)); + if let Some(pressed) = pressed { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.step(); +} + +#[test] +fn interface_scale_slider_follows_the_pointer() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.extra.show_ui_customization = true; + let mut h = Harness::builder().with_size(WINDOW_PX).with_pixels_per_point(1.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let rail = h.get_by_role(Role::Slider).rect(); + let start = pos2(rail.left() + rail.width() / 3.0, rail.center().y); + let end = pos2(rail.right() + 40.0, rail.center().y); + frame(&mut h, start, Some(true)); + let mut scales = vec![]; + for i in 1..=20 { + frame(&mut h, start.lerp(end, i as f32 / 20.0), None); + scales.push(h.state().extra.ui_scale); + } + assert!(scales.windows(2).all(|w| w[0] <= w[1]), "{scales:?}"); + frame(&mut h, end, Some(false)); + frame(&mut h, end, None); + assert_eq!(h.state().extra.ui_scale, 1.5); + assert_eq!(h.ctx.zoom_factor(), 1.5); +} diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index fc7fdc4..5d04d47 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -1,7 +1,7 @@ use egui::os::OperatingSystem; use egui::{Key, Modifiers}; use serde_json::json; -use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts}; 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(); @@ -24,3 +24,23 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}"); } } + +#[test] +fn cmd_equals_toggles_mix_and_edit_without_zooming() { + let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(os); + ctx.set_zoom_factor(1.25); + press(&ctx, &mut app, Key::Equals, cmd); + assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}"); + press(&ctx, &mut app, Key::Equals, cmd); + press(&ctx, &mut app, Key::Num0, cmd); + press(&ctx, &mut app, Key::Minus, cmd); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}"); + assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}"); + } +} From 4aa837e933656f3ab860fc252b6fc83da75b48b7 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:38:23 +0100 Subject: [PATCH 12/29] Make Cmd+H heal and Cmd+Alt+H copy to send on macOS On macOS, Heal Separation (Cmd+H) and Copy to Send (Cmd+Alt+H) never ran (#140). The system menu bar used muda's predefined Hide and Hide Others items, which carry those keys, and macOS offers a key press to the menu bar's key equivalents before the window, so egui never saw them: Cmd+H hid SoundCraft and Cmd+Alt+H hid the other apps. Pro Tools uses the same keys for Heal Separation and Copy Automation to Send, so the keys stay and Hide loses its shortcut instead. Hide SoundCraft and Hide Others are now plain menu items that call NSApplication's hide: and hideOtherApplications:. These are safe objc2-app-kit calls, and both crates were already in the build through muda. Show All keeps muda's item, which has no shortcut. Windows and Linux have no such menu, so nothing changes there. --- Cargo.lock | 2 ++ apps/soundcraft/Cargo.toml | 2 ++ apps/soundcraft/src/native_menu.rs | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b26758..2ea1d8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3409,6 +3409,8 @@ dependencies = [ "egui", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "rfd", "serde_json", "soundcraft-engine", diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index cb9dab1..783c7b5 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -28,3 +28,5 @@ winresource = "0.1" [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.19.2", default-features = false } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] } diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs index 9cc72de..4bd085a 100644 --- a/apps/soundcraft/src/native_menu.rs +++ b/apps/soundcraft/src/native_menu.rs @@ -1,5 +1,7 @@ //! macOS system menu bar. All actions use the same menu dispatch as the egui host. -use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; use soundcraft_ui_egui::{SoundApp, menus}; use std::sync::mpsc::{Receiver, channel}; @@ -36,8 +38,9 @@ impl NativeMenu { app_menu.append(&PredefinedMenuItem::separator())?; app_menu.append(&PredefinedMenuItem::services(None))?; app_menu.append(&PredefinedMenuItem::separator())?; - app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?; - app_menu.append(&PredefinedMenuItem::hide_others(None))?; + // muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send. + app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?; + app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?; app_menu.append(&PredefinedMenuItem::show_all(None))?; app_menu.append(&PredefinedMenuItem::separator())?; native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?; @@ -82,7 +85,12 @@ impl NativeMenu { while let Ok(event) = self.events.try_recv() { // A click changes state (and Cocoa flips the item's check mark): sync on the next pass. self.last_sync = None; - if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) + if (event.id == "hide" || event.id == "hide_others") + && let Some(mtm) = MainThreadMarker::new() + { + let ns = NSApplication::sharedApplication(mtm); + if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) } + } else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) && let Some(command) = &entry.command && menus::item_state(app, &entry.path, Some(command)).0 { From 404940b40dc1be6399b981517828581bcd775880 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:26:05 +1100 Subject: [PATCH 13/29] Import multichannel audio onto a track of the same width. A new track for an imported file was clamped to stereo, so a 5.1 file lost every channel past the left and right. The track now follows the file's channel count. Importing onto an existing track is unchanged. --- crates/engine/src/cmd/file.rs | 25 ++++++++++++++++++++++++- crates/engine/src/io.rs | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..248ddff 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -390,7 +390,6 @@ mod tests { assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}"); let _ = std::fs::remove_dir_all(&dir); } - fn tone_engine() -> (Engine, u64) { let mut e = Engine::default(); e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); @@ -428,4 +427,28 @@ mod tests { assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn import_surround_creates_a_matching_track() { + let channels: Vec> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels }; + let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap(); + let mut e = Engine::default(); + let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap(); + assert_eq!(v["channels"], 6); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51); + let src = e.session().sources[0].id; + let audio = e.session().pool.get(src).unwrap(); + assert_eq!(audio.buffer.channels.len(), 6); + assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3); + + let mut e = Engine::default(); + e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap(); + let id = e.session().tracks[0].id; + crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51); + assert_eq!(e.session().tracks[0].clips().len(), 1); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..26fca8f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -56,7 +56,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option Some(t) => t, None => { let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); - s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem)) + s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem)) } }; let cid = s.new_clip_id(); From 09d64ab2ec34ad777853157aed26b050dc1b9a41 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:26 +1100 Subject: [PATCH 14/29] Export MIDI controllers and honour the tracks filter. file.export_midi copied notes only, so controller, pressure, and pitch-bend events were dropped, and the tracks parameter was ignored. The file now includes those events, and a tracks list limits the export. Naming only non-MIDI tracks is an error instead of exporting every other MIDI track. --- crates/engine/src/cmd/file.rs | 58 ++++++++++++++++++++++++++++++++++- crates/engine/src/io.rs | 17 +++++++--- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..4b189fd 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -98,7 +98,8 @@ pub fn specs() -> Vec { }), cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); - let bytes = crate::io::export_midi(e)?; + let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None }; + let bytes = crate::io::export_midi(e, only.as_deref())?; std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) }), @@ -426,6 +427,61 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + + fn midi_engine() -> Engine { + use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + let mut s = Session::default(); + let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys")); + let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums")); + let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room")); + let mut keys_seq = Sequence::default(); + keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 }); + keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 }); + let mut drum_seq = Sequence::default(); + drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 }); + drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 }); + let a = s.new_clip_id(); + let b = s.new_clip_id(); + crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq)); + crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq)); + Engine::new(s) + } + + fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> { + let smf = soundcraft_midi::read_smf(bytes).unwrap(); + let mut out = Vec::new(); + for t in &smf.tracks { + for c in &t.sequence.ctrls { + if let soundcraft_midi::CtrlKind::Cc(n) = c.kind { + out.push((t.name.clone(), n, c.value)); + } + } + } + out + } + + #[test] + fn midi_export_keeps_controllers_and_honours_tracks() { + let mut e = midi_engine(); + let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let all = dir.join("all.mid"); + e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&all).unwrap()); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}"); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}"); + assert!(ctrls.iter().all(|(n, _, _)| n != "Room")); + + let keys = dir.join("keys.mid"); + e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&keys).unwrap()); + assert_eq!(ctrls.len(), 1, "{ctrls:?}"); + assert_eq!(ctrls[0].0, "Keys"); + + let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err(); + assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..edc29f1 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -180,8 +180,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b Ok(out) } -/// Export all MIDI clips as a format-1 SMF. -pub fn export_midi(e: &Engine) -> Result> { +/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks. +pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { let s = e.session(); let mut smf = soundcraft_midi::Smf { format: 1, @@ -191,16 +191,25 @@ pub fn export_midi(e: &Engine) -> Result> { markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(), key_sigs: Vec::new(), }; - for t in s.tracks.iter().filter(|t| t.kind.is_midi()) { + let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect(); + if only.is_some() && chosen.is_empty() { + return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into())); + } + for t in chosen { let mut seq = soundcraft_midi::Sequence::default(); for c in t.clips() { if let ClipContent::Midi { sequence } = &c.content { let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); for n in &sequence.notes { let mut n = *n; - n.start += base; + n.start = n.start.saturating_add(base); seq.notes.push(n); } + for ev in &sequence.ctrls { + let mut ev = *ev; + ev.tick = ev.tick.saturating_add(base); + seq.ctrls.push(ev); + } } } seq.sort(); From 2afbfd94776ef1789156787b1919852973a361e4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:08 +1100 Subject: [PATCH 15/29] Save a session copy without retargeting the open document. Save Session Copy In wrote through the normal save, which renamed the session, marked it clean, and pointed it at the copy. The open session now stays dirty, named, and path-ed as it was, while the copy is a separate folder. --- crates/engine/src/cmd/file.rs | 30 +++++++++++++++++++++++++++--- crates/engine/src/io.rs | 6 ++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..2d24472 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -27,9 +27,7 @@ pub fn specs() -> Vec { }), cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string(); - let keep = e.path.clone(); - let written = crate::io::save_session(e, &path)?; - e.path = keep; + let written = crate::io::save_session_copy(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| { @@ -426,6 +424,32 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + + fn save_copy_leaves_the_open_session_alone() { + let (mut e, _) = tone_engine(); + let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("Original.scraft"); + e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap(); + e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap(); + assert!(e.is_dirty()); + let copy_dir = dir.join("copy"); + std::fs::create_dir_all(©_dir).unwrap(); + let copy = copy_dir.join("Copy.scraft"); + e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref())); + assert_eq!(e.session().name, "Original"); + assert!(e.is_dirty()); + assert_eq!(e.session().tracks.len(), 2); + + let mut from_original = Engine::default(); + from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap(); + assert_eq!(from_original.session().tracks.len(), 1); + let mut from_copy = Engine::default(); + from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(from_copy.session().tracks.len(), 2); + assert!(from_copy.session().track_by_name("Extra").is_some()); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..583b5a6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -471,6 +471,12 @@ pub fn save_copy(e: &Engine, path: &str) -> Result { write_session(e.session(), path, true).map(|(_, n, _)| n) } +/// File › Save Session Copy In: a normal session folder that does not rename, retarget or +/// clean the session that stays open. +pub fn save_session_copy(e: &Engine, path: &str) -> Result { + write_session(e.session(), path, false).map(|(_, n, _)| n) +} + /// Write `session` to `path`; returns the session with updated media paths, the number of audio /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { From 849cc79675a9ca8edddbe5e7bef15fd25b52c26b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:22:53 +1100 Subject: [PATCH 16/29] Keep each consolidation in its own audio file. A second consolidate of the same track reused Audio Files/{name}.wav, and saving then skipped the rename because that path already belonged to the earlier source. New sources now take a free name, and a save splits sources that already share a path instead of overwriting one file. --- crates/engine/src/cmd/file.rs | 37 +++++++++++++++++++++++++ crates/engine/src/io.rs | 52 ++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..6ebc4c2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -426,6 +426,43 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + + fn tone_engine() -> (Engine, u64) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); + let track = e.session().tracks[0].id; + let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = e.session_mut().new_clip_id(); + crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800)); + e.session_mut().edit.selected_tracks = vec![track]; + (e, track.0) + } + + #[test] + fn repeated_consolidate_keeps_distinct_audio_files() { + let (mut e, track) = tone_engine(); + e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap(); + let cid = e.session().tracks[0].clips()[0].id; + e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap(); + e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap(); + let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect(); + assert_eq!(consolidated.len(), 2, "{consolidated:?}"); + assert_ne!(consolidated[0], consolidated[1]); + + let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Tone.scraft"); + e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut opened = Engine::default(); + opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect(); + assert_eq!(files.len(), 2, "{:?}", opened.session().sources); + let a = std::fs::read(&files[0]).unwrap(); + let b = std::fs::read(&files[1]).unwrap(); + assert_ne!(a, b, "the second consolidation replaced the first file"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..f09fc50 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -11,6 +11,21 @@ use std::sync::Arc; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. const MAX_IMPORT_SAMPLES: usize = 1 << 30; +/// 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); + let mut path = format!("Audio Files/{stem}.wav"); + let mut n = 2u32; + while s.sources.iter().any(|src| src.path == path) { + path = format!("Audio Files/{stem}-{n}.wav"); + n = n.saturating_add(1); + if n > 10_000 { + break; + } + } + path +} + /// Register decoded audio as a new source. Resamples to the session rate if needed. pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId { let sr = s.sample_rate.hz(); @@ -20,10 +35,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio } let id = SourceId(s.alloc()); let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); + let stored = match path { + Some(p) => p.to_string(), + None => unique_audio_rel(s, &stem), + }; s.sources.push(Source { id, name: stem.clone(), - path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string), + path: stored, channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX), frames: buf.frames() as u64, sample_rate: sr, @@ -480,10 +499,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); let mut written = 0; - for src in &mut s.sources { + let shared_paths: Vec = + s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect(); + for (i, src) in s.sources.iter_mut().enumerate() { let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); - if exists_rel && !src.unsaved { + let shared = shared_paths.get(i).copied().unwrap_or(false); + if exists_rel && !src.unsaved && !shared { continue; } let Some(audio) = s.pool.get(src.id) else { continue }; @@ -499,7 +521,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess } } else { let mut k = 1; - while dir.join(&target).exists() && !(exists_rel && target == src.path) { + while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) { target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); k += 1; if k > 999 { @@ -622,6 +644,28 @@ mod save_tests { assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn save_gives_sources_that_share_a_path_their_own_files() { + let mut s = Session::default(); + let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] }; + let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] }; + add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav); + add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav); + let mut e = Engine::new(s); + let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Shared.scraft"); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect(); + assert_eq!(paths.len(), 2); + assert_ne!(paths[0], paths[1], "{paths:?}"); + let a = std::fs::read(dir.join(&paths[0])).unwrap(); + let b = std::fs::read(dir.join(&paths[1])).unwrap(); + assert_ne!(a, b); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] From 592991789e8a5da9b31350b1b8b9fa17222037cb Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:46:49 -0700 Subject: [PATCH 17/29] fix(theme): restore Light Mix Instrument label contrast Use the current populated insert fill and slot outline for Instrument slots. This restores readable Light and System-Light labels while preserving the original Dark colors, hover behavior and picker route. Add a root-UI regression that checks actual emitted colors in four palettes, preserves session/audio data, replaces the built-in instrument, checks parameter defaults and restores the session through undo. Include the authorized SMF match-guard prerequisite and a contract test for first-name-wins and ignored metadata. All 16 focused SMF tests and the complete populated Instrument regression passed. Full CI passed formatting and cleared the SMF lint, then stopped on unchanged metronome and MP3 Clippy expressions. Later CI stages did not run. Full local CI before publication was explicitly waived. Closes #161 --- crates/midi/src/smf.rs | 6 +- crates/midi/tests/smf.rs | 18 ++ crates/ui-egui/src/mix_window.rs | 4 +- .../ui-egui/tests/mix_instrument_contrast.rs | 172 ++++++++++++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/mix_instrument_contrast.rs diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs index 345d3e8..36c18a0 100644 --- a/crates/midi/src/smf.rs +++ b/crates/midi/src/smf.rs @@ -267,10 +267,8 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; match kind { 0x2F => break, // End of Track - 0x03 => { - if name.is_none() { - name = Some(String::from_utf8_lossy(data).into_owned()); - } + 0x03 if name.is_none() => { + name = Some(String::from_utf8_lossy(data).into_owned()); } 0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())), 0x51 => { diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/smf.rs index 5b89e34..f1861d8 100644 --- a/crates/midi/tests/smf.rs +++ b/crates/midi/tests/smf.rs @@ -91,6 +91,24 @@ fn round_trip() { assert_eq!(write_smf(&again).unwrap(), bytes); } +#[test] +fn first_track_name_wins_and_ignored_meta_preserves_following_notes() { + let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat(); + for first in [&b"First"[..], &b""[..], &[0xFF][..]] { + let body = [ + meta(0x03, first), + meta(0x03, b"Second"), + meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored. + vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first)); + assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]); + } +} + #[test] fn write_rejects_bad_values() { let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() }; diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 20591fe..26d0ba9 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -410,12 +410,12 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { let ins = track.instrument.as_ref(); let info = ins.and_then(|i| plugin_info(&i.plugin)); let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click()); - let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg }; + 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, Color32::from_rgb(16, 16, 16)), + Stroke::new(1.0, t.slot_border), StrokeKind::Inside, ); 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); diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs new file mode 100644 index 0000000..fb33610 --- /dev/null +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -0,0 +1,172 @@ +use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode}; + +#[derive(Debug)] +struct Slot { + rect: Rect, + fill: Color32, + text: Color32, + border: Color32, +} + +// Read the rectangle 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 + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::Text(text) if text.galley.job.text == label => Some(text), + _ => None, + }) + .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), + _ => None, + }) + .unwrap_or_else(|| panic!("missing painted slot 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); + assert_eq!(rect.fill.a(), 255); + Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color } +} + +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 populated_mix_instrument_uses_readable_palette_in_real_frames() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + assert_eq!(app.ui.theme, ThemeMode::Dark); + // Keep demo audio loaded for the preservation check while isolating the new strips. + for track in &mut app.engine.session_mut().tracks { + track.hidden = true; + } + app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap(); + let instrument = app.engine.session().edit.selected_tracks[0]; + assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth"); + app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap(); + app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap(); + // An unassigned instrument is a supported model state; track.new normally supplies a synth. + let empty = app.engine.session().edit.selected_tracks[0]; + app.engine.session_mut().track_mut(empty).unwrap().instrument = None; + app.run("window.mix", json!({})).unwrap(); + app.run("view.mix_section", json!({"section": "minimal"})).unwrap(); + for section in ["instruments", "inserts_ae"] { + app.run("view.mix_section", json!({"section": section})).unwrap(); + } + let session = app.engine.session().clone(); + assert!(!session.pool.is_empty(), "the fixture must include decoded audio"); + let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from); + let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0); + if proof_dir.is_some() { + builder = builder.wgpu(); + } + let mut harness = builder.build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + let mut failures = Vec::new(); + for (name, mode, system, light) in [ + ("light", ThemeMode::Light, Some(egui::Theme::Dark), true), + ("dark", ThemeMode::Dark, Some(egui::Theme::Light), false), + ("system-light", ThemeMode::System, Some(egui::Theme::Light), true), + ("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false), + ] { + harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap(); + harness.input_mut().system_theme = system; + harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0))); + for _ in 0..4 { + harness.step(); + } + 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); + assert_eq!(ordinary.text, palette.text); + if ordinary.fill != insert.fill || ordinary.border != insert.border { + failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}")); + } + for hovered in [false, true] { + if hovered { + harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center())); + harness.step(); + harness.step(); + } + let painted = slot(harness.output(), "SubSynth"); + let ratio = contrast(painted.text, painted.fill); + println!( + "{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}", + painted.text.to_array(), + painted.fill.to_array(), + painted.rect + ); + 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)); + } + if let Some(dir) = &proof_dir { + harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap(); + } + } + assert_eq!(harness.state().ui.theme, mode); + assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + + // Exercise the same painted control's ordinary click/picker path after palette changes. + let at = slot(harness.output(), "SubSynth").rect.center(); + for pressed in [true, false] { + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }); + harness.step(); + } + harness.step(); // Let the popup finish its initial sizing pass. + harness.get_by_label_contains("Built-in").hover(); + harness.step(); + harness.step(); + harness.get_by_label("Drum Synth").click(); + harness.step(); + harness.step(); + let app = harness.state(); + let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap(); + assert_eq!(replacement.plugin, "drum_synth"); + for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params { + assert_eq!(replacement.params.get(param.id), Some(¶m.default)); + } + assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks); + assert_eq!(app.engine.session().pool, session.pool); + assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker"); + harness.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(harness.state().engine.session(), &session); +} From 75d3901ab431a61f69c5e5f275308cbbe9fb47c0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 02:03:08 -0400 Subject: [PATCH 18/29] CI: run FreeBSD and Windows ARM64 checks nightly and on release, not per PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock, which is nearly every PR. See craftrules release/playbook.md §2. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/freebsd.yml | 12 ++++++++---- .github/workflows/windows-arm64.yml | 12 +++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 8974daf..9a71640 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -3,16 +3,20 @@ # player falls back to its silent clock when the VM has no sound device. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push +# or dependency change: a drifted dependency still shows up within a day, and always before a +# release ships. PRs that change the FreeBSD packaging itself still run it. on: + schedule: + - cron: "47 3 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - "crates/playback/**" - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh workflow_dispatch: concurrency: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index bafdca6..1dc25eb 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -4,19 +4,17 @@ # out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "53 5 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - .github/workflows/windows-arm64.yml - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up workflow_dispatch: concurrency: From b2a3b0f861a5fec4e2cc34d5eeb1bcc46b9be79a Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:10:06 +0100 Subject: [PATCH 19/29] Keep armed tracks' meters live while the transport is stopped Record-enabled and input-monitored tracks were heard while stopped, but their meters froze (#42). The audio thread returned before copying the meters whenever it wasn't playing. The UI faded every meter to zero while stopped and stopped redrawing once they reached zero. After a selection had played to its end, the stale end point also silenced input monitoring until the next Play. The audio thread now copies the meters for every block it renders and forgets the end point and loop when playback stops. While a track is armed or monitoring input, the UI keeps feeding and redrawing its meters, and the Mix window's per-channel meters include armed tracks. --- crates/playback/src/lib.rs | 38 +++++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 10 +++++++-- crates/ui-egui/src/mix_window.rs | 6 +---- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..a48934d 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -171,6 +171,8 @@ impl AudioState { } Cmd::Stop => { self.playing = false; + self.end = None; + self.looped = None; self.mix.reset(); } Cmd::Locate(at) => { @@ -209,6 +211,7 @@ impl AudioState { } else if let Some(e) = self.end { if self.pos >= e { self.playing = false; + self.end = None; self.shared.playing.store(false, Ordering::Relaxed); n = 0; } else { @@ -239,11 +242,6 @@ impl AudioState { } } self.pending_len = n; - if !self.playing { - return; - } - self.pos += n as i64; - self.shared.position.store(self.pos, Ordering::Relaxed); if let Ok(mut m) = self.shared.meters.try_lock() { m.main.copy_from(&self.mix.main_meter); for (k, v) in &self.mix.meters { @@ -255,6 +253,10 @@ impl AudioState { } } } + if self.playing { + self.pos += n as i64; + self.shared.position.store(self.pos, Ordering::Relaxed); + } } /// Fill an interleaved device buffer. @@ -896,4 +898,30 @@ mod tests { assert!(p <= 1000, "position {p} should stay inside the loop"); assert!(shared.playing.load(Ordering::Relaxed)); } + + #[test] + fn armed_tracks_are_heard_and_metered_after_playback_stops() { + use soundcraft_model::{ChannelFormat, TrackKind}; + let mut s = soundcraft_model::Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.track_mut(t).unwrap().mixer.record_arm = true; + 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); + let ring = record::InputRing::new(1, 48_000); + tx.send(Cmd::Input(Arc::clone(&ring))).unwrap(); + tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap(); + let mut out = vec![0.0f32; 4096]; + st.fill(&mut out, 2); + assert!(!shared.playing.load(Ordering::Relaxed)); + ring.push(&[0.5; 4096]); + st.fill(&mut out, 2); + assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard"); + assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered"); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..72f9c6d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -512,6 +512,12 @@ impl SoundApp { best.map(|(_, p)| p) } + /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard. + fn meters_live(&self) -> bool { + self.engine.transport.playing + || (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)) + } + /// Open the input device once (for recording and input monitoring) and feed the player. fn ensure_input(&mut self) { if self.recorder.is_some() || self.recorder_failed || self.player.is_none() { @@ -749,7 +755,7 @@ impl SoundApp { self.stop_play(); } } - if !self.engine.transport.playing { + if !self.meters_live() { for d in self.meters.values_mut() { feed_meter(d, [0.0, 0.0], 0.0, dt); } @@ -805,7 +811,7 @@ impl SoundApp { { self.ensure_input(); } - if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + if self.meters_live() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { ctx.request_repaint(); } control::collect_screenshots(self, ctx); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 0a6b84e..1173d6b 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -22,11 +22,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); // Per-channel peaks for multichannel meters (read once per frame). - let snap = app - .player - .as_ref() - .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) - .map(|p| p.meters()); + let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters()); egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 1.0; From 7208c610cd2618eaef2e59608e7f438d9c3bbd42 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:14:15 +0100 Subject: [PATCH 20/29] Make Clear All Clip Indicators clear them Track > Clear All Clip Indicators (Alt+C) was a stub: the red clip indicators on the meters stayed lit until another session was opened. The command now clears them on every track's meter and the main meter. --- crates/ui-egui/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72f9c6d..fe62e80 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -329,6 +329,9 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } + if id == "track.clear_clip_indicators" { + self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); + } Ok(v) } Err(e) => { @@ -985,6 +988,16 @@ mod tests { assert!(!app.engine.is_dirty()); } + #[test] + fn clear_all_clip_indicators_clears_every_meter() { + 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.meters.entry(kick).or_default().clip = true; + app.main_meter.clip = true; + app.run("track.clear_clip_indicators", json!({})).unwrap(); + assert!(!app.meters[&kick].clip && !app.main_meter.clip); + } + #[test] fn automation_first_pass_after_start_is_due_without_overflow() { // `last_write_at` starts (and resets) at `i64::MIN`: the previous From 27d934e726e4a8373f7db3ebdd05f806b8378d13 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:09:39 +0200 Subject: [PATCH 21/29] macOS: build the DMG with hdiutil create so the app passes codesign --strict hdiutil makehybrid -hfs put a com.apple.FinderInfo on every file in the image, so the signed app inside the DMG (and any ditto copy of it) failed codesign --verify --deep --strict. Build the image with hdiutil create -srcfolder (retried, since create can hit "Resource busy" on CI), clear build-time xattrs before signing, and verify the app inside the finished DMG and a ditto copy of it with --strict. Ported from storytold/pdfcraft#823 and storytold/lightcraft#959. Fixes #176 Co-Authored-By: Claude Opus 5.5 --- packaging/macos/dmg/README.md | 4 +-- packaging/macos/package.sh | 55 ++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md index 5bbc169..0cacbb7 100644 --- a/packaging/macos/dmg/README.md +++ b/packaging/macos/dmg/README.md @@ -2,8 +2,8 @@ What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the `Applications` link side by side. `package.sh` copies these files into the image; nothing here is -generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no -Finder scripting on CI, nothing extra installed in the signing job). +generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder +scripting on CI, nothing extra installed in the signing job). | File | What | |---|---| diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index a898e3d..8208aac 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -109,6 +109,10 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" +# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing: +# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle. +xattr -cr "$APP" + # Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature). # Today the only nested code is the main executable; frameworks/helpers would be signed here too. sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/SoundCraft" @@ -133,15 +137,52 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (#176). `create` +# attaches a device while it copies, which fails now and then on CI runners ("Resource busy"), so +# it gets a few tries. The volume name has no version: .DS_Store finds the background through an +# alias that includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "SoundCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app users get must still pass --strict: inside the finished image and after a ditto copy out +# of it, so a valid container signature can't hide a damaged app (or a makehybrid-style regression). +MOUNT="$WORK/dmg-check" +COPY="$WORK/dmg-copy" +rm -rf "$MOUNT" "$COPY" +mkdir -p "$MOUNT" "$COPY" +hdiutil_retry attach attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$DMG" >/dev/null +dmg_ok=1 +codesign --verify --strict --deep --verbose=2 "$MOUNT/SoundCraft.app" || dmg_ok=0 +if [ "$dmg_ok" = 1 ]; then + { ditto "$MOUNT/SoundCraft.app" "$COPY/SoundCraft.app" && + codesign --verify --strict --deep --verbose=2 "$COPY/SoundCraft.app"; } || dmg_ok=0 +fi +if [ "$dmg_ok" = 1 ] && [ "$NOTARIZE" = 1 ]; then + spctl --assess --type execute -vvv "$COPY/SoundCraft.app" || dmg_ok=0 +fi +[ "$dmg_ok" = 1 ] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true +hdiutil_retry detach detach "$MOUNT" >/dev/null || dmg_ok=0 +rm -rf "$COPY" +if [ "$dmg_ok" != 1 ]; then + echo "error: SoundCraft.app in $(basename "$DMG") fails strict verification" >&2 + exit 1 +fi if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" From e102ed36567a9d3418b9436e1923494d2e4d22da Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:37:56 +0200 Subject: [PATCH 22/29] Windows: compile DX12 shaders with FXC wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL search path finds. SoundCraft ships none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's indirect-validation pipeline, the device is lost and the app exits before the first frame. Pin the compiler to FXC, which is part of Windows; WGPU_DX12_COMPILER still overrides it. Fixes #185 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++ apps/soundcraft/src/graphics.rs | 56 ++++++++++++++++++++++++++++++--- apps/soundcraft/src/main.rs | 4 +-- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 33d08f1..b297d75 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux and web backend defaults are unchanged. +DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or +`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"` +(or `"auto"`) selects wgpu's DXC handling again for troubleshooting. + ### macOS | Build | File | Notes | diff --git a/apps/soundcraft/src/graphics.rs b/apps/soundcraft/src/graphics.rs index ccd0c45..2a932a6 100644 --- a/apps/soundcraft/src/graphics.rs +++ b/apps/soundcraft/src/graphics.rs @@ -1,10 +1,14 @@ //! Windows graphics instance configuration, applied before wgpu can initialize any driver. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{Backends, Dx12Compiler}, +}; -/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally -/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { +/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit +/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence. +pub fn configure(options: &mut NativeOptions, backend_override: Option, compiler_override: Option) { if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { // eframe's default includes GL. Creating that backend can crash inside AMD's // atio6axx.dll before adapter selection or any Rust error handling runs, so the @@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 // workaround without mutating process environment variables. create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); + // Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships + // none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's + // indirect-validation pipeline and loses the device before the first frame + // (issue #185). FXC uses the compiler that is part of Windows. + create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc); + log::info!( + "Windows graphics: backends={:?}, DX12 compiler={:?}", + create.instance_descriptor.backends, + create.instance_descriptor.backend_options.dx12.shader_compiler + ); } } @@ -21,7 +35,7 @@ mod tests { fn configured_backends(backend_override: Option) -> Backends { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + configure(&mut options, backend_override, None); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; @@ -38,6 +52,38 @@ mod tests { assert_eq!(configured_backends(None), Backends::DX12); } + #[test] + fn windows_default_uses_fxc_instead_of_searching_for_dxc() { + let mut options = NativeOptions::default(); + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // Auto can find an unrelated DXC without its matching DXIL runtime (issue #185). + create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto; + } + configure(&mut options, None, None); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + + #[test] + fn explicit_compiler_override_is_preserved() { + for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] { + let mut options = NativeOptions::default(); + configure(&mut options, None, Some(compiler.clone())); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) { + (Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {} + (Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => { + assert_eq!(actual, expected); + } + (expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"), + } + } + } + #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index a54e7e1..8004acf 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -225,9 +225,9 @@ fn main() -> eframe::Result { if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); } - // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs). #[cfg(target_os = "windows")] - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env()); eframe::run_native( "SoundCraft", options, From e60c41c2c83bbc9879f711db660b52a4edf39194 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 18:03:21 -0400 Subject: [PATCH 23/29] Release workflow: pass the Azure signing secrets so Windows builds are signed Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 084c253..d60e820 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,3 +34,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From eddc846f5137b893bd2c92be45c2ac96d94f101e Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 19:33:37 -0300 Subject: [PATCH 24/29] perf: adapt meter polling and repaint rate --- crates/playback/src/lib.rs | 30 ++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 20 ++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..fd23f1a 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -633,6 +633,21 @@ impl Player { pub fn meters(&self) -> MeterSnapshot { self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone() } + + /// Copy meters into reusable UI storage. Once track/channel counts are stable this performs + /// no allocation, unlike cloning a new snapshot every video frame. + pub fn copy_meters(&self, out: &mut MeterSnapshot) { + let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner); + copy_meter_snapshot(&source, out); + } +} + +fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) { + out.tracks.retain(|id, _| source.tracks.contains_key(id)); + for (id, meter) in &source.tracks { + out.tracks.entry(*id).or_default().copy_from(meter); + } + out.main.copy_from(&source.main); } impl Side { @@ -730,6 +745,21 @@ fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::Stre mod tests { use super::*; + #[test] + fn reusable_meter_snapshot_updates_and_drops_stale_tracks() { + let keep = TrackId(1); + let stale = TrackId(2); + let mut source = MeterSnapshot::default(); + source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 }); + source.main.peaks = vec![0.75, 0.5]; + let mut out = MeterSnapshot::default(); + out.tracks.insert(stale, StripMeter::default()); + copy_meter_snapshot(&source, &mut out); + assert_eq!(out.tracks.len(), 1); + assert_eq!(out.tracks[&keep], source.tracks[&keep]); + assert_eq!(out.main, source.main); + } + #[test] fn audio_state_plays_and_stops_at_end() { let s = Arc::new(soundcraft_model::Session::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..b991aef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -220,6 +220,8 @@ pub struct SoundApp { pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, + /// Reused multichannel snapshot; populated at an adaptive rate by `logic`. + meter_snapshot: soundcraft_playback::MeterSnapshot, pub gesture: Option, pub edit_layout: edit_window::EditLayout, pub midi: midi_editor::MidiEditorState, @@ -247,6 +249,7 @@ pub struct SoundApp { last_write_at: Samples, pub frame_ms: f32, last_frame: Option, + last_meter_poll: f64, /// Movies for the Video window and Video track (decoded in the background). pub video: video_track::VideoPool, } @@ -280,6 +283,7 @@ impl SoundApp { dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), + meter_snapshot: soundcraft_playback::MeterSnapshot::default(), gesture: None, edit_layout: edit_window::EditLayout::default(), midi: midi_editor::MidiEditorState::default(), @@ -302,6 +306,7 @@ impl SoundApp { last_write_at: i64::MIN, frame_ms: 0.0, last_frame: None, + last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), } } @@ -725,7 +730,13 @@ impl SoundApp { if self.engine.transport.playing { self.engine.transport.position = p.position(); } - let snap = p.meters(); + let now = self.last_frame.unwrap_or_default(); + let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 }; + if now - self.last_meter_poll >= meter_interval { + p.copy_meters(&mut self.meter_snapshot); + self.last_meter_poll = now; + } + let snap = &self.meter_snapshot; for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default(); feed_meter(d, m.peak, m.gain_reduction_db, dt); @@ -805,7 +816,12 @@ impl SoundApp { { self.ensure_input(); } - if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + if self.is_playing() { + let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 }; + ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz)); + } else if self.meters.values().any(|m| m.level[0] > 0.0001) { + ctx.request_repaint_after(std::time::Duration::from_millis(80)); + } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() { ctx.request_repaint(); } control::collect_screenshots(self, ctx); From 9850a41f2993c8f2f6181782247994a678458c1f Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 19:36:39 -0300 Subject: [PATCH 25/29] perf: reuse realtime mix buffers --- crates/mix/src/lib.rs | 59 ++++++++++++++++++++++++++++---------- crates/playback/src/lib.rs | 37 +++++++++++++++--------- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ef6b8ef..967b29e 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -308,6 +308,8 @@ pub struct MixEngine { adopt: HashMap<(TrackId, usize), PreparedInstance>, /// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]). retired: Vec>, + /// Ownership workspace reused for parallel strip processing. + work: Vec<(usize, Strip)>, } /// What `sync` needs to fill a slot. @@ -370,6 +372,7 @@ impl MixEngine { external: false, adopt: HashMap::new(), retired: Vec::new(), + work: Vec::new(), } } @@ -568,16 +571,17 @@ impl MixEngine { } let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive); let order = std::mem::take(&mut self.order); - let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); + let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); // Phase 1: independent strips (audio, instrument, MIDI) in parallel. - let mut work: Vec<(&Track, Strip)> = order - .iter() - .filter_map(|&i| s.tracks.get(i)) - .filter(live) - .filter(|t| !is_aux(t)) - .filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st))) - .collect(); + let mut work = std::mem::take(&mut self.work); + work.clear(); + for &i in &order { + let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue }; + if let Some(st) = self.strips.remove(&t.id) { + work.push((i, st)); + } + } for (_, st) in work.iter_mut() { if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); @@ -585,7 +589,11 @@ impl MixEngine { } let busses = std::mem::take(&mut self.busses); let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo)); + run_strips(&mut work, |(i, st)| { + if let Some(t) = s.tracks.get(*i) { + process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo); + } + }); self.busses = busses; // Delay compensation: align every strip to the slowest; direct-to-main outputs also wait // for the slowest aux return (aux latencies are from the previous block; they are stable). @@ -594,7 +602,8 @@ impl MixEngine { let lmax_a = if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; self.latency = lmax_t + lmax_a; - for (t, st) in work.iter_mut() { + for (i, st) in work.iter_mut() { + let Some(t) = s.tracks.get(*i) else { continue }; let ch = st.buf.len(); st.align.set(lmax_t.saturating_sub(st.latency), ch); st.align.process(&mut st.buf, frames); @@ -604,15 +613,20 @@ impl MixEngine { } st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch); } - for (t, st) in work.iter_mut() { - self.route_strip(t, st, pos, frames); + for (i, st) in work.iter_mut() { + if let Some(t) = s.tracks.get(*i) { + self.route_strip(t, st, pos, frames); + } } - for (t, st) in work { - self.strips.insert(t.id, st); + for (i, st) in work.drain(..) { + if let Some(t) = s.tracks.get(i) { + self.strips.insert(t.id, st); + } } + self.work = work; let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); // Phase 2: auxes in dependency order (they read busses). - for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) { + for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) { let Some(mut st) = self.strips.remove(&t.id) else { continue }; if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); @@ -2085,6 +2099,21 @@ mod tests { assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); } + #[test] + fn strip_workspace_keeps_its_allocation_between_blocks() { + let mut s = Session::default(); + for _ in 0..6 { + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + } + let mut eng = MixEngine::new(48_000.0, 512); + let mut out = vec![vec![0.0; 512]; 2]; + eng.render(&s, 0, 512, &mut out); + let capacity = eng.work.capacity(); + assert!(capacity >= 6); + eng.render(&s, 512, 512, &mut out); + assert_eq!(eng.work.capacity(), capacity); + } + #[test] fn stopped_monitoring_does_not_click() { let mut s = Session::default(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..c429f84 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -86,7 +86,6 @@ struct AudioState { playing: bool, end: Option, looped: Option, - block: Vec>, /// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index. pending: Vec>, /// Stereo fold-down gains per main channel (used when the device has fewer channels). @@ -122,7 +121,6 @@ impl AudioState { playing: false, end: None, looped: None, - block: vec![vec![0.0; BLOCK]; nch], pending: vec![vec![0.0; BLOCK]; nch], pending_len: 0, read: 0.0, @@ -151,8 +149,7 @@ impl AudioState { self.mix.adopt(instances); // The main format changed (a document edit, not steady state): resize. let nch = main_channels(&s); - if self.block.len() != nch { - self.block = vec![vec![0.0; BLOCK]; nch]; + if self.pending.len() != nch { self.pending = vec![vec![0.0; BLOCK]; nch]; self.pending_len = 0; self.read = 0.0; @@ -197,8 +194,8 @@ impl AudioState { } /// Render the next block at the session rate into `pending`. - fn render_next(&mut self) { - let mut n = BLOCK; + fn render_next(&mut self, quantum: usize) { + let mut n = quantum.clamp(1, BLOCK); if let Some(l) = self.looped && self.pos >= l.end { @@ -231,13 +228,8 @@ impl AudioState { } self.mix.monitor_only = !self.playing; self.mix.recording = self.recording; - self.mix.render(&self.session, self.pos, n, &mut self.block); + self.mix.render(&self.session, self.pos, n, &mut self.pending); self.send_garbage(); - for (d, s) in self.pending.iter_mut().zip(self.block.iter()) { - if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) { - dd.copy_from_slice(ss); - } - } self.pending_len = n; if !self.playing { return; @@ -272,13 +264,15 @@ impl AudioState { } let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0; let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0); + let callback_frames = out.len() / channels; + let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK); for frame in out.chunks_mut(channels) { while self.read >= self.pending_len as f64 { self.read -= self.pending_len as f64; if !self.playing && !monitoring { break; } - self.render_next(); + self.render_next(quantum); if self.pending_len == 0 { break; } @@ -748,6 +742,23 @@ mod tests { assert!(!shared.playing.load(Ordering::Relaxed)); } + #[test] + fn short_device_callbacks_render_short_mix_blocks() { + let s = Arc::new(soundcraft_model::Session::default()); + 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), s, 48_000.0, channel().0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + let mut out = vec![0.0f32; 2 * 64]; + st.fill(&mut out, 2); + assert_eq!(shared.position.load(Ordering::Relaxed), 64); + } + #[test] fn surround_main_maps_to_device_channels_or_folds_down() { use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; From 113292cc1ae450e4929d00dce6a4dc9b0fab2081 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:43:40 -0400 Subject: [PATCH 26/29] Close the export-clips test the rebase left open Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6ebc4c2..fe00b94 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -426,18 +426,7 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); - - fn tone_engine() -> (Engine, u64) { - let mut e = Engine::default(); - e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); - let track = e.session().tracks[0].id; - let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); - let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; - let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); - let id = e.session_mut().new_clip_id(); - crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800)); - e.session_mut().edit.selected_tracks = vec![track]; - (e, track.0) + let _ = std::fs::remove_dir_all(&dir); } #[test] From 75430d2472b57d369c6ebba40179e11c4e732af3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:43:41 -0400 Subject: [PATCH 27/29] Close the export-clips test the rebase left open Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2d24472..4836209 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -424,7 +424,10 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn save_copy_leaves_the_open_session_alone() { let (mut e, _) = tone_engine(); let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id())); From 55dfcc95d1087001c8ed3d82ac662dfab34a456c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:43:41 -0400 Subject: [PATCH 28/29] Close the export-clips test the rebase left open Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4b189fd..e37600a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -427,6 +427,8 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } fn midi_engine() -> Engine { use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; From e7c31321f37d7f91746fda6189427c9aedce7b5c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:26:29 -0400 Subject: [PATCH 29/29] Volume to clip gain: keep the level outside the selection; keep clip gain points; ramps reach their end - When every lane point lay inside the selection the lane was cleared, so the held level outside fell back to the fader (6 dB louder in the repro). Clear only when all clips lie inside the selection. - The clip's own gain points inside the selection are kept, offset by the lane, instead of being dropped. - Volume/clip-gain ramps now end on the block's last sample, so interpolate over n-1 steps and actually reach the end value. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mix.rs | 35 ++++++++++++++++++++++++++++++++++- crates/mix/src/lib.rs | 4 ++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 37e1a0f..3a01d3d 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -579,6 +579,10 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { let at = rel(pt.at); pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); } + // The clip's own gain points inside the selection keep their shape, offset by the lane. + for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { + pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at)))); + } if sel1 - 1 > sel0 { let at = rel(sel1 - 1); pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); @@ -597,7 +601,10 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { c.gain_env = env; } } - let covers_all = lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); + // Clearing the lane is only safe when no audio plays outside the selection; otherwise the + // held edge values outside would fall back to the fader level. + let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end); + let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); let lane_m = tr.lane_mut(&AutoParam::Volume); if covers_all { lane_m.clear_range(r.start, r.end); @@ -758,4 +765,30 @@ mod tests { assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}"); } } + + #[test] + fn lane_points_all_inside_the_selection_keep_the_outside_level() { + let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB"); + } + } + + #[test] + fn clip_gain_points_inside_the_selection_are_kept() { + let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]); + let clip = e.session().track(t).unwrap().clips()[0].id; + e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)]; + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB"); + } + } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index a0297d3..2f7b75d 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -905,7 +905,7 @@ fn process_strip( for c in strip.buf.iter_mut() { for i in 0..frames { if let Some(x) = c.get_mut(i) { - *x *= lerp_gain(g0, g1, i, frames); + *x *= lerp_gain(g0, g1, i, frames.saturating_sub(1)); } } } @@ -1210,7 +1210,7 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start); let g0 = clip.gain_at(rel0); let g1 = clip.gain_at(rel1.min(clip.length)); - let n = (seg_end - i) as f32; + let n = (seg_end - i).saturating_sub(1).max(1) as f32; for k in i..seg_end { let rel = pos.saturating_add(k as i64).saturating_sub(clip.start); let v = if (stretch - 1.0).abs() < 1e-9 {