From bc4c3d1fd3bb9e3068757ece700bc6efb54cdcb5 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 10 Apr 2025 13:04:20 +0200 Subject: [PATCH] looks good enough for a -p --- .gitignore | 1 + Cargo.lock | 363 +++++++++++++++++++++++++++++++++++++ Cargo.toml | 10 ++ src/main.rs | 502 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 876 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fd731ea --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,363 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +dependencies = [ + "shlex", +] + +[[package]] +name = "clap" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "libc" +version = "0.2.164" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" + +[[package]] +name = "ncurses" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd71afa95710e841d173521e8483e764004eb332bdf47bd01d00f568688027f" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustfft" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43806561bc506d0c5d160643ad742e3161049ac01027b5e6d7524091fd401d86" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", + "version_check", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "spectrust" +version = "0.1.0" +dependencies = [ + "byteorder", + "clap", + "ncurses", + "rustfft", +] + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..02661e5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "spectrust" +version = "0.1.0" +edition = "2021" + +[dependencies] +byteorder = "1.5.0" +clap = { version = "4.5.21", features = ["derive"] } +ncurses = "6.0.1" +rustfft = "6.2.0" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..17e6efa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,502 @@ +use clap::Parser; +use ncurses::*; +use rustfft::{algorithm::Radix4, num_complex::Complex, Fft, FftDirection}; +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}; +use std::thread::sleep; + +// 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)] +struct Cell { + character: String, + filled: bool, +} + +// Function to handle terminal resizing +fn handle_terminal_resize( + 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_mapping: &mut Vec, + log_power: f32, + 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 + 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); + 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", + 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\ + - Direct PipeWire capture: spectrust -p\n\ + - From audio file: spectrust -i audio.pcm\n\ + - From stdin: pw-record --raw - | spectrust")] +struct Cli { + #[arg(value_name = "FPS", default_value_t = 60)] + fps: u16, + + #[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)] + drop_off: f32, +} + +fn main() { + let cli = Cli::parse(); + + // Initialize ncurses with proper locale for UTF-8 support + setlocale(LcCategory::all, ""); // Set locale before ncurses init for UTF-8 support + 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 + 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 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 + let mut input_buffer = vec![0; samples_per_frame * (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 + 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 + let process = Command::new("pw-record") + .args(["--format=s16", "--rate=48000", "--channels=1", "--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 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 last_terminal_resize = Instant::now(); + let resize_check_interval = Duration::from_millis(500); // Check resize every 500ms + + 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); + } + + 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); + } + + // 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(); + } + } + + // Read and process all available data for this frame to prevent latency + let mut all_samples = Vec::new(); + 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 + let mut cursor = std::io::Cursor::new(actual_input); + + // Read as many complete samples as possible + while cursor.position() < (n as u64 - 1) { + match cursor.read_i16::() { + Ok(sample) => all_samples.push(sample as f32 / 32768.0), + Err(_) => break, + } + } + + // 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::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 + }; + } + } + + // Render only the changed cells - using safe dimensions + for y in 0..safe_rows as usize { + for x in 0..safe_cols as usize { + if current_buffer[y][x] != next_buffer[y][x] { + let _ = mvprintw(y as i32, x as i32, &next_buffer[y][x].character); + } + } + } + + // 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) + 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; + 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; + } + } + } + + // 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; + } + + 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 + 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 + }; + } + } + + // 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 + 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 + }; + } + } + } + } + } + + // Render only the cells that changed (diff and patch) - using safe dimensions + for y in 0..safe_rows as usize { + for x in 0..safe_cols as usize { + if current_buffer[y][x] != next_buffer[y][x] { + let _ = mvprintw(y as i32, x as i32, &next_buffer[y][x].character); + } + } + } + + // 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(); + } + } + + // 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 + }; + } + } + } + } + + // Render only the cells that changed - using safe dimensions + for y in 0..safe_rows as usize { + for x in 0..safe_cols as usize { + if current_buffer[y][x] != next_buffer[y][x] { + let _ = mvprintw(y as i32, x as i32, &next_buffer[y][x].character); + } + } + } + + // Swap buffers + std::mem::swap(&mut current_buffer, &mut next_buffer); + + refresh(); + } + } + } +}