File size: 5,996 Bytes
8061544 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """
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 # lv_1 / inote_1
ADVANCED = 2 # lv_2 / inote_2
EXPERT = 3 # lv_3 / inote_3
MASTER = 4 # lv_4 / inote_4
ReMASTER = 5 # lv_5 / inote_5 → SD Re:MASTER is inote_6
# DX uses lv_6 for Re:MASTER (inote_6)
# Some charts use lv_7 / inote_7 for Ura/裏 or extra
@classmethod
def from_index(cls, idx: int) -> "Difficulty":
for d in cls:
if d.value == idx:
return d
return cls.ReMASTER # fallback for 5+
@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 # {4}, {8}, {16}, {1}, etc.
raw: str = "" # original text
is_rest: bool = False
is_end: bool = False
is_break: bool = False # 'b' suffix on note
is_star: bool = False # '*' suffix (EX note)
is_slide: bool = False # '>' or 'V' or other slide patterns
is_hold: bool = False # 'h[...]' modifier
is_touch: bool = False # touch panel notes (C, Cf, Ch, etc.)
is_simultaneous: bool = False # contains '/' for multi-tap
positions: list[int] = field(default_factory=list) # 1-8 button positions
touch_regions: list[str] = field(default_factory=list) # e.g. ["C", "B7", "E8"]
hold_duration: tuple[int, int] | None = None # (beat, subdiv) from h[beat:subdiv]
slide_path: list[int] = field(default_factory=list) # slide positions
firework: bool = False # 'x' suffix
ex_notes: list[str] = field(default_factory=list) # EX modifiers
@dataclass
class Chart:
"""
A single difficulty chart (one &inote_N block).
Contains the parsed note sequence and metadata about the chart.
"""
difficulty_index: int # 1-7, maps to &inote_N
difficulty: Difficulty # enum value
level: str = "" # e.g. "12.4", "13+", "14.6?"
level_value: float = 0.0 # parsed numeric level
is_plus: bool = False # has '+' suffix
is_ura: bool = False # has '?' suffix (裏譜面)
charter: str = "" # &des_N, charter/description
notes: list[TouchNote] = field(default_factory=list)
# Computed stats
note_count: int = 0 # total non-rest notes
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 # total chart duration in beats
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/.
"""
# Identifiers
song_id: str = "" # folder name (e.g. "10", "10021")
title: str = "" # &title (raw, may include [SD] etc.)
title_clean: str = "" # cleaned title without tags
artist: str = "" # &artist
artist_id: int = 0 # &artistid
genre: str = "" # &genre
genre_id: int = 0 # &genreid
# Music info
bpm: float = 0.0 # &wholebpm
first: float = 0.0 # &first (offset in seconds)
# Cabinet & version
cabinet: Cabinet = Cabinet.UNKNOWN
version: str = "" # &version
short_id: int = 0 # &shortid
# Chart converter metadata
converter: str = "" # &ChartConverter
converter_tool: str = "" # &ChartConvertTool
converter_version: str = "" # &ChartConvertToolVersion
# Raw header
description: str = "" # &des
# Difficulty info (keyed by difficulty index 1-7)
levels: dict[int, str] = field(default_factory=dict) # &lv_N
charters: dict[int, str] = field(default_factory=dict) # &des_N
# Parsed charts (keyed by difficulty index 1-7)
charts: dict[int, Chart] = field(default_factory=dict)
# Flags
is_fulltouch: bool = False
is_full: bool = False # [FULL] in title
is_utage: bool = False # 宴会場 genre
tags: list[str] = field(default_factory=list) # [SD], [DX], [宴], etc.
# File paths (relative to dataset root)
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)
|