use clap::Parser;
use console::{Key, Term};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use std::time::Duration;
use xmrs::core::duration::ModuleDuration;
use xmrs::prelude::*;
use xmrsplayer::prelude::*;
fn format_mm_ss(d: Duration) -> String {
let total = d.as_secs();
format!("{:02}:{:02}", total / 60, total % 60)
}
macro_rules! tprintln {
() => {{
use std::io::Write as _;
let _ = std::io::stdout().write_all(b"\r\n");
}};
($($arg:tt)*) => {{
use std::io::Write as _;
print!($($arg)*);
let _ = std::io::stdout().write_all(b"\r\n");
}};
}
fn restore_terminal() {
#[cfg(unix)]
{
let _ = std::process::Command::new("stty").arg("sane").status();
}
}
struct ExitOnSongEnd {
flag: Arc<AtomicBool>,
}
impl PlayerObserver for ExitOnSongEnd {
fn on_row(&mut self, _ctx: &RowContext<'_>) {}
fn on_song_end(&mut self) {
self.flag.store(true, Ordering::Relaxed);
}
}
#[cfg(feature = "import_sid")]
use xmrs::tracker::import::sid::sid_module::SidModule;
#[derive(Parser)]
struct Cli {
#[cfg_attr(
feature = "import_sid",
arg(
short = 'f',
long,
required_unless_present = "sid_test_player",
value_name = "filename"
)
)]
#[cfg_attr(
not(feature = "import_sid"),
arg(short = 'f', long, required = true, value_name = "filename")
)]
filename: Option<String>,
#[arg(short = 's', long, default_value = "1")]
song: usize,
#[arg(short = 'o', long, value_name = "output filename")]
output: Option<String>,
#[arg(short = 'a', long, default_value = "1.0")]
amplification: f32,
#[arg(short = 'c', long, default_value = "0")]
ch: u8,
#[arg(short = 'd', long, default_value = "false")]
debug: bool,
#[arg(short = 'l', long, default_value = "0")]
loops: usize,
#[arg(short = 'p', long, default_value = "0")]
position: usize,
#[arg(short = 'e', long, default_value = "0")]
speed: usize,
#[arg(short = 'i', long, value_name = "index")]
instrument: Option<usize>,
#[arg(short = 'n', long, default_value = "48")]
note: u8,
#[arg(short = 'D', long, default_value = "false")]
duration: bool,
#[cfg(feature = "import_sid")]
#[arg(
long = "sid",
value_name = "name|list",
num_args = 0..=1,
default_missing_value = "list"
)]
sid_test_player: Option<String>,
}
#[cfg(feature = "import_sid")]
type SidTune = (&'static str, fn() -> SidModule);
#[cfg(feature = "import_sid")]
const SID_REGISTRY: &[SidTune] = &[
("commando", SidModule::get_sid_commando),
("crazy_comets", SidModule::get_sid_crazy_comets),
("last_v8", SidModule::get_sid_last_v8),
("monty", SidModule::get_sid_monty_on_the_run),
("thing_on_a_spring", SidModule::get_sid_thing_on_a_spring),
("zoid", SidModule::get_sid_zoid),
("ace_2", SidModule::get_sid_ace_2),
("delta", SidModule::get_sid_delta),
("human_race", SidModule::get_sid_human_race),
(
"international_karate",
SidModule::get_sid_international_karate,
),
("lightforce", SidModule::get_sid_lightforce),
("sanxion_song_1", SidModule::get_sid_sanxion_song_1),
("sanxion_song_2", SidModule::get_sid_sanxion_song_2),
("spellbound", SidModule::get_sid_spellbound),
("thrust", SidModule::get_sid_thrust),
];
#[cfg(feature = "import_sid")]
fn sid_play_one(cli: &Cli, name: &str, ctor: fn() -> SidModule) {
let modules = ctor().to_modules();
let leaked_modules: &'static [Module] = Box::leak(modules.into_boxed_slice());
let total = leaked_modules.len();
let Some(module_ref) = leaked_modules.get(cli.song) else {
eprintln!(
"error: --song {} out of range ({name} has {total} sub-song(s), 1..={total})",
cli.song + 1,
);
std::process::exit(2);
};
println!("SID tune: {name}");
println!(" Name : {}", module_ref.name);
println!(" Comment : {}", module_ref.comment);
println!(" Sub-song : {}/{}", cli.song + 1, total);
if cli.duration {
let d = module_ref.duration(0);
println!("{}", format_mm_ss(d));
return;
}
play_music(
module_ref,
0,
cli.amplification,
cli.position,
cli.loops,
cli.debug,
cli.ch,
cli.speed,
cli.output.clone(),
);
}
#[cfg(feature = "import_sid")]
fn sid_test_player(cli: &Cli, selector: &str) {
match selector {
"list" => {
println!("Available SID tunes (use --sid <name>):");
for (name, _) in SID_REGISTRY {
println!(" {name}");
}
}
name => match SID_REGISTRY.iter().find(|(n, _)| *n == name) {
Some((name, ctor)) => sid_play_one(cli, name, *ctor),
None => {
eprintln!("error: unknown SID tune '{name}'. Use --sid list to see all.");
std::process::exit(2);
}
},
}
}
fn main() -> Result<(), std::io::Error> {
let mut cli = Cli::parse();
if cli.song == 0 {
eprintln!(
"error: --song is counted from 1 (song 1 = first sub-song); \
0 is not a valid song number. Use -s 1 for the first song."
);
std::process::exit(2);
}
cli.song -= 1;
println!("--===~ XmRs Player Example ~===--");
println!("(c) 2023-2026 Sébastien Béchet\n");
println!("Because demo scene can't die :)\n");
#[cfg(feature = "import_sid")]
if let Some(selector) = cli.sid_test_player.clone() {
sid_test_player(&cli, &selector);
return Ok(());
}
if let Some(filename) = cli.filename {
println!("opening {}", filename);
let contents = std::fs::read(filename.trim())?;
match Module::load(&contents) {
Ok(mut module) => {
drop(contents); if module.origin == Some(xmrs::tracker::format::ModuleFormat::Dw) {
if let Some(stem) = std::path::Path::new(filename.trim())
.file_stem()
.and_then(|s| s.to_str())
{
module.name = stem.to_string();
}
}
if cli.duration {
let d = module.duration(cli.song);
println!("{}", format_mm_ss(d));
return Ok(());
}
if let Some(instr) = cli.instrument {
let pitch = xmrs::core::pitch::Pitch::try_from(cli.note)
.unwrap_or(xmrs::core::pitch::Pitch::C4);
let sec_per_row = (2.5 * module.default_tempo.max(1) as f64)
/ module.default_bpm.max(1) as f64;
let rows = (4.0 / sec_per_row).ceil().max(2.0) as usize;
if !xmrsplayer::sample_preview::install_instrument_audition(
&mut module,
instr,
pitch,
rows,
) {
eprintln!(
"error: --instrument {instr} out of range \
(module has {} instruments, 0..={})",
module.instrument.len(),
module.instrument.len().saturating_sub(1)
);
return Ok(());
}
cli.song = 0;
println!(
"Auditioning instrument {instr} at {:?} (note {})",
pitch, cli.note
);
}
let song_count = module
.timeline_map
.entries
.iter()
.map(|e| e.song as usize)
.max()
.map(|m| m + 1)
.unwrap_or(1);
let (song_speed, song_bpm) = module
.timeline_map
.entries
.iter()
.filter(|e| e.song as usize == cli.song && e.loop_iter == 0)
.min_by_key(|e| e.tick)
.map(|e| (e.speed_at_row as usize, e.bpm_at_row as usize))
.unwrap_or((module.default_tempo, module.default_bpm));
println!(
"Playing {} ! (song {}/{}, speed {}, bpm {})",
module.name,
cli.song + 1,
song_count,
song_speed,
song_bpm,
);
let module = Box::new(module);
let module_ref: &'static Module = Box::leak(module);
play_music(
module_ref,
cli.song,
cli.amplification,
cli.position,
cli.loops,
cli.debug,
cli.ch,
cli.speed,
cli.output.clone(),
);
}
Err(e) => {
println!("{:?}", e);
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn play_music(
module: &'static Module,
song: usize,
amplification: f32,
position: usize,
loops: usize,
debug: bool,
ch: u8,
speed: usize,
output: Option<String>,
) {
let host = cpal::default_host();
let device = host
.default_output_device()
.expect("no output device available");
let config = device
.default_output_config()
.expect("failed to get default output config");
let sample_rate = config.sample_rate();
let sample_rate_hz: u32 = sample_rate;
let player = Arc::new(Mutex::new(XmrsPlayer::new(module, sample_rate_hz, song)));
let player_summary = Arc::clone(&player);
let song_ended = Arc::new(AtomicBool::new(false));
{
let mut player_lock = player.lock().unwrap();
player_lock.set_amplification(Amplification::from_raw_q4_12(
((amplification * 4096.0)
.round()
.clamp(i16::MIN as f32, i16::MAX as f32)) as i16,
));
if debug {
tprintln!("Debug on");
tprintln!("Module format: {:?}", module.origin);
player_lock.add_observer(Box::new(DebugObserver::new()));
}
player_lock.add_observer(Box::new(ExitOnSongEnd {
flag: Arc::clone(&song_ended),
}));
if ch != 0 {
player_lock.mute_all(true);
player_lock.set_mute_channel((ch - 1).into(), false);
}
player_lock.set_max_loop_count(loops);
player_lock.goto(position, 0, speed);
}
if let Some(output) = output {
tprintln!("writing {}...", output);
write_wave(player, output.as_str()).unwrap();
} else {
let player_clone = Arc::clone(&player);
let stream = device
.build_output_stream(
&config.config(),
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let mut player_lock = player_clone.lock().unwrap();
data.iter_mut()
.zip(player_lock.by_ref()) .for_each(|(sample, value)| {
*sample = value as f32 / i16::MAX as f32;
});
},
|_: cpal::StreamError| {},
None,
)
.expect("failed to build output stream");
stream.play().expect("failed to play stream");
let (tx, rx) = mpsc::channel::<Key>();
thread::spawn(move || {
let stdout = Term::stdout();
while let Ok(key) = stdout.read_key() {
if tx.send(key).is_err() {
break;
}
}
});
tprintln!(
"Enter and i keys for info, Space for pause, left or right arrow to move, escape key to exit..."
);
let mut playing = true;
'event_loop: loop {
if song_ended.load(Ordering::Relaxed) {
tprintln!("Song finished.");
break 'event_loop;
}
let character = match rx.recv_timeout(Duration::from_millis(50)) {
Ok(k) => k,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break 'event_loop,
};
match character {
Key::Enter => {
let ti = player.lock().unwrap().get_current_table_index();
let p = player.lock().unwrap().get_current_pattern();
tprintln!("current table index:{:02x}, current pattern:{:02x}", ti, p);
}
Key::Escape | Key::Char('q') => {
tprintln!("Have a nice day!");
break 'event_loop;
}
Key::ArrowLeft => {
let i = player.lock().unwrap().get_current_table_index();
if i != 0 {
player.lock().unwrap().goto(i - 1, 0, 0);
}
}
Key::ArrowRight => {
let len = module.song_length(song);
let i = player.lock().unwrap().get_current_table_index();
if i + 1 < len {
player.lock().unwrap().goto(i + 1, 0, 0);
}
}
Key::Char(' ') => {
if playing {
tprintln!("Pause, press space to continue");
player.lock().unwrap().pause(true);
playing = false;
{
let player_lock = player.lock().unwrap();
let ti = player_lock.get_current_table_index();
let p = player_lock.get_current_pattern();
let row = player_lock.get_current_row();
tprintln!("Pattern [{:02X}]={:02X}, Row {:02X}", ti, p, row);
}
} else {
tprintln!("Playing");
player.lock().unwrap().pause(false);
playing = true;
}
}
Key::Char('i') => {
let player_lock = player.lock().unwrap();
tprintln!(
"name:{}\ncomment:{}",
player_lock.module.name,
player_lock.module.comment
);
tprintln!(
"speed={}, generated samples:{}, loop count:{}",
player_lock.get_tempo(),
player_lock.generated_samples(),
player_lock.get_loop_count()
);
let actual_frames = player_lock.generated_samples();
let sr = player_lock.get_sample_rate();
let actual = Duration::from_secs(actual_frames / sr as u64);
let theoretical = module.duration(song);
tprintln!(
"Duration: {} / {}",
format_mm_ss(actual),
format_mm_ss(theoretical),
);
for (i, instr) in player_lock.module.instrument.iter().enumerate() {
if !instr.name.is_empty() {
tprintln!("instrument {:2}: {}", i, instr.name);
}
}
}
_ => {}
}
}
}
let (actual_frames, sample_rate_hz_actual) = {
let lock = player_summary.lock().unwrap();
(lock.generated_samples(), lock.get_sample_rate())
};
let actual = Duration::from_secs(actual_frames / sample_rate_hz_actual as u64);
let theoretical = module.duration(song);
tprintln!(
"Duration: {} / {}",
format_mm_ss(actual),
format_mm_ss(theoretical),
);
restore_terminal();
}
use hound::{SampleFormat, WavSpec, WavWriter};
fn write_wave(
amp: Arc<Mutex<XmrsPlayer>>,
output_file: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let spec = WavSpec {
channels: 2,
sample_rate: 44100,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(output_file, spec)?;
amp.lock().unwrap().set_max_loop_count(1);
let player_clone = Arc::clone(&);
let mut player_lock = player_clone.lock().unwrap();
for sample in player_lock.by_ref() {
writer.write_sample(sample)?;
}
writer.finalize()?;
Ok(())
}