use crate::chart::{
ensure_finite_and_non_empty, ensure_valid_y_range, new_line_chart, set_y_range, write_png,
};
use crate::error::Error;
use charts_rs::{LineChart, Series};
use std::ops::Range;
use std::path::Path;
const MAX_POINTS: usize = 1200;
#[derive(Debug, Clone)]
pub struct Waveform<'a> {
samples: &'a [f32],
sample_rate: Option<f32>,
y_range: Option<Range<f32>>,
width: u32,
height: u32,
title: String,
}
impl<'a> Waveform<'a> {
#[must_use]
pub const fn new(samples: &'a [f32]) -> Self {
Self {
samples,
sample_rate: None,
y_range: None,
width: 1400,
height: 400,
title: String::new(),
}
}
#[must_use]
pub const fn sample_rate(mut self, sample_rate_hz: f32) -> Self {
self.sample_rate = Some(sample_rate_hz);
self
}
#[must_use]
pub const fn y_range(mut self, range: Range<f32>) -> Self {
self.y_range = Some(range);
self
}
#[must_use]
pub const fn size(mut self, width: u32, height: u32) -> Self {
self.width = width;
self.height = height;
self
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn to_svg(&self) -> Result<String, Error> {
Ok(self.chart()?.svg()?)
}
pub fn write_png(&self, path: impl AsRef<Path>) -> Result<(), Error> {
write_png(&self.chart()?, path.as_ref())
}
fn chart(&self) -> Result<LineChart, Error> {
ensure_finite_and_non_empty(self.samples.iter().copied())?;
let y_range = self.y_axis_range()?;
let clip = |amplitude: f32| amplitude.clamp(y_range.start, y_range.end);
let buckets = envelope(self.samples, MAX_POINTS);
let x_labels = buckets.iter().map(|b| self.x_label(b.start)).collect();
let mut upper = Series::new(
"upper".to_string(),
buckets.iter().map(|b| clip(b.max)).collect(),
);
let mut lower = Series::new(
"lower".to_string(),
buckets.iter().map(|b| clip(b.min)).collect(),
);
upper.index = Some(0);
lower.index = Some(0);
let mut chart = new_line_chart(
vec![upper, lower],
x_labels,
self.width,
self.height,
&self.title,
);
set_y_range(&mut chart, &y_range);
Ok(chart)
}
fn y_axis_range(&self) -> Result<Range<f32>, Error> {
if let Some(range) = &self.y_range {
ensure_valid_y_range(range)?;
return Ok(range.clone());
}
let max_abs = self.samples.iter().fold(0.0_f32, |acc, s| acc.max(s.abs()));
let y_max = if max_abs == 0.0 { 1.0 } else { max_abs };
Ok(-y_max..y_max)
}
fn x_label(&self, sample_index: usize) -> String {
self.sample_rate.map_or_else(
|| sample_index.to_string(),
|rate| format!("{:.2}s", sample_index as f32 / rate),
)
}
}
pub(crate) struct Bucket {
pub(crate) start: usize,
pub(crate) min: f32,
pub(crate) max: f32,
}
pub(crate) fn envelope(samples: &[f32], max_points: usize) -> Vec<Bucket> {
let bucket_len = samples.len().div_ceil(max_points);
samples
.chunks(bucket_len)
.enumerate()
.map(|(i, bucket)| bucket_of(i, bucket_len, bucket))
.collect()
}
pub(crate) fn envelope_exact(samples: &[f32], bucket_len: usize) -> Vec<Bucket> {
samples
.chunks_exact(bucket_len)
.enumerate()
.map(|(i, bucket)| bucket_of(i, bucket_len, bucket))
.collect()
}
fn bucket_of(index: usize, bucket_len: usize, samples: &[f32]) -> Bucket {
let (min, max) = samples
.iter()
.fold((f32::MAX, f32::MIN), |(lo, hi), s| (lo.min(*s), hi.max(*s)));
Bucket {
start: index * bucket_len,
min,
max,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chart::numeric_labels;
use crate::tests::testutil::TEST_OUT_DIR;
fn full_scale_sine() -> Vec<f32> {
(0..44100)
.map(|i| (i as f32 / 44100.0 * 2.0 * std::f32::consts::PI * 100.0).sin())
.collect()
}
fn y_axis_bounds(svg: &str) -> (f32, f32) {
let labels = numeric_labels(svg);
let min = labels.iter().copied().fold(f32::MAX, f32::min);
let max = labels.iter().copied().fold(f32::MIN, f32::max);
(min, max)
}
#[test]
fn auto_y_axis_is_symmetric_around_zero() {
let svg = Waveform::new(&full_scale_sine())
.sample_rate(44100.0)
.to_svg()
.unwrap();
assert_eq!(y_axis_bounds(&svg), (-1.0, 1.0));
}
#[test]
fn y_range_fixes_the_axis_exactly() {
for range in [-0.5..0.5, 0.2..1.0] {
let svg = Waveform::new(&full_scale_sine())
.sample_rate(44100.0)
.y_range(range.clone())
.to_svg()
.unwrap();
assert_eq!(y_axis_bounds(&svg), (range.start, range.end));
}
}
#[test]
fn rejects_invalid_y_range() {
for range in [1.0..0.0, 0.0..0.0, f32::NAN..1.0, 0.0..f32::INFINITY] {
assert!(matches!(
Waveform::new(&[0.0]).y_range(range).to_svg(),
Err(Error::InvalidData(_))
));
}
}
#[test]
fn envelope_keeps_peaks() {
let mut samples = vec![0.1_f32; 1000];
samples[500] = -0.9;
samples[501] = 0.9;
let buckets = envelope(&samples, 10);
assert_eq!(buckets.len(), 10);
assert_eq!(buckets[5].min, -0.9);
assert_eq!(buckets[5].max, 0.9);
}
#[test]
fn rejects_empty_input() {
assert!(matches!(
Waveform::new(&[]).to_svg(),
Err(Error::InvalidData(_))
));
}
#[test]
fn rejects_nan() {
assert!(matches!(
Waveform::new(&[0.0, f32::NAN]).to_svg(),
Err(Error::InvalidData(_))
));
}
#[test]
fn writes_png_file() {
Waveform::new(&full_scale_sine())
.sample_rate(44100.0)
.title("100 Hz sine wave")
.write_png(format!("{TEST_OUT_DIR}/waveform_sine_100hz.png"))
.unwrap();
}
}