Add selectable soundtracks via CLI

Introduce a `Soundtrack` enum with Clap `ValueEnum` support and expose
it in the CLI as a `--sound-track` option. Extend `AudioCmd` with a
`ChangeSoundtrack` variant and refactor audio handling to load and cache
soundtracks per‑track using a thread‑safe `RwLock`. Update the
`handle_audio` function signature to accept the selected soundtrack,
adjust re‑exports, and modify `main` to pass the chosen soundtrack to
the audio thread. Also reposition the logging flag in the CLI
definition.
This commit is contained in:
2026-04-09 02:00:24 +02:00
parent 7ec3eec6e3
commit 633494ed46
4 changed files with 127 additions and 34 deletions
+108 -26
View File
@@ -1,3 +1,4 @@
use clap::ValueEnum;
use once_cell::sync::Lazy;
use rodio::{
Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, cpal::BufferSize, source::Amplify,
@@ -6,7 +7,8 @@ use std::{
collections::HashMap,
fs::read,
io::{BufReader, Cursor},
sync::mpsc::Receiver,
process::exit,
sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc::Receiver},
};
#[derive(Debug)]
@@ -15,9 +17,15 @@ pub enum AudioCmd {
VolumeUp,
VolumeDown,
ChangePart(SoundrackParts),
ChangeSoundtrack(Soundtrack),
}
#[derive(Debug, Eq, PartialEq, Hash)]
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, ValueEnum)]
pub enum Soundtrack {
Default,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy)]
pub enum SoundrackParts {
Calm,
Buildup,
@@ -25,35 +33,103 @@ pub enum SoundrackParts {
Outro,
}
static PRELOADED_DEFAULT_SOUNDTRACK: Lazy<HashMap<SoundrackParts, Vec<u8>>> = Lazy::new(|| {
let mut map: HashMap<SoundrackParts, Vec<u8>> = HashMap::new();
type SoundtrackCache = HashMap<SoundrackParts, Vec<u8>>;
static PRELOADED_SOUNDTRACKS: Lazy<RwLock<HashMap<Soundtrack, Arc<SoundtrackCache>>>> =
Lazy::new(|| {
let mut map: HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>> = HashMap::new();
map.insert(
Soundtrack::Default,
Arc::new(load_soundtrack_folder("default")),
);
RwLock::new(map)
});
fn load_soundtrack_folder(folder: &str) -> SoundtrackCache {
let base: String = format!("soundtrack/{}", folder);
let mut cache: HashMap<SoundrackParts, Vec<u8>> = HashMap::new();
macro_rules! load {
($part:expr, $path:expr) => {
map.insert($part, read($path).expect(concat!("cannot read ", $path)));
($part:ident, $file:expr) => {
let path = format!("{}/{}", base, $file);
cache.insert(
SoundrackParts::$part,
read(&path).expect(&format!("cannot read {}", path)),
);
};
}
load!(SoundrackParts::Calm, "soundtrack/default/test.ogg"); // calm.ogg
load!(SoundrackParts::Buildup, "soundtrack/default/test.ogg"); // buildup.ogg
load!(SoundrackParts::Assault, "soundtrack/default/test.ogg"); // assault.ogg
load!(SoundrackParts::Outro, "soundtrack/default/test.ogg"); // outro.ogg
load!(Calm, "test.ogg"); // calm.ogg
load!(Buildup, "test.ogg"); // buildup.ogg
load!(Assault, "test.ogg"); // assault.ogg
load!(Outro, "test.ogg"); // outro.ogg
map
});
fn load_audio(part: &SoundrackParts) -> Amplify<Decoder<BufReader<Cursor<Vec<u8>>>>> {
let data: &Vec<u8> = PRELOADED_DEFAULT_SOUNDTRACK
.get(part)
.expect("preloaded ogg not found");
let cursor: Cursor<Vec<u8>> = Cursor::new(data.clone());
let reader: BufReader<Cursor<Vec<u8>>> = BufReader::new(cursor);
Decoder::new(reader).expect("decoder issue").amplify(0.20)
cache
}
pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool) {
fn load_audio(
soundtrack: &Soundtrack,
part: &SoundrackParts,
) -> Amplify<Decoder<BufReader<Cursor<Vec<u8>>>>> {
{
let guard: RwLockReadGuard<'_, HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>> =
PRELOADED_SOUNDTRACKS
.read()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
return Decoder::new(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
}
}
{
let mut guard: RwLockWriteGuard<
'_,
HashMap<Soundtrack, Arc<HashMap<SoundrackParts, Vec<u8>>>>,
> = PRELOADED_SOUNDTRACKS
.write()
.map_err(|e| {
eprintln!("RwLock poisoned: {}", e);
exit(1);
})
.expect("lock issue");
if let Some(soundtrack_cache) = guard.get(soundtrack) {
let data: &Vec<u8> = soundtrack_cache.get(part).expect("part missing in cache");
return Decoder::new(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20);
}
let folder_name: &str = match soundtrack {
Soundtrack::Default => "default",
};
let new_cache: Arc<HashMap<SoundrackParts, Vec<u8>>> =
Arc::new(load_soundtrack_folder(folder_name));
let data: &Vec<u8> = new_cache.get(&part).expect("part missing after load");
guard.insert(*soundtrack, Arc::clone(&new_cache));
Decoder::new(BufReader::new(Cursor::new(data.clone())))
.expect("decoder issue")
.amplify(0.20)
}
}
pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool, soundtrack: Soundtrack) {
let mut handle: MixerDeviceSink = DeviceSinkBuilder::from_default_device()
.expect("get default device")
.with_buffer_size(BufferSize::Fixed(4096))
@@ -66,8 +142,9 @@ pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool) {
let mut volume: f32 = player.volume();
player.set_volume(if mute { 0.0 } else { volume });
let mut current_soundtrack: Soundtrack = soundtrack;
let mut current_part: SoundrackParts = SoundrackParts::Calm;
player.append(load_audio(&current_part));
player.append(load_audio(&current_soundtrack, &current_part));
loop {
for cmd in rx.try_iter() {
@@ -85,14 +162,19 @@ pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool) {
}
AudioCmd::ChangePart(part) => {
current_part = part;
player.append(load_audio(&current_part));
player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one();
}
AudioCmd::ChangeSoundtrack(st) => {
current_soundtrack = st;
player.append(load_audio(&current_soundtrack, &current_part));
player.skip_one();
}
}
}
if player.empty() {
player.append(load_audio(&current_part));
player.append(load_audio(&current_soundtrack, &current_part));
}
}
}
+1 -1
View File
@@ -2,6 +2,6 @@ pub mod audio;
pub mod events;
pub mod handle_events;
pub use audio::{AudioCmd, SoundrackParts, handle_audio};
pub use audio::{AudioCmd, SoundrackParts, Soundtrack, handle_audio};
pub use events::AppEvent;
pub use handle_events::handle_events;
+17 -6
View File
@@ -1,5 +1,6 @@
use crate::app::{
states::{GameMode, PerkDecks, ZoomLevel},
threads::Soundtrack,
view::View,
};
use clap::{Error, Parser, error::ErrorKind, value_parser};
@@ -133,13 +134,15 @@ pub struct Cli {
)]
pub skill_points_limit: u16,
/// Enable logging to a file (default: disabled).
/// Which soundtrack to play (default: default)
#[arg(
long,
help = "Enable logging to file (default: disabled)",
default_value_t = false
)]
pub log: bool,
long,
help = "Soundtrack",
value_name = "...",
default_value_t = Soundtrack::Default,
value_enum
)]
pub sound_track: Soundtrack,
/// Mute soundtrack (default: disabled).
#[arg(
@@ -148,6 +151,14 @@ pub struct Cli {
default_value_t = false
)]
pub mute: bool,
/// Enable logging to a file (default: disabled).
#[arg(
long,
help = "Enable logging to file (default: disabled)",
default_value_t = false
)]
pub log: bool,
}
/// Parses the command line arguments and returns a fully populated `Cli`
+1 -1
View File
@@ -44,7 +44,7 @@ fn main() -> Result<()> {
// });
thread::spawn(move || {
handle_audio(audio_rx, args.mute);
handle_audio(audio_rx, args.mute, args.sound_track);
});
let mut terminal: Terminal<CrosstermBackend<Stdout>> = ratatui::init();