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        #[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}