Skip to main content

audio_visualizer/
spectrum.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 frequency spectrum visualization: render `(frequency, magnitude)`
25//! pairs to a PNG file or SVG string via [`Spectrum`].
26
27use crate::chart::{
28    ensure_finite_and_non_empty, ensure_valid_y_range, new_line_chart, set_y_range, write_png,
29};
30use crate::error::Error;
31use charts_rs::{LineChart, Series};
32use std::ops::Range;
33use std::path::Path;
34
35/// Builder that renders a frequency spectrum as an image.
36///
37/// Input is a list of `(frequency in Hz, magnitude)` pairs; it does not need
38/// to be sorted. The x-axis is labeled with the frequencies, the y-axis
39/// ranges from zero to the largest magnitude unless [`Self::y_range`] fixes
40/// it.
41///
42/// This crate does not compute spectra itself; pair it with an FFT crate such
43/// as `spectrum-analyzer`. Individual frequencies can be highlighted in the
44/// resulting chart, which is handy to verify that an expected peak is where
45/// it should be. For real-time visualization see [`crate::live`].
46///
47/// # Example
48/// ```no_run
49/// use audio_visualizer::SpectrumVisualizer;
50///
51/// let spectrum: Vec<(f32, f32)> = vec![(55.0, 0.1), (60.0, 0.9), (65.0, 0.2)];
52/// SpectrumVisualizer::new(&spectrum)
53///     .highlight(60.0)
54///     .write_png("spectrum.png")
55///     .unwrap();
56/// ```
57#[derive(Debug, Clone)]
58pub struct Spectrum<'a> {
59    data: &'a [(f32, f32)],
60    highlights: Vec<f32>,
61    y_range: Option<Range<f32>>,
62    width: u32,
63    height: u32,
64    title: String,
65}
66
67impl<'a> Spectrum<'a> {
68    /// Creates a spectrum visualization of `(frequency in Hz, magnitude)`
69    /// pairs.
70    #[must_use]
71    pub const fn new(data: &'a [(f32, f32)]) -> Self {
72        Self {
73            data,
74            highlights: vec![],
75            y_range: None,
76            width: 1400,
77            height: 500,
78            title: String::new(),
79        }
80    }
81
82    /// Highlights the spectrum entry closest to the given frequency with a
83    /// red marker. Can be called multiple times.
84    #[must_use]
85    pub fn highlight(mut self, frequency_hz: f32) -> Self {
86        self.highlights.push(frequency_hz);
87        self
88    }
89
90    /// Highlights the spectrum entry closest to the given frequencies with a
91    /// red marker. Can be called multiple times.
92    #[must_use]
93    pub fn highlights(mut self, frequencies_hz: &[f32]) -> Self {
94        for frequency in frequencies_hz {
95            self.highlights.push(*frequency);
96        }
97        self
98    }
99
100    /// Fixes the y-axis to the given range instead of fitting it to the
101    /// data. Magnitudes outside the range are clipped to its bounds.
102    ///
103    /// This makes images of different spectra comparable.
104    #[must_use]
105    pub const fn y_range(mut self, range: Range<f32>) -> Self {
106        self.y_range = Some(range);
107        self
108    }
109
110    /// Sets the image dimensions in pixels. Default: 1400x500.
111    #[must_use]
112    pub const fn size(mut self, width: u32, height: u32) -> Self {
113        self.width = width;
114        self.height = height;
115        self
116    }
117
118    /// Sets a title displayed above the chart.
119    #[must_use]
120    pub fn title(mut self, title: impl Into<String>) -> Self {
121        self.title = title.into();
122        self
123    }
124
125    /// Renders the spectrum to an SVG string.
126    pub fn to_svg(&self) -> Result<String, Error> {
127        Ok(self.chart()?.svg()?)
128    }
129
130    /// Renders the spectrum and writes it as PNG file, creating missing
131    /// parent directories.
132    pub fn write_png(&self, path: impl AsRef<Path>) -> Result<(), Error> {
133        write_png(&self.to_svg()?, path.as_ref())
134    }
135
136    fn chart(&self) -> Result<LineChart, Error> {
137        ensure_finite_and_non_empty(self.data.iter().flat_map(|(f, m)| [*f, *m]))?;
138
139        let mut data = self.data.to_vec();
140        data.sort_by(|a, b| a.0.total_cmp(&b.0));
141        let y_range = self.y_axis_range(&data)?;
142        for (_, magnitude) in &mut data {
143            *magnitude = magnitude.clamp(y_range.start, y_range.end);
144        }
145
146        let x_labels = data.iter().map(|(f, _)| format_frequency(*f)).collect();
147        let magnitudes = Series::new(
148            "magnitude".to_string(),
149            data.iter().map(|(_, m)| *m).collect(),
150        );
151        let mut series_list = vec![magnitudes];
152        if !self.highlights.is_empty() {
153            series_list.push(Series::new_nullable(
154                "highlight".to_string(),
155                highlight_series(&data, &self.highlights),
156            ));
157        }
158
159        let mut chart = new_line_chart(series_list, x_labels, self.width, self.height, &self.title);
160        chart.series_colors[1] = (255, 0, 0).into();
161        set_y_range(&mut chart, &y_range);
162        Ok(chart)
163    }
164
165    /// The fixed range, or zero to the largest magnitude. Negative
166    /// magnitudes, e.g. in dB, extend the range downwards.
167    fn y_axis_range(&self, data: &[(f32, f32)]) -> Result<Range<f32>, Error> {
168        if let Some(range) = &self.y_range {
169            ensure_valid_y_range(range)?;
170            return Ok(range.clone());
171        }
172        let (min, max) = data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), (_, m)| {
173            (lo.min(*m), hi.max(*m))
174        });
175        // silence: all magnitudes are zero, keep a visible axis anyway
176        let max = if max == min { 1.0 } else { max };
177        Ok(min..max)
178    }
179}
180
181/// Overlay series that is `None` everywhere except a short segment around
182/// the entry closest to each highlighted frequency. A segment of three
183/// points guarantees visibility independent of the spectral resolution.
184fn highlight_series(data: &[(f32, f32)], highlights: &[f32]) -> Vec<Option<f32>> {
185    let mut overlay = vec![None; data.len()];
186    for highlight in highlights {
187        let nearest = data
188            .iter()
189            .enumerate()
190            .min_by(|(_, a), (_, b)| (a.0 - highlight).abs().total_cmp(&(b.0 - highlight).abs()))
191            .map(|(i, _)| i)
192            .expect("data is non-empty");
193        let range = nearest.saturating_sub(1)..=(nearest + 1).min(data.len() - 1);
194        for i in range {
195            overlay[i] = Some(data[i].1);
196        }
197    }
198    overlay
199}
200
201/// Compact frequency label: no decimals for values that are (almost) whole.
202fn format_frequency(frequency_hz: f32) -> String {
203    if frequency_hz.fract().abs() < 0.01 {
204        format!("{frequency_hz:.0}")
205    } else {
206        format!("{frequency_hz:.1}")
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::chart::numeric_labels;
214    use crate::tests::testutil::TEST_OUT_DIR;
215
216    /// The old hardcoded example spectrum with a peak at 60 Hz.
217    fn peak_60hz_spectrum() -> Vec<(f32, f32)> {
218        [
219            (0, 0.0),
220            (10, 5.0),
221            (20, 20.0),
222            (30, 40.0),
223            (40, 80.0),
224            (50, 120.0),
225            (55, 130.0),
226            (60, 140.0),
227            (65, 130.0),
228            (70, 120.0),
229            (80, 80.0),
230            (90, 40.0),
231            (100, 20.0),
232            (110, 5.0),
233            (120, 0.0),
234            (130, 0.0),
235        ]
236        .iter()
237        .map(|(f, m)| (*f as f32, *m))
238        .collect()
239    }
240
241    #[test]
242    fn highlight_marks_nearest_entries() {
243        let data = peak_60hz_spectrum();
244        let overlay = highlight_series(&data, &[61.0]);
245        // nearest to 61 Hz is 60 Hz (index 7), plus one neighbor on each side
246        assert_eq!(overlay[6], Some(130.0));
247        assert_eq!(overlay[7], Some(140.0));
248        assert_eq!(overlay[8], Some(130.0));
249        assert_eq!(overlay.iter().filter(|v| v.is_some()).count(), 3);
250    }
251
252    #[test]
253    fn auto_y_axis_ends_at_the_peak() {
254        let svg = Spectrum::new(&peak_60hz_spectrum()).to_svg().unwrap();
255        // the frequency labels are numeric too, but all below the peak
256        let max_label = numeric_labels(&svg).into_iter().fold(f32::MIN, f32::max);
257        assert_eq!(max_label, 140.0);
258    }
259
260    #[test]
261    fn y_range_fixes_the_axis_and_clips() {
262        // the peak is clipped, so the data touches the upper bound
263        let data = [(1.0, 10.0), (2.0, 1000.0), (3.0, 10.0)];
264        let svg = Spectrum::new(&data).y_range(0.0..100.0).to_svg().unwrap();
265        let labels = numeric_labels(&svg);
266        // the frequency labels are numeric too, but far below the bound
267        let max_label = labels.iter().copied().fold(f32::MIN, f32::max);
268        assert_eq!(max_label, 100.0);
269        // the lower bound must be labeled "0", not "-0"
270        assert!(labels.iter().all(|l| l.is_sign_positive()), "{labels:?}");
271    }
272
273    #[test]
274    fn rejects_invalid_y_range() {
275        for range in [1.0..0.0, 0.0..0.0, f32::NAN..1.0, 0.0..f32::INFINITY] {
276            assert!(matches!(
277                Spectrum::new(&[(0.0, 0.0)]).y_range(range).to_svg(),
278                Err(Error::InvalidData(_))
279            ));
280        }
281    }
282
283    #[test]
284    fn rejects_nan() {
285        assert!(matches!(
286            Spectrum::new(&[(0.0, f32::NAN)]).to_svg(),
287            Err(Error::InvalidData(_))
288        ));
289    }
290
291    #[test]
292    fn rejects_empty_input() {
293        assert!(matches!(
294            Spectrum::new(&[]).to_svg(),
295            Err(Error::InvalidData(_))
296        ));
297    }
298
299    #[test]
300    fn writes_png_file() {
301        let data = peak_60hz_spectrum();
302        Spectrum::new(&data)
303            .highlight(60.0)
304            .title("Spectrum with peak at 60 Hz")
305            .write_png(format!("{TEST_OUT_DIR}/spectrum_60hz_peak.png"))
306            .unwrap();
307    }
308}