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
@@ -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()); }@@ -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);+}