use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use bms_rs::bms::prelude::*;
use bms_rs::chart::prelude::*;
use clap::Parser;
use gametime::{TimeSpan, TimeStamp};
use kira::{
AudioManager, AudioManagerSettings, Capacities, DefaultBackend,
sound::static_sound::StaticSoundData,
};
use macroquad::prelude::Color;
use macroquad::prelude::*;
use rayon::prelude::*;
use strict_num_extended::{FinF64, PositiveF64};
const DEFAULT_BPM: PositiveF64 = PositiveF64::new_const(120.0);
fn window_conf() -> Conf {
Conf {
window_title: "BMS Player".to_owned(),
platform: miniquad::conf::Platform {
linux_backend: miniquad::conf::LinuxBackend::WaylandWithX11Fallback,
..Default::default()
},
..Default::default()
}
}
#[macroquad::main(window_conf)]
async fn main() -> Result<(), String> {
let config = Config::parse();
if !config.chart_path.exists() {
return Err(format!("File not found: {}", config.chart_path.display()));
}
println!("Loading chart: {}", config.chart_path.display());
let (chart, base_bpm) = load_chart(&config.chart_path)?;
println!("Chart loaded successfully");
let base_path = config.chart_path.parent().unwrap_or_else(|| Path::new("."));
let reaction_time = TimeSpan::from_duration(Duration::from_millis(config.reaction_time_ms));
let visible_range = VisibleRangePerBpm::new(base_bpm.value(), reaction_time);
println!("Loading audio files...");
let audio_data_map = load_audio_files_parallel(chart.resources().wav_files(), base_path);
println!(
"Audio loading completed: {} files loaded",
audio_data_map.len()
);
let start_time = TimeStamp::now();
let mut chart_player = ChartPlayer::start(&chart, visible_range, start_time);
chart_player.set_visibility_range(FinF64::NEG_HALF..FinF64::ONE);
println!("Player started");
let mut audio_manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings {
capacities: Capacities {
sub_track_capacity: 512,
send_track_capacity: 16,
clock_capacity: 8,
modulator_capacity: 16,
listener_capacity: 8,
},
internal_buffer_size: 256,
..Default::default()
})
.map_err(|e| format!("Failed to initialize audio: {e}"))?;
println!("Audio system initialized");
let mut played_events = HashSet::new();
println!("Starting playback...");
let mut next_print_time = start_time;
let mut missed_sounds = 0u32;
loop {
let now = TimeStamp::now();
let events = chart_player.update(now);
if now >= next_print_time {
let state = chart_player.playback_state();
let elapsed = now
.checked_elapsed_since(start_time)
.unwrap_or(TimeSpan::ZERO);
println!(
"[Playback] Time: {:.1}s | BPM: {:.1} | Y: {:.2} | Speed: {:.2} | Scroll: {:.2} | Missed: {}",
elapsed.as_secs_f64(),
state.current_bpm.as_f64(),
state.progressed_y().as_f64(),
state.current_speed.as_f64(),
state.current_scroll.as_f64(),
missed_sounds,
);
next_print_time += TimeSpan::SECOND;
}
for event in &events {
let wav_id = match event.event() {
ChartEvent::Note { wav_id, .. } | ChartEvent::Bgm { wav_id } => wav_id,
_ => continue,
};
let Some(id) = wav_id else { continue };
let Some(audio) = audio_data_map.get(id) else {
continue;
};
if played_events.contains(&event.id()) {
continue;
}
if let Err(e) = audio_manager.play(audio.clone()) {
if matches!(e, kira::PlaySoundError::SoundLimitReached) {
missed_sounds += 1;
} else {
eprintln!("Failed to play audio: {e}");
}
} else {
played_events.insert(event.id());
}
}
macroquad::prelude::clear_background(COLOR_BG);
render_tracks();
render_notes(&mut chart_player, &events);
render_info(&chart_player);
macroquad::prelude::next_frame().await;
}
}
#[derive(Parser, Debug)]
#[command(name = "microquad_player")]
#[command(about = "A simple BMS/BMSON chart player", long_about = None)]
struct Config {
#[arg(value_name = "FILE")]
chart_path: PathBuf,
#[arg(short, long, default_value = "550", value_name = "MILLISECONDS")]
reaction_time_ms: u64,
}
fn load_chart(path: &Path) -> Result<(Chart, BaseBpm), String> {
let bytes = std::fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?;
let content = {
let bytes: &[u8] = &bytes;
encoding_rs::SHIFT_JIS.decode(bytes).0.to_string()
};
let extension = path
.extension()
.and_then(|e| e.to_str())
.ok_or("Invalid file extension")?;
let (chart, base_bpm) = match extension.to_lowercase().as_str() {
"bms" | "bme" | "bml" | "pms" => {
let output = parse_bms(&content, default_config());
let bms = output.bms.map_err(|e| format!("Parse error: {e:?}"))?;
let base_bpm = StartBpmGenerator
.generate(&bms)
.unwrap_or(BaseBpm::new(DEFAULT_BPM));
let chart = BmsProcessor::parse::<KeyLayoutBeat>(&bms)
.map_err(|e| format!("Failed to parse chart: {e}"))?;
(chart, base_bpm)
}
"bmson" => {
#[cfg(feature = "bmson")]
{
let bmson =
serde_json::from_str(&content).map_err(|e| format!("JSON parse error: {e}"))?;
let base_bpm = StartBpmGenerator
.generate(&bmson)
.unwrap_or(BaseBpm::new(DEFAULT_BPM));
let chart = BmsonProcessor::parse(&bmson);
(chart, base_bpm)
}
#[cfg(not(feature = "bmson"))]
return Err("BMSON feature not enabled".to_string());
}
_ => return Err(format!("Unsupported format: {extension}")),
};
Ok((chart, base_bpm))
}
fn find_audio_with_extensions(path: &Path, extensions: &[&str]) -> Option<PathBuf> {
let stem = path.with_extension("");
for ext in extensions {
let candidate = stem.with_extension(ext);
if candidate.exists() {
return Some(candidate);
}
}
None
}
fn load_audio_files_parallel(
audio_files: &HashMap<WavId, PathBuf>,
base_path: &Path,
) -> HashMap<WavId, StaticSoundData> {
audio_files
.par_iter()
.filter_map(|(wav_id, path)| {
let full_path = base_path.join(path);
let found_path =
find_audio_with_extensions(&full_path, &["ogg", "flac", "wav", "mp3"])?;
match StaticSoundData::from_file(&found_path)
.map_err(|e| format!("Failed to load audio: {e}"))
{
Ok(data) => Some((*wav_id, data)),
Err(e) => {
eprintln!(
"Warning: Failed to load audio {} (ID: {wav_id:?}): {e}",
found_path.display()
);
None
}
}
})
.collect()
}
const SCREEN_WIDTH: f32 = 800.0;
const SCREEN_HEIGHT: f32 = 600.0;
const TRACK_COUNT: usize = 8;
const TRACK_WIDTH: f32 = 60.0;
const TRACK_SPACING: f32 = 5.0;
const TOTAL_TRACKS_WIDTH: f32 = TRACK_COUNT as f32 * (TRACK_WIDTH + TRACK_SPACING);
const JUDGMENT_LINE_Y: f32 = 500.0;
const COLOR_BG: Color = Color::from_rgba(30, 30, 30, 255);
const COLOR_TRACK_LINE: Color = Color::from_rgba(100, 100, 100, 255);
const COLOR_JUDGMENT_LINE: Color = Color::from_rgba(255, 255, 255, 255);
const COLOR_BAR_LINE: Color = Color::from_rgba(80, 80, 80, 180);
const COLOR_NOTE_WHITE: Color = Color::from_rgba(255, 255, 255, 255);
const COLOR_NOTE_BLUE: Color = Color::from_rgba(100, 149, 237, 255);
const COLOR_NOTE_SCRATCH: Color = Color::from_rgba(255, 0, 0, 255);
const COLOR_NOTE_MINE: Color = Color::from_rgba(255, 255, 0, 255);
#[must_use]
const fn supported_keys() -> [Key; 8] {
[
Key::Scratch(1),
Key::Key(1),
Key::Key(2),
Key::Key(3),
Key::Key(4),
Key::Key(5),
Key::Key(6),
Key::Key(7),
]
}
#[must_use]
const fn key_to_index(key: Key) -> Option<usize> {
match key {
Key::Scratch(1) => Some(0),
Key::Key(1) => Some(1),
Key::Key(2) => Some(2),
Key::Key(3) => Some(3),
Key::Key(4) => Some(4),
Key::Key(5) => Some(5),
Key::Key(6) => Some(6),
Key::Key(7) => Some(7),
_ => None,
}
}
#[must_use]
fn track_x(key: Key) -> Option<f32> {
let start_x = (SCREEN_WIDTH - TOTAL_TRACKS_WIDTH) / 2.0;
let index = key_to_index(key)?;
Some(start_x + index as f32 * (TRACK_WIDTH + TRACK_SPACING))
}
fn render_tracks() {
let start_x = (SCREEN_WIDTH - TOTAL_TRACKS_WIDTH) / 2.0;
for key in supported_keys() {
let x = track_x(key).expect("supported keys should have valid track position");
macroquad::prelude::draw_line(x, 0.0, x, SCREEN_HEIGHT, 2.0, COLOR_TRACK_LINE);
macroquad::prelude::draw_line(
x + TRACK_WIDTH,
0.0,
x + TRACK_WIDTH,
SCREEN_HEIGHT,
1.0,
COLOR_TRACK_LINE,
);
}
macroquad::prelude::draw_line(
start_x - 10.0,
JUDGMENT_LINE_Y,
start_x + TOTAL_TRACKS_WIDTH + 10.0,
JUDGMENT_LINE_Y,
3.0,
COLOR_JUDGMENT_LINE,
);
}
fn render_notes(player: &mut ChartPlayer, _events: &[PlayheadEvent]) {
let visible = player.visible_events();
for (event, display_range) in visible {
if matches!(event.event(), ChartEvent::BarLine) {
let ratio = display_range.start().value().as_f64();
let y = JUDGMENT_LINE_Y - (ratio as f32 * JUDGMENT_LINE_Y);
let start_x = (SCREEN_WIDTH - TOTAL_TRACKS_WIDTH) / 2.0;
macroquad::prelude::draw_line(
start_x,
y,
start_x + TOTAL_TRACKS_WIDTH,
y,
2.0,
COLOR_BAR_LINE,
);
continue;
}
if let ChartEvent::Note { key, kind, .. } = event.event() {
let x = match track_x(*key) {
Some(x) => x,
None => continue,
};
let ratio_start = display_range.start().value().as_f64();
let ratio_end = display_range.end().value().as_f64();
let y_start = JUDGMENT_LINE_Y - (ratio_start as f32 * JUDGMENT_LINE_Y);
let y_end = JUDGMENT_LINE_Y - (ratio_end as f32 * JUDGMENT_LINE_Y);
let color = match key {
Key::Scratch(_) => COLOR_NOTE_SCRATCH,
Key::Key(n) if n % 2 == 1 => COLOR_NOTE_WHITE,
Key::Key(_) => COLOR_NOTE_BLUE,
_ => COLOR_NOTE_WHITE,
};
match kind {
NoteKind::Visible | NoteKind::Invisible => {
let note_height = 10.0;
macroquad::prelude::draw_rectangle(
x + 2.0,
y_start - note_height,
TRACK_WIDTH - 4.0,
note_height,
color,
);
}
NoteKind::Long => {
let height = y_start - y_end;
macroquad::prelude::draw_rectangle(
x + 2.0,
y_end,
TRACK_WIDTH - 4.0,
height,
color,
);
macroquad::prelude::draw_rectangle(
x + 2.0,
y_start - 5.0,
TRACK_WIDTH - 4.0,
5.0,
color,
);
}
NoteKind::Landmine => {
macroquad::prelude::draw_circle(
x + TRACK_WIDTH / 2.0,
y_start,
TRACK_WIDTH / 3.0,
COLOR_NOTE_MINE,
);
}
}
}
}
}
fn render_info(player: &ChartPlayer) {
let state = player.playback_state();
let bpm = state.current_bpm.as_f64();
macroquad::prelude::draw_text(
&format!("BPM: {bpm:.1}"),
10.0,
20.0,
20.0,
Color::from_rgba(255, 255, 255, 255),
);
let y = state.progressed_y().as_f64();
macroquad::prelude::draw_text(
&format!("Position: {y:.2}"),
10.0,
50.0,
20.0,
Color::from_rgba(255, 255, 255, 255),
);
macroquad::prelude::draw_text(
"7+1k Layout: S | 1 | 2 | 3 | 4 | 5 | 6 | 7",
10.0,
SCREEN_HEIGHT - 30.0,
16.0,
Color::from_rgba(128, 128, 128, 255),
);
}