Krolyxon
Commit b139f20

rmptuib139f20

Add ncmpcpp's visualizer styles, cycled with Space

The reader now publishes per-channel samples alongside the spectrum, and
the visualizer draws wave (the default), filled wave, spectrum and
ellipse, with stereo waves split into top and bottom halves. New options:
style, stereo and wave_symbol; cycle_visualizer is bound to Space.
Author
krolyxon
Date
2026-10-17 17:51 +0530
Parent
222ab2f
Commit
b139f201d459ea7ca6210f3edae668a8e099b35c

+413 −86

10 files changed

  1. M CHANGELOG.md +2 −1
  2. M README.md +7 −2
  3. M src/action.rs +3 −0
  4. M src/config/default.toml +9 −0
  5. M src/config/mod.rs +36 −0
  6. M src/mpd/worker.rs +3 −1
  7. M src/screens/mod.rs +3 −3
  8. M src/screens/visualizer.rs +298 −50
  9. M src/visualizer/bars.rs +8 −8
  10. M src/visualizer/mod.rs +44 −21
M CHANGELOG.md View
@@ -3,7 +3,8 @@
33 A rewrite from scratch with a new architecture modelled on ncmpcpp and rmpc.
44
55 - New screens: Media Library (artists → albums → songs), Tag Editor,
6− spectrum Visualizer (MPD fifo output) and synced Lyrics (local `.lrc`
6+ ncmpcpp-style Visualizer (wave, filled wave, spectrum and ellipse, in
7+ stereo, from an MPD fifo output) and synced Lyrics (local `.lrc`
78 files, cache and LRCLIB), plus a lyrics pane on the queue.
89 - Browser shows stored playlists at the bottom of the root folder;
910 playlist editor, queue and browser keep every 0.1 feature.
M README.md View
@@ -8,7 +8,7 @@
88 rmptui is a terminal interface for [Music Player Daemon
99 (MPD)](https://www.musicpd.org/) that looks and feels like ncmpcpp. Browse your
1010 library, manage the queue and playlists, edit tags, read synced lyrics and
11−watch a spectrum visualizer without leaving the terminal.
11+watch a music visualizer without leaving the terminal.
1212
1313 ![rmptui screenshot](https://raw.githubusercontent.com/krolyxon/rmptui/master/assets/ss.png)
1414
@@ -21,7 +21,7 @@ watch a spectrum visualizer without leaving the terminal.
2121 | `3` | **Playlist editor** | Stored playlists and their songs: load, play from a song, remove or reorder songs, rename or delete playlists. |
2222 | `4` | **Media library** | Album artists → albums (with year) → songs. `t` switches between AlbumArtist and Artist. |
2323 | `5` | **Tag editor** | Directories, tag types and tags, like ncmpcpp: edit Title, Artist, Album Artist, Album, Date, Track, Genre, Composer, Performer, Disc and Comment of one or many songs, rename files, fix letter case, then save. |
24−| `6` | **Music visualizer** | A spectrum analyzer fed by an MPD fifo output. |
24+| `6` | **Music visualizer** | ncmpcpp-style wave, filled wave, spectrum and ellipse visualizers fed by an MPD fifo output; `Space` cycles them. |
2525 | `7` | **Lyrics** | Synced lyrics that follow playback, or plain lyrics you can scroll. |
2626
2727 Every screen shares ncmpcpp's frame: the screen title and volume on top
@@ -168,6 +168,11 @@ audio_output {
168168 `[visualizer]` must match the `path` and `format`. The fifo is only read while
169169 the Visualizer screen is visible.
170170
171+Like ncmpcpp, there are four styles: `wave` (the default), `wave_filled`,
172+`spectrum` and `ellipse`. Set the startup one with `style` and press `Space` on
173+the screen to cycle through them. With `stereo = true` the wave styles draw the
174+left channel in the top half and the right channel in the bottom half.
175+
171176 ### Lyrics
172177
173178 Lyrics are looked up in this order:
M src/action.rs View
@@ -133,6 +133,7 @@ named_enum! {
133133 ToggleArtistTag = "toggle_artist_tag",
134134 SaveTags = "save_tags",
135135 RevertTags = "revert_tags",
136+ CycleVisualizer = "cycle_visualizer",
136137 ReloadLyrics = "reload_lyrics",
137138 // Popups and text input
138139 Close = "close",
@@ -219,6 +220,7 @@ impl Action {
219220 DeletePlaylist | RenamePlaylist => &[C::Playlists],
220221 ToggleArtistTag => &[C::Library],
221222 SaveTags | RevertTags => &[C::TagEditor],
223+ CycleVisualizer => &[C::Visualizer],
222224 ReloadLyrics => &[C::Lyrics],
223225 Close => &[C::Popup],
224226 Submit | Cancel | Backspace | DeleteChar | DeleteWord | CursorLeft | CursorRight
@@ -287,6 +289,7 @@ impl Action {
287289 ToggleArtistTag => "Switch between AlbumArtist and Artist",
288290 SaveTags => "Write pending tag changes",
289291 RevertTags => "Discard pending tag changes",
292+ CycleVisualizer => "Switch to the next visualizer style",
290293 ReloadLyrics => "Fetch lyrics again",
291294 Close => "Close the popup",
292295 Submit => "Submit",
M src/config/default.toml View
@@ -95,6 +95,14 @@ lrclib_url = "https://lrclib.net"
9595 # format "44100:16:2"
9696 # }
9797 fifo = "/tmp/mpd.fifo"
98+# Startup style: wave, wave_filled, spectrum or ellipse. Press
99+# `cycle_visualizer` (Space) on the visualizer screen to switch.
100+style = "wave"
101+# Draw the left channel in the top half and the right one in the bottom half
102+# (wave styles only).
103+stereo = true
104+# Character used to draw the wave and ellipse styles.
105+wave_symbol = "●"
98106 # Must match the `format` of the fifo output.
99107 sample_rate = 44100
100108 channels = 2
@@ -193,6 +201,7 @@ save_tags = ["w"]
193201 revert_tags = ["u"]
194202
195203 [keys.visualizer]
204+cycle_visualizer = ["Space"]
196205
197206 [keys.lyrics]
198207 reload_lyrics = ["F5"]
M src/config/mod.rs View
@@ -123,6 +123,9 @@ pub struct LyricsConfig {
123123 #[serde(deny_unknown_fields)]
124124 pub struct VisualizerConfig {
125125 pub fifo: PathBuf,
126+ pub style: VisualizerStyle,
127+ pub stereo: bool,
128+ pub wave_symbol: String,
126129 pub sample_rate: u32,
127130 pub channels: u16,
128131 pub framerate: u32,
@@ -133,6 +136,35 @@ pub struct VisualizerConfig {
133136 pub falloff: f32,
134137 }
135138
139+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
140+#[serde(rename_all = "snake_case")]
141+pub enum VisualizerStyle {
142+ Wave,
143+ WaveFilled,
144+ Spectrum,
145+ Ellipse,
146+}
147+
148+impl VisualizerStyle {
149+ pub fn next(self) -> Self {
150+ match self {
151+ Self::Wave => Self::WaveFilled,
152+ Self::WaveFilled => Self::Spectrum,
153+ Self::Spectrum => Self::Ellipse,
154+ Self::Ellipse => Self::Wave,
155+ }
156+ }
157+
158+ pub fn name(self) -> &'static str {
159+ match self {
160+ Self::Wave => "wave",
161+ Self::WaveFilled => "wave filled",
162+ Self::Spectrum => "spectrum",
163+ Self::Ellipse => "ellipse",
164+ }
165+ }
166+}
167+
136168 #[derive(Debug, Deserialize)]
137169 #[serde(deny_unknown_fields)]
138170 struct RawConfig {
@@ -273,6 +305,10 @@ impl Config {
273305 vis.min_frequency > 0.0 && vis.min_frequency < vis.max_frequency,
274306 "visualizer.min_frequency must be positive and below max_frequency",
275307 )?;
308+ check(
309+ vis.wave_symbol.chars().count() == 1,
310+ "visualizer.wave_symbol must be a single character",
311+ )?;
276312 check(
277313 vis.falloff > 0.0,
278314 "visualizer.falloff must be greater than zero",
M src/mpd/worker.rs View
@@ -296,7 +296,9 @@ async fn execute(conn: &mut Connection, request: &Request) -> MpdResult<Option<R
296296 }
297297 Request::LoadAndPlay { name, offset } => {
298298 let start = queue_length(conn).await?;
299− let entries = conn.execute(&Command::new("listplaylist").arg(name)).await?;
299+ let entries = conn
300+ .execute(&Command::new("listplaylist").arg(name))
301+ .await?;
300302 conn.execute(&commands::load(name)).await?;
301303 // `load` silently skips entries the server cannot resolve, so the
302304 // playlist index is not necessarily the queue offset.
M src/screens/mod.rs View
@@ -31,7 +31,7 @@ use crate::{
3131 popups::Popup,
3232 state::MpdState,
3333 tags::{TagField, TagWrite},
34− visualizer::Spectrum,
34+ visualizer::AudioFrame,
3535 };
3636
3737 /// What a screen or popup may read, plus a queue of effects for the app.
@@ -136,14 +136,14 @@ pub struct Screens {
136136 }
137137
138138 impl Screens {
139− pub fn new(config: &Config, spectrum: watch::Receiver<Arc<Spectrum>>) -> Self {
139+ pub fn new(config: &Config, spectrum: watch::Receiver<Arc<AudioFrame>>) -> Self {
140140 Self {
141141 queue: QueueScreen::new(config),
142142 browser: BrowserScreen::default(),
143143 playlists: PlaylistsScreen::default(),
144144 library: LibraryScreen::default(),
145145 tag_editor: TagEditorScreen::default(),
146− visualizer: VisualizerScreen::new(spectrum),
146+ visualizer: VisualizerScreen::new(config, spectrum),
147147 lyrics: LyricsScreen::default(),
148148 }
149149 }
M src/screens/visualizer.rs View
@@ -1,83 +1,95 @@
1−//! Screen 6: spectrum visualizer fed by MPD's fifo output.
1+//! Screen 6: music visualizer fed by MPD's fifo output, with ncmpcpp's
2+//! styles: wave, filled wave, spectrum and ellipse.
23
34 use std::sync::Arc;
45
5−use ratatui::{Frame, layout::Rect, text::Line};
6+use ratatui::{Frame, buffer::Buffer, layout::Rect, style::Color, text::Line};
67 use tokio::sync::watch;
78
89 use super::{Ctx, Screen};
910 use crate::{
1011 action::Action,
12+ config::{Config, VisualizerConfig, VisualizerStyle},
1113 ui,
12− visualizer::{Bars, Spectrum},
14+ visualizer::{AudioFrame, Bars},
1315 };
1416
1517 const EIGHTHS: [&str; 9] = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
1618
1719 pub struct VisualizerScreen {
18− spectrum: watch::Receiver<Arc<Spectrum>>,
20+ frames: watch::Receiver<Arc<AudioFrame>>,
1921 bars: Bars,
22+ style: VisualizerStyle,
2023 }
2124
2225 impl VisualizerScreen {
23− pub fn new(spectrum: watch::Receiver<Arc<Spectrum>>) -> Self {
26+ pub fn new(config: &Config, frames: watch::Receiver<Arc<AudioFrame>>) -> Self {
2427 Self {
25− spectrum,
28+ frames,
2629 bars: Bars::default(),
30+ style: config.visualizer.style,
2731 }
2832 }
29−}
3033
31−impl Screen for VisualizerScreen {
32− fn title(&self, _ctx: &Ctx) -> Line<'static> {
33− Line::from("Music visualizer")
34− }
35−
36− fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
37− let theme = ctx.theme();
34+ fn draw_waves<'a>(&self, buffer: &mut Buffer, area: Rect, audio: &'a AudioFrame, ctx: &Ctx) {
3835 let config = &ctx.config.visualizer;
39− let inner = area;
40− let spectrum = Arc::clone(&self.spectrum.borrow_and_update());
41− if let Some(error) = &spectrum.error {
42− let fifo = config.fifo.display().to_string();
43− ui::draw_placeholder(
44− frame,
45− inner,
46− &[
47− error.as_str(),
48− "",
49− "Add a fifo output to mpd.conf and restart MPD:",
50− "",
51− "audio_output {",
52− " type \"fifo\"",
53− " name \"Visualizer feed\"",
54− &format!(" path \"{fifo}\""),
55− " format \"44100:16:2\"",
56− "}",
57− ],
58− theme,
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,
5958 );
60− return;
61− }
62− if inner.width == 0 || inner.height == 0 {
63− return;
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);
6474 }
75+ }
6576
77+ fn draw_spectrum(&mut self, buffer: &mut Buffer, area: Rect, audio: &AudioFrame, ctx: &Ctx) {
78+ let config = &ctx.config.visualizer;
6679 let stride = config.bar_width + config.bar_gap;
67− let count = usize::from((inner.width + config.bar_gap) / stride);
80+ let count = usize::from((area.width + config.bar_gap) / stride);
6881 let heights = self.bars.update(
69− &spectrum,
82+ audio,
7083 count,
7184 config.min_frequency,
7285 config.max_frequency,
7386 config.falloff,
7487 );
75− let colors = &theme.visualizer;
76− let rows = inner.height;
88+ let colors = &ctx.theme().visualizer;
89+ let rows = area.height;
7790 // Center the bars horizontally.
7891 let used = (count as u16 * stride).saturating_sub(config.bar_gap);
79− let left = inner.x + inner.width.saturating_sub(used) / 2;
80− let buffer = frame.buffer_mut();
92+ let left = area.x + area.width.saturating_sub(used) / 2;
8193 for (i, &height) in heights.iter().enumerate() {
8294 let eighths = (height * f32::from(rows) * 8.0).round() as u32;
8395 for row in 0..rows {
@@ -85,20 +97,256 @@ impl Screen for VisualizerScreen {
8597 if filled == 0 {
8698 break;
8799 }
88− let y = inner.bottom() - 1 - row;
89− let level = usize::from(row) * colors.len() / usize::from(rows);
90− let color = colors[level.min(colors.len() - 1)];
100+ let y = area.bottom() - 1 - row;
101+ let color = gradient(colors, f32::from(row) / f32::from(rows));
91102 for dx in 0..config.bar_width {
92103 let x = left + i as u16 * stride + dx;
93− if x < inner.right() {
104+ if x < area.right() {
94105 buffer[(x, y)].set_symbol(EIGHTHS[filled]).set_fg(color);
95106 }
96107 }
97108 }
98109 }
99110 }
111+}
112+
113+impl 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+
155+fn 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.
184+fn 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.
216+fn 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`.
237+fn 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.
245+fn 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.
250+fn 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.
257+fn span(a: u16, b: u16) -> (u16, u16) {
258+ (a.min(b), a.max(b))
259+}
100260
101− fn handle_action(&mut self, _action: Action, _ctx: &mut Ctx) -> bool {
102− false
261+/// The theme color for `level` in `0.0..=1.0`, from the first color up.
262+fn 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)]
271+mod 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+ );
103351 }
104352 }
M src/visualizer/bars.rs View
@@ -2,7 +2,7 @@
22
33 use std::time::Instant;
44
5−use super::Spectrum;
5+use super::AudioFrame;
66
77 /// Bar heights (0.0–1.0) with gravity falloff between frames.
88 #[derive(Debug)]
@@ -29,7 +29,7 @@ impl Bars {
2929 /// logarithmic scale. `falloff` is in heights per second.
3030 pub fn update(
3131 &mut self,
32− spectrum: &Spectrum,
32+ spectrum: &AudioFrame,
3333 count: usize,
3434 min_hz: f32,
3535 max_hz: f32,
@@ -53,7 +53,7 @@ impl Bars {
5353 }
5454
5555 /// Levels (0.0–1.0) of `count` log-spaced bands.
56−pub fn band_levels(spectrum: &Spectrum, count: usize, min_hz: f32, max_hz: f32) -> Vec<f32> {
56+pub fn band_levels(spectrum: &AudioFrame, count: usize, min_hz: f32, max_hz: f32) -> Vec<f32> {
5757 let bins = &spectrum.magnitudes;
5858 if count == 0 || bins.is_empty() || spectrum.bin_hz <= 0.0 {
5959 return vec![0.0; count];
@@ -85,14 +85,14 @@ pub fn band_levels(spectrum: &Spectrum, count: usize, min_hz: f32, max_hz: f32)
8585 mod tests {
8686 use super::*;
8787
88− fn spectrum_with_peak(hz: f32) -> Spectrum {
88+ fn spectrum_with_peak(hz: f32) -> AudioFrame {
8989 let bin_hz = 44_100.0 / 2048.0;
9090 let mut magnitudes = vec![0.0; 1025];
9191 magnitudes[(hz / bin_hz).round() as usize] = 1.0;
92− Spectrum {
92+ AudioFrame {
9393 magnitudes,
9494 bin_hz,
95− error: None,
95+ ..AudioFrame::default()
9696 }
9797 }
9898
@@ -113,7 +113,7 @@ mod tests {
113113 #[test]
114114 fn silence_and_empty_input_give_zero() {
115115 assert_eq!(
116− band_levels(&Spectrum::default(), 4, 40.0, 16_000.0),
116+ band_levels(&AudioFrame::default(), 4, 40.0, 16_000.0),
117117 [0.0; 4]
118118 );
119119 }
@@ -123,7 +123,7 @@ mod tests {
123123 let mut bars = Bars::default();
124124 let loud = spectrum_with_peak(1000.0);
125125 let peak = bars.update(&loud, 20, 40.0, 16_000.0, 1.0).to_vec();
126− let after = bars.update(&Spectrum::default(), 20, 40.0, 16_000.0, 1.0);
126+ let after = bars.update(&AudioFrame::default(), 20, 40.0, 16_000.0, 1.0);
127127 let i = peak.iter().position(|&h| h > 0.0).unwrap();
128128 assert!(after[i] > 0.0 && after[i] <= peak[i]);
129129 }
M src/visualizer/mod.rs View
@@ -1,8 +1,9 @@
11 //! Reads PCM data from MPD's fifo output and computes spectra.
22 //!
33 //! A dedicated thread does the I/O and FFT while the visualizer is visible
4−//! and publishes each spectrum on a `watch` channel; the UI turns spectra into
5−//! bars (see [`bars`]).
4+//! and publishes each frame (recent samples plus their spectrum) on a `watch`
5+//! channel; the UI draws waves from the samples and bars from the spectrum
6+//! (see [`bars`]).
67
78 mod bars;
89
@@ -25,27 +26,33 @@ use tracing::warn;
2526 use crate::config::VisualizerConfig;
2627
2728 const FFT_SIZE: usize = 2048;
29+/// Samples per channel kept for the wave styles.
30+const WAVE_SAMPLES: usize = 1024;
2831
2932 /// One analysis frame.
3033 #[derive(Clone, Debug, Default)]
31−pub struct Spectrum {
34+pub struct AudioFrame {
3235 /// Normalized magnitude per FFT bin (0 Hz up to Nyquist).
3336 pub magnitudes: Vec<f32>,
3437 /// Width of one bin in Hz.
3538 pub bin_hz: f32,
39+ /// The most recent samples of each channel in `-1.0..=1.0`, oldest
40+ /// first. Both hold the same data for mono input.
41+ pub left: Vec<f32>,
42+ pub right: Vec<f32>,
3643 /// Why there is no audio, if the fifo cannot be read.
3744 pub error: Option<String>,
3845 }
3946
4047 pub struct Visualizer {
4148 active: Arc<AtomicBool>,
42− spectrum: watch::Receiver<Arc<Spectrum>>,
49+ spectrum: watch::Receiver<Arc<AudioFrame>>,
4350 }
4451
4552 impl Visualizer {
4653 pub fn spawn(config: VisualizerConfig) -> Self {
4754 let active = Arc::new(AtomicBool::new(false));
48− let (tx, spectrum) = watch::channel(Arc::new(Spectrum::default()));
55+ let (tx, spectrum) = watch::channel(Arc::new(AudioFrame::default()));
4956 let flag = Arc::clone(&active);
5057 let spawned = thread::Builder::new()
5158 .name("visualizer".into())
@@ -65,12 +72,12 @@ impl Visualizer {
6572 self.active.load(Ordering::Relaxed)
6673 }
6774
68− pub fn latest(&self) -> Arc<Spectrum> {
75+ pub fn latest(&self) -> Arc<AudioFrame> {
6976 Arc::clone(&self.spectrum.borrow())
7077 }
7178
7279 /// A receiver to await new spectra on.
73− pub fn subscribe(&self) -> watch::Receiver<Arc<Spectrum>> {
80+ pub fn subscribe(&self) -> watch::Receiver<Arc<AudioFrame>> {
7481 self.spectrum.clone()
7582 }
7683 }
@@ -78,18 +85,20 @@ impl Visualizer {
7885 struct Reader {
7986 config: VisualizerConfig,
8087 active: Arc<AtomicBool>,
81− tx: watch::Sender<Arc<Spectrum>>,
88+ tx: watch::Sender<Arc<AudioFrame>>,
8289 fft: Arc<dyn RealToComplex<f32>>,
8390 window: Vec<f32>,
8491 /// The most recent mono samples, oldest first.
8592 samples: Vec<f32>,
93+ left: Vec<f32>,
94+ right: Vec<f32>,
8695 }
8796
8897 impl Reader {
8998 fn new(
9099 config: VisualizerConfig,
91100 active: Arc<AtomicBool>,
92− tx: watch::Sender<Arc<Spectrum>>,
101+ tx: watch::Sender<Arc<AudioFrame>>,
93102 ) -> Self {
94103 let window = (0..FFT_SIZE)
95104 .map(|i| {
@@ -104,6 +113,8 @@ impl Reader {
104113 tx,
105114 window,
106115 samples: vec![0.0; FFT_SIZE],
116+ left: vec![0.0; WAVE_SAMPLES],
117+ right: vec![0.0; WAVE_SAMPLES],
107118 }
108119 }
109120
@@ -118,12 +129,12 @@ impl Reader {
118129 match open_fifo(&self.config) {
119130 Ok(file) => self.read_while_active(file),
120131 Err(error) => {
121− self.publish(Spectrum {
132+ self.publish(AudioFrame {
122133 error: Some(format!(
123134 "cannot open {}: {error}",
124135 self.config.fifo.display()
125136 )),
126− ..Spectrum::default()
137+ ..AudioFrame::default()
127138 });
128139 thread::sleep(Duration::from_secs(1));
129140 }
@@ -151,16 +162,19 @@ impl Reader {
151162 pending.extend_from_slice(&buf[..n]);
152163 let usable = pending.len() - pending.len() % frame_bytes;
153164 for frame in pending[..usable].chunks_exact(frame_bytes) {
154− let sum: f32 = frame
155− .chunks_exact(2)
156− .map(|s| f32::from(i16::from_le_bytes([s[0], s[1]])))
157− .sum();
158− self.samples
159− .push(sum / (channels as f32 * f32::from(i16::MAX)));
165+ let sample = |i: usize| {
166+ let s = &frame[2 * i..2 * i + 2];
167+ f32::from(i16::from_le_bytes([s[0], s[1]])) / f32::from(i16::MAX)
168+ };
169+ let sum: f32 = (0..channels).map(sample).sum();
170+ self.samples.push(sum / channels as f32);
171+ self.left.push(sample(0));
172+ self.right.push(sample(channels.min(2) - 1));
160173 }
161174 pending.drain(..usable);
162− let excess = self.samples.len().saturating_sub(FFT_SIZE);
163− self.samples.drain(..excess);
175+ keep_last(&mut self.samples, FFT_SIZE);
176+ keep_last(&mut self.left, WAVE_SAMPLES);
177+ keep_last(&mut self.right, WAVE_SAMPLES);
164178 last_data = Instant::now();
165179 silent = false;
166180 }
@@ -180,6 +194,8 @@ impl Reader {
180194 if !silent {
181195 silent = true;
182196 self.samples.fill(0.0);
197+ self.left.fill(0.0);
198+ self.right.fill(0.0);
183199 self.analyze();
184200 }
185201 } else {
@@ -203,18 +219,25 @@ impl Reader {
203219 // A full-scale sine yields |X| = N/4 with a Hann window.
204220 let scale = 4.0 / FFT_SIZE as f32;
205221 let magnitudes = output.iter().map(|c| c.norm() * scale).collect();
206− self.publish(Spectrum {
222+ self.publish(AudioFrame {
207223 magnitudes,
208224 bin_hz: self.config.sample_rate as f32 / FFT_SIZE as f32,
225+ left: self.left.clone(),
226+ right: self.right.clone(),
209227 error: None,
210228 });
211229 }
212230
213− fn publish(&self, spectrum: Spectrum) {
231+ fn publish(&self, spectrum: AudioFrame) {
214232 let _ = self.tx.send(Arc::new(spectrum));
215233 }
216234 }
217235
236+fn keep_last(samples: &mut Vec<f32>, len: usize) {
237+ let excess = samples.len().saturating_sub(len);
238+ samples.drain(..excess);
239+}
240+
218241 fn open_fifo(config: &VisualizerConfig) -> io::Result<File> {
219242 let mut options = OpenOptions::new();
220243 options.read(true);