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 #[allow(clippy::tuple_array_conversions)]
220 let options = eframe::NativeOptions {
221 viewport: egui::ViewportBuilder::default()
222 .with_inner_size([self.window_size.0, self.window_size.1]),
223 ..Default::default()
224 };
225 eframe::run_native(&self.title, options, Box::new(move |_cc| Ok(Box::new(app))))
226 .map_err(|e| Error::Gui(e.to_string()))?;
227
228 // dropped here, after the window is closed: recording stops
229 drop(stream);
230 Ok(())
231 }
232}
233
234/// The [`eframe`] application: two vertically stacked plots, redrawn
235/// continuously with the latest audio data.
236struct App<'a> {
237 audio: Arc<Mutex<AudioBuffer>>,
238 sample_rate: f32,
239 transform: Transform<'a>,
240 x_range: Option<Range<f64>>,
241 y_range: Option<Range<f64>>,
242 x_label: String,
243 y_label: String,
244}
245
246impl eframe::App for App<'_> {
247 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
248 let ctx = ui.ctx().clone();
249 if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
250 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
251 }
252
253 // lock released immediately; the copy keeps the audio callback fast
254 let (samples, total) = self.audio.lock().unwrap().snapshot();
255 // The buffer is always full, so this does not change between frames.
256 let bucket_len = samples.len().div_ceil(MAX_POINTS);
257
258 egui::Frame::central_panel(ui.style()).show(ui, |ui| {
259 let plot_height = ui.available_height() / 2.0 - ui.spacing().item_spacing.y;
260 let history_secs = samples.len() as f64 / self.sample_rate as f64;
261
262 fixed_plot("waveform")
263 .height(plot_height)
264 .x_axis_label("time (seconds)")
265 .y_axis_label("amplitude")
266 .default_x_bounds(-history_secs, 0.0)
267 .default_y_bounds(-1.0, 1.0)
268 .show(ui, |plot_ui| {
269 plot_ui.add(waveform_area(
270 "original",
271 &samples,
272 self.sample_rate,
273 total,
274 bucket_len,
275 ORIGINAL_COLOR,
276 ));
277 });
278
279 let mut plot = fixed_plot("transformed")
280 .height(plot_height)
281 .x_axis_label(&self.x_label)
282 .y_axis_label(&self.y_label);
283 match &mut self.transform {
284 Transform::Waveform(f) => {
285 let transformed = f(&samples, self.sample_rate);
286 let (x0, x1) = self
287 .x_range
288 .as_ref()
289 .map_or((-history_secs, 0.0), |r| (r.start, r.end));
290 let (y0, y1) = self
291 .y_range
292 .as_ref()
293 .map_or((-1.0, 1.0), |r| (r.start, r.end));
294 plot = plot.default_x_bounds(x0, x1).default_y_bounds(y0, y1);
295 plot.show(ui, |plot_ui| {
296 plot_ui.add(waveform_area(
297 "transformed",
298 &transformed,
299 self.sample_rate,
300 total,
301 bucket_len,
302 TRANSFORM_COLOR,
303 ));
304 });
305 }
306 Transform::Points(f) => {
307 let points = f(&samples, self.sample_rate);
308 // an axis without a fixed range keeps automatic bounds
309 if let Some(r) = &self.x_range {
310 plot = plot.default_x_bounds(r.start, r.end);
311 }
312 if let Some(r) = &self.y_range {
313 plot = plot.default_y_bounds(r.start, r.end);
314 }
315 plot.show(ui, |plot_ui| {
316 let points: PlotPoints = points.iter().map(|(x, y)| [*x, *y]).collect();
317 plot_ui.line(Line::new("transformed", points).color(TRANSFORM_COLOR));
318 });
319 }
320 }
321 });
322
323 // continuous rendering, the audio buffer changes permanently
324 ctx.request_repaint();
325 }
326}
327
328/// A plot with a fixed view: the live data moves, the cursor must not.
329fn fixed_plot<'p>(id: &str) -> Plot<'p> {
330 Plot::new(id)
331 .allow_drag(false)
332 .allow_zoom(false)
333 .allow_scroll(false)
334 .allow_boxed_zoom(false)
335 .allow_double_click_reset(false)
336 .allow_axis_zoom_drag(false)
337}
338
339/// The waveform as one filled shape between the per-bucket minimum and
340/// maximum, with x as seconds relative to now (`-history..0`).
341///
342/// The min/max envelope describes a single signal, so it is drawn as a
343/// single item in a single color. Two separate lines would read as two
344/// unrelated signals.
345///
346/// `samples` are the newest `samples.len()` of the `total` samples recorded
347/// so far; a transformation may return fewer, which then covers accordingly
348/// less time.
349fn waveform_area(
350 name: &str,
351 samples: &[f32],
352 sample_rate: f32,
353 total: u64,
354 bucket_len: usize,
355 color: Color32,
356) -> FilledArea {
357 let (anchor, buckets) = aligned_envelope(samples, total, bucket_len);
358 let time_of =
359 |offset: usize| ((anchor + offset as u64) as f64 - total as f64) / sample_rate as f64;
360
361 let xs: Vec<f64> = buckets.iter().map(|b| time_of(b.start)).collect();
362 let mins: Vec<f64> = buckets.iter().map(|b| b.min as f64).collect();
363 let maxs: Vec<f64> = buckets.iter().map(|b| b.max as f64).collect();
364
365 FilledArea::new(name, &xs, &mins, &maxs)
366 .fill_color(color.gamma_multiply(0.45))
367 .stroke(Stroke::new(1.0, color))
368}
369
370/// Reduces `samples` to min/max buckets that are aligned to absolute
371/// positions in the audio stream, and returns the stream position of the
372/// first bucket along with them.
373///
374/// # Invariant
375///
376/// Bucket boundaries sit on multiples of `bucket_len` counted from the start
377/// of the stream, never relative to `samples[0]`. A given stretch of audio
378/// therefore always lands in the same bucket and keeps its min/max until it
379/// scrolls out of the window.
380///
381/// This is what makes the view stable. Every frame sees a different window
382/// of the stream - about 800 new samples per frame at 48 kHz and 60 fps,
383/// against a bucket of 164 samples. Anchored at `samples[0]`, the same audio
384/// would be re-bucketed with a different phase on every frame: its min/max
385/// would change although the audio did not, so the envelope shimmers instead
386/// of scrolling, and a transient jumps between fixed x slots instead of
387/// sliding.
388///
389/// Two things this relies on, both easy to break:
390/// - `bucket_len` must be the same on every frame. It is derived from the
391/// ringbuffer length, which is constant - not from `samples.len()`, which
392/// a [`Transform`] may change.
393/// - `total` must include the silence the buffer is pre-filled with, see
394/// [`AudioBuffer::new`]. Otherwise the stream position of `samples[0]` is
395/// negative until the recording is longer than the buffer.
396fn aligned_envelope(samples: &[f32], total: u64, bucket_len: usize) -> (u64, Vec<Bucket>) {
397 // saturating: a transformation may return more samples than were
398 // recorded, which has no meaningful position in the stream
399 let first = total.saturating_sub(samples.len() as u64);
400 let anchor = first.next_multiple_of(bucket_len as u64);
401 let skip = ((anchor - first) as usize).min(samples.len());
402 (anchor, envelope_exact(&samples[skip..], bucket_len))
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 /// Deterministic signal that varies slowly compared to a bucket, so that
410 /// moving the bucket boundaries does change the min/max values. A signal
411 /// repeating within a single bucket would make the tests below pass no
412 /// matter where the boundaries are.
413 fn signal(len: usize) -> Vec<f32> {
414 (0..len)
415 .map(|i| (i as f32 / 997.0).sin() * 0.8 + (i as f32 / 37.0).sin() * 0.2)
416 .collect()
417 }
418
419 #[test]
420 fn buckets_keep_their_values_while_scrolling() {
421 const BUCKET_LEN: usize = 164;
422 const WINDOW: usize = 4096;
423 // not a multiple of BUCKET_LEN: this is what used to shift the phase
424 const ARRIVED: usize = 800;
425
426 let stream = signal(WINDOW + ARRIVED);
427 let (anchor_a, a) = aligned_envelope(&stream[..WINDOW], WINDOW as u64, BUCKET_LEN);
428 let (anchor_b, b) =
429 aligned_envelope(&stream[ARRIVED..], (WINDOW + ARRIVED) as u64, BUCKET_LEN);
430
431 // Both frames must describe the audio they have in common with the
432 // very same buckets, just at a different position in the stream.
433 let shift = (anchor_b - anchor_a) as usize / BUCKET_LEN;
434 assert!(shift > 0 && shift < a.len());
435 for (i, bucket) in b.iter().take(a.len() - shift).enumerate() {
436 assert_eq!(bucket.min, a[i + shift].min, "bucket {i}");
437 assert_eq!(bucket.max, a[i + shift].max, "bucket {i}");
438 }
439 }
440
441 #[test]
442 fn buckets_are_anchored_to_the_stream_not_to_the_buffer() {
443 const BUCKET_LEN: usize = 164;
444 let samples = signal(4096);
445 for arrived in [0, 1, 163, 164, 800] {
446 let (anchor, buckets) = aligned_envelope(&samples, 4096 + arrived as u64, BUCKET_LEN);
447 assert_eq!(anchor % BUCKET_LEN as u64, 0);
448 // no trailing partial bucket
449 assert!(anchor + (buckets.len() * BUCKET_LEN) as u64 <= 4096 + arrived as u64);
450 }
451 }
452
453 #[test]
454 fn survives_a_buffer_longer_than_the_recording() {
455 // AudioBuffer counts its pre-filled silence, so this cannot happen
456 // through the public API; guard against it regressing anyway, since
457 // it silently wrapped the whole waveform off-screen in release mode.
458 let (anchor, buckets) = aligned_envelope(&signal(4096), 100, 164);
459 assert_eq!(anchor, 0);
460 assert!(!buckets.is_empty());
461 }
462
463 #[test]
464 fn handles_a_transformation_shorter_than_one_bucket() {
465 let (_, buckets) = aligned_envelope(&signal(10), 262144, 164);
466 assert!(buckets.is_empty());
467 }
468}