mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
G3: preserve exported audio sample positions
Co-authored-by: Codex <[email protected]>
This commit is contained in:
1 parent
813c7c4650
commit
34b894a249
8 files changed
+526
-6
No files matched your search
@@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option<FileXform> {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for Impulse {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
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<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| 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<f32> = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect();
|
||||
let impulses: Vec<usize> = 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);
|
||||
}
|
||||
@@ -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<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for RecordingNative {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
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<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| 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);
|
||||
}
|
||||
@@ -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<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for RecordingNative {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
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<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| 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<f32> = 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);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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<Arc<Image>>;
|
||||
/// `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<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user