1use crate::chart::{ensure_finite_and_non_empty, ensure_valid_y_range, write_png};
28use crate::error::Error;
29use charts_rs::{DEFAULT_FONT_FAMILY, measure_text_width_family};
30use std::ops::Range;
31use std::path::Path;
32
33const PEAK_COLOR: &str = "#5470c6";
36const RMS_COLOR: &str = "#98a9dd";
37const GRID_COLOR: &str = "#e0e6f2";
38const AXIS_COLOR: &str = "#6e7079";
39const TITLE_COLOR: &str = "#464646";
40const FONT_SIZE: f32 = 14.0;
41const TITLE_FONT_SIZE: f32 = 18.0;
42
43const MIN_X_TICK_SPACING: f32 = 80.0;
46const MIN_Y_TICK_SPACING: f32 = 50.0;
47
48#[derive(Debug, Clone)]
82pub struct Waveform<'a> {
83 samples: &'a [f32],
84 sample_rate: Option<f32>,
85 y_range: Option<Range<f32>>,
86 width: u32,
87 height: u32,
88 title: String,
89}
90
91impl<'a> Waveform<'a> {
92 #[must_use]
94 pub const fn new(samples: &'a [f32]) -> Self {
95 Self {
96 samples,
97 sample_rate: None,
98 y_range: None,
99 width: 1400,
100 height: 400,
101 title: String::new(),
102 }
103 }
104
105 #[must_use]
107 pub const fn sample_rate(mut self, sample_rate_hz: f32) -> Self {
108 self.sample_rate = Some(sample_rate_hz);
109 self
110 }
111
112 #[must_use]
118 pub const fn y_range(mut self, range: Range<f32>) -> Self {
119 self.y_range = Some(range);
120 self
121 }
122
123 #[must_use]
125 pub const fn size(mut self, width: u32, height: u32) -> Self {
126 self.width = width;
127 self.height = height;
128 self
129 }
130
131 #[must_use]
133 pub fn title(mut self, title: impl Into<String>) -> Self {
134 self.title = title.into();
135 self
136 }
137
138 pub fn to_svg(&self) -> Result<String, Error> {
140 ensure_finite_and_non_empty(self.samples.iter().copied())?;
141 let samples_per_unit = self.samples_per_unit()?;
142 let y_range = self.y_axis_range()?;
143
144 let top = if self.title.is_empty() { 12.0 } else { 40.0 };
145 let plot_height = self.height as f32 - top - 32.0;
146 let y_ticks = ticks(&y_range, plot_height / MIN_Y_TICK_SPACING, "");
147 let mut label_width = 0.0_f32;
148 for (_, label) in &y_ticks {
149 let size = measure_text_width_family(DEFAULT_FONT_FAMILY, FONT_SIZE, label)?;
150 label_width = label_width.max(size.width());
151 }
152 let left = (label_width + 16.0).round();
155 let plot = Plot {
156 left,
157 top,
158 width: self.width as f32 - left - 24.0,
159 height: plot_height,
160 len: self.samples.len(),
161 y_range,
162 };
163 let x_range = 0.0..self.samples.len() as f32 / samples_per_unit;
164 let x_unit = if self.sample_rate.is_some() { "s" } else { "" };
165 let x_ticks = ticks(&x_range, plot.width / MIN_X_TICK_SPACING, x_unit);
166
167 let (width, height) = (self.width, self.height);
168 let (right, bottom) = (plot.left + plot.width, plot.bottom());
169 let mut svg = format!(
170 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" font-family="{DEFAULT_FONT_FAMILY}" font-size="{FONT_SIZE}">"#
171 );
172 svg.push_str(r#"<rect width="100%" height="100%" fill="white"/>"#);
173 if !self.title.is_empty() {
174 svg.push_str(&format!(
175 r#"<text x="{}" y="26" text-anchor="middle" font-size="{TITLE_FONT_SIZE}" fill="{TITLE_COLOR}">{}</text>"#,
176 width as f32 / 2.0,
177 escape_xml(&self.title),
178 ));
179 }
180 svg.push_str(r#"<g shape-rendering="crispEdges">"#);
183 for (value, _) in &y_ticks {
184 let y = plot.y(*value);
185 svg.push_str(&format!(
186 r#"<line x1="{left}" x2="{right}" y1="{y}" y2="{y}" stroke="{GRID_COLOR}"/>"#
187 ));
188 }
189 let (peak_path, rms_path) = self.column_paths(&plot);
190 svg.push_str(&format!(r#"<path d="{peak_path}" fill="{PEAK_COLOR}"/>"#));
191 svg.push_str(&format!(r#"<path d="{rms_path}" fill="{RMS_COLOR}"/>"#));
192 svg.push_str(&format!(
193 r#"<line x1="{left}" x2="{right}" y1="{bottom}" y2="{bottom}" stroke="{AXIS_COLOR}"/>"#
194 ));
195 for (value, _) in &x_ticks {
196 let x = plot.x(value * samples_per_unit);
197 let tick_end = bottom + 5.0;
198 svg.push_str(&format!(
199 r#"<line x1="{x}" x2="{x}" y1="{bottom}" y2="{tick_end}" stroke="{AXIS_COLOR}"/>"#
200 ));
201 }
202 svg.push_str("</g>");
203 for (value, label) in &y_ticks {
204 svg.push_str(&format!(
205 r#"<text x="{}" y="{}" text-anchor="end" fill="{AXIS_COLOR}">{label}</text>"#,
206 left - 8.0,
207 plot.y(*value) + FONT_SIZE * 0.35,
208 ));
209 }
210 for (value, label) in &x_ticks {
211 svg.push_str(&format!(
212 r#"<text x="{}" y="{}" text-anchor="middle" fill="{AXIS_COLOR}">{label}</text>"#,
213 plot.x(value * samples_per_unit),
214 bottom + 8.0 + FONT_SIZE,
215 ));
216 }
217 svg.push_str("</svg>");
218 Ok(svg)
219 }
220
221 pub fn write_png(&self, path: impl AsRef<Path>) -> Result<(), Error> {
224 write_png(&self.to_svg()?, path.as_ref())
225 }
226
227 fn column_paths(&self, plot: &Plot) -> (String, String) {
231 let buckets = envelope(self.samples, plot.width as usize);
232 let (mut peak_path, mut rms_path) = (String::new(), String::new());
233 for (i, bucket) in buckets.iter().enumerate() {
234 let end = buckets.get(i + 1).map_or(self.samples.len(), |b| b.start);
235 let next = self.samples.get(end).copied();
239 let min = next.map_or(bucket.min, |n| bucket.min.min(n));
240 let max = next.map_or(bucket.max, |n| bucket.max.max(n));
241 let (top, bottom) = (plot.y(max), plot.y(min));
242 let pad = ((1.0 - (bottom - top)) / 2.0).max(0.0);
244 let x = plot.x(bucket.start as f32)..plot.x(end as f32);
245 push_rect(&mut peak_path, x.clone(), top - pad..bottom + pad);
246
247 if bucket.min < 0.0 && bucket.max > 0.0 {
250 let rms_top = plot.y(bucket.rms.min(bucket.max));
253 let rms_bottom = plot.y((-bucket.rms).max(bucket.min));
254 push_rect(&mut rms_path, x, rms_top..rms_bottom);
255 }
256 }
257 (peak_path, rms_path)
258 }
259
260 fn samples_per_unit(&self) -> Result<f32, Error> {
263 match self.sample_rate {
264 None => Ok(1.0),
265 Some(rate) if rate.is_finite() && rate > 0.0 => Ok(rate),
266 Some(rate) => Err(Error::InvalidData(format!(
267 "sample rate {rate} must be finite and positive"
268 ))),
269 }
270 }
271
272 fn y_axis_range(&self) -> Result<Range<f32>, Error> {
274 if let Some(range) = &self.y_range {
275 ensure_valid_y_range(range)?;
276 return Ok(range.clone());
277 }
278 let max_abs = self.samples.iter().fold(0.0_f32, |acc, s| acc.max(s.abs()));
279 let y_max = if max_abs == 0.0 { 1.0 } else { max_abs };
280 Ok(-y_max..y_max)
281 }
282}
283
284struct Plot {
287 left: f32,
288 top: f32,
289 width: f32,
290 height: f32,
291 len: usize,
293 y_range: Range<f32>,
294}
295
296impl Plot {
297 fn x(&self, sample_index: f32) -> f32 {
298 self.left + sample_index / self.len as f32 * self.width
299 }
300
301 fn y(&self, amplitude: f32) -> f32 {
303 let Range { start, end } = self.y_range;
304 let clipped = amplitude.clamp(start, end);
305 self.top + (end - clipped) / (end - start) * self.height
306 }
307
308 fn bottom(&self) -> f32 {
309 self.top + self.height
310 }
311}
312
313fn ticks(range: &Range<f32>, max_intervals: f32, unit: &str) -> Vec<(f32, String)> {
320 let (start, end) = (f64::from(range.start), f64::from(range.end));
321 let raw_step = (end - start) / f64::from(max_intervals.max(1.0));
322 if !raw_step.is_finite() || raw_step <= 0.0 {
324 return Vec::new();
325 }
326 let exponent = raw_step.log10().floor() as i32;
327 let magnitude = 10_f64.powi(exponent);
328 let (step, exponent) = [1.0, 2.0, 5.0]
329 .into_iter()
330 .map(|m| m * magnitude)
331 .find(|step| *step >= raw_step)
332 .map_or((10.0 * magnitude, exponent + 1), |step| (step, exponent));
333 let decimals = (-exponent).max(0) as usize;
334 let first = (start / step - 1e-3).ceil() as i64;
337 let last = (end / step + 1e-3).floor() as i64;
338 (first..=last)
339 .map(|k| {
340 let value = k as f64 * step;
341 (value as f32, format!("{value:.decimals$}{unit}"))
342 })
343 .collect()
344}
345
346fn push_rect(path: &mut String, x: Range<f32>, y: Range<f32>) {
348 path.push_str(&format!(
349 "M{:.1} {:.1}H{:.1}V{:.1}H{:.1}Z",
350 x.start, y.start, x.end, y.end, x.start
351 ));
352}
353
354fn escape_xml(text: &str) -> String {
356 text.replace('&', "&")
357 .replace('<', "<")
358 .replace('>', ">")
359}
360
361pub(crate) struct Bucket {
366 pub(crate) start: usize,
368 pub(crate) min: f32,
369 pub(crate) max: f32,
370 pub(crate) rms: f32,
371}
372
373fn envelope(samples: &[f32], columns: usize) -> Vec<Bucket> {
380 let count = columns.min(samples.len());
381 let boundary = |i: usize| i * samples.len() / count;
382 (0..count)
383 .map(|i| bucket_of(boundary(i), &samples[boundary(i)..boundary(i + 1)]))
384 .collect()
385}
386
387pub(crate) fn envelope_exact(samples: &[f32], bucket_len: usize) -> Vec<Bucket> {
396 samples
397 .chunks_exact(bucket_len)
398 .enumerate()
399 .map(|(i, bucket)| bucket_of(i * bucket_len, bucket))
400 .collect()
401}
402
403fn bucket_of(start: usize, samples: &[f32]) -> Bucket {
404 let (min, max, sum_of_squares) = samples
405 .iter()
406 .fold((f32::MAX, f32::MIN, 0.0), |(lo, hi, sq), s| {
407 (lo.min(*s), hi.max(*s), sq + s * s)
408 });
409 Bucket {
410 start,
411 min,
412 max,
413 rms: (sum_of_squares / samples.len() as f32).sqrt(),
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use crate::chart::numeric_labels;
421 use crate::tests::testutil::TEST_OUT_DIR;
422
423 fn full_scale_sine() -> Vec<f32> {
424 (0..44100)
425 .map(|i| (i as f32 / 44100.0 * 2.0 * std::f32::consts::PI * 100.0).sin())
426 .collect()
427 }
428
429 #[test]
430 fn auto_y_axis_is_symmetric_around_zero() {
431 let waveform = Waveform::new(&[0.0, 0.8, -0.3]);
432 assert_eq!(waveform.y_axis_range().unwrap(), -0.8..0.8);
433 }
434
435 #[test]
436 fn y_axis_is_labeled_at_round_values() {
437 let svg = Waveform::new(&full_scale_sine())
438 .sample_rate(44100.0)
439 .y_range(-1.0..1.0)
440 .to_svg()
441 .unwrap();
442 assert_eq!(numeric_labels(&svg), [-1.0, -0.5, 0.0, 0.5, 1.0]);
443 }
444
445 #[test]
446 fn ticks_use_round_steps() {
447 let labels = |range, max_intervals| {
448 ticks(&range, max_intervals, "")
449 .into_iter()
450 .map(|(_, label)| label)
451 .collect::<Vec<_>>()
452 };
453 assert_eq!(labels(-0.79..0.79, 6.0), ["-0.5", "0.0", "0.5"]);
454 assert_eq!(labels(0.2..1.0, 5.0), ["0.2", "0.4", "0.6", "0.8", "1.0"]);
455 assert_eq!(
456 labels(0.0..352_800.0, 8.0),
457 [
458 "0", "50000", "100000", "150000", "200000", "250000", "300000", "350000"
459 ]
460 );
461 }
462
463 #[test]
464 fn y_range_clips_amplitudes_to_the_plot() {
465 let plot = Plot {
466 left: 0.0,
467 top: 10.0,
468 width: 100.0,
469 height: 50.0,
470 len: 1,
471 y_range: -0.5..0.5,
472 };
473 assert_eq!(plot.y(1.0), 10.0);
474 assert_eq!(plot.y(0.0), 35.0);
475 assert_eq!(plot.y(-1.0), 60.0);
476 }
477
478 #[test]
479 fn rejects_invalid_sample_rate() {
480 for rate in [0.0, -44100.0, f32::NAN, f32::INFINITY] {
481 assert!(matches!(
482 Waveform::new(&[0.0]).sample_rate(rate).to_svg(),
483 Err(Error::InvalidData(_))
484 ));
485 }
486 }
487
488 #[test]
489 fn renders_extreme_amplitudes() {
490 for amplitude in [1e-44, f32::MAX] {
491 assert!(Waveform::new(&[amplitude, -amplitude]).to_svg().is_ok());
492 }
493 }
494
495 #[test]
496 fn escapes_the_title() {
497 let svg = Waveform::new(&[0.0]).title("L&R <mix>").to_svg().unwrap();
498 assert!(svg.contains(">L&R <mix></text>"));
499 }
500
501 #[test]
502 fn rejects_invalid_y_range() {
503 for range in [1.0..0.0, 0.0..0.0, f32::NAN..1.0, 0.0..f32::INFINITY] {
504 assert!(matches!(
505 Waveform::new(&[0.0]).y_range(range).to_svg(),
506 Err(Error::InvalidData(_))
507 ));
508 }
509 }
510
511 #[test]
512 fn envelope_keeps_peaks() {
513 let mut samples = vec![0.1_f32; 1000];
514 samples[500] = -0.9;
515 samples[501] = 0.9;
516 let buckets = envelope(&samples, 10);
517 assert_eq!(buckets.len(), 10);
518 assert_eq!(buckets[5].min, -0.9);
519 assert_eq!(buckets[5].max, 0.9);
520 }
521
522 #[test]
523 fn rms_only_in_columns_crossing_zero() {
524 let plot = Plot {
525 left: 0.0,
526 top: 0.0,
527 width: 2.0,
528 height: 100.0,
529 len: 4,
530 y_range: -1.0..1.0,
531 };
532 let (_, rms_path) = Waveform::new(&[0.2, 0.4, -0.5, 0.5]).column_paths(&plot);
533 assert_eq!(rms_path.matches('M').count(), 1);
534 }
535
536 #[test]
537 fn bucket_has_rms() {
538 let bucket = bucket_of(0, &[0.5, -0.5, 0.5, -0.5]);
539 assert_eq!(bucket.rms, 0.5);
540 assert_eq!(bucket_of(0, &[0.0; 4]).rms, 0.0);
541 }
542
543 #[test]
544 fn envelope_covers_all_samples() {
545 let samples: Vec<f32> = (0..10).map(|i| i as f32).collect();
546 let buckets = envelope(&samples, 4);
547 let starts: Vec<_> = buckets.iter().map(|b| b.start).collect();
548 assert_eq!(starts, [0, 2, 5, 7]);
549 assert_eq!(buckets[3].max, 9.0);
550 assert_eq!(envelope(&samples, 100).len(), 10);
552 }
553
554 #[test]
555 fn rejects_empty_input() {
556 assert!(matches!(
557 Waveform::new(&[]).to_svg(),
558 Err(Error::InvalidData(_))
559 ));
560 }
561
562 #[test]
563 fn rejects_nan() {
564 assert!(matches!(
565 Waveform::new(&[0.0, f32::NAN]).to_svg(),
566 Err(Error::InvalidData(_))
567 ));
568 }
569
570 #[test]
571 fn writes_png_file() {
572 Waveform::new(&full_scale_sine())
573 .sample_rate(44100.0)
574 .title("100 Hz sine wave")
575 .write_png(format!("{TEST_OUT_DIR}/waveform_sine_100hz.png"))
576 .unwrap();
577 }
578}