rhythm_open_exchange/codec/formats/osu/
decoder.rs1use std::collections::HashMap;
4
5use crate::codec::Decoder;
6use crate::error::RoxResult;
7use crate::model::{Hitsound, Metadata, Note, RoxChart, TimingPoint};
8
9use super::parser;
10use super::types::OsuBeatmap;
11
12pub struct OsuDecoder;
14
15impl OsuDecoder {
16 #[must_use]
18 pub fn from_beatmap(beatmap: &OsuBeatmap) -> RoxChart {
19 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
21 let key_count = beatmap.difficulty.circle_size as u8;
22 let mut chart = RoxChart::new(key_count);
23
24 chart.metadata = Metadata {
26 #[allow(clippy::cast_sign_loss)]
28 chart_id: beatmap.metadata.beatmap_id.map(|id| id as u64),
29 #[allow(clippy::cast_sign_loss)]
30 chartset_id: beatmap.metadata.beatmap_set_id.map(|id| id as u64),
31 key_count,
32 title: beatmap
33 .metadata
34 .title_unicode
35 .clone()
36 .unwrap_or_else(|| beatmap.metadata.title.clone())
37 .into(),
38 artist: beatmap
39 .metadata
40 .artist_unicode
41 .clone()
42 .unwrap_or_else(|| beatmap.metadata.artist.clone())
43 .into(),
44 creator: beatmap.metadata.creator.clone().into(),
45 difficulty_name: beatmap.metadata.version.clone().into(),
46 difficulty_value: Some(beatmap.difficulty.overall_difficulty),
47 audio_file: beatmap.general.audio_filename.clone().into(),
48 background_file: beatmap.background.clone().map(Into::into),
49 audio_offset_us: i64::from(beatmap.general.audio_lead_in) * 1000,
50 preview_time_us: if beatmap.general.preview_time > 0 {
51 i64::from(beatmap.general.preview_time) * 1000
52 } else {
53 0
54 },
55 source: beatmap.metadata.source.clone().map(Into::into),
56 tags: beatmap
57 .metadata
58 .tags
59 .iter()
60 .map(|s| s.clone().into())
61 .collect(),
62 ..Default::default()
63 };
64
65 for tp in &beatmap.timing_points {
67 #[allow(clippy::cast_possible_truncation)]
69 let time_us = (tp.time * 1000.0) as i64;
70
71 if tp.uninherited {
72 if let Some(bpm) = tp.bpm() {
74 let mut timing = TimingPoint::bpm(time_us, bpm);
75 timing.signature = tp.meter;
76 chart.timing_points.push(timing);
77 }
78 } else {
79 let sv = tp.scroll_velocity();
81 chart.timing_points.push(TimingPoint::sv(time_us, sv));
82 }
83 }
84
85 let mut hitsound_map: HashMap<String, u16> = HashMap::new();
87
88 for ho in &beatmap.hit_objects {
90 let column = ho.column(key_count);
91 let time_us = i64::from(ho.time) * 1000;
92
93 let mut note = if ho.is_hold() {
94 let duration_us = i64::from(ho.duration_ms()) * 1000;
95 Note::hold(time_us, duration_us, column)
96 } else {
97 Note::tap(time_us, column)
98 };
99
100 if !ho.extras.is_empty() {
104 let parts: Vec<&str> = ho.extras.split(':').collect();
105
106 let filename_idx = if ho.is_hold() { 5 } else { 4 };
109
110 if let Some(&filename) = parts.get(filename_idx) {
111 let filename = filename.trim();
112 if !filename.is_empty() {
113 let hitsound_index = if let Some(&idx) = hitsound_map.get(filename) {
115 idx
116 } else {
117 let volume_idx = if ho.is_hold() { 4 } else { 3 };
119 let volume: Option<u8> = parts
120 .get(volume_idx)
121 .and_then(|v| v.parse().ok())
122 .filter(|&v| v > 0 && v <= 100);
123
124 let hitsound = if let Some(vol) = volume {
125 Hitsound::with_volume(filename, vol)
126 } else {
127 Hitsound::new(filename)
128 };
129
130 #[allow(clippy::cast_possible_truncation)]
132 let idx = chart.hitsounds.len() as u16;
133 chart.hitsounds.push(hitsound);
134 hitsound_map.insert(filename.to_string(), idx);
135 idx
136 };
137
138 note.hitsound_index = Some(hitsound_index);
139 }
140 }
141 }
142
143 chart.notes.push(note);
144 }
145
146 chart.notes.sort_by_key(|n| n.time_us);
148
149 chart
150 }
151}
152
153impl Decoder for OsuDecoder {
154 fn decode(data: &[u8]) -> RoxResult<RoxChart> {
155 let beatmap = parser::parse(data)?;
156
157 if beatmap.general.mode != 3 {
159 return Err(crate::error::RoxError::InvalidFormat(format!(
160 "Not a mania beatmap (mode={}, expected 3)",
161 beatmap.general.mode
162 )));
163 }
164
165 Ok(Self::from_beatmap(&beatmap))
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::codec::Decoder;
173
174 #[test]
175 fn test_decode_sample_7k() {
176 let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
177 let chart = <OsuDecoder as Decoder>::decode(&data).expect("Failed to decode");
178
179 assert_eq!(chart.key_count(), 7);
180 assert!(!chart.notes.is_empty());
181 assert!(!chart.timing_points.is_empty());
182 assert_eq!(chart.metadata.difficulty_name, "7K Awakened");
183 assert_eq!(chart.metadata.creator, "arcwinolivirus");
184 }
185
186 #[test]
187 fn test_decode_metadata() {
188 let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
189 let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
190
191 assert!(chart.metadata.title.contains("宙の旋律") || chart.metadata.title.contains("Sora"));
193 assert!(!chart.metadata.audio_file.is_empty());
194 assert!(chart.metadata.background_file.is_some());
195 }
196
197 #[test]
198 fn test_decode_timing_points() {
199 let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
200 let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
201
202 let bpm_points: Vec<_> = chart
204 .timing_points
205 .iter()
206 .filter(|tp| !tp.is_inherited)
207 .collect();
208 assert!(!bpm_points.is_empty());
209
210 let first_bpm = &bpm_points[0];
212 assert!((first_bpm.bpm - 186.0).abs() < 1.0);
213 }
214
215 #[test]
216 fn test_decode_notes_sorted() {
217 let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
218 let chart = <OsuDecoder as Decoder>::decode(&data).unwrap();
219
220 for window in chart.notes.windows(2) {
222 assert!(window[0].time_us <= window[1].time_us);
223 }
224 }
225
226 #[test]
227 fn test_decode_hitsounds() {
228 let data = crate::test_utils::get_test_asset("osu/mania_hitsound.osu");
229 let chart = <OsuDecoder as Decoder>::decode(&data).expect("Failed to decode");
230
231 assert_eq!(chart.key_count(), 4);
233
234 assert_eq!(chart.hitsounds.len(), 4);
236
237 let notes_with_hs = chart
239 .notes
240 .iter()
241 .filter(|n| n.hitsound_index.is_some())
242 .count();
243 assert_eq!(notes_with_hs, 276);
244
245 let hs_files: Vec<&str> = chart.hitsounds.iter().map(|h| h.file.as_str()).collect();
247 assert!(hs_files.contains(&"RimShot.wav"));
248 assert!(hs_files.contains(&"KICK 2.wav"));
249 }
250}