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 00000000..b7531e51 Binary files /dev/null and b/src/cortex-tui/src/sounds/approval.wav differ diff --git a/src/cortex-tui/src/sounds/complete.wav b/src/cortex-tui/src/sounds/complete.wav new file mode 100644 index 00000000..2d179c75 Binary files /dev/null and b/src/cortex-tui/src/sounds/complete.wav differ