1use 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
11const SHADES: [char; 5] = [' ', '░', '▒', '▓', '█'];
13const ASCII_SHADES: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
15const HEAT_STYLES: usize = 5;
17const MAX_HEIGHT: usize = u16::MAX as usize;
19
20#[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 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 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 pub fn row(mut self, bins: impl IntoIterator<Item = f64>) -> Self {
101 self.push(bins);
102 self
103 }
104
105 pub fn clear(&mut self) {
107 self.rows.clear();
108 }
109
110 pub fn len(&self) -> usize {
112 self.rows.len()
113 }
114
115 pub fn is_empty(&self) -> bool {
117 self.rows.is_empty()
118 }
119
120 pub fn rows(&self) -> impl Iterator<Item = &[f64]> {
122 self.rows.iter().map(Vec::as_slice)
123 }
124
125 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 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 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 pub fn scale(mut self, scale: AxisScale) -> Self {
153 self.scale = scale;
154 self
155 }
156
157 pub fn charset(mut self, charset: Charset) -> Self {
160 self.charset = charset;
161 self
162 }
163
164 pub fn height(mut self, rows: usize) -> Self {
166 self.height = Some(rows.min(MAX_HEIGHT));
167 self
168 }
169
170 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 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 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 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
306impl 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 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 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}