From c4a88ee64a1f459db35181e082273eb6ed310c38 Mon Sep 17 00:00:00 2001
From: Vikram Bhaskaran
Date: Sat, 10 Oct 2026 16:23:43 +1100
Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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
+
+Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
diff --git a/ROADMAP.md b/ROADMAP.md
index a89da3f..14ef908 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -61,6 +61,10 @@ mutated and truncated movies.
## Current focus
+Common toolbar icon buttons and track record-enable buttons use shared `craft-ui` interaction,
+accessible labels and keyboard focus, retaining SoundCraft's blue tool-selection and red record-arm
+states. The specialized transport buttons, faders, meters and saved session model remain local.
+
1. The alpha list above.
2. Surround-pan automation; AU editor views.
3. Built-in plugin depth (more processors, better UIs).
@@ -76,3 +80,5 @@ mutated and truncated movies.
- **M9 (in progress):** AudioSuite breadth (done), Elastic Audio, Beat Detective.
- **M10 (next):** alpha release on all platforms.
- **M11 (mostly done):** third-party plugin hosting (CLAP, VST3, AU), video, surround.
+
+- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. No milestone percentage change.
diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs
index 2ba9112..b127f19 100644
--- a/apps/soundcraft-cli/src/main.rs
+++ b/apps/soundcraft-cli/src/main.rs
@@ -299,7 +299,7 @@ fn app(args: &[String]) -> ExitCode {
let positional: Vec<&String> = args
.iter()
.enumerate()
- .filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))
+ .filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))))
.map(|(_, a)| a)
.collect();
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs
new file mode 100644
index 0000000..71abd34
--- /dev/null
+++ b/apps/soundcraft/src/config.rs
@@ -0,0 +1,192 @@
+//! Per-user settings and automation profile directories.
+
+use std::ffi::OsString;
+use std::path::PathBuf;
+
+#[derive(Clone, Copy)]
+enum Platform {
+ Mac,
+ Windows,
+ Other,
+}
+
+/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
+/// The override is the complete directory; no application-name suffix is appended. An explicitly
+/// empty value disables the settings directory instead of falling back to a personal profile.
+pub fn config_dir() -> Option {
+ let platform = if cfg!(target_os = "macos") {
+ Platform::Mac
+ } else if cfg!(windows) {
+ Platform::Windows
+ } else {
+ Platform::Other
+ };
+ config_dir_with(platform, |name| std::env::var_os(name))
+}
+
+fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option {
+ if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
+ return (!dir.is_empty()).then(|| PathBuf::from(dir));
+ }
+ match platform {
+ Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
+ Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
+ Platform::Other => {
+ env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("soundcraft"))
+ }
+ }
+}
+
+/// Read a bounded native settings file and reject invalid docking trees before using them.
+pub fn load_ui(path: &std::path::Path) -> Result