| 1 | //! Reads PCM data from MPD's fifo output and computes spectra. |
| 2 | //! |
| 3 | //! A dedicated thread does the I/O and FFT while the visualizer is visible |
| 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`]). |
| 7 | |
| 8 | mod bars; |
| 9 | |
| 10 | use std::{ |
| 11 | fs::{File, OpenOptions}, |
| 12 | io::{self, Read}, |
| 13 | sync::{ |
| 14 | Arc, |
| 15 | atomic::{AtomicBool, Ordering}, |
| 16 | }, |
| 17 | thread, |
| 18 | time::{Duration, Instant}, |
| 19 | }; |
| 20 | |
| 21 | pub use bars::Bars; |
| 22 | use realfft::{RealFftPlanner, RealToComplex}; |
| 23 | use tokio::sync::watch; |
| 24 | use tracing::warn; |
| 25 | |
| 26 | use crate::config::VisualizerConfig; |
| 27 | |
| 28 | const FFT_SIZE: usize = 2048; |
| 29 | /// Samples per channel kept for the wave styles. |
| 30 | const WAVE_SAMPLES: usize = 1024; |
| 31 | |
| 32 | /// One analysis frame. |
| 33 | #[derive(Clone, Debug, Default)] |
| 34 | pub struct AudioFrame { |
| 35 | /// Normalized magnitude per FFT bin (0 Hz up to Nyquist). |
| 36 | pub magnitudes: Vec<f32>, |
| 37 | /// Width of one bin in Hz. |
| 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>, |
| 43 | /// Why there is no audio, if the fifo cannot be read. |
| 44 | pub error: Option<String>, |
| 45 | } |
| 46 | |
| 47 | pub struct Visualizer { |
| 48 | active: Arc<AtomicBool>, |
| 49 | spectrum: watch::Receiver<Arc<AudioFrame>>, |
| 50 | } |
| 51 | |
| 52 | impl Visualizer { |
| 53 | pub fn spawn(config: VisualizerConfig) -> Self { |
| 54 | let active = Arc::new(AtomicBool::new(false)); |
| 55 | let (tx, spectrum) = watch::channel(Arc::new(AudioFrame::default())); |
| 56 | let flag = Arc::clone(&active); |
| 57 | let spawned = thread::Builder::new() |
| 58 | .name("visualizer".into()) |
| 59 | .spawn(move || Reader::new(config, flag, tx).run()); |
| 60 | if let Err(error) = spawned { |
| 61 | warn!("cannot start visualizer thread: {error}"); |
| 62 | } |
| 63 | Self { active, spectrum } |
| 64 | } |
| 65 | |
| 66 | /// Starts or stops reading the fifo. |
| 67 | pub fn set_active(&self, active: bool) { |
| 68 | self.active.store(active, Ordering::Relaxed); |
| 69 | } |
| 70 | |
| 71 | pub fn is_active(&self) -> bool { |
| 72 | self.active.load(Ordering::Relaxed) |
| 73 | } |
| 74 | |
| 75 | pub fn latest(&self) -> Arc<AudioFrame> { |
| 76 | Arc::clone(&self.spectrum.borrow()) |
| 77 | } |
| 78 | |
| 79 | /// A receiver to await new spectra on. |
| 80 | pub fn subscribe(&self) -> watch::Receiver<Arc<AudioFrame>> { |
| 81 | self.spectrum.clone() |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | struct Reader { |
| 86 | config: VisualizerConfig, |
| 87 | active: Arc<AtomicBool>, |
| 88 | tx: watch::Sender<Arc<AudioFrame>>, |
| 89 | fft: Arc<dyn RealToComplex<f32>>, |
| 90 | window: Vec<f32>, |
| 91 | /// The most recent mono samples, oldest first. |
| 92 | samples: Vec<f32>, |
| 93 | left: Vec<f32>, |
| 94 | right: Vec<f32>, |
| 95 | } |
| 96 | |
| 97 | impl Reader { |
| 98 | fn new( |
| 99 | config: VisualizerConfig, |
| 100 | active: Arc<AtomicBool>, |
| 101 | tx: watch::Sender<Arc<AudioFrame>>, |
| 102 | ) -> Self { |
| 103 | let window = (0..FFT_SIZE) |
| 104 | .map(|i| { |
| 105 | let phase = 2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32; |
| 106 | 0.5 - 0.5 * phase.cos() |
| 107 | }) |
| 108 | .collect(); |
| 109 | Self { |
| 110 | fft: RealFftPlanner::<f32>::new().plan_fft_forward(FFT_SIZE), |
| 111 | config, |
| 112 | active, |
| 113 | tx, |
| 114 | window, |
| 115 | samples: vec![0.0; FFT_SIZE], |
| 116 | left: vec![0.0; WAVE_SAMPLES], |
| 117 | right: vec![0.0; WAVE_SAMPLES], |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | fn run(mut self) { |
| 122 | loop { |
| 123 | while !self.active.load(Ordering::Relaxed) { |
| 124 | if self.tx.is_closed() { |
| 125 | return; |
| 126 | } |
| 127 | thread::sleep(Duration::from_millis(100)); |
| 128 | } |
| 129 | match open_fifo(&self.config) { |
| 130 | Ok(file) => self.read_while_active(file), |
| 131 | Err(error) => { |
| 132 | self.publish(AudioFrame { |
| 133 | error: Some(format!( |
| 134 | "cannot open {}: {error}", |
| 135 | self.config.fifo.display() |
| 136 | )), |
| 137 | ..AudioFrame::default() |
| 138 | }); |
| 139 | thread::sleep(Duration::from_secs(1)); |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | fn read_while_active(&mut self, mut file: File) { |
| 146 | let channels = usize::from(self.config.channels); |
| 147 | let frame_bytes = 2 * channels; |
| 148 | let interval = Duration::from_secs_f64(1.0 / f64::from(self.config.framerate)); |
| 149 | let mut buf = vec![0u8; 16 * 1024]; |
| 150 | let mut pending: Vec<u8> = Vec::new(); |
| 151 | let mut last_frame = Instant::now(); |
| 152 | let mut last_data = Instant::now(); |
| 153 | let mut silent = false; |
| 154 | |
| 155 | // Drop audio that piled up while nobody was reading. |
| 156 | while matches!(file.read(&mut buf), Ok(n) if n > 0) {} |
| 157 | |
| 158 | while self.active.load(Ordering::Relaxed) { |
| 159 | match file.read(&mut buf) { |
| 160 | Ok(0) => thread::sleep(Duration::from_millis(10)), |
| 161 | Ok(n) => { |
| 162 | pending.extend_from_slice(&buf[..n]); |
| 163 | let usable = pending.len() - pending.len() % frame_bytes; |
| 164 | for frame in pending[..usable].chunks_exact(frame_bytes) { |
| 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)); |
| 173 | } |
| 174 | pending.drain(..usable); |
| 175 | keep_last(&mut self.samples, FFT_SIZE); |
| 176 | keep_last(&mut self.left, WAVE_SAMPLES); |
| 177 | keep_last(&mut self.right, WAVE_SAMPLES); |
| 178 | last_data = Instant::now(); |
| 179 | silent = false; |
| 180 | } |
| 181 | Err(error) if error.kind() == io::ErrorKind::WouldBlock => { |
| 182 | thread::sleep(Duration::from_millis(4)); |
| 183 | } |
| 184 | Err(error) if error.kind() == io::ErrorKind::Interrupted => {} |
| 185 | Err(error) => { |
| 186 | warn!("reading visualizer fifo failed: {error}"); |
| 187 | return; |
| 188 | } |
| 189 | } |
| 190 | if last_frame.elapsed() >= interval { |
| 191 | last_frame = Instant::now(); |
| 192 | if last_data.elapsed() > Duration::from_millis(150) { |
| 193 | // Paused or stopped: publish silence once so bars fall. |
| 194 | if !silent { |
| 195 | silent = true; |
| 196 | self.samples.fill(0.0); |
| 197 | self.left.fill(0.0); |
| 198 | self.right.fill(0.0); |
| 199 | self.analyze(); |
| 200 | } |
| 201 | } else { |
| 202 | self.analyze(); |
| 203 | } |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | fn analyze(&mut self) { |
| 209 | let mut input: Vec<f32> = self |
| 210 | .samples |
| 211 | .iter() |
| 212 | .zip(&self.window) |
| 213 | .map(|(s, w)| s * w) |
| 214 | .collect(); |
| 215 | let mut output = self.fft.make_output_vec(); |
| 216 | if self.fft.process(&mut input, &mut output).is_err() { |
| 217 | return; |
| 218 | } |
| 219 | // A full-scale sine yields |X| = N/4 with a Hann window. |
| 220 | let scale = 4.0 / FFT_SIZE as f32; |
| 221 | let magnitudes = output.iter().map(|c| c.norm() * scale).collect(); |
| 222 | self.publish(AudioFrame { |
| 223 | magnitudes, |
| 224 | bin_hz: self.config.sample_rate as f32 / FFT_SIZE as f32, |
| 225 | left: self.left.clone(), |
| 226 | right: self.right.clone(), |
| 227 | error: None, |
| 228 | }); |
| 229 | } |
| 230 | |
| 231 | fn publish(&self, spectrum: AudioFrame) { |
| 232 | let _ = self.tx.send(Arc::new(spectrum)); |
| 233 | } |
| 234 | } |
| 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 | |
| 241 | fn open_fifo(config: &VisualizerConfig) -> io::Result<File> { |
| 242 | let mut options = OpenOptions::new(); |
| 243 | options.read(true); |
| 244 | #[cfg(unix)] |
| 245 | { |
| 246 | use std::os::unix::fs::OpenOptionsExt; |
| 247 | // Never block, whether or not MPD has the fifo open for writing. |
| 248 | options.custom_flags(libc::O_NONBLOCK); |
| 249 | } |
| 250 | options.open(&config.fifo) |
| 251 | } |