rhythm-open-exchange 0.6.2

A try to create the ffmpeg of vsrg
Documentation
//! Parser for osu!taiko format.
//!
//! Reuses the osu parser struct parsing logic but extracts Taiko-specific hit objects.

use crate::codec::formats::osu::parser::{
    parse_difficulty, parse_event, parse_general, parse_metadata, parse_timing_point,
};
use crate::error::{RoxError, RoxResult};

use super::types::{TaikoBeatmap, TaikoHitObject, TaikoHitsound};

// Safety limit: 100MB
const MAX_FILE_SIZE: usize = 100 * 1024 * 1024;

/// Parse a Taiko beatmap from raw bytes.
///
/// # Why this design?
/// This parser reuses the `osu` parser logic for common sections (`[General]`, `[Metadata]`).
/// It only implements custom logic for `[HitObjects]` to extract Taiko-specific data.
/// This avoids code duplication while allowing specialization.
///
/// # Errors
///
/// Returns an error if:
/// - The data is not valid UTF-8
/// - The file is larger than 100MB
pub fn parse(data: &[u8]) -> RoxResult<TaikoBeatmap> {
    if data.len() > MAX_FILE_SIZE {
        return Err(RoxError::InvalidFormat(format!(
            "File too large: {} bytes (max {}MB)",
            data.len(),
            MAX_FILE_SIZE / 1024 / 1024
        )));
    }

    let content = std::str::from_utf8(data)
        .map_err(|e| RoxError::InvalidFormat(format!("Invalid UTF-8: {e}")))?;

    let mut beatmap = TaikoBeatmap::default();
    let mut section = "";

    for line in content.lines() {
        let line = line.trim();

        // Skip empty lines and comments
        if line.is_empty() || line.starts_with("//") {
            continue;
        }

        // Check for format version
        if line.starts_with("osu file format v") {
            beatmap.format_version = line
                .strip_prefix("osu file format v")
                .and_then(|s| s.parse().ok())
                .unwrap_or(14);
            continue;
        }

        // Section headers
        if line.starts_with('[') && line.ends_with(']') {
            section = line;
            continue;
        }

        match section {
            "[General]" => parse_general(line, &mut beatmap.general),
            "[Metadata]" => parse_metadata(line, &mut beatmap.metadata),
            "[Difficulty]" => parse_difficulty(line, &mut beatmap.difficulty),
            "[Events]" => parse_event(line, &mut beatmap.background),
            "[TimingPoints]" => {
                if let Some(tp) = parse_timing_point(line) {
                    beatmap.timing_points.push(tp);
                }
            }
            "[HitObjects]" => parse_hit_object_line(line, &mut beatmap),
            _ => {}
        }
    }

    Ok(beatmap)
}

fn parse_hit_object_line(line: &str, beatmap: &mut TaikoBeatmap) {
    let parts: Vec<&str> = line.split(',').collect();

    // Format: x,y,time,type,hitSound,...
    if parts.len() >= 5 {
        let time_ms: f64 = match parts[2].parse() {
            Ok(v) => v,
            Err(_) => {
                tracing::warn!("Failed to parse time_ms in taiko object: '{}'", parts[2]);
                0.0
            }
        };

        let object_type: u32 = match parts[3].parse() {
            Ok(v) => v,
            Err(_) => {
                tracing::warn!(
                    "Failed to parse object_type in taiko object: '{}'",
                    parts[3]
                );
                0
            }
        };

        let hitsound: u32 = match parts[4].parse() {
            Ok(v) => v,
            Err(_) => {
                tracing::warn!("Failed to parse hitsound in taiko object: '{}'", parts[4]);
                0
            }
        };

        beatmap.hit_objects.push(TaikoHitObject {
            time_ms,
            hitsound: TaikoHitsound::from_bits_truncate(hitsound),
            object_type,
        });
    }
}