diff --git a/README.md b/README.md index 6e1ccfc..c5fc6c4 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,14 @@ trails = 0 minimal = true ``` +`default` and `nice-vocals` are included in the binary. `default` is the current +sane command-line defaults. `nice-vocals` is a focused 25-1200 Hz Mel/RMS bars +preset with adaptive gain and vocal-oriented shelf filtering: + +```sh +vizzzz --profile nice-vocals +``` + The default profile file is selected in this order: 1. `$VIZZZZ_PROFILES` @@ -167,6 +175,10 @@ Underscores in profile keys are accepted as aliases for hyphens. Boolean flags such as `minimal` and `peaks` accept `true`, `false`, `yes`, `no`, `on`, `off`, `1`, or `0`. See `profiles.ini.example` for complete presets. +Press `r` while either the terminal or desktop frontend is running to cycle +through included and user-defined profiles. Analyzer state is rebuilt when the +profile changes; capture source, selected device, and frontend stay unchanged. + ## Response shaping The defaults combine techniques used by motion-graphics and metering tools: @@ -333,6 +345,7 @@ cargo run --release -- \ | --- | --- | | `q`, `Esc` | Quit | | `+`, `-` | Adjust gain by 1 dB | +| `r` | Cycle profile | | `d` | Cycle display style | | `x` | Cycle spectrum path | | `s` | Cycle Side A, Side B, and both | diff --git a/benches/spectrum.rs b/benches/spectrum.rs index 0efde7d..63f50a4 100644 --- a/benches/spectrum.rs +++ b/benches/spectrum.rs @@ -251,6 +251,7 @@ fn render_modes(criterion: &mut Criterion) { secondary_peaks: None, secondary_glow_levels: None, source: "Benchmark", + profile: "default", sample_rate: 48_000, channel_mode: ChannelMode::Mix, gain: 6.0, diff --git a/src/cli.rs b/src/cli.rs index 70b5a50..2c57aab 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -304,7 +304,7 @@ impl Theme { version, about = "A responsive terminal audio spectrum visualizer", args_override_self = true, - after_help = "Keys: q/esc quit | +/- gain | d display | b bar style | x path | s side | t theme | p peaks | space freeze | f spectral hold | ? help" + after_help = "Keys: q/esc quit | +/- gain | r profile | d display | b bar style | x path | s side | t theme | p peaks | space freeze | f spectral hold | ? help" )] pub struct Cli { /// Rendering frontend. Window uses Metal on macOS and Vulkan/OpenGL under Wayland. diff --git a/src/main.rs b/src/main.rs index b3f7a2f..197d5b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,8 +98,11 @@ fn main() -> Result<()> { return Ok(()); } if cli.list_profiles { - let profiles = profiles.context("profile file was not loaded")?; - println!("Profiles in {}:", profiles.path.display()); + if let Some(path) = &profiles.path { + println!("Included profiles and profiles in {}:", path.display()); + } else { + println!("Included profiles:"); + } for name in profiles.names() { println!(" {name}"); } @@ -114,7 +117,7 @@ fn main() -> Result<()> { if let Some(seconds) = cli.check { return check_capture(capture, seconds); } - run_ui(cli, capture) + run_ui(cli, profiles, capture) } fn check_capture(mut capture: audio::AudioReceiver, seconds: u64) -> Result<()> { @@ -154,51 +157,12 @@ fn check_capture(mut capture: audio::AudioReceiver, seconds: u64) -> Result<()> Ok(()) } -fn run_ui(cli: Cli, mut capture: audio::AudioReceiver) -> Result<()> { - let spectrum_config = SpectrumConfig { - fft_size: cli.fft_size, - min_freq: cli.min_freq, - max_freq: cli.max_freq, - frequency_scale: cli.frequency_scale, - detector: cli.detector, - analysis: cli.analysis, - db_floor: cli.db_floor, - db_ceiling: cli.db_ceiling, - attack_ms: cli.attack, - release_ms: cli.release, - spatial_smoothing: cli.spatial_smoothing, - contrast: cli.contrast, - spectral_contrast: cli.spectral_contrast, - audio_duration_ms: cli.audio_duration, - audio_offset_ms: cli.audio_offset, - whitening: cli.whitening, - peak_isolation: cli.peak_isolation, - spectral_flux: cli.spectral_flux, - adaptive_gain: cli.adaptive_gain, - adaptive_window_ms: cli.adaptive_window, - adaptive_max_boost_db: cli.adaptive_max_boost, - adaptive_max_cut_db: cli.adaptive_max_cut, - root_harmonics: cli.root_harmonics, - root_isolation: cli.root_isolation, - fundamental_core: cli.fundamental_core, - harmonic_halo: cli.harmonic_halo, - freeze_mix: cli.freeze_mix, - morph_speed: cli.morph_speed, - harmonics: cli.harmonics, - harmonic_boost: cli.harmonic_boost, - tilt: cli.tilt, - low_shelf_hz: cli.low_shelf, - low_shelf_attenuation_db: cli.low_shelf_attenuation, - high_shelf_hz: cli.high_shelf, - high_shelf_attenuation_db: cli.high_shelf_attenuation, - shelf_width_octaves: cli.shelf_width, - compressor_threshold_db: cli.compressor_threshold, - compressor_ratio: cli.compressor_ratio, - post_filter_gain_db: cli.post_filter_gain, - peak_hold_ms: cli.peak_hold, - peak_decay_db: cli.peak_decay, - glow_release_ms: cli.glow_release, - }; +fn run_ui( + cli: Cli, + profiles: profile::ProfileFile, + mut capture: audio::AudioReceiver, +) -> Result<()> { + let spectrum_config = SpectrumConfig::from(&cli); let mut spectrum = Spectrum::new(spectrum_config.clone(), capture.sample_rate); let mut secondary = (cli.channels != ChannelMode::Mix) .then(|| Spectrum::new(spectrum_config, capture.sample_rate)); @@ -207,26 +171,28 @@ fn run_ui(cli: Cli, mut capture: audio::AudioReceiver) -> Result<()> { let mut terminal = ratatui::init(); let result = ui_loop( &mut terminal, - &cli, + cli, + profiles, &mut capture, &mut spectrum, - secondary.as_mut(), + &mut secondary, ); ratatui::restore(); result } - Frontend::Window => window::run(cli, capture, spectrum, secondary), + Frontend::Window => window::run(cli, profiles, capture, spectrum, secondary), } } fn ui_loop( terminal: &mut ratatui::DefaultTerminal, - cli: &Cli, + mut cli: Cli, + mut profiles: profile::ProfileFile, capture: &mut audio::AudioReceiver, spectrum: &mut Spectrum, - mut secondary: Option<&mut Spectrum>, + secondary: &mut Option, ) -> Result<()> { - let frame_time = Duration::from_secs_f64(1.0 / f64::from(cli.fps)); + let mut frame_time = Duration::from_secs_f64(1.0 / f64::from(cli.fps)); let mut last_update = Instant::now(); let mut gain = cli.gain; let mut theme = cli.theme; @@ -344,6 +310,7 @@ fn ui_loop( secondary_peaks: secondary_frame.as_ref().map(|frame| frame.peaks), secondary_glow_levels: secondary_frame.as_ref().map(|frame| frame.glow_levels), source: &capture.label, + profile: profiles.active(), sample_rate: capture.sample_rate, channel_mode: cli.channels, gain, @@ -411,6 +378,25 @@ fn ui_loop( KeyCode::Char('x') => path = path.next(), KeyCode::Char('s') => side = side.next(), KeyCode::Char('p') => show_peaks = !show_peaks, + KeyCode::Char('r') => { + cli = profiles.next_cli()?; + frame_time = Duration::from_secs_f64(1.0 / f64::from(cli.fps)); + gain = cli.gain; + theme = cli.theme; + display = cli.display; + bar_style = cli.bar_style; + path = cli.path; + side = cli.side; + show_peaks = cli.peaks; + frozen = false; + spectral_frozen = false; + let config = SpectrumConfig::from(&cli); + *spectrum = Spectrum::new(config.clone(), capture.sample_rate); + *secondary = (cli.channels != ChannelMode::Mix) + .then(|| Spectrum::new(config, capture.sample_rate)); + trails.fill(0.0); + secondary_trails.fill(0.0); + } KeyCode::Char(' ') => frozen = !frozen, KeyCode::Char('f') => { spectral_frozen = !spectral_frozen; diff --git a/src/profile.rs b/src/profile.rs index 0e54022..610d599 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -7,6 +7,9 @@ use std::{ }; use anyhow::{Context, Result, bail}; +use clap::Parser; + +use crate::cli::Cli; const PROFILE_FLAGS: &[&str] = &["minimal", "peaks"]; const RESERVED_KEYS: &[&str] = &[ @@ -19,9 +22,50 @@ const RESERVED_KEYS: &[&str] = &[ "version", ]; +const INCLUDED_PROFILES: &str = r#" +[default] + +[nice-vocals] +display = bars +bands = 0 +bar-width = 2 +gap = 1 +fft-size = 4096 +fps = 60 +min-freq = 25 +max-freq = 1200 +frequency-scale = mel +detector = rms +attack = 18 +release = 115 +spatial-smoothing = 0.1 +whitening = 0.1 +peak-isolation = 0 +spectral-contrast = 0.3 +spectral-flux = 0 +adaptive-gain = 0.8 +adaptive-window = 4000 +low-shelf = 150 +low-shelf-attenuation = 15 +high-shelf = 2000 +high-shelf-attenuation = 0 +shelf-width = 3 +db-floor = -38 +db-ceiling = 0 +gain = 0 +contrast = 2 +post-filter-gain = 8 +trails = 0 +beat-reactivity = 1 +max-height = 1 +theme = mono-dark +"#; + pub struct ProfileFile { - pub path: PathBuf, + pub path: Option, sections: BTreeMap>, + base_args: Vec, + active: String, } impl ProfileFile { @@ -29,6 +73,25 @@ impl ProfileFile { self.sections.keys().map(String::as_str) } + pub fn active(&self) -> &str { + &self.active + } + + pub fn next_cli(&mut self) -> Result { + let names = self.names().map(str::to_owned).collect::>(); + let index = names + .iter() + .position(|name| name == &self.active) + .unwrap_or(0); + let next = &names[(index + 1) % names.len()]; + let mut args = self.base_args.clone(); + args.splice(1..1, self.arguments(next)?); + let cli = Cli::try_parse_from(args).map_err(anyhow::Error::msg)?; + cli.validate()?; + self.active.clone_from(next); + Ok(cli) + } + fn arguments(&self, name: &str) -> Result> { let Some(entries) = self.sections.get(name) else { let available = self.names().collect::>().join(", "); @@ -62,29 +125,66 @@ impl ProfileFile { pub fn prepare_args( args: impl IntoIterator, -) -> Result<(Vec, Option)> { +) -> Result<(Vec, ProfileFile)> { let args: Vec = args.into_iter().collect(); let request = scan_request(&args)?; - if request.name.is_none() && !request.list { - return Ok((args, None)); - } - + let explicit_path = request.path.is_some(); let path = request.path.unwrap_or_else(default_profile_path); - let contents = fs::read_to_string(&path) - .with_context(|| format!("could not read profile file {}", path.display()))?; - let profiles = parse_profiles(&contents) - .with_context(|| format!("could not parse profile file {}", path.display()))?; + let mut sections = parse_profiles(INCLUDED_PROFILES).expect("included profiles are valid"); + let loaded_path = match fs::read_to_string(&path) { + Ok(contents) => { + let configured = parse_profiles(&contents) + .with_context(|| format!("could not parse profile file {}", path.display()))?; + for (name, entries) in configured { + if sections.insert(name.clone(), entries).is_some() { + bail!("profile file cannot replace included profile '{name}'"); + } + } + Some(path) + } + Err(error) if !explicit_path && error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(error) + .with_context(|| format!("could not read profile file {}", path.display())); + } + }; + let active = request.name.unwrap_or_else(|| "default".to_owned()); + let base_args = strip_profile_args(&args); let profiles = ProfileFile { - path, - sections: profiles, + path: loaded_path, + sections, + base_args, + active: active.clone(), }; let mut expanded = args; - if let Some(name) = request.name { - let profile_args = profiles.arguments(&name)?; + if active != "default" { + let profile_args = profiles.arguments(&active)?; expanded.splice(1..1, profile_args); } - Ok((expanded, Some(profiles))) + Ok((expanded, profiles)) +} + +fn strip_profile_args(args: &[OsString]) -> Vec { + let mut stripped = Vec::with_capacity(args.len()); + let mut index = 0; + while index < args.len() { + let text = args[index].to_string_lossy(); + if text == "--profile" || text == "--profile-file" { + index += 2; + continue; + } + if text == "--list-profiles" + || text.starts_with("--profile=") + || text.starts_with("--profile-file=") + { + index += 1; + continue; + } + stripped.push(args[index].clone()); + index += 1; + } + stripped } #[derive(Default)] @@ -240,8 +340,10 @@ mod tests { ) .unwrap(); let profiles = ProfileFile { - path: "profiles.ini".into(), + path: Some("profiles.ini".into()), sections, + base_args: vec!["vizzzz".into()], + active: "roots".into(), }; assert_eq!(profiles.names().collect::>(), ["roots"]); @@ -284,8 +386,10 @@ mod tests { fn command_line_options_override_profile_arguments() { let sections = parse_profiles("[test]\nfps=120\ntheme=ember\n").unwrap(); let profiles = ProfileFile { - path: "profiles.ini".into(), + path: Some("profiles.ini".into()), sections, + base_args: vec!["vizzzz".into()], + active: "test".into(), }; let mut args = vec![OsString::from("vizzzz")]; args.extend(profiles.arguments("test").unwrap()); @@ -295,4 +399,40 @@ mod tests { assert_eq!(cli.fps, 60); assert_eq!(cli.theme, crate::cli::Theme::Ember); } + + #[test] + fn included_nice_vocals_profile_matches_the_preset() { + let sections = parse_profiles(INCLUDED_PROFILES).unwrap(); + let mut profiles = ProfileFile { + path: None, + sections, + base_args: vec!["vizzzz".into()], + active: "default".into(), + }; + + let cli = profiles.next_cli().unwrap(); + assert_eq!(profiles.active(), "nice-vocals"); + assert_eq!(cli.display, crate::cli::Display::Bars); + assert_eq!(cli.bands, 0); + assert_eq!(cli.frequency_scale, crate::cli::FrequencyScale::Mel); + assert_eq!(cli.detector, crate::cli::Detector::Rms); + assert_eq!(cli.theme, crate::cli::Theme::MonoDark); + assert_eq!(cli.max_freq, 1200.0); + assert_eq!(cli.post_filter_gain, 8.0); + } + + #[test] + fn cycling_preserves_explicit_command_line_overrides() { + let sections = parse_profiles(INCLUDED_PROFILES).unwrap(); + let mut profiles = ProfileFile { + path: None, + sections, + base_args: ["vizzzz", "--theme", "gold"].map(OsString::from).to_vec(), + active: "default".into(), + }; + + let cli = profiles.next_cli().unwrap(); + assert_eq!(cli.theme, crate::cli::Theme::Gold); + assert_eq!(cli.display, crate::cli::Display::Bars); + } } diff --git a/src/spectrum.rs b/src/spectrum.rs index 5092820..feee5c3 100644 --- a/src/spectrum.rs +++ b/src/spectrum.rs @@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration}; use rustfft::{Fft, FftPlanner, num_complex::Complex32}; -use crate::cli::{Analysis, Detector, FrequencyScale}; +use crate::cli::{Analysis, Cli, Detector, FrequencyScale}; #[derive(Clone)] pub struct SpectrumConfig { @@ -50,6 +50,55 @@ pub struct SpectrumConfig { pub glow_release_ms: f32, } +impl From<&Cli> for SpectrumConfig { + fn from(cli: &Cli) -> Self { + Self { + fft_size: cli.fft_size, + min_freq: cli.min_freq, + max_freq: cli.max_freq, + frequency_scale: cli.frequency_scale, + detector: cli.detector, + analysis: cli.analysis, + db_floor: cli.db_floor, + db_ceiling: cli.db_ceiling, + attack_ms: cli.attack, + release_ms: cli.release, + spatial_smoothing: cli.spatial_smoothing, + contrast: cli.contrast, + spectral_contrast: cli.spectral_contrast, + audio_duration_ms: cli.audio_duration, + audio_offset_ms: cli.audio_offset, + whitening: cli.whitening, + peak_isolation: cli.peak_isolation, + spectral_flux: cli.spectral_flux, + adaptive_gain: cli.adaptive_gain, + adaptive_window_ms: cli.adaptive_window, + adaptive_max_boost_db: cli.adaptive_max_boost, + adaptive_max_cut_db: cli.adaptive_max_cut, + root_harmonics: cli.root_harmonics, + root_isolation: cli.root_isolation, + fundamental_core: cli.fundamental_core, + harmonic_halo: cli.harmonic_halo, + freeze_mix: cli.freeze_mix, + morph_speed: cli.morph_speed, + harmonics: cli.harmonics, + harmonic_boost: cli.harmonic_boost, + tilt: cli.tilt, + low_shelf_hz: cli.low_shelf, + low_shelf_attenuation_db: cli.low_shelf_attenuation, + high_shelf_hz: cli.high_shelf, + high_shelf_attenuation_db: cli.high_shelf_attenuation, + shelf_width_octaves: cli.shelf_width, + compressor_threshold_db: cli.compressor_threshold, + compressor_ratio: cli.compressor_ratio, + post_filter_gain_db: cli.post_filter_gain, + peak_hold_ms: cli.peak_hold, + peak_decay_db: cli.peak_decay, + glow_release_ms: cli.glow_release, + } + } +} + pub struct Spectrum { config: SpectrumConfig, sample_rate: f32, diff --git a/src/ui.rs b/src/ui.rs index c66537e..2b64885 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -30,6 +30,7 @@ pub struct View<'a> { pub secondary_peaks: Option<&'a [f32]>, pub secondary_glow_levels: Option<&'a [f32]>, pub source: &'a str, + pub profile: &'a str, pub sample_rate: u32, pub channel_mode: ChannelMode, pub gain: f32, @@ -150,7 +151,8 @@ fn draw_header(frame: &mut Frame, area: Rect, view: &View<'_>) { Span::styled(view.source, Style::default().fg(Color::Rgb(205, 215, 232))), Span::styled( format!( - " {} Hz {:+.1} dB {} / {} / {} / {} / {}{}", + " [{}] {} Hz {:+.1} dB {} / {} / {} / {} / {}{}", + view.profile, view.sample_rate, view.gain, view.display.label(), @@ -1290,7 +1292,7 @@ fn draw_footer(frame: &mut Frame, area: Rect, view: &View<'_>) { fn draw_help(frame: &mut Frame, area: Rect, theme: Theme) { let width = area.width.min(52); - let height = area.height.min(15); + let height = area.height.min(16); let popup = Rect::new( area.x + area.width.saturating_sub(width) / 2, area.y + area.height.saturating_sub(height) / 2, @@ -1302,6 +1304,7 @@ fn draw_help(frame: &mut Frame, area: Rect, theme: Theme) { Line::from(""), Line::from(" q / esc quit"), Line::from(" + / - gain by 1 dB"), + Line::from(" r cycle profile"), Line::from(" d cycle display style"), Line::from(" b cycle bar glyph style"), Line::from(" x cycle spectrum path"), @@ -1554,6 +1557,7 @@ mod tests { secondary_peaks: None, secondary_glow_levels: None, source: "Test Source", + profile: "default", sample_rate: 48_000, channel_mode: ChannelMode::Mix, gain: 6.0, diff --git a/src/window.rs b/src/window.rs index bbbbdad..4ff0606 100644 --- a/src/window.rs +++ b/src/window.rs @@ -18,7 +18,8 @@ use winit::{ use crate::{ audio::{AudioReceiver, AudioSample}, cli::{BarStyle, ChannelMode, Cli, Display, Path, RgbColor, Side, Theme}, - spectrum::{SpectralDescriptors, Spectrum}, + profile::ProfileFile, + spectrum::{SpectralDescriptors, Spectrum, SpectrumConfig}, }; const MAX_BANDS: usize = 256; @@ -27,12 +28,13 @@ const SCOPE_SAMPLES: usize = 512; pub fn run( cli: Cli, + profiles: ProfileFile, capture: AudioReceiver, spectrum: Spectrum, secondary: Option, ) -> Result<()> { let event_loop = EventLoop::new().context("could not create the desktop event loop")?; - let mut app = WindowApp::new(cli, capture, spectrum, secondary); + let mut app = WindowApp::new(cli, profiles, capture, spectrum, secondary); event_loop .run_app(&mut app) .context("desktop event loop failed")?; @@ -44,6 +46,7 @@ pub fn run( struct WindowApp { cli: Cli, + profiles: ProfileFile, capture: AudioReceiver, spectrum: Spectrum, secondary: Option, @@ -74,6 +77,7 @@ struct WindowApp { impl WindowApp { fn new( cli: Cli, + profiles: ProfileFile, capture: AudioReceiver, spectrum: Spectrum, secondary: Option, @@ -87,6 +91,7 @@ impl WindowApp { path: cli.path, side: cli.side, show_peaks: cli.peaks, + profiles, cli, capture, spectrum, @@ -257,6 +262,39 @@ impl WindowApp { Key::Character(key) if key.eq_ignore_ascii_case("p") => { self.show_peaks = !self.show_peaks; } + Key::Character(key) if key.eq_ignore_ascii_case("r") => { + let cli = match self.profiles.next_cli() { + Ok(cli) => cli, + Err(error) => { + self.fail(event_loop, error); + return; + } + }; + self.frame_time = Duration::from_secs_f64(1.0 / f64::from(cli.fps)); + self.gain = cli.gain; + self.theme = cli.theme; + self.display = cli.display; + self.bar_style = cli.bar_style; + self.path = cli.path; + self.side = cli.side; + self.show_peaks = cli.peaks; + self.frozen = false; + self.spectral_frozen = false; + let config = SpectrumConfig::from(&cli); + self.spectrum = Spectrum::new(config.clone(), self.capture.sample_rate); + self.secondary = (cli.channels != ChannelMode::Mix) + .then(|| Spectrum::new(config, self.capture.sample_rate)); + self.trails.fill(0.0); + self.secondary_trails.fill(0.0); + self.cli = cli; + if let Some(window) = &self.window { + window.set_title(&format!( + "vizzzz - {} [{}]", + self.capture.label, + self.profiles.active() + )); + } + } Key::Character(key) if key.eq_ignore_ascii_case("f") => { self.spectral_frozen = !self.spectral_frozen; self.spectrum.toggle_freeze(); @@ -275,7 +313,11 @@ impl ApplicationHandler for WindowApp { return; } let attributes = Window::default_attributes() - .with_title(format!("vizzzz - {}", self.capture.label)) + .with_title(format!( + "vizzzz - {} [{}]", + self.capture.label, + self.profiles.active() + )) .with_inner_size(LogicalSize::new(1280.0, 720.0)) .with_min_inner_size(LogicalSize::new(480.0, 270.0)); let window = match event_loop.create_window(attributes) {