diff --git a/Cargo.toml b/Cargo.toml index 21d527b..ddad5ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,14 +24,15 @@ serde_json = "1.0" reqwest = { version = "0.11", features = ["blocking", "json"] } tokio = { version = "1", features = ["full"] } dotenv = "0.15" -rodio = "0.18" +rodio = { version = "0.18", optional = true } [dev-dependencies] tempfile = "3.8" [features] chess-tui = [] -default = ["chess-tui"] +sound = ["rodio"] +default = ["chess-tui", "sound"] [profile.release] lto = true diff --git a/src/app.rs b/src/app.rs index 2e84648..ae8e258 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1225,14 +1225,21 @@ impl App { self.cycle_skin(); self.update_config(); } + #[cfg(feature = "sound")] 5 => { // Toggle sound self.sound_enabled = !self.sound_enabled; crate::sound::set_sound_enabled(self.sound_enabled); self.update_config(); } + #[cfg(feature = "sound")] 6 => self.toggle_help_popup(), + #[cfg(feature = "sound")] 7 => self.current_page = Pages::Credit, + #[cfg(not(feature = "sound"))] + 5 => self.toggle_help_popup(), + #[cfg(not(feature = "sound"))] + 6 => self.current_page = Pages::Credit, _ => {} } } diff --git a/src/handler.rs b/src/handler.rs index 2be5a57..824dad2 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -307,9 +307,21 @@ fn handle_page_input(app: &mut App, key_event: KeyEvent) { /// Handles keyboard input on the home/menu page. /// Supports navigation through menu items and selection. fn handle_home_page_events(app: &mut App, key_event: KeyEvent) { + // Number of menu items depends on whether sound feature is enabled + const MENU_ITEMS: u8 = { + #[cfg(feature = "sound")] + { + 8 // Local game, Multiplayer, Lichess, Bot, Skin, Sound, Help, About + } + #[cfg(not(feature = "sound"))] + { + 7 // Local game, Multiplayer, Lichess, Bot, Skin, Help, About + } + }; + match key_event.code { - KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(8), // 8 menu items: Local game, Multiplayer, Lichess, Bot, Skin, Sound, Help, About - KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(8), // 8 menu items + KeyCode::Up | KeyCode::Char('k') => app.menu_cursor_up(MENU_ITEMS), + KeyCode::Down | KeyCode::Char('j') => app.menu_cursor_down(MENU_ITEMS), // If on skin selection menu item (index 3), use left/right to cycle skins KeyCode::Left | KeyCode::Char('h') if app.menu_cursor == 3 => { app.cycle_skin_backward(); diff --git a/src/main.rs b/src/main.rs index f1bb464..f1633d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,9 @@ struct Args { /// Lichess API token #[arg(short, long)] lichess_token: Option, + /// Disable sound effects + #[arg(long)] + no_sound: bool, } fn main() -> AppResult<()> { @@ -178,6 +181,12 @@ fn main() -> AppResult<()> { app.lichess_token = Some(token.clone()); } + // Command line no-sound flag takes precedence over configuration file + if args.no_sound { + app.sound_enabled = false; + chess_tui::sound::set_sound_enabled(false); + } + // Setup logging if let Err(e) = logging::setup_logging(&folder_path, &app.log_level) { eprintln!("Failed to initialize logging: {}", e); @@ -361,6 +370,11 @@ fn config_create(args: &Args, folder_path: &Path, config_path: &Path) -> AppResu config.bot_depth = Some(args.depth); } + // Always update sound_enabled if --no-sound flag is provided via command line (command line takes precedence) + if args.no_sound { + config.sound_enabled = Some(false); + } + let toml_string = toml::to_string(&config) .expect("Failed to serialize config to TOML. This is a bug, please report it."); let mut file = File::create(config_path)?; @@ -395,6 +409,7 @@ mod tests { engine_path: "test_engine_path".to_string(), depth: 10, lichess_token: None, + no_sound: false, }; let config_dir = config_dir().unwrap(); diff --git a/src/sound.rs b/src/sound.rs index 0926dab..85e67e8 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -1,4 +1,3 @@ -use rodio::{OutputStream, Sink}; use std::sync::atomic::{AtomicBool, Ordering}; // Global sound enabled state @@ -9,11 +8,20 @@ static AUDIO_AVAILABLE: AtomicBool = AtomicBool::new(true); /// Check if audio is available and update the availability state /// This should be called at startup to detect if we're in an environment without audio (e.g., Docker) pub fn check_audio_availability() -> bool { - // Try to create an output stream - // Note: ALSA may print errors to stderr, but we handle the failure gracefully - let available = OutputStream::try_default().is_ok(); - AUDIO_AVAILABLE.store(available, Ordering::Relaxed); - available + #[cfg(feature = "sound")] + { + use rodio::OutputStream; + // Try to create an output stream + // Note: ALSA may print errors to stderr, but we handle the failure gracefully + let available = OutputStream::try_default().is_ok(); + AUDIO_AVAILABLE.store(available, Ordering::Relaxed); + available + } + #[cfg(not(feature = "sound"))] + { + AUDIO_AVAILABLE.store(false, Ordering::Relaxed); + false + } } /// Set whether sounds are enabled @@ -29,112 +37,124 @@ pub fn is_sound_enabled() -> bool { /// Plays a move sound when a chess piece is moved. /// This generates a pleasant, wood-like "click" sound using multiple harmonics. pub fn play_move_sound() { - if !is_sound_enabled() { - return; - } - // Spawn in a separate thread to avoid blocking the main game loop - std::thread::spawn(|| { - // Try to get an output stream, but don't fail if audio isn't available - let Ok((_stream, stream_handle)) = OutputStream::try_default() else { - return; - }; - - // Create a sink to play the sound - let Ok(sink) = Sink::try_new(&stream_handle) else { + #[cfg(feature = "sound")] + { + if !is_sound_enabled() { return; - }; - - // Generate a pleasant wood-like click sound - // Using a lower fundamental frequency with harmonics for a richer sound - let sample_rate = 44100; - let duration = 0.08; // 80 milliseconds - slightly longer for better perception - let fundamental = 200.0; // Lower frequency for a more pleasant, less harsh sound - - let num_samples = (sample_rate as f64 * duration) as usize; - let mut samples = Vec::with_capacity(num_samples); - - for i in 0..num_samples { - let t = i as f64 / sample_rate as f64; - - // Create a more sophisticated envelope with exponential decay - // Quick attack, smooth decay - like a wood piece being placed - let envelope = if t < duration * 0.1 { - // Quick attack (10% of duration) - (t / (duration * 0.1)).powf(0.5) - } else { - // Exponential decay - let decay_start = duration * 0.1; - let decay_time = t - decay_start; - let decay_duration = duration - decay_start; - (-decay_time * 8.0 / decay_duration).exp() + } + // Spawn in a separate thread to avoid blocking the main game loop + std::thread::spawn(|| { + use rodio::{OutputStream, Sink}; + // Try to get an output stream, but don't fail if audio isn't available + let Ok((_stream, stream_handle)) = OutputStream::try_default() else { + return; }; - // Generate a richer sound with harmonics - // Fundamental + 2nd harmonic (octave) + 3rd harmonic (fifth) - let fundamental_wave = (t * fundamental * 2.0 * std::f64::consts::PI).sin(); - let harmonic2 = (t * fundamental * 2.0 * 2.0 * std::f64::consts::PI).sin() * 0.3; - let harmonic3 = (t * fundamental * 2.0 * 3.0 * std::f64::consts::PI).sin() * 0.15; - - // Combine harmonics with envelope - let sample = (fundamental_wave + harmonic2 + harmonic3) * envelope * 0.25; - - // Convert to i16 sample - samples.push((sample * i16::MAX as f64).clamp(i16::MIN as f64, i16::MAX as f64) as i16); - } + // Create a sink to play the sound + let Ok(sink) = Sink::try_new(&stream_handle) else { + return; + }; - // Convert to a source that rodio can play - let source = rodio::buffer::SamplesBuffer::new(1, sample_rate, samples); - sink.append(source); - sink.sleep_until_end(); - }); + // Generate a pleasant wood-like click sound + // Using a lower fundamental frequency with harmonics for a richer sound + let sample_rate = 44100; + let duration = 0.08; // 80 milliseconds - slightly longer for better perception + let fundamental = 200.0; // Lower frequency for a more pleasant, less harsh sound + + let num_samples = (sample_rate as f64 * duration) as usize; + let mut samples = Vec::with_capacity(num_samples); + + for i in 0..num_samples { + let t = i as f64 / sample_rate as f64; + + // Create a more sophisticated envelope with exponential decay + // Quick attack, smooth decay - like a wood piece being placed + let envelope = if t < duration * 0.1 { + // Quick attack (10% of duration) + (t / (duration * 0.1)).powf(0.5) + } else { + // Exponential decay + let decay_start = duration * 0.1; + let decay_time = t - decay_start; + let decay_duration = duration - decay_start; + (-decay_time * 8.0 / decay_duration).exp() + }; + + // Generate a richer sound with harmonics + // Fundamental + 2nd harmonic (octave) + 3rd harmonic (fifth) + let fundamental_wave = (t * fundamental * 2.0 * std::f64::consts::PI).sin(); + let harmonic2 = (t * fundamental * 2.0 * 2.0 * std::f64::consts::PI).sin() * 0.3; + let harmonic3 = (t * fundamental * 2.0 * 3.0 * std::f64::consts::PI).sin() * 0.15; + + // Combine harmonics with envelope + let sample = (fundamental_wave + harmonic2 + harmonic3) * envelope * 0.25; + + // Convert to i16 sample + samples.push( + (sample * i16::MAX as f64).clamp(i16::MIN as f64, i16::MAX as f64) as i16, + ); + } + + // Convert to a source that rodio can play + let source = rodio::buffer::SamplesBuffer::new(1, sample_rate, samples); + sink.append(source); + sink.sleep_until_end(); + }); + } } /// Plays a light navigation sound when moving through menu items. /// This generates a subtle, high-pitched "tick" sound for menu navigation. pub fn play_menu_nav_sound() { - if !is_sound_enabled() { - return; - } - // Spawn in a separate thread to avoid blocking the main game loop - std::thread::spawn(|| { - // Try to get an output stream, but don't fail if audio isn't available - let Ok((_stream, stream_handle)) = OutputStream::try_default() else { + #[cfg(feature = "sound")] + { + if !is_sound_enabled() { return; - }; - - // Create a sink to play the sound - let Ok(sink) = Sink::try_new(&stream_handle) else { - return; - }; - - // Generate a light, high-pitched tick sound for menu navigation - let sample_rate = 44100; - let duration = 0.04; - let frequency = 600.0; - - let num_samples = (sample_rate as f64 * duration) as usize; - let mut samples = Vec::with_capacity(num_samples); - - for i in 0..num_samples { - let t = i as f64 / sample_rate as f64; - - let envelope = if t < duration * 0.2 { - (t / (duration * 0.2)).powf(0.3) - } else { - let decay_start = duration * 0.2; - let decay_time = t - decay_start; - let decay_duration = duration - decay_start; - (-decay_time * 12.0 / decay_duration).exp() + } + // Spawn in a separate thread to avoid blocking the main game loop + std::thread::spawn(|| { + use rodio::{OutputStream, Sink}; + // Try to get an output stream, but don't fail if audio isn't available + let Ok((_stream, stream_handle)) = OutputStream::try_default() else { + return; }; - let sample = (t * frequency * 2.0 * std::f64::consts::PI).sin() * envelope * 0.3; - - samples.push((sample * i16::MAX as f64).clamp(i16::MIN as f64, i16::MAX as f64) as i16); - } + // Create a sink to play the sound + let Ok(sink) = Sink::try_new(&stream_handle) else { + return; + }; - // Convert to a source that rodio can play - let source = rodio::buffer::SamplesBuffer::new(1, sample_rate, samples); - sink.append(source); - sink.sleep_until_end(); - }); + // Generate a light, high-pitched tick sound for menu navigation + let sample_rate = 44100; + let duration = 0.04; + let frequency = 600.0; + + let num_samples = (sample_rate as f64 * duration) as usize; + let mut samples = Vec::with_capacity(num_samples); + + for i in 0..num_samples { + let t = i as f64 / sample_rate as f64; + + let envelope = if t < duration * 0.2 { + (t / (duration * 0.2)).powf(0.3) + } else { + let decay_start = duration * 0.2; + let decay_time = t - decay_start; + let decay_duration = duration - decay_start; + (-decay_time * 12.0 / decay_duration).exp() + }; + + let sample = (t * frequency * 2.0 * std::f64::consts::PI).sin() * envelope * 0.3; + + samples.push( + (sample * i16::MAX as f64).clamp(i16::MIN as f64, i16::MAX as f64) as i16, + ); + } + + // Convert to a source that rodio can play + let source = rodio::buffer::SamplesBuffer::new(1, sample_rate, samples); + sink.append(source); + sink.sleep_until_end(); + }); + } } diff --git a/src/ui/main_ui.rs b/src/ui/main_ui.rs index 35980c7..3d25ef4 100644 --- a/src/ui/main_ui.rs +++ b/src/ui/main_ui.rs @@ -257,7 +257,8 @@ pub fn render_menu_ui(frame: &mut Frame, app: &App, main_area: Rect) { format!("Skin: {skin_name}") }; - // Determine the "sound" text + // Determine the "sound" text (only if sound feature is enabled) + #[cfg(feature = "sound")] let sound_menu = { let sound_status = if app.sound_enabled { "On 🔊" @@ -268,19 +269,27 @@ pub fn render_menu_ui(frame: &mut Frame, app: &App, main_area: Rect) { }; // Menu items with descriptions - let menu_items: Vec<(&str, &str)> = vec![ + let mut menu_items: Vec<(&str, &str)> = vec![ ("Local game", "Practice mode - play against yourself"), ("Multiplayer", "Play with friends over network"), ("Lichess Online", "Play on Lichess.org"), ("Play Bot", "Challenge a chess engine"), (&display_mode_menu, "Change display theme"), - (&sound_menu, "Toggle sound effects"), + ]; + + // Add sound menu item only if sound feature is enabled + #[cfg(feature = "sound")] + { + menu_items.push((&sound_menu, "Toggle sound effects")); + } + + menu_items.extend(vec![ ("Help", "View keyboard shortcuts and controls"), ("About", "Project information and credits"), - ]; + ]); - // Menu has 8 items, each takes 3 lines (item + description/empty + spacing), plus padding - const MENU_HEIGHT: u16 = 8 * 3 + 4; + // Menu height depends on number of items, each takes 3 lines (item + description/empty + spacing), plus padding + let menu_height = menu_items.len() as u16 * 3 + 4; let main_layout_horizontal = Layout::default() .direction(Direction::Vertical) @@ -289,7 +298,7 @@ pub fn render_menu_ui(frame: &mut Frame, app: &App, main_area: Rect) { Constraint::Ratio(1, 5), // Title Constraint::Length(1), // Subtitle Constraint::Min(0), // Flexible space above menu - Constraint::Length(MENU_HEIGHT), // Menu (fixed height) + Constraint::Length(menu_height), // Menu (fixed height) Constraint::Min(0), // Flexible space below menu Constraint::Ratio(1, 10), // Footer/hints ] diff --git a/website/docs/Configuration/intro.md b/website/docs/Configuration/intro.md index b165f56..7a14a5d 100644 --- a/website/docs/Configuration/intro.md +++ b/website/docs/Configuration/intro.md @@ -27,6 +27,9 @@ chess-tui -e "/opt/homebrew/bin/gnuchess --uci" # Set bot thinking depth chess-tui --depth 15 +# Disable sound effects +chess-tui --no-sound + # Combine both options chess-tui -e /path/to/engine --depth 15 chess-tui -e "/opt/homebrew/bin/gnuchess --uci" --depth 15 @@ -57,6 +60,9 @@ log_level = "OFF" # Bot thinking depth for chess engine (1-255, default: 10) bot_depth = 10 + +# Enable or disable sound effects (default: true) +sound_enabled = true ``` CONFIG_DIR is typically: diff --git a/website/docs/Installation/Build from source.md b/website/docs/Installation/Build from source.md index 5e32b36..cb6de49 100644 --- a/website/docs/Installation/Build from source.md +++ b/website/docs/Installation/Build from source.md @@ -14,3 +14,19 @@ cargo build --release ./target/release/chess-tui ``` + +## Building without sound features + +If you want to build **chess-tui** without sound support (useful for environments without audio support, like Docker containers or headless servers), you can disable the sound feature: + +```bash +git clone https://github.com/thomas-mauran/chess-tui +cd chess-tui +cargo build --no-default-features + +./target/debug/chess-tui +``` + +:::note +By default, **chess-tui** includes sound features. Use `--no-default-features` to build without sound support. +::: diff --git a/website/docs/Installation/Cargo.md b/website/docs/Installation/Cargo.md index ac7159b..53f23b3 100644 --- a/website/docs/Installation/Cargo.md +++ b/website/docs/Installation/Cargo.md @@ -14,4 +14,16 @@ cargo install chess-tui chess-tui ``` -The package is available on [crates.io](https://crates.io/crates/chess-tui) :tada: \ No newline at end of file +The package is available on [crates.io](https://crates.io/crates/chess-tui) :tada: + +## Installing without sound features + +If you want to install **chess-tui** without sound support (useful for environments without audio support), you can disable the sound feature: + +```bash +cargo install chess-tui --no-default-features +``` + +:::note +By default, **chess-tui** includes sound features. Use `--no-default-features` to install without sound support. +::: \ No newline at end of file diff --git a/website/docs/Installation/Cargod.md b/website/docs/Installation/Cargod.md deleted file mode 100644 index ac7159b..0000000 --- a/website/docs/Installation/Cargod.md +++ /dev/null @@ -1,17 +0,0 @@ -# Cargo - -**Chess-tui** can be installed with cargo, the Rust package manager. - - -## Installation - -```bash -cargo install chess-tui -``` - -**Then run the game with:** -```bash -chess-tui -``` - -The package is available on [crates.io](https://crates.io/crates/chess-tui) :tada: \ No newline at end of file