use alloc::collections::VecDeque;
use core::cell::Cell;
use crate::core::{Color, Rect};
use crate::event::{Event, EventHandler};
use crate::impl_widget_property_hooks;
use crate::property_names_of;
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_f32, expect_string, expect_usize};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VisualizerMode {
#[default]
Waveform,
Spectrum,
}
pub struct AudioVisualizer {
base: BaseWidget,
samples: VecDeque<f32>,
mode: VisualizerMode,
spectrum: Option<Vec<f32>>,
spectrum_cache: Cell<Option<[f32; SPECTRUM_BINS]>>,
bar_count: usize,
bar_spacing: f32,
bar_color: Option<Color>,
bar_buffer: Vec<f32>,
peak_moving: bool,
smoothing_moving: bool,
background_color: Option<Color>,
mirror: bool,
peak_hold: bool,
peak_hold_duration: u64,
peak_values: Vec<f32>,
smoothed_levels: Vec<f32>,
attack_ms: f32,
release_ms: f32,
peak_ages: Vec<u64>,
pub level_changed: Signal1<f32>,
pub peak_exceeded: Signal1<f32>,
pub mode_changed: Signal1<String>,
peak_threshold: f32,
level_report_interval_ms: u32,
report_clock_ms: u32,
last_reported_level: f32,
last_reported_peak: f32,
}
const PEAK_FALL_PER_SECOND: f32 = 1.0;
const SPECTRUM_WINDOW: usize = 256;
const SPECTRUM_BINS: usize = SPECTRUM_WINDOW / 2;
const SAMPLE_HISTORY_LIMIT: usize = SPECTRUM_WINDOW * 2;
impl AudioVisualizer {
pub fn new(geometry: Rect) -> Self {
let bar_count = 64;
Self {
base: BaseWidget::new(WidgetKind::AudioVisualizer, geometry, "AudioVisualizer"),
samples: VecDeque::new(),
mode: VisualizerMode::Waveform,
spectrum: None,
spectrum_cache: Cell::new(None),
bar_count,
bar_spacing: 2.0,
bar_color: None,
bar_buffer: Vec::new(),
peak_moving: false,
smoothing_moving: false,
background_color: None,
mirror: false,
peak_hold: false,
peak_hold_duration: 500,
peak_values: vec![0.0; bar_count],
peak_ages: vec![0; bar_count],
smoothed_levels: Vec::new(),
attack_ms: 0.0,
release_ms: 0.0,
level_changed: Signal1::new(),
peak_exceeded: Signal1::new(),
mode_changed: Signal1::new(),
peak_threshold: 0.9,
level_report_interval_ms: 50,
report_clock_ms: 0,
last_reported_level: 0.0,
last_reported_peak: 0.0,
}
}
pub fn set_samples(&mut self, data: Vec<f32>) {
let skip = data.len().saturating_sub(SAMPLE_HISTORY_LIMIT);
self.samples = data.into_iter().skip(skip).map(|v| v.clamp(-1.0, 1.0)).collect();
self.spectrum_cache.set(None);
if !self.smoothed_levels.is_empty() {
self.smoothing_moving = true;
}
self.base.request_redraw();
}
pub fn set_samples_bulk(&mut self, data: &[f32]) {
for &sample in data {
self.push_sample(sample);
}
self.spectrum_cache.set(None);
if !self.smoothed_levels.is_empty() {
self.smoothing_moving = true;
}
self.base.request_redraw();
}
fn push_sample(&mut self, value: f32) {
if self.samples.len() >= SAMPLE_HISTORY_LIMIT {
self.samples.pop_front();
}
self.samples.push_back(value.clamp(-1.0, 1.0));
}
pub fn add_sample(&mut self, value: f32) {
self.push_sample(value);
self.spectrum_cache.set(None);
if !self.smoothed_levels.is_empty() {
self.smoothing_moving = true;
}
self.base.request_redraw();
}
pub fn set_smoothing(&mut self, attack_ms: f32, release_ms: f32) {
let attack = if attack_ms.is_finite() { attack_ms.max(0.0) } else { 0.0 };
let release = if release_ms.is_finite() { release_ms.max(0.0) } else { 0.0 };
if self.attack_ms == attack && self.release_ms == release {
return;
}
self.attack_ms = attack;
self.release_ms = release;
if attack == 0.0 && release == 0.0 {
self.smoothed_levels.clear();
self.smoothing_moving = false;
} else {
self.smoothed_levels.resize(self.bar_count, 0.0);
self.smoothing_moving = true;
}
self.base.request_redraw();
}
pub fn is_smoothing_enabled(&self) -> bool {
!self.smoothed_levels.is_empty()
}
fn display_level(&self, index: usize) -> f32 {
self.smoothed_levels.get(index).copied().unwrap_or_else(|| self.bar_value(index))
}
fn advance_smoothing(&mut self, delta_ms: u32) -> bool {
if self.smoothed_levels.is_empty() || delta_ms == 0 {
return false;
}
let mut moved = false;
for index in 0..self.smoothed_levels.len() {
let target = self.bar_value(index);
let current = self.smoothed_levels[index];
let tau = if target > current { self.attack_ms } else { self.release_ms };
let next = if tau <= 0.0 {
target
} else {
let coefficient = 1.0 - (-(delta_ms as f32) / tau).exp();
current + (target - current) * coefficient
};
if (next - current).abs() > f32::EPSILON {
self.smoothed_levels[index] = next;
moved = true;
}
}
moved
}
pub fn clear_samples(&mut self) {
self.samples.clear();
self.spectrum_cache.set(None);
self.base.request_redraw();
}
pub fn samples(&self) -> Vec<f32> {
self.samples.iter().copied().collect()
}
pub fn set_mode(&mut self, mode: VisualizerMode) {
if self.mode == mode {
return;
}
self.mode = mode;
let name = match mode {
VisualizerMode::Waveform => "waveform",
VisualizerMode::Spectrum => "spectrum",
};
self.mode_changed.emit(name.to_string());
self.base.request_redraw();
}
pub fn peak_threshold(&self) -> f32 {
self.peak_threshold
}
pub fn set_peak_threshold(&mut self, threshold: f32) {
if !threshold.is_finite() {
return;
}
let threshold = threshold.clamp(0.0, 1.0);
if self.peak_threshold == threshold {
return;
}
self.peak_threshold = threshold;
}
pub fn level_report_interval(&self) -> u32 {
self.level_report_interval_ms
}
pub fn set_level_report_interval(&mut self, ms: u32) {
if self.level_report_interval_ms == ms {
return;
}
self.level_report_interval_ms = ms;
}
pub fn mode(&self) -> VisualizerMode {
self.mode
}
pub fn set_spectrum(&mut self, bins: Vec<f32>) {
let sanitized: Vec<f32> = bins
.into_iter()
.map(|bin| if bin.is_finite() { bin.clamp(0.0, 1.0) } else { 0.0 })
.collect();
self.spectrum = if sanitized.is_empty() { None } else { Some(sanitized) };
self.base.request_redraw();
}
pub fn set_bar_count(&mut self, n: usize) {
let bar_count = n.max(1);
if self.bar_count == bar_count {
return;
}
self.bar_count = bar_count;
self.peak_values.resize(self.bar_count, 0.0);
self.peak_ages.resize(self.bar_count, 0);
self.bar_buffer.resize(self.bar_count, 0.0);
if !self.smoothed_levels.is_empty() {
self.smoothed_levels.resize(self.bar_count, 0.0);
}
self.base.request_redraw();
}
pub fn bar_count(&self) -> usize {
self.bar_count
}
pub fn set_bar_spacing(&mut self, spacing: f32) {
let spacing = spacing.max(0.0);
if self.bar_spacing == spacing {
return;
}
self.bar_spacing = spacing;
self.base.request_redraw();
}
pub fn bar_spacing(&self) -> f32 {
self.bar_spacing
}
pub fn set_bar_color(&mut self, color: Color) {
if self.bar_color == Some(color) {
return;
}
self.bar_color = Some(color);
self.base.request_redraw();
}
pub fn bar_color(&self) -> Option<Color> {
self.bar_color
}
pub fn set_background_color(&mut self, color: Color) {
if self.background_color == Some(color) {
return;
}
self.background_color = Some(color);
self.base.request_redraw();
}
pub fn background_color(&self) -> Color {
self.resolved_background()
}
fn resolved_background(&self) -> Color {
if let Some(chosen) = self.background_color {
return chosen;
}
let themed = crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.surface_container);
themed.unwrap_or(Color::rgba(20, 20, 30, 255))
}
pub fn set_mirror(&mut self, mirror: bool) {
if self.mirror == mirror {
return;
}
self.mirror = mirror;
self.base.request_redraw();
}
pub fn is_mirror_enabled(&self) -> bool {
self.mirror
}
pub fn set_peak_hold(&mut self, enabled: bool) {
if self.peak_hold == enabled {
return;
}
self.peak_hold = enabled;
self.peak_moving = enabled;
self.base.request_redraw();
}
pub fn is_peak_hold_enabled(&self) -> bool {
self.peak_hold
}
pub fn set_peak_hold_duration(&mut self, ms: u64) {
if self.peak_hold_duration == ms {
return;
}
self.peak_hold_duration = ms;
self.base.request_redraw();
}
pub fn peak_hold_duration(&self) -> u64 {
self.peak_hold_duration
}
fn advance_peak_hold(&mut self, delta_ms: u64) -> bool {
if !self.peak_hold || delta_ms == 0 {
return false;
}
let fall = PEAK_FALL_PER_SECOND * (delta_ms as f32 / 1000.0);
let mut moved = false;
for index in 0..self.peak_values.len() {
let value = self.bar_value(index);
if value >= self.peak_values[index] {
moved |= self.peak_values[index] != value;
self.peak_values[index] = value;
self.peak_ages[index] = 0;
continue;
}
self.peak_ages[index] = self.peak_ages[index].saturating_add(delta_ms);
if self.peak_ages[index] <= self.peak_hold_duration {
continue;
}
let next = (self.peak_values[index] - fall).max(self.peak_values[index].min(value));
if next <= 0.0 || next <= value {
self.peak_values[index] = value;
self.peak_ages[index] = 0;
} else {
self.peak_values[index] = next;
}
moved = true;
}
moved
}
fn bar_value(&self, index: usize) -> f32 {
if self.bar_count == 0 || index >= self.bar_count {
return 0.0;
}
match self.mode {
VisualizerMode::Waveform => self.waveform_bar_value(index),
VisualizerMode::Spectrum => self.spectrum_bar_value(index),
}
}
fn waveform_bar_value(&self, index: usize) -> f32 {
if self.samples.is_empty() {
let t = index as f32 / self.bar_count as f32;
return ((t * std::f32::consts::PI * 4.0).sin().abs() * 0.6 + 0.1).min(1.0);
}
let step = (self.samples.len() as f32 / self.bar_count as f32).max(1.0);
let start = (index as f32 * step) as usize;
let end = (((index as f32 + 1.0) * step) as usize).min(self.samples.len());
if start >= end {
return 0.0;
}
let sum: f32 =
self.samples.iter().skip(start).take(end - start).map(|sample| sample.abs()).sum();
(sum / (end - start) as f32).min(1.0)
}
fn spectrum_bar_value(&self, index: usize) -> f32 {
if let Some(bins) = self.spectrum.as_deref() {
let (start, end) = self.bar_bin_range(index, bins.len());
return peak_in_band(bins, start, end);
}
if self.samples.is_empty() {
return 0.0;
}
let magnitudes = self.derived_spectrum();
let (start, end) = self.bar_bin_range(index, SPECTRUM_BINS);
peak_in_band(&magnitudes, start, end)
}
fn bar_bin_range(&self, index: usize, bin_count: usize) -> (usize, usize) {
if bin_count == 0 {
return (0, 0);
}
if bin_count == 1 {
return (0, 1);
}
let bars = self.bar_count.max(1);
let first = 1.0f32;
let last = bin_count as f32;
let ratio = last / first;
let position = |fraction: f32| first * ratio.powf(fraction.clamp(0.0, 1.0));
let start_position = position(index as f32 / bars as f32);
let end_position = position((index + 1) as f32 / bars as f32);
let start = (start_position.floor() as usize).clamp(1, bin_count - 1);
let end = (end_position.round() as usize).clamp(start + 1, bin_count);
(start, end)
}
fn analysis_window(&self) -> [f32; SPECTRUM_WINDOW] {
let mut window = [0.0f32; SPECTRUM_WINDOW];
let available = self.samples.len().min(SPECTRUM_WINDOW);
let start = self.samples.len() - available;
for (slot, sample) in self.samples.iter().skip(start).enumerate() {
window[slot] = *sample;
}
window
}
fn derived_spectrum(&self) -> [f32; SPECTRUM_BINS] {
if let Some(cached) = self.spectrum_cache.get() {
return cached;
}
let magnitudes = fft_magnitudes(&self.analysis_window());
self.spectrum_cache.set(Some(magnitudes));
magnitudes
}
fn advance_level_report(&mut self, delta_ms: u32) -> bool {
self.report_clock_ms = self.report_clock_ms.saturating_add(delta_ms);
if self.level_report_interval_ms != 0
&& self.report_clock_ms < self.level_report_interval_ms
{
return false;
}
self.report_clock_ms = 0;
let level = if self.bar_count == 0 {
0.0
} else {
(0..self.bar_count).map(|index| self.display_level(index)).sum::<f32>()
/ self.bar_count as f32
};
let mut reported = false;
if (level - self.last_reported_level).abs() > 0.005 {
self.last_reported_level = level;
self.level_changed.emit(level);
reported = true;
}
let peak = self.samples.iter().fold(0.0f32, |current, sample| current.max(sample.abs()));
if peak > self.peak_threshold && (peak - self.last_reported_peak).abs() > 0.005 {
self.last_reported_peak = peak;
self.peak_exceeded.emit(peak);
reported = true;
}
reported
}
}
fn peak_in_band(bins: &[f32], start: usize, end: usize) -> f32 {
let end = end.min(bins.len());
if start >= end {
return 0.0;
}
bins[start..end].iter().copied().fold(0.0, f32::max)
}
fn reverse_bits(mut value: u32, bits: u32) -> u32 {
let mut reversed = 0u32;
for _ in 0..bits {
reversed = (reversed << 1) | (value & 1);
value >>= 1;
}
reversed
}
fn fft_magnitudes(window: &[f32; SPECTRUM_WINDOW]) -> [f32; SPECTRUM_BINS] {
let mut re = [0.0f32; SPECTRUM_WINDOW];
let mut im = [0.0f32; SPECTRUM_WINDOW];
for (index, sample) in window.iter().enumerate() {
let phase = 2.0 * std::f32::consts::PI * index as f32 / SPECTRUM_WINDOW as f32;
let hann = 0.5 - 0.5 * phase.cos();
re[index] = sample * hann;
}
let bits = SPECTRUM_WINDOW.trailing_zeros();
for index in 0..SPECTRUM_WINDOW {
let mirrored = reverse_bits(index as u32, bits) as usize;
if mirrored > index {
re.swap(index, mirrored);
im.swap(index, mirrored);
}
}
let mut length = 2;
while length <= SPECTRUM_WINDOW {
let angle = -2.0 * std::f32::consts::PI / length as f32;
let (twiddle_re, twiddle_im) = (angle.cos(), angle.sin());
let mut block = 0;
while block < SPECTRUM_WINDOW {
let mut rotation_re = 1.0f32;
let mut rotation_im = 0.0f32;
for offset in 0..length / 2 {
let even = block + offset;
let odd = even + length / 2;
let odd_re = re[odd] * rotation_re - im[odd] * rotation_im;
let odd_im = re[odd] * rotation_im + im[odd] * rotation_re;
re[odd] = re[even] - odd_re;
im[odd] = im[even] - odd_im;
re[even] += odd_re;
im[even] += odd_im;
let next_re = rotation_re * twiddle_re - rotation_im * twiddle_im;
rotation_im = rotation_re * twiddle_im + rotation_im * twiddle_re;
rotation_re = next_re;
}
block += length;
}
length <<= 1;
}
let scale = 4.0 / SPECTRUM_WINDOW as f32;
let mut magnitudes = [0.0f32; SPECTRUM_BINS];
for (bin, magnitude) in magnitudes.iter_mut().enumerate() {
*magnitude = (re[bin].hypot(im[bin]) * scale).min(1.0);
}
magnitudes
}
impl Widget for AudioVisualizer {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(
crate::widget::metrics::dimensions::AUDIO_VISUALIZER_DEFAULT_WIDTH,
crate::widget::metrics::dimensions::AUDIO_VISUALIZER_DEFAULT_HEIGHT,
)
}
fn tick(&mut self, delta_ms: u32) -> bool {
let peaked = self.advance_peak_hold(delta_ms as u64);
let smoothed = self.advance_smoothing(delta_ms);
let reported = self.advance_level_report(delta_ms);
self.peak_moving = peaked;
self.smoothing_moving = smoothed;
if peaked || smoothed {
self.base.request_redraw();
}
peaked || smoothed || reported
}
fn is_animating(&self) -> bool {
self.peak_moving || self.smoothing_moving
}
fn as_draw_mut(&mut self) -> Option<&mut dyn crate::widget::Draw> {
Some(self)
}
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"level_changed" => {
Some(EventSignalRef::mapped("level_changed", &self.level_changed, |v| {
CapabilityValue::Float(*v as f64)
}))
}
"peak_exceeded" => {
Some(EventSignalRef::mapped("peak_exceeded", &self.peak_exceeded, |v| {
CapabilityValue::Float(*v as f64)
}))
}
"mode_changed" => {
Some(EventSignalRef::mapped("mode_changed", &self.mode_changed, |v| {
CapabilityValue::String((*v).clone())
}))
}
_ => None,
}
}
}
impl WidgetProperties for AudioVisualizer {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"bar_count" => Ok(CapabilityValue::UInt(self.bar_count() as u64)),
"peak_threshold" => Ok(CapabilityValue::Float(self.peak_threshold() as f64)),
"mode" => Ok(CapabilityValue::String(
match self.mode() {
VisualizerMode::Waveform => "waveform",
VisualizerMode::Spectrum => "spectrum",
}
.to_string(),
)),
"level_report_interval" => {
Ok(CapabilityValue::UInt(self.level_report_interval() as u64))
}
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"bar_count" => {
self.set_bar_count(expect_usize(value)?);
Ok(())
}
"peak_threshold" => {
self.set_peak_threshold(expect_f32(value)?);
Ok(())
}
"mode" => {
let mode = match expect_string(value)?.to_ascii_lowercase().as_str() {
"waveform" => VisualizerMode::Waveform,
"spectrum" => VisualizerMode::Spectrum,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_mode(mode);
Ok(())
}
"level_report_interval" => {
let ms = expect_usize(value)?.min(u32::MAX as usize) as u32;
self.set_level_report_interval(ms);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"bar_count",
"peak_threshold",
"mode",
"level_report_interval",
BASE_PROPERTY_NAMES
]
}
}
impl Draw for AudioVisualizer {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let w = rect.width as f32;
let h = rect.height as f32;
if w <= 0.0 || h <= 0.0 {
return;
}
context.face_with_gradient(
rect,
self.resolved_background(),
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
let total_spacing = self.bar_spacing * (self.bar_count as f32 + 1.0);
let bar_width = ((w - total_spacing) / self.bar_count as f32).max(1.0);
let center_y = rect.y as f32 + h / 2.0;
let half_height = h / 2.0 - 2.0;
self.bar_buffer.clear();
self.bar_buffer.reserve(self.bar_count);
for index in 0..self.bar_count {
let level = self.display_level(index);
self.bar_buffer.push(level);
}
let explicit_bar_color = self.bar_color;
for (i, &value) in self.bar_buffer.iter().enumerate() {
let value = value.min(1.0);
let bar_height = (value * half_height).max(1.0);
let x = rect.x as f32 + self.bar_spacing + i as f32 * (bar_width + self.bar_spacing);
let bar_rect = Rect::new(
x as i32,
(center_y - bar_height) as i32,
bar_width as u32,
(bar_height * 2.0) as u32,
);
let intensity = (value * 255.0) as u8;
let bar_color = explicit_bar_color.unwrap_or_else(|| {
if value > 0.7 {
Color::rgba(255, intensity, intensity, 255)
} else if value > 0.4 {
Color::rgba(intensity, 200, 255, 255)
} else {
Color::rgba(intensity / 2, intensity / 2, 200, 255)
}
});
if self.mirror {
let top_bar = Rect::new(
x as i32,
(center_y - bar_height) as i32,
bar_width as u32,
bar_height as u32,
);
let bottom_bar =
Rect::new(x as i32, center_y as i32, bar_width as u32, bar_height as u32);
context.fill_rect(top_bar, bar_color);
context.fill_rect(bottom_bar, bar_color);
} else {
context.fill_rect(bar_rect, bar_color);
}
if self.peak_hold {
let peak_value = self.peak_values[i];
if peak_value > 0.0 {
let peak_y = center_y - peak_value * half_height;
let peak_rect = Rect::new(
x as i32,
peak_y as i32,
bar_width as u32,
std::cmp::max(1, (bar_width * 0.5) as u32),
);
context.fill_rect(peak_rect, Color::rgba(255, 255, 100, 255));
}
}
}
}
}
impl EventHandler for AudioVisualizer {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button, .. }
if *button == 1 && self.geometry().contains_point(*pos) =>
{
self.peak_hold = !self.peak_hold;
self.base.request_redraw();
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
#[test]
fn audio_visualizer_default_state() {
let av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
assert_eq!(av.bar_count(), 64);
assert!(av.samples().is_empty());
assert!(!av.is_mirror_enabled());
assert!(!av.is_peak_hold_enabled());
assert_eq!(av.kind(), WidgetKind::AudioVisualizer);
}
#[test]
fn audio_visualizer_set_samples() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
let data = vec![0.0, 0.5, 1.0, -0.5, 0.0];
av.set_samples(data.clone());
assert_eq!(av.samples(), data);
}
#[test]
fn audio_visualizer_add_and_clear_samples() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.add_sample(0.5);
av.add_sample(-0.3);
av.add_sample(0.8);
assert_eq!(av.samples().len(), 3);
av.clear_samples();
assert!(av.samples().is_empty());
}
#[test]
fn audio_visualizer_set_bar_count() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_bar_count(32);
assert_eq!(av.bar_count(), 32);
av.set_bar_count(0); assert_eq!(av.bar_count(), 1);
}
#[test]
fn audio_visualizer_toggle_mirror_and_peak_hold() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
assert!(!av.is_mirror_enabled());
av.set_mirror(true);
assert!(av.is_mirror_enabled());
assert!(!av.is_peak_hold_enabled());
av.set_peak_hold(true);
assert!(av.is_peak_hold_enabled());
}
#[test]
fn is_animating_follows_the_peak_hold_clock() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
assert!(!av.is_animating(), "a rest visualizer owes no frames");
av.set_peak_hold(true);
av.set_bar_count(4);
av.set_peak_hold_duration(0);
av.set_samples(vec![1.0, 1.0, 1.0, 1.0]);
assert!(av.tick(16), "raising the markers is a frame-worthy change");
av.set_samples(vec![0.0, 0.0, 0.0, 0.0]);
assert!(av.is_animating(), "markers above their bars are in flight");
for _ in 0..400 {
if !av.tick(16) {
break;
}
}
assert!(!av.is_animating(), "a settled visualizer must stop asking for frames");
}
#[test]
fn audio_visualizer_bar_spacing() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
assert!((av.bar_spacing() - 2.0).abs() < f32::EPSILON);
av.set_bar_spacing(5.0);
assert!((av.bar_spacing() - 5.0).abs() < f32::EPSILON);
}
#[test]
#[cfg(all(device_profile, feature = "desktop"))]
fn the_background_defaults_to_the_theme_and_still_honours_an_override() {
let _guard = crate::style::theme_test_guard();
crate::widget::census::install_preset_appearances();
let themed = |appearance| -> Color {
crate::theme::global_theme_manager().set_appearance(appearance);
AudioVisualizer::new(Rect::new(0, 0, 300, 150)).background_color()
};
let dark = themed(crate::theme::AppearanceMode::Dark);
let light = themed(crate::theme::AppearanceMode::Light);
assert_ne!(
dark, light,
"an un-configured visualizer must take the theme's surface, not a fixed near-black; \
both were {dark:?}"
);
crate::theme::global_theme_manager().set_appearance(crate::theme::AppearanceMode::Light);
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
let chosen = Color::rgb(1, 2, 3);
av.set_background_color(chosen);
assert_eq!(av.background_color(), chosen, "a caller's colour must survive the theme");
}
#[test]
fn peak_hold_holds_for_its_duration_then_falls() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_bar_count(1);
av.set_peak_hold(true);
av.set_peak_hold_duration(500);
av.set_samples(vec![1.0]);
assert!(av.advance_peak_hold(16), "the marker rises to meet the bar");
assert!((av.peak_values[0] - 1.0).abs() < 0.001, "it rose to the bar's level");
av.set_samples(vec![0.0]);
assert!(!av.advance_peak_hold(400), "a held marker owes no frame");
assert!((av.peak_values[0] - 1.0).abs() < 0.001, "still at its peak, not falling yet");
assert!(av.advance_peak_hold(200), "past the hold, the marker moves");
let falling = av.peak_values[0];
assert!(
falling < 1.0 && falling > 0.0,
"a released marker descends instead of jumping to zero: {falling}"
);
let mut patient = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
patient.set_bar_count(1);
patient.set_peak_hold(true);
patient.set_peak_hold_duration(5_000);
patient.set_samples(vec![1.0]);
patient.advance_peak_hold(16);
patient.set_samples(vec![0.0]);
assert!(!patient.advance_peak_hold(200), "a 5 s hold is still holding at 200 ms");
assert!((patient.peak_values[0] - 1.0).abs() < 0.001);
for _ in 0..40 {
av.advance_peak_hold(100);
}
assert!(
(av.peak_values[0] - 0.0).abs() < 0.001,
"a fully released marker sits on its bar: {}",
av.peak_values[0]
);
assert!(!av.advance_peak_hold(100), "a settled marker owes no frame");
}
#[test]
fn the_hold_duration_reaches_the_pixels() {
let build = |duration| -> String {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 200, 60));
av.set_bar_count(1);
av.set_peak_hold(true);
av.set_peak_hold_duration(duration);
av.set_samples(vec![1.0]);
av.advance_peak_hold(16);
av.set_samples(vec![0.0]);
av.advance_peak_hold(600);
render_to_svg(&mut av)
};
assert_ne!(
build(500),
build(5_000),
"the same instant must look different under two hold durations"
);
}
#[test]
fn each_bar_holds_its_own_peak() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_bar_count(2);
av.set_peak_hold(true);
av.set_peak_hold_duration(0);
av.set_samples(vec![1.0, 0.0]);
av.advance_peak_hold(16);
assert!((av.peak_values[0] - 1.0).abs() < 0.001);
assert!(av.peak_values[1].abs() < 0.001);
av.set_samples(vec![0.0, 1.0]);
av.advance_peak_hold(1_000);
assert!((av.peak_values[1] - 1.0).abs() < 0.001, "bar 1's marker rose");
assert!(
av.peak_values[0] < 1.0,
"bar 0's marker fell on its own clock: {}",
av.peak_values[0]
);
}
#[test]
fn mode_defaults_to_waveform_and_round_trips() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
assert_eq!(av.mode(), VisualizerMode::Waveform);
assert_eq!(VisualizerMode::default(), VisualizerMode::Waveform);
av.set_mode(VisualizerMode::Spectrum);
assert_eq!(av.mode(), VisualizerMode::Spectrum);
av.set_mode(VisualizerMode::Waveform);
assert_eq!(av.mode(), VisualizerMode::Waveform);
}
#[test]
fn waveform_mode_keeps_the_mean_magnitude_formula() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_bar_count(4);
let samples = vec![0.5, -0.25, 0.75, -1.0, 0.0, 0.25, -0.5, 1.0];
av.set_samples(samples.clone());
assert_eq!(av.mode(), VisualizerMode::Waveform);
let step = samples.len() as f32 / 4.0;
for index in 0..4 {
let start = (index as f32 * step) as usize;
let end = (((index as f32 + 1.0) * step) as usize).min(samples.len());
let expected: f32 = samples[start..end].iter().map(|sample| sample.abs()).sum::<f32>()
/ (end - start) as f32;
assert!(
(av.bar_value(index) - expected).abs() < 1e-6,
"bar {index}: {} vs {expected}",
av.bar_value(index)
);
}
for (index, expected) in [0.375f32, 0.875, 0.125, 0.75].into_iter().enumerate() {
assert!(
(av.bar_value(index) - expected).abs() < 1e-6,
"bar {index} must be the mean magnitude {expected}, got {}",
av.bar_value(index)
);
}
}
#[test]
fn spectrum_mode_places_a_one_kilohertz_sine_in_the_expected_band() {
const BARS: usize = 64;
let sample_rate = 48_000.0f32;
let tone = 1_000.0f32;
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_mode(VisualizerMode::Spectrum);
av.set_bar_count(BARS);
assert_eq!(av.mode(), VisualizerMode::Spectrum);
let samples: Vec<f32> = (0..SPECTRUM_WINDOW)
.map(|n| (2.0 * std::f32::consts::PI * tone * n as f32 / sample_rate).sin())
.collect();
av.set_samples(samples.clone());
let mut window = [0.0f32; SPECTRUM_WINDOW];
window.copy_from_slice(&samples);
let magnitudes = fft_magnitudes(&window);
let bin_width = sample_rate / SPECTRUM_WINDOW as f32;
let expected_bin = tone / bin_width;
assert!(
(expected_bin - 5.33).abs() < 0.01,
"the test's own arithmetic drifted: {expected_bin}"
);
let peak_bin = (0..SPECTRUM_BINS).fold(0usize, |best, bin| {
if magnitudes[bin] > magnitudes[best] {
bin
} else {
best
}
});
assert!(
peak_bin == 5 || peak_bin == 6,
"a 1 kHz tone must peak on bins 5/6, got {peak_bin}"
);
assert!(
magnitudes[peak_bin] > 0.5,
"the tone must carry real energy, got {}",
magnitudes[peak_bin]
);
let lowest = bin_width; let highest = sample_rate / 2.0; let ratio = highest / lowest;
let band = |index: usize| {
(
lowest * ratio.powf(index as f32 / BARS as f32),
lowest * ratio.powf((index + 1) as f32 / BARS as f32),
)
};
let expected_region: Vec<usize> = (0..BARS)
.filter(|&index| {
let (low, high) = band(index);
high >= tone - bin_width && low <= tone + bin_width
})
.collect();
assert!(
!expected_region.is_empty(),
"the expected region must not be empty, or the assertion below is vacuous"
);
let peak_bar =
(0..BARS).fold(
0usize,
|best, bar| {
if av.bar_value(bar) > av.bar_value(best) {
bar
} else {
best
}
},
);
assert!(
expected_region.contains(&peak_bar),
"peak bar {peak_bar} (band {:?}) must sit in the expected region {expected_region:?}",
band(peak_bar)
);
assert!(
av.bar_value(0) < 0.1,
"the low end must not pick up a 1 kHz tone: {}",
av.bar_value(0)
);
assert!(
av.bar_value(BARS - 1) < 0.1,
"the high end must not pick up a 1 kHz tone: {}",
av.bar_value(BARS - 1)
);
}
#[test]
fn a_short_history_is_zero_padded_instead_of_stretched() {
const BARS: usize = 64;
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_bar_count(BARS);
av.set_mode(VisualizerMode::Spectrum);
let tone = |count: usize| -> Vec<f32> {
(0..count)
.map(|n| (2.0 * std::f32::consts::PI * 1_000.0 * n as f32 / 48_000.0).sin())
.collect()
};
let peak_bar =
|av: &AudioVisualizer| {
(0..BARS).fold(0usize, |best, bar| {
if av.bar_value(bar) > av.bar_value(best) {
bar
} else {
best
}
})
};
av.set_samples(tone(SPECTRUM_WINDOW));
let full_peak = peak_bar(&av);
let full_level = av.bar_value(full_peak);
av.set_samples(tone(SPECTRUM_WINDOW / 4));
let short_peak = peak_bar(&av);
assert!(
short_peak.abs_diff(full_peak) <= 2,
"zero padding must keep the tone's band, full={full_peak} short={short_peak}"
);
assert!(
av.bar_value(short_peak) > 0.05,
"the short window still carries the tone: {}",
av.bar_value(short_peak)
);
assert!(
av.bar_value(short_peak) < full_level,
"a changed history must invalidate the cached spectrum"
);
}
#[test]
fn sample_history_is_bounded() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
for step in 0..5_000 {
av.add_sample((step as f32 / 5_000.0) - 0.5);
}
assert_eq!(
av.samples().len(),
SAMPLE_HISTORY_LIMIT,
"a stream must not grow the history without bound"
);
let newest = (4_999.0 / 5_000.0) - 0.5;
assert!(
(av.samples()[SAMPLE_HISTORY_LIMIT - 1] - newest).abs() < 1e-6,
"the newest sample survives the cap"
);
let oldest = ((5_000 - SAMPLE_HISTORY_LIMIT) as f32 / 5_000.0) - 0.5;
assert!((av.samples()[0] - oldest).abs() < 1e-6, "the oldest samples are the ones dropped");
let bulk: Vec<f32> = (0..4_096).map(|index| index as f32 / 4_096.0).collect();
av.set_samples(bulk);
assert_eq!(av.samples().len(), SAMPLE_HISTORY_LIMIT);
assert!((av.samples()[SAMPLE_HISTORY_LIMIT - 1] - (4_095.0 / 4_096.0)).abs() < 1e-6);
assert!((av.samples()[0] - (3_584.0 / 4_096.0)).abs() < 1e-6);
}
#[test]
fn a_host_supplied_spectrum_takes_precedence() {
const BARS: usize = 64;
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_bar_count(BARS);
av.set_mode(VisualizerMode::Spectrum);
let samples: Vec<f32> = (0..SPECTRUM_WINDOW)
.map(|n| (2.0 * std::f32::consts::PI * 1_000.0 * n as f32 / 48_000.0).sin())
.collect();
av.set_samples(samples);
let peak =
|av: &AudioVisualizer| {
(0..BARS).fold(0usize, |best, bar| {
if av.bar_value(bar) > av.bar_value(best) {
bar
} else {
best
}
})
};
let derived_peak = peak(&av);
assert!(
derived_peak < BARS / 2,
"the widget's own FFT peaks low for a 1 kHz tone: {derived_peak}"
);
let mut bins = vec![0.0f32; SPECTRUM_BINS];
bins[64] = 1.0;
av.set_spectrum(bins);
let host_peak = peak(&av);
assert!(
host_peak > BARS / 2,
"the host's high bin must move the peak up, got bar {host_peak}"
);
assert_eq!(av.bar_value(host_peak), 1.0, "a full-scale host bin is drawn full scale");
let sounding = (0..BARS).filter(|&bar| av.bar_value(bar) > 0.0).count();
assert!(
(1..=2).contains(&sounding),
"only the band(s) around the supplied bin may sound, got {sounding} bars"
);
let first: Vec<f32> = (0..BARS).map(|bar| av.bar_value(bar)).collect();
let second: Vec<f32> = (0..BARS).map(|bar| av.bar_value(bar)).collect();
assert_eq!(first, second, "the host spectrum path must be deterministic");
av.set_spectrum(Vec::new());
assert_eq!(
peak(&av),
derived_peak,
"an empty spectrum must return the widget to its own FFT"
);
}
#[test]
fn smoothing_approaches_the_signal_instead_of_jumping() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_bar_count(4);
av.set_samples(vec![1.0, 1.0, 1.0, 1.0]);
let raw = av.bar_value(0);
av.set_smoothing(100.0, 100.0);
assert!(av.is_smoothing_enabled());
assert_eq!(av.display_level(0), 0.0, "a fresh smoother starts at rest");
av.tick(16);
let first = av.display_level(0);
assert!(first > 0.0 && first < raw, "one tick is a step toward the signal: {first}");
av.tick(1000);
assert!((av.display_level(0) - raw).abs() < 1e-3);
}
#[test]
fn the_meter_publishes_throttled_level_and_peak_reports() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
let levels = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let peaks = std::sync::Arc::new(std::sync::Mutex::new(0.0f32));
let modes = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let l = levels.clone();
av.level_changed.connect(move |_value: std::sync::Arc<f32>| {
l.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
});
let p = peaks.clone();
av.peak_exceeded.connect(move |value: std::sync::Arc<f32>| {
*p.lock().unwrap() = *value;
});
let m = modes.clone();
av.mode_changed.connect(move |value: std::sync::Arc<String>| {
*m.lock().unwrap() = (*value).clone();
});
av.set_peak_threshold(0.5);
av.set_level_report_interval(50);
av.set_samples(vec![1.0, -1.0, 0.5, 0.5]);
assert!(!av.tick(16), "a report is not due yet");
assert_eq!(levels.load(std::sync::atomic::Ordering::SeqCst), 0);
assert!(av.tick(50));
assert_eq!(levels.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(*peaks.lock().unwrap() > 0.99);
av.set_mode(VisualizerMode::Spectrum);
assert_eq!(*modes.lock().unwrap(), "spectrum");
for name in ["level_changed", "peak_exceeded", "mode_changed"] {
assert!(av.event_signal_dyn(name).is_some(), "{name} must resolve");
}
}
#[test]
fn bulk_samples_keep_the_ring_ordered_and_bounded() {
let mut av = AudioVisualizer::new(Rect::new(0, 0, 300, 150));
av.set_samples_bulk(&[0.1, 0.2, 0.3]);
assert_eq!(av.samples(), vec![0.1, 0.2, 0.3]);
let block: Vec<f32> =
(0..SAMPLE_HISTORY_LIMIT + 100).map(|i| (i % 7) as f32 / 7.0).collect();
av.set_samples_bulk(&block);
assert_eq!(av.samples().len(), SAMPLE_HISTORY_LIMIT);
assert_eq!(av.samples()[SAMPLE_HISTORY_LIMIT - 1], block[block.len() - 1]);
}
}