From 34b894a2492047f4a30458d1bf6585a525c8835a Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:55:50 +1000 Subject: [PATCH] G3: preserve exported audio sample positions Co-authored-by: Codex --- crates/effects/src/ocio.rs | 2 +- crates/export/src/encode.rs | 5 +- crates/export/src/webm.rs | 4 +- crates/export/tests/audio_sample_positions.rs | 125 ++++++++++ .../tests/mov_audio_sample_positions.rs | 220 ++++++++++++++++++ .../tests/native_audio_sample_positions.rs | 169 ++++++++++++++ crates/render/src/audio.rs | 4 +- crates/render/src/lib.rs | 3 +- 8 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 crates/export/tests/audio_sample_positions.rs create mode 100644 crates/export/tests/mov_audio_sample_positions.rs create mode 100644 crates/export/tests/native_audio_sample_positions.rs diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index d00f6e9e..c8fcf848 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option { } let mut m = [0.0; 16]; let mut offset = [0.0; 4]; - for (r, row) in v.chunks_exact(4).enumerate() { + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); offset[r] = row[3] / 65535.0; } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index 67ff2736..098a8c11 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -6,7 +6,7 @@ use std::io::Write; use effectcraft_project::Comp; use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile}; -use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use effectcraft_time::{FrameRate, Tick}; use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; @@ -331,7 +331,8 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St return Ok(()); } let n = (end - *cursor) as usize; - let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64); + let start = Tick::from_units(*cursor, i64::from(sr)); + let start = if start.to_units_floor(i64::from(sr)) < *cursor { Tick(start.0.saturating_add(1)) } else { start }; let buf = mix(job, start, n, sr); *cursor = end; match a { diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index c3c136d1..a12bd4e1 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -344,7 +344,9 @@ pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> R let mut reported = 0u64; while done < n { let k = block.min(n - done); - let t = Tick((((start + done as i64) as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64); + let sample = start + done as i64; + let t = Tick::from_units(sample, i64::from(sr)); + let t = if t.to_units_floor(i64::from(sr)) < sample { Tick(t.0.saturating_add(1)) } else { t }; pcm.extend(pcm_bytes(&mix(job, t, k, sr), fmt, aiff)); done += k; let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames); diff --git a/crates/export/tests/audio_sample_positions.rs b/crates/export/tests/audio_sample_positions.rs new file mode 100644 index 00000000..0fa0deb9 --- /dev/null +++ b/crates/export/tests/audio_sample_positions.rs @@ -0,0 +1,125 @@ +//! Sample positions through the public native WAV exporter, using a tiny recorded impulse. +use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_render::FootageSource; +use effectcraft_time::{FrameRate, Tick}; +use std::sync::{Arc, Mutex}; + +struct Impulse { + first: i64, + last: i64, + calls: Mutex>, +} +impl FootageSource for Impulse { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + let index = start.to_units_floor(i64::from(rate)); + self.calls.lock().unwrap().push((start, index, frames, rate)); + let mut samples = Vec::with_capacity(frames * 2); + for offset in 0..frames { + let sample = index + offset as i64; + let value = if sample == self.first { + 0.75 + } else if sample == self.last { + 0.25 + } else { + 0.0 + }; + samples.extend([value, -value]); + } + Some(samples) + } +} +fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] { + let mut offset = 12; + while offset + 8 <= bytes.len() { + let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize; + if &bytes[offset..offset + 4] == name { + return &bytes[offset + 8..offset + 8 + length]; + } + offset += 8 + length + length % 2; + } + panic!("native WAV chunk missing"); +} +fn check(rate: u32) { + let frame_rate = FrameRate::FPS_24; + let span = (frame_rate.tick_of(1), frame_rate.tick_of(2)); + // Match the exporter's existing floor-to-sample span convention; this proof tests whether + // its selected samples survive subsequent Tick conversions, without changing that convention. + let first = span.0.to_units_floor(i64::from(rate)); + let end = span.1.to_units_floor(i64::from(rate)); + let expected_frames = usize::try_from(end - first).unwrap(); + let mut project = Project::default(); + let duration = Tick::from_seconds_f64(1.0); + let item = project.add_item( + "Impulse", + Default::default(), + None, + ItemKind::Footage(Footage { path: "recorded-sample-impulse.wav".into(), kind: FootageKind::Audio, duration, has_audio: true, ..Default::default() }), + ); + let mut comp = Comp::new(4, 4, frame_rate, duration); + comp.work_area = span; + comp.layers.push(build::layer(&mut project, &comp, "Impulse", LayerSource::Footage { item }, (4, 4), None)); + let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into())); + let settings = RenderSettings::default(); + let mut output = OutputModule::for_format(OutputFormat::Wav); + output.audio_sample_rate = rate; + output.audio_channels = 2; + output.audio_format = AudioFormat::F32; + let provider = Impulse { first, last: end - 1, calls: Mutex::default() }; + let files: Arc)>>> = Default::default(); + let received = files.clone(); + let sink: Box = Box::new(move |name: &str, bytes: Vec| received.lock().unwrap().push((name.into(), bytes))); + let job = Job { + project: &project, + footage: &provider, + expr: None, + accel: None, + comp, + settings: &settings, + output: &output, + path: "impulse.wav", + sink: Some(&*sink), + nested_switches: true, + options: JobOptions::default(), + }; + let report = export(&job, &mut |_| true).unwrap(); + assert!(report.audio, "the production native audio exporter ran"); + let files = files.lock().unwrap(); + let bytes = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1; + assert_eq!(&bytes[..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WAVE"); + let format = chunk(bytes, b"fmt "); + assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3, "float PCM"); + assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), rate); + let pcm = chunk(bytes, b"data"); + assert_eq!(pcm.len(), expected_frames * 8, "the selected work-area sample count is unchanged"); + let samples: Vec = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect(); + let impulses: Vec = samples.as_chunks::<2>().0.iter().enumerate().filter_map(|(i, pair)| (pair[0] != 0.0).then_some(i)).collect(); + let calls = provider.calls.lock().unwrap(); + eprintln!("rate={rate} first={first} end={end} frames={expected_frames} calls={calls:?} exported_impulses={impulses:?}"); + assert!(!calls.is_empty(), "native export actually requested provider samples"); + assert_eq!( + samples[..2], + [0.75, -0.75], + "the first selected source sample must be the first exported sample at {rate} Hz; impulses={impulses:?}, calls={calls:?}" + ); + assert_eq!(samples[samples.len() - 2..], [0.25, -0.25], "the final selected source sample must be preserved at {rate} Hz"); + assert_eq!(impulses, [0, expected_frames - 1], "no shifted or duplicated impulse"); +} +#[test] +fn native_wav_work_area_preserves_44100_sample_positions() { + check(44_100); +} +#[test] +fn native_wav_work_area_preserves_48000_sample_positions() { + check(48_000); +} +#[test] +fn native_wav_work_area_preserves_44117_sample_positions() { + check(44_117); +} diff --git a/crates/export/tests/mov_audio_sample_positions.rs b/crates/export/tests/mov_audio_sample_positions.rs new file mode 100644 index 00000000..c58d2b14 --- /dev/null +++ b/crates/export/tests/mov_audio_sample_positions.rs @@ -0,0 +1,220 @@ +#![cfg(not(target_arch = "wasm32"))] + +//! Tiny native WAV to ProRes PCM MOV sample-position proof. +use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_render::FootageSource; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use std::sync::{Arc, Mutex}; + +struct RecordingNative { + pool: MediaPool, + calls: Mutex>, +} +impl FootageSource for RecordingNative { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate)); + Some(self.pool.audio_samples(footage, start, frames, rate)) + } +} +fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> { + let mut pcm = Vec::with_capacity(native_rate as usize * 8); + for index in 0..i64::from(native_rate) { + let pair = if impulses { + let value: f32 = if index == first { + 0.75 + } else if index == last { + 0.25 + } else { + 0.0 + }; + [value, -value] + } else { + [(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0] + }; + pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes)); + } + let mut wav = b"RIFF".to_vec(); + wav.extend((36 + pcm.len() as u32).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend(3u16.to_le_bytes()); + wav.extend(2u16.to_le_bytes()); + wav.extend(native_rate.to_le_bytes()); + wav.extend((native_rate * 8).to_le_bytes()); + wav.extend(8u16.to_le_bytes()); + wav.extend(32u16.to_le_bytes()); + wav.extend(b"data"); + wav.extend((pcm.len() as u32).to_le_bytes()); + wav.extend(pcm); + wav.into() +} +fn boxes(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let mut out = Vec::new(); + let mut offset = 0usize; + while offset + 8 <= bytes.len() { + let size = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let name = bytes[offset + 4..offset + 8].try_into().unwrap(); + let (size, header) = if size == 1 { + (usize::try_from(u64::from_be_bytes(bytes[offset + 8..offset + 16].try_into().unwrap())).unwrap(), 16) + } else if size == 0 { + (bytes.len() - offset, 8) + } else { + (size as usize, 8) + }; + assert!(size >= header && offset + size <= bytes.len(), "valid returned MOV boxes"); + out.push((name, &bytes[offset + header..offset + size])); + offset += size; + } + assert_eq!(offset, bytes.len()); + out +} +fn child<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] { + boxes(bytes).into_iter().find(|(kind, _)| kind == name).unwrap().1 +} +fn movie_pcm_count(bytes: &[u8], rate: u32, frames: usize) { + let moov = child(bytes, b"moov"); + let mdia = boxes(moov) + .into_iter() + .filter(|(kind, _)| kind == b"trak") + .map(|(_, track)| child(track, b"mdia")) + .find(|media| &child(media, b"hdlr")[8..12] == b"soun") + .expect("the returned MOV has a sound track"); + let header = child(mdia, b"mdhd"); + let (timescale, duration) = if header[0] == 1 { + (u32::from_be_bytes(header[20..24].try_into().unwrap()), u64::from_be_bytes(header[24..32].try_into().unwrap())) + } else { + (u32::from_be_bytes(header[12..16].try_into().unwrap()), u64::from(u32::from_be_bytes(header[16..20].try_into().unwrap()))) + }; + assert_eq!(timescale, rate, "PCM MOV audio timescale"); + assert_eq!(duration, frames as u64, "PCM MOV selected sample count"); + let table = child(child(mdia, b"minf"), b"stbl"); + let timing = child(table, b"stts"); + let count = u32::from_be_bytes(timing[4..8].try_into().unwrap()) as usize; + let duration: u64 = timing[8..] + .as_chunks::<8>() + .0 + .iter() + .take(count) + .map(|entry| u64::from(u32::from_be_bytes(entry[..4].try_into().unwrap())) * u64::from(u32::from_be_bytes(entry[4..].try_into().unwrap()))) + .sum(); + assert_eq!(duration, frames as u64, "all encoded PCM samples are counted"); + let sizes = child(table, b"stsz"); + let fixed = u32::from_be_bytes(sizes[4..8].try_into().unwrap()) as u64; + let count = u32::from_be_bytes(sizes[8..12].try_into().unwrap()) as usize; + let bytes = if fixed != 0 { + fixed * count as u64 + } else { + sizes[12..].as_chunks::<4>().0.iter().take(count).map(|entry| u64::from(u32::from_be_bytes(*entry))).sum() + }; + assert_eq!(bytes, (frames * 8) as u64, "native stereo float PCM bytes"); +} +fn first_sample_tick(index: i64, rate: u32) -> Tick { + let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND); + let denominator = i128::from(rate); + Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64) +} +fn check(native_rate: u32, output_rate: u32) { + let frame_rate = FrameRate::FPS_24; + let span = (frame_rate.tick_of(1), frame_rate.tick_of(2)); + // Keep the production exporter's floor-to-sample span selection. The oracle below requests + // that same first selected integer sample directly from the actual native decoder. + let first = span.0.to_units_floor(i64::from(output_rate)); + let end = span.1.to_units_floor(i64::from(output_rate)); + let frames = usize::try_from(end - first).unwrap(); + let impulses = native_rate == output_rate; + let bytes = native_wav(native_rate, first, end - 1, impulses); + let mut mismatches = Vec::new(); + for loops in [1, 2] { + let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav"); + let mut footage = probe_bytes(&path, bytes.clone()).unwrap(); + assert!(footage.has_audio && !footage.missing); + footage.loop_count = loops; + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes.clone()); + let oracle_tick = first_sample_tick(first, output_rate); + assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first); + let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate); + assert_eq!(expected.len(), frames * 2); + assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent"); + if impulses { + assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse"); + assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse"); + } + let provider = RecordingNative { pool, calls: Mutex::default() }; + let mut project = Project::default(); + let duration = Tick::from_seconds_f64(1.0); + let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage)); + let mut comp = Comp::new(16, 16, frame_rate, duration); + comp.work_area = span; + comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (16, 16), None)); + let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into())); + let settings = RenderSettings::default(); + let mut output = OutputModule::for_format(OutputFormat::ProRes); + output.audio_sample_rate = output_rate; + output.audio_channels = 2; + output.audio_format = AudioFormat::F32; + let files: Arc)>>> = Default::default(); + let received = files.clone(); + let sink: Box = Box::new(move |name: &str, bytes: Vec| received.lock().unwrap().push((name.into(), bytes))); + let job = Job { + project: &project, + footage: &provider, + expr: None, + accel: None, + comp, + settings: &settings, + output: &output, + path: "native-impulse.mov", + sink: Some(&*sink), + nested_switches: true, + options: JobOptions::default(), + }; + let report = export(&job, &mut |_| true).unwrap(); + assert!(report.audio); + let files = files.lock().unwrap(); + let movie = &files.iter().find(|(name, _)| name.ends_with(".mov")).unwrap().1; + assert_eq!(report.frames, 1, "the one-frame ProRes movie was actually encoded"); + movie_pcm_count(movie, output_rate, frames); + let decoded = probe_bytes("returned-native-impulse.mov", Arc::from(movie.as_slice())).unwrap(); + assert!(decoded.has_audio && decoded.has_video, "the native movie decoder recognized both tracks"); + let decoder = MediaPool::new(); + decoder.add_bytes(&decoded.path, Arc::from(movie.as_slice())); + let decoded_pcm = decoder.audio_samples(&decoded, Tick::ZERO, frames + 8, output_rate); + assert_eq!(decoded_pcm.len(), (frames + 8) * 2); + assert!(decoded_pcm[frames * 2..].iter().all(|value| *value == 0.0), "native PCM ends at the selected sample count"); + let actual = decoded_pcm[..frames * 2].to_vec(); + let calls = provider.calls.lock().unwrap(); + assert!(!calls.is_empty(), "production export actually called the real native provider"); + eprintln!( + "native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}", + &actual[..2], + &expected[..2], + &actual[actual.len() - 2..], + &expected[expected.len() - 2..] + ); + if actual != expected { + let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b); + mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec())); + } + } + assert!(mismatches.is_empty(), "production native MOV PCM export must preserve the native provider's selected sample positions: {mismatches:?}"); +} +#[test] +fn native_media_mov_pcm_work_area_preserves_44100_positions() { + check(44_100, 44_100); +} +#[test] +fn native_media_mov_pcm_work_area_preserves_48000_positions() { + check(48_000, 48_000); +} +#[test] +fn native_media_mov_pcm_work_area_preserves_44117_positions() { + check(44_117, 44_117); +} diff --git a/crates/export/tests/native_audio_sample_positions.rs b/crates/export/tests/native_audio_sample_positions.rs new file mode 100644 index 00000000..1b59e8c1 --- /dev/null +++ b/crates/export/tests/native_audio_sample_positions.rs @@ -0,0 +1,169 @@ +#![cfg(not(target_arch = "wasm32"))] + +//! Native MediaPool decoding and native WAV export preserve selected sample positions. +use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings}; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_render::FootageSource; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use std::sync::{Arc, Mutex}; + +struct RecordingNative { + pool: MediaPool, + calls: Mutex>, +} +impl FootageSource for RecordingNative { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate)); + Some(self.pool.audio_samples(footage, start, frames, rate)) + } +} +fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> { + let mut pcm = Vec::with_capacity(native_rate as usize * 8); + for index in 0..i64::from(native_rate) { + let pair = if impulses { + let value: f32 = if index == first { + 0.75 + } else if index == last { + 0.25 + } else { + 0.0 + }; + [value, -value] + } else { + [(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0] + }; + pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes)); + } + let mut wav = b"RIFF".to_vec(); + wav.extend((36 + pcm.len() as u32).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend(3u16.to_le_bytes()); + wav.extend(2u16.to_le_bytes()); + wav.extend(native_rate.to_le_bytes()); + wav.extend((native_rate * 8).to_le_bytes()); + wav.extend(8u16.to_le_bytes()); + wav.extend(32u16.to_le_bytes()); + wav.extend(b"data"); + wav.extend((pcm.len() as u32).to_le_bytes()); + wav.extend(pcm); + wav.into() +} +fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] { + let mut offset = 12; + while offset + 8 <= bytes.len() { + let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize; + if &bytes[offset..offset + 4] == name { + return &bytes[offset + 8..offset + 8 + length]; + } + offset += 8 + length + length % 2; + } + panic!("native WAV chunk missing"); +} +fn first_sample_tick(index: i64, rate: u32) -> Tick { + let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND); + let denominator = i128::from(rate); + Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64) +} +fn check(native_rate: u32, output_rate: u32) { + let frame_rate = FrameRate::FPS_24; + let span = (frame_rate.tick_of(1), frame_rate.tick_of(2)); + // Keep the production exporter's floor-to-sample span selection. The oracle below requests + // that same first selected integer sample directly from the actual native decoder. + let first = span.0.to_units_floor(i64::from(output_rate)); + let end = span.1.to_units_floor(i64::from(output_rate)); + let frames = usize::try_from(end - first).unwrap(); + let impulses = native_rate == output_rate; + let bytes = native_wav(native_rate, first, end - 1, impulses); + let mut mismatches = Vec::new(); + for loops in [1, 2] { + let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav"); + let mut footage = probe_bytes(&path, bytes.clone()).unwrap(); + assert!(footage.has_audio && !footage.missing); + footage.loop_count = loops; + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes.clone()); + let oracle_tick = first_sample_tick(first, output_rate); + assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first); + let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate); + assert_eq!(expected.len(), frames * 2); + assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent"); + if impulses { + assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse"); + assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse"); + } + let provider = RecordingNative { pool, calls: Mutex::default() }; + let mut project = Project::default(); + let duration = Tick::from_seconds_f64(1.0); + let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage)); + let mut comp = Comp::new(4, 4, frame_rate, duration); + comp.work_area = span; + comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (4, 4), None)); + let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into())); + let settings = RenderSettings::default(); + let mut output = OutputModule::for_format(OutputFormat::Wav); + output.audio_sample_rate = output_rate; + output.audio_channels = 2; + output.audio_format = AudioFormat::F32; + let files: Arc)>>> = Default::default(); + let received = files.clone(); + let sink: Box = Box::new(move |name: &str, bytes: Vec| received.lock().unwrap().push((name.into(), bytes))); + let job = Job { + project: &project, + footage: &provider, + expr: None, + accel: None, + comp, + settings: &settings, + output: &output, + path: "native-impulse.wav", + sink: Some(&*sink), + nested_switches: true, + options: JobOptions::default(), + }; + let report = export(&job, &mut |_| true).unwrap(); + assert!(report.audio); + let files = files.lock().unwrap(); + let wav = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1; + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + let format = chunk(wav, b"fmt "); + assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3); + assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), output_rate); + let pcm = chunk(wav, b"data"); + assert_eq!(pcm.len(), frames * 8, "native work-area sample count"); + let actual: Vec = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect(); + let calls = provider.calls.lock().unwrap(); + assert!(!calls.is_empty(), "production export actually called the real native provider"); + eprintln!( + "native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}", + &actual[..2], + &expected[..2], + &actual[actual.len() - 2..], + &expected[expected.len() - 2..] + ); + if actual != expected { + let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b); + mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec())); + } + } + assert!(mismatches.is_empty(), "production native WAV export must preserve the native provider's selected sample positions: {mismatches:?}"); +} +#[test] +fn native_media_wav_work_area_preserves_44100_positions() { + check(44_100, 44_100); +} +#[test] +fn native_media_wav_work_area_preserves_48000_positions() { + check(48_000, 48_000); +} +#[test] +fn native_media_wav_work_area_preserves_44117_positions() { + check(44_117, 44_117); +} diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index abcea9fa..d221ac59 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -85,7 +85,9 @@ fn units_ceil(t: Tick, rate: u32) -> i64 { } fn sample_tick(i: i64, rate: u32) -> Tick { - Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64) + // Keep the requested sample when the rate does not divide the tick clock. + let t = Tick::from_units(i, i64::from(rate)); + if t.to_units_floor(i64::from(rate)) < i { Tick(t.0.saturating_add(1)) } else { t } } struct MixEnv<'a> { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 591213e6..3e077045 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -41,7 +41,8 @@ pub trait FootageSource: Send + Sync { /// The frame of `item` at source time `t`, straight from the file (any size). fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option>; /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at - /// `rate` Hz (`None` when unavailable). + /// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`; + /// a sample boundary uses the earliest representable tick that selects that sample. fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option> { None }