Skip to main content

rich_ext/chart/
spectrogram.rs

1//! A spectrogram, or waterfall: spectra over time, the newest at the top,
2//! each a row of shades, as cava, spek and Audacity draw them.
3
4use std::collections::VecDeque;
5
6use rich::measure::Measurement;
7use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
8
9use super::{AxisScale, Charset, Line, Scale, has_colour, lines_to_segments, theme_style};
10
11/// Levels for blocks, lowest first (as a [`Heatmap`](super::Heatmap)'s).
12const SHADES: [char; 5] = [' ', '░', '▒', '▓', '█'];
13/// Levels for ASCII, lowest first.
14const ASCII_SHADES: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
15/// How many `chart.heat.N` styles there are.
16const HEAT_STYLES: usize = 5;
17/// The tallest spectrogram drawn, in rows.
18const MAX_HEIGHT: usize = u16::MAX as usize;
19
20/// Spectra against time: each [`push`](Self::push)ed spectrum (the
21/// magnitudes of its bins, lowest frequency first) becomes a row, the
22/// newest at the top, and older rows move down until `capacity` are kept.
23///
24/// - Each column stands for a span of frequencies: the bins are spread
25///   across the width, evenly, or by their logarithm with
26///   [`scale`](Self::scale)`(AxisScale::Log)` so each octave takes the same
27///   width. A column covering several bins shows the loudest; one between
28///   bins (the low end of a log scale) shows the nearest.
29///   [`resolution`](Self::resolution) says how many Hz apart the bins are
30///   and [`bounds`](Self::bounds) which frequencies the width spans
31///   (default: every bin, from 0 Hz).
32/// - Levels are scaled from the smallest to the largest kept (or
33///   [`range`](Self::range)) into the shades of a
34///   [`Heatmap`](super::Heatmap): ` ░▒▓█` in blocks, ` .:-=+*#%@` in
35///   ASCII, and with colour the `chart.heat.N` styles, cold to hot. A NaN
36///   level is `·` (`?` in ASCII); an infinite one is drawn at the end it
37///   passes, so the decibels of silence (`-inf`) are the lowest shade.
38///   Feed it decibels (`20 log10(magnitude)`) to see quiet partials beside
39///   loud ones.
40/// - It fills the height it is given (a `Layout` region, an intuiTUIve
41///   node) unless [`height`](Self::height) is set; given none, it is as
42///   tall as the rows it holds. Rows below the oldest are blank.
43///
44/// ```
45/// use rich::Console;
46/// use rich_ext::chart::{Charset, Spectrogram};
47///
48/// let console = Console::builder().width(4).color_system(None).build();
49/// let mut waterfall = Spectrogram::new(3).charset(Charset::Ascii).range(0.0, 9.0);
50/// waterfall.push([9.0, 0.0, 0.0, 0.0]);
51/// waterfall.push([0.0, 9.0, 0.0, 0.0]);
52/// waterfall.push([0.0, 0.0, 9.0, 4.5]);
53/// // The newest at the top.
54/// assert_eq!(console.render_export(&waterfall), "  @+\n @  \n@   \n");
55/// waterfall.push([0.0; 4]);
56/// // Three kept: the oldest has gone.
57/// assert_eq!(console.render_export(&waterfall), "    \n  @+\n @  \n");
58/// ```
59#[derive(Clone, Debug, PartialEq)]
60#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
61pub struct Spectrogram {
62    rows: VecDeque<Vec<f64>>,
63    capacity: usize,
64    min: Option<f64>,
65    max: Option<f64>,
66    resolution: f64,
67    bounds: Option<(f64, f64)>,
68    scale: AxisScale,
69    charset: Charset,
70    height: Option<usize>,
71}
72
73impl Spectrogram {
74    /// An empty spectrogram keeping the newest `capacity` spectra (at
75    /// least one).
76    pub fn new(capacity: usize) -> Self {
77        Spectrogram {
78            rows: VecDeque::new(),
79            capacity: capacity.max(1),
80            min: None,
81            max: None,
82            resolution: 1.0,
83            bounds: None,
84            scale: AxisScale::Linear,
85            charset: Charset::Auto,
86            height: None,
87        }
88    }
89
90    /// Add a spectrum at the top: the levels of its bins, lowest frequency
91    /// first. The oldest goes once there are `capacity`.
92    pub fn push(&mut self, bins: impl IntoIterator<Item = f64>) {
93        if self.rows.len() == self.capacity {
94            self.rows.pop_back();
95        }
96        self.rows.push_front(bins.into_iter().collect());
97    }
98
99    /// [`push`](Self::push) as a builder.
100    pub fn row(mut self, bins: impl IntoIterator<Item = f64>) -> Self {
101        self.push(bins);
102        self
103    }
104
105    /// Forget every spectrum.
106    pub fn clear(&mut self) {
107        self.rows.clear();
108    }
109
110    /// How many spectra it holds.
111    pub fn len(&self) -> usize {
112        self.rows.len()
113    }
114
115    /// Whether it holds none.
116    pub fn is_empty(&self) -> bool {
117        self.rows.is_empty()
118    }
119
120    /// The spectra, newest first.
121    pub fn rows(&self) -> impl Iterator<Item = &[f64]> {
122        self.rows.iter().map(Vec::as_slice)
123    }
124
125    /// The levels shaded lowest and highest (default: the smallest and
126    /// largest kept). Levels outside are drawn at the end they pass.
127    pub fn range(mut self, min: f64, max: f64) -> Self {
128        self.min = Some(min);
129        self.max = Some(max);
130        self
131    }
132
133    /// How many Hz apart the bins are (default 1): `rate / n` for a
134    /// spectrum of `n` samples taken at `rate`. Bin `i` is at
135    /// `i × resolution` Hz.
136    pub fn resolution(mut self, hz: f64) -> Self {
137        if hz.is_finite() && hz > 0.0 {
138            self.resolution = hz;
139        }
140        self
141    }
142
143    /// The frequencies the width spans, in Hz (default: every bin, from 0
144    /// Hz to the top of the last; from the first bin above 0 on a log
145    /// scale). Bin `i` covers `i × resolution` up to the next bin.
146    pub fn bounds(mut self, low: f64, high: f64) -> Self {
147        self.bounds = Some((low.min(high), low.max(high)));
148        self
149    }
150
151    /// How frequency runs across: evenly (the default) or by its logarithm.
152    pub fn scale(mut self, scale: AxisScale) -> Self {
153        self.scale = scale;
154        self
155    }
156
157    /// Which glyphs to draw with (default [`Charset::Auto`]: blocks, or
158    /// ASCII on an ASCII-only console).
159    pub fn charset(mut self, charset: Charset) -> Self {
160        self.charset = charset;
161        self
162    }
163
164    /// Height in rows (at most 65535), whatever height it is given.
165    pub fn height(mut self, rows: usize) -> Self {
166        self.height = Some(rows.min(MAX_HEIGHT));
167        self
168    }
169
170    /// The scale every level is shaded on.
171    pub fn level_scale(&self) -> Scale {
172        let values = self.rows.iter().flat_map(|r| r.iter().copied());
173        Scale::from_values(values).bounds(self.min, self.max)
174    }
175
176    fn bins(&self) -> usize {
177        self.rows.iter().map(Vec::len).max().unwrap_or(0)
178    }
179
180    /// The frequency span across the width, mapped (Hz, or log10 Hz).
181    fn span(&self) -> Option<(f64, f64)> {
182        let bins = self.bins();
183        if bins == 0 {
184            return None;
185        }
186        let top = bins as f64 * self.resolution;
187        let (low, high) = self.bounds.unwrap_or(match self.scale {
188            AxisScale::Linear => (0.0, top),
189            AxisScale::Log => (self.resolution, top),
190        });
191        match self.scale {
192            AxisScale::Linear => Some((low, high)),
193            AxisScale::Log if low > 0.0 && high > 0.0 => Some((low.log10(), high.log10())),
194            AxisScale::Log => None,
195        }
196    }
197
198    /// For each of `width` columns, the bins it shows: a range, never
199    /// empty.
200    fn columns(&self, width: usize) -> Vec<(usize, usize)> {
201        let bins = self.bins();
202        let Some((lo, hi)) = self.span() else {
203            return Vec::new();
204        };
205        let unmap = |m: f64| match self.scale {
206            AxisScale::Linear => m,
207            AxisScale::Log => 10f64.powf(m),
208        };
209        // The bin a frequency falls in, within the bins there are.
210        let bin = |hz: f64| ((hz / self.resolution).max(0.0) as usize).min(bins - 1);
211        (0..width)
212            .map(|c| {
213                let at = |k: f64| lo + (hi - lo) * k / width as f64;
214                let (from, to) = (unmap(at(c as f64)), unmap(at(c as f64 + 1.0)));
215                let first = (from / self.resolution).ceil().max(0.0) as usize;
216                let last = (to / self.resolution).ceil().max(0.0) as usize;
217                if first < last && first < bins {
218                    (first, last.min(bins))
219                } else {
220                    let nearest = bin((from + to) / 2.0);
221                    (nearest, nearest + 1)
222                }
223            })
224            .collect()
225    }
226
227    fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
228        let width = options.max_width;
229        let height = self
230            .height
231            .or(options.height)
232            .unwrap_or(self.rows.len().max(1))
233            .min(MAX_HEIGHT);
234        let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
235        let colour = has_colour(console);
236        let shades: &[char] = if ascii { &ASCII_SHADES } else { &SHADES };
237        let missing = if ascii { "?" } else { "·" };
238        let levels = shades.len();
239        let scale = self.level_scale();
240        let columns = self.columns(width);
241        let style = |level: usize| {
242            let key = format!("chart.heat.{}", level * HEAT_STYLES / levels + 1);
243            colour.then(|| theme_style(console, &key))
244        };
245        (0..height)
246            .map(|r| {
247                let mut line = Line::new();
248                let Some(row) = self.rows.get(r) else {
249                    line.pad(width);
250                    return line;
251                };
252                for &(first, last) in &columns {
253                    let loudest = row
254                        .get(first..last.min(row.len()))
255                        .unwrap_or_default()
256                        .iter()
257                        .copied()
258                        .filter(|v| !v.is_nan())
259                        .fold(None, |a: Option<f64>, v| Some(a.map_or(v, |a| a.max(v))));
260                    let level = loudest
261                        .and_then(|v| scale.normalize(v.clamp(scale.min(), scale.max())))
262                        .map(|n| ((n * levels as f64).floor() as usize).min(levels - 1));
263                    match level {
264                        Some(level) => line.push(&shades[level].to_string(), style(level)),
265                        None => line.push(missing, None::<Style>),
266                    }
267                }
268                line.pad(width.saturating_sub(columns.len()));
269                line
270            })
271            .collect()
272    }
273}
274
275impl crate::surface::Draw for Spectrogram {
276    fn draw(
277        &self,
278        console: &Console,
279        options: &ConsoleOptions,
280        screen: &mut crate::surface::Screen,
281        rect: crate::surface::Rect,
282    ) {
283        super::draw_lines(
284            self.lines(console, options),
285            options.max_width,
286            options,
287            screen,
288            rect,
289        );
290    }
291}
292
293impl Renderable for Spectrogram {
294    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
295        lines_to_segments(self.lines(console, options), options.max_width)
296    }
297
298    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
299        let bins = self.bins().max(1);
300        Measurement::new(1, bins)
301            .with_maximum(options.max_width)
302            .normalize()
303    }
304}
305
306/// `Spectrogram, 12 spectra of 513 bins, 0 to 24k Hz, levels from 0 to 3`,
307/// then where the newest spectrum is loudest (`Newest peaks at 440 Hz`).
308impl crate::a11y::AccessibleText for Spectrogram {
309    fn accessible_text(&self, _width: usize) -> String {
310        use super::ValueFormat;
311        use super::describe::{counted, extent, range};
312        if self.rows.is_empty() {
313            return "Spectrogram, no spectra".to_string();
314        }
315        let mut head = format!(
316            "Spectrogram, {} of {}",
317            counted(self.rows.len(), "spectrum", "spectra"),
318            counted(self.bins(), "bin", "bins")
319        );
320        if let Some((lo, hi)) = self.span() {
321            let unmap = |m: f64| match self.scale {
322                AxisScale::Linear => m,
323                AxisScale::Log => 10f64.powf(m),
324            };
325            head.push_str(&format!(
326                ", {} Hz",
327                range(unmap(lo), unmap(hi), ValueFormat::Compact).trim_start_matches("from ")
328            ));
329            if self.scale == AxisScale::Log {
330                head.push_str(", log scale");
331            }
332        }
333        let levels = self.rows.iter().flat_map(|r| r.iter().copied());
334        if let Some((lo, hi)) = extent(levels) {
335            head.push_str(&format!(", levels {}", range(lo, hi, ValueFormat::Compact)));
336        }
337        let mut out = vec![head];
338        let newest = &self.rows[0];
339        let peak = newest
340            .iter()
341            .enumerate()
342            .filter(|(_, v)| v.is_finite())
343            .max_by(|a, b| a.1.total_cmp(b.1));
344        if let Some((bin, _)) = peak {
345            let hz = bin as f64 * self.resolution;
346            out.push(format!(
347                "Newest peaks at {} Hz",
348                ValueFormat::Compact.format(hz)
349            ));
350        }
351        out.join("\n")
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::a11y::AccessibleText;
359
360    fn plain(width: usize, spectrogram: &Spectrogram) -> String {
361        let console = Console::builder().width(width).color_system(None).build();
362        console.render_export(spectrogram)
363    }
364
365    #[test]
366    fn wide_spectra_show_the_loudest_bin_of_each_column() {
367        let mut s = Spectrogram::new(2).charset(Charset::Ascii).range(0.0, 9.0);
368        s.push([0.0, 9.0, 0.0, 0.0, 0.0, 0.0, 9.0, 0.0]);
369        assert_eq!(plain(4, &s), "@  @\n");
370    }
371
372    #[test]
373    fn narrow_spectra_repeat_the_nearest_bin() {
374        let s = Spectrogram::new(1)
375            .charset(Charset::Ascii)
376            .range(0.0, 9.0)
377            .row([0.0, 9.0]);
378        assert_eq!(plain(4, &s), "  @@\n");
379    }
380
381    #[test]
382    fn a_log_scale_gives_each_octave_the_same_width() {
383        // Bins 1 Hz apart up to 16 Hz: 1-2, 2-4, 4-8 and 8-16 Hz a column.
384        let mut bins = vec![0.0; 17];
385        bins[1] = 9.0;
386        bins[12] = 9.0;
387        let s = Spectrogram::new(1)
388            .charset(Charset::Ascii)
389            .range(0.0, 9.0)
390            .scale(AxisScale::Log)
391            .bounds(1.0, 16.0)
392            .row(bins);
393        assert_eq!(plain(4, &s), "@  @\n");
394    }
395
396    #[test]
397    fn missing_levels_and_padding() {
398        let s = Spectrogram::new(4)
399            .charset(Charset::Ascii)
400            .range(0.0, 1.0)
401            .height(2)
402            .row([f64::NAN, 1.0, f64::NEG_INFINITY]);
403        assert_eq!(plain(3, &s), "?@ \n   \n");
404        assert_eq!(plain(3, &Spectrogram::new(1)), "   \n");
405    }
406
407    #[test]
408    fn heat_styles_with_colour() {
409        let console = Console::builder()
410            .width(2)
411            .force_terminal(true)
412            .color_system(Some(rich::ColorSystem::Standard))
413            .build();
414        let s = Spectrogram::new(1).range(0.0, 1.0).row([0.0, 1.0]);
415        let out = console.render_export(&s);
416        // The loudest in chart.heat.5, red.
417        assert!(out.contains("\u{1b}[31m█"), "{out:?}");
418    }
419
420    #[test]
421    fn accessible_text_names_the_peak() {
422        let s = Spectrogram::new(4)
423            .resolution(10.0)
424            .row([1.0, 3.0, 2.0])
425            .row([0.0, 1.0, 5.0]);
426        assert_eq!(
427            s.accessible_text(80),
428            "Spectrogram, 2 spectra of 3 bins, 0 to 30 Hz, levels from 0 to 5\n\
429             Newest peaks at 20 Hz"
430        );
431        assert_eq!(
432            Spectrogram::new(1).accessible_text(80),
433            "Spectrogram, no spectra"
434        );
435    }
436}