mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-26 19:46:47 -06:00
Bounce Mix rendered the whole file inside one UI frame: the window stopped drawing and nothing could cancel the bounce until the file was written, which takes minutes for long or plugin-heavy sessions. - engine: io::Bounce renders a slice at a time (step) and reports its progress; dropping an unfinished one cancels it, removing the partial file and keeping any older one. cmd::bounce_job builds it from file.bounce_mix's params; the command and stems still run it to completion. - ui: the Bounce dialog starts the job and logic() renders it for 30 ms per call (32 blocks in the browser, where Instant panics), requesting another call each time, so it carries on while the window is hidden (eframe then runs logic alone). A modal shows the file, a progress bar and Cancel (or Esc); the status line reports the result, and the Task Manager lists the running bounce. - It stays on the UI thread because hosted plugins must be created there.
88 lines
4.2 KiB
Rust
88 lines
4.2 KiB
Rust
//! Bounces stream the main mix or a bus to their file, exactly as rendered in memory.
|
|
|
|
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
|
|
use soundcraft_engine::io::{Bounce, bounce_stems};
|
|
use soundcraft_time::Range;
|
|
use std::path::PathBuf;
|
|
|
|
fn scratch(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn bounces_write_exactly_the_mix_and_report_its_levels() {
|
|
let e = soundcraft_engine::demo::demo_engine();
|
|
let s = e.session();
|
|
let dir = scratch("bounce-exact");
|
|
let path = dir.join("Mix.wav");
|
|
let r = Range::new(s.sample_rate.samples(10.0) + 7, s.sample_rate.samples(13.0));
|
|
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
|
let verb = s.bus_by_name("Verb").unwrap().id;
|
|
for bus in [None, Some(verb)] {
|
|
let mut mix = match bus {
|
|
Some(b) => soundcraft_mix::render_bus(s, b, r, 1024),
|
|
None => soundcraft_mix::render_range(s, r, 1024),
|
|
};
|
|
assert!(mix.iter().flatten().any(|v| v.abs() > 1e-3), "{bus:?}");
|
|
for normalize in [false, true] {
|
|
let mut bounce = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap();
|
|
let mut progress = vec![bounce.progress()];
|
|
let done = loop {
|
|
if let Some(done) = bounce.step(|| false).unwrap() {
|
|
break done;
|
|
}
|
|
progress.push(bounce.progress());
|
|
};
|
|
let last = progress[progress.len() - 1];
|
|
assert!(progress.len() > 100 && progress.is_sorted() && progress[0] < 0.01 && last > 0.99 && last <= 1.0, "{progress:?}");
|
|
if normalize {
|
|
soundcraft_dsp::offline::normalize(&mut mix, -0.1, false);
|
|
}
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap();
|
|
assert_eq!(file.channels, mix, "{bus:?} normalize: {normalize}");
|
|
let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, mix.len());
|
|
lm.process(&mix, mix[0].len());
|
|
let levels = (soundcraft_dsp::offline::peak_db(&mix), lm.integrated_lufs(), lm.true_peak_db());
|
|
assert_eq!((done.bytes, (done.peak_db, done.lufs, done.true_peak_db)), (bytes.len() as u64, levels));
|
|
}
|
|
}
|
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() {
|
|
let e = soundcraft_engine::demo::demo_engine();
|
|
let s = e.session();
|
|
let dir = scratch("bounce-too-big");
|
|
let path = dir.join("Mix.aif");
|
|
std::fs::write(&path, b"old").unwrap();
|
|
let opts = EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
|
let err = Bounce::new(e.session_arc(), &path, Range::new(0, s.sample_rate.samples(3.9 * 3600.0)), &opts, None, false, false).err().unwrap();
|
|
assert!(err.to_string().contains("4 GiB"), "{err}");
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"old");
|
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
|
let err = bounce_stems(&e, &dir.to_string_lossy(), Range::new(0, s.sample_rate.samples(4.1 * 3600.0)), &opts).unwrap_err();
|
|
assert!(err.to_string().contains("4 hours"), "{err}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() {
|
|
let e = soundcraft_engine::demo::demo_engine();
|
|
let dir = scratch("bounce-cancel");
|
|
let path = dir.join("Mix.wav");
|
|
std::fs::write(&path, b"old").unwrap();
|
|
let mut bounce = Bounce::new(e.session_arc(), &path, Range::new(0, 48_000), &EncodeOptions::default(), None, false, false).unwrap();
|
|
assert_eq!(bounce.step(|| false).unwrap(), None);
|
|
assert!(dir.join("Mix.wav.part").exists());
|
|
drop(bounce);
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"old");
|
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|