mod input;
pub use input::AudioInput;
use crate::Error;
use crate::waveform::{Bucket, envelope_exact};
use eframe::egui;
use eframe::egui::{Color32, Stroke};
use egui_plot::{FilledArea, Line, Plot, PlotPoints};
use input::AudioBuffer;
use std::ops::Range;
use std::sync::{Arc, Mutex};
const AUDIO_HISTORY_SECONDS: usize = 5;
const MAX_POINTS: usize = 1600;
const ORIGINAL_COLOR: Color32 = Color32::from_rgb(0x4a, 0x9e, 0xda);
const TRANSFORM_COLOR: Color32 = Color32::from_rgb(0xe8, 0x9c, 0x3a);
pub type WaveformFn<'a> = Box<dyn FnMut(&[f32], f32) -> Vec<f32> + 'a>;
pub type PointsFn<'a> = Box<dyn FnMut(&[f32], f32) -> Vec<(f64, f64)> + 'a>;
#[expect(missing_debug_implementations)]
pub enum Transform<'a> {
Waveform(WaveformFn<'a>),
Points(PointsFn<'a>),
}
impl<'a> Transform<'a> {
pub fn waveform(f: impl FnMut(&[f32], f32) -> Vec<f32> + 'a) -> Self {
Self::Waveform(Box::new(f))
}
pub fn points(f: impl FnMut(&[f32], f32) -> Vec<(f64, f64)> + 'a) -> Self {
Self::Points(Box::new(f))
}
}
#[allow(missing_debug_implementations)]
pub struct LiveVisualizer<'a> {
transform: Transform<'a>,
title: String,
input: Option<AudioInput>,
x_range: Option<Range<f64>>,
y_range: Option<Range<f64>>,
x_label: String,
y_label: String,
window_size: (f32, f32),
}
impl<'a> LiveVisualizer<'a> {
#[must_use]
pub fn new(transform: Transform<'a>) -> Self {
Self {
transform,
title: "Live Audio Visualization".to_string(),
input: None,
x_range: None,
y_range: None,
x_label: String::new(),
y_label: String::new(),
window_size: (1280.0, 720.0),
}
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[must_use]
pub fn input(mut self, input: AudioInput) -> Self {
self.input = Some(input);
self
}
#[must_use]
pub const fn x_range(mut self, range: Range<f64>) -> Self {
self.x_range = Some(range);
self
}
#[must_use]
pub const fn y_range(mut self, range: Range<f64>) -> Self {
self.y_range = Some(range);
self
}
#[must_use]
pub fn axis_labels(mut self, x: impl Into<String>, y: impl Into<String>) -> Self {
self.x_label = x.into();
self.y_label = y.into();
self
}
#[must_use]
pub const fn window_size(mut self, width: f32, height: f32) -> Self {
self.window_size = (width, height);
self
}
pub fn open(self) -> Result<(), Error> {
let input = match self.input {
Some(input) => input,
None => AudioInput::default_device()?,
};
let sample_rate = input.config().sample_rate as f32;
let audio = Arc::new(Mutex::new(AudioBuffer::new(
(AUDIO_HISTORY_SECONDS * sample_rate as usize).next_power_of_two(),
)));
let stream = input.build_stream(audio.clone())?;
cpal::traits::StreamTrait::play(&stream)
.map_err(|e| Error::Audio(format!("can't start recording: {e}")))?;
let app = App {
audio,
sample_rate,
transform: self.transform,
x_range: self.x_range,
y_range: self.y_range,
x_label: self.x_label,
y_label: self.y_label,
};
#[allow(clippy::tuple_array_conversions)]
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([self.window_size.0, self.window_size.1]),
..Default::default()
};
eframe::run_native(&self.title, options, Box::new(move |_cc| Ok(Box::new(app))))
.map_err(|e| Error::Gui(e.to_string()))?;
drop(stream);
Ok(())
}
}
struct App<'a> {
audio: Arc<Mutex<AudioBuffer>>,
sample_rate: f32,
transform: Transform<'a>,
x_range: Option<Range<f64>>,
y_range: Option<Range<f64>>,
x_label: String,
y_label: String,
}
impl eframe::App for App<'_> {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
let (samples, total) = self.audio.lock().unwrap().snapshot();
let bucket_len = samples.len().div_ceil(MAX_POINTS);
egui::Frame::central_panel(ui.style()).show(ui, |ui| {
let plot_height = ui.available_height() / 2.0 - ui.spacing().item_spacing.y;
let history_secs = samples.len() as f64 / self.sample_rate as f64;
fixed_plot("waveform")
.height(plot_height)
.x_axis_label("time (seconds)")
.y_axis_label("amplitude")
.default_x_bounds(-history_secs, 0.0)
.default_y_bounds(-1.0, 1.0)
.show(ui, |plot_ui| {
plot_ui.add(waveform_area(
"original",
&samples,
self.sample_rate,
total,
bucket_len,
ORIGINAL_COLOR,
));
});
let mut plot = fixed_plot("transformed")
.height(plot_height)
.x_axis_label(&self.x_label)
.y_axis_label(&self.y_label);
match &mut self.transform {
Transform::Waveform(f) => {
let transformed = f(&samples, self.sample_rate);
let (x0, x1) = self
.x_range
.as_ref()
.map_or((-history_secs, 0.0), |r| (r.start, r.end));
let (y0, y1) = self
.y_range
.as_ref()
.map_or((-1.0, 1.0), |r| (r.start, r.end));
plot = plot.default_x_bounds(x0, x1).default_y_bounds(y0, y1);
plot.show(ui, |plot_ui| {
plot_ui.add(waveform_area(
"transformed",
&transformed,
self.sample_rate,
total,
bucket_len,
TRANSFORM_COLOR,
));
});
}
Transform::Points(f) => {
let points = f(&samples, self.sample_rate);
if let Some(r) = &self.x_range {
plot = plot.default_x_bounds(r.start, r.end);
}
if let Some(r) = &self.y_range {
plot = plot.default_y_bounds(r.start, r.end);
}
plot.show(ui, |plot_ui| {
let points: PlotPoints = points.iter().map(|(x, y)| [*x, *y]).collect();
plot_ui.line(Line::new("transformed", points).color(TRANSFORM_COLOR));
});
}
}
});
ctx.request_repaint();
}
}
fn fixed_plot<'p>(id: &str) -> Plot<'p> {
Plot::new(id)
.allow_drag(false)
.allow_zoom(false)
.allow_scroll(false)
.allow_boxed_zoom(false)
.allow_double_click_reset(false)
.allow_axis_zoom_drag(false)
}
fn waveform_area(
name: &str,
samples: &[f32],
sample_rate: f32,
total: u64,
bucket_len: usize,
color: Color32,
) -> FilledArea {
let (anchor, buckets) = aligned_envelope(samples, total, bucket_len);
let time_of =
|offset: usize| ((anchor + offset as u64) as f64 - total as f64) / sample_rate as f64;
let xs: Vec<f64> = buckets.iter().map(|b| time_of(b.start)).collect();
let mins: Vec<f64> = buckets.iter().map(|b| b.min as f64).collect();
let maxs: Vec<f64> = buckets.iter().map(|b| b.max as f64).collect();
FilledArea::new(name, &xs, &mins, &maxs)
.fill_color(color.gamma_multiply(0.45))
.stroke(Stroke::new(1.0, color))
}
fn aligned_envelope(samples: &[f32], total: u64, bucket_len: usize) -> (u64, Vec<Bucket>) {
let first = total.saturating_sub(samples.len() as u64);
let anchor = first.next_multiple_of(bucket_len as u64);
let skip = ((anchor - first) as usize).min(samples.len());
(anchor, envelope_exact(&samples[skip..], bucket_len))
}
#[cfg(test)]
mod tests {
use super::*;
fn signal(len: usize) -> Vec<f32> {
(0..len)
.map(|i| (i as f32 / 997.0).sin() * 0.8 + (i as f32 / 37.0).sin() * 0.2)
.collect()
}
#[test]
fn buckets_keep_their_values_while_scrolling() {
const BUCKET_LEN: usize = 164;
const WINDOW: usize = 4096;
const ARRIVED: usize = 800;
let stream = signal(WINDOW + ARRIVED);
let (anchor_a, a) = aligned_envelope(&stream[..WINDOW], WINDOW as u64, BUCKET_LEN);
let (anchor_b, b) =
aligned_envelope(&stream[ARRIVED..], (WINDOW + ARRIVED) as u64, BUCKET_LEN);
let shift = (anchor_b - anchor_a) as usize / BUCKET_LEN;
assert!(shift > 0 && shift < a.len());
for (i, bucket) in b.iter().take(a.len() - shift).enumerate() {
assert_eq!(bucket.min, a[i + shift].min, "bucket {i}");
assert_eq!(bucket.max, a[i + shift].max, "bucket {i}");
}
}
#[test]
fn buckets_are_anchored_to_the_stream_not_to_the_buffer() {
const BUCKET_LEN: usize = 164;
let samples = signal(4096);
for arrived in [0, 1, 163, 164, 800] {
let (anchor, buckets) = aligned_envelope(&samples, 4096 + arrived as u64, BUCKET_LEN);
assert_eq!(anchor % BUCKET_LEN as u64, 0);
assert!(anchor + (buckets.len() * BUCKET_LEN) as u64 <= 4096 + arrived as u64);
}
}
#[test]
fn survives_a_buffer_longer_than_the_recording() {
let (anchor, buckets) = aligned_envelope(&signal(4096), 100, 164);
assert_eq!(anchor, 0);
assert!(!buckets.is_empty());
}
#[test]
fn handles_a_transformation_shorter_than_one_bucket() {
let (_, buckets) = aligned_envelope(&signal(10), 262144, 164);
assert!(buckets.is_empty());
}
}