From 86b6a8b6b9b4846e4070241ed947887956dfe15a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 14:20:47 +0700 Subject: [PATCH] Media: conformed audio keeps its sound past 12:36 (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conforming a footage file's audio (Settings ▸ Disk ▸ Conformed Audio Folder, which preview playback reads) timed each 65,536-frame chunk as `frame index × ticks per second / rate` in i64, which overflows past 36,310,516 frames: 12:36.47 at 48 kHz (25:13 at 24 kHz, hence the workaround). Chunks after the overflow decoded at a wrapped, negative time and came out silent, so preview audio stopped at 12:37.76 (the end of the last chunk that started in time) in every long file. Chunk times now come from Tick::from_units (exact, 128-bit). The conformed files' names include a format version, so files written by earlier builds are ignored and conformed again. Test: conform_chunks_keep_their_times_on_long_files (every chunk start of a 2-hour file at 24–96 kHz); it failed before (multiply overflow). Checked end to end: a 13-minute file's conformed audio has sound at 760 s and 790 s. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 48db82d8e..46f379ea7 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -30,6 +30,16 @@ struct Entry { bytes: usize, } +/// Conformed audio files written by builds before this version are ignored (and written again): +/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172). +const CONFORM_VERSION: u32 = 2; + +/// The source time of sample frame `at` at `rate` Hz. (`at` × ticks per second overflows `i64` +/// past about 12:36 at 48 kHz.) +fn sample_time(at: usize, rate: u32) -> Tick { + Tick::from_units(i64::try_from(at).unwrap_or(i64::MAX), i64::from(rate)) +} + /// At most this many recycled pixel buffers are kept (outside the budget). const MAX_SPARE: usize = 4; @@ -277,13 +287,13 @@ impl MediaPool { } /// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and - /// modification time, so an edited file conforms again). + /// modification time, so an edited file conforms again, and of [`CONFORM_VERSION`]). pub fn conformed_path(&self, path: &str, rate: u32) -> Option { use std::hash::{Hash, Hasher}; let folder = lock(&self.inner.conform).clone()?; let meta = std::fs::metadata(path).ok()?; let mut h = std::collections::hash_map::DefaultHasher::new(); - (path, meta.len(), meta.modified().ok()).hash(&mut h); + (CONFORM_VERSION, path, meta.len(), meta.modified().ok()).hash(&mut h); Some(folder.join(format!("{:016x}_{rate}.ecaf", h.finish()))) } @@ -320,7 +330,7 @@ impl MediaPool { let mut at = 0usize; while at < frames { let n = (frames - at).min(1 << 16); - let t = Tick(at as i64 * TICKS_PER_SECOND / rate as i64); + let t = sample_time(at, rate); for v in pool.decode_audio(&f, t, n, rate) { bytes.extend_from_slice(&v.to_le_bytes()); } @@ -661,6 +671,24 @@ fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { mod tests { use super::*; + /// Conforming a long file's audio reads every chunk at its own time: the sample index times + /// ticks per second overflowed `i64` past 12:36 at 48 kHz, and later chunks came out silent + /// (#172). + #[test] + fn conform_chunks_keep_their_times_on_long_files() { + for rate in [24_000u32, 44_100, 48_000, 96_000] { + let mut last = Tick(-1); + // Every chunk start of a 2-hour file. + for at in (0..rate as usize * 7200).step_by(1 << 16) { + let t = sample_time(at, rate); + assert!(t > last, "{rate} Hz, frame {at}: {t:?} after {last:?}"); + assert!((t.seconds() - at as f64 / rate as f64).abs() < 1e-6, "{rate} Hz, frame {at}"); + last = t; + } + } + assert!((sample_time(36_372_480, 48_000).seconds() - 757.76).abs() < 1e-9); + } + #[test] fn frame_index_loops_and_clamps() { let r = FrameRate::FPS_30;