From 705111df53190006792617f9e4a6468955577467 Mon Sep 17 00:00:00 2001 From: echobt Date: Mon, 2 Feb 2026 01:30:41 +0000 Subject: [PATCH] feat(tui): add audio notification system for completion and approval events - Add rodio dependency for cross-platform audio playback - Create sound.rs module with channel-based audio thread architecture - Embed WAV files for response completion and approval notification sounds - Play completion sound when streaming response finishes - Play approval sound when tool approval is requested - Graceful fallback to terminal bell when audio unavailable --- Cargo.lock | 226 +++++++++++++++++- Cargo.toml | 1 + src/cortex-tui/Cargo.toml | 3 + src/cortex-tui/src/app/methods.rs | 3 + src/cortex-tui/src/lib.rs | 6 + .../src/runner/app_runner/runner.rs | 8 + src/cortex-tui/src/runner/event_loop/modal.rs | 3 + .../src/runner/event_loop/streaming.rs | 3 + src/cortex-tui/src/sound.rs | 201 ++++++++++++++++ src/cortex-tui/src/sounds/approval.wav | Bin 0 -> 8864 bytes src/cortex-tui/src/sounds/complete.wav | Bin 0 -> 6658 bytes 11 files changed, 448 insertions(+), 6 deletions(-) create mode 100644 src/cortex-tui/src/sound.rs create mode 100644 src/cortex-tui/src/sounds/approval.wav create mode 100644 src/cortex-tui/src/sounds/complete.wav diff --git a/Cargo.lock b/Cargo.lock index d7d728f5..cce1f247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -540,6 +562,24 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.114", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -806,6 +846,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfb" version = "0.7.3" @@ -880,6 +929,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.5.56" @@ -1193,6 +1253,26 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + [[package]] name = "cortex-agents" version = "0.1.0" @@ -2035,6 +2115,7 @@ dependencies = [ "libc", "ratatui", "reqwest 0.13.1", + "rodio", "serde", "serde_json", "serde_yaml", @@ -2253,6 +2334,29 @@ dependencies = [ "windows 0.58.0", ] +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpp_demangle" version = "0.4.5" @@ -2648,6 +2752,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.10.0" @@ -3990,6 +4100,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.29.1" @@ -4772,7 +4888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -4816,6 +4932,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" @@ -5224,6 +5350,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -5233,7 +5373,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -5245,6 +5385,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -5411,6 +5560,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -5828,6 +5988,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -7241,6 +7424,17 @@ dependencies = [ "serde", ] +[[package]] +name = "rodio" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +dependencies = [ + "cpal", + "hound", + "thiserror 1.0.69", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -8104,7 +8298,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk", + "ndk 0.9.0", "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", @@ -8418,9 +8612,9 @@ dependencies = [ "lazy_static", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", @@ -10626,6 +10820,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.56.0" @@ -10708,6 +10912,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.56.0" @@ -11458,7 +11672,7 @@ dependencies = [ "jni", "kuchikiki", "libc", - "ndk", + "ndk 0.9.0", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-core-foundation", diff --git a/Cargo.toml b/Cargo.toml index ca308aab..20313f65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -383,6 +383,7 @@ sysinfo = "0.32" hex = "0.4" urlencoding = "2.1" pin-project-lite = "0.2" +rodio = { version = "0.19", default-features = false, features = ["wav"] } [workspace.lints.rust] unsafe_code = "allow" diff --git a/src/cortex-tui/Cargo.toml b/src/cortex-tui/Cargo.toml index cf3568ce..e73a6d75 100644 --- a/src/cortex-tui/Cargo.toml +++ b/src/cortex-tui/Cargo.toml @@ -65,6 +65,9 @@ walkdir = { workspace = true } # External editor which = { workspace = true } +# Audio notifications +rodio = { workspace = true } + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src/cortex-tui/src/app/methods.rs b/src/cortex-tui/src/app/methods.rs index a7143fc0..80eda194 100644 --- a/src/cortex-tui/src/app/methods.rs +++ b/src/cortex-tui/src/app/methods.rs @@ -53,6 +53,9 @@ impl AppState { approval_mode: ApprovalMode::Ask, }); self.set_view(AppView::Approval); + + // Play approval required sound + crate::sound::play_approval_required(self.sound_enabled); } /// Approve the pending tool diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 512fb537..481f19bf 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -117,6 +117,9 @@ pub mod capture; // MCP server storage (persistent storage for MCP configurations) pub mod mcp_storage; +// Sound notification system +pub mod sound; + // Re-export main types pub use actions::{ActionContext, ActionMapper, KeyAction, KeyBinding}; pub use app::{ @@ -169,6 +172,9 @@ pub use capture::{TuiCapture, capture_enabled}; // MCP storage re-exports pub use mcp_storage::{McpStorage, McpTransport, StoredMcpServer}; +// Sound notification re-exports +pub use sound::{SoundType, play_approval_required, play_response_complete}; + // Re-export cortex-core for downstream users pub use cortex_engine; diff --git a/src/cortex-tui/src/runner/app_runner/runner.rs b/src/cortex-tui/src/runner/app_runner/runner.rs index 13f0e25e..3bd3ded5 100644 --- a/src/cortex-tui/src/runner/app_runner/runner.rs +++ b/src/cortex-tui/src/runner/app_runner/runner.rs @@ -319,6 +319,10 @@ impl AppRunner { /// /// The TUI should appear almost instantly after trust verification and auth check. async fn run_direct_provider(self) -> Result { + // Initialize sound system early for audio notifications + // This spawns a background thread for audio playback + crate::sound::init(); + // Trust verification before anything else let workspace = std::env::current_dir()?; if !is_workspace_trusted(&workspace) { @@ -771,6 +775,10 @@ impl AppRunner { /// Run using legacy SessionBridge mode. async fn run_legacy_bridge(self) -> Result { + // Initialize sound system early for audio notifications + // This spawns a background thread for audio playback + crate::sound::init(); + // Initialize terminal let mut terminal = CortexTerminal::with_options(self.terminal_options)?; terminal.set_title("Cortex")?; diff --git a/src/cortex-tui/src/runner/event_loop/modal.rs b/src/cortex-tui/src/runner/event_loop/modal.rs index 5a8d058a..97874e1f 100644 --- a/src/cortex-tui/src/runner/event_loop/modal.rs +++ b/src/cortex-tui/src/runner/event_loop/modal.rs @@ -516,6 +516,9 @@ impl EventLoop { "sandbox" => { self.app_state.sandbox_mode = !self.app_state.sandbox_mode; } + "sound" => { + self.app_state.sound_enabled = !self.app_state.sound_enabled; + } _ => {} }; self.reopen_settings_menu(); diff --git a/src/cortex-tui/src/runner/event_loop/streaming.rs b/src/cortex-tui/src/runner/event_loop/streaming.rs index a5a13138..dd487297 100644 --- a/src/cortex-tui/src/runner/event_loop/streaming.rs +++ b/src/cortex-tui/src/runner/event_loop/streaming.rs @@ -429,6 +429,9 @@ impl EventLoop { } else { tracing::info!("Tools still running, will continue when they complete"); } + + // Play completion sound notification + crate::sound::play_response_complete(self.app_state.sound_enabled); } /// Handle stream error diff --git a/src/cortex-tui/src/sound.rs b/src/cortex-tui/src/sound.rs new file mode 100644 index 00000000..0417eaf4 --- /dev/null +++ b/src/cortex-tui/src/sound.rs @@ -0,0 +1,201 @@ +//! Sound notification system for the Cortex TUI. +//! +//! Provides audio notifications for key events like response completion, +//! tool approval requests, and spec plan approval. +//! +//! Audio playback is handled in a dedicated thread since rodio's OutputStream +//! is not Send/Sync. We use a channel-based approach to send sound requests +//! from any thread to the dedicated audio thread. + +use std::io::{Cursor, Write}; +use std::sync::mpsc; +use std::sync::OnceLock; +use std::thread; + +/// Type of sound notification +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SoundType { + /// Response/streaming completed + ResponseComplete, + /// Tool requires approval + ApprovalRequired, +} + +/// Channel sender for sound requests (Send + Sync) +/// Using sync_channel with capacity of 16 to prevent unbounded growth +static SOUND_SENDER: OnceLock> = OnceLock::new(); + +/// Embedded WAV data for response complete sound +const COMPLETE_WAV: &[u8] = include_bytes!("sounds/complete.wav"); +/// Embedded WAV data for approval required sound +const APPROVAL_WAV: &[u8] = include_bytes!("sounds/approval.wav"); + +/// Initialize the global sound system. +/// Spawns a dedicated audio thread that owns the OutputStream. +/// Should be called once at application startup. +pub fn init() { + // Only initialize once + if SOUND_SENDER.get().is_some() { + return; + } + + // Use bounded channel to prevent memory exhaustion from rapid triggers + let (tx, rx) = mpsc::sync_channel::(16); + + // Store the sender globally + if SOUND_SENDER.set(tx).is_err() { + // Another thread beat us to initialization + return; + } + + // Spawn a dedicated audio thread with a descriptive name + thread::Builder::new() + .name("cortex-audio".to_string()) + .spawn(move || { + // Try to create audio output + let output = match rodio::OutputStream::try_default() { + Ok((stream, handle)) => Some((stream, handle)), + Err(e) => { + tracing::debug!("Failed to initialize audio output: {}", e); + None + } + }; + + // Process sound requests + while let Ok(sound_type) = rx.recv() { + if let Some((ref _stream, ref handle)) = output { + let data: &'static [u8] = match sound_type { + SoundType::ResponseComplete => COMPLETE_WAV, + SoundType::ApprovalRequired => APPROVAL_WAV, + }; + + if let Err(e) = play_wav_internal(handle, data) { + tracing::debug!("Failed to play sound: {}", e); + } + } + } + }) + .expect("Failed to spawn audio thread"); +} + +/// Internal function to play WAV data using a stream handle +fn play_wav_internal(handle: &rodio::OutputStreamHandle, data: &'static [u8]) -> Result<(), String> { + let cursor = Cursor::new(data); + let source = rodio::Decoder::new(cursor).map_err(|e| format!("Decoder error: {}", e))?; + let sink = rodio::Sink::try_new(handle).map_err(|e| format!("Sink error: {}", e))?; + sink.append(source); + sink.detach(); + Ok(()) +} + +/// Emit terminal bell as fallback, ensuring immediate output +fn emit_terminal_bell() { + print!("\x07"); + // Flush stdout to ensure bell is emitted immediately (not buffered) + let _ = std::io::stdout().flush(); +} + +/// Play a notification sound. +/// +/// If `enabled` is false or audio is unavailable, this function does nothing. +/// Falls back to terminal bell if the sound system is not initialized. +/// This function is non-blocking - sound plays in background thread. +pub fn play(sound_type: SoundType, enabled: bool) { + if !enabled { + return; + } + + // Try to send sound request to audio thread + if let Some(sender) = SOUND_SENDER.get() { + // Use try_send to avoid blocking if channel is full + if sender.try_send(sound_type).is_err() { + // Channel full or audio thread terminated, fall back to bell + emit_terminal_bell(); + } + } else { + // Sound system not initialized, fall back to terminal bell + emit_terminal_bell(); + } +} + +/// Play notification for response completion +pub fn play_response_complete(enabled: bool) { + play(SoundType::ResponseComplete, enabled); +} + +/// Play notification for approval required +pub fn play_approval_required(enabled: bool) { + play(SoundType::ApprovalRequired, enabled); +} + +/// Check if the sound system has been initialized. +/// Useful for testing and diagnostics. +pub fn is_initialized() -> bool { + SOUND_SENDER.get().is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sound_type_equality() { + assert_eq!(SoundType::ResponseComplete, SoundType::ResponseComplete); + assert_eq!(SoundType::ApprovalRequired, SoundType::ApprovalRequired); + assert_ne!(SoundType::ResponseComplete, SoundType::ApprovalRequired); + } + + #[test] + fn test_sound_type_debug() { + let complete = SoundType::ResponseComplete; + let approval = SoundType::ApprovalRequired; + assert_eq!(format!("{:?}", complete), "ResponseComplete"); + assert_eq!(format!("{:?}", approval), "ApprovalRequired"); + } + + #[test] + fn test_sound_type_clone() { + let original = SoundType::ResponseComplete; + let cloned = original; + assert_eq!(original, cloned); + } + + #[test] + fn test_play_when_disabled() { + // Should not panic when sound is disabled + play(SoundType::ResponseComplete, false); + play(SoundType::ApprovalRequired, false); + } + + #[test] + fn test_play_response_complete_disabled() { + // Should not panic when sound is disabled + play_response_complete(false); + } + + #[test] + fn test_play_approval_required_disabled() { + // Should not panic when sound is disabled + play_approval_required(false); + } + + #[test] + fn test_embedded_wav_data_not_empty() { + // Verify that the embedded WAV files are not empty + assert!(!COMPLETE_WAV.is_empty(), "complete.wav should not be empty"); + assert!(!APPROVAL_WAV.is_empty(), "approval.wav should not be empty"); + } + + #[test] + fn test_embedded_wav_data_has_riff_header() { + // WAV files should start with "RIFF" magic bytes + assert!( + COMPLETE_WAV.starts_with(b"RIFF"), + "complete.wav should have RIFF header" + ); + assert!( + APPROVAL_WAV.starts_with(b"RIFF"), + "approval.wav should have RIFF header" + ); + } +} diff --git a/src/cortex-tui/src/sounds/approval.wav b/src/cortex-tui/src/sounds/approval.wav new file mode 100644 index 0000000000000000000000000000000000000000..b7531e51ee6baa9ca46e1e1fe3b3b492bf453fd8 GIT binary patch literal 8864 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 A_y7O^ literal 0 HcmV?d00001 diff --git a/src/cortex-tui/src/sounds/complete.wav b/src/cortex-tui/src/sounds/complete.wav new file mode 100644 index 0000000000000000000000000000000000000000..2d179c7541262ab9ce855047d59da001f5b2f8b9 GIT binary patch literal 6658 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 oeYGek4a(Wfvi8mv1p(j!jQ#h