From 4252fb7b129121867ef5c8bdbddf2130d10245ed Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 28 Jul 2026 15:07:39 -0500 Subject: [PATCH] feat: use capl to play short tone when timer changes phase --- Cargo.lock | 2 +- src/app.rs | 12 +++++++ src/args.rs | 23 +++++++++++--- src/main.rs | 4 ++- src/sound.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 src/sound.rs diff --git a/Cargo.lock b/Cargo.lock index 4b9a3a2..e9c1855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2120,7 +2120,7 @@ dependencies = [ [[package]] name = "termato" -version = "0.1.0" +version = "0.1.1" dependencies = [ "cpal", "crossterm", diff --git a/src/app.rs b/src/app.rs index 39ce384..81226c9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use crate::constants::SECONDS_PER_MIN; use crate::msg::Message; use crate::notification::send_desktop_notification; +use crate::sound::play_notification_sound; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum TimerMode { @@ -17,6 +18,7 @@ pub struct Termato { pub time_remaining_in_sec: u32, pub show_help: bool, pub enable_notifications: bool, + pub enable_sound: bool, should_quit: bool, } @@ -31,6 +33,7 @@ impl Termato { time_remaining_in_sec: work_mins * SECONDS_PER_MIN, show_help: false, enable_notifications: false, + enable_sound: false, should_quit: false, } } @@ -44,6 +47,11 @@ impl Termato { self } + pub fn with_sound(mut self, enable: bool) -> Self { + self.enable_sound = enable; + self + } + pub fn update(&mut self, msg: Message) { match msg { Message::Tick => self.tick(), @@ -83,6 +91,10 @@ impl Termato { } } + if self.enable_sound { + play_notification_sound(); + } + self.toggle_mode(); } } diff --git a/src/args.rs b/src/args.rs index 41977ac..a600876 100644 --- a/src/args.rs +++ b/src/args.rs @@ -8,6 +8,8 @@ pub const FLAG_VERSION: &str = "-v"; pub const FLAG_VERSION_LONG: &str = "--version"; pub const FLAG_NOTIFY: &str = "-n"; pub const FLAG_NOTIFY_LONG: &str = "--notify"; +pub const FLAG_SOUND: &str = "-s"; +pub const FLAG_SOUND_LONG: &str = "--sound"; pub const FLAG_VISUALIZER: &str = "-z"; pub const FLAG_VISUALIZER_LONG: &str = "--visualizer"; @@ -16,6 +18,7 @@ pub struct CliConfig { pub work_mins: u32, pub break_mins: u32, pub enable_notifications: bool, + pub enable_sound: bool, pub enable_visualizer: bool, } @@ -37,6 +40,7 @@ impl Cli { pub fn parse_args(args: Vec) -> CliAction { let mut enable_notifications = false; let mut enable_visualizer = false; + let mut enable_sound = false; let mut positional = Vec::new(); for arg in args.into_iter().skip(1) { @@ -44,6 +48,7 @@ impl Cli { FLAG_HELP | FLAG_HELP_LONG => return CliAction::PrintHelp, FLAG_VERSION | FLAG_VERSION_LONG => return CliAction::PrintVersion, FLAG_NOTIFY | FLAG_NOTIFY_LONG => enable_notifications = true, + FLAG_SOUND | FLAG_SOUND_LONG => enable_sound = true, FLAG_VISUALIZER | FLAG_VISUALIZER_LONG => enable_visualizer = true, "--" => {} s if s.starts_with('-') => { @@ -69,6 +74,7 @@ impl Cli { work_mins, break_mins, enable_notifications, + enable_sound, enable_visualizer, }) } @@ -82,22 +88,27 @@ impl Cli { println!( " {:<19} Enable desktop notifications", - format_args!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG) + format!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG) + ); + + println!( + " {:<19} Enable sound notifications", + format!("{}, {}", FLAG_SOUND, FLAG_SOUND_LONG) ); println!( " {:<19} Display visualizer of playing audio", - format_args!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG) + format!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG) ); println!( " {:<19} Print this help message", - format_args!("{}, {}", FLAG_HELP, FLAG_HELP_LONG) + format!("{}, {}", FLAG_HELP, FLAG_HELP_LONG) ); println!( " {:<19} Print the version number", - format_args!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG) + format!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG) ); println!(); @@ -126,6 +137,7 @@ mod tests { work_mins: 25, break_mins: 5, enable_notifications: false, + enable_sound: false, enable_visualizer: false, }) ); @@ -145,6 +157,7 @@ mod tests { work_mins: 25, break_mins: 5, enable_notifications: false, + enable_sound: false, enable_visualizer: true, }) ); @@ -165,6 +178,7 @@ mod tests { work_mins: 50, break_mins: 10, enable_notifications: true, + enable_sound: false, enable_visualizer: true, }) ); @@ -194,6 +208,7 @@ mod tests { work_mins: 25, break_mins: 5, enable_notifications: false, + enable_sound: false, enable_visualizer: false, }) ); diff --git a/src/main.rs b/src/main.rs index 2c03afb..abd921e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod constants; mod events; mod msg; mod notification; +mod sound; mod terminal; mod ui; @@ -61,7 +62,8 @@ fn main() -> Result<(), io::Error> { }; let mut termato = Termato::new(config.work_mins, config.break_mins) - .with_notifications(config.enable_notifications); + .with_notifications(config.enable_notifications) + .with_sound(config.enable_sound); let (tx, rx) = mpsc::channel(); diff --git a/src/sound.rs b/src/sound.rs new file mode 100644 index 0000000..a6b2015 --- /dev/null +++ b/src/sound.rs @@ -0,0 +1,88 @@ +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + +const FREQ_D5_HZ: f32 = 587.33; +const FREQ_A5_HZ: f32 = 880.00; + +const NOTE_1_DURATION_SECS: f32 = 0.15; +const NOTE_2_DURATION_SECS: f32 = 0.25; +const TOTAL_CHIME_DURATION_SECS: f32 = NOTE_1_DURATION_SECS + NOTE_2_DURATION_SECS; +const CHIME_VOLUME_GAIN: f32 = 0.25; + +pub fn play_notification_sound() { + thread::spawn(move || { + // term bell fallback + print!("\x07"); + + let _ = std::io::Write::flush(&mut std::io::stdout()); + + let _ = play_audio_chime(); + }); +} + +fn synthesize_sine_wave(freq_hz: f32, t_seconds: f32) -> f32 { + (2.0 * std::f32::consts::PI * freq_hz * t_seconds).sin() +} + +fn calculate_linear_decay(t_seconds: f32, duration_secs: f32) -> f32 { + (1.0 - (t_seconds / duration_secs)).max(0.0) +} + +fn play_audio_chime() -> Result<(), Box> { + let host = cpal::default_host(); + + let device = host + .default_output_device() + .ok_or("No default output audio device found")?; + + let config: cpal::StreamConfig = device.default_output_config()?.into(); + + let sample_rate = config.sample_rate as f32; + let channels = config.channels as usize; + + let mut sample_clock = 0u64; + let (tx, rx) = mpsc::channel::<()>(); + + let stream = device.build_output_stream( + config, + move |data: &mut [f32], _| { + for frame in data.chunks_mut(channels) { + // Convert sample counter to elapsed time in seconds + let elapsed_time = sample_clock as f32 / sample_rate; + sample_clock += 1; + + let sample = if elapsed_time < NOTE_1_DURATION_SECS { + let wave = synthesize_sine_wave(FREQ_D5_HZ, elapsed_time); + let envelope = calculate_linear_decay(elapsed_time, NOTE_1_DURATION_SECS); + wave * envelope * CHIME_VOLUME_GAIN + } else if elapsed_time < TOTAL_CHIME_DURATION_SECS { + let t_note2 = elapsed_time - NOTE_1_DURATION_SECS; + let wave = synthesize_sine_wave(FREQ_A5_HZ, t_note2); + let envelope = calculate_linear_decay(t_note2, NOTE_2_DURATION_SECS); + wave * envelope * CHIME_VOLUME_GAIN + } else { + 0.0 + }; + + for channel in frame.iter_mut() { + *channel = sample; + } + + if elapsed_time >= TOTAL_CHIME_DURATION_SECS { + let _ = tx.send(()); + } + } + }, + |err| eprintln!("Audio output stream error: {}", err), + None, + )?; + + stream.play()?; + + let _ = rx.recv_timeout(Duration::from_millis(500)); + + Ok(()) +}