use std::collections::HashMap;
use crate::codec::Decoder;
use crate::error::RoxResult;
use crate::model::{Hitsound, Metadata, Note, RoxChart, TimingPoint};
use super::parser;
use super::types::OsuBeatmap;
pub struct OsuDecoder;
impl OsuDecoder {
#[must_use]
pub fn from_beatmap(beatmap: &OsuBeatmap) -> RoxChart {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let key_count = beatmap.difficulty.circle_size as u8;
let mut chart = RoxChart::new(key_count);
chart.metadata = Metadata {
#[allow(clippy::cast_sign_loss)]
chart_id: beatmap.metadata.beatmap_id.map(|id| id as u64),
#[allow(clippy::cast_sign_loss)]
chartset_id: beatmap.metadata.beatmap_set_id.map(|id| id as u64),
key_count,
title: beatmap
.metadata
.title_unicode
.clone()
.unwrap_or_else(|| beatmap.metadata.title.clone())
.into(),
artist: beatmap
.metadata
.artist_unicode
.clone()
.unwrap_or_else(|| beatmap.metadata.artist.clone())
.into(),
creator: beatmap.metadata.creator.clone().into(),
difficulty_name: beatmap.metadata.version.clone().into(),
difficulty_value: Some(beatmap.difficulty.overall_difficulty),
audio_file: beatmap.general.audio_filename.clone().into(),
background_file: beatmap.background.clone().map(Into::into),
audio_offset_us: i64::from(beatmap.general.audio_lead_in) * 1000,
preview_time_us: if beatmap.general.preview_time > 0 {
i64::from(beatmap.general.preview_time) * 1000
} else {
0
},
source: beatmap.metadata.source.clone().map(Into::into),
tags: beatmap
.metadata
.tags
.iter()
.map(|s| s.clone().into())
.collect(),
..Default::default()
};
for tp in &beatmap.timing_points {
#[allow(clippy::cast_possible_truncation)]
let time_us = (tp.time * 1000.0) as i64;
if tp.uninherited {
if let Some(bpm) = tp.bpm() {
let mut timing = TimingPoint::bpm(time_us, bpm);
timing.signature = tp.meter;
chart.timing_points.push(timing);
}
} else {
let sv = tp.scroll_velocity();
chart.timing_points.push(TimingPoint::sv(time_us, sv));
}
}
let mut hitsound_map: HashMap<String, u16> = HashMap::new();
for ho in &beatmap.hit_objects {
let column = ho.column(key_count);
let time_us = i64::from(ho.time) * 1000;
let mut note = if ho.is_hold() {
let duration_us = i64::from(ho.duration_ms()) * 1000;
Note::hold(time_us, duration_us, column)
} else {
Note::tap(time_us, column)
};
if !ho.extras.is_empty() {
let parts: Vec<&str> = ho.extras.split(':').collect();
let filename_idx = if ho.is_hold() { 5 } else { 4 };
if let Some(&filename) = parts.get(filename_idx) {
let filename = filename.trim();
if !filename.is_empty() {
let hitsound_index = if let Some(&idx) = hitsound_map.get(filename) {
idx
} else {
let volume_idx = if ho.is_hold() { 4 } else { 3 };
let volume: Option<u8> = parts
.get(volume_idx)
.and_then(|v| v.parse().ok())
.filter(|&v| v > 0 && v <= 100);
let hitsound = if let Some(vol) = volume {
Hitsound::with_volume(filename, vol)
} else {
Hitsound::new(filename)
};
#[allow(clippy::cast_possible_truncation)]
let idx = chart.hitsounds.len() as u16;
chart.hitsounds.push(hitsound);
hitsound_map.insert(filename.to_string(), idx);
idx
};
note.hitsound_index = Some(hitsound_index);
}
}
}
chart.notes.push(note);
}
chart.notes.sort_by_key(|n| n.time_us);
chart
}
}
impl Decoder for OsuDecoder {
fn decode(data: &[u8]) -> RoxResult<RoxChart> {
let beatmap = parser::parse(data)?;
if beatmap.general.mode != 3 {
return Err(crate::error::RoxError::InvalidFormat(format!(
"Not a mania beatmap (mode={}, expected 3)",
beatmap.general.mode
)));
}
Ok(Self::from_beatmap(&beatmap))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::Decoder;
#[test]
fn test_decode_sample_7k() {
let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
let chart = <OsuDecoder as Decoder>::decode(&data).expect("Failed to decode");
assert_eq!(chart.key_count(), 7);
assert!(!chart.notes.is_empty());
assert!(!chart.timing_points.is_empty());
assert_eq!(chart.metadata.difficulty_name, "7K Awakened");
assert_eq!(chart.metadata.creator, "arcwinolivirus");
}
#[test]
fn test_decode_metadata() {
let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
assert!(chart.metadata.title.contains("宙の旋律") || chart.metadata.title.contains("Sora"));
assert!(!chart.metadata.audio_file.is_empty());
assert!(chart.metadata.background_file.is_some());
}
#[test]
fn test_decode_timing_points() {
let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
let bpm_points: Vec<_> = chart
.timing_points
.iter()
.filter(|tp| !tp.is_inherited)
.collect();
assert!(!bpm_points.is_empty());
let first_bpm = &bpm_points[0];
assert!((first_bpm.bpm - 186.0).abs() < 1.0);
}
#[test]
fn test_decode_notes_sorted() {
let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
for window in chart.notes.windows(2) {
assert!(window[0].time_us <= window[1].time_us);
}
}
#[test]
fn test_decode_hitsounds() {
let data = crate::test_utils::get_test_asset("osu/mania_hitsound.osu");
let chart = <OsuDecoder as Decoder>::decode(&data).expect("Failed to decode");
assert_eq!(chart.key_count(), 4);
assert_eq!(chart.hitsounds.len(), 4);
let notes_with_hs = chart
.notes
.iter()
.filter(|n| n.hitsound_index.is_some())
.count();
assert_eq!(notes_with_hs, 276);
let hs_files: Vec<&str> = chart.hitsounds.iter().map(|h| h.file.as_str()).collect();
assert!(hs_files.contains(&"RimShot.wav"));
assert!(hs_files.contains(&"KICK 2.wav"));
}
}