From b0793ca8755aeba086ecfc94b86899f9a795b38b Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 23:36:15 -0300 Subject: [PATCH] Stamp WAV bounces with their timecode position (BWF bext) Bounces passed no bwf to the encoder, so mix and stem WAVs had no bext chunk and post tools placed them at zero. Bounce::new now stamps them with TimeReference = the session's timecode start plus the bounce start, in samples since midnight, as other DAWs do. A caller's own bwf wins; AIFF and FLAC have no bext and are unchanged. Fixes #1296. --- crates/engine/src/io.rs | 10 ++++++++++ crates/engine/tests/it/bounce.rs | 32 ++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 60d9a323..c4db15a6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -178,6 +178,13 @@ pub struct Bounce { enc: Option>, } +/// The Broadcast Wave stamp of timeline position `at`: the session's timecode start plus `at`, in +/// samples since midnight (`bext` TimeReference; 0 before midnight). +fn time_stamp(s: &Session, at: Samples) -> soundcraft_audio_io::BwfInfo { + let time_reference = u64::try_from(s.timecode_start.saturating_add(at)).unwrap_or(0); + soundcraft_audio_io::BwfInfo { time_reference, ..Default::default() } +} + /// A finished bounce: the size of its file and its levels. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Bounced { @@ -208,6 +215,9 @@ impl Bounce { let part = path.with_file_name(format!("{name}.part")); crate::file_access::check_write(path).map_err(EngineError::Io)?; crate::file_access::check_write(&part).map_err(EngineError::Io)?; + // A WAV bounce says where it starts on the session's timecode, as other DAWs' bounces do, + // so post and broadcast tools can place it (only WAV has a `bext` chunk to say it in). + let opts = &EncodeOptions { bwf: opts.bwf.clone().or_else(|| Some(time_stamp(&s, r.start))), ..opts.clone() }; let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> { let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?; enc.check_room(u64::try_from(r.len()).unwrap_or(0))?; diff --git a/crates/engine/tests/it/bounce.rs b/crates/engine/tests/it/bounce.rs index 36630319..9460cea6 100644 --- a/crates/engine/tests/it/bounce.rs +++ b/crates/engine/tests/it/bounce.rs @@ -123,6 +123,38 @@ fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() { let _ = std::fs::remove_dir_all(&dir); } +/// A WAV bounce is a Broadcast Wave stamped with where it starts on the session's timecode (`bext` +/// TimeReference, samples since midnight), as other DAWs write it, so post tools can place it. +#[test] +fn wav_bounces_carry_their_timecode_position_as_a_bwf_time_reference() { + let mut e = soundcraft_engine::demo::demo_engine(); + let sr = u64::from(e.session().sample_rate.hz()); + // The session starts at 01:00:00:00; the bounce at 2 s into it. + e.session_mut().timecode_start = (3600 * sr) as i64; + let (start, end) = (2 * sr, 3 * sr); + let dir = scratch("bounce-bext"); + let stamp = |path: &std::path::Path| { + let ext = path.extension().and_then(|x| x.to_str()); + soundcraft_audio_io::probe(&std::fs::read(path).unwrap(), ext).unwrap().bwf.map(|b| b.time_reference) + }; + let mix = dir.join("Mix.wav"); + e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap(); + assert_eq!(stamp(&mix), Some(3600 * sr + start), "mix"); + let stems = dir.join("stems"); + let files = e.execute("file.bounce_stems", &serde_json::json!({"dir": stems, "start": start, "end": end})).unwrap(); + let files = files["files"].as_array().unwrap(); + assert!(!files.is_empty()); + for f in files { + let f = std::path::PathBuf::from(f.as_str().unwrap()); + assert_eq!(stamp(&f), Some(3600 * sr + start), "{}", f.display()); + } + // A session that starts at zero still says where the bounce starts. + e.session_mut().timecode_start = 0; + e.execute("file.bounce_mix", &serde_json::json!({"path": mix, "start": start, "end": end})).unwrap(); + assert_eq!(stamp(&mix), Some(start)); + 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(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4d9009be..6f9a7b9d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-02 · **Change:** trivial (bounces carry a BWF time reference, #1296; previously trivial: Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its @@ -33,7 +33,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Format | Pro Tools | Read | Write | Tests / notes | |---|---|---|---|---| -| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). | +| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). WAV bounces (mix and stems) carry a `bext` TimeReference: the session's timecode start plus the bounce start (#1296). | | RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` | | WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) | | AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native; `fl32` retains finite headroom (#791) | @@ -80,6 +80,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | WAV bounces (mix and stems) write a `bext` TimeReference: session timecode start plus bounce start (#1296) | | 2026-10-19 | trivial | WAV and AIFF-C Float32 encoders retain finite headroom (#791); whole-file, streaming and bounce regression tests | | 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) | | 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged |