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;
#[derive(Debug, Clone)]
pub struct Spectrum<'a> {
data: &'a [(f32, f32)],
highlights: Vec<f32>,
y_range: Option<Range<f32>>,
width: u32,
height: u32,
title: String,
}
impl<'a> Spectrum<'a> {
#[must_use]
pub const fn new(data: &'a [(f32, f32)]) -> Self {
Self {
data,
highlights: vec![],
y_range: None,
width: 1400,
height: 500,
title: String::new(),
}
}
#[must_use]
pub fn highlight(mut self, frequency_hz: f32) -> Self {
self.highlights.push(frequency_hz);
self
}
#[must_use]
pub fn highlights(mut self, frequencies_hz: &[f32]) -> Self {
for frequency in frequencies_hz {
self.highlights.push(*frequency);
}
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.to_svg()?, path.as_ref())
}
fn chart(&self) -> Result<LineChart, Error> {
ensure_finite_and_non_empty(self.data.iter().flat_map(|(f, m)| [*f, *m]))?;
let mut data = self.data.to_vec();
data.sort_by(|a, b| a.0.total_cmp(&b.0));
let y_range = self.y_axis_range(&data)?;
for (_, magnitude) in &mut data {
*magnitude = magnitude.clamp(y_range.start, y_range.end);
}
let x_labels = data.iter().map(|(f, _)| format_frequency(*f)).collect();
let magnitudes = Series::new(
"magnitude".to_string(),
data.iter().map(|(_, m)| *m).collect(),
);
let mut series_list = vec![magnitudes];
if !self.highlights.is_empty() {
series_list.push(Series::new_nullable(
"highlight".to_string(),
highlight_series(&data, &self.highlights),
));
}
let mut chart = new_line_chart(series_list, x_labels, self.width, self.height, &self.title);
chart.series_colors[1] = (255, 0, 0).into();
set_y_range(&mut chart, &y_range);
Ok(chart)
}
fn y_axis_range(&self, data: &[(f32, f32)]) -> Result<Range<f32>, Error> {
if let Some(range) = &self.y_range {
ensure_valid_y_range(range)?;
return Ok(range.clone());
}
let (min, max) = data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), (_, m)| {
(lo.min(*m), hi.max(*m))
});
let max = if max == min { 1.0 } else { max };
Ok(min..max)
}
}
fn highlight_series(data: &[(f32, f32)], highlights: &[f32]) -> Vec<Option<f32>> {
let mut overlay = vec![None; data.len()];
for highlight in highlights {
let nearest = data
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (a.0 - highlight).abs().total_cmp(&(b.0 - highlight).abs()))
.map(|(i, _)| i)
.expect("data is non-empty");
let range = nearest.saturating_sub(1)..=(nearest + 1).min(data.len() - 1);
for i in range {
overlay[i] = Some(data[i].1);
}
}
overlay
}
fn format_frequency(frequency_hz: f32) -> String {
if frequency_hz.fract().abs() < 0.01 {
format!("{frequency_hz:.0}")
} else {
format!("{frequency_hz:.1}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chart::numeric_labels;
use crate::tests::testutil::TEST_OUT_DIR;
fn peak_60hz_spectrum() -> Vec<(f32, f32)> {
[
(0, 0.0),
(10, 5.0),
(20, 20.0),
(30, 40.0),
(40, 80.0),
(50, 120.0),
(55, 130.0),
(60, 140.0),
(65, 130.0),
(70, 120.0),
(80, 80.0),
(90, 40.0),
(100, 20.0),
(110, 5.0),
(120, 0.0),
(130, 0.0),
]
.iter()
.map(|(f, m)| (*f as f32, *m))
.collect()
}
#[test]
fn highlight_marks_nearest_entries() {
let data = peak_60hz_spectrum();
let overlay = highlight_series(&data, &[61.0]);
assert_eq!(overlay[6], Some(130.0));
assert_eq!(overlay[7], Some(140.0));
assert_eq!(overlay[8], Some(130.0));
assert_eq!(overlay.iter().filter(|v| v.is_some()).count(), 3);
}
#[test]
fn auto_y_axis_ends_at_the_peak() {
let svg = Spectrum::new(&peak_60hz_spectrum()).to_svg().unwrap();
let max_label = numeric_labels(&svg).into_iter().fold(f32::MIN, f32::max);
assert_eq!(max_label, 140.0);
}
#[test]
fn y_range_fixes_the_axis_and_clips() {
let data = [(1.0, 10.0), (2.0, 1000.0), (3.0, 10.0)];
let svg = Spectrum::new(&data).y_range(0.0..100.0).to_svg().unwrap();
let labels = numeric_labels(&svg);
let max_label = labels.iter().copied().fold(f32::MIN, f32::max);
assert_eq!(max_label, 100.0);
assert!(labels.iter().all(|l| l.is_sign_positive()), "{labels:?}");
}
#[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!(
Spectrum::new(&[(0.0, 0.0)]).y_range(range).to_svg(),
Err(Error::InvalidData(_))
));
}
}
#[test]
fn rejects_nan() {
assert!(matches!(
Spectrum::new(&[(0.0, f32::NAN)]).to_svg(),
Err(Error::InvalidData(_))
));
}
#[test]
fn rejects_empty_input() {
assert!(matches!(
Spectrum::new(&[]).to_svg(),
Err(Error::InvalidData(_))
));
}
#[test]
fn writes_png_file() {
let data = peak_60hz_spectrum();
Spectrum::new(&data)
.highlight(60.0)
.title("Spectrum with peak at 60 Hz")
.write_png(format!("{TEST_OUT_DIR}/spectrum_60hz_peak.png"))
.unwrap();
}
}