simple_editor\media/
waveform.rs

1//! Audio waveform peaks per (file, audio stream): computed once on a background thread (decoding the
2//! whole stream through an AudioSource), cached in memory and on disk under Settings::cache_dir()
3//! (key = hash of path + file size + mtime + stream). `PEAKS_PER_SEC` buckets of mono (min, max).
4
5use super::{AudioSource, Backend, SAMPLE_RATE};
6use crate::settings::Settings;
7use std::collections::{HashMap, HashSet};
8use std::hash::{Hash, Hasher};
9use std::sync::{Arc, Mutex};
10
11pub const PEAKS_PER_SEC: u32 = 100;
12
13pub struct Peaks {
14    /// Per bucket: min sample in [-1,1].
15    pub min: Vec<f32>,
16    /// Per bucket: max sample in [-1,1].
17    pub max: Vec<f32>,
18}
19
20impl Peaks {
21    pub fn len(&self) -> usize {
22        self.min.len()
23    }
24    pub fn is_empty(&self) -> bool {
25        self.min.is_empty()
26    }
27    /// (min, max) over source time range [a, b) seconds.
28    pub fn range(&self, a: f64, b: f64) -> (f32, f32) {
29        let i0 = ((a * PEAKS_PER_SEC as f64).floor().max(0.0)) as usize;
30        let i1 = ((b * PEAKS_PER_SEC as f64).ceil().max(0.0)) as usize;
31        let i1 = i1.min(self.len()).max(i0 + 1);
32        if i0 >= self.len() {
33            return (0.0, 0.0);
34        }
35        let (mut lo, mut hi) = (f32::MAX, f32::MIN);
36        for i in i0..i1 {
37            lo = lo.min(self.min[i]);
38            hi = hi.max(self.max[i]);
39        }
40        (lo, hi)
41    }
42}
43
44#[derive(Default)]
45struct State {
46    /// Finished (or failed → empty) peaks, keyed by hash of (path, stream).
47    ready: HashMap<u64, Arc<Peaks>>,
48    pending: HashSet<u64>,
49}
50
51pub struct WaveformCache {
52    backend: Backend,
53    ctx: eframe::egui::Context,
54    state: Arc<Mutex<State>>,
55}
56
57/// ponytail: 64-bit hash as the map key so the per-frame lookup allocates nothing — collisions are astronomically unlikely.
58fn mem_key(path: &str, stream: usize) -> u64 {
59    let mut h = std::collections::hash_map::DefaultHasher::new();
60    (path, stream).hash(&mut h);
61    h.finish()
62}
63
64impl WaveformCache {
65    pub fn new(ctx: eframe::egui::Context, backend: Backend) -> Self {
66        Self { backend, ctx, state: Arc::default() }
67    }
68    pub fn set_backend(&mut self, b: Backend) {
69        self.backend = b;
70    }
71    /// Forget all in-memory peaks (a file at a known path was rewritten, e.g. Save over the original).
72    /// Swaps the state so an in-flight computation finishes into the orphaned map instead of re-inserting
73    /// stale peaks; unchanged files just reload from the len+mtime-keyed disk cache.
74    pub fn clear(&mut self) {
75        self.state = Arc::default();
76    }
77    /// Non-blocking. Returns the peaks if available; otherwise starts computing them in the background
78    /// (once) and returns None. Calls `ctx.request_repaint()` when a computation finishes.
79    pub fn get(&mut self, path: &str, stream: usize) -> Option<Arc<Peaks>> {
80        let key = mem_key(path, stream);
81        let mut st = self.state.lock().ok()?;
82        if let Some(p) = st.ready.get(&key) {
83            return Some(p.clone());
84        }
85        if st.pending.insert(key) {
86            let (state, ctx, backend, path) = (self.state.clone(), self.ctx.clone(), self.backend, path.to_string());
87            std::thread::spawn(move || {
88                // ponytail: a decoder panic must leave the key resolved (as empty peaks, same as a file
89                // that won't open) — otherwise it stays pending forever and callers wait for good.
90                let peaks =
91                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load_or_compute(&path, stream, backend)))
92                        .unwrap_or_else(|_| Peaks { min: Vec::new(), max: Vec::new() });
93                if let Ok(mut st) = state.lock() {
94                    st.pending.remove(&key);
95                    st.ready.insert(key, Arc::new(peaks));
96                }
97                ctx.request_repaint();
98            });
99        }
100        None
101    }
102}
103
104fn cache_file(path: &str, stream: usize) -> Option<std::path::PathBuf> {
105    let meta = std::fs::metadata(path).ok()?;
106    let mtime = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
107    let mut h = std::collections::hash_map::DefaultHasher::new();
108    (path, meta.len(), mtime, stream).hash(&mut h);
109    Some(Settings::cache_dir().join(format!("{:016x}.peaks", h.finish())))
110}
111
112/// Disk cache first; else decode the whole stream (empty Peaks when the stream can't be opened).
113fn load_or_compute(path: &str, stream: usize, backend: Backend) -> Peaks {
114    let file = cache_file(path, stream);
115    if let Some(bytes) = file.as_ref().and_then(|f| std::fs::read(f).ok()) {
116        if !bytes.is_empty() && bytes.len() % 8 == 0 {
117            let mut p = Peaks { min: Vec::with_capacity(bytes.len() / 8), max: Vec::with_capacity(bytes.len() / 8) };
118            for c in bytes.chunks_exact(8) {
119                p.min.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
120                p.max.push(f32::from_le_bytes([c[4], c[5], c[6], c[7]]));
121            }
122            return p;
123        }
124    }
125    let Ok(mut src) = super::open_audio(path, stream, backend) else {
126        return Peaks { min: Vec::new(), max: Vec::new() };
127    };
128    let duration = src.duration();
129    let peaks = compute_peaks(&mut *src, duration);
130    if let Some(f) = file {
131        if !peaks.is_empty() {
132            let mut bytes = Vec::with_capacity(peaks.len() * 8);
133            for (lo, hi) in peaks.min.iter().zip(&peaks.max) {
134                bytes.extend_from_slice(&lo.to_le_bytes());
135                bytes.extend_from_slice(&hi.to_le_bytes());
136            }
137            let tmp = f.with_extension("tmp");
138            let _ = std::fs::create_dir_all(Settings::cache_dir());
139            if std::fs::write(&tmp, bytes).is_ok() {
140                let _ = std::fs::rename(&tmp, &f);
141            }
142        }
143    }
144    peaks
145}
146
147/// Sequentially decode `source` in 4800-frame blocks into 10 ms mono (min, max) buckets. Stops at the
148/// first all-zero block at/after `duration` (so an unknown duration of 0 stops at the first silent block).
149pub(crate) fn compute_peaks(source: &mut dyn AudioSource, duration: f64) -> Peaks {
150    const BLOCK: usize = 4800;
151    const BUCKET: usize = (SAMPLE_RATE / PEAKS_PER_SEC) as usize; // 480 frames
152    let mut p = Peaks { min: Vec::new(), max: Vec::new() };
153    let mut buf = vec![0f32; BLOCK * 2];
154    let mut frame: u64 = 0;
155    loop {
156        let t = frame as f64 / SAMPLE_RATE as f64;
157        if t > 24.0 * 3600.0 {
158            break; // ponytail: hard cap — never spin forever on a broken source
159        }
160        source.read_at(t, &mut buf);
161        if t >= duration && buf.iter().all(|&x| x == 0.0) {
162            break;
163        }
164        for b in buf.chunks_exact(BUCKET * 2) {
165            let (mut lo, mut hi) = (f32::MAX, f32::MIN);
166            for s in b.chunks_exact(2) {
167                let m = (s[0] + s[1]) * 0.5;
168                lo = lo.min(m);
169                hi = hi.max(m);
170            }
171            p.min.push(lo);
172            p.max.push(hi);
173        }
174        frame += BLOCK as u64;
175    }
176    p
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::media::ffpipe;
183
184    #[test]
185    fn peaks_from_test_clip() {
186        let mut src = ffpipe::open_audio(&ffpipe::tests::test_mp4(), 0).unwrap();
187        let d = src.duration();
188        let p = compute_peaks(&mut *src, d);
189        let expect = (d * PEAKS_PER_SEC as f64) as usize;
190        assert!(p.len().abs_diff(expect) <= 10, "len {} vs {expect}", p.len());
191        let hi = p.max.iter().cloned().fold(f32::MIN, f32::max);
192        let lo = p.min.iter().cloned().fold(f32::MAX, f32::min);
193        assert!(hi > 0.1 && lo < -0.1, "{lo}..{hi}");
194        let (a, b) = p.range(0.5, 0.6);
195        assert!(a < -0.1 && b > 0.1);
196    }
197
198    /// get() is non-blocking, completes in the background, and the second instance hits the disk cache.
199    #[test]
200    fn cache_get_async_and_disk() {
201        let path = ffpipe::tests::test_mp4();
202        let poll = |c: &mut WaveformCache, path: &str| {
203            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
204            assert!(c.get(path, 1).is_none(), "first get must not block");
205            loop {
206                if let Some(p) = c.get(path, 1) {
207                    return p;
208                }
209                assert!(std::time::Instant::now() < deadline, "waveform never finished");
210                std::thread::sleep(std::time::Duration::from_millis(20));
211            }
212        };
213        let mut c = WaveformCache::new(eframe::egui::Context::default(), Backend::Ffmpeg);
214        let first = poll(&mut c, &path);
215        assert!(first.len() > 350 && first.max.iter().any(|&x| x > 0.1));
216        let mut c2 = WaveformCache::new(eframe::egui::Context::default(), Backend::Ffmpeg);
217        let second = poll(&mut c2, &path);
218        assert_eq!(first.len(), second.len());
219        assert_eq!(first.max, second.max);
220        // failure is remembered as empty peaks (no respawn loop)
221        let mut c3 = WaveformCache::new(eframe::egui::Context::default(), Backend::Ffmpeg);
222        assert!(poll(&mut c3, "Z:/definitely/missing.mp4").is_empty());
223    }
224
225    /// After the file at a path is rewritten, clear() makes get() recompute instead of serving old peaks.
226    #[test]
227    fn clear_forgets_rewritten_file() {
228        let poll = |c: &mut WaveformCache, path: &str| {
229            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
230            loop {
231                if let Some(p) = c.get(path, 0) {
232                    return p;
233                }
234                assert!(std::time::Instant::now() < deadline, "waveform never finished");
235                std::thread::sleep(std::time::Duration::from_millis(20));
236            }
237        };
238        let path = std::path::Path::new(&ffpipe::tests::test_mp4())
239            .with_file_name(format!("{}-rewrite.mp4", std::process::id()));
240        std::fs::copy(ffpipe::tests::test_mp4(), &path).unwrap();
241        let path = path.to_string_lossy().into_owned();
242        let mut c = WaveformCache::new(eframe::egui::Context::default(), Backend::Ffmpeg);
243        let old = poll(&mut c, &path);
244        assert!(old.len() > 350, "{}", old.len());
245        let st = std::process::Command::new("ffmpeg")
246            .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "aac"])
247            .arg(&path)
248            .status()
249            .expect("ffmpeg on PATH");
250        assert!(st.success());
251        assert_eq!(poll(&mut c, &path).len(), old.len(), "stale until cleared");
252        c.clear();
253        let new = poll(&mut c, &path);
254        assert!(new.len() < 150, "{} (old {})", new.len(), old.len());
255    }
256
257    #[test]
258    fn peaks_range_bounds() {
259        let p = Peaks { min: vec![-0.5, -0.2], max: vec![0.5, 0.2] };
260        assert_eq!(p.range(0.0, 0.01), (-0.5, 0.5));
261        assert_eq!(p.range(0.01, 0.02), (-0.2, 0.2));
262        assert_eq!(p.range(5.0, 6.0), (0.0, 0.0));
263    }
264
265    #[test]
266    fn mem_key_differs_per_stream() {
267        assert_ne!(mem_key("a.mp4", 0), mem_key("a.mp4", 1));
268        assert_eq!(mem_key("a.mp4", 0), mem_key("a.mp4", 0));
269    }
270}