| """ |
| Data models for maimai chart parsing. |
| |
| Represents the full structure of a maimai song entry: |
| - Song metadata (title, artist, BPM, version, etc.) |
| - Difficulty configs (level, charter) |
| - Chart note sequences |
| """ |
|
|
| from __future__ import annotations |
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import Optional |
|
|
|
|
| class Cabinet(Enum): |
| """Cabinet / hardware type.""" |
| SD = "SD" |
| DX = "DX" |
| UNKNOWN = "UNKNOWN" |
|
|
|
|
| class Difficulty(Enum): |
| """Difficulty tiers (1-indexed matching &inote_N).""" |
| BASIC = 1 |
| ADVANCED = 2 |
| EXPERT = 3 |
| MASTER = 4 |
| ReMASTER = 5 |
| |
| |
|
|
| @classmethod |
| def from_index(cls, idx: int) -> "Difficulty": |
| for d in cls: |
| if d.value == idx: |
| return d |
| return cls.ReMASTER |
|
|
|
|
| @dataclass |
| class TouchNote: |
| """ |
| A single parsed note/event. |
| |
| Raw format examples: |
| {4}1 → beat_div=4, pos=1 |
| {4}1/8 → beat_div=4, pos=1/8 (simultaneous press) |
| {4}1h[4:1] → beat_div=4, pos=1, modifier=hold, duration=4:1 |
| {4}1b → beat_div=4, pos=1, is_break=True |
| {4}3>6[4:1]→ beat_div=4, pos=3>6 (slide), modifier=slide, duration=4:1 |
| {1} → beat_div=1, rest |
| E → end marker |
| """ |
| beat_div: int = 4 |
| raw: str = "" |
| is_rest: bool = False |
| is_end: bool = False |
| is_break: bool = False |
| is_star: bool = False |
| is_slide: bool = False |
| is_hold: bool = False |
| is_touch: bool = False |
| is_simultaneous: bool = False |
| positions: list[int] = field(default_factory=list) |
| touch_regions: list[str] = field(default_factory=list) |
| hold_duration: tuple[int, int] | None = None |
| slide_path: list[int] = field(default_factory=list) |
| firework: bool = False |
| ex_notes: list[str] = field(default_factory=list) |
|
|
|
|
| @dataclass |
| class Chart: |
| """ |
| A single difficulty chart (one &inote_N block). |
| |
| Contains the parsed note sequence and metadata about the chart. |
| """ |
| difficulty_index: int |
| difficulty: Difficulty |
| level: str = "" |
| level_value: float = 0.0 |
| is_plus: bool = False |
| is_ura: bool = False |
| charter: str = "" |
| notes: list[TouchNote] = field(default_factory=list) |
| |
| note_count: int = 0 |
| tap_count: int = 0 |
| hold_count: int = 0 |
| slide_count: int = 0 |
| break_count: int = 0 |
| touch_count: int = 0 |
| total_beats: float = 0.0 |
|
|
| def compute_stats(self) -> None: |
| """Recalculate computed statistics from notes.""" |
| self.note_count = 0 |
| self.tap_count = 0 |
| self.hold_count = 0 |
| self.slide_count = 0 |
| self.break_count = 0 |
| self.touch_count = 0 |
|
|
| for n in self.notes: |
| if n.is_rest or n.is_end: |
| continue |
| self.note_count += 1 |
| if n.is_touch: |
| self.touch_count += 1 |
| elif n.is_break: |
| self.break_count += 1 |
| elif n.is_hold: |
| self.hold_count += 1 |
| elif n.is_slide: |
| self.slide_count += 1 |
| else: |
| self.tap_count += 1 |
|
|
|
|
| @dataclass |
| class Song: |
| """ |
| Full parsed song entry — maps to one folder in datasets/. |
| """ |
| |
| song_id: str = "" |
| title: str = "" |
| title_clean: str = "" |
| artist: str = "" |
| artist_id: int = 0 |
| genre: str = "" |
| genre_id: int = 0 |
|
|
| |
| bpm: float = 0.0 |
| first: float = 0.0 |
|
|
| |
| cabinet: Cabinet = Cabinet.UNKNOWN |
| version: str = "" |
| short_id: int = 0 |
|
|
| |
| converter: str = "" |
| converter_tool: str = "" |
| converter_version: str = "" |
|
|
| |
| description: str = "" |
|
|
| |
| levels: dict[int, str] = field(default_factory=dict) |
| charters: dict[int, str] = field(default_factory=dict) |
|
|
| |
| charts: dict[int, Chart] = field(default_factory=dict) |
|
|
| |
| is_fulltouch: bool = False |
| is_full: bool = False |
| is_utage: bool = False |
| tags: list[str] = field(default_factory=list) |
|
|
| |
| maidata_path: str = "" |
| audio_path: str = "" |
|
|
| def has_chart(self, diff: Difficulty) -> bool: |
| """Check if a difficulty level exists.""" |
| return diff.value in self.charts |
|
|
| def get_chart(self, diff: Difficulty) -> Chart | None: |
| return self.charts.get(diff.value) |
|
|