Skip to main content

audio_visualizer/
waveform.rs

1/*
2MIT License
3
4Copyright (c) 2026 Philipp Schuster
5
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24//! Static waveform visualization: render mono audio samples to a PNG file or
25//! SVG string via [`Waveform`].
26
27use 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
33// Colors and font sizes of the charts-rs light theme, so that waveform and
34// spectrum images look alike.
35const 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
43/// Minimum distances between two axis ticks in pixels, so that the labels
44/// do not crowd.
45const MIN_X_TICK_SPACING: f32 = 80.0;
46const MIN_Y_TICK_SPACING: f32 = 50.0;
47
48/// Builder that renders mono audio samples as a waveform image.
49///
50/// Samples are expected as amplitudes in `[-1.0, 1.0]`, the usual DSP
51/// convention; other symmetric ranges work too since the y-axis fits the
52/// data unless [`Self::y_range`] fixes it. For interleaved stereo data, split
53/// it with [`crate::deinterleave_stereo`] first and render each channel
54/// separately.
55///
56/// # What the image shows
57///
58/// An image is far narrower than the audio is long: one second at 44.1 kHz
59/// is dozens of samples per pixel column already. A column therefore is a
60/// filled bar from the smallest to the largest sample it covers, as in audio
61/// editors such as Audacity - the shape is the peak amplitude over time, and
62/// the oscillation within a column is deliberately not resolved. A lighter
63/// bar inside shows the RMS of the column, i.e. how loud it is.
64///
65/// Drawing a line through every n-th sample instead would undersample the
66/// signal by orders of magnitude: peaks between two picked samples vanish,
67/// and what is left aliases into a pattern that is not in the audio.
68///
69/// For real-time visualization see [`crate::live`].
70///
71/// # Example
72/// ```no_run
73/// use audio_visualizer::WaveformVisualizer;
74///
75/// let samples: Vec<f32> = vec![0.0, 0.5, -0.5, 0.3];
76/// WaveformVisualizer::new(&samples)
77///     .sample_rate(44100.0)
78///     .write_png("waveform.png")
79///     .unwrap();
80/// ```
81#[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    /// Creates a waveform visualization of the given mono samples.
93    #[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    /// Labels the x-axis with seconds instead of sample indices.
106    #[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    /// Fixes the y-axis to the given range instead of fitting it to the
113    /// data. Amplitudes outside the range are clipped to its bounds.
114    ///
115    /// This makes images of different signals comparable: with `-1.0..1.0`,
116    /// every image shows the full scale of `[-1.0, 1.0]` samples.
117    #[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    /// Sets the image dimensions in pixels. Default: 1400x400.
124    #[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    /// Sets a title displayed above the chart.
132    #[must_use]
133    pub fn title(mut self, title: impl Into<String>) -> Self {
134        self.title = title.into();
135        self
136    }
137
138    /// Renders the waveform to an SVG string.
139    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        // Whole pixels, so that every column of the waveform is exactly one
153        // pixel wide.
154        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        // Grid, waveform and axis are aligned to pixels; anti-aliasing
181        // would only blur them.
182        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    /// Renders the waveform and writes it as PNG file, creating missing
222    /// parent directories.
223    pub fn write_png(&self, path: impl AsRef<Path>) -> Result<(), Error> {
224        write_png(&self.to_svg()?, path.as_ref())
225    }
226
227    /// One filled column per pixel from the smallest to the largest sample
228    /// it covers, and a lighter one inside for the RMS (the loudness), each
229    /// as a single SVG path.
230    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            // The column also covers the step to the next column's first
236            // sample. Otherwise, the columns of a steep signal float apart,
237            // as each one only covers its own samples.
238            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            // At least one pixel high, so that silence remains visible.
243            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            // A column that does not cross zero shows a part of a single
248            // slope rather than a level around zero.
249            if bucket.min < 0.0 && bucket.max > 0.0 {
250                // Kept inside the peak bar, which it exceeds for samples
251                // that are not centered around zero.
252                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    /// Samples per x-axis unit: per second with a sample rate, otherwise the
261    /// axis counts samples.
262    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    /// The fixed range, or the peak amplitude mirrored around zero.
273    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
284/// Maps sample positions and amplitudes to pixels of the plot area, i.e.
285/// the image without title and axis labels.
286struct Plot {
287    left: f32,
288    top: f32,
289    width: f32,
290    height: f32,
291    /// Number of samples, spanning the full width.
292    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    /// Amplitudes outside the y-axis range are clipped to its bounds.
302    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
313/// Ticks at round values, i.e. multiples of 1, 2 or 5 times a power of
314/// ten, using the smallest such step that splits `range` into at most
315/// `max_intervals` intervals. Returns the value and label of each tick.
316///
317/// Computed in f64: in f32, the step underflows to zero for amplitudes close
318/// to zero, and the span of a wide range overflows.
319fn 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    // An empty or infinite range has no meaningful ticks.
323    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    // The tolerance keeps a tick on a bound that is a multiple of the step
335    // except for rounding errors.
336    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
346/// Appends a rectangle as closed subpath to SVG path data.
347fn 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
354/// Escapes text for use as content of an SVG element.
355fn escape_xml(text: &str) -> String {
356    text.replace('&', "&amp;")
357        .replace('<', "&lt;")
358        .replace('>', "&gt;")
359}
360
361/// One chart point or image column per bucket of consecutive samples.
362///
363/// A bucket keeps only its minimum, maximum and RMS; everything else is
364/// discarded. That is what makes the reduction lossy but peak-preserving.
365pub(crate) struct Bucket {
366    /// Index of the bucket's first sample, used for the x-axis position.
367    pub(crate) start: usize,
368    pub(crate) min: f32,
369    pub(crate) max: f32,
370    pub(crate) rms: f32,
371}
372
373/// Reduces the samples to one min/max bucket per pixel column, or to one
374/// bucket per sample if there are fewer samples than columns.
375///
376/// The bucket length follows from the input length, which is what a static
377/// image needs: the whole input is covered whatever its size. Bucket lengths
378/// differ by at most one sample, so that all columns have the same width.
379fn 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
387/// Like [`envelope`], but with a caller-chosen bucket length and without a
388/// trailing bucket that is not completely filled.
389///
390/// A scrolling view needs both, because its input is a moving window over a
391/// stream instead of a fixed slice: the bucket length must not change with
392/// the window, and a partially filled bucket would summarize a different
393/// number of samples on every frame. See `live::aligned_envelope` for the
394/// invariant built on top of this.
395pub(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&amp;R &lt;mix&gt;</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        // Fewer samples than columns: one bucket per sample.
551        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}