Krolyxon
Commit b139f20

rmptuisrcscreensvisualizer.rs

352 lines · 11.0 KB · Rust

1//! Screen 6: music visualizer fed by MPD's fifo output, with ncmpcpp's
2//! styles: wave, filled wave, spectrum and ellipse.
3
4use std::sync::Arc;
5
6use ratatui::{Frame, buffer::Buffer, layout::Rect, style::Color, text::Line};
7use tokio::sync::watch;
8
9use super::{Ctx, Screen};
10use crate::{
11 action::Action,
12 config::{Config, VisualizerConfig, VisualizerStyle},
13 ui,
14 visualizer::{AudioFrame, Bars},
15};
16
17const EIGHTHS: [&str; 9] = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
18
19pub struct VisualizerScreen {
20 frames: watch::Receiver<Arc<AudioFrame>>,
21 bars: Bars,
22 style: VisualizerStyle,
23}
24
25impl VisualizerScreen {
26 pub fn new(config: &Config, frames: watch::Receiver<Arc<AudioFrame>>) -> Self {
27 Self {
28 frames,
29 bars: Bars::default(),
30 style: config.visualizer.style,
31 }
32 }
33
34 fn draw_waves<'a>(&self, buffer: &mut Buffer, area: Rect, audio: &'a AudioFrame, ctx: &Ctx) {
35 let config = &ctx.config.visualizer;
36 let colors = &ctx.theme().visualizer;
37 let filled = self.style == VisualizerStyle::WaveFilled;
38 // Like ncmpcpp, show the audio of one frame.
39 let window = (config.sample_rate / config.framerate) as usize;
40 let recent = |samples: &'a [f32]| &samples[samples.len().saturating_sub(window)..];
41 if config.stereo && config.channels == 2 {
42 let top = Rect {
43 height: area.height / 2,
44 ..area
45 };
46 let bottom = Rect {
47 y: area.y + top.height,
48 height: area.height - top.height,
49 ..area
50 };
51 draw_wave(
52 buffer,
53 top,
54 recent(&audio.left),
55 filled,
56 &config.wave_symbol,
57 colors,
58 );
59 draw_wave(
60 buffer,
61 bottom,
62 recent(&audio.right),
63 filled,
64 &config.wave_symbol,
65 colors,
66 );
67 } else {
68 let mono: Vec<f32> = recent(&audio.left)
69 .iter()
70 .zip(recent(&audio.right))
71 .map(|(l, r)| (l + r) / 2.0)
72 .collect();
73 draw_wave(buffer, area, &mono, filled, &config.wave_symbol, colors);
74 }
75 }
76
77 fn draw_spectrum(&mut self, buffer: &mut Buffer, area: Rect, audio: &AudioFrame, ctx: &Ctx) {
78 let config = &ctx.config.visualizer;
79 let stride = config.bar_width + config.bar_gap;
80 let count = usize::from((area.width + config.bar_gap) / stride);
81 let heights = self.bars.update(
82 audio,
83 count,
84 config.min_frequency,
85 config.max_frequency,
86 config.falloff,
87 );
88 let colors = &ctx.theme().visualizer;
89 let rows = area.height;
90 // Center the bars horizontally.
91 let used = (count as u16 * stride).saturating_sub(config.bar_gap);
92 let left = area.x + area.width.saturating_sub(used) / 2;
93 for (i, &height) in heights.iter().enumerate() {
94 let eighths = (height * f32::from(rows) * 8.0).round() as u32;
95 for row in 0..rows {
96 let filled = eighths.saturating_sub(u32::from(row) * 8).min(8) as usize;
97 if filled == 0 {
98 break;
99 }
100 let y = area.bottom() - 1 - row;
101 let color = gradient(colors, f32::from(row) / f32::from(rows));
102 for dx in 0..config.bar_width {
103 let x = left + i as u16 * stride + dx;
104 if x < area.right() {
105 buffer[(x, y)].set_symbol(EIGHTHS[filled]).set_fg(color);
106 }
107 }
108 }
109 }
110 }
111}
112
113impl Screen for VisualizerScreen {
114 fn title(&self, _ctx: &Ctx) -> Line<'static> {
115 Line::from("Music visualizer")
116 }
117
118 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
119 let audio = Arc::clone(&self.frames.borrow_and_update());
120 if let Some(error) = &audio.error {
121 draw_setup_help(frame, area, error, &ctx.config.visualizer, ctx);
122 return;
123 }
124 if area.width == 0 || area.height == 0 {
125 return;
126 }
127 let buffer = frame.buffer_mut();
128 match self.style {
129 VisualizerStyle::Wave | VisualizerStyle::WaveFilled => {
130 self.draw_waves(buffer, area, &audio, ctx);
131 }
132 VisualizerStyle::Spectrum => self.draw_spectrum(buffer, area, &audio, ctx),
133 VisualizerStyle::Ellipse => draw_ellipse(
134 buffer,
135 area,
136 &audio,
137 &ctx.config.visualizer.wave_symbol,
138 &ctx.theme().visualizer,
139 ),
140 }
141 }
142
143 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
144 match action {
145 Action::CycleVisualizer => {
146 self.style = self.style.next();
147 ctx.notify(format!("Visualizer: {}", self.style.name()));
148 true
149 }
150 _ => false,
151 }
152 }
153}
154
155fn draw_setup_help(
156 frame: &mut Frame,
157 area: Rect,
158 error: &str,
159 config: &VisualizerConfig,
160 ctx: &Ctx,
161) {
162 let fifo = config.fifo.display().to_string();
163 ui::draw_placeholder(
164 frame,
165 area,
166 &[
167 error,
168 "",
169 "Add a fifo output to mpd.conf and restart MPD:",
170 "",
171 "audio_output {",
172 " type \"fifo\"",
173 " name \"Visualizer feed\"",
174 &format!(" path \"{fifo}\""),
175 " format \"44100:16:2\"",
176 "}",
177 ],
178 ctx.theme(),
179 );
180}
181
182/// Draws one channel across `area`, centered vertically. A plain wave joins
183/// each point to the previous column's; a filled one reaches the center line.
184fn draw_wave(
185 buffer: &mut Buffer,
186 area: Rect,
187 samples: &[f32],
188 filled: bool,
189 symbol: &str,
190 colors: &[Color],
191) {
192 if area.width == 0 || area.height == 0 || samples.is_empty() {
193 return;
194 }
195 let center = center_row(area.height);
196 let mut previous = None;
197 for x in 0..area.width {
198 let sample = column_average(samples, usize::from(x), usize::from(area.width));
199 let row = sample_row(sample, area.height);
200 let (from, to) = if filled {
201 span(center, row)
202 } else {
203 span(previous.unwrap_or(row), row)
204 };
205 previous = Some(row);
206 for y in from..=to {
207 let distance = f32::from(y.abs_diff(center)) / f32::from(center.max(1));
208 buffer[(area.x + x, area.y + y)]
209 .set_symbol(symbol)
210 .set_fg(gradient(colors, distance));
211 }
212 }
213}
214
215/// Plots left against right channel samples, one point per sample.
216fn draw_ellipse(
217 buffer: &mut Buffer,
218 area: Rect,
219 audio: &AudioFrame,
220 symbol: &str,
221 colors: &[Color],
222) {
223 let half_w = f32::from(area.width.saturating_sub(1)) / 2.0;
224 let half_h = f32::from(area.height.saturating_sub(1)) / 2.0;
225 for (&l, &r) in audio.left.iter().zip(&audio.right) {
226 let (l, r) = (l.clamp(-1.0, 1.0), r.clamp(-1.0, 1.0));
227 let x = (half_w + l * half_w).round() as u16;
228 let y = (half_h - r * half_h).round() as u16;
229 let distance = (l * l + r * r).sqrt().min(1.0);
230 buffer[(area.x + x, area.y + y)]
231 .set_symbol(symbol)
232 .set_fg(gradient(colors, distance));
233 }
234}
235
236/// The mean of the samples that fall into column `x` of `width`.
237fn column_average(samples: &[f32], x: usize, width: usize) -> f32 {
238 let start = x * samples.len() / width;
239 let end = ((x + 1) * samples.len() / width).max(start + 1);
240 let column = &samples[start..end.min(samples.len())];
241 column.iter().sum::<f32>() / column.len() as f32
242}
243
244/// The row of the zero line in an area `height` rows tall.
245fn center_row(height: u16) -> u16 {
246 height.saturating_sub(1) / 2
247}
248
249/// The row a sample in `-1.0..=1.0` lands on; positive values go up.
250fn sample_row(sample: f32, height: u16) -> u16 {
251 let last = f32::from(height.saturating_sub(1));
252 let row = ((1.0 - sample.clamp(-1.0, 1.0)) / 2.0 * last).round();
253 row as u16
254}
255
256/// The rows from `a` to `b`, lowest first.
257fn span(a: u16, b: u16) -> (u16, u16) {
258 (a.min(b), a.max(b))
259}
260
261/// The theme color for `level` in `0.0..=1.0`, from the first color up.
262fn gradient(colors: &[Color], level: f32) -> Color {
263 let index = (level.clamp(0.0, 1.0) * colors.len() as f32) as usize;
264 colors
265 .get(index.min(colors.len().saturating_sub(1)))
266 .copied()
267 .unwrap_or_default()
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn samples_map_to_rows() {
276 assert_eq!(sample_row(1.0, 11), 0);
277 assert_eq!(sample_row(0.0, 11), 5);
278 assert_eq!(sample_row(-1.0, 11), 10);
279 assert_eq!(sample_row(5.0, 11), 0);
280 assert_eq!(center_row(11), 5);
281 }
282
283 #[test]
284 fn columns_average_their_samples() {
285 let samples = [0.0, 1.0, 0.5, 0.5];
286 assert_eq!(column_average(&samples, 0, 2), 0.5);
287 assert_eq!(column_average(&samples, 1, 2), 0.5);
288 // More columns than samples repeats samples instead of panicking.
289 assert_eq!(column_average(&samples, 7, 8), 0.5);
290 }
291
292 #[test]
293 fn plain_wave_connects_to_previous_point() {
294 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 11));
295 draw_wave(
296 &mut buffer,
297 Rect::new(0, 0, 2, 11),
298 &[1.0, -1.0],
299 false,
300 "●",
301 &[Color::Red],
302 );
303 let column = |x| (0..11).filter(|&y| buffer[(x, y)].symbol() == "●").count();
304 assert_eq!(column(0), 1);
305 assert_eq!(column(1), 11);
306 }
307
308 #[test]
309 fn filled_wave_reaches_center() {
310 let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 11));
311 draw_wave(
312 &mut buffer,
313 Rect::new(0, 0, 1, 11),
314 &[1.0],
315 true,
316 "●",
317 &[Color::Red],
318 );
319 let drawn: Vec<u16> = (0..11)
320 .filter(|&y| buffer[(0, y)].symbol() == "●")
321 .collect();
322 assert_eq!(drawn, (0..=5).collect::<Vec<_>>());
323 }
324
325 #[test]
326 fn gradient_picks_by_level() {
327 let colors = [Color::Blue, Color::Green, Color::Red];
328 assert_eq!(gradient(&colors, 0.0), Color::Blue);
329 assert_eq!(gradient(&colors, 0.5), Color::Green);
330 assert_eq!(gradient(&colors, 1.0), Color::Red);
331 }
332
333 #[test]
334 fn styles_cycle_like_ncmpcpp() {
335 let mut style = VisualizerStyle::Wave;
336 let mut seen = vec![style];
337 for _ in 0..4 {
338 style = style.next();
339 seen.push(style);
340 }
341 assert_eq!(
342 seen,
343 [
344 VisualizerStyle::Wave,
345 VisualizerStyle::WaveFilled,
346 VisualizerStyle::Spectrum,
347 VisualizerStyle::Ellipse,
348 VisualizerStyle::Wave,
349 ]
350 );
351 }
352}