diff --git a/Cargo.lock b/Cargo.lock index 5838484..eb9d9c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -278,6 +300,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "castaway" version = "0.2.4" @@ -309,6 +337,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -341,6 +379,50 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows", + "windows-core", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -452,6 +534,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "deltae" version = "0.3.2" @@ -845,6 +933,64 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -960,6 +1106,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "memchr" version = "2.8.3" @@ -1009,6 +1161,35 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nix" version = "0.29.0" @@ -1046,6 +1227,15 @@ dependencies = [ "zbus", ] +[[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-conv" version = "0.2.2" @@ -1063,6 +1253,15 @@ dependencies = [ "syn 2.0.119", ] +[[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" @@ -1072,6 +1271,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1090,6 +1311,55 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -1097,7 +1367,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", + "block2", "dispatch2", + "libc", "objc2", ] @@ -1309,6 +1581,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "polling" version = "3.11.0" @@ -1335,6 +1613,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[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-macro-crate" version = "3.5.0" @@ -1490,6 +1777,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1537,6 +1833,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1562,6 +1872,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1669,6 +1988,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -1693,6 +2028,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[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" @@ -1781,10 +2122,12 @@ dependencies = [ name = "termato" version = "0.0.0" dependencies = [ + "cpal", "crossterm", "figlet-rs", "notify-rust", "ratatui", + "realfft", ] [[package]] @@ -1985,6 +2328,16 @@ dependencies = [ "once_cell", ] +[[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 = "typenum" version = "1.20.1" @@ -2071,6 +2424,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2131,6 +2494,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2219,6 +2592,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 044c823..bc0ad7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,12 @@ edition = "2024" repository = "https://github.com/StevanFreeborn/termato" [dependencies] +cpal = "0.18.1" crossterm = "0.29.0" figlet-rs = "1.0.0" notify-rust = "4.18.0" ratatui = "0.30.2" +realfft = "3.5.0" [profile.dist] inherits = "release" diff --git a/src/main.rs b/src/main.rs index 31842c3..4707e56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ use std::{ - io::{self, stdout}, sync::{Arc, Mutex, mpsc}, thread, time::Duration, + io::{self, stdout}, + sync::{Arc, Mutex, mpsc}, + thread, + time::Duration, }; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use crossterm::{ ExecutableCommand, event::{self, Event, KeyCode, KeyModifiers}, @@ -11,12 +15,13 @@ use crossterm::{ use figlet_rs::Toilet; use notify_rust::Notification; use ratatui::{ - Terminal, + Frame, Terminal, backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Style}, widgets::{Block, Borders, Clear, Paragraph}, }; +use realfft::{RealFftPlanner, num_complex}; const SECONDS_PER_MIN: u32 = 60; @@ -138,10 +143,310 @@ impl Drop for TerminalGuard { } } +pub struct AudioVisualizer { + pub bar_data: Arc>>, +} + +// This is a implementation largely lifted from these +// open source implementation. I just wrote it in Rust here: +// 1. CAVA (C): https://github.com/karlstav/cava +// 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer +impl AudioVisualizer { + pub fn new(num_bars: usize) -> Self { + // + let bar_data = Arc::new(Mutex::new(vec![0; num_bars])); + let bar_data_clone = Arc::clone(&bar_data); + + thread::spawn(move || { + if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars) { + eprintln!("Audio capture error: {:?}", e); + } + }); + + Self { bar_data } + } + + // Doing best effort to support cross-platform functionality + fn get_audio_device( + host: &cpal::Host, + ) -> Result<(cpal::Device, cpal::StreamConfig), Box> { + // Try windows WASAPI loopback on default output device + #[cfg(target_os = "windows")] + { + if let Some(device) = host.default_output_device() + && let Ok(config) = device.default_output_config() + { + return Ok((device, config.into())); + } + } + + // Try Linux PipeWire/PulseAudio output monitor device + #[cfg(target_os = "linux")] + { + if let Ok(devices) = host.devices() { + for dev in devices { + if let Ok(name) = dev.name() { + // Monitor devices mirror system output under PulseAudio/PipeWire + if name.contains("monitor") { + if let Ok(config) = dev.default_input_config() { + return Ok((dev, config.into())); + } + } + } + } + } + } + + // Fallback to default input device + let device = host + .default_input_device() + .or_else(|| host.default_output_device()) + .ok_or("No audio input or output device found")?; + + let config = device + .default_input_config() + .or_else(|_| device.default_output_config())? + .into(); + + Ok((device, config)) + } + + // we here in doubling octaves but fft outputs linearly spaced bins. + // so we bundle bins on a log scale from 20 hz to 12 kHz + // this allows bass, mids, and trembls to have equal visual + // proportions during display + // + // i.e. + // bins like this [0-500Hz] [500-1k] [1k-1.5k] [1.5k-2k] [2k-2.5k] [2.5k-3k] [3k-12kHz] + // vs + // bins like this [20-60Hz] [60-250Hz] [250-500Hz] [500-2kHz] [2k-4kHz] [4k-8kHz] [8k-12kHz] + fn build_log_bins(num_bars: usize, sample_rate: f32, chunk_size: usize) -> Vec<(usize, usize)> { + let nyquist = sample_rate / 2.0; + let max_hz = 12000.0f32; + + (0..num_bars) + .map(|i| { + let low_hz = 20.0 * (max_hz / 20.0).powf(i as f32 / num_bars as f32); + let high_hz = 20.0 * (max_hz / 20.0).powf((i + 1) as f32 / num_bars as f32); + + let low = ((low_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize; + let high = ((high_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize; + + (low.max(1), high.max(low + 1)) + }) + .collect() + } + + // without we get sharp edges which gives noise in FFT processing + fn apply_hann_window(samples: &[f32], input_buffer: &mut [f32]) { + let chunk_size = samples.len(); + + for (i, sample) in samples.iter().enumerate() { + let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / chunk_size as f32).cos()); + input_buffer[i] = sample * window; + } + } + + fn process_fft_magnitudes( + spectrum: &[num_complex::Complex32], + log_bins: &[(usize, usize)], + freq_boost: &[f32], + prev_heights: &[f32], + autosens: f32, + smoothing: f32, + falloff: f32, + ) -> Vec { + let num_bars = log_bins.len(); + let mut current_bars = vec![0.0f32; num_bars]; + + // determine magnitude + for i in 0..num_bars { + let (start, stop) = log_bins[i]; + let bin_slice = &spectrum[start..stop.min(spectrum.len())]; + let magnitude_sum: f32 = bin_slice.iter().map(|c| c.norm()).sum(); + let avg_mag = if !bin_slice.is_empty() { + magnitude_sum / bin_slice.len() as f32 + } else { + 0.0 + }; + + // ignore quiet static noise below specific amplitude + let raw_val = if avg_mag < 0.02 { + 0.0 + } else { + (avg_mag * freq_boost[i] * autosens + 1.0).log10() * 3.5 + }; + + // rise smoothly from previous height + let target = (raw_val * (1.0 - smoothing)) + (prev_heights[i] * smoothing); + + // prevent sudden drops + if target < prev_heights[i] { + current_bars[i] = (prev_heights[i] - falloff).max(0.0); + } else { + current_bars[i] = target; + } + } + + current_bars + } + + // blend the heights between neighboring freq bins + // so instead of sharp spikes we get more of waves + fn apply_monstercat_smoothing(bars: &[f32]) -> Vec { + let num_bars = bars.len(); + let mut smoothed = bars.to_vec(); + + for i in 1..(num_bars - 1) { + smoothed[i] = (bars[i - 1] * 0.25) + (bars[i] * 0.50) + (bars[i + 1] * 0.25); + } + + smoothed + } + + // songs can be quiet and load so we try to adjust + // sensitivity to avoid bars becoming flattened or + // clipped + fn adjust_autosens(autosens: &mut f32, bars: &[f32]) { + let max_val = bars.iter().cloned().fold(0.0f32, f32::max); + if max_val > 8.0 { + *autosens *= 0.98; + } else if max_val < 3.0 && *autosens < 3.0 { + *autosens *= 1.01; + } + } + + // captures output and runs through pipeline + // system audio -> audio buffer -> windowing -> fft process -> binning -> floor/autosens -> smoothing + fn run_audio_loop( + bar_data: Arc>>, + num_bars: usize, + ) -> Result<(), Box> { + let host = cpal::default_host(); + let (device, config) = Self::get_audio_device(&host)?; + + let sample_rate = config.sample_rate as f32; + let channels = config.channels as usize; + + let chunk_size = 2048; + let mut planner = RealFftPlanner::::new(); + let fft = planner.plan_fft_forward(chunk_size); + let mut windowed_buffer = fft.make_input_vec(); + let mut spectrum = fft.make_output_vec(); + + let mut prev_heights = vec![0.0f32; num_bars]; + let smoothing = 0.70f32; + let falloff = 0.08f32; + let mut autosens = 1.0f32; + + let log_bins = Self::build_log_bins(num_bars, sample_rate, chunk_size); + + // high freq have less amplitude so we boost + // more and more as we go right + let freq_boost: Vec = (0..num_bars) + .map(|i| 1.0 + (3.5 * (i as f32 / num_bars as f32).powf(1.2))) + .collect(); + + let audio_buffer = Arc::new(Mutex::new(Vec::::with_capacity(chunk_size * 2))); + let buffer_clone = Arc::clone(&audio_buffer); + + let stream = device.build_input_stream( + config, + move |data: &[f32], _| { + let mut buf = buffer_clone.lock().unwrap(); + + for chunk in data.chunks(channels) { + let mono: f32 = chunk.iter().sum::() / channels as f32; + buf.push(mono); + } + + if buf.len() > chunk_size * 2 { + let drain_amt = buf.len() - chunk_size; + buf.drain(0..drain_amt); + } + }, + |err| eprintln!("Stream error: {}", err), + None, + )?; + + stream.play()?; + + loop { + thread::sleep(std::time::Duration::from_millis(16)); + + let samples = { + let buf = audio_buffer.lock().unwrap(); + if buf.len() < chunk_size { + continue; + } + buf[buf.len() - chunk_size..].to_vec() + }; + + Self::apply_hann_window(&samples, &mut windowed_buffer); + + fft.process(&mut windowed_buffer, &mut spectrum).unwrap(); + + let raw_bars = Self::process_fft_magnitudes( + &spectrum, + &log_bins, + &freq_boost, + &prev_heights, + autosens, + smoothing, + falloff, + ); + + let smoothed_bars = Self::apply_monstercat_smoothing(&raw_bars); + prev_heights = smoothed_bars.clone(); + + Self::adjust_autosens(&mut autosens, &smoothed_bars); + + if let Ok(mut bars) = bar_data.lock() { + for (i, val) in smoothed_bars.iter().enumerate() { + bars[i] = ((*val * 10.0).clamp(0.0, 100.0)) as u64; + } + } + } + } +} + +// use unicode blocks for rendering +// Empty -> " " +// 1/8th height -> "▂" +// 2/8th height -> "▃" +// 3/8th height -> "▄" +// 4/8th height -> "▅" +// 5/8th height -> "▆" +// 6/8th height -> "▇" +// Full Height -> "█" +fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) { + const BLOCKS: [&str; 8] = ["▂", "▃", "▄", "▅", "▆", "▇", "█", "█"]; + + let line: String = bar_values + .iter() + .map(|&val| { + if val == 0 { + " " + } else { + let idx = ((val as f32 / 100.0) * (BLOCKS.len() - 1) as f32) + .clamp(0.0, (BLOCKS.len() - 1) as f32) as usize; + + BLOCKS[idx] + } + }) + .collect(); + + let viz_paragraph = Paragraph::new(line) + .style(Style::default().fg(Color::Cyan)) + .alignment(Alignment::Center); + + f.render_widget(viz_paragraph, area); +} + // TODO: Refactor to an Elm like architecture fn main() -> Result<(), io::Error> { let args: Vec = std::env::args().collect(); - + if args.iter().any(|arg| arg == "-h" || arg == "--help") { println!("Usage: termato [options] [work_minutes] [break_minutes]"); println!(); @@ -169,18 +474,31 @@ fn main() -> Result<(), io::Error> { let enable_visualizer = args.iter().any(|arg| arg == "-z" || arg == "--visualizer"); let enable_notifications = args.iter().any(|arg| arg == "-n" || arg == "--notify"); - let positional_args: Vec<&String> = args.iter() + let visualizer = if enable_visualizer { + Some(AudioVisualizer::new(64)) + } else { + None + }; + + let positional_args: Vec<&String> = args + .iter() .skip(1) - .filter(|arg| { - *arg != "-n" && *arg != "--notify" && *arg != "-vx" && *arg != "--visualizer" - }) + .filter(|arg| *arg != "-n" && *arg != "--notify" && *arg != "-z" && *arg != "--visualizer") .collect(); - let work_mins = positional_args.first().and_then(|s| s.parse().ok()).unwrap_or(25); - let break_mins = positional_args.get(1).and_then(|s| s.parse().ok()).unwrap_or(5); + let work_mins = positional_args + .first() + .and_then(|s| s.parse().ok()) + .unwrap_or(25); + + let break_mins = positional_args + .get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(5); + let mut termato = Termato::new(work_mins, break_mins) - .with_notifications(enable_notifications) - .with_visualizer(enable_visualizer); + .with_notifications(enable_notifications) + .with_visualizer(enable_visualizer); let (tx, rx) = mpsc::channel(); @@ -210,28 +528,58 @@ fn main() -> Result<(), io::Error> { } }; - let big_time_text = if let Some(fig) = font.convert(&time_str) { + let raw_time_text = if let Some(fig) = font.convert(&time_str) { fig.to_string() } else { time_str }; - let text_height = big_time_text.lines().count() as u16; + let trimmed_time_text = raw_time_text.trim_matches('\n'); + let text_lines: Vec<&str> = trimmed_time_text.lines().collect(); + let text_height = text_lines.len() as u16; - let vertical_layout = Layout::default() + let viz_height = if visualizer.is_some() { 1 } else { 0 }; + let viz_width = 64; + + let content_height = text_height + viz_height; + + let outer_vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(0), - Constraint::Length(text_height), + Constraint::Length(content_height), Constraint::Min(0), ]) .split(size); - let timer_paragraph = Paragraph::new(big_time_text) + let inner_vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(text_height), + Constraint::Length(viz_height), + ]) + .split(outer_vertical[1]); + + let viz_horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(viz_width.min(size.width)), + Constraint::Min(0), + ]) + .split(inner_vertical[1]); + + let timer_paragraph = Paragraph::new(trimmed_time_text) .style(Style::default().fg(time_color)) .alignment(Alignment::Center); - f.render_widget(timer_paragraph, vertical_layout[1]); + f.render_widget(timer_paragraph, inner_vertical[0]); + + if let Some(ref viz) = visualizer + && let Ok(bar_data) = viz.bar_data.lock() + { + render_visualizer(f, viz_horizontal[1], &bar_data); + } if termato.show_help { let popup_width = 44; @@ -280,7 +628,7 @@ fn main() -> Result<(), io::Error> { } })?; - if event::poll(Duration::from_millis(100))? + if event::poll(Duration::from_millis(16))? && let Event::Key(key) = event::read()? && key.kind == event::KeyEventKind::Press {