Krolyxon
Commit cf9c69a

rmptuisrcvisualizermod.rs

228 lines · 7.2 KB · Rust

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