collective

Browse / github.com/storytold/soundcraft / patch

Keep bounced stems from overwriting each other on name collisions

Keep bounced stems from overwriting each other on name collisions Found while testing `crates/engine/src/io.rs:658 (bounce_stems_with)` (no existing issue): Bounce Stems overwrites a stem when two track names sanitize to the same file name. ## Cause bounce_stems_with builds each stem path from the sanitized track name and never checks whether that path was already used. Names like `Kick?` and `Kick*` both become `Kick_`, and names like `Kick` and `kick` point to the same file on case-insensitive file systems. The later bounce overwrote the earlier one, and both paths were still reported. ## Fix bounce_stems_with now records each written path, lowercased, a

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #1027, open)
Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195
Size
+54 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
3c770280bdd73cb6d9fc6ee8

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/io.rs crates/engine/tests/it/bounce.rs

diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs--- a/crates/engine/src/io.rs+++ b/crates/engine/src/io.rs@@ -698,6 +698,7 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions,         _ => "wav",     };     let mut out = Vec::new();+    let mut taken = std::collections::HashSet::new();     let stems: Vec<(TrackId, String)> = s         .tracks         .iter()@@ -709,7 +710,15 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions,         for t in &mut solo.tracks {             t.mixer.solo = t.id == id;         }-        let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));+        let stem = format!("{} - {}", sanitize_name(&s.name), sanitize_name(&name));+        let mut path = Path::new(dir).join(format!("{stem}.{ext}"));+        let mut k = 2u32;+        // Compare case-folded: default macOS/Windows file systems are case-insensitive.+        while taken.contains(&path.to_string_lossy().to_lowercase()) {+            path = Path::new(dir).join(format!("{stem}-{k}.{ext}"));+            k = k.saturating_add(1);+        }+        taken.insert(path.to_string_lossy().to_lowercase());         Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?;         out.push(path.to_string_lossy().into_owned());     }diff --git a/crates/engine/tests/it/bounce.rs b/crates/engine/tests/it/bounce.rs--- a/crates/engine/tests/it/bounce.rs+++ b/crates/engine/tests/it/bounce.rs@@ -137,3 +137,47 @@ fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() {     assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);     let _ = std::fs::remove_dir_all(&dir); }++#[test]+fn stems_of_tracks_with_colliding_file_names_do_not_overwrite_each_other() {+    stems_collide(["Kick?", "Kick*"], "bounce-stem-collision");+}++#[test]+fn stems_of_tracks_whose_names_differ_only_in_case_do_not_overwrite_each_other() {+    stems_collide(["Kick", "kick"], "bounce-stem-case");+}++fn stems_collide(new_names: [&str; 2], scratch_name: &str) {+    let mut e = soundcraft_engine::demo::demo_engine();+    let ids: Vec<_> = e+        .session()+        .tracks+        .iter()+        .filter(|t| matches!(t.kind, soundcraft_model::TrackKind::Audio) && !t.inactive && !t.mixer.mute)+        .map(|t| t.id)+        .take(2)+        .collect();+    assert_eq!(ids.len(), 2);+    for (id, name) in ids.iter().zip(new_names) {+        e.execute("track.rename", &serde_json::json!({"track": id.0, "name": name})).unwrap();+    }+    let dir = scratch(scratch_name);+    let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };+    let paths = bounce_stems(&e, &dir.to_string_lossy(), Range::new(0, e.session().sample_rate.samples(1.0)), &opts).unwrap();+    let mut unique = paths.clone();+    unique.sort();+    unique.dedup();+    assert_eq!(unique.len(), paths.len(), "{paths:?}");+    let mut folded: Vec<String> = paths.iter().map(|p| p.to_lowercase()).collect();+    folded.sort();+    folded.dedup();+    assert_eq!(folded.len(), paths.len(), "{paths:?}");+    assert_eq!(std::fs::read_dir(&dir).unwrap().count(), paths.len());+    let names: Vec<String> = paths.iter().filter_map(|p| PathBuf::from(p).file_name().map(|n| n.to_string_lossy().into_owned())).collect();+    if new_names[0] == "Kick?" {+        assert!(names.iter().any(|n| n.ends_with(" - Kick_.wav")), "{names:?}");+        assert!(names.iter().any(|n| n.ends_with(" - Kick_-2.wav")), "{names:?}");+    }+    let _ = std::fs::remove_dir_all(&dir);+}