diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ba456f6c..67d6e4a9 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 |