Skip to main content

falcon_library/
waveform.rs

1//! Audio waveforms: decoding a file to mono peaks and drawing the waveform
2//! picture Eagle uses as an audio item's thumbnail (see the Thumbnails
3//! chapter of the docs). Eagle caches no peak data on disk, so neither does
4//! falcon.
5
6use std::path::Path;
7
8use image::{Rgba, RgbaImage};
9
10/// Size Eagle records for an audio item and draws its waveform at.
11pub const WIDTH: u32 = 280;
12pub const HEIGHT: u32 = 140;
13/// Eagle's waveform colour, #0072EF.
14pub const COLOR: [u8; 3] = [0x00, 0x72, 0xEF];
15
16/// Frames folded into one block on the first pass, before the file's
17/// length is known.
18const BLOCK: usize = 256;
19
20/// Decodes an audio file and returns `buckets` (min, max) pairs of the mono
21/// mix, in -1..=1. `None` if it can't be decoded.
22pub fn peaks(path: &Path, ext: &str, buckets: usize) -> Option<Vec<(f32, f32)>> {
23    let mut source = crate::audio::open(path, ext).ok()?;
24    let mut blocks: Vec<(f32, f32)> = Vec::new();
25    let (mut lo, mut hi, mut n) = (f32::MAX, f32::MIN, 0usize);
26    while let Some(block) = source.next_block() {
27        for frame in block.frames() {
28            let s = frame.iter().sum::<f32>() / frame.len() as f32;
29            lo = lo.min(s);
30            hi = hi.max(s);
31            n += 1;
32            if n == BLOCK {
33                blocks.push((lo, hi));
34                (lo, hi, n) = (f32::MAX, f32::MIN, 0);
35            }
36        }
37    }
38    if n > 0 {
39        blocks.push((lo, hi));
40    }
41    (!blocks.is_empty()).then(|| rebucket(&blocks, buckets))
42}
43
44/// Merges (min, max) pairs into `buckets` evenly spread groups.
45pub fn rebucket(blocks: &[(f32, f32)], buckets: usize) -> Vec<(f32, f32)> {
46    let buckets = buckets.max(1);
47    (0..buckets)
48        .map(|b| {
49            let start = b * blocks.len() / buckets;
50            let end = ((b + 1) * blocks.len() / buckets).max(start + 1).min(blocks.len());
51            blocks.get(start..end).unwrap_or(&[]).iter().fold((0.0f32, 0.0f32), |(l, h), &(a, z)| (l.min(a), h.max(z)))
52        })
53        .collect()
54}
55
56/// Draws peaks as a mirrored waveform, one column per peak, scaled so the
57/// loudest peak fills the height (Eagle's waveform is normalised), on a
58/// transparent background.
59pub fn render(peaks: &[(f32, f32)], height: u32, color: [u8; 3]) -> RgbaImage {
60    let width = peaks.len().max(1) as u32;
61    let mut img = RgbaImage::new(width, height);
62    let loudest = peaks.iter().map(|&(l, h)| l.abs().max(h.abs())).fold(0.0f32, f32::max);
63    let scale = if loudest > 0.0 { 1.0 / loudest } else { 0.0 };
64    let mid = height as f32 / 2.0;
65    for (x, &(l, h)) in peaks.iter().enumerate() {
66        let top = (mid - h * scale * mid).floor().clamp(0.0, height as f32 - 1.0) as u32;
67        let bottom = (mid - l * scale * mid).ceil().clamp(top as f32 + 1.0, height as f32) as u32;
68        for y in top..bottom {
69            img.put_pixel(x as u32, y, Rgba([color[0], color[1], color[2], 255]));
70        }
71    }
72    img
73}
74
75/// The audio thumbnail: a 280×140 waveform.
76pub fn thumbnail(path: &Path, ext: &str) -> Option<RgbaImage> {
77    let p = peaks(path, ext, WIDTH as usize)?;
78    Some(render(&p, HEIGHT, COLOR))
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::media::tests::wav;
85
86    #[test]
87    fn peaks_follow_the_envelope() {
88        let dir = tempfile::tempdir().unwrap();
89        let p = dir.path().join("ramp.wav");
90        // A tone that gets louder over two seconds.
91        std::fs::write(&p, wav(8000, 2.0, |t| (t / 2.0) * (t * 220.0 * std::f64::consts::TAU).sin())).unwrap();
92        let peaks = peaks(&p, "wav", 400).unwrap();
93        assert_eq!(peaks.len(), 400);
94        let amp = |i: usize| peaks[i].1 - peaks[i].0;
95        assert!(amp(10) < amp(200) && amp(200) < amp(390), "{} {} {}", amp(10), amp(200), amp(390));
96        assert!(peaks[399].1 > 0.9 && peaks[399].0 < -0.9);
97    }
98
99    #[test]
100    fn renders_a_waveform() {
101        let dir = tempfile::tempdir().unwrap();
102        let p = dir.path().join("tone.wav");
103        std::fs::write(&p, wav(8000, 1.0, |t| 0.5 * (t * 440.0 * std::f64::consts::TAU).sin())).unwrap();
104        let img = thumbnail(&p, "wav").unwrap();
105        assert_eq!(img.dimensions(), (WIDTH, HEIGHT));
106        // Normalised: a steady tone fills the height in every column.
107        assert_eq!(img.get_pixel(140, 1).0, [0x00, 0x72, 0xEF, 255]);
108        assert_eq!(img.get_pixel(140, 70).0[3], 255);
109        let silent = render(&[(0.0, 0.0); 4], 10, COLOR);
110        assert!(silent.pixels().filter(|p| p.0[3] > 0).count() <= 4, "a silent file is a thin line");
111    }
112
113    #[test]
114    fn rebuckets() {
115        let blocks = [(-0.1, 0.1), (-0.5, 0.2), (-0.2, 0.9), (0.0, 0.0)];
116        assert_eq!(rebucket(&blocks, 2), [(-0.5, 0.2), (-0.2, 0.9)]);
117        assert_eq!(rebucket(&blocks, 8).len(), 8);
118    }
119}