diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index afe1bd39..4c00cf36 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -94,9 +94,14 @@ pub fn specs() -> Vec { let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string(); let r = range_param(e, "file.bounce_stems", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; - let format = str_param(p, "format") - .and_then(soundcraft_audio_io::FileFormat::from_write_extension) - .unwrap_or(soundcraft_audio_io::FileFormat::Wav); + let format_name = match p.get("format") { + None => "wav", + Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?, + }; + let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| { + let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", "); + bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})")) + })?; let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() { Some("16") => soundcraft_audio_io::BitDepth::Int16, Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32, diff --git a/crates/engine/src/cmd/file/tests.rs b/crates/engine/src/cmd/file/tests.rs index b8c5be94..a2b709b2 100644 --- a/crates/engine/src/cmd/file/tests.rs +++ b/crates/engine/src/cmd/file/tests.rs @@ -118,6 +118,71 @@ fn import_audio_honours_new_tracks() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn bounce_stems_rejects_unsupported_formats_before_writing() { + let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir(&dir).unwrap(); + let stems = dir.join("stems"); + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); + let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1})); + let created_output = stems.exists(); + // Own this directory exclusively; remove any baseline output before the assertion can fail. + std::fs::remove_dir_all(&dir).unwrap(); + let err = result.map(|_| ()).unwrap_err(); + assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}"); + assert!(err.to_string().contains("supported: wav"), "{err}"); + assert!(!created_output, "an unsupported format must not create the output directory"); +} + +#[test] +fn bounce_stems_rejects_non_string_formats_before_writing() { + let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir(&dir).unwrap(); + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); + let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})] + .into_iter() + .enumerate() + .map(|(index, format)| { + let stems = dir.join(index.to_string()); + let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1})); + (result, stems.exists()) + }) + .collect(); + std::fs::remove_dir_all(&dir).unwrap(); + for (result, created_output) in results { + let err = result.map(|_| ()).unwrap_err(); + assert!(err.to_string().contains("`format` must be a string"), "{err}"); + assert!(!created_output, "a non-string format must not create the output directory"); + } +} + +#[test] +fn bounce_stems_accepts_explicit_wav_format() { + let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir(&dir).unwrap(); + let mut e = Engine::default(); + let wav = tone_wav(&dir, "tone.wav"); + e.execute("file.import_audio", &json!({"path": wav})).unwrap(); + let stems = dir.join("stems"); + let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap(); + let files = result["files"].as_array().unwrap(); + assert_eq!(files.len(), 1); + let path = std::path::Path::new(files[0].as_str().unwrap()); + assert_eq!(path.parent(), Some(stems.as_path())); + assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav")); + let (info, audio) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap(); + assert_eq!(info.format, soundcraft_audio_io::FileFormat::Wav); + assert_eq!(audio.frames(), 32); + assert_eq!(audio.num_channels(), e.session().main_format().channels()); + assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01)); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn bounce_refuses_formats_it_cannot_write() { let mut e = Engine::default();