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 once_cell::sync::Lazy;
use rodio::{ use rodio::{
Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, cpal::BufferSize, source::Amplify, Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, cpal::BufferSize, source::Amplify,
@@ -6,7 +7,8 @@ use std::{
collections::HashMap, collections::HashMap,
fs::read, fs::read,
io::{BufReader, Cursor}, io::{BufReader, Cursor},
sync::mpsc::Receiver, process::exit,
sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc::Receiver},
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -15,9 +17,15 @@ pub enum AudioCmd {
VolumeUp, VolumeUp,
VolumeDown, VolumeDown,
ChangePart(SoundrackParts), 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 { pub enum SoundrackParts {
Calm, Calm,
Buildup, Buildup,
@@ -25,35 +33,103 @@ pub enum SoundrackParts {
Outro, Outro,
} }
static PRELOADED_DEFAULT_SOUNDTRACK: Lazy<HashMap<SoundrackParts, Vec<u8>>> = Lazy::new(|| { type SoundtrackCache = HashMap<SoundrackParts, Vec<u8>>;
let mut map: HashMap<SoundrackParts, Vec<u8>> = HashMap::new();
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 { macro_rules! load {
($part:expr, $path:expr) => { ($part:ident, $file:expr) => {
map.insert($part, read($path).expect(concat!("cannot read ", $path))); 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!(Calm, "test.ogg"); // calm.ogg
load!(SoundrackParts::Buildup, "soundtrack/default/test.ogg"); // buildup.ogg load!(Buildup, "test.ogg"); // buildup.ogg
load!(SoundrackParts::Assault, "soundtrack/default/test.ogg"); // assault.ogg load!(Assault, "test.ogg"); // assault.ogg
load!(SoundrackParts::Outro, "soundtrack/default/test.ogg"); // outro.ogg load!(Outro, "test.ogg"); // outro.ogg
map cache
});
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)
} }
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() let mut handle: MixerDeviceSink = DeviceSinkBuilder::from_default_device()
.expect("get default device") .expect("get default device")
.with_buffer_size(BufferSize::Fixed(4096)) .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(); let mut volume: f32 = player.volume();
player.set_volume(if mute { 0.0 } else { volume }); player.set_volume(if mute { 0.0 } else { volume });
let mut current_soundtrack: Soundtrack = soundtrack;
let mut current_part: SoundrackParts = SoundrackParts::Calm; let mut current_part: SoundrackParts = SoundrackParts::Calm;
player.append(load_audio(&current_part)); player.append(load_audio(&current_soundtrack, &current_part));
loop { loop {
for cmd in rx.try_iter() { for cmd in rx.try_iter() {
@@ -85,14 +162,19 @@ pub fn handle_audio(rx: Receiver<AudioCmd>, mute: bool) {
} }
AudioCmd::ChangePart(part) => { AudioCmd::ChangePart(part) => {
current_part = 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(); player.skip_one();
} }
} }
} }
if player.empty() { 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 events;
pub mod handle_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 events::AppEvent;
pub use handle_events::handle_events; pub use handle_events::handle_events;
+17 -6
View File
@@ -1,5 +1,6 @@
use crate::app::{ use crate::app::{
states::{GameMode, PerkDecks, ZoomLevel}, states::{GameMode, PerkDecks, ZoomLevel},
threads::Soundtrack,
view::View, view::View,
}; };
use clap::{Error, Parser, error::ErrorKind, value_parser}; use clap::{Error, Parser, error::ErrorKind, value_parser};
@@ -133,13 +134,15 @@ pub struct Cli {
)] )]
pub skill_points_limit: u16, pub skill_points_limit: u16,
/// Enable logging to a file (default: disabled). /// Which soundtrack to play (default: default)
#[arg( #[arg(
long, long,
help = "Enable logging to file (default: disabled)", help = "Soundtrack",
default_value_t = false value_name = "...",
)] default_value_t = Soundtrack::Default,
pub log: bool, value_enum
)]
pub sound_track: Soundtrack,
/// Mute soundtrack (default: disabled). /// Mute soundtrack (default: disabled).
#[arg( #[arg(
@@ -148,6 +151,14 @@ pub struct Cli {
default_value_t = false default_value_t = false
)] )]
pub mute: bool, 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` /// 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 || { 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(); let mut terminal: Terminal<CrosstermBackend<Stdout>> = ratatui::init();