1use leptos::prelude::*;
16
17#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct WaveBucket {
20 pub min: f32,
22 pub max: f32,
24}
25
26impl WaveBucket {
27 #[must_use]
29 pub fn amplitude(self) -> f32 {
30 (self.max - self.min).max(0.0)
31 }
32}
33
34#[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#[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#[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 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 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 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}