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