use std::{ io::{self, stdout}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc, }, thread, time::Duration, }; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use crossterm::{ ExecutableCommand, event::{self, Event, KeyCode, KeyModifiers}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use figlet_rs::Toilet; use notify_rust::Notification; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, 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; #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum TimerMode { Work, Break, } fn send_desktop_notification(title: &str, body: &str) { let title = title.to_string(); let body = body.to_string(); std::thread::spawn(move || { let _ = Notification::new() .summary(&title) .body(&body) .appname("termato") .show(); }); } struct Termato { work_mins: u32, break_mins: u32, mode: TimerMode, is_running: bool, duration_in_secs: u32, time_remaining_in_sec: u32, show_help: bool, enable_notifications: bool, enable_visualizer: bool, } impl Termato { fn new(work_mins: u32, break_mins: u32) -> Self { Termato { work_mins, break_mins, mode: TimerMode::Work, is_running: false, duration_in_secs: work_mins * SECONDS_PER_MIN, time_remaining_in_sec: work_mins * SECONDS_PER_MIN, show_help: false, enable_notifications: false, enable_visualizer: false, } } fn with_notifications(mut self, enable: bool) -> Self { self.enable_notifications = enable; self } fn with_visualizer(mut self, enable: bool) -> Self { self.enable_visualizer = enable; self } fn toggle_running(&mut self) { self.is_running = !self.is_running; } fn toggle_mode(&mut self) { self.mode = match self.mode { TimerMode::Work => TimerMode::Break, TimerMode::Break => TimerMode::Work, }; self.duration_in_secs = match self.mode { TimerMode::Work => self.work_mins * SECONDS_PER_MIN, TimerMode::Break => self.break_mins * SECONDS_PER_MIN, }; self.time_remaining_in_sec = self.duration_in_secs; } fn tick(&mut self) { if self.is_running && self.time_remaining_in_sec > 0 { self.time_remaining_in_sec -= 1; if self.time_remaining_in_sec == 0 { if self.enable_notifications { match self.mode { TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."), TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."), } } self.toggle_mode(); } } } fn reset(&mut self) { self.is_running = false; self.mode = TimerMode::Work; self.duration_in_secs = self.work_mins * SECONDS_PER_MIN; self.time_remaining_in_sec = self.duration_in_secs; } fn toggle_help(&mut self) { self.show_help = !self.show_help; } } struct TerminalGuard; impl TerminalGuard { fn new() -> Result { enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; Ok(TerminalGuard) } } impl Drop for TerminalGuard { fn drop(&mut self) { let _ = disable_raw_mode(); let _ = stdout().execute(LeaveAlternateScreen); } } struct AudioVisualizer { bar_data: Arc>>, stop_flag: 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 { 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); let stop_flag = Arc::new(AtomicBool::new(false)); let stop_clone = Arc::clone(&stop_flag); thread::spawn(move || { if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars, stop_clone) { eprintln!("Audio capture error: {:?}", e); } }); Self { bar_data, stop_flag, } } // 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 loud 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().copied().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, stop: Arc, ) -> 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], _| { if let Ok(mut buf) = buffer_clone.lock() { 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()?; while !stop.load(Ordering::Relaxed) { thread::sleep(std::time::Duration::from_millis(16)); let samples = { let buf = match audio_buffer.lock() { Ok(b) => b, Err(_) => continue, }; if buf.len() < chunk_size { continue; } buf[buf.len() - chunk_size..].to_vec() }; Self::apply_hann_window(&samples, &mut windowed_buffer); if let Err(e) = fft.process(&mut windowed_buffer, &mut spectrum) { eprintln!("FFT error: {:?}", e); continue; } 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; } } } Ok(()) } } impl Drop for AudioVisualizer { fn drop(&mut self) { self.stop_flag.store(true, Ordering::Relaxed); } } // 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 max_bars = area.width as usize; let line: String = bar_values .iter() .take(max_bars) .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::Rgb(200, 184, 224))) .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!(); println!("Options:"); println!(" -n, --notify Enable desktop notifications"); println!(" -h, --help Print this help message"); println!(" -v, --version Print the version number"); println!(" -z, --visualizer Display visualizer of playing audio"); println!(); println!("Defaults: work_minutes = 25, break_minutes = 5"); return Ok(()); } if args.iter().any(|arg| arg == "-v" || arg == "--version") { println!("termato version {}", env!("CARGO_PKG_VERSION")); return Ok(()); } let _guard = TerminalGuard::new()?; let backend = CrosstermBackend::new(stdout()); let mut terminal = Terminal::new(backend)?; let font = Toilet::smblock().unwrap(); let enable_visualizer = args.iter().any(|arg| arg == "-z" || arg == "--visualizer"); let enable_notifications = args.iter().any(|arg| arg == "-n" || arg == "--notify"); let visualizer = if enable_visualizer { Some(AudioVisualizer::new(64)) } else { None }; let is_flag = |arg: &str| { arg == "-n" || arg == "--notify" || arg == "-z" || arg == "--visualizer" || arg == "-h" || arg == "--help" || arg == "-v" || arg == "--version" }; let positional_args: Vec<&String> = args.iter().skip(1).filter(|arg| !is_flag(arg)).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 mut termato = Termato::new(work_mins, break_mins) .with_notifications(enable_notifications) .with_visualizer(enable_visualizer); let (tx, rx) = mpsc::channel(); thread::spawn(move || { loop { thread::sleep(Duration::from_secs(1)); if tx.send(()).is_err() { break; } } }); loop { terminal.draw(|f| { let size = f.area(); let minutes = termato.time_remaining_in_sec / SECONDS_PER_MIN; let seconds = termato.time_remaining_in_sec % SECONDS_PER_MIN; let time_str = format!("{:02}:{:02}", minutes, seconds); let time_color = if !termato.is_running { Color::Rgb(212, 200, 122) } else { match termato.mode { TimerMode::Work => Color::Rgb(212, 115, 115), TimerMode::Break => Color::Rgb(126, 200, 192), } }; let raw_time_text = if let Some(fig) = font.convert(&time_str) { fig.to_string() } else { time_str }; 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 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(content_height), Constraint::Min(0), ]) .split(size); 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, 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; let popup_height = 7; if size.width >= popup_width && size.height >= popup_height { let popup_vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length((size.height.saturating_sub(popup_height)) / 2), Constraint::Length(popup_height), Constraint::Min(1), ]) .split(size); let popup_horizontal = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length((size.width.saturating_sub(popup_width)) / 2), Constraint::Length(popup_width), Constraint::Min(1), ]) .split(popup_vertical[1]); let popup_area = popup_horizontal[1]; let help_block = Block::default() .title(" Controls ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(58, 62, 70))); let help_content = Paragraph::new( "[Space] Pause / Resume\n\ [S] Switch Mode\n\ [R] Reset Timer\n\ [?] Toggle Help\n\ [Q] Quit", ) .block(help_block) .style(Style::default().fg(Color::Rgb(224, 228, 232))) .alignment(Alignment::Left); f.render_widget(Clear, popup_area); f.render_widget(help_content, popup_area); } } })?; if event::poll(Duration::from_millis(16))? && let Event::Key(key) = event::read()? && key.kind == event::KeyEventKind::Press { match key.code { KeyCode::Char('q') | KeyCode::Char('Q') => break, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break, KeyCode::Char(' ') => termato.toggle_running(), KeyCode::Char('s') | KeyCode::Char('S') => termato.toggle_mode(), KeyCode::Char('r') | KeyCode::Char('R') => termato.reset(), KeyCode::Char('?') => termato.toggle_help(), _ => {} } } if rx.try_recv().is_ok() { termato.tick(); } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn termato_new_when_called_it_should_return_expected_starting_state() { let result = Termato::new(25, 5); assert_eq!(result.work_mins, 25); assert_eq!(result.break_mins, 5); assert_eq!(result.mode, TimerMode::Work); assert!(!result.is_running); assert_eq!(result.duration_in_secs, 1500); assert_eq!(result.time_remaining_in_sec, 1500); assert!(!result.show_help); assert!(!result.enable_notifications); assert!(!result.enable_visualizer); } #[test] fn termato_tick_when_called_and_not_running_it_should_do_nothing() { let mut termato = Termato::new(1, 1); termato.tick(); assert_eq!(termato.time_remaining_in_sec, 60); } #[test] fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() { let mut termato = Termato::new(1, 1); termato.is_running = true; termato.time_remaining_in_sec = 0; termato.tick(); assert_eq!(termato.time_remaining_in_sec, 0); } #[test] fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() { let mut termato = Termato::new(1, 1); termato.is_running = true; termato.tick(); assert_eq!(termato.time_remaining_in_sec, 59); } #[test] fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() { let mut termato = Termato::new(1, 2); termato.is_running = true; termato.time_remaining_in_sec = 1; termato.tick(); assert_eq!(termato.mode, TimerMode::Break); assert_eq!(termato.duration_in_secs, 120); assert_eq!(termato.time_remaining_in_sec, 120); } #[test] fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() { let mut termato = Termato::new(2, 1); termato.mode = TimerMode::Break; termato.is_running = true; termato.time_remaining_in_sec = 1; termato.tick(); assert_eq!(termato.mode, TimerMode::Work); assert_eq!(termato.duration_in_secs, 120); assert_eq!(termato.time_remaining_in_sec, 120); } #[test] fn termato_toggle_mode_when_called_it_should_toggle_the_mode() { let mut termato = Termato::new(2, 1); termato.toggle_mode(); assert_eq!(termato.mode, TimerMode::Break); assert_eq!(termato.duration_in_secs, 60); assert_eq!(termato.time_remaining_in_sec, 60); termato.toggle_mode(); assert_eq!(termato.mode, TimerMode::Work); assert_eq!(termato.duration_in_secs, 120); assert_eq!(termato.time_remaining_in_sec, 120); } #[test] fn termato_toggle_running_when_called_and_already_running_it_should_pause() { let mut termato = Termato::new(1, 1); termato.is_running = true; termato.toggle_running(); assert!(!termato.is_running) } #[test] fn termato_toggle_running_when_called_and_already_paused_it_should_run() { let mut termato = Termato::new(1, 1); termato.toggle_running(); assert!(termato.is_running) } #[test] fn termato_rest_when_called_it_should_reset_the_apps_state() { let mut termato = Termato::new(1, 1); termato.is_running = true; termato.mode = TimerMode::Break; termato.duration_in_secs = 1; termato.time_remaining_in_sec = 1; termato.reset(); assert!(!termato.is_running); assert_eq!(termato.mode, TimerMode::Work); assert_eq!(termato.duration_in_secs, 60); assert_eq!(termato.time_remaining_in_sec, 60); } #[test] fn termato_toggle_help_when_called_it_should_toggle_help() { let mut termato = Termato::new(1, 1); termato.toggle_help(); assert!(termato.show_help); termato.toggle_help(); assert!(!termato.show_help); } #[test] fn termato_notifications_when_called_it_should_verify_opt_in_behavior() { let result = Termato::new(25, 5); assert!(!result.enable_notifications); let result = result.with_notifications(true); assert!(result.enable_notifications); } #[test] fn termato_visualizer_when_called_it_should_verify_opt_in_behavior() { let result = Termato::new(25, 5); assert!(!result.enable_visualizer); let result = result.with_visualizer(true); assert!(result.enable_visualizer); } }