Skip to main content

media/
waveform.rs

1//! Waveform bar geometry (M-MEDIA.9 / AUT-105).
2//!
3//! Maps an [`AudioHistogram`] (M-MEDIA.8) to a list of axis-aligned
4//! rectangles that `wisp`'s graphics pipeline can render directly. The
5//! point of this module is the [crate-level architecture
6//! boundary](crate#three-way-split) — `wisp` should NOT know about
7//! audio. `media` produces geometry; `wisp` draws it.
8//!
9//! # Coordinate convention
10//!
11//! Rectangles use a `y`-up convention (matching wisp NDC): `x`/`y` is
12//! the **bottom-left** corner, `width`/`height` are positive. Callers
13//! pass layout values in whatever unit they want — NDC `[-1, +1]`,
14//! screen pixels, or normalized `[0, 1]`. The math is unit-agnostic.
15//!
16//! # Display modes
17//!
18//! - [`mono_bars`] — one bar per bucket, two arrangement options:
19//!   - [`WaveformDisplayMode::Anchored`] — bar's bottom sits on
20//!     `baseline_y`, grows upward by `value * max_height`. Common for
21//!     dope-sheet rows above a timeline.
22//!   - [`WaveformDisplayMode::Mirrored`] — bar centered on
23//!     `baseline_y`, extends `value * max_height / 2` in both
24//!     directions. Common for centered "VU-style" displays.
25//! - [`stereo_bars`] — pairs left + right histograms: left grows up
26//!   from `baseline_y`, right grows down. Mode is implicitly anchored;
27//!   [`WaveformLayout::mode`] is ignored.
28//!
29//! # Bar metric
30//!
31//! [`BarMetric::Peak`] uses each bucket's `peak` (max `|sample|`); the
32//! visualization feels punchier. [`BarMetric::Rms`] uses RMS; the
33//! visualization feels smoother.
34
35use crate::histogram::AudioHistogram;
36
37/// One waveform bar's rectangle.
38///
39/// Coordinates use a `y`-up convention: `x`/`y` is the bottom-left
40/// corner; `width`/`height` are non-negative.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct WaveformBarRect {
43    /// Left edge.
44    pub x: f32,
45    /// Bottom edge (`y`-up).
46    pub y: f32,
47    /// Bar width — always `> 0` when `layout.bar_width > 0`.
48    pub width: f32,
49    /// Bar height — `0` for silence, `layout.max_height` for `|sample| = 1.0`.
50    pub height: f32,
51    /// RGBA color, each component in `[0, 1]`.
52    pub color: [f32; 4],
53}
54
55/// Which summary statistic to drive bar height with.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub enum BarMetric {
58    /// Bar height ∝ `peak` (max `|sample|`). Punchy.
59    #[default]
60    Peak,
61    /// Bar height ∝ `rms`. Smoother.
62    Rms,
63}
64
65/// Arrangement of bars relative to the baseline.
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
67pub enum WaveformDisplayMode {
68    /// Bar's bottom edge sits on `baseline_y`; grows upward.
69    #[default]
70    Anchored,
71    /// Bar is centered on `baseline_y`; extends half up + half down.
72    Mirrored,
73}
74
75/// Layout parameters shared by every bar in one waveform.
76///
77/// All values use the caller's chosen unit system (NDC, pixels, …).
78#[derive(Debug, Clone, Copy)]
79pub struct WaveformLayout {
80    /// `x` of the first bar's left edge.
81    pub origin_x: f32,
82    /// Reference `y` line for [`WaveformDisplayMode`].
83    pub baseline_y: f32,
84    /// Width of one bar (excluding gap). Must be `> 0`.
85    pub bar_width: f32,
86    /// Horizontal spacing between adjacent bar left edges, on top of
87    /// `bar_width`. May be `0` for touching bars.
88    pub bar_gap: f32,
89    /// Bar height when the metric is `1.0`. Must be `> 0`.
90    pub max_height: f32,
91    /// Per-bar RGBA color.
92    pub color: [f32; 4],
93    /// Whether to read `peak` or `rms` from each bucket.
94    pub metric: BarMetric,
95    /// Anchored vs mirrored.
96    pub mode: WaveformDisplayMode,
97}
98
99impl WaveformLayout {
100    /// Reasonable defaults for an NDC `[-1, +1]` mono waveform centered
101    /// vertically: gray bars `0.02` wide, gap `0.005`, max height
102    /// `0.4`, anchored at `y = 0`. Tweak from here.
103    #[must_use]
104    pub fn ndc_default() -> Self {
105        Self {
106            origin_x: -0.9,
107            baseline_y: 0.0,
108            bar_width: 0.02,
109            bar_gap: 0.005,
110            max_height: 0.4,
111            color: [0.65, 0.70, 0.78, 1.0],
112            metric: BarMetric::Peak,
113            mode: WaveformDisplayMode::Anchored,
114        }
115    }
116}
117
118/// Layout one mono histogram into a list of bar rectangles.
119///
120/// # Panics
121///
122/// Panics if `layout.bar_width <= 0` or `layout.max_height <= 0`. Both
123/// are caller errors that would produce degenerate geometry.
124#[must_use]
125pub fn mono_bars(hist: &AudioHistogram, layout: &WaveformLayout) -> Vec<WaveformBarRect> {
126    assert!(layout.bar_width > 0.0, "bar_width must be > 0");
127    assert!(layout.max_height > 0.0, "max_height must be > 0");
128
129    let stride = layout.bar_width + layout.bar_gap;
130    let mut out = Vec::with_capacity(hist.bars.len());
131
132    for (i, bar) in hist.bars.iter().enumerate() {
133        let metric_value = match layout.metric {
134            BarMetric::Peak => bar.peak,
135            BarMetric::Rms => bar.rms,
136        }
137        .clamp(0.0, 1.0);
138
139        let height = metric_value * layout.max_height;
140        let x = layout.origin_x + i_as_f32(i) * stride;
141        let y = match layout.mode {
142            WaveformDisplayMode::Anchored => layout.baseline_y,
143            WaveformDisplayMode::Mirrored => layout.baseline_y - height * 0.5,
144        };
145
146        out.push(WaveformBarRect {
147            x,
148            y,
149            width: layout.bar_width,
150            height,
151            color: layout.color,
152        });
153    }
154
155    out
156}
157
158/// Layout stereo histograms: `left` bars extend up from `baseline_y`,
159/// `right` bars extend down. Always anchored; `layout.mode` is ignored.
160///
161/// If the two histograms have different lengths, the shorter one wins
162/// — the surplus bars are dropped (their data is meaningless without
163/// a paired channel).
164///
165/// # Panics
166///
167/// Same conditions as [`mono_bars`].
168#[must_use]
169pub fn stereo_bars(
170    left: &AudioHistogram,
171    right: &AudioHistogram,
172    layout: &WaveformLayout,
173) -> Vec<WaveformBarRect> {
174    assert!(layout.bar_width > 0.0, "bar_width must be > 0");
175    assert!(layout.max_height > 0.0, "max_height must be > 0");
176
177    let n = left.bars.len().min(right.bars.len());
178    let stride = layout.bar_width + layout.bar_gap;
179    let mut out = Vec::with_capacity(n * 2);
180
181    for i in 0..n {
182        let lv = match layout.metric {
183            BarMetric::Peak => left.bars[i].peak,
184            BarMetric::Rms => left.bars[i].rms,
185        }
186        .clamp(0.0, 1.0);
187        let rv = match layout.metric {
188            BarMetric::Peak => right.bars[i].peak,
189            BarMetric::Rms => right.bars[i].rms,
190        }
191        .clamp(0.0, 1.0);
192
193        let lh = lv * layout.max_height;
194        let rh = rv * layout.max_height;
195        let x = layout.origin_x + i_as_f32(i) * stride;
196
197        out.push(WaveformBarRect {
198            x,
199            y: layout.baseline_y,
200            width: layout.bar_width,
201            height: lh,
202            color: layout.color,
203        });
204        out.push(WaveformBarRect {
205            x,
206            y: layout.baseline_y - rh,
207            width: layout.bar_width,
208            height: rh,
209            color: layout.color,
210        });
211    }
212
213    out
214}
215
216#[expect(
217    clippy::cast_precision_loss,
218    reason = "histogram bar indices below 2^24 fit f32 exactly; realistic dope-sheet bar counts stay there"
219)]
220fn i_as_f32(i: usize) -> f32 {
221    i as f32
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::audio::AudioFormat;
228    use crate::clock::MediaDuration;
229    use crate::histogram::quantize;
230    use crate::mock_audio::{SilenceSource, SineWaveSource};
231
232    fn sine_hist() -> AudioHistogram {
233        let fmt = AudioFormat::mono_f32(48_000);
234        let mut src = SineWaveSource::new(fmt, 440.0, 0.6);
235        let chunk = src.next_chunk(48_000);
236        quantize(&chunk, MediaDuration::from_millis(50)) // 20 bars
237    }
238
239    #[test]
240    fn anchored_bars_progress_left_to_right_with_stride() {
241        let h = sine_hist();
242        let layout = WaveformLayout {
243            origin_x: 0.0,
244            baseline_y: 0.0,
245            bar_width: 0.1,
246            bar_gap: 0.02,
247            max_height: 1.0,
248            color: [1.0; 4],
249            metric: BarMetric::Peak,
250            mode: WaveformDisplayMode::Anchored,
251        };
252        let rects = mono_bars(&h, &layout);
253        assert_eq!(rects.len(), h.len());
254        for (i, r) in rects.iter().enumerate() {
255            let expected_x = i_as_f32(i) * 0.12;
256            assert!(
257                (r.x - expected_x).abs() < 1e-6,
258                "bar {i}: x={} expected {expected_x}",
259                r.x
260            );
261            assert!((r.width - 0.1).abs() < 1e-6);
262            assert!((r.y - 0.0).abs() < 1e-6, "anchored y == baseline_y");
263        }
264    }
265
266    #[test]
267    fn anchored_bar_height_equals_peak_times_max_height() {
268        let h = sine_hist();
269        let layout = WaveformLayout {
270            origin_x: 0.0,
271            baseline_y: 0.0,
272            bar_width: 0.1,
273            bar_gap: 0.0,
274            max_height: 2.0,
275            color: [1.0; 4],
276            metric: BarMetric::Peak,
277            mode: WaveformDisplayMode::Anchored,
278        };
279        let rects = mono_bars(&h, &layout);
280        for (r, bar) in rects.iter().zip(h.bars.iter()) {
281            let expected = bar.peak * 2.0;
282            assert!(
283                (r.height - expected).abs() < 1e-6,
284                "got {} expected {expected}",
285                r.height
286            );
287        }
288    }
289
290    #[test]
291    fn mirrored_bars_are_centered_on_baseline() {
292        let h = sine_hist();
293        let layout = WaveformLayout {
294            origin_x: 0.0,
295            baseline_y: 0.5,
296            bar_width: 0.1,
297            bar_gap: 0.0,
298            max_height: 1.0,
299            color: [1.0; 4],
300            metric: BarMetric::Peak,
301            mode: WaveformDisplayMode::Mirrored,
302        };
303        let rects = mono_bars(&h, &layout);
304        for r in &rects {
305            let center = r.y + r.height * 0.5;
306            assert!(
307                (center - 0.5).abs() < 1e-6,
308                "expected center=0.5, got {center}"
309            );
310        }
311    }
312
313    #[test]
314    fn rms_metric_uses_rms_field() {
315        let h = sine_hist();
316        let layout = WaveformLayout {
317            origin_x: 0.0,
318            baseline_y: 0.0,
319            bar_width: 0.1,
320            bar_gap: 0.0,
321            max_height: 1.0,
322            color: [1.0; 4],
323            metric: BarMetric::Rms,
324            mode: WaveformDisplayMode::Anchored,
325        };
326        let rects = mono_bars(&h, &layout);
327        for (r, bar) in rects.iter().zip(h.bars.iter()) {
328            assert!(
329                (r.height - bar.rms).abs() < 1e-6,
330                "rms metric got {} expected {}",
331                r.height,
332                bar.rms
333            );
334        }
335    }
336
337    #[test]
338    fn silent_histogram_produces_zero_height_bars() {
339        let fmt = AudioFormat::mono_f32(48_000);
340        let mut src = SilenceSource::new(fmt);
341        let chunk = src.next_chunk(48_000);
342        let h = quantize(&chunk, MediaDuration::from_millis(50));
343        let rects = mono_bars(&h, &WaveformLayout::ndc_default());
344        assert_eq!(rects.len(), 20);
345        for r in &rects {
346            assert!(r.height.abs() < f32::EPSILON);
347        }
348    }
349
350    #[test]
351    fn empty_histogram_produces_empty_geometry() {
352        let h = AudioHistogram {
353            bucket_duration: MediaDuration::from_millis(20),
354            bars: Vec::new(),
355        };
356        let rects = mono_bars(&h, &WaveformLayout::ndc_default());
357        assert!(rects.is_empty());
358    }
359
360    #[test]
361    fn stereo_pairs_left_above_right_below_baseline() {
362        let fmt = AudioFormat::mono_f32(48_000);
363        let mut sl = SineWaveSource::new(fmt, 440.0, 0.6);
364        let mut sr = SineWaveSource::new(fmt, 440.0, 0.3);
365        let chunk_l = sl.next_chunk(48_000);
366        let chunk_r = sr.next_chunk(48_000);
367        let hl = quantize(&chunk_l, MediaDuration::from_millis(50));
368        let hr = quantize(&chunk_r, MediaDuration::from_millis(50));
369        let layout = WaveformLayout {
370            origin_x: 0.0,
371            baseline_y: 0.5,
372            bar_width: 0.1,
373            bar_gap: 0.0,
374            max_height: 0.4,
375            color: [1.0; 4],
376            metric: BarMetric::Peak,
377            mode: WaveformDisplayMode::Anchored,
378        };
379        let rects = stereo_bars(&hl, &hr, &layout);
380        assert_eq!(rects.len(), hl.len() * 2);
381
382        for pair in rects.chunks_exact(2) {
383            let (l, r) = (pair[0], pair[1]);
384            // Same x — they share a column.
385            assert!((l.x - r.x).abs() < 1e-6);
386            // L sits on the baseline going up; R's TOP edge is the baseline going down.
387            assert!(
388                (l.y - 0.5).abs() < 1e-6,
389                "left bar y={} expected baseline 0.5",
390                l.y
391            );
392            assert!(
393                ((r.y + r.height) - 0.5).abs() < 1e-6,
394                "right top {} expected baseline 0.5",
395                r.y + r.height
396            );
397        }
398    }
399
400    #[test]
401    fn stereo_truncates_to_shorter_input() {
402        let fmt = AudioFormat::mono_f32(48_000);
403        let mut sl = SineWaveSource::new(fmt, 440.0, 0.5);
404        let mut sr = SineWaveSource::new(fmt, 440.0, 0.5);
405        let chunk_l = sl.next_chunk(48_000); // 1s → 20 bars
406        let chunk_r = sr.next_chunk(24_000); // 0.5s → 10 bars
407        let hl = quantize(&chunk_l, MediaDuration::from_millis(50));
408        let hr = quantize(&chunk_r, MediaDuration::from_millis(50));
409        let rects = stereo_bars(&hl, &hr, &WaveformLayout::ndc_default());
410        assert_eq!(rects.len(), 10 * 2, "truncates to min(20, 10) = 10 pairs");
411    }
412
413    #[test]
414    fn color_is_propagated_unchanged() {
415        let h = sine_hist();
416        let layout = WaveformLayout {
417            color: [0.1, 0.2, 0.3, 0.5],
418            ..WaveformLayout::ndc_default()
419        };
420        let rects = mono_bars(&h, &layout);
421        let expected = [0.1_f32, 0.2, 0.3, 0.5];
422        for r in &rects {
423            for (a, b) in r.color.iter().zip(expected.iter()) {
424                assert!((a - b).abs() < 1e-6, "color channel {a} expected {b}");
425            }
426        }
427    }
428
429    #[test]
430    fn manual_regression_four_bar_table() {
431        // Four-bar histogram, peaks 1.0, 0.5, 0.25, 0.0, anchored at
432        // y=0, bar_width=0.1, gap=0.02, max_height=1.0, origin=0.0.
433        // Expected: bars at x = 0.0, 0.12, 0.24, 0.36; height = peak.
434        // Manually craft a 4-bucket histogram with peaks 1.0, 0.5, 0.25, 0.0
435        // (StepPulseSource isn't expressive enough for arbitrary
436        // per-bucket peaks; the bar table is the contract.)
437        let bars = vec![
438            crate::histogram::AudioBar {
439                start_time: crate::clock::MediaTime::ZERO,
440                duration: MediaDuration::from_millis(50),
441                peak: 1.0,
442                rms: 1.0 / std::f32::consts::SQRT_2,
443            },
444            crate::histogram::AudioBar {
445                start_time: crate::clock::MediaTime::from_seconds(0.05),
446                duration: MediaDuration::from_millis(50),
447                peak: 0.5,
448                rms: 0.5 / std::f32::consts::SQRT_2,
449            },
450            crate::histogram::AudioBar {
451                start_time: crate::clock::MediaTime::from_seconds(0.10),
452                duration: MediaDuration::from_millis(50),
453                peak: 0.25,
454                rms: 0.25 / std::f32::consts::SQRT_2,
455            },
456            crate::histogram::AudioBar {
457                start_time: crate::clock::MediaTime::from_seconds(0.15),
458                duration: MediaDuration::from_millis(50),
459                peak: 0.0,
460                rms: 0.0,
461            },
462        ];
463        let h = AudioHistogram {
464            bucket_duration: MediaDuration::from_millis(50),
465            bars,
466        };
467        let layout = WaveformLayout {
468            origin_x: 0.0,
469            baseline_y: 0.0,
470            bar_width: 0.1,
471            bar_gap: 0.02,
472            max_height: 1.0,
473            color: [1.0; 4],
474            metric: BarMetric::Peak,
475            mode: WaveformDisplayMode::Anchored,
476        };
477        let rects = mono_bars(&h, &layout);
478
479        let expected = [(0.0_f32, 1.0_f32), (0.12, 0.5), (0.24, 0.25), (0.36, 0.0)];
480        for (i, (xe, he)) in expected.iter().enumerate() {
481            assert!(
482                (rects[i].x - xe).abs() < 1e-6,
483                "bar {i}: x={} expected {xe}",
484                rects[i].x
485            );
486            assert!(
487                (rects[i].height - he).abs() < 1e-6,
488                "bar {i}: height={} expected {he}",
489                rects[i].height
490            );
491        }
492    }
493
494    #[test]
495    fn types_are_send_and_sync() {
496        fn assert_send_sync<T: Send + Sync>() {}
497        assert_send_sync::<WaveformBarRect>();
498        assert_send_sync::<WaveformLayout>();
499    }
500}