Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)

> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence Extract and Separate) · **Target:** Avid Pro Tools Ultimate 2026.4.1

SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
Expand Down Expand Up @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track

## Progress log

- **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change.
- **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change.
- **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change.
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
Expand Down Expand Up @@ -166,6 +167,7 @@ mutated and truncated movies. Field reports from the first public week are track

| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged |
| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged |
| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged |
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
Expand Down
9 changes: 7 additions & 2 deletions crates/engine/src/cmd/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Strip Silence",
["Edit"],
Some("Cmd+U"),
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20}",
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any)",
has_range,
strip_silence
),
Expand Down Expand Up @@ -796,10 +796,15 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let sr = e.session().sample_rate;
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0));
let mode = match p.get("mode") {
None => crate::io::StripMode::Strip,
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")),
};
let s = e.session_mut();
let mut kept = 0;
for t in &tracks {
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode)?;
}
Ok(json!({"clips": kept}))
}
Expand Down
96 changes: 90 additions & 6 deletions crates/engine/src/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -733,10 +733,36 @@ pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Sample
soundcraft_dsp::offline::detect_transients(&audio, s.sample_rate.as_f64() as f32, sens).into_iter().map(|i| r.start + i as i64).collect()
}

/// Strip Silence: keep only the non-silent ranges of clips inside `r`.
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<usize> {
/// What Strip Silence does with the clips inside the selection once it has found the silence.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StripMode {
/// Remove the silent parts and keep the parts that hold audio.
Strip,
/// Remove the parts that hold audio and keep the silent parts.
Extract,
/// Separate the clips at the boundaries of the silent parts without removing any clip.
Separate,
}

impl StripMode {
pub fn from_id(id: &str) -> Option<Self> {
match id {
"strip" => Some(Self::Strip),
"extract" => Some(Self::Extract),
"separate" => Some(Self::Separate),
_ => None,
}
}
}

/// Strip Silence: separate the clips inside `r` where silence starts and ends, then keep only the
/// non-silent ranges (`Strip`), only the silent ranges (`Extract`), or every piece (`Separate`).
/// `pad` is the clip start and end pad added around each non-silent range. Returns the number of
/// ranges kept: non-silent ranges for `Strip` and `Separate`, silent ranges that still hold a clip for
/// `Extract`.
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pad: (Samples, Samples), mode: StripMode) -> Result<usize> {
let r = bounded_to_track(s, t, r);
let keep = non_silent(s, t, r, thr, min_len, pre, post)?;
let keep = non_silent(s, t, r, thr, min_len, pad.0, pad.1)?;
let mut cut_points = vec![r.start];
for (a, b) in &keep {
cut_points.push(*a);
Expand All @@ -746,7 +772,9 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
for p in &cut_points {
crate::edit::separate_at(s, t, *p);
}
// Remove pieces that fall in the gaps.
if mode == StripMode::Separate {
return Ok(keep.len());
}
let mut gaps = Vec::new();
let mut prev = r.start;
for (a, b) in &keep {
Expand All @@ -758,8 +786,13 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
if prev < r.end {
gaps.push(Range::new(prev, r.end));
}
if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) {
pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end));
let audio: Vec<Range> = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect();
let remove = if mode == StripMode::Extract { &audio } else { &gaps };
let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end;
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return Ok(if mode == StripMode::Extract { 0 } else { keep.len() }) };
pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g)));
if mode == StripMode::Extract {
return Ok(gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count());
}
Ok(keep.len())
}
Expand Down Expand Up @@ -2277,6 +2310,57 @@ mod strip_tests {
assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far));
}

/// A stereo track with one three-second clip: a second of tone, a second of silence, a second of tone.
fn tone_gap_tone() -> (Engine, TrackId) {
let mut e = Engine::default();
e.execute("track.new", &json!({"format": "stereo"})).unwrap();
let t = e.session().tracks[0].id;
let s = e.session_mut();
let mut ch = vec![0.5; 144_000];
ch[48_000..96_000].fill(0.0);
let src = add_source(s, "tone gap tone", AudioBuffer { sample_rate: 48_000, channels: vec![ch.clone(), ch] }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "tone gap tone", src, 0, 0, 144_000));
(e, t)
}

