Skip to main content

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}