use clap::{Parser, ValueEnum}; use clap_complete::Shell; use std::{path::PathBuf, str::FromStr}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RgbColor { pub red: u8, pub green: u8, pub blue: u8, } impl FromStr for RgbColor { type Err = String; fn from_str(value: &str) -> Result { let value = value.strip_prefix('#').unwrap_or(value); if value.len() != 6 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("expected a six-digit RGB hex color such as FF2040".into()); } Ok(Self { red: u8::from_str_radix(&value[0..2], 16).map_err(|error| error.to_string())?, green: u8::from_str_radix(&value[2..4], 16).map_err(|error| error.to_string())?, blue: u8::from_str_radix(&value[4..6], 16).map_err(|error| error.to_string())?, }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Source { /// Main system output on macOS and Windows; default input elsewhere. Auto, /// Main system output via ScreenCaptureKit or WASAPI loopback. Output, /// A microphone, interface, monitor, or loopback input. Input, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Frontend { /// Render in the current terminal with Ratatui. Terminal, /// Render in a native GPU-accelerated desktop window. Window, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Theme { Aurora, Sunset, Ice, Crimson, Vaporwave, Toxic, Gold, Midnight, Ocean, Ember, Orchid, Forest, Sakura, Mono, MonoDark, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum BarStyle { /// Smooth eighth-cell columns. Solid, /// Horizontal meter segments. Segments, /// Round points stacked vertically. Dots, /// Thin vertical needles. Lines, /// Separated rows like a hardware LED meter. Led, /// Hollow bars with sides and a top cap. Outline, /// Bright moving tips with fading tails. Comet, /// Show only the current tip of each bar. PeakOnly, } impl BarStyle { pub fn next(self) -> Self { match self { Self::Solid => Self::Segments, Self::Segments => Self::Dots, Self::Dots => Self::Lines, Self::Lines => Self::Led, Self::Led => Self::Outline, Self::Outline => Self::Comet, Self::Comet => Self::PeakOnly, Self::PeakOnly => Self::Solid, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Display { /// Dense, continuous strokes like AE's Analog Lines display. AnalogLines, /// Points distributed along each frequency stroke. AnalogDots, /// Segmented rectangular frequency strokes. Digital, /// The original bottom-up terminal meter. Bars, /// Frequency history flowing through time. Waterfall, /// Smooth 2x4 subcell strokes, especially useful for curved paths. Braille, } impl Display { pub fn next(self) -> Self { match self { Self::AnalogLines => Self::AnalogDots, Self::AnalogDots => Self::Digital, Self::Digital => Self::Bars, Self::Bars => Self::Waterfall, Self::Waterfall => Self::Braille, Self::Braille => Self::AnalogLines, } } pub fn uses_bar_spacing(self) -> bool { matches!(self, Self::Digital | Self::Bars) } pub fn label(self) -> &'static str { match self { Self::AnalogLines => "ANALOG LINES", Self::AnalogDots => "ANALOG DOTS", Self::Digital => "DIGITAL", Self::Bars => "BARS", Self::Waterfall => "WATERFALL", Self::Braille => "BRAILLE", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Path { Line, Circle, Arc, Sine, Diamond, Pulse, } impl Path { pub fn next(self) -> Self { match self { Self::Line => Self::Circle, Self::Circle => Self::Arc, Self::Arc => Self::Sine, Self::Sine => Self::Diamond, Self::Diamond => Self::Pulse, Self::Pulse => Self::Line, } } pub fn label(self) -> &'static str { match self { Self::Line => "LINE", Self::Circle => "CIRCLE", Self::Arc => "ARC", Self::Sine => "SINE", Self::Diamond => "DIAMOND", Self::Pulse => "PULSE", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Side { /// Draw above and below the spectrum path. Both, /// Draw above the spectrum path. A, /// Draw below the spectrum path. B, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum FrequencyScale { /// Equal Hz per band, matching the characteristic AE distribution. Linear, /// Equal musical ratio per band for a conventional analyzer. Log, /// Musical constant-Q-style spacing backed by the shared FFT. Cqt, /// Psychoacoustic Mel spacing. Mel, /// Psychoacoustic Bark spacing. Bark, } impl FrequencyScale { pub fn label(self) -> &'static str { match self { Self::Linear => "linear frequency", Self::Log => "logarithmic frequency", Self::Cqt => "constant-Q frequency", Self::Mel => "Mel frequency", Self::Bark => "Bark frequency", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Analysis { /// Normal frequency spectrum. Spectrum, /// Harmonic root-candidate strengths arranged like a spectrum. Fundamentals, /// Twelve musical pitch classes, C through B. Chroma, } impl Analysis { pub fn label(self) -> &'static str { match self { Self::Spectrum => "SPECTRUM", Self::Fundamentals => "ROOTS", Self::Chroma => "CHROMA", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum ChannelMode { /// Average left and right into one spectrum. Mix, /// Left on Side A and right on Side B. Stereo, /// Mid on Side A and stereo difference on Side B. MidSide, } impl ChannelMode { pub fn label(self) -> &'static str { match self { Self::Mix => "MIX", Self::Stereo => "L/R", Self::MidSide => "M/S", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Detector { /// Keep the strongest FFT component in each band. Peak, /// Average the energy contained in each band. Rms, } impl Side { pub fn next(self) -> Self { match self { Self::Both => Self::A, Self::A => Self::B, Self::B => Self::Both, } } pub fn label(self) -> &'static str { match self { Self::Both => "A+B", Self::A => "A", Self::B => "B", } } } impl Theme { pub fn next(self) -> Self { match self { Self::Aurora => Self::Sunset, Self::Sunset => Self::Ice, Self::Ice => Self::Crimson, Self::Crimson => Self::Vaporwave, Self::Vaporwave => Self::Toxic, Self::Toxic => Self::Gold, Self::Gold => Self::Midnight, Self::Midnight => Self::Ocean, Self::Ocean => Self::Ember, Self::Ember => Self::Orchid, Self::Orchid => Self::Forest, Self::Forest => Self::Sakura, Self::Sakura => Self::Mono, Self::Mono => Self::MonoDark, Self::MonoDark => Self::Aurora, } } } #[derive(Debug, Parser)] #[command( version, about = "A responsive terminal audio spectrum visualizer", args_override_self = true, 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. #[arg(long, value_enum, default_value_t = Frontend::Window)] pub frontend: Frontend, /// Capture source. Auto uses system output on macOS and Windows. #[arg(long, value_enum, default_value_t = Source::Auto)] pub source: Source, /// Input device, or Windows loopback output, by ID or description substring. #[arg(short, long, value_name = "DEVICE")] pub device: Option, /// List available capture devices and exit. #[arg(long)] pub list_devices: bool, /// Capture for N seconds without opening the UI, then report activity. #[arg(long, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=60))] pub check: Option, /// Print a shell completion script and exit. #[arg(long, value_enum, value_name = "SHELL")] pub generate_completion: Option, /// Load options from a named INI profile before applying command-line options. #[arg(long, value_name = "NAME")] pub profile: Option, /// INI profile file; defaults to the configured user profile path. #[arg(long, value_name = "PATH")] pub profile_file: Option, /// List profile names and exit. #[arg(long)] pub list_profiles: bool, /// Number of frequency bands; 0 fits bands to terminal width. #[arg(short, long, default_value_t = 64, value_parser = parse_bands)] pub bands: usize, /// FFT window size. Larger values improve bass resolution but add latency. #[arg(long, default_value_t = 4096, value_parser = parse_fft_size)] pub fft_size: usize, /// Lowest displayed frequency in Hz. #[arg(long, default_value_t = 20.0, value_parser = clap::value_parser!(f32))] pub min_freq: f32, /// Highest displayed frequency in Hz. #[arg(long, default_value_t = 2_000.0, value_parser = clap::value_parser!(f32))] pub max_freq: f32, /// Frequency spacing along the spectrum path. #[arg(long, value_enum, default_value_t = FrequencyScale::Linear)] pub frequency_scale: FrequencyScale, /// How FFT components are reduced into each displayed band. #[arg(long, value_enum, default_value_t = Detector::Peak)] pub detector: Detector, /// Spectral representation. #[arg(long, value_enum, default_value_t = Analysis::Spectrum)] pub analysis: Analysis, /// Channel mapping used by Side A and Side B. #[arg(long, value_enum, default_value_t = ChannelMode::Mix)] pub channels: ChannelMode, /// Input gain in dB. #[arg(short, long, default_value_t = 6.0, allow_hyphen_values = true)] pub gain: f32, /// Spectrum floor in dBFS. #[arg(long, default_value_t = -72.0, allow_hyphen_values = true)] pub db_floor: f32, /// Spectrum ceiling in dBFS. #[arg(long, default_value_t = -3.0, allow_hyphen_values = true)] pub db_ceiling: f32, /// Rise response time in milliseconds. #[arg(long, default_value_t = 30.0, value_parser = clap::value_parser!(f32))] pub attack: f32, /// Fall response time in milliseconds. #[arg(long, default_value_t = 180.0, value_parser = clap::value_parser!(f32))] pub release: f32, /// Neighbor-band smoothing amount from 0 to 1. #[arg(long, default_value_t = 0.08, value_parser = parse_unit)] pub spatial_smoothing: f32, /// Height contrast. Values above 1 suppress weak frequencies. #[arg(long, default_value_t = 1.65, value_parser = parse_contrast)] pub contrast: f32, /// Emphasize local spectral peaks and valleys, from 0 to 1. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub spectral_contrast: f32, /// Integrate spectral energy over this many milliseconds. #[arg(long, default_value_t = 0.0, value_parser = parse_milliseconds)] pub audio_duration: f32, /// Analyze audio this many milliseconds behind realtime. #[arg(long, default_value_t = 0.0, value_parser = parse_milliseconds)] pub audio_offset: f32, /// Local spectral whitening from 0 to 1. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub whitening: f32, /// Suppress bands that are not local peaks, from 0 to 1. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub peak_isolation: f32, /// Blend toward positive spectral change rather than level, from 0 to 1. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub spectral_flux: f32, /// Normalize every band against its slowly moving average, from 0 to 1. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub adaptive_gain: f32, /// Adaptive-gain averaging window in milliseconds. #[arg(long, default_value_t = 4_000.0, value_parser = parse_adaptive_window)] pub adaptive_window: f32, /// Maximum adaptive boost in dB. #[arg(long, default_value_t = 12.0, value_parser = parse_attenuation)] pub adaptive_max_boost: f32, /// Maximum adaptive cut in dB. #[arg(long, default_value_t = 18.0, value_parser = parse_attenuation)] pub adaptive_max_cut: f32, /// Highest harmonic used to score root candidates in fundamentals analysis. #[arg(long, default_value_t = 6, value_parser = clap::value_parser!(u8).range(2..=12))] pub root_harmonics: u8, /// Suppress frequencies without harmonic support in fundamentals analysis. #[arg(long, default_value_t = 0.65, value_parser = parse_unit)] pub root_isolation: f32, /// Strength of phase-coherent tonal body rendering. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub tonal_body: f32, /// Strength of transient spark rendering. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub transient_sparks: f32, /// Strength of the detected fundamental-frequency core. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub fundamental_core: f32, /// Strength of the classified overtone halo. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub harmonic_halo: f32, /// Number of harmonics to reinforce from strong fundamentals. #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(u8).range(0..=12))] pub harmonics: u8, /// Strength of synthetic harmonic reinforcement from 0 to 1. #[arg(long, default_value_t = 0.35, value_parser = parse_unit)] pub harmonic_boost: f32, /// High-frequency compensation in dB per octave. #[arg(long, default_value_t = 0.0)] pub tilt: f32, /// Center frequency of a smooth low-shelf cut; 0 disables it. #[arg(long, default_value_t = 0.0, value_parser = parse_nonnegative)] pub low_shelf: f32, /// Attenuation below the low-shelf frequency, in dB. #[arg(long, default_value_t = 0.0, value_parser = parse_attenuation)] pub low_shelf_attenuation: f32, /// Center frequency of a smooth high-shelf cut; 0 disables it. #[arg(long, default_value_t = 0.0, value_parser = parse_nonnegative)] pub high_shelf: f32, /// Attenuation above the high-shelf frequency, in dB. #[arg(long, default_value_t = 0.0, value_parser = parse_attenuation)] pub high_shelf_attenuation: f32, /// Width of both shelf transitions in octaves. #[arg(long, default_value_t = 1.0, value_parser = parse_shelf_width)] pub shelf_width: f32, /// Compress bands above this dBFS level. #[arg(long, default_value_t = -18.0, allow_hyphen_values = true)] pub compressor_threshold: f32, /// Spectral compressor ratio; 1 disables compression. #[arg(long, default_value_t = 1.0, value_parser = parse_compressor_ratio)] pub compressor_ratio: f32, /// Makeup gain applied after spectral shelves and compression, in dB. #[arg(long, default_value_t = 0.0, value_parser = parse_post_filter_gain)] pub post_filter_gain: f32, /// How long peak markers remain fixed in milliseconds. #[arg(long, default_value_t = 420.0, value_parser = clap::value_parser!(f32))] pub peak_hold: f32, /// Peak marker fall rate in dB per second. #[arg(long, default_value_t = 24.0, value_parser = clap::value_parser!(f32))] pub peak_decay: f32, /// Show peak markers. Disabled by default for an AE-like presentation. #[arg(long)] pub peaks: bool, /// Target redraw rate. #[arg(long, default_value_t = 60, value_parser = clap::value_parser!(u16).range(10..=240))] pub fps: u16, /// Spectrum rendering style. #[arg(long, value_enum, default_value_t = Display::AnalogLines)] pub display: Display, /// Glyph treatment used by the bars display. #[arg(long, value_enum, default_value_t = BarStyle::Solid)] pub bar_style: BarStyle, /// Baseline path used by non-bar spectrum modes. #[arg(long, value_enum, default_value_t = Path::Line)] pub path: Path, /// Draw on Side A, Side B, or both sides of the center path. #[arg(long, value_enum, default_value_t = Side::Both)] pub side: Side, /// Maximum spectrum height as a fraction of the available space. #[arg(long, default_value_t = 0.88, value_parser = parse_unit)] pub max_height: f32, /// Simulated edge softness/glow from 0 to 1. #[arg(long, default_value_t = 0.55, value_parser = parse_unit)] pub softness: f32, /// Strength of a slower, wider spectrum layer under the core. #[arg(long, default_value_t = 0.45, value_parser = parse_unit)] pub glow_strength: f32, /// Release time of the slower glow layer in milliseconds. #[arg(long, default_value_t = 480.0, value_parser = parse_milliseconds)] pub glow_release: f32, /// Number of palette interpolations across the spectrum path. #[arg(long, default_value_t = 1.0, value_parser = parse_hue_cycles)] pub hue_cycles: f32, /// Color at the spectrum path, as RRGGBB or #RRGGBB. #[arg(long, value_name = "HEX")] pub inside_color: Option, /// Color at the tip of each spectrum stroke, as RRGGBB or #RRGGBB. #[arg(long, value_name = "HEX")] pub outside_color: Option, /// Number of delayed spectral layers; waterfall history is capped at 32. #[arg(long, default_value_t = 3, value_parser = clap::value_parser!(u8).range(0..=32))] pub trails: u8, /// Scale spectrum motion from bass energy, from 0 to 1. #[arg(long, default_value_t = 0.2, value_parser = parse_unit)] pub beat_reactivity: f32, /// Launch path ripples from detected note and percussion onsets. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub onset_ripples: f32, /// Synchronize geometry to the estimated tempo phase. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub tempo_reactivity: f32, /// Shift hue using spectral centroid/brightness. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub centroid_color: f32, /// Modulate height and glow from spectral shape, coherence, and bass ratio. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub descriptor_motion: f32, /// Draw a stereo phase/Lissajous scope in the center. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub phase_scope: f32, /// Mark the three strongest formant-like spectral regions. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub formants: f32, /// Frozen-spectrum contribution after pressing f. #[arg(long, default_value_t = 0.75, value_parser = parse_unit)] pub freeze_mix: f32, /// How quickly a frozen spectrum morphs toward live audio; 0 holds forever. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub morph_speed: f32, /// Stroke width for digital and bar displays, in terminal cells. #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u16).range(1..=8))] pub bar_width: u16, /// Empty cells between bars. #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u16).range(0..=4))] pub gap: u16, /// Color palette. #[arg(long, value_enum, default_value_t = Theme::Aurora)] pub theme: Theme, /// Hide the title, source details, and frequency labels. #[arg(long)] pub minimal: bool, } impl Cli { pub fn validate(&self) -> anyhow::Result<()> { anyhow::ensure!(self.min_freq >= 1.0, "--min-freq must be at least 1 Hz"); anyhow::ensure!( self.max_freq > self.min_freq, "--max-freq must be greater than --min-freq" ); anyhow::ensure!( self.db_ceiling > self.db_floor, "--db-ceiling must be greater than --db-floor" ); anyhow::ensure!(self.attack >= 0.0, "--attack cannot be negative"); anyhow::ensure!(self.release >= 0.0, "--release cannot be negative"); anyhow::ensure!(self.peak_hold >= 0.0, "--peak-hold cannot be negative"); anyhow::ensure!(self.peak_decay >= 0.0, "--peak-decay cannot be negative"); anyhow::ensure!( self.compressor_threshold <= self.db_ceiling, "--compressor-threshold must not exceed --db-ceiling" ); Ok(()) } } fn parse_unit(value: &str) -> Result { let value = value.parse::().map_err(|error| error.to_string())?; if (0.0..=1.0).contains(&value) { Ok(value) } else { Err("expected a value from 0 to 1".into()) } } fn parse_fft_size(value: &str) -> Result { let size = value.parse::().map_err(|error| error.to_string())?; if (512..=32_768).contains(&size) && size.is_power_of_two() { Ok(size) } else { Err("expected a power of two from 512 to 32768".into()) } } fn parse_bands(value: &str) -> Result { let bands = value.parse::().map_err(|error| error.to_string())?; if bands <= 256 { Ok(bands) } else { Err("expected a value from 0 to 256".into()) } } fn parse_hue_cycles(value: &str) -> Result { let cycles = value.parse::().map_err(|error| error.to_string())?; if (0.0..=8.0).contains(&cycles) { Ok(cycles) } else { Err("expected a value from 0 to 8".into()) } } fn parse_contrast(value: &str) -> Result { let contrast = value.parse::().map_err(|error| error.to_string())?; if (0.1..=4.0).contains(&contrast) { Ok(contrast) } else { Err("expected a value from 0.1 to 4".into()) } } fn parse_milliseconds(value: &str) -> Result { let milliseconds = value.parse::().map_err(|error| error.to_string())?; if (0.0..=2_000.0).contains(&milliseconds) { Ok(milliseconds) } else { Err("expected milliseconds from 0 to 2000".into()) } } fn parse_nonnegative(value: &str) -> Result { let value = value.parse::().map_err(|error| error.to_string())?; if value >= 0.0 { Ok(value) } else { Err("expected a non-negative value".into()) } } fn parse_attenuation(value: &str) -> Result { let value = value.parse::().map_err(|error| error.to_string())?; if (0.0..=60.0).contains(&value) { Ok(value) } else { Err("expected attenuation from 0 to 60 dB".into()) } } fn parse_shelf_width(value: &str) -> Result { let value = value.parse::().map_err(|error| error.to_string())?; if (0.1..=6.0).contains(&value) { Ok(value) } else { Err("expected a transition width from 0.1 to 6 octaves".into()) } } fn parse_compressor_ratio(value: &str) -> Result { let value = value.parse::().map_err(|error| error.to_string())?; if (1.0..=20.0).contains(&value) { Ok(value) } else { Err("expected a compressor ratio from 1 to 20".into()) } } fn parse_post_filter_gain(value: &str) -> Result { let value = value.parse::().map_err(|error| error.to_string())?; if (0.0..=60.0).contains(&value) { Ok(value) } else { Err("expected post-filter gain from 0 to 60 dB".into()) } } fn parse_adaptive_window(value: &str) -> Result { let value = value.parse::().map_err(|error| error.to_string())?; if (100.0..=30_000.0).contains(&value) { Ok(value) } else { Err("expected an adaptive window from 100 to 30000 ms".into()) } } #[cfg(test)] mod tests { use clap::CommandFactory; use super::*; #[test] fn parses_hash_prefixed_rgb_colors() { assert_eq!( "#ff2040".parse(), Ok(RgbColor { red: 255, green: 32, blue: 64 }) ); assert!("red".parse::().is_err()); } #[test] fn generated_completion_contains_current_options() { let mut command = Cli::command(); let mut output = Vec::new(); clap_complete::generate(Shell::Bash, &mut command, "vizzzz", &mut output); let script = String::from_utf8(output).unwrap(); assert!(script.contains("--post-filter-gain")); assert!(script.contains("--profile-file")); assert!(script.contains("--generate-completion")); } #[test] fn post_filter_gain_is_bounded() { let cli = Cli::try_parse_from(["vizzzz", "--post-filter-gain", "12"]).unwrap(); assert_eq!(cli.post_filter_gain, 12.0); assert!(Cli::try_parse_from(["vizzzz", "--post-filter-gain", "61"]).is_err()); } #[test] fn parses_sakura_theme() { let cli = Cli::try_parse_from(["vizzzz", "--theme", "sakura"]).unwrap(); assert_eq!(cli.theme, Theme::Sakura); } #[test] fn parses_window_frontend() { let cli = Cli::try_parse_from(["vizzzz", "--frontend", "window"]).unwrap(); assert_eq!(cli.frontend, Frontend::Window); } }