diff --git a/Cargo.toml b/Cargo.toml index 02661e5..9c9df3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "spectrust" version = "0.1.0" edition = "2021" +[profile.release] +lto = true +codegen-units = 1 + [dependencies] byteorder = "1.5.0" clap = { version = "4.5.21", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 0c3adb6..883e249 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,99 +2,107 @@ use clap::Parser; use ncurses::*; use ncurses::{LcCategory, setlocale}; use rustfft::{algorithm::Radix4, num_complex::Complex, Fft, FftDirection}; -use std::collections::{VecDeque, HashMap}; +use std::collections::VecDeque; use std::io::{stdin, Read}; use std::fs::File; use std::time::{Duration, Instant}; use byteorder::{LittleEndian, ReadBytesExt}; use std::process::{Command, Stdio}; -// Create screen buffer struct to track cell states -// Each cell stores a character and its state (whether it's filled or not) -#[derive(Clone, PartialEq, Debug)] +const EMPTY_CELL: Cell = Cell { character: ' ', filled: false }; +const BLOCK_CELL: Cell = Cell { character: '█', filled: true }; +const SUB_BLOCKS: [char; 7] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇']; + +#[derive(Clone, Copy, PartialEq, Debug)] struct Cell { - character: String, + character: char, filled: bool, } -// ViewDiff struct for efficient terminal updates struct ViewDiff { - changes: HashMap<(usize, usize), String>, + changes: Vec<(i32, i32, char)>, } impl ViewDiff { fn new() -> Self { - ViewDiff { - changes: HashMap::new(), - } + ViewDiff { changes: Vec::with_capacity(512) } } - fn add_change(&mut self, y: usize, x: usize, character: String) { - self.changes.insert((y, x), character); + fn clear(&mut self) { + self.changes.clear(); + } + + fn add_change(&mut self, y: usize, x: usize, character: char) { + self.changes.push((y as i32, x as i32, character)); } fn apply(&self) { - for ((y, x), character) in &self.changes { - let _ = mvprintw(*y as i32, *x as i32, character); + let mut buf = [0u8; 4]; + for &(y, x, ch) in &self.changes { + let _ = mvprintw(y, x, ch.encode_utf8(&mut buf)); } } } +fn render_bar(buf: &mut Vec>, col: usize, sr: usize, bar_height_f: f32) { + let full = (bar_height_f as usize).min(sr); + for j in 0..full { + buf[sr - 1 - j][col] = BLOCK_CELL; + } + let sub_idx = (bar_height_f.fract() * 8.0) as usize; + if sub_idx > 0 && full < sr { + buf[sr - 1 - full][col] = Cell { character: SUB_BLOCKS[sub_idx - 1], filled: true }; + } +} -// Function to handle terminal resizing fn handle_terminal_resize( - rows: &mut i32, - cols: &mut i32, - new_rows: i32, + rows: &mut i32, + cols: &mut i32, + new_rows: i32, new_cols: i32, max_width: i32, max_height: i32, safe_rows: &mut i32, safe_cols: &mut i32, - empty_cell: &Cell, current_buffer: &mut Vec>, next_buffer: &mut Vec>, last_bars: &mut Vec, + freq_bands: &mut Vec, freq_mapping: &mut Vec, log_power: f32, - fft_size: usize + fft_size: usize, ) { - // Update terminal dimensions *rows = new_rows; *cols = new_cols; - - // Update safe dimensions to account for new terminal size *safe_cols = new_cols.min(max_width); *safe_rows = new_rows.min(max_height); - - // Resize screen buffers - *current_buffer = vec![vec![empty_cell.clone(); *safe_cols as usize]; *safe_rows as usize]; - *next_buffer = vec![vec![empty_cell.clone(); *safe_cols as usize]; *safe_rows as usize]; - - // Resize last_bars to fit new screen width - *last_bars = vec![0.0; *safe_cols as usize]; - - // Recalculate frequency mapping for new terminal width + + let sc = *safe_cols as usize; + let sr = *safe_rows as usize; + + *current_buffer = vec![vec![EMPTY_CELL; sc]; sr]; + *next_buffer = vec![vec![EMPTY_CELL; sc]; sr]; + + last_bars.clear(); + last_bars.resize(sc, 0.0); + freq_bands.clear(); + freq_bands.resize(sc, 0.0); + freq_mapping.clear(); - freq_mapping.reserve(*safe_cols as usize); - for i in 0..*safe_cols as usize { - // Using a more balanced logarithmic scale to better distribute frequencies - let log_pos = (i as f32 / *safe_cols as f32).powf(log_power * 0.8); + for i in 0..sc { + let log_pos = (i as f32 / sc as f32).powf(log_power * 0.8); let index = (log_pos * (fft_size / 2) as f32) as usize; freq_mapping.push(index.min(fft_size / 2 - 1)); } - - // Completely clear the screen + clear(); - - // Apply changes refresh(); } #[derive(Parser)] #[command(name = "Spectrust")] #[command(version = "0.1.0")] -#[command(about = "A PCM spectrum analyzer for audio visualization", +#[command(about = "A PCM spectrum analyzer for audio visualization", long_about = "Spectrust is a PCM spectrum analyzer that visualizes audio in your terminal.\n\ It can read from stdin, a file, or capture directly from PipeWire.\n\n\ Examples:\n\ @@ -107,452 +115,288 @@ struct Cli { #[arg(short, long, help = "Use PipeWire to capture audio directly")] pipewire: bool, - + #[arg(short, long, help = "Input file (reads from stdin if not provided)")] input: Option, - + #[arg(short, long, help = "Logarithmic scaling power (higher values emphasize lower frequencies)", default_value_t = 1.4)] log_power: f32, - - #[arg(short, long, help = "Drop-off factor for bar animation (0.0-1.0)", default_value_t = 0.85)] + + #[arg(short, long, help = "Drop-off factor for bar animation (0.0-1.0)", default_value_t = 0.75)] drop_off: f32, } fn main() { let cli = Cli::parse(); - - // Initialize ncurses with proper locale for UTF-8 support - let _ = setlocale(LcCategory::all, ""); // Set locale before ncurses init for UTF-8 support + + let _ = setlocale(LcCategory::all, ""); initscr(); noecho(); curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE); keypad(stdscr(), true); - // Enable handling of window resize signals - timeout(0); // Non-blocking input for getch() - - // Get terminal dimensions + timeout(0); + let mut rows: i32 = 0; let mut cols: i32 = 0; getmaxyx(stdscr(), &mut rows, &mut cols); - + let sample_rate: usize = 48000; let bit_depth: usize = 16; - let num_channels: usize = 2; // Default to assume stereo for better handling + let num_channels: usize = 2; let samples_per_frame: usize = sample_rate / usize::from(cli.fps); let fft_size: usize = 1024; - - // Set up FFT processor + let fft = Radix4::new(fft_size, FftDirection::Forward); - - // Set up audio input source - account for num_channels in buffer size - let mut input_buffer = vec![0; samples_per_frame * num_channels * (bit_depth / 8)]; - let mut complex_buffer = vec![Complex { re: 0.0, im: 0.0 }; fft_size]; - - // Set up input reader based on command line arguments + + let mut input_buffer = vec![0u8; samples_per_frame * num_channels * (bit_depth / 8)]; + let mut complex_buffer = vec![Complex { re: 0.0f32, im: 0.0f32 }; fft_size]; + + let hann_window: Vec = (0..fft_size) + .map(|i| 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / fft_size as f32).cos())) + .collect(); + enum AudioSource { StdIn(std::io::StdinLock<'static>), File(File), PipeWire(std::process::ChildStdout), } - - // Initialize display without a title bar + refresh(); - - // Set up audio source + let mut audio_source = if cli.pipewire { - // Launch PipeWire capture process with raw output format - // Using stereo (2 channels) for consistent behavior with other input sources let process = Command::new("pw-record") .args(["--format=s16", "--rate=48000", "--channels=2", "--raw", "-"]) .stdout(Stdio::piped()) .spawn() .expect("Failed to start pw-record"); - let stdout = process.stdout.expect("Failed to capture pw-record stdout"); refresh(); AudioSource::PipeWire(stdout) } else if let Some(filename) = &cli.input { - // Read from file let file = File::open(filename).expect("Failed to open input file"); AudioSource::File(file) } else { - // Read from stdin AudioSource::StdIn(stdin().lock()) }; - - // Define maximum sizes to avoid overflow + let max_width = 1000; let max_height = 1000; - - // Calculate safe dimensions that will be updated when terminal resizes + let mut safe_cols = cols.min(max_width); let mut safe_rows = rows.min(max_height); - - // Initialize screen buffers with safe dimensions - let empty_cell = Cell { character: " ".to_string(), filled: false }; - let mut current_buffer: Vec> = vec![vec![empty_cell.clone(); safe_cols as usize]; safe_rows as usize]; - let mut next_buffer: Vec> = vec![vec![empty_cell.clone(); safe_cols as usize]; safe_rows as usize]; - - // For smooth drop-off - let mut last_bars: Vec = vec![0.0; safe_cols as usize]; - let drop_factor: f32 = cli.drop_off.max(0.0).min(1.0); // Clamp between 0 and 1 + + let mut sc = safe_cols as usize; + let mut sr = safe_rows as usize; + + let mut current_buffer: Vec> = vec![vec![EMPTY_CELL; sc]; sr]; + let mut next_buffer: Vec> = vec![vec![EMPTY_CELL; sc]; sr]; + + let mut last_bars: Vec = vec![0.0; sc]; + let mut freq_bands: Vec = vec![0.0; sc]; + let drop_factor: f32 = cli.drop_off.clamp(0.0, 1.0); let interval = Duration::from_secs_f32(1.0 / f32::from(cli.fps)); - let mut last_frame_time = Instant::now(); - // Store max amplitudes for brief stability (but not smoothing) let mut max_magnitudes: VecDeque = VecDeque::with_capacity(usize::from(cli.fps / 2)); - - // Frequency scaling with safe dimensions - balanced logarithmic mapping for better audio visualization - let mut freq_mapping: Vec = Vec::with_capacity(safe_cols as usize); - for i in 0..safe_cols as usize { - // Using a more balanced logarithmic scale to better distribute frequencies - // Lower log_power to give more space to high frequencies - let log_pos = (i as f32 / safe_cols as f32).powf(cli.log_power * 0.8); // Reduced power for better balance - let index = (log_pos * (fft_size / 2) as f32) as usize; - freq_mapping.push(index.min(fft_size / 2 - 1)); - } - // Track if terminal has been resized + let mut freq_mapping: Vec = (0..sc) + .map(|i| { + let log_pos = (i as f32 / sc as f32).powf(cli.log_power * 0.8); + ((log_pos * (fft_size / 2) as f32) as usize).min(fft_size / 2 - 1) + }) + .collect(); + let mut last_terminal_resize = Instant::now(); - let resize_check_interval = Duration::from_millis(500); // Check resize every 500ms - + let resize_check_interval = Duration::from_millis(500); + + let mut all_samples: Vec = Vec::with_capacity(fft_size * 4); + let mut view_diff = ViewDiff::new(); + let mut max_magnitude: f32 = 1.0; + loop { - // Check for terminal resize - polling approach for terminals that don't send KEY_RESIZE events if last_terminal_resize.elapsed() >= resize_check_interval { let mut new_rows = 0; let mut new_cols = 0; getmaxyx(stdscr(), &mut new_rows, &mut new_cols); - - // Handle resize operation if new_rows != rows || new_cols != cols { - handle_terminal_resize(&mut rows, &mut cols, new_rows, new_cols, - max_width, max_height, &mut safe_rows, &mut safe_cols, - &empty_cell, &mut current_buffer, &mut next_buffer, - &mut last_bars, &mut freq_mapping, cli.log_power, fft_size); + handle_terminal_resize( + &mut rows, &mut cols, new_rows, new_cols, + max_width, max_height, &mut safe_rows, &mut safe_cols, + &mut current_buffer, &mut next_buffer, + &mut last_bars, &mut freq_bands, &mut freq_mapping, + cli.log_power, fft_size, + ); + sc = safe_cols as usize; + sr = safe_rows as usize; } - last_terminal_resize = Instant::now(); } - - // Check for character input - direct resize event + let ch = getch(); if ch == KEY_RESIZE { - // Terminal was resized, update dimensions let mut new_rows = 0; let mut new_cols = 0; getmaxyx(stdscr(), &mut new_rows, &mut new_cols); - - // Handle resize operation - handle_terminal_resize(&mut rows, &mut cols, new_rows, new_cols, - max_width, max_height, &mut safe_rows, &mut safe_cols, - &empty_cell, &mut current_buffer, &mut next_buffer, - &mut last_bars, &mut freq_mapping, cli.log_power, fft_size); + handle_terminal_resize( + &mut rows, &mut cols, new_rows, new_cols, + max_width, max_height, &mut safe_rows, &mut safe_cols, + &mut current_buffer, &mut next_buffer, + &mut last_bars, &mut freq_bands, &mut freq_mapping, + cli.log_power, fft_size, + ); + sc = safe_cols as usize; + sr = safe_rows as usize; } - - // Process audio frames at regular intervals - if last_frame_time.elapsed() >= interval { - // Clear the entire screen before rendering the new frame (prevents lingering characters) - erase(); - // No welcome message - keeping the display clean as requested - - // Reset next buffer for this frame - using safe dimensions - for y in 0..safe_rows as usize { - for x in 0..safe_cols as usize { - next_buffer[y][x] = empty_cell.clone(); - // Also reset the current buffer to ensure clean state - current_buffer[y][x] = empty_cell.clone(); - } + if last_frame_time.elapsed() >= interval { + for row in next_buffer.iter_mut() { + row.fill(EMPTY_CELL); } - - // Read and process all available data for this frame to prevent latency - let mut all_samples = Vec::new(); + + all_samples.clear(); let mut buffer_is_empty = false; - - // Process all available data until buffer is empty or we have an error + loop { let read_result = match &mut audio_source { AudioSource::StdIn(input) => input.read(&mut input_buffer), AudioSource::File(file) => file.read(&mut input_buffer), AudioSource::PipeWire(stdout) => stdout.read(&mut input_buffer), }; - + match read_result { Ok(0) => { - // No data read but not EOF - buffer is empty buffer_is_empty = true; break; - }, + } Ok(n) => { - // Resize input buffer to match actual bytes read - let actual_input = &input_buffer[0..n]; - - // Convert raw bytes to audio samples, handling multichannel input - let mut cursor = std::io::Cursor::new(actual_input); - - // Read as many complete sample frames as possible + let mut cursor = std::io::Cursor::new(&input_buffer[..n]); while cursor.position() < (n as u64 - (num_channels * (bit_depth / 8)) as u64 + 1) { - let mut frame_sum = 0.0; - let mut valid_channels = 0; - - // Read all channels and average them + let mut frame_sum = 0.0f32; + let mut valid_channels = 0u32; for _ in 0..num_channels { match cursor.read_i16::() { Ok(sample) => { frame_sum += sample as f32; valid_channels += 1; - }, + } Err(_) => break, } } - - // Average the channels (if any valid channels were read) if valid_channels > 0 { all_samples.push((frame_sum / valid_channels as f32) / 32768.0); } } - - // If we've collected enough samples, we can stop to avoid too much processing if all_samples.len() >= fft_size * 2 { break; } - }, - - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { - break; - }, + } + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // Buffer is currently empty, but might have data later buffer_is_empty = true; break; - }, + } Err(e) => { - // Error occurred - // Show error message in the buffer - using safe dimensions let error_msg = format!("Error: {}", e); for (i, c) in error_msg.chars().enumerate() { - if i < safe_cols as usize { - next_buffer[0][i] = Cell { - character: c.to_string(), - filled: true - }; + if i < sc { + next_buffer[0][i] = Cell { character: c, filled: true }; } } - - // Create a view diff for error display - let mut view_diff = ViewDiff::new(); - - // Record only the changed cells - using safe dimensions - for y in 0..safe_rows as usize { - for x in 0..safe_cols as usize { + view_diff.clear(); + for y in 0..sr { + for x in 0..sc { if current_buffer[y][x] != next_buffer[y][x] { - view_diff.add_change(y, x, next_buffer[y][x].character.clone()); + view_diff.add_change(y, x, next_buffer[y][x].character); } } } - - // Apply all changes in one batch operation view_diff.apply(); - - // Swap buffers std::mem::swap(&mut current_buffer, &mut next_buffer); - refresh(); break; } } } - + if !all_samples.is_empty() { - // Process all samples by averaging if we have more than fft_size if all_samples.len() > fft_size { - // Calculate how many samples to average per FFT bin - let samples_per_bin = all_samples.len() / fft_size; - let remainder = all_samples.len() % fft_size; - - // Average samples to fill complex buffer (ensures we don't lose data) + let spb = all_samples.len() / fft_size; + let rem = all_samples.len() % fft_size; for i in 0..fft_size { - let start = i * samples_per_bin; - let end = if i < remainder { - start + samples_per_bin + 1 // Distribute remainder - } else { - start + samples_per_bin - }; - - // Average the samples in this bin - let bin_samples = &all_samples[start..end]; - let avg = bin_samples.iter().sum::() / bin_samples.len() as f32; - - complex_buffer[i].re = avg; + let start = i * spb; + let end = start + spb + if i < rem { 1 } else { 0 }; + let bin = &all_samples[start..end]; + complex_buffer[i].re = bin.iter().sum::() / bin.len() as f32; complex_buffer[i].im = 0.0; } } else { - // If we have fewer samples than fft_size, use them directly for i in 0..fft_size { - if i < all_samples.len() { - complex_buffer[i].re = all_samples[i]; - complex_buffer[i].im = 0.0; - } else { - complex_buffer[i].re = 0.0; - complex_buffer[i].im = 0.0; - } + complex_buffer[i].re = if i < all_samples.len() { all_samples[i] } else { 0.0 }; + complex_buffer[i].im = 0.0; } } - // Apply window function (Hann window) to reduce spectral leakage for i in 0..fft_size { - let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / fft_size as f32).cos()); - complex_buffer[i].re *= window; + complex_buffer[i].re *= hann_window[i]; } - // Process FFT using standard rustfft implementation (already optimized) fft.process(&mut complex_buffer); - - // Calculate frequency bands using our mapping - with safe dimensions - let mut freq_bands: Vec = vec![0.0; safe_cols as usize]; - - // We only use the first half of FFT results (Nyquist theorem) + for (i, &mapping_idx) in freq_mapping.iter().enumerate() { - if i < freq_bands.len() && mapping_idx < fft_size / 2 { - // Get magnitude and apply frequency-dependent scaling for better visualization + if mapping_idx < fft_size / 2 { let magnitude = complex_buffer[mapping_idx].norm(); - - // Boost higher frequencies to make them more visible - // The higher the frequency, the more we boost it let freq_boost = 1.0 + (mapping_idx as f32 / (fft_size / 2) as f32) * 4.0; - - // Amplify the signal with the frequency boost - let scaled_magnitude = (magnitude * 80.0 * freq_boost).powf(0.5); - - // Direct response for punchier visualization - freq_bands[i] = if scaled_magnitude > last_bars[i] { - // Immediate rise for maximum punch - scaled_magnitude - } else { - // Quick fall with drop factor - last_bars[i] * drop_factor - }; + let scaled = (magnitude * 80.0 * freq_boost).powf(0.5); + freq_bands[i] = if scaled > last_bars[i] { scaled } else { last_bars[i] * drop_factor }; } } - - // Update last bars for next frame - last_bars = freq_bands.clone(); - // Calculate max magnitude over all frequencies for normalization - let current_max = freq_bands.iter().cloned().fold(0.0, f32::max); - - // Make sure we have a reasonable minimum value to avoid empty display + std::mem::swap(&mut last_bars, &mut freq_bands); + + let current_max = last_bars.iter().copied().fold(0.0f32, f32::max); let current_max = if current_max < 0.001 { 1.0 } else { current_max }; - - // Store this frame's maximum for very minimal smoothing + max_magnitudes.push_front(current_max); - - // Keep just half a second of history - enough to prevent extreme flickering - // but not enough to create significant smoothing if max_magnitudes.len() > usize::from(cli.fps) / 2 { max_magnitudes.pop_back(); } - - // Direct maximum for most punchy visualization - // Just take the current frame's maximum with minimal safety buffer - let max_magnitude = if !max_magnitudes.is_empty() { - max_magnitudes.iter().cloned().fold(0.0, f32::max).max(0.1) - } else { - current_max.max(0.1) - }; - - // Update next buffer with the new bar states - using safe dimensions - for (i, &magnitude) in freq_bands.iter().enumerate() { - if i < safe_cols as usize { - let normalized_magnitude = magnitude / max_magnitude; - let bar_height = (normalized_magnitude * safe_rows as f32) as i32; - - for j in 0..safe_rows as i32 { - let y = safe_rows - 1 - j; - if j < bar_height { - // Simple solid bar - one character wide - let char_to_use = "▒"; // Using medium shade block (U+2592) for better compatibility - - if y >= 0 && y < safe_rows && i < safe_cols as usize { - next_buffer[y as usize][i] = Cell { - character: char_to_use.to_string(), - filled: true - }; - } - } - } - } + + max_magnitude = max_magnitudes.iter().copied().fold(0.0f32, f32::max).max(0.1); + + for (i, &magnitude) in last_bars[..sc].iter().enumerate() { + render_bar(&mut next_buffer, i, sr, (magnitude / max_magnitude) * sr as f32); } - - // Create a view diff for efficient terminal updates - let mut view_diff = ViewDiff::new(); - - // Record only the changed cells (diff and patch) - using safe dimensions - for y in 0..safe_rows as usize { - for x in 0..safe_cols as usize { + + view_diff.clear(); + for y in 0..sr { + for x in 0..sc { if current_buffer[y][x] != next_buffer[y][x] { - view_diff.add_change(y, x, next_buffer[y][x].character.clone()); + view_diff.add_change(y, x, next_buffer[y][x].character); } } } - - // Apply changes efficiently in one batch view_diff.apply(); - - // Swap buffers for next frame std::mem::swap(&mut current_buffer, &mut next_buffer); - last_frame_time = Instant::now(); refresh(); } else if buffer_is_empty { - // Reset next buffer - using safe dimensions - for y in 0..safe_rows as usize { - for x in 0..safe_cols as usize { - next_buffer[y][x] = empty_cell.clone(); - } + for i in 0..sc { + last_bars[i] *= drop_factor; + render_bar(&mut next_buffer, i, sr, (last_bars[i] / max_magnitude) * sr as f32); } - - // Update buffer with reduced heights - using safe dimensions - for i in 0..last_bars.len() { - if i < safe_cols as usize { - last_bars[i] *= drop_factor; - - let bar_height = (last_bars[i] * safe_rows as f32) as i32; - for j in 0..bar_height { - let y = safe_rows - 1 - j; - if y >= 0 && y < safe_rows && i < safe_cols as usize { - // Simple solid bar - one character wide - let char_to_use = "▒"; // Using medium shade block (U+2592) for better compatibility - - next_buffer[y as usize][i] = Cell { - character: char_to_use.to_string(), - filled: true - }; - } - } - } - } - - // Create a view diff for efficient terminal updates - let mut view_diff = ViewDiff::new(); - - // Record only the changed cells - using safe dimensions - for y in 0..safe_rows as usize { - for x in 0..safe_cols as usize { + + view_diff.clear(); + for y in 0..sr { + for x in 0..sc { if current_buffer[y][x] != next_buffer[y][x] { - view_diff.add_change(y, x, next_buffer[y][x].character.clone()); + view_diff.add_change(y, x, next_buffer[y][x].character); } } } - - // Apply all changes in one batch operation view_diff.apply(); - - // Swap buffers std::mem::swap(&mut current_buffer, &mut next_buffer); - + last_frame_time = Instant::now(); refresh(); } }