fn strip_mode(mode: &str) -> (Value, Vec<(Samples, Samples)>) {
let (mut e, t) = tone_gap_tone();
let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": mode});
let r = e.execute("edit.strip_silence", &p).unwrap();
(r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect())
}

#[test]
fn strip_keeps_the_audio_and_removes_the_silence() {
let (r, clips) = strip_mode("strip");
assert_eq!(r["clips"], 2);
assert_eq!(clips, vec![(0, 48_000), (96_000, 144_000)]);
}

#[test]
fn extract_keeps_the_silence_and_removes_the_audio() {
let (r, clips) = strip_mode("extract");
assert_eq!(r["clips"], 1);
assert_eq!(clips, vec![(48_000, 96_000)]);
}

#[test]
fn separate_cuts_at_the_silence_and_removes_nothing() {
let (r, clips) = strip_mode("separate");
assert_eq!(r["clips"], 2);
assert_eq!(clips, vec![(0, 48_000), (48_000, 96_000), (96_000, 144_000)]);
}

#[test]
fn an_unknown_strip_mode_is_refused() {
let (mut e, t) = tone_gap_tone();
for mode in [json!("remove"), json!(null), json!(true), json!(1)] {
assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 144_000, "mode": mode})).is_err());
assert_eq!(e.session().track(t).unwrap().clips().len(), 1);
}
}

#[test]
fn more_than_a_day_of_clips_is_refused() {
let (mut e, t) = tones(&[0], 48_000 * 86_401);
Expand Down
66 changes: 59 additions & 7 deletions crates/ui-egui/src/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,15 +555,27 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => {
ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB"));
ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text("Min strip duration ms"));
ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms"));
ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms"));
ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms"));
if buttons(ui, "Strip", enter) {
action = Some((
"edit.strip_silence".into(),
json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}),
));
}
let values = [*threshold, *min_ms, *pre_ms, *post_ms];
ui.separator();
ui.horizontal(|ui| {
let mut mode = None;
if ui.button("Extract").clicked() {
mode = Some("extract");
}
if ui.button("Separate").clicked() {
mode = Some("separate");
}
// Enter on a focused Extract or Separate clicks that button, so Enter presses Strip only when neither was clicked.
if buttons(ui, "Strip", enter) && mode.is_none() {
mode = Some("strip");
}
if let Some(mode) = mode {
action = Some(strip_silence_action(mode, values));
}
});
}
Dialog::Group { id, name, edit, mix, members } => {
ui.horizontal(|ui| {
Expand Down Expand Up @@ -714,6 +726,16 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result<Value, Strin
app.run(cmd, params)
}

/// The command a Strip Silence button runs: `mode` is strip, extract or separate, and `values` holds
/// the threshold in dB, the minimum strip duration and the clip start and end pads in milliseconds.
fn strip_silence_action(mode: &str, values: [f64; 4]) -> (String, Value) {
let [threshold, min_ms, pre_ms, post_ms] = values;
(
"edit.strip_silence".into(),
json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms, "mode": mode}),
)
}

/// Cancel/OK row; returns true when OK was pressed (or Enter).
fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
buttons_enabled(ui, ok, enter, true)
Expand Down Expand Up @@ -850,6 +872,36 @@ mod tests {
assert_eq!(params["bpm"], 133.5);
}

#[test]
fn strip_silence_buttons_send_strip_extract_and_separate() {
let mut e = soundcraft_engine::demo::demo_engine();
let kick = e.session().track_by_name("Kick").unwrap().id;
let end = e.session().content_end();
e.execute("edit.select", &json!({"tracks": [kick.0], "start": 0, "end": end})).unwrap();
let mut d = Dialogs::default();
assert!(d.open_for_command(&e, "edit.strip_silence"));
let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = d.open.take() else {
panic!("Strip Silence did not open its dialog")
};
let values = [threshold, min_ms, pre_ms, post_ms];
let clips = |e: &Engine| e.session().track(kick).unwrap().clips().iter().map(|c| (c.start, c.end())).collect::<Vec<_>>();
let covered = |c: &[(i64, i64)]| c.iter().map(|(a, b)| b - a).sum::<i64>();
let before = clips(&e);
let mut run = |mode: &str| {
let (cmd, params) = strip_silence_action(mode, values);
assert_eq!(params["mode"], mode);
e.execute(&cmd, &params).unwrap();
let after = clips(&e);
e.execute("edit.undo", &json!({})).unwrap();
after
};
let (strip, extract, separate) = (run("strip"), run("extract"), run("separate"));
assert!(separate.len() > before.len(), "Separate cuts the clips");
assert_eq!(covered(&separate), covered(&before), "Separate keeps all the audio");
assert!(!strip.is_empty() && !extract.is_empty());
assert_eq!(covered(&strip) + covered(&extract), covered(&before), "Strip and Extract keep opposite parts");
}

#[test]
fn clear_always_asks_and_names_the_timeline_clips_that_go_too() {
let mut e = soundcraft_engine::demo::demo_engine();
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mod shared_controls;
mod shortcuts;
mod side_lists_access;
mod side_lists_text;
mod strip_silence_dialog;
mod toolbar_scroll;
mod toolbar_tempo;
mod track_height;
Expand Down
91 changes: 91 additions & 0 deletions crates/ui-egui/tests/it/strip_silence_dialog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
use egui::accesskit::Role;
use egui::{Event, Key, Modifiers, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_model::TrackId;
use soundcraft_ui_egui::{Services, SoundApp, menus};

/// Selects the whole Kick track and opens Edit > Strip Silence from the menu.
fn strip_silence_dialog() -> (Harness<'static, SoundApp>, TrackId, Vec<(i64, i64)>) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let track = app.engine.session().track_by_name("Kick").unwrap().id;
let end = app.engine.session().content_end();
app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap();
let before = app.engine.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
menus::invoke_menu(&mut app, "edit.strip_silence", "Edit > Strip Silence");
assert_eq!(app.dialogs.open_name(), Some("strip_silence"));
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
(h, track, before)
}

fn clips(h: &Harness<'_, SoundApp>, track: TrackId) -> Vec<(i64, i64)> {
h.state().engine.session().track(track).map(|t| t.clips().iter().map(|c| (c.start, c.end())).collect()).unwrap_or_default()
}

fn covered(clips: &[(i64, i64)]) -> i64 {
clips.iter().map(|(a, b)| b - a).sum()
}

/// Strip on the same selection, run as a command, for comparison.
fn stripped(track: TrackId) -> Vec<(i64, i64)> {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let end = app.engine.session().content_end();
app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap();
app.run("edit.strip_silence", json!({})).unwrap();
app.engine.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.end())).collect()
}

#[test]
fn strip_silence_extract_keeps_what_strip_removes() {
let (mut h, track, before) = strip_silence_dialog();
h.get_by_role_and_label(Role::Button, "Extract").click();
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
let extracted = clips(&h, track);
let strip = stripped(track);
assert!(!extracted.is_empty() && !strip.is_empty());
assert_eq!(covered(&extracted) + covered(&strip), covered(&before));
}

#[test]
fn strip_silence_separate_cuts_the_clips_and_removes_nothing() {
let (mut h, track, before) = strip_silence_dialog();
h.get_by_role_and_label(Role::Button, "Separate").click();
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
let separated = clips(&h, track);
assert!(separated.len() > before.len());
assert_eq!(covered(&separated), covered(&before));
}

#[test]
fn enter_on_a_focused_separate_separates_and_does_not_strip() {
let (mut h, track, before) = strip_silence_dialog();
h.get_by_role_and_label(Role::Button, "Separate").focus();
h.run_steps(2);
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
let separated = clips(&h, track);
assert!(separated.len() > before.len());
assert_eq!(covered(&separated), covered(&before), "Enter on Separate keeps all the audio");
}

#[test]
fn strip_silence_strip_removes_the_silence() {
let (mut h, track, before) = strip_silence_dialog();
h.get_by_role_and_label(Role::Button, "Strip").click();
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
let strip = clips(&h, track);
assert_eq!(strip, stripped(track));
assert!(covered(&strip) < covered(&before));
}
Loading