Skip to main content

app_ui/
waveform.rs

1//! Audio waveform lane (ED.10 / M-EDIT).
2//!
3//! You cannot splice on a sound you cannot see. The cutting room solved
4//! this with the **mag track** running beside the picture and a soundhead
5//! on the flatbed; we solve it with a waveform — the audio's peak envelope
6//! made visible. [`downsample_peaks`] reduces a sea of samples to one
7//! min/max pair per horizontal bucket (drawing every sample is impossible
8//! and pointless — the envelope is what the eye actually reads); the
9//! [`AudioWaveform`] lane draws those buckets beneath the video track.
10//!
11//! Decoding the recording's audio into samples is gst work that lands with
12//! the render-integration pass; this chunk is the (pure, tested) envelope
13//! math + the lane that renders it.
14
15use leptos::prelude::*;
16
17/// The min/max amplitude envelope of one horizontal bucket of the waveform.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct WaveBucket {
20    /// Most-negative sample in the bucket.
21    pub min: f32,
22    /// Most-positive sample in the bucket.
23    pub max: f32,
24}
25
26impl WaveBucket {
27    /// Peak-to-peak amplitude (`max - min`), the bucket's drawn height.
28    #[must_use]
29    pub fn amplitude(self) -> f32 {
30        (self.max - self.min).max(0.0)
31    }
32}
33
34/// Reduce `samples` to `buckets` min/max envelopes — the peak-pair
35/// representation every scrubbable waveform uses. Pure + deterministic.
36#[must_use]
37pub fn downsample_peaks(samples: &[f32], buckets: usize) -> Vec<WaveBucket> {
38    if buckets == 0 || samples.is_empty() {
39        return Vec::new();
40    }
41    let n = samples.len();
42    (0..buckets)
43        .map(|bucket| {
44            let start = bucket * n / buckets;
45            let end = (((bucket + 1) * n / buckets).max(start + 1)).min(n);
46            let slice = &samples[start..end];
47            let mut min = f32::INFINITY;
48            let mut max = f32::NEG_INFINITY;
49            for &s in slice {
50                min = min.min(s);
51                max = max.max(s);
52            }
53            if slice.is_empty() {
54                WaveBucket { min: 0.0, max: 0.0 }
55            } else {
56                WaveBucket { min, max }
57            }
58        })
59        .collect()
60}
61
62#[allow(
63    clippy::cast_precision_loss,
64    reason = "bucket counts are small; exact in f64 for percent positioning"
65)]
66fn percent(part: usize, total: usize) -> f64 {
67    if total == 0 {
68        return 0.0;
69    }
70    part as f64 / total as f64 * 100.0
71}
72
73/// The horizontal `(left%, width%)` of bucket `index` of `count`, tiling the
74/// lane edge-to-edge (bar `i` spans `[i/count, (i+1)/count]`). Computing the
75/// width as the gap to the next bucket avoids cumulative rounding and lands
76/// the final bar exactly at 100%. Pure — the regression guard for the
77/// "bars render blank because they have no width" bug.
78#[must_use]
79fn bar_geometry(index: usize, count: usize) -> (f64, f64) {
80    let left = percent(index, count);
81    let right = percent(index + 1, count);
82    (left, (right - left).max(0.0))
83}
84
85/// The audio lane: the waveform envelope beneath the video track. Reads the
86/// peak buckets from context; renders a quiet baseline until the audio is
87/// decoded (render-integration).
88#[component]
89pub fn AudioWaveform() -> impl IntoView {
90    let peaks =
91        use_context::<RwSignal<Vec<WaveBucket>>>().unwrap_or_else(|| RwSignal::new(Vec::new()));
92    view! {
93        <div class="timeline-lane timeline-lane--audio" aria-label="Audio track">
94            {move || {
95                let buckets = peaks.get();
96                if buckets.is_empty() {
97                    return view! { <div class="waveform-baseline"></div> }.into_any();
98                }
99                let count = buckets.len();
100                buckets
101                    .into_iter()
102                    .enumerate()
103                    .map(|(index, bucket)| {
104                        let (left, width) = bar_geometry(index, count);
105                        // Signal is normalized to [-1, 1] → amplitude in [0, 2].
106                        let height = (f64::from(bucket.amplitude()) / 2.0 * 100.0).clamp(2.0, 100.0);
107                        let style = format!("left:{left:.3}%;width:{width:.3}%;height:{height:.1}%");
108                        view! { <span class="waveform-bar" style=style></span> }
109                    })
110                    .collect_view()
111                    .into_any()
112            }}
113        </div>
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn approx(a: f32, b: f32) -> bool {
122        (a - b).abs() < 1e-6
123    }
124
125    #[test]
126    fn empty_inputs_yield_no_buckets() {
127        assert!(downsample_peaks(&[], 8).is_empty());
128        assert!(downsample_peaks(&[0.5, -0.5], 0).is_empty());
129    }
130
131    #[test]
132    fn buckets_capture_min_max_envelope() {
133        let samples = [-1.0, 1.0, -0.5, 0.5];
134        let peaks = downsample_peaks(&samples, 2);
135        assert_eq!(peaks.len(), 2);
136        assert!(approx(peaks[0].min, -1.0) && approx(peaks[0].max, 1.0));
137        assert!(approx(peaks[1].min, -0.5) && approx(peaks[1].max, 0.5));
138        assert!(approx(peaks[0].amplitude(), 2.0));
139        assert!(approx(peaks[1].amplitude(), 1.0));
140    }
141
142    #[test]
143    fn more_buckets_than_samples_does_not_panic() {
144        let peaks = downsample_peaks(&[0.2, -0.3], 8);
145        assert_eq!(peaks.len(), 8);
146        // Every bucket still has a valid (finite) envelope.
147        assert!(peaks.iter().all(|b| b.min.is_finite() && b.max.is_finite()));
148    }
149
150    #[test]
151    fn bars_tile_the_lane_edge_to_edge() {
152        // Each bar's [left, left+width] must abut the next with no gap, and
153        // the last bar must reach 100% — a zero/absent width is the blank-
154        // lane bug (an inline `left`/`height` on a width-less inline span
155        // renders nothing).
156        let count = 7;
157        let mut cursor = 0.0;
158        for index in 0..count {
159            let (left, width) = bar_geometry(index, count);
160            assert!(
161                (left - cursor).abs() < 1e-9,
162                "bar {index} abuts the previous"
163            );
164            assert!(width > 0.0, "bar {index} has a non-zero width");
165            cursor = left + width;
166        }
167        assert!((cursor - 100.0).abs() < 1e-9, "bars fill the lane to 100%");
168    }
169
170    #[test]
171    fn one_bucket_spans_all_samples() {
172        let samples = [0.1, 0.9, -0.4, 0.2, -0.7];
173        let peaks = downsample_peaks(&samples, 1);
174        assert_eq!(peaks.len(), 1);
175        assert!(approx(peaks[0].min, -0.7) && approx(peaks[0].max, 0.9));
176    }
177}