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.
+413 −86
10 files changed
- M CHANGELOG.md +2 −1
- M README.md +7 −2
- M src/action.rs +3 −0
- M src/config/default.toml +9 −0
- M src/config/mod.rs +36 −0
- M src/mpd/worker.rs +3 −1
- M src/screens/mod.rs +3 −3
- M src/screens/visualizer.rs +298 −50
- M src/visualizer/bars.rs +8 −8
- M src/visualizer/mod.rs +44 −21
| @@ -3,7 +3,8 @@ | |||
| 3 | 3 | A rewrite from scratch with a new architecture modelled on ncmpcpp and rmpc. | |
| 4 | 4 | ||
| 5 | 5 | - 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` | |
| 7 | 8 | files, cache and LRCLIB), plus a lyrics pane on the queue. | |
| 8 | 9 | - Browser shows stored playlists at the bottom of the root folder; | |
| 9 | 10 | playlist editor, queue and browser keep every 0.1 feature. | |
| @@ -8,7 +8,7 @@ | |||
| 8 | 8 | rmptui is a terminal interface for [Music Player Daemon | |
| 9 | 9 | (MPD)](https://www.musicpd.org/) that looks and feels like ncmpcpp. Browse your | |
| 10 | 10 | 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. | |
| 12 | 12 | ||
| 13 | 13 |  | |
| 14 | 14 | ||
| @@ -21,7 +21,7 @@ watch a spectrum visualizer without leaving the terminal. | |||
| 21 | 21 | | `3` | **Playlist editor** | Stored playlists and their songs: load, play from a song, remove or reorder songs, rename or delete playlists. | | |
| 22 | 22 | | `4` | **Media library** | Album artists → albums (with year) → songs. `t` switches between AlbumArtist and Artist. | | |
| 23 | 23 | | `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. | | |
| 25 | 25 | | `7` | **Lyrics** | Synced lyrics that follow playback, or plain lyrics you can scroll. | | |
| 26 | 26 | ||
| 27 | 27 | Every screen shares ncmpcpp's frame: the screen title and volume on top | |
| @@ -168,6 +168,11 @@ audio_output { | |||
| 168 | 168 | `[visualizer]` must match the `path` and `format`. The fifo is only read while | |
| 169 | 169 | the Visualizer screen is visible. | |
| 170 | 170 | ||
| 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 | + | ||
| 171 | 176 | ### Lyrics | |
| 172 | 177 | ||
| 173 | 178 | Lyrics are looked up in this order: | |
| @@ -133,6 +133,7 @@ named_enum! { | |||
| 133 | 133 | ToggleArtistTag = "toggle_artist_tag", | |
| 134 | 134 | SaveTags = "save_tags", | |
| 135 | 135 | RevertTags = "revert_tags", | |
| 136 | + | CycleVisualizer = "cycle_visualizer", | |
| 136 | 137 | ReloadLyrics = "reload_lyrics", | |
| 137 | 138 | // Popups and text input | |
| 138 | 139 | Close = "close", | |
| @@ -219,6 +220,7 @@ impl Action { | |||
| 219 | 220 | DeletePlaylist | RenamePlaylist => &[C::Playlists], | |
| 220 | 221 | ToggleArtistTag => &[C::Library], | |
| 221 | 222 | SaveTags | RevertTags => &[C::TagEditor], | |
| 223 | + | CycleVisualizer => &[C::Visualizer], | |
| 222 | 224 | ReloadLyrics => &[C::Lyrics], | |
| 223 | 225 | Close => &[C::Popup], | |
| 224 | 226 | Submit | Cancel | Backspace | DeleteChar | DeleteWord | CursorLeft | CursorRight | |
| @@ -287,6 +289,7 @@ impl Action { | |||
| 287 | 289 | ToggleArtistTag => "Switch between AlbumArtist and Artist", | |
| 288 | 290 | SaveTags => "Write pending tag changes", | |
| 289 | 291 | RevertTags => "Discard pending tag changes", | |
| 292 | + | CycleVisualizer => "Switch to the next visualizer style", | |
| 290 | 293 | ReloadLyrics => "Fetch lyrics again", | |
| 291 | 294 | Close => "Close the popup", | |
| 292 | 295 | Submit => "Submit", | |
| @@ -95,6 +95,14 @@ lrclib_url = "https://lrclib.net" | |||
| 95 | 95 | # format "44100:16:2" | |
| 96 | 96 | # } | |
| 97 | 97 | 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 = "●" | |
| 98 | 106 | # Must match the `format` of the fifo output. | |
| 99 | 107 | sample_rate = 44100 | |
| 100 | 108 | channels = 2 | |
| @@ -193,6 +201,7 @@ save_tags = ["w"] | |||
| 193 | 201 | revert_tags = ["u"] | |
| 194 | 202 | ||
| 195 | 203 | [keys.visualizer] | |
| 204 | + | cycle_visualizer = ["Space"] | |
| 196 | 205 | ||
| 197 | 206 | [keys.lyrics] | |
| 198 | 207 | reload_lyrics = ["F5"] | |
| @@ -123,6 +123,9 @@ pub struct LyricsConfig { | |||
| 123 | 123 | #[serde(deny_unknown_fields)] | |
| 124 | 124 | pub struct VisualizerConfig { | |
| 125 | 125 | pub fifo: PathBuf, | |
| 126 | + | pub style: VisualizerStyle, | |
| 127 | + | pub stereo: bool, | |
| 128 | + | pub wave_symbol: String, | |
| 126 | 129 | pub sample_rate: u32, | |
| 127 | 130 | pub channels: u16, | |
| 128 | 131 | pub framerate: u32, | |
| @@ -133,6 +136,35 @@ pub struct VisualizerConfig { | |||
| 133 | 136 | pub falloff: f32, | |
| 134 | 137 | } | |
| 135 | 138 | ||
| 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 | + | ||
| 136 | 168 | #[derive(Debug, Deserialize)] | |
| 137 | 169 | #[serde(deny_unknown_fields)] | |
| 138 | 170 | struct RawConfig { | |
| @@ -273,6 +305,10 @@ impl Config { | |||
| 273 | 305 | vis.min_frequency > 0.0 && vis.min_frequency < vis.max_frequency, | |
| 274 | 306 | "visualizer.min_frequency must be positive and below max_frequency", | |
| 275 | 307 | )?; | |
| 308 | + | check( | |
| 309 | + | vis.wave_symbol.chars().count() == 1, | |
| 310 | + | "visualizer.wave_symbol must be a single character", | |
| 311 | + | )?; | |
| 276 | 312 | check( | |
| 277 | 313 | vis.falloff > 0.0, | |
| 278 | 314 | "visualizer.falloff must be greater than zero", | |
| @@ -296,7 +296,9 @@ async fn execute(conn: &mut Connection, request: &Request) -> MpdResult<Option<R | |||
| 296 | 296 | } | |
| 297 | 297 | Request::LoadAndPlay { name, offset } => { | |
| 298 | 298 | 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?; | |
| 300 | 302 | conn.execute(&commands::load(name)).await?; | |
| 301 | 303 | // `load` silently skips entries the server cannot resolve, so the | |
| 302 | 304 | // playlist index is not necessarily the queue offset. | |
| @@ -31,7 +31,7 @@ use crate::{ | |||
| 31 | 31 | popups::Popup, | |
| 32 | 32 | state::MpdState, | |
| 33 | 33 | tags::{TagField, TagWrite}, | |
| 34 | − | visualizer::Spectrum, | |
| 34 | + | visualizer::AudioFrame, | |
| 35 | 35 | }; | |
| 36 | 36 | ||
| 37 | 37 | /// What a screen or popup may read, plus a queue of effects for the app. | |
| @@ -136,14 +136,14 @@ pub struct Screens { | |||
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | 138 | 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 { | |
| 140 | 140 | Self { | |
| 141 | 141 | queue: QueueScreen::new(config), | |
| 142 | 142 | browser: BrowserScreen::default(), | |
| 143 | 143 | playlists: PlaylistsScreen::default(), | |
| 144 | 144 | library: LibraryScreen::default(), | |
| 145 | 145 | tag_editor: TagEditorScreen::default(), | |
| 146 | − | visualizer: VisualizerScreen::new(spectrum), | |
| 146 | + | visualizer: VisualizerScreen::new(config, spectrum), | |
| 147 | 147 | lyrics: LyricsScreen::default(), | |
| 148 | 148 | } | |
| 149 | 149 | } | |
| @@ -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. | |
| 2 | 3 | ||
| 3 | 4 | use std::sync::Arc; | |
| 4 | 5 | ||
| 5 | − | use ratatui::{Frame, layout::Rect, text::Line}; | |
| 6 | + | use ratatui::{Frame, buffer::Buffer, layout::Rect, style::Color, text::Line}; | |
| 6 | 7 | use tokio::sync::watch; | |
| 7 | 8 | ||
| 8 | 9 | use super::{Ctx, Screen}; | |
| 9 | 10 | use crate::{ | |
| 10 | 11 | action::Action, | |
| 12 | + | config::{Config, VisualizerConfig, VisualizerStyle}, | |
| 11 | 13 | ui, | |
| 12 | − | visualizer::{Bars, Spectrum}, | |
| 14 | + | visualizer::{AudioFrame, Bars}, | |
| 13 | 15 | }; | |
| 14 | 16 | ||
| 15 | 17 | const EIGHTHS: [&str; 9] = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; | |
| 16 | 18 | ||
| 17 | 19 | pub struct VisualizerScreen { | |
| 18 | − | spectrum: watch::Receiver<Arc<Spectrum>>, | |
| 20 | + | frames: watch::Receiver<Arc<AudioFrame>>, | |
| 19 | 21 | bars: Bars, | |
| 22 | + | style: VisualizerStyle, | |
| 20 | 23 | } | |
| 21 | 24 | ||
| 22 | 25 | impl VisualizerScreen { | |
| 23 | − | pub fn new(spectrum: watch::Receiver<Arc<Spectrum>>) -> Self { | |
| 26 | + | pub fn new(config: &Config, frames: watch::Receiver<Arc<AudioFrame>>) -> Self { | |
| 24 | 27 | Self { | |
| 25 | − | spectrum, | |
| 28 | + | frames, | |
| 26 | 29 | bars: Bars::default(), | |
| 30 | + | style: config.visualizer.style, | |
| 27 | 31 | } | |
| 28 | 32 | } | |
| 29 | − | } | |
| 30 | 33 | ||
| 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) { | |
| 38 | 35 | 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, | |
| 59 | 58 | ); | |
| 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); | |
| 64 | 74 | } | |
| 75 | + | } | |
| 65 | 76 | ||
| 77 | + | fn draw_spectrum(&mut self, buffer: &mut Buffer, area: Rect, audio: &AudioFrame, ctx: &Ctx) { | |
| 78 | + | let config = &ctx.config.visualizer; | |
| 66 | 79 | 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); | |
| 68 | 81 | let heights = self.bars.update( | |
| 69 | − | &spectrum, | |
| 82 | + | audio, | |
| 70 | 83 | count, | |
| 71 | 84 | config.min_frequency, | |
| 72 | 85 | config.max_frequency, | |
| 73 | 86 | config.falloff, | |
| 74 | 87 | ); | |
| 75 | − | let colors = &theme.visualizer; | |
| 76 | − | let rows = inner.height; | |
| 88 | + | let colors = &ctx.theme().visualizer; | |
| 89 | + | let rows = area.height; | |
| 77 | 90 | // Center the bars horizontally. | |
| 78 | 91 | 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; | |
| 81 | 93 | for (i, &height) in heights.iter().enumerate() { | |
| 82 | 94 | let eighths = (height * f32::from(rows) * 8.0).round() as u32; | |
| 83 | 95 | for row in 0..rows { | |
| @@ -85,20 +97,256 @@ impl Screen for VisualizerScreen { | |||
| 85 | 97 | if filled == 0 { | |
| 86 | 98 | break; | |
| 87 | 99 | } | |
| 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)); | |
| 91 | 102 | for dx in 0..config.bar_width { | |
| 92 | 103 | let x = left + i as u16 * stride + dx; | |
| 93 | − | if x < inner.right() { | |
| 104 | + | if x < area.right() { | |
| 94 | 105 | buffer[(x, y)].set_symbol(EIGHTHS[filled]).set_fg(color); | |
| 95 | 106 | } | |
| 96 | 107 | } | |
| 97 | 108 | } | |
| 98 | 109 | } | |
| 99 | 110 | } | |
| 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 | + | } | |
| 100 | 260 | ||
| 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 | + | ); | |
| 103 | 351 | } | |
| 104 | 352 | } | |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::time::Instant; | |
| 4 | 4 | ||
| 5 | − | use super::Spectrum; | |
| 5 | + | use super::AudioFrame; | |
| 6 | 6 | ||
| 7 | 7 | /// Bar heights (0.0–1.0) with gravity falloff between frames. | |
| 8 | 8 | #[derive(Debug)] | |
| @@ -29,7 +29,7 @@ impl Bars { | |||
| 29 | 29 | /// logarithmic scale. `falloff` is in heights per second. | |
| 30 | 30 | pub fn update( | |
| 31 | 31 | &mut self, | |
| 32 | − | spectrum: &Spectrum, | |
| 32 | + | spectrum: &AudioFrame, | |
| 33 | 33 | count: usize, | |
| 34 | 34 | min_hz: f32, | |
| 35 | 35 | max_hz: f32, | |
| @@ -53,7 +53,7 @@ impl Bars { | |||
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | /// 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> { | |
| 57 | 57 | let bins = &spectrum.magnitudes; | |
| 58 | 58 | if count == 0 || bins.is_empty() || spectrum.bin_hz <= 0.0 { | |
| 59 | 59 | return vec![0.0; count]; | |
| @@ -85,14 +85,14 @@ pub fn band_levels(spectrum: &Spectrum, count: usize, min_hz: f32, max_hz: f32) | |||
| 85 | 85 | mod tests { | |
| 86 | 86 | use super::*; | |
| 87 | 87 | ||
| 88 | − | fn spectrum_with_peak(hz: f32) -> Spectrum { | |
| 88 | + | fn spectrum_with_peak(hz: f32) -> AudioFrame { | |
| 89 | 89 | let bin_hz = 44_100.0 / 2048.0; | |
| 90 | 90 | let mut magnitudes = vec![0.0; 1025]; | |
| 91 | 91 | magnitudes[(hz / bin_hz).round() as usize] = 1.0; | |
| 92 | − | Spectrum { | |
| 92 | + | AudioFrame { | |
| 93 | 93 | magnitudes, | |
| 94 | 94 | bin_hz, | |
| 95 | − | error: None, | |
| 95 | + | ..AudioFrame::default() | |
| 96 | 96 | } | |
| 97 | 97 | } | |
| 98 | 98 | ||
| @@ -113,7 +113,7 @@ mod tests { | |||
| 113 | 113 | #[test] | |
| 114 | 114 | fn silence_and_empty_input_give_zero() { | |
| 115 | 115 | assert_eq!( | |
| 116 | − | band_levels(&Spectrum::default(), 4, 40.0, 16_000.0), | |
| 116 | + | band_levels(&AudioFrame::default(), 4, 40.0, 16_000.0), | |
| 117 | 117 | [0.0; 4] | |
| 118 | 118 | ); | |
| 119 | 119 | } | |
| @@ -123,7 +123,7 @@ mod tests { | |||
| 123 | 123 | let mut bars = Bars::default(); | |
| 124 | 124 | let loud = spectrum_with_peak(1000.0); | |
| 125 | 125 | 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); | |
| 127 | 127 | let i = peak.iter().position(|&h| h > 0.0).unwrap(); | |
| 128 | 128 | assert!(after[i] > 0.0 && after[i] <= peak[i]); | |
| 129 | 129 | } | |
| @@ -1,8 +1,9 @@ | |||
| 1 | 1 | //! Reads PCM data from MPD's fifo output and computes spectra. | |
| 2 | 2 | //! | |
| 3 | 3 | //! 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`]). | |
| 6 | 7 | ||
| 7 | 8 | mod bars; | |
| 8 | 9 | ||
| @@ -25,27 +26,33 @@ use tracing::warn; | |||
| 25 | 26 | use crate::config::VisualizerConfig; | |
| 26 | 27 | ||
| 27 | 28 | const FFT_SIZE: usize = 2048; | |
| 29 | + | /// Samples per channel kept for the wave styles. | |
| 30 | + | const WAVE_SAMPLES: usize = 1024; | |
| 28 | 31 | ||
| 29 | 32 | /// One analysis frame. | |
| 30 | 33 | #[derive(Clone, Debug, Default)] | |
| 31 | − | pub struct Spectrum { | |
| 34 | + | pub struct AudioFrame { | |
| 32 | 35 | /// Normalized magnitude per FFT bin (0 Hz up to Nyquist). | |
| 33 | 36 | pub magnitudes: Vec<f32>, | |
| 34 | 37 | /// Width of one bin in Hz. | |
| 35 | 38 | 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>, | |
| 36 | 43 | /// Why there is no audio, if the fifo cannot be read. | |
| 37 | 44 | pub error: Option<String>, | |
| 38 | 45 | } | |
| 39 | 46 | ||
| 40 | 47 | pub struct Visualizer { | |
| 41 | 48 | active: Arc<AtomicBool>, | |
| 42 | − | spectrum: watch::Receiver<Arc<Spectrum>>, | |
| 49 | + | spectrum: watch::Receiver<Arc<AudioFrame>>, | |
| 43 | 50 | } | |
| 44 | 51 | ||
| 45 | 52 | impl Visualizer { | |
| 46 | 53 | pub fn spawn(config: VisualizerConfig) -> Self { | |
| 47 | 54 | 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())); | |
| 49 | 56 | let flag = Arc::clone(&active); | |
| 50 | 57 | let spawned = thread::Builder::new() | |
| 51 | 58 | .name("visualizer".into()) | |
| @@ -65,12 +72,12 @@ impl Visualizer { | |||
| 65 | 72 | self.active.load(Ordering::Relaxed) | |
| 66 | 73 | } | |
| 67 | 74 | ||
| 68 | − | pub fn latest(&self) -> Arc<Spectrum> { | |
| 75 | + | pub fn latest(&self) -> Arc<AudioFrame> { | |
| 69 | 76 | Arc::clone(&self.spectrum.borrow()) | |
| 70 | 77 | } | |
| 71 | 78 | ||
| 72 | 79 | /// 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>> { | |
| 74 | 81 | self.spectrum.clone() | |
| 75 | 82 | } | |
| 76 | 83 | } | |
| @@ -78,18 +85,20 @@ impl Visualizer { | |||
| 78 | 85 | struct Reader { | |
| 79 | 86 | config: VisualizerConfig, | |
| 80 | 87 | active: Arc<AtomicBool>, | |
| 81 | − | tx: watch::Sender<Arc<Spectrum>>, | |
| 88 | + | tx: watch::Sender<Arc<AudioFrame>>, | |
| 82 | 89 | fft: Arc<dyn RealToComplex<f32>>, | |
| 83 | 90 | window: Vec<f32>, | |
| 84 | 91 | /// The most recent mono samples, oldest first. | |
| 85 | 92 | samples: Vec<f32>, | |
| 93 | + | left: Vec<f32>, | |
| 94 | + | right: Vec<f32>, | |
| 86 | 95 | } | |
| 87 | 96 | ||
| 88 | 97 | impl Reader { | |
| 89 | 98 | fn new( | |
| 90 | 99 | config: VisualizerConfig, | |
| 91 | 100 | active: Arc<AtomicBool>, | |
| 92 | − | tx: watch::Sender<Arc<Spectrum>>, | |
| 101 | + | tx: watch::Sender<Arc<AudioFrame>>, | |
| 93 | 102 | ) -> Self { | |
| 94 | 103 | let window = (0..FFT_SIZE) | |
| 95 | 104 | .map(|i| { | |
| @@ -104,6 +113,8 @@ impl Reader { | |||
| 104 | 113 | tx, | |
| 105 | 114 | window, | |
| 106 | 115 | samples: vec![0.0; FFT_SIZE], | |
| 116 | + | left: vec![0.0; WAVE_SAMPLES], | |
| 117 | + | right: vec![0.0; WAVE_SAMPLES], | |
| 107 | 118 | } | |
| 108 | 119 | } | |
| 109 | 120 | ||
| @@ -118,12 +129,12 @@ impl Reader { | |||
| 118 | 129 | match open_fifo(&self.config) { | |
| 119 | 130 | Ok(file) => self.read_while_active(file), | |
| 120 | 131 | Err(error) => { | |
| 121 | − | self.publish(Spectrum { | |
| 132 | + | self.publish(AudioFrame { | |
| 122 | 133 | error: Some(format!( | |
| 123 | 134 | "cannot open {}: {error}", | |
| 124 | 135 | self.config.fifo.display() | |
| 125 | 136 | )), | |
| 126 | − | ..Spectrum::default() | |
| 137 | + | ..AudioFrame::default() | |
| 127 | 138 | }); | |
| 128 | 139 | thread::sleep(Duration::from_secs(1)); | |
| 129 | 140 | } | |
| @@ -151,16 +162,19 @@ impl Reader { | |||
| 151 | 162 | pending.extend_from_slice(&buf[..n]); | |
| 152 | 163 | let usable = pending.len() - pending.len() % frame_bytes; | |
| 153 | 164 | 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)); | |
| 160 | 173 | } | |
| 161 | 174 | 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); | |
| 164 | 178 | last_data = Instant::now(); | |
| 165 | 179 | silent = false; | |
| 166 | 180 | } | |
| @@ -180,6 +194,8 @@ impl Reader { | |||
| 180 | 194 | if !silent { | |
| 181 | 195 | silent = true; | |
| 182 | 196 | self.samples.fill(0.0); | |
| 197 | + | self.left.fill(0.0); | |
| 198 | + | self.right.fill(0.0); | |
| 183 | 199 | self.analyze(); | |
| 184 | 200 | } | |
| 185 | 201 | } else { | |
| @@ -203,18 +219,25 @@ impl Reader { | |||
| 203 | 219 | // A full-scale sine yields |X| = N/4 with a Hann window. | |
| 204 | 220 | let scale = 4.0 / FFT_SIZE as f32; | |
| 205 | 221 | let magnitudes = output.iter().map(|c| c.norm() * scale).collect(); | |
| 206 | − | self.publish(Spectrum { | |
| 222 | + | self.publish(AudioFrame { | |
| 207 | 223 | magnitudes, | |
| 208 | 224 | bin_hz: self.config.sample_rate as f32 / FFT_SIZE as f32, | |
| 225 | + | left: self.left.clone(), | |
| 226 | + | right: self.right.clone(), | |
| 209 | 227 | error: None, | |
| 210 | 228 | }); | |
| 211 | 229 | } | |
| 212 | 230 | ||
| 213 | − | fn publish(&self, spectrum: Spectrum) { | |
| 231 | + | fn publish(&self, spectrum: AudioFrame) { | |
| 214 | 232 | let _ = self.tx.send(Arc::new(spectrum)); | |
| 215 | 233 | } | |
| 216 | 234 | } | |
| 217 | 235 | ||
| 236 | + | fn keep_last(samples: &mut Vec<f32>, len: usize) { | |
| 237 | + | let excess = samples.len().saturating_sub(len); | |
| 238 | + | samples.drain(..excess); | |
| 239 | + | } | |
| 240 | + | ||
| 218 | 241 | fn open_fifo(config: &VisualizerConfig) -> io::Result<File> { | |
| 219 | 242 | let mut options = OpenOptions::new(); | |
| 220 | 243 | options.read(true); | |