1use crate::histogram::AudioHistogram;
36
37#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct WaveformBarRect {
43 pub x: f32,
45 pub y: f32,
47 pub width: f32,
49 pub height: f32,
51 pub color: [f32; 4],
53}
54
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub enum BarMetric {
58 #[default]
60 Peak,
61 Rms,
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
67pub enum WaveformDisplayMode {
68 #[default]
70 Anchored,
71 Mirrored,
73}
74
75#[derive(Debug, Clone, Copy)]
79pub struct WaveformLayout {
80 pub origin_x: f32,
82 pub baseline_y: f32,
84 pub bar_width: f32,
86 pub bar_gap: f32,
89 pub max_height: f32,
91 pub color: [f32; 4],
93 pub metric: BarMetric,
95 pub mode: WaveformDisplayMode,
97}
98
99impl WaveformLayout {
100 #[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#[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#[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)) }
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 assert!((l.x - r.x).abs() < 1e-6);
386 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); let chunk_r = sr.next_chunk(24_000); 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 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}