Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
_ => {}
}
}
Expand Down
16 changes: 14 additions & 2 deletions src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ struct Args {
/// Lichess API token
#[arg(short, long)]
lichess_token: Option<String>,
/// Disable sound effects
#[arg(long)]
no_sound: bool,
}

fn main() -> AppResult<()> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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();
Expand Down
222 changes: 121 additions & 101 deletions src/sound.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use rodio::{OutputStream, Sink};
use std::sync::atomic::{AtomicBool, Ordering};

// Global sound enabled state
Expand All @@ -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
Expand All @@ -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();
});
}
}
Loading