audio_visualizer/live/mod.rs
1/*
2MIT License
3
4Copyright (c) 2026 Philipp Schuster
5
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24//! Live audio visualization: records audio from an input device and shows it
25//! in a real-time GUI window ([`egui`](https://crates.io/crates/egui)).
26//!
27//! Start with [`LiveVisualizer`]; see the `live_visualize_*` examples in
28//! `examples/`. **Run this only with `--release`, otherwise it is very
29//! laggy.**
30//!
31//! # What the window shows
32//!
33//! The upper chart is the recorded audio, the lower chart a [`Transform`] of
34//! it, e.g. a lowpass-filtered waveform or a frequency spectrum. Both cover
35//! the last five seconds and scroll from right (now) to left.
36//!
37//! Recording is always mono. A stereo device is averaged down to one
38//! channel, so a transformation always receives a single sequence of
39//! amplitudes, and each chart shows a single signal.
40//!
41//! A waveform is drawn as a filled shape from the smallest to the largest
42//! amplitude per pixel column, not as a line through individual samples: a
43//! column covers a few hundred samples at common sample rates. See
44//! [`crate::WaveformVisualizer`] for why that is the right thing to show.
45
46mod input;
47
48pub use input::AudioInput;
49
50use crate::Error;
51use crate::waveform::{Bucket, envelope_exact};
52use eframe::egui;
53use eframe::egui::{Color32, Stroke};
54use egui_plot::{FilledArea, Line, Plot, PlotPoints};
55use input::AudioBuffer;
56use std::ops::Range;
57use std::sync::{Arc, Mutex};
58
59/// How many seconds of the latest audio are kept and displayed.
60const AUDIO_HISTORY_SECONDS: usize = 5;
61
62/// Upper bound of waveform points per chart; roughly one point per
63/// horizontal pixel.
64const MAX_POINTS: usize = 1600;
65
66/// Color of the recorded audio in the upper chart.
67const ORIGINAL_COLOR: Color32 = Color32::from_rgb(0x4a, 0x9e, 0xda);
68/// Color of the transformation in the lower chart.
69const TRANSFORM_COLOR: Color32 = Color32::from_rgb(0xe8, 0x9c, 0x3a);
70
71/// Closure type of [`Transform::Waveform`].
72pub type WaveformFn<'a> = Box<dyn FnMut(&[f32], f32) -> Vec<f32> + 'a>;
73/// Closure type of [`Transform::Points`].
74pub type PointsFn<'a> = Box<dyn FnMut(&[f32], f32) -> Vec<(f64, f64)> + 'a>;
75
76/// Transformation of the recorded audio, displayed in the lower half of the
77/// [`LiveVisualizer`] window.
78///
79/// Called with the latest audio samples (mono amplitudes in `[-1.0, 1.0]`)
80/// and the sample rate in Hz, once per rendered frame. The closures may hold
81/// state (`FnMut`), e.g. for smoothing across frames.
82#[expect(missing_debug_implementations)]
83pub enum Transform<'a> {
84 /// The output is a waveform on the same time axis as the input, e.g. a
85 /// filtered version of it.
86 Waveform(WaveformFn<'a>),
87 /// The output is a series of arbitrary `(x, y)` points, e.g. a frequency
88 /// spectrum.
89 Points(PointsFn<'a>),
90}
91
92impl<'a> Transform<'a> {
93 /// Creates a [`Transform::Waveform`].
94 pub fn waveform(f: impl FnMut(&[f32], f32) -> Vec<f32> + 'a) -> Self {
95 Self::Waveform(Box::new(f))
96 }
97
98 /// Creates a [`Transform::Points`].
99 pub fn points(f: impl FnMut(&[f32], f32) -> Vec<(f64, f64)> + 'a) -> Self {
100 Self::Points(Box::new(f))
101 }
102}
103
104/// Builder that opens a GUI window showing the live waveform of an audio
105/// input device along with a custom [`Transform`] of it.
106///
107/// # Example
108/// ```no_run
109/// use audio_visualizer::live::{LiveVisualizer, Transform};
110///
111/// LiveVisualizer::new(Transform::waveform(|samples, _sample_rate| {
112/// samples.iter().map(|s| s * 0.5).collect()
113/// }))
114/// .title("Half amplitude")
115/// .open()
116/// .unwrap();
117/// ```
118#[allow(missing_debug_implementations)]
119pub struct LiveVisualizer<'a> {
120 transform: Transform<'a>,
121 title: String,
122 input: Option<AudioInput>,
123 x_range: Option<Range<f64>>,
124 y_range: Option<Range<f64>>,
125 x_label: String,
126 y_label: String,
127 window_size: (f32, f32),
128}
129
130impl<'a> LiveVisualizer<'a> {
131 /// Creates a live visualizer with the given transformation for the lower
132 /// chart.
133 #[must_use]
134 pub fn new(transform: Transform<'a>) -> Self {
135 Self {
136 transform,
137 title: "Live Audio Visualization".to_string(),
138 input: None,
139 x_range: None,
140 y_range: None,
141 x_label: String::new(),
142 y_label: String::new(),
143 window_size: (1280.0, 720.0),
144 }
145 }
146
147 /// Sets the window title.
148 #[must_use]
149 pub fn title(mut self, title: impl Into<String>) -> Self {
150 self.title = title.into();
151 self
152 }
153
154 /// Sets the audio input to record from. Default: the system default
155 /// input device.
156 #[must_use]
157 pub fn input(mut self, input: AudioInput) -> Self {
158 self.input = Some(input);
159 self
160 }
161
162 /// Fixes the x-axis range of the lower chart. Default: the same time
163 /// axis as the waveform for [`Transform::Waveform`], automatic bounds
164 /// for [`Transform::Points`].
165 #[must_use]
166 pub const fn x_range(mut self, range: Range<f64>) -> Self {
167 self.x_range = Some(range);
168 self
169 }
170
171 /// Fixes the y-axis range of the lower chart. See [`Self::x_range`].
172 #[must_use]
173 pub const fn y_range(mut self, range: Range<f64>) -> Self {
174 self.y_range = Some(range);
175 self
176 }
177
178 /// Sets the axis labels of the lower chart.
179 #[must_use]
180 pub fn axis_labels(mut self, x: impl Into<String>, y: impl Into<String>) -> Self {
181 self.x_label = x.into();
182 self.y_label = y.into();
183 self
184 }
185
186 /// Sets the initial window size in logical pixels. Default: 1280x720.
187 #[must_use]
188 pub const fn window_size(mut self, width: f32, height: f32) -> Self {
189 self.window_size = (width, height);
190 self
191 }
192
193 /// Starts recording, opens the window and blocks until it is closed
194 /// (close button or Escape key).
195 pub fn open(self) -> Result<(), Error> {
196 let input = match self.input {
197 Some(input) => input,
198 None => AudioInput::default_device()?,
199 };
200 let sample_rate = input.config().sample_rate as f32;
201
202 let audio = Arc::new(Mutex::new(AudioBuffer::new(
203 (AUDIO_HISTORY_SECONDS * sample_rate as usize).next_power_of_two(),
204 )));
205
206 let stream = input.build_stream(audio.clone())?;
207 cpal::traits::StreamTrait::play(&stream)
208 .map_err(|e| Error::Audio(format!("can't start recording: {e}")))?;
209
210 let app = App {
211 audio,
212 sample_rate,
213 transform: self.transform,
214 x_range: self.x_range,
215 y_range: self.y_range,
216 x_label: self.x_label,
217 y_label: self.y_label,
218 };
219 let options = eframe::NativeOptions {
220 viewport: egui::ViewportBuilder::default()
221 .with_inner_size([self.window_size.0, self.window_size.1]),
222 ..Default::default()
223 };
224 eframe::run_native(&self.title, options, Box::new(move |_cc| Ok(Box::new(app))))
225 .map_err(|e| Error::Gui(e.to_string()))?;
226
227 // dropped here, after the window is closed: recording stops
228 drop(stream);
229 Ok(())
230 }
231}
232
233/// The [`eframe`] application: two vertically stacked plots, redrawn
234/// continuously with the latest audio data.
235struct App<'a> {
236 audio: Arc<Mutex<AudioBuffer>>,
237 sample_rate: f32,
238 transform: Transform<'a>,
239 x_range: Option<Range<f64>>,
240 y_range: Option<Range<f64>>,
241 x_label: String,
242 y_label: String,
243}
244
245impl eframe::App for App<'_> {
246 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
247 let ctx = ui.ctx().clone();
248 if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
249 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
250 }
251
252 // lock released immediately; the copy keeps the audio callback fast
253 let (samples, total) = self.audio.lock().unwrap().snapshot();
254 // The buffer is always full, so this does not change between frames.
255 let bucket_len = samples.len().div_ceil(MAX_POINTS);
256
257 egui::Frame::central_panel(ui.style()).show(ui, |ui| {
258 let plot_height = ui.available_height() / 2.0 - ui.spacing().item_spacing.y;
259 let history_secs = samples.len() as f64 / self.sample_rate as f64;
260
261 fixed_plot("waveform")
262 .height(plot_height)
263 .x_axis_label("time (seconds)")
264 .y_axis_label("amplitude")
265 .default_x_bounds(-history_secs, 0.0)
266 .default_y_bounds(-1.0, 1.0)
267 .show(ui, |plot_ui| {
268 plot_ui.add(waveform_area(
269 "original",
270 &samples,
271 self.sample_rate,
272 total,
273 bucket_len,
274 ORIGINAL_COLOR,
275 ));
276 });
277
278 let mut plot = fixed_plot("transformed")
279 .height(plot_height)
280 .x_axis_label(&self.x_label)
281 .y_axis_label(&self.y_label);
282 match &mut self.transform {
283 Transform::Waveform(f) => {
284 let transformed = f(&samples, self.sample_rate);
285 let (x0, x1) = self
286 .x_range
287 .as_ref()
288 .map_or((-history_secs, 0.0), |r| (r.start, r.end));
289 let (y0, y1) = self
290 .y_range
291 .as_ref()
292 .map_or((-1.0, 1.0), |r| (r.start, r.end));
293 plot = plot.default_x_bounds(x0, x1).default_y_bounds(y0, y1);
294 plot.show(ui, |plot_ui| {
295 plot_ui.add(waveform_area(
296 "transformed",
297 &transformed,
298 self.sample_rate,
299 total,
300 bucket_len,
301 TRANSFORM_COLOR,
302 ));
303 });
304 }
305 Transform::Points(f) => {
306 let points = f(&samples, self.sample_rate);
307 // an axis without a fixed range keeps automatic bounds
308 if let Some(r) = &self.x_range {
309 plot = plot.default_x_bounds(r.start, r.end);
310 }
311 if let Some(r) = &self.y_range {
312 plot = plot.default_y_bounds(r.start, r.end);
313 }
314 plot.show(ui, |plot_ui| {
315 let points: PlotPoints = points.iter().map(|(x, y)| [*x, *y]).collect();
316 plot_ui.line(Line::new("transformed", points).color(TRANSFORM_COLOR));
317 });
318 }
319 }
320 });
321
322 // continuous rendering, the audio buffer changes permanently
323 ctx.request_repaint();
324 }
325}
326
327/// A plot with a fixed view: the live data moves, the cursor must not.
328fn fixed_plot<'p>(id: &str) -> Plot<'p> {
329 Plot::new(id)
330 .allow_drag(false)
331 .allow_zoom(false)
332 .allow_scroll(false)
333 .allow_boxed_zoom(false)
334 .allow_double_click_reset(false)
335 .allow_axis_zoom_drag(false)
336}
337
338/// The waveform as one filled shape between the per-bucket minimum and
339/// maximum, with x as seconds relative to now (`-history..0`).
340///
341/// The min/max envelope describes a single signal, so it is drawn as a
342/// single item in a single color. Two separate lines would read as two
343/// unrelated signals.
344///
345/// `samples` are the newest `samples.len()` of the `total` samples recorded
346/// so far; a transformation may return fewer, which then covers accordingly
347/// less time.
348fn waveform_area(
349 name: &str,
350 samples: &[f32],
351 sample_rate: f32,
352 total: u64,
353 bucket_len: usize,
354 color: Color32,
355) -> FilledArea {
356 let (anchor, buckets) = aligned_envelope(samples, total, bucket_len);
357 let time_of =
358 |offset: usize| ((anchor + offset as u64) as f64 - total as f64) / sample_rate as f64;
359
360 let xs: Vec<f64> = buckets.iter().map(|b| time_of(b.start)).collect();
361 let mins: Vec<f64> = buckets.iter().map(|b| b.min as f64).collect();
362 let maxs: Vec<f64> = buckets.iter().map(|b| b.max as f64).collect();
363
364 FilledArea::new(name, &xs, &mins, &maxs)
365 .fill_color(color.gamma_multiply(0.45))
366 .stroke(Stroke::new(1.0, color))
367}
368
369/// Reduces `samples` to min/max buckets that are aligned to absolute
370/// positions in the audio stream, and returns the stream position of the
371/// first bucket along with them.
372///
373/// # Invariant
374///
375/// Bucket boundaries sit on multiples of `bucket_len` counted from the start
376/// of the stream, never relative to `samples[0]`. A given stretch of audio
377/// therefore always lands in the same bucket and keeps its min/max until it
378/// scrolls out of the window.
379///
380/// This is what makes the view stable. Every frame sees a different window
381/// of the stream - about 800 new samples per frame at 48 kHz and 60 fps,
382/// against a bucket of 164 samples. Anchored at `samples[0]`, the same audio
383/// would be re-bucketed with a different phase on every frame: its min/max
384/// would change although the audio did not, so the envelope shimmers instead
385/// of scrolling, and a transient jumps between fixed x slots instead of
386/// sliding.
387///
388/// Two things this relies on, both easy to break:
389/// - `bucket_len` must be the same on every frame. It is derived from the
390/// ringbuffer length, which is constant - not from `samples.len()`, which
391/// a [`Transform`] may change.
392/// - `total` must include the silence the buffer is pre-filled with, see
393/// [`AudioBuffer::new`]. Otherwise the stream position of `samples[0]` is
394/// negative until the recording is longer than the buffer.
395fn aligned_envelope(samples: &[f32], total: u64, bucket_len: usize) -> (u64, Vec<Bucket>) {
396 // saturating: a transformation may return more samples than were
397 // recorded, which has no meaningful position in the stream
398 let first = total.saturating_sub(samples.len() as u64);
399 let anchor = first.next_multiple_of(bucket_len as u64);
400 let skip = ((anchor - first) as usize).min(samples.len());
401 (anchor, envelope_exact(&samples[skip..], bucket_len))
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 /// Deterministic signal that varies slowly compared to a bucket, so that
409 /// moving the bucket boundaries does change the min/max values. A signal
410 /// repeating within a single bucket would make the tests below pass no
411 /// matter where the boundaries are.
412 fn signal(len: usize) -> Vec<f32> {
413 (0..len)
414 .map(|i| (i as f32 / 997.0).sin() * 0.8 + (i as f32 / 37.0).sin() * 0.2)
415 .collect()
416 }
417
418 #[test]
419 fn buckets_keep_their_values_while_scrolling() {
420 const BUCKET_LEN: usize = 164;
421 const WINDOW: usize = 4096;
422 // not a multiple of BUCKET_LEN: this is what used to shift the phase
423 const ARRIVED: usize = 800;
424
425 let stream = signal(WINDOW + ARRIVED);
426 let (anchor_a, a) = aligned_envelope(&stream[..WINDOW], WINDOW as u64, BUCKET_LEN);
427 let (anchor_b, b) =
428 aligned_envelope(&stream[ARRIVED..], (WINDOW + ARRIVED) as u64, BUCKET_LEN);
429
430 // Both frames must describe the audio they have in common with the
431 // very same buckets, just at a different position in the stream.
432 let shift = (anchor_b - anchor_a) as usize / BUCKET_LEN;
433 assert!(shift > 0 && shift < a.len());
434 for (i, bucket) in b.iter().take(a.len() - shift).enumerate() {
435 assert_eq!(bucket.min, a[i + shift].min, "bucket {i}");
436 assert_eq!(bucket.max, a[i + shift].max, "bucket {i}");
437 }
438 }
439
440 #[test]
441 fn buckets_are_anchored_to_the_stream_not_to_the_buffer() {
442 const BUCKET_LEN: usize = 164;
443 let samples = signal(4096);
444 for arrived in [0, 1, 163, 164, 800] {
445 let (anchor, buckets) = aligned_envelope(&samples, 4096 + arrived as u64, BUCKET_LEN);
446 assert_eq!(anchor % BUCKET_LEN as u64, 0);
447 // no trailing partial bucket
448 assert!(anchor + (buckets.len() * BUCKET_LEN) as u64 <= 4096 + arrived as u64);
449 }
450 }
451
452 #[test]
453 fn survives_a_buffer_longer_than_the_recording() {
454 // AudioBuffer counts its pre-filled silence, so this cannot happen
455 // through the public API; guard against it regressing anyway, since
456 // it silently wrapped the whole waveform off-screen in release mode.
457 let (anchor, buckets) = aligned_envelope(&signal(4096), 100, 164);
458 assert_eq!(anchor, 0);
459 assert!(!buckets.is_empty());
460 }
461
462 #[test]
463 fn handles_a_transformation_shorter_than_one_bucket() {
464 let (_, buckets) = aligned_envelope(&signal(10), 262144, 164);
465 assert!(buckets.is_empty());
466 }
467}