Goldgom commited on
Commit
8061544
·
1 Parent(s): bd4d374

Upload MaiGenerator model (epoch 10) and inference code

Browse files
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ __pycache__/
README.md CHANGED
@@ -1,3 +1,156 @@
1
  ---
 
2
  license: mit
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ language: zh
3
  license: mit
4
+ tags:
5
+ - maimai
6
+ - music-generation
7
+ - chart-generation
8
+ - transformer
9
+ - audio-conditioned
10
+ - pytorch
11
+ pipeline_tag: text-generation
12
  ---
13
+
14
+ # MaiGenerator (maiChartGen)
15
+
16
+ 基于 EnCodec 音频编码 + Transformer 的舞萌 (maimai) 谱面自动生成模型。
17
+
18
+ 输入音频 + BPM/难度/等级 → 输出可玩的 maimai 谱面 (maidata.txt)。
19
+
20
+ ## 模型简介
21
+
22
+ - **架构**: Encoder-Decoder Transformer (4 enc + 8 dec),512 维,8 头
23
+ - **Decoder**: 前 6 层共享 FFN + 后 2 层 MoE(4 专家 × 难度路由)
24
+ - **核心设计**: 时间对齐 RoPE(谱面位置 = BPM 转换的音频帧号)
25
+ - **推理**: KV-Cache 增量推理,支持 float16 加速
26
+ - **输出**: 标准 maidata.txt 格式,可直接导入 Simai/maimai 模拟器
27
+
28
+ ## 快速使用
29
+
30
+ ### 安装依赖
31
+
32
+ ```bash
33
+ pip install torch torchaudio encodec soundfile tqdm numpy
34
+ pip install huggingface_hub
35
+ ```
36
+
37
+ ### 下载模型并推理
38
+
39
+ ```python
40
+ from huggingface_hub import hf_hub_download
41
+ import torch
42
+ import subprocess
43
+
44
+ # 下载模型权重
45
+ model_path = hf_hub_download(repo_id="Goldgom/maiChartGen", filename="best.pt")
46
+
47
+ # 推理
48
+ subprocess.run([
49
+ "python", "inference.py",
50
+ "--checkpoint", model_path,
51
+ "--audio", "your_track.mp3",
52
+ "--bpm", "270", "--diff", "MASTER", "--level", "13.5",
53
+ "--temperature", "1.0", "--top-k", "500", "--top-p", "0.95",
54
+ "--density-bias", "1.0", "--target-notes", "650",
55
+ "--config-density-bias", "0.4", "--hold-bias", "0.2", "--slide-bias", "0.8",
56
+ "--break-penalty", "2.5", "--max-break-ratio", "0.06",
57
+ "--output", "maidata.txt"
58
+ ])
59
+ ```
60
+
61
+ ### 命令行推理
62
+
63
+ ```bash
64
+ # 基础用法
65
+ python inference.py --checkpoint best.pt --audio track.mp3 \
66
+ --bpm 150 --diff MASTER --level 12.0 --output maidata.txt
67
+
68
+ # 高质量 MASTER 谱面
69
+ python inference.py --checkpoint best.pt --audio track.mp3 \
70
+ --bpm 270 --diff MASTER --level 13.5 \
71
+ --temperature 1.0 --top-k 500 --top-p 0.95 \
72
+ --density-bias 1.0 --target-notes 650 \
73
+ --config-density-bias 0.4 --hold-bias 0.2 --slide-bias 0.8 \
74
+ --break-penalty 2.5 --max-break-ratio 0.06 \
75
+ --output maidata.txt
76
+
77
+ # 高性能推理 (float16 + torch.compile)
78
+ python inference.py --checkpoint best.pt --audio track.mp3 \
79
+ --bpm 150 --diff MASTER --level 12.0 \
80
+ --precision float16 --compile --output maidata.txt
81
+ ```
82
+
83
+ ### 后端 API 使用
84
+
85
+ ```python
86
+ from Tokenizer.MaiTrackTokenizer import MaiTrackTokenizer
87
+ from inference import generate_chart
88
+ import torch
89
+
90
+ # 加载模型
91
+ device = torch.device("cuda")
92
+ ckpt = torch.load("best.pt", map_location=device, weights_only=False)
93
+ config = ckpt["config"]
94
+ model = MaiGenerator(
95
+ d_model=config.get("d_model", 512),
96
+ enc_layers=config.get("enc_layers", 4),
97
+ dec_layers=config.get("dec_layers", 8),
98
+ heads=config.get("heads", 8),
99
+ d_ff=config.get("d_ff", 2048),
100
+ ).to(device)
101
+ model.load_state_dict(ckpt["model_state"], strict=False)
102
+ model.eval()
103
+
104
+ # 音频 tokenize
105
+ audio_tok = MaiTrackTokenizer(n_layers=2, device="cuda")
106
+ audio_tokens = audio_tok.encode("track.mp3")
107
+
108
+ # 生成谱面
109
+ chart_tokens = generate_chart(
110
+ model, torch.tensor([audio_tokens]),
111
+ bpm=270, difficulty=3, level_value=13.5,
112
+ temperature=1.0, top_k=500, top_p=0.95,
113
+ density_bias=1.0, target_notes=650,
114
+ config_density_bias=0.4,
115
+ type_biases={"hold": 0.2, "slide": 0.8},
116
+ break_penalty=2.5, max_break_ratio=0.06,
117
+ )
118
+ ```
119
+
120
+ ## 推理参数
121
+
122
+ | 参数 | 默认值 | 说明 |
123
+ |------|--------|------|
124
+ | `--temperature` | 0.8 | 采样温度 |
125
+ | `--top-k` | 50 | Top-K 过滤 |
126
+ | `--top-p` | 0.95 | Nucleus 过滤 |
127
+ | `--density-bias` | 0.0 | 密度引导强度 |
128
+ | `--target-notes` | auto | 目标 note 数 |
129
+ | `--hold-bias` | 0.0 | HOLD 偏置 |
130
+ | `--slide-bias` | 0.0 | SLIDE 偏置 |
131
+ | `--break-penalty` | 1.5 | BREAK 比例惩罚 |
132
+ | `--max-break-ratio` | 0.08 | BREAK 比例上限 |
133
+ | `--precision` | float32 | float16/bf16 精度 |
134
+
135
+ 完整参数见 [README](https://github.com/Goldgom/maiGenerator) 或 `python inference.py --help`。
136
+
137
+ ## 训练数据
138
+
139
+ - 1,730 首歌曲,~5,000 个谱面样本
140
+ - 难度范围: BASIC ~ ReMASTER (lv1~15)
141
+ - 谱面类型: SD / DX / FULLTOUCH
142
+
143
+ ## 技术细节
144
+
145
+ - **音频处理**: EnCodec 24kHz, 2-layer RVQ, 75Hz 帧率
146
+ - **分词器**: 基础 256 token + Config Token(单 token 编码完整 note)
147
+ - **约束解码**: 硬语法状态机 (NORMAL → SIM → DUR → SLIDE_BODY)
148
+ - **采样引导**: 密度引导 + 类型偏置 + BREAK 比例控制
149
+
150
+ ## 代码仓库
151
+
152
+ https://github.com/Goldgom/maiGenerator
153
+
154
+ ## License
155
+
156
+ MIT
Tokenizer/MaiChartTokenizer.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MaiChartTokenizer — VQ-VAE based learned tokenizer for maimai charts.
3
+
4
+ Two modes:
5
+ 1. Rule-based: use tokenizer.MaiChartTokenizer (vocab-based, deterministic)
6
+ 2. Learned (VQ-VAE): this module — encoder → codebook → decoder
7
+
8
+ Architecture:
9
+ note_features (35-dim) → Encoder MLP → latent → VectorQuantizer → discrete codes
10
+ ↓
11
+ Decoder MLP → reconstructed note_features
12
+
13
+ Usage:
14
+ from Tokenizer.MaiChartTokenizer import MaiChartTokenizer
15
+
16
+ model = MaiChartTokenizer(input_dim=35, hidden_dim=256, codebook_size=512, latent_dim=64)
17
+ tokens, commitment_loss = model.encode(features) # → discrete token sequence
18
+ reconstructed = model.decode(tokens) # → back to features
19
+ """
20
+
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+
25
+
26
+ class VectorQuantizer(nn.Module):
27
+ """
28
+ Vector Quantization layer (VQ-VAE codebook).
29
+
30
+ Maps continuous latent vectors to the nearest codebook entry.
31
+ Returns discrete indices + commitment loss for training.
32
+ """
33
+
34
+ def __init__(self, codebook_size: int, latent_dim: int, beta: float = 0.25):
35
+ super().__init__()
36
+ self.codebook_size = codebook_size
37
+ self.latent_dim = latent_dim
38
+ self.beta = beta
39
+
40
+ # Codebook: [codebook_size, latent_dim]
41
+ self.embedding = nn.Embedding(codebook_size, latent_dim)
42
+ self.embedding.weight.data.uniform_(-1.0 / codebook_size, 1.0 / codebook_size)
43
+
44
+ def forward(self, z: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
45
+ """
46
+ Args:
47
+ z: [B, T, latent_dim] or [B*T, latent_dim]
48
+
49
+ Returns:
50
+ z_q: Quantized vectors (same shape as z)
51
+ indices: Codebook indices [B, T] or [B*T]
52
+ loss: Commitment loss (scalar)
53
+ """
54
+ # Flatten if needed
55
+ original_shape = z.shape
56
+ if z.dim() == 3:
57
+ B, T, D = z.shape
58
+ z_flat = z.reshape(-1, D)
59
+ else:
60
+ z_flat = z
61
+
62
+ # Compute distances to codebook: [N, codebook_size]
63
+ # ||z - e||^2 = ||z||^2 + ||e||^2 - 2 * z @ e.T
64
+ z_sq = (z_flat ** 2).sum(dim=1, keepdim=True)
65
+ e_sq = (self.embedding.weight ** 2).sum(dim=1)
66
+ dist = z_sq + e_sq.unsqueeze(0) - 2 * z_flat @ self.embedding.weight.T
67
+
68
+ # Nearest codebook entry
69
+ indices = dist.argmin(dim=1) # [N]
70
+ z_q = self.embedding(indices) # [N, latent_dim]
71
+
72
+ # VQ loss: commitment loss + codebook loss
73
+ commitment_loss = F.mse_loss(z_flat.detach(), z_q)
74
+ codebook_loss = F.mse_loss(z_flat, z_q.detach())
75
+ vq_loss = codebook_loss + self.beta * commitment_loss
76
+
77
+ # Straight-through estimator
78
+ z_q = z_flat + (z_q - z_flat).detach()
79
+
80
+ # Reshape back
81
+ if len(original_shape) == 3:
82
+ z_q = z_q.reshape(B, T, D)
83
+ indices = indices.reshape(B, T)
84
+
85
+ return z_q, indices, vq_loss
86
+
87
+ def decode_indices(self, indices: torch.Tensor) -> torch.Tensor:
88
+ """Convert discrete indices back to vectors."""
89
+ return self.embedding(indices)
90
+
91
+
92
+ class MaiChartTokenizer(nn.Module):
93
+ """
94
+ VQ-VAE Tokenizer: encodes chart note features → discrete token sequence.
95
+
96
+ The rule-based tokenizer (tokenizer.py) provides the baseline vocabulary.
97
+ This learned tokenizer can be trained end-to-end to discover optimal tokens.
98
+
99
+ Args:
100
+ input_dim: Feature dimension per note (from mai_parser note_to_vector).
101
+ hidden_dim: Hidden dimension of encoder/decoder MLP.
102
+ codebook_size: Number of discrete codes (vocabulary size).
103
+ latent_dim: Dimension of each codebook vector.
104
+ num_layers: Number of MLP layers in encoder/decoder.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ input_dim: int = 35,
110
+ hidden_dim: int = 256,
111
+ codebook_size: int = 512,
112
+ latent_dim: int = 64,
113
+ num_layers: int = 2,
114
+ ):
115
+ super().__init__()
116
+ self.input_dim = input_dim
117
+ self.hidden_dim = hidden_dim
118
+ self.codebook_size = codebook_size
119
+ self.latent_dim = latent_dim
120
+
121
+ # ── Encoder: note features → latent ──
122
+ encoder_layers = []
123
+ in_dim = input_dim
124
+ for i in range(num_layers):
125
+ out_dim = hidden_dim if i < num_layers - 1 else latent_dim
126
+ encoder_layers.append(nn.Linear(in_dim, out_dim))
127
+ if i < num_layers - 1:
128
+ encoder_layers.append(nn.ReLU())
129
+ in_dim = out_dim
130
+ self.encoder = nn.Sequential(*encoder_layers)
131
+
132
+ # ── Pre-quantization projection ──
133
+ self.pre_vq = nn.Linear(latent_dim, latent_dim)
134
+
135
+ # ── Vector Quantizer ──
136
+ self.vq = VectorQuantizer(codebook_size, latent_dim)
137
+
138
+ # ── Decoder: latent → note features ──
139
+ decoder_layers = []
140
+ in_dim = latent_dim
141
+ for i in range(num_layers):
142
+ out_dim = hidden_dim if i < num_layers - 1 else input_dim
143
+ decoder_layers.append(nn.Linear(in_dim, out_dim))
144
+ if i < num_layers - 1:
145
+ decoder_layers.append(nn.ReLU())
146
+ in_dim = out_dim
147
+ self.decoder = nn.Sequential(*decoder_layers)
148
+
149
+ def encode(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
150
+ """
151
+ Encode chart features to discrete token sequence.
152
+
153
+ Args:
154
+ x: [B, T, input_dim] — batch of chart feature sequences.
155
+
156
+ Returns:
157
+ tokens: [B, T] — discrete token IDs.
158
+ vq_loss: scalar commitment loss (for training).
159
+ """
160
+ # Encode
161
+ z = self.encoder(x) # [B, T, latent_dim]
162
+ z = self.pre_vq(z)
163
+
164
+ # Quantize
165
+ z_q, indices, vq_loss = self.vq(z)
166
+
167
+ return indices, vq_loss, z_q
168
+
169
+ def decode(self, tokens: torch.Tensor) -> torch.Tensor:
170
+ """
171
+ Decode token sequence back to chart features.
172
+
173
+ Args:
174
+ tokens: [B, T] — discrete token IDs.
175
+
176
+ Returns:
177
+ x_recon: [B, T, input_dim] — reconstructed note features.
178
+ """
179
+ z_q = self.vq.decode_indices(tokens)
180
+ x_recon = self.decoder(z_q)
181
+ return x_recon
182
+
183
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
184
+ """
185
+ Full forward pass: encode → quantize → decode.
186
+
187
+ Args:
188
+ x: [B, T, input_dim]
189
+
190
+ Returns:
191
+ x_recon: [B, T, input_dim] reconstructed features.
192
+ tokens: [B, T] discrete token IDs.
193
+ vq_loss: scalar loss.
194
+ """
195
+ tokens, vq_loss, z_q = self.encode(x)
196
+ x_recon = self.decoder(z_q)
197
+ return x_recon, tokens, vq_loss
198
+
199
+ @property
200
+ def vocab_size(self) -> int:
201
+ return self.codebook_size
Tokenizer/MaiTrackTokenizer.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MaiTrackTokenizer — Audio tokenizer using pre-trained EnCodec (Meta).
3
+
4
+ Wraps EnCodec 24kHz model to convert maimai track.mp3 audio into
5
+ discrete token sequences for transformer training.
6
+
7
+ Key design decisions:
8
+ - Uses EnCodec 24kHz (pre-trained on speech+music, 8-layer RVQ, 1024 bins)
9
+ - Default: 2 codebook layers (~15816 tokens/2min song)
10
+ - Stride: 320 samples @ 24kHz = 75Hz = 13.3ms per token
11
+ - BPM is NOT encoded (computed separately by external program)
12
+
13
+ Usage:
14
+ from Tokenizer.MaiTrackTokenizer import MaiTrackTokenizer
15
+
16
+ tok = MaiTrackTokenizer()
17
+ tokens = tok.encode("datasets/10/track.mp3") # → list[int]
18
+ tokens_2l = tok.encode("datasets/10/track.mp3", n_layers=2)
19
+ audio = tok.decode(tokens) # → torch.Tensor
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from pathlib import Path
26
+ from typing import Optional, Union
27
+
28
+ import numpy as np
29
+ import soundfile as sf
30
+ import torch
31
+
32
+ from encodec import EncodecModel
33
+ from encodec.utils import convert_audio
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # ═══════════════════════════════════════════════════════════════════════
38
+ # Constants
39
+ # ═══════════════════════════════════════════════════════════════════════
40
+
41
+ # EnCodec 24kHz: 8 codebooks, 1024 bins each, stride=320 samples
42
+ # Frame rate = 24000 / 320 = 75 Hz (= 13.33 ms per token)
43
+ ENC_SAMPLE_RATE = 24000
44
+ ENC_STRIDE = 320 # samples per frame
45
+ ENC_FRAME_RATE = ENC_SAMPLE_RATE / ENC_STRIDE # 75 Hz
46
+ ENC_CODEBOOK_SIZE = 1024
47
+ ENC_NUM_CODEBOOKS = 8
48
+ ENC_BANDWIDTH = 6.0 # kbps target
49
+
50
+ # Default: use only first 2 codebook layers for efficiency
51
+ DEFAULT_N_LAYERS = 2
52
+
53
+ # Special tokens (aligned with chart tokenizer)
54
+ PAD = 0
55
+ BOS = 1
56
+ EOS = 2
57
+
58
+ # Token value offset per layer (to avoid overlap)
59
+ # Layer 0: codes 0..1023 → tokens 3..1026
60
+ # Layer 1: codes 0..1023 → tokens 1027..2050
61
+ # etc.
62
+ TOKEN_OFFSET_BASE = 3 # after PAD, BOS, EOS
63
+
64
+
65
+ # ═══════════════════════════════════════════════════════════════════════
66
+ # Tokenizer class
67
+ # ═══════════════════════════════════════════════════════════════════════
68
+
69
+ class MaiTrackTokenizer:
70
+ """
71
+ Pre-trained audio tokenizer using Meta EnCodec.
72
+
73
+ Converts audio waveforms to/from discrete token sequences.
74
+ Multi-layer tokens are interleaved: [L0_t0, L1_t0, L0_t1, L1_t1, ...]
75
+
76
+ Attributes:
77
+ sample_rate: 24000 Hz
78
+ frame_rate: 75 Hz (13.3ms per token)
79
+ n_layers: Number of codebook layers used (default 2)
80
+ vocab_size: Total vocabulary size (layers × 1024 + special tokens)
81
+ """
82
+
83
+ def __init__(self, n_layers: int = DEFAULT_N_LAYERS, device: str = "cpu"):
84
+ """
85
+ Args:
86
+ n_layers: Number of EnCodec codebook layers to use (1-8).
87
+ More layers = better audio quality, more tokens.
88
+ 1-2 layers typically sufficient for rhythm game features.
89
+ device: Device to run the model on ("cpu" or "cuda").
90
+ """
91
+ self.n_layers = n_layers
92
+ self.device = device
93
+
94
+ # Load pre-trained model
95
+ self._model = EncodecModel.encodec_model_24khz()
96
+ self._model.set_target_bandwidth(ENC_BANDWIDTH)
97
+ self._model.eval()
98
+ self._model.to(device)
99
+
100
+ # Vocabulary: specials + n_layers * 1024 codes
101
+ self.vocab_size = TOKEN_OFFSET_BASE + n_layers * ENC_CODEBOOK_SIZE
102
+ self.pad_token_id = PAD
103
+ self.bos_token_id = BOS
104
+ self.eos_token_id = EOS
105
+
106
+ @property
107
+ def sample_rate(self) -> int:
108
+ return ENC_SAMPLE_RATE
109
+
110
+ @property
111
+ def frame_rate(self) -> float:
112
+ return ENC_FRAME_RATE
113
+
114
+ # ── Load audio ──────────────────────────────────────────────────
115
+
116
+ def load_audio(self, path: Union[str, Path]) -> torch.Tensor:
117
+ """
118
+ Load an audio file and convert to 24kHz mono tensor.
119
+
120
+ Args:
121
+ path: Path to audio file (mp3, wav, flac, etc.).
122
+
123
+ Returns:
124
+ Tensor [1, samples] at 24kHz mono.
125
+ """
126
+ data, sr = sf.read(str(path), dtype="float32")
127
+
128
+ # Convert to mono
129
+ if data.ndim > 1:
130
+ data = data.mean(axis=1)
131
+
132
+ wav = torch.from_numpy(data.copy()).unsqueeze(0) # [1, T]
133
+
134
+ # Resample to 24kHz if needed
135
+ if sr != ENC_SAMPLE_RATE:
136
+ wav = convert_audio(wav, sr, ENC_SAMPLE_RATE, 1)
137
+
138
+ return wav
139
+
140
+ def load_audio_batch(self, paths: list[str],
141
+ max_duration: Optional[float] = None) -> tuple[torch.Tensor, list[int]]:
142
+ """
143
+ Load a batch of audio files, padding to same length.
144
+
145
+ Args:
146
+ paths: List of audio file paths.
147
+ max_duration: Truncate to max_duration seconds (None = no truncation).
148
+
149
+ Returns:
150
+ (wavs [B, 1, max_samples], lengths [B])
151
+ """
152
+ wavs = []
153
+ lengths = []
154
+ for p in paths:
155
+ wav = self.load_audio(p)
156
+ if max_duration is not None:
157
+ max_samples = int(max_duration * ENC_SAMPLE_RATE)
158
+ wav = wav[:, :max_samples]
159
+ lengths.append(wav.shape[1])
160
+ wavs.append(wav)
161
+
162
+ # Pad to max length
163
+ max_len = max(lengths)
164
+ padded = torch.zeros(len(wavs), 1, max_len)
165
+ for i, w in enumerate(wavs):
166
+ padded[i, :, :w.shape[1]] = w
167
+
168
+ return padded, lengths
169
+
170
+ # ── Encode ──────────────────────────────────────────────────────
171
+
172
+ @torch.no_grad()
173
+ def encode(self, audio: Union[str, Path, torch.Tensor, np.ndarray],
174
+ n_layers: Optional[int] = None,
175
+ add_bos: bool = True,
176
+ add_eos: bool = True,
177
+ interleave: bool = True) -> list[int]:
178
+ """
179
+ Encode audio into a discrete token sequence.
180
+
181
+ Args:
182
+ audio: Path to audio file, or waveform tensor [1, T] / numpy [T].
183
+ n_layers: Override number of codebook layers (default: self.n_layers).
184
+ add_bos: Prepend BOS token.
185
+ add_eos: Append EOS token.
186
+ interleave: If True, interleave layers: [L0, L1, L0, L1, ...].
187
+ If False, concatenate: [L0_all..., L1_all...].
188
+
189
+ Returns:
190
+ List of integer token IDs.
191
+ """
192
+ n_layers = n_layers or self.n_layers
193
+
194
+ # Load if path
195
+ if isinstance(audio, (str, Path)):
196
+ wav = self.load_audio(audio).to(self.device)
197
+ elif isinstance(audio, np.ndarray):
198
+ wav = torch.from_numpy(audio.astype("float32")).unsqueeze(0).to(self.device)
199
+ else:
200
+ wav = audio.to(self.device)
201
+
202
+ # Ensure correct shape
203
+ if wav.dim() == 1:
204
+ wav = wav.unsqueeze(0).unsqueeze(0)
205
+ elif wav.dim() == 2:
206
+ wav = wav.unsqueeze(1)
207
+
208
+ # Resample if needed
209
+ if wav.shape[1] != 1:
210
+ wav = wav.mean(dim=1, keepdim=True)
211
+
212
+ # Encode with EnCodec. Long files are returned as multiple chunks;
213
+ # concatenate them so token length reflects the full track duration.
214
+ encoded = self._model.encode(wav)
215
+ codes = torch.cat([frame_codes for frame_codes, _ in encoded], dim=-1) # [B, n_q, T]
216
+ codes = codes[0, :n_layers, :] # [n_layers, T]
217
+
218
+ # Convert to flat token list
219
+ tokens: list[int] = []
220
+ if add_bos:
221
+ tokens.append(BOS)
222
+
223
+ T = codes.shape[1]
224
+ if interleave:
225
+ # Interleave: L0_t0, L1_t0, L0_t1, L1_t1, ...
226
+ for t in range(T):
227
+ for layer in range(n_layers):
228
+ code = codes[layer, t].item()
229
+ token = self._code_to_token(code, layer)
230
+ tokens.append(token)
231
+ else:
232
+ # Concatenate per layer
233
+ for layer in range(n_layers):
234
+ for t in range(T):
235
+ code = codes[layer, t].item()
236
+ token = self._code_to_token(code, layer)
237
+ tokens.append(token)
238
+
239
+ if add_eos:
240
+ tokens.append(EOS)
241
+
242
+ return tokens
243
+
244
+ @torch.no_grad()
245
+ def encode_batch(self, audios: list[Union[str, Path, torch.Tensor]],
246
+ n_layers: Optional[int] = None,
247
+ max_duration: Optional[float] = None,
248
+ pad_to: Optional[int] = None,
249
+ return_tensors: bool = False):
250
+ """
251
+ Encode a batch of audio files.
252
+
253
+ Args:
254
+ audios: List of paths or tensors.
255
+ n_layers: Number of codebook layers.
256
+ max_duration: Truncate audio to this many seconds.
257
+ pad_to: Pad token sequences to this length.
258
+ return_tensors: Return torch tensors instead of lists.
259
+
260
+ Returns:
261
+ If return_tensors=False: (list[list[int]], list[int])
262
+ If return_tensors=True: (Tensor[B, max_len], Tensor[B])
263
+ """
264
+ n_layers = n_layers or self.n_layers
265
+
266
+ token_seqs = []
267
+ for audio in audios:
268
+ tokens = self.encode(audio, n_layers=n_layers, interleave=True)
269
+ if pad_to is not None and len(tokens) > pad_to:
270
+ tokens = tokens[:pad_to]
271
+ token_seqs.append(tokens)
272
+
273
+ lengths = [len(s) for s in token_seqs]
274
+ max_len = max(lengths) if pad_to is None else max(pad_to, max(lengths))
275
+
276
+ padded = []
277
+ for seq in token_seqs:
278
+ if len(seq) < max_len:
279
+ seq = seq + [PAD] * (max_len - len(seq))
280
+ padded.append(seq[:max_len])
281
+
282
+ if return_tensors:
283
+ return (torch.tensor(padded, dtype=torch.long),
284
+ torch.tensor(lengths, dtype=torch.long))
285
+
286
+ return padded, lengths
287
+
288
+ # ── Decode ──────────────────────────────────────────────────────
289
+
290
+ @torch.no_grad()
291
+ def decode(self, tokens: list[int],
292
+ n_layers: Optional[int] = None,
293
+ interleave: bool = True) -> torch.Tensor:
294
+ """
295
+ Decode a token sequence back to audio waveform.
296
+
297
+ Args:
298
+ tokens: Token ID list.
299
+ n_layers: Number of codebook layers used (must match encoding).
300
+ interleave: Whether tokens are interleaved (must match encoding).
301
+
302
+ Returns:
303
+ Waveform tensor [1, samples] at 24kHz.
304
+ """
305
+ n_layers = n_layers or self.n_layers
306
+
307
+ # Strip BOS/EOS
308
+ if tokens and tokens[0] == BOS:
309
+ tokens = tokens[1:]
310
+ if tokens and tokens[-1] == EOS:
311
+ tokens = tokens[:-1]
312
+
313
+ total_tokens = len(tokens)
314
+ if interleave:
315
+ T = total_tokens // n_layers
316
+ else:
317
+ T = total_tokens // n_layers
318
+
319
+ if T == 0:
320
+ logger.warning("Token sequence too short for decoding")
321
+ return torch.zeros(1, ENC_STRIDE)
322
+
323
+ # Convert tokens back to EnCodec codes
324
+ # Full 8 layers: fill unused layers with zeros
325
+ codes = torch.zeros(ENC_NUM_CODEBOOKS, T, dtype=torch.long, device=self.device)
326
+
327
+ if interleave:
328
+ for t in range(T):
329
+ for layer in range(n_layers):
330
+ idx = t * n_layers + layer
331
+ if idx < total_tokens:
332
+ codes[layer, t] = self._token_to_code(tokens[idx], layer)
333
+ else:
334
+ for layer in range(n_layers):
335
+ for t in range(T):
336
+ idx = layer * T + t
337
+ if idx < total_tokens:
338
+ codes[layer, t] = self._token_to_code(tokens[idx], layer)
339
+
340
+ # Decode with EnCodec
341
+ codes = codes.unsqueeze(0) # [1, 8, T]
342
+ decoded = self._model.decode([(codes, None)])
343
+ return decoded.squeeze(0) # [1, samples]
344
+
345
+ # ── Token ↔ Code conversion ─────────────────────────────────────
346
+
347
+ def _code_to_token(self, code: int, layer: int) -> int:
348
+ """Convert EnCodec code (0..1023) to global token ID."""
349
+ return TOKEN_OFFSET_BASE + layer * ENC_CODEBOOK_SIZE + code
350
+
351
+ def _token_to_code(self, token: int, layer: int) -> int:
352
+ """Convert global token ID back to EnCodec code (0..1023)."""
353
+ offset = TOKEN_OFFSET_BASE + layer * ENC_CODEBOOK_SIZE
354
+ code = token - offset
355
+ return max(0, min(code, ENC_CODEBOOK_SIZE - 1))
356
+
357
+ # ── Debug ───────────────────────────────────────────────────────
358
+
359
+ def tokens_to_str(self, tokens: list[int], max_show: int = 30) -> str:
360
+ """Pretty-print token sequence (truncated)."""
361
+ parts = []
362
+ for t in tokens[:max_show]:
363
+ if t == PAD:
364
+ parts.append("[PAD]")
365
+ elif t == BOS:
366
+ parts.append("[BOS]")
367
+ elif t == EOS:
368
+ parts.append("[EOS]")
369
+ else:
370
+ # Determine layer and code
371
+ code = t - TOKEN_OFFSET_BASE
372
+ layer = code // ENC_CODEBOOK_SIZE
373
+ c = code % ENC_CODEBOOK_SIZE
374
+ parts.append(f"L{layer}:{c}")
375
+ if len(tokens) > max_show:
376
+ parts.append(f"... ({len(tokens) - max_show} more)")
377
+ return " ".join(parts)
378
+
379
+ # ── Info ────────────────────────────────────────────────────────
380
+
381
+ def token_count_estimate(self, duration_seconds: float,
382
+ n_layers: Optional[int] = None) -> int:
383
+ """
384
+ Estimate the number of tokens for a given audio duration.
385
+
386
+ Args:
387
+ duration_seconds: Audio duration in seconds.
388
+ n_layers: Number of codebook layers.
389
+
390
+ Returns:
391
+ Estimated token count (including BOS/EOS).
392
+ """
393
+ n_layers = n_layers or self.n_layers
394
+ frames = int(duration_seconds * ENC_FRAME_RATE)
395
+ return 2 + frames * n_layers # BOS + frames*layers + EOS
396
+
397
+ def __repr__(self) -> str:
398
+ return (f"MaiTrackTokenizer(n_layers={self.n_layers}, "
399
+ f"sr={ENC_SAMPLE_RATE}Hz, "
400
+ f"frame_rate={ENC_FRAME_RATE:.0f}Hz, "
401
+ f"vocab_size={self.vocab_size}, "
402
+ f"device={self.device})")
403
+
404
+
405
+ # ═══════════════════════════════════════════════════════════════════════
406
+ # Quick test
407
+ # ═══════════════════════════════════════════════════════════════════════
408
+
409
+ if __name__ == "__main__":
410
+ import sys
411
+
412
+ path = sys.argv[1] if len(sys.argv) > 1 else "datasets/10/track.mp3"
413
+
414
+ tok = MaiTrackTokenizer(n_layers=2)
415
+ print(tok)
416
+ print(f"Vocab size: {tok.vocab_size}")
417
+
418
+ tokens = tok.encode(path)
419
+ print(f"Tokens: {len(tokens)} ({tok.tokens_to_str(tokens, 30)})")
420
+
421
+ audio = tok.decode(tokens)
422
+ print(f"Decoded audio: {audio.shape}, {audio.shape[1]/ENC_SAMPLE_RATE:.1f}s")
best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:886781211e93a4d3c30dceb456ae21c1f456f50e2f086f484bd53505e879c3ec
3
+ size 1599244629
constrained_decode.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import torch
6
+
7
+ from grammar import ChartGrammarState, GrammarMode
8
+ from model import DIV_MAP, DUR_TOKEN, is_timeline_token
9
+ from grammar import NOTE_TOKENS
10
+ from tokenizer import (
11
+ BOS, EOS, PAD, RST, DUR, HLD_TO_ID, ID_TO_DIV, SIM_BEG, SIM_END, SLD_BEG,
12
+ SLD_END_TOKEN, SIM_COUNT_2, DUR_NUM_TO_ID, ID_TO_DUR_NUM, DUR_DEN_TO_ID,
13
+ ID_TO_DUR_DEN, CONFIG_TO_ID, ID_TO_CONFIG,
14
+ )
15
+
16
+
17
+ VALID_DUR_NUMS = list(DUR_NUM_TO_ID.keys())
18
+ VALID_DUR_DENS = list(DUR_DEN_TO_ID.keys())
19
+ MAX_DURATION_BEATS = 4.0
20
+
21
+
22
+ def snap_duration(beat: int, subdiv: int, max_beats: float,
23
+ max_duration_beats: float = MAX_DURATION_BEATS) -> tuple[int, int]:
24
+ """Snap/clamp duration params to common values that fit before track end."""
25
+ limit = min(max_beats, max_duration_beats)
26
+ if limit <= 0:
27
+ return 1, 64
28
+ candidates = [
29
+ (b, d) for b in VALID_DUR_NUMS for d in VALID_DUR_DENS
30
+ if b / d <= limit + 1e-6
31
+ ]
32
+ if not candidates:
33
+ return 1, 64
34
+ raw = max(float(beat), 1.0) / max(float(subdiv), 1.0)
35
+ return min(candidates, key=lambda x: (abs((x[0] / x[1]) - raw), x[1], x[0]))
36
+
37
+
38
+ def _config_duration_beats(spec: tuple) -> float:
39
+ if not spec:
40
+ return 0.0
41
+ kind = spec[0]
42
+ if kind in ("hld", "sld") and len(spec) >= 4:
43
+ return max(float(spec[-2]), 1.0) / max(float(spec[-1]), 1.0)
44
+ if kind == "pair" and len(spec) >= 7 and ("hld" in (spec[1], spec[3])):
45
+ return max(float(spec[-2]), 1.0) / max(float(spec[-1]), 1.0)
46
+ if kind == "multi" and len(spec) >= 5 and spec[1] == "hld":
47
+ return max(float(spec[-2]), 1.0) / max(float(spec[-1]), 1.0)
48
+ return 0.0
49
+
50
+
51
+ def _replace_config_duration_token(token_id: int, max_beats: float,
52
+ max_duration_beats: float = MAX_DURATION_BEATS) -> int:
53
+ spec = ID_TO_CONFIG.get(token_id)
54
+ if spec is None or _config_duration_beats(spec) <= 0:
55
+ return token_id
56
+ n, d = snap_duration(int(spec[-2]), int(spec[-1]), max_beats, max_duration_beats)
57
+ new_spec = tuple(list(spec[:-2]) + [n, d])
58
+ return CONFIG_TO_ID.get(new_spec, RST)
59
+
60
+
61
+ @dataclass
62
+ class DecodeState:
63
+ """Stateful validator for candidate next-token decoding."""
64
+
65
+ bpm: float
66
+ total_beats: float
67
+ start_offset_beats: float
68
+ min_division: int = 4
69
+ max_sim: int = 2
70
+ max_duration_beats: float = MAX_DURATION_BEATS
71
+
72
+ def __post_init__(self) -> None:
73
+ self.grammar = ChartGrammarState(min_division=self.min_division, max_sim=self.max_sim)
74
+ self.current_beat = float(self.start_offset_beats)
75
+ self.div_value = float(self.min_division)
76
+ self.skip_beat = 0
77
+ self.slide_group_active = False
78
+ self.pending_dur_num: int | None = None
79
+ self.pending_duration_start_beat: float | None = None
80
+ self.sim_start_beat: float | None = None
81
+ self.slide_start_beat: float | None = None
82
+ self._normal_mask_cache: dict[tuple[torch.device, int], torch.Tensor] = {}
83
+ self._config_duration_cache: dict[tuple[torch.device, int], torch.Tensor] = {}
84
+
85
+ def remaining_beats(self) -> float:
86
+ return max(0.0, self.total_beats - self.current_beat)
87
+
88
+ def remaining_duration_beats(self) -> float:
89
+ start = self.pending_duration_start_beat
90
+ if start is None:
91
+ start = self.current_beat
92
+ return max(0.0, min(self.total_beats - start, self.max_duration_beats))
93
+
94
+ def allowed_tokens(self) -> set[int]:
95
+ allowed = set(self.grammar.allowed_tokens())
96
+ remaining = self.remaining_duration_beats()
97
+
98
+ if self.grammar.mode is GrammarMode.DUR_NUM:
99
+ nums = {DUR_NUM_TO_ID[b] for b in VALID_DUR_NUMS if b / max(VALID_DUR_DENS) <= remaining + 1e-6}
100
+ return allowed & nums if nums else {DUR_NUM_TO_ID[1]}
101
+
102
+ if self.grammar.mode is GrammarMode.DUR_DEN:
103
+ beat_num = self.pending_dur_num or 1
104
+ dens = {DUR_DEN_TO_ID[d] for d in VALID_DUR_DENS if beat_num / d <= remaining + 1e-6}
105
+ return allowed & dens if dens else {DUR_DEN_TO_ID[64]}
106
+
107
+ filtered: set[int] = set()
108
+ for tok in allowed:
109
+ if self.is_candidate_safe(tok):
110
+ filtered.add(tok)
111
+ return filtered
112
+
113
+ def can_start_sim(self) -> bool:
114
+ return self.grammar.mode is GrammarMode.NORMAL and self.max_sim >= 2
115
+
116
+ def is_candidate_safe(self, tok: int) -> bool:
117
+ if self.grammar.mode is not GrammarMode.NORMAL:
118
+ return True
119
+ if tok in (PAD, BOS, EOS):
120
+ return False
121
+ if tok == SIM_BEG and self.max_sim < 2:
122
+ return False
123
+ if tok in ID_TO_DIV:
124
+ next_div = float(ID_TO_DIV[tok])
125
+ return self.remaining_beats() >= 4.0 / max(next_div, 1.0) - 1e-6
126
+ if tok in HLD_TO_ID.values() or tok == SLD_BEG:
127
+ if self.remaining_beats() < 1.0 / max(VALID_DUR_DENS):
128
+ return False
129
+ if self._would_advance_time(tok):
130
+ step = 4.0 / max(self.div_value, 1.0)
131
+ return self.current_beat + step <= self.total_beats + 1e-6
132
+ return True
133
+
134
+ def apply_logits_mask(self, logits: torch.Tensor) -> torch.Tensor:
135
+ masked = torch.full_like(logits, float("-inf"))
136
+ vocab_size = int(logits.numel())
137
+
138
+ if self.grammar.mode is GrammarMode.NORMAL:
139
+ mask = self._normal_mask(logits.device, vocab_size).clone()
140
+
141
+ step = 4.0 / max(self.div_value, 1.0)
142
+ if self.current_beat + step > self.total_beats + 1e-6:
143
+ timeline_ids = [RST, SIM_BEG, SLD_BEG, *NOTE_TOKENS, *ID_TO_CONFIG.keys()]
144
+ timeline_ids = [t for t in timeline_ids if 0 <= int(t) < vocab_size]
145
+ if timeline_ids:
146
+ mask[torch.tensor(timeline_ids, dtype=torch.long, device=logits.device)] = False
147
+
148
+ remaining = self.remaining_duration_beats()
149
+ if remaining < 1.0 / max(VALID_DUR_DENS):
150
+ risky_ids = [SLD_BEG, *HLD_TO_ID.values()]
151
+ risky_ids = [t for t in risky_ids if 0 <= int(t) < vocab_size]
152
+ if risky_ids:
153
+ mask[torch.tensor(risky_ids, dtype=torch.long, device=logits.device)] = False
154
+
155
+ cfg_dur = self._config_duration_tensor(logits.device, vocab_size)
156
+ mask &= cfg_dur <= remaining + 1e-6
157
+
158
+ for div_tok, div_value in ID_TO_DIV.items():
159
+ if 0 <= div_tok < vocab_size:
160
+ mask[div_tok] = self.remaining_beats() >= 4.0 / max(float(div_value), 1.0) - 1e-6
161
+
162
+ masked[mask] = logits[mask]
163
+ return masked
164
+
165
+ allowed = self.allowed_tokens()
166
+ if allowed:
167
+ idx = torch.tensor([t for t in sorted(allowed) if 0 <= t < vocab_size],
168
+ dtype=torch.long, device=logits.device)
169
+ if idx.numel() > 0:
170
+ masked[idx] = logits[idx]
171
+ return masked
172
+
173
+ def _normal_mask(self, device: torch.device, vocab_size: int) -> torch.Tensor:
174
+ key = (device, vocab_size)
175
+ cached = self._normal_mask_cache.get(key)
176
+ if cached is not None:
177
+ return cached
178
+
179
+ mask = torch.zeros(vocab_size, dtype=torch.bool, device=device)
180
+ ids = {RST, SLD_BEG} | set(NOTE_TOKENS) | set(ID_TO_CONFIG.keys()) | set(self.grammar.allowed_div_tokens)
181
+ if self.max_sim >= 2:
182
+ ids.add(SIM_BEG)
183
+ ids.discard(PAD)
184
+ ids.discard(BOS)
185
+ ids.discard(EOS)
186
+ valid = [int(t) for t in ids if 0 <= int(t) < vocab_size]
187
+ if valid:
188
+ mask[torch.tensor(valid, dtype=torch.long, device=device)] = True
189
+ self._normal_mask_cache[key] = mask
190
+ return mask
191
+
192
+ def _config_duration_tensor(self, device: torch.device, vocab_size: int) -> torch.Tensor:
193
+ key = (device, vocab_size)
194
+ cached = self._config_duration_cache.get(key)
195
+ if cached is not None:
196
+ return cached
197
+
198
+ dur = torch.zeros(vocab_size, dtype=torch.float32, device=device)
199
+ for token_id, spec in ID_TO_CONFIG.items():
200
+ if 0 <= token_id < vocab_size:
201
+ d = _config_duration_beats(spec)
202
+ if d > 0:
203
+ dur[token_id] = float(d)
204
+ self._config_duration_cache[key] = dur
205
+ return dur
206
+
207
+ def step(self, tok: int) -> None:
208
+ mode_before = self.grammar.mode
209
+
210
+ if mode_before is GrammarMode.NORMAL:
211
+ if tok in HLD_TO_ID.values():
212
+ self.pending_duration_start_beat = self.current_beat
213
+ elif tok == SLD_BEG:
214
+ self.slide_start_beat = self.current_beat
215
+ elif tok == SIM_BEG:
216
+ self.sim_start_beat = self.current_beat
217
+
218
+ if mode_before is GrammarMode.SLIDE_BODY and tok == SLD_END_TOKEN:
219
+ self.pending_duration_start_beat = self.slide_start_beat
220
+ self.slide_start_beat = None
221
+
222
+ if mode_before is GrammarMode.SIM_BODY and tok == SIM_END and self.grammar.sim_has_hold:
223
+ self.pending_duration_start_beat = self.sim_start_beat
224
+ self.sim_start_beat = None
225
+
226
+ if mode_before is GrammarMode.DUR_NUM:
227
+ self.pending_dur_num = ID_TO_DUR_NUM.get(int(tok), int(tok))
228
+ elif mode_before is GrammarMode.DUR_DEN and self.pending_dur_num is not None:
229
+ den_value = ID_TO_DUR_DEN.get(int(tok), max(int(tok), 1))
230
+ beat, den = snap_duration(self.pending_dur_num, den_value,
231
+ self.remaining_duration_beats(),
232
+ self.max_duration_beats)
233
+ self.pending_dur_num = None
234
+ self.pending_duration_start_beat = None
235
+
236
+ in_sim_body = self.grammar.mode in (GrammarMode.SIM_COUNT, GrammarMode.SIM_BODY)
237
+ in_slide_body = self.slide_group_active and tok != SLD_BEG
238
+ if self.skip_beat > 0:
239
+ self.skip_beat -= 1
240
+ elif tok == DUR_TOKEN:
241
+ self.skip_beat = 2
242
+ elif tok in ID_TO_DIV:
243
+ self.div_value = float(ID_TO_DIV[tok])
244
+ elif is_timeline_token(torch.tensor(tok)) and not in_sim_body and not in_slide_body:
245
+ self.current_beat += 4.0 / max(self.div_value, 1.0)
246
+
247
+ if tok == SLD_BEG:
248
+ self.slide_group_active = True
249
+ elif tok == SLD_END_TOKEN:
250
+ self.slide_group_active = False
251
+
252
+ self.grammar.step(tok)
253
+
254
+ def _would_advance_time(self, tok: int) -> bool:
255
+ if self.skip_beat > 0 or tok == DUR_TOKEN:
256
+ return False
257
+ in_sim_body = self.grammar.mode in (GrammarMode.SIM_COUNT, GrammarMode.SIM_BODY)
258
+ in_slide_body = self.slide_group_active and tok != SLD_BEG
259
+ return bool(is_timeline_token(torch.tensor(tok)) and not in_sim_body and not in_slide_body)
260
+
261
+
262
+ def clamp_duration_tokens(tokens: list[int], total_beats: float,
263
+ start_offset_beats: float, min_division: int = 4) -> list[int]:
264
+ """Clamp generated hold/slide durations so notes cannot extend past track end."""
265
+ result = list(tokens)
266
+ state = DecodeState(bpm=150.0, total_beats=total_beats,
267
+ start_offset_beats=start_offset_beats,
268
+ min_division=min_division)
269
+ i = 0
270
+ while i < len(result):
271
+ tok = result[i]
272
+ if tok in ID_TO_CONFIG:
273
+ result[i] = _replace_config_duration_token(
274
+ tok,
275
+ state.remaining_duration_beats(),
276
+ state.max_duration_beats,
277
+ )
278
+ state.step(result[i])
279
+ i += 1
280
+ continue
281
+ if tok == DUR and i + 2 < len(result):
282
+ result[i + 1], result[i + 2] = snap_duration(
283
+ ID_TO_DUR_NUM.get(result[i + 1], result[i + 1]),
284
+ ID_TO_DUR_DEN.get(result[i + 2], result[i + 2]),
285
+ state.remaining_duration_beats(),
286
+ state.max_duration_beats
287
+ )
288
+ result[i + 1] = DUR_NUM_TO_ID[result[i + 1]]
289
+ result[i + 2] = DUR_DEN_TO_ID[result[i + 2]]
290
+ state.step(tok)
291
+ state.step(result[i + 1])
292
+ state.step(result[i + 2])
293
+ i += 3
294
+ continue
295
+ state.step(tok)
296
+ i += 1
297
+ return result
298
+
299
+
300
+ def duration_report(tokens: list[int], total_beats: float, start_offset_beats: float,
301
+ min_division: int = 4) -> dict[str, float | int]:
302
+ state = DecodeState(bpm=150.0, total_beats=total_beats,
303
+ start_offset_beats=start_offset_beats,
304
+ min_division=min_division)
305
+ n_dur = 0
306
+ max_dur = 0.0
307
+ overrun = 0
308
+ i = 0
309
+ while i < len(tokens):
310
+ tok = tokens[i]
311
+ if tok in ID_TO_CONFIG:
312
+ dur = _config_duration_beats(ID_TO_CONFIG[tok])
313
+ if dur > 0:
314
+ n_dur += 1
315
+ max_dur = max(max_dur, dur)
316
+ if state.current_beat + dur > total_beats + 1e-6:
317
+ overrun += 1
318
+ if tok == DUR and i + 2 < len(tokens):
319
+ num = ID_TO_DUR_NUM.get(tokens[i + 1], tokens[i + 1])
320
+ den = ID_TO_DUR_DEN.get(tokens[i + 2], tokens[i + 2])
321
+ dur = max(float(num), 1.0) / max(float(den), 1.0)
322
+ n_dur += 1
323
+ max_dur = max(max_dur, dur)
324
+ start = state.pending_duration_start_beat
325
+ if start is None:
326
+ start = state.current_beat
327
+ if start + dur > total_beats + 1e-6:
328
+ overrun += 1
329
+ state.step(tok)
330
+ i += 1
331
+ return {"durations": n_dur, "max_beats": max_dur, "overrun": overrun}
332
+
333
+
334
+ def simultaneous_report(tokens: list[int], max_sim: int = 2) -> dict[str, int]:
335
+ n_sim = 0
336
+ max_count = 0
337
+ over_limit = 0
338
+ first_bad = -1
339
+ i = 0
340
+ while i < len(tokens):
341
+ tok = tokens[i]
342
+ if tok == SIM_BEG and i + 1 < len(tokens):
343
+ count = 2 if tokens[i + 1] == SIM_COUNT_2 else int(tokens[i + 1])
344
+ n_sim += 1
345
+ max_count = max(max_count, count)
346
+ if count > max_sim:
347
+ over_limit += 1
348
+ if first_bad < 0:
349
+ first_bad = i
350
+ i += 2
351
+ continue
352
+ i += 1
353
+ return {"groups": n_sim, "max_count": max_count, "over_limit": over_limit, "first_bad": first_bad}
354
+
355
+
356
+ def sanitize_sim_tokens(tokens: list[int], max_sim: int = 2) -> list[int]:
357
+ """Remove malformed or over-wide SIM groups before decode/export."""
358
+ result: list[int] = []
359
+ i = 0
360
+ while i < len(tokens):
361
+ tok = tokens[i]
362
+ if tok != SIM_BEG:
363
+ result.append(tok)
364
+ i += 1
365
+ continue
366
+
367
+ if i + 1 >= len(tokens):
368
+ i += 1
369
+ continue
370
+
371
+ declared = int(tokens[i + 1])
372
+ j = i + 2
373
+ body: list[int] = []
374
+ while j < len(tokens) and tokens[j] not in (SIM_END, EOS):
375
+ if tokens[j] in NOTE_TOKENS:
376
+ body.append(tokens[j])
377
+ j += 1
378
+
379
+ declared_count = 2 if declared == SIM_COUNT_2 else declared
380
+ if declared_count == 2 and len(body) == 2 and max_sim >= 2:
381
+ result.extend([SIM_BEG, SIM_COUNT_2, body[0], body[1], SIM_END])
382
+ elif len(body) >= 2 and max_sim >= 2:
383
+ result.extend([SIM_BEG, SIM_COUNT_2, body[0], body[1], SIM_END])
384
+ result.extend(body[2:])
385
+ else:
386
+ result.extend(body[:1])
387
+
388
+ i = j + 1 if j < len(tokens) and tokens[j] == SIM_END else j
389
+ return result
grammar.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum, auto
5
+
6
+ import torch
7
+ from tokenizer import (
8
+ BOS, EOS, HLD_ON, MASK, META_BPM, META_END, PAD, RST, SEP, SLD_MID, SLD_ON,
9
+ BRK_TO_ID, DUR, HLD_TO_ID, ID_TO_DIV, SIM_BEG, SIM_END, SLD_BEG,
10
+ SLD_END_TOKEN, SLD_TO_ID, TAP_TO_ID, TCH_TO_ID,
11
+ SIM_COUNT_2, DUR_NUM_TO_ID, DUR_DEN_TO_ID,
12
+ CONFIG_BASE,
13
+ )
14
+
15
+
16
+ class GrammarMode(Enum):
17
+ NORMAL = auto()
18
+ SIM_COUNT = auto()
19
+ SIM_BODY = auto()
20
+ NEED_DUR = auto()
21
+ DUR_NUM = auto()
22
+ DUR_DEN = auto()
23
+ SLIDE_BODY = auto()
24
+
25
+
26
+ NOTE_TOKENS = (
27
+ set(TAP_TO_ID.values())
28
+ | set(BRK_TO_ID.values())
29
+ | set(HLD_TO_ID.values())
30
+ | set(SLD_TO_ID.values())
31
+ | set(TCH_TO_ID.values())
32
+ )
33
+ HOLD_TOKENS = set(HLD_TO_ID.values())
34
+ SLIDE_POINT_TOKENS = set(SLD_TO_ID.values())
35
+ META_TOKENS = set(range(META_BPM, META_END + 1))
36
+ ENGINE_ONLY_TOKENS = {PAD, BOS, EOS, SEP, MASK, HLD_ON, SLD_ON, SLD_MID} | META_TOKENS
37
+ # Duration parameters are raw integer tokens, not normal vocabulary tokens.
38
+ # Keep them in the same compact domain produced by real charts; allowing the
39
+ # whole uint8 range lets generation sample special/control-looking ids and
40
+ # absurdly long holds/slides.
41
+ VALID_DUR_NUMS = set(DUR_NUM_TO_ID.values())
42
+ VALID_DUR_DENS = set(DUR_DEN_TO_ID.values())
43
+
44
+
45
+ def config_tokens() -> set[int]:
46
+ # Config vocab can be extended after importing this module when a checkpoint
47
+ # loads its learned config_vocab. Keep this dynamic instead of freezing at import.
48
+ import tokenizer as chart_tokenizer
49
+ return set(chart_tokenizer.ID_TO_CONFIG.keys())
50
+
51
+
52
+ @dataclass
53
+ class ChartGrammarState:
54
+ """Hard token grammar for chart generation.
55
+
56
+ This keeps the sequence parseable. It intentionally does not enforce chart
57
+ quality or muri rules; those belong in a later event/window validator.
58
+ """
59
+
60
+ min_division: int = 1
61
+ max_sim: int = 2
62
+ mode: GrammarMode = GrammarMode.NORMAL
63
+ sim_target: int = 0
64
+ sim_seen: int = 0
65
+ sim_has_hold: bool = False
66
+ slide_points: int = 0
67
+ last_event_token: int | None = None
68
+ can_take_duration: bool = False
69
+ active_slide: bool = False
70
+ allowed_div_tokens: set[int] = field(default_factory=set)
71
+
72
+ def __post_init__(self) -> None:
73
+ self.allowed_div_tokens = {
74
+ div_id for div_id, div_value in ID_TO_DIV.items()
75
+ if div_value >= self.min_division
76
+ }
77
+
78
+ def allowed_tokens(self) -> set[int]:
79
+ if self.mode is GrammarMode.SIM_COUNT:
80
+ return {SIM_COUNT_2} if self.max_sim >= 2 else set()
81
+
82
+ if self.mode is GrammarMode.SIM_BODY:
83
+ if self.sim_seen >= self.sim_target:
84
+ return {SIM_END}
85
+ return NOTE_TOKENS - SLIDE_POINT_TOKENS
86
+
87
+ if self.mode is GrammarMode.NEED_DUR:
88
+ return {DUR}
89
+
90
+ if self.mode is GrammarMode.DUR_NUM:
91
+ return VALID_DUR_NUMS
92
+
93
+ if self.mode is GrammarMode.DUR_DEN:
94
+ return VALID_DUR_DENS
95
+
96
+ if self.mode is GrammarMode.SLIDE_BODY:
97
+ allowed = set(SLIDE_POINT_TOKENS)
98
+ if self.slide_points >= 2:
99
+ allowed.add(SLD_END_TOKEN)
100
+ return allowed
101
+
102
+ allowed = {RST, SIM_BEG, SLD_BEG} | NOTE_TOKENS | config_tokens() | self.allowed_div_tokens
103
+ return allowed - ENGINE_ONLY_TOKENS
104
+
105
+ def apply_logits_mask(self, logits: torch.Tensor) -> torch.Tensor:
106
+ allowed = self.allowed_tokens()
107
+ masked = torch.full_like(logits, float("-inf"))
108
+ idx = torch.tensor(sorted(allowed), dtype=torch.long, device=logits.device)
109
+ masked[idx] = logits[idx]
110
+ return masked
111
+
112
+ def step(self, token: int) -> None:
113
+ if self.mode is GrammarMode.SIM_COUNT:
114
+ self.sim_target = 2 if token == SIM_COUNT_2 else min(max(int(token), 1), self.max_sim)
115
+ self.sim_seen = 0
116
+ self.sim_has_hold = False
117
+ self.mode = GrammarMode.SIM_BODY
118
+ return
119
+
120
+ if self.mode is GrammarMode.SIM_BODY:
121
+ if token == SIM_END:
122
+ self.last_event_token = SIM_END
123
+ if self.sim_has_hold:
124
+ self.mode = GrammarMode.NEED_DUR
125
+ self.can_take_duration = True
126
+ else:
127
+ self.mode = GrammarMode.NORMAL
128
+ self.can_take_duration = False
129
+ else:
130
+ if token in HOLD_TOKENS:
131
+ self.sim_has_hold = True
132
+ self.sim_seen += 1
133
+ return
134
+
135
+ if self.mode is GrammarMode.NEED_DUR:
136
+ if token == DUR:
137
+ self.mode = GrammarMode.DUR_NUM
138
+ return
139
+
140
+ if self.mode is GrammarMode.DUR_NUM:
141
+ self.mode = GrammarMode.DUR_DEN
142
+ return
143
+
144
+ if self.mode is GrammarMode.DUR_DEN:
145
+ self.mode = GrammarMode.NORMAL
146
+ self.last_event_token = None
147
+ self.can_take_duration = False
148
+ return
149
+
150
+ if self.mode is GrammarMode.SLIDE_BODY:
151
+ if token == SLD_END_TOKEN:
152
+ self.mode = GrammarMode.NEED_DUR
153
+ self.active_slide = False
154
+ self.last_event_token = SLD_END_TOKEN
155
+ self.can_take_duration = True
156
+ elif token in SLIDE_POINT_TOKENS:
157
+ self.slide_points += 1
158
+ return
159
+
160
+ if token == SIM_BEG:
161
+ self.mode = GrammarMode.SIM_COUNT
162
+ self.last_event_token = SIM_BEG
163
+ self.can_take_duration = False
164
+ elif token == SLD_BEG:
165
+ self.mode = GrammarMode.SLIDE_BODY
166
+ self.slide_points = 0
167
+ self.active_slide = True
168
+ self.last_event_token = SLD_BEG
169
+ self.can_take_duration = False
170
+ elif token == DUR:
171
+ self.mode = GrammarMode.DUR_NUM
172
+ elif token in ID_TO_DIV:
173
+ return
174
+ elif token == RST or token in NOTE_TOKENS or token >= CONFIG_BASE:
175
+ self.last_event_token = token
176
+ if token in HOLD_TOKENS:
177
+ self.mode = GrammarMode.NEED_DUR
178
+ self.can_take_duration = True
179
+ else:
180
+ self.can_take_duration = False
inference.py ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference script for MaiGenerator — generate maimai charts from audio.
3
+
4
+ Usage:
5
+ python inference.py --checkpoint checkpoints/best.pt --audio datasets/10/track.mp3
6
+ python inference.py --checkpoint checkpoints/best.pt --song-id 10
7
+ python inference.py --checkpoint checkpoints/best.pt --audio track.mp3 --bpm 173 --diff MASTER --level 12.4
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn.functional as F
19
+
20
+ from tqdm import tqdm
21
+
22
+ from model import MaiGenerator, BOS, EOS, DUR_TOKEN, AUDIO_FRAME_RATE
23
+ from Tokenizer.MaiTrackTokenizer import MaiTrackTokenizer
24
+ from tokenizer import (
25
+ MaiChartTokenizer, tokens_to_maitext, PAD, SIM_BEG, RST,
26
+ TAP_TO_ID, BRK_TO_ID, HLD_TO_ID, SLD_TO_ID, TCH_TO_ID,
27
+ )
28
+ from tokenizer import ID_TO_DUR_NUM, ID_TO_DUR_DEN, DUR_NUM_TO_ID, DUR_DEN_TO_ID
29
+ from tokenizer import load_config_vocab, ID_TO_CONFIG
30
+ from mai_parser import Difficulty, parse_maidata_file
31
+ from grammar import NOTE_TOKENS
32
+ from constrained_decode import (
33
+ DecodeState, snap_duration, clamp_duration_tokens, duration_report,
34
+ simultaneous_report, sanitize_sim_tokens,
35
+ )
36
+
37
+
38
+ def sample_token(logits: torch.Tensor, temperature: float = 0.8,
39
+ top_k: int = 50, top_p: float = 0.95,
40
+ repetition_penalty: float = 1.0,
41
+ prev_tokens: list[int] | None = None,
42
+ penalty_window: int = 50) -> int:
43
+ """Sample a token with temperature, top-k, top-p, and repetition penalty.
44
+
45
+ Args:
46
+ repetition_penalty: >1.0 penalizes repeated tokens; 1.0 = off.
47
+ prev_tokens: Recent token history for repetition detection.
48
+ penalty_window: How many recent tokens to check for repeats.
49
+ """
50
+ # Repetition penalty: reduce logits for tokens that appear in recent history
51
+ if repetition_penalty > 1.0 and prev_tokens:
52
+ recent = set(prev_tokens[-penalty_window:])
53
+ for tok in recent:
54
+ logits[tok] = logits[tok] / repetition_penalty
55
+
56
+ logits = logits / max(temperature, 1e-8)
57
+ logits = torch.nan_to_num(logits, nan=-1e9, posinf=1e9, neginf=-1e9)
58
+
59
+ if top_k > 0:
60
+ k = min(top_k, logits.size(-1))
61
+ min_val = torch.topk(logits, k).values[-1]
62
+ logits[logits < min_val] = float("-inf")
63
+
64
+ if top_p < 1.0:
65
+ sorted_l, sorted_i = torch.sort(logits, descending=True)
66
+ cum = torch.cumsum(F.softmax(sorted_l, dim=-1), dim=-1)
67
+ mask = cum > top_p
68
+ mask[1:] = mask[:-1].clone()
69
+ mask[0] = False
70
+ sorted_l[mask] = float("-inf")
71
+ logits = sorted_l.scatter(0, sorted_i, sorted_l)
72
+
73
+ probs = F.softmax(logits, dim=-1)
74
+ return torch.multinomial(probs, 1).item()
75
+
76
+
77
+ def _config_note_count(spec: tuple) -> int:
78
+ if not spec:
79
+ return 0
80
+ kind = spec[0]
81
+ if kind == "pair":
82
+ return 2
83
+ if kind == "multi":
84
+ return max(1, len(spec) - (4 if spec[1] == "hld" else 2))
85
+ if kind == "touch_multi":
86
+ return max(1, len(spec) - 1)
87
+ return 1
88
+
89
+
90
+ def _token_note_count(tok: int) -> int:
91
+ if tok in NOTE_TOKENS:
92
+ return 1
93
+ spec = ID_TO_CONFIG.get(tok)
94
+ if spec is not None:
95
+ return _config_note_count(spec)
96
+ return 0
97
+
98
+
99
+ def _config_has_break(spec: tuple) -> bool:
100
+ if not spec:
101
+ return False
102
+ if spec[0] == "brk":
103
+ return True
104
+ if spec[0] == "pair":
105
+ return spec[1] == "brk" or spec[3] == "brk"
106
+ if spec[0] == "multi":
107
+ return len(spec) > 1 and spec[1] == "brk"
108
+ return False
109
+
110
+
111
+ def _config_type_counts(spec: tuple) -> dict[str, int]:
112
+ if not spec:
113
+ return {}
114
+ kind = spec[0]
115
+ if kind == "tap":
116
+ return {"tap": 1}
117
+ if kind == "brk":
118
+ return {"break": 1}
119
+ if kind == "hld":
120
+ return {"hold": 1}
121
+ if kind == "sld":
122
+ return {"slide": 1}
123
+ if kind in ("tch", "touch_multi"):
124
+ return {"touch": 1}
125
+ if kind == "pair":
126
+ counts: dict[str, int] = {}
127
+ for typ in (spec[1], spec[3]):
128
+ key = "break" if typ == "brk" else "hold" if typ == "hld" else "tap"
129
+ counts[key] = counts.get(key, 0) + 1
130
+ return counts
131
+ if kind == "multi":
132
+ typ = spec[1]
133
+ key = "break" if typ == "brk" else "hold" if typ == "hld" else "tap"
134
+ if typ == "hld":
135
+ n = max(1, len(spec) - 4)
136
+ else:
137
+ n = max(1, len(spec) - 2)
138
+ return {key: n}
139
+ return {}
140
+
141
+
142
+ def _token_has_break(tok: int) -> bool:
143
+ if tok in BRK_TO_ID.values():
144
+ return True
145
+ spec = ID_TO_CONFIG.get(tok)
146
+ return spec is not None and _config_has_break(spec)
147
+
148
+
149
+ def _break_ids(vocab_size: int, device: torch.device) -> torch.Tensor:
150
+ ids = set(BRK_TO_ID.values())
151
+ ids.update(t for t, spec in ID_TO_CONFIG.items() if _config_has_break(spec))
152
+ valid = [int(t) for t in ids if 0 <= int(t) < vocab_size]
153
+ return torch.tensor(valid, dtype=torch.long, device=device)
154
+
155
+
156
+ def _density_note_ids(vocab_size: int, device: torch.device,
157
+ include_break: bool = False) -> torch.Tensor:
158
+ ids = set(NOTE_TOKENS) | set(ID_TO_CONFIG.keys())
159
+ if not include_break:
160
+ ids = {t for t in ids if not _token_has_break(int(t))}
161
+ valid = [int(t) for t in ids if 0 <= int(t) < vocab_size]
162
+ return torch.tensor(valid, dtype=torch.long, device=device)
163
+
164
+
165
+ def _type_bias_vector(vocab_size: int, device: torch.device,
166
+ type_biases: dict[str, float]) -> torch.Tensor | None:
167
+ active = {k: float(v) for k, v in type_biases.items() if abs(float(v)) > 1e-9}
168
+ if not active:
169
+ return None
170
+ bias = torch.zeros(vocab_size, dtype=torch.float32, device=device)
171
+ token_groups = [
172
+ ("tap", TAP_TO_ID.values()),
173
+ ("hold", HLD_TO_ID.values()),
174
+ ("slide", SLD_TO_ID.values()),
175
+ ("break", BRK_TO_ID.values()),
176
+ ("touch", TCH_TO_ID.values()),
177
+ ]
178
+ for name, ids in token_groups:
179
+ value = active.get(name, 0.0)
180
+ if value == 0.0:
181
+ continue
182
+ valid = [int(t) for t in ids if 0 <= int(t) < vocab_size]
183
+ if valid:
184
+ bias[torch.tensor(valid, dtype=torch.long, device=device)] += value
185
+ if active.get("rest", 0.0) and 0 <= RST < vocab_size:
186
+ bias[RST] += active["rest"]
187
+ if active.get("sim", 0.0) and 0 <= SIM_BEG < vocab_size:
188
+ bias[SIM_BEG] += active["sim"]
189
+
190
+ for token_id, spec in ID_TO_CONFIG.items():
191
+ if not (0 <= int(token_id) < vocab_size):
192
+ continue
193
+ for name, count in _config_type_counts(spec).items():
194
+ value = active.get(name, 0.0)
195
+ if value:
196
+ bias[int(token_id)] += value * max(1, int(count))
197
+ return bias
198
+
199
+
200
+ def _config_density_bias_vector(vocab_size: int, device: torch.device,
201
+ strength: float,
202
+ include_break: bool = False) -> torch.Tensor | None:
203
+ if abs(float(strength)) <= 1e-9:
204
+ return None
205
+ bias = torch.zeros(vocab_size, dtype=torch.float32, device=device)
206
+ for token_id, spec in ID_TO_CONFIG.items():
207
+ if not (0 <= int(token_id) < vocab_size):
208
+ continue
209
+ if not include_break and _config_has_break(spec):
210
+ continue
211
+ extra_notes = max(0, _config_note_count(spec) - 1)
212
+ if extra_notes > 0:
213
+ bias[int(token_id)] += float(strength) * extra_notes
214
+ return bias
215
+
216
+
217
+ def _auto_target_notes(level_value: float, duration_sec: float) -> int:
218
+ # Conservative maimai-ish density prior. It is only used when density bias is enabled.
219
+ notes_per_sec = float(np.interp(
220
+ level_value,
221
+ [10.0, 12.0, 13.0, 14.0, 14.5, 15.0],
222
+ [2.2, 3.2, 4.0, 4.8, 5.3, 5.8],
223
+ ))
224
+ return max(1, int(round(notes_per_sec * duration_sec)))
225
+
226
+
227
+ @torch.inference_mode()
228
+ def generate_chart(
229
+ model: MaiGenerator,
230
+ audio_tokens: torch.Tensor,
231
+ bpm: float,
232
+ difficulty: int,
233
+ level_value: float,
234
+ genre: int = 0,
235
+ max_tokens: int = 4096,
236
+ temperature: float = 0.8,
237
+ top_k: int = 50,
238
+ top_p: float = 0.95,
239
+ eos_suppress: float = 1.5,
240
+ min_division: int = 4,
241
+ max_duration_beats: float = 4.0,
242
+ max_sim: int = 2,
243
+ sim_penalty: float = 1.0,
244
+ repetition_penalty: float = 1.0,
245
+ rest_streak_penalty: float = 0.0,
246
+ rest_streak_threshold: int = 4,
247
+ target_notes: int | None = None,
248
+ density_bias: float = 0.0,
249
+ break_penalty: float = 0.0,
250
+ break_window: int = 64,
251
+ max_break_ratio: float = 0.08,
252
+ type_biases: dict[str, float] | None = None,
253
+ config_density_bias: float = 0.0,
254
+ config_density_include_break: bool = False,
255
+ ) -> list[int]:
256
+ """
257
+ Autoregressive chart generation from audio (with KV-cache for speed).
258
+
259
+ Args:
260
+ model: Trained MaiGenerator.
261
+ audio_tokens: [1, T_aud] — EnCodec tokens.
262
+ bpm: BPM value.
263
+ difficulty: 0=BASIC..4=ReMASTER.
264
+ level_value: e.g. 12.4.
265
+ genre: Genre index.
266
+ max_tokens: Max tokens to generate.
267
+ temperature: Sampling temperature.
268
+ top_k: Top-K filter.
269
+ top_p: Nucleus filter.
270
+
271
+ Returns:
272
+ List of chart token IDs (including BOS, metadata header, and EOS).
273
+ """
274
+ model.eval()
275
+ device = next(model.parameters()).device
276
+ param_dtype = next(model.parameters()).dtype
277
+
278
+ if audio_tokens.dim() == 1:
279
+ audio_tokens = audio_tokens.unsqueeze(0)
280
+ audio_tokens = audio_tokens.to(device)
281
+
282
+ # ── Encode audio (once) ──
283
+ max_len = model.audio_pos_embed.num_embeddings # 32768
284
+ if audio_tokens.shape[1] > max_len:
285
+ audio_tokens = audio_tokens[:, :max_len]
286
+ T_aud = audio_tokens.shape[1]
287
+ aud = model.audio_embed(audio_tokens)
288
+ aud = aud + model.audio_pos_embed(
289
+ torch.arange(T_aud, device=device).unsqueeze(0))
290
+
291
+ # Downsample audio (same as training forward)
292
+ if model.audio_downsample > 1:
293
+ aud = aud.transpose(1, 2)
294
+ aud = model.audio_down(aud)
295
+ aud = aud.transpose(1, 2)
296
+ T_aud = aud.shape[1]
297
+
298
+ aud = model.dropout(aud)
299
+ aud_pos = torch.arange(T_aud, device=device, dtype=torch.float32)
300
+ for blk in model.audio_encoder:
301
+ aud = blk(aud, positions=aud_pos)
302
+ aud = model.enc_norm(aud)
303
+
304
+ # ── Conditions ──
305
+ bpm_t = torch.tensor([[bpm]], device=device, dtype=param_dtype)
306
+ diff_t = torch.tensor([[difficulty]], device=device)
307
+ level_t = torch.tensor([[level_value]], device=device, dtype=param_dtype)
308
+ genre_t = torch.tensor([[genre]], device=device)
309
+
310
+ cond = (model.bpm_proj(bpm_t).unsqueeze(1) +
311
+ model.diff_embed(diff_t.squeeze(-1)).unsqueeze(1) +
312
+ model.level_proj(level_t).unsqueeze(1) +
313
+ model.genre_embed(genre_t.squeeze(-1)).unsqueeze(1))
314
+
315
+ diff_vec = model.diff_embed(diff_t.squeeze(-1)) # [1, d_model]
316
+
317
+ # Total beats
318
+ audio_duration_sec = audio_tokens.shape[1] / 2 / AUDIO_FRAME_RATE
319
+ total_beats = audio_duration_sec * bpm / 60.0
320
+ start_offset_beats = 2.0 * max(bpm, 30.0) / 60.0
321
+ print(f"Track: {audio_duration_sec:.0f}s = {total_beats:.0f} beats @ {bpm} BPM (start at beat {start_offset_beats:.1f})")
322
+ if density_bias > 0 and target_notes is None:
323
+ target_notes = _auto_target_notes(level_value, audio_duration_sec)
324
+ if density_bias > 0 and target_notes:
325
+ print(f"Density target: {target_notes} notes bias={density_bias}")
326
+
327
+ # ── Init KV-cache ──
328
+ model.init_kv_cache(batch_size=1, max_len=max_tokens + 1, enc_out=aud)
329
+
330
+ # ── Prefill with BOS token ──
331
+ tokens = [BOS]
332
+ pos_cache = [0.0]
333
+
334
+ tok_t = torch.tensor([[BOS]], device=device)
335
+ emb = model.chart_embed(tok_t) + cond # cond adds batch, tok_t is [1,1]
336
+ chart_pos = torch.tensor([[0.0]], device=device, dtype=torch.float32)
337
+
338
+ x = emb
339
+ for blk in model.chart_decoder:
340
+ x = blk(x, enc_out=aud, self_positions=chart_pos,
341
+ diff_emb=diff_vec, use_cache=True)
342
+
343
+ # ── Autoregressive loop (KV-cached, one token at a time) ──
344
+ decode_state = DecodeState(
345
+ bpm=bpm,
346
+ total_beats=total_beats,
347
+ start_offset_beats=start_offset_beats,
348
+ min_division=min_division,
349
+ max_sim=max_sim,
350
+ max_duration_beats=max_duration_beats,
351
+ )
352
+ note_bonus_ids = _density_note_ids(model.chart_vocab_size, device, include_break=False) if density_bias > 0 else None
353
+ break_ids = _break_ids(model.chart_vocab_size, device) if break_penalty > 0 else None
354
+ type_bias = _type_bias_vector(model.chart_vocab_size, device, type_biases or {})
355
+ config_density_bias_vec = _config_density_bias_vector(
356
+ model.chart_vocab_size, device, config_density_bias,
357
+ include_break=config_density_include_break,
358
+ )
359
+ if type_bias is not None:
360
+ print("Type bias:", ", ".join(f"{k}={v:+.2f}" for k, v in (type_biases or {}).items() if abs(v) > 1e-9))
361
+ if config_density_bias_vec is not None:
362
+ print(f"Config density bias: {config_density_bias:+.2f} "
363
+ f"(include_break={config_density_include_break})")
364
+ generated_notes = 0
365
+ generated_breaks = 0
366
+
367
+ pbar = tqdm(total=max_tokens, desc="Generating", unit="tok")
368
+ for step_idx in range(max_tokens):
369
+ x_last = model.dec_norm(x[:, -1:, :])
370
+ logits = model.output_head(x_last).squeeze(0).squeeze(0)
371
+
372
+ # ── Constraints ──
373
+ logits[EOS] = float("-inf")
374
+
375
+ if decode_state.current_beat >= total_beats:
376
+ pbar.update(1); pbar.set_postfix(tokens=len(tokens)+1, beat=f"{decode_state.current_beat:.0f}/{total_beats:.0f}", end="track")
377
+ tokens.append(EOS)
378
+ break
379
+
380
+ logits = decode_state.apply_logits_mask(logits)
381
+ if type_bias is not None:
382
+ logits = logits + type_bias.to(dtype=logits.dtype)
383
+ if config_density_bias_vec is not None:
384
+ logits = logits + config_density_bias_vec.to(dtype=logits.dtype)
385
+ if decode_state.can_start_sim() and SIM_BEG < logits.numel():
386
+ logits[SIM_BEG] -= sim_penalty
387
+ if not torch.isfinite(logits).any():
388
+ pbar.update(1); pbar.set_postfix(tokens=len(tokens)+1, beat=f"{decode_state.current_beat:.0f}/{total_beats:.0f}", end="no_legal")
389
+ tokens.append(EOS)
390
+ break
391
+
392
+ # ── Rest-streak penalty: if last N notes are all rests, suppress RST ──
393
+ if rest_streak_penalty > 0 and len(tokens) >= rest_streak_threshold:
394
+ recent = tokens[-rest_streak_threshold:]
395
+ # Check if recent tokens are all RST (not counting structural tokens like DUR/SIM etc.)
396
+ if all(t == RST for t in recent):
397
+ logits[RST] -= rest_streak_penalty
398
+
399
+ if density_bias > 0 and target_notes and note_bonus_ids is not None and note_bonus_ids.numel() > 0:
400
+ progress = max(0.0, decode_state.current_beat - start_offset_beats)
401
+ denom = max(1e-6, total_beats - start_offset_beats)
402
+ expected = float(target_notes) * min(1.0, progress / denom)
403
+ deficit = max(0.0, expected - generated_notes) / max(expected, 1.0)
404
+ if deficit > 0:
405
+ bonus = density_bias * deficit
406
+ logits[note_bonus_ids] += bonus
407
+ if RST < logits.numel():
408
+ logits[RST] -= bonus
409
+
410
+ if break_penalty > 0 and break_ids is not None and break_ids.numel() > 0:
411
+ recent = tokens[-break_window:] if break_window > 0 else tokens
412
+ recent_notes = sum(_token_note_count(t) for t in recent)
413
+ recent_breaks = sum(_token_note_count(t) for t in recent if _token_has_break(t))
414
+ global_ratio = generated_breaks / max(generated_notes, 1)
415
+ recent_ratio = recent_breaks / max(recent_notes, 1)
416
+ excess = max(global_ratio - max_break_ratio, recent_ratio - max_break_ratio, 0.0)
417
+ if excess > 0:
418
+ logits[break_ids] -= break_penalty * (1.0 + 8.0 * excess)
419
+
420
+ next_tok = sample_token(logits, temperature, top_k, top_p,
421
+ repetition_penalty=repetition_penalty,
422
+ prev_tokens=tokens)
423
+ tokens.append(next_tok)
424
+ note_count = _token_note_count(next_tok)
425
+ generated_notes += note_count
426
+ if _token_has_break(next_tok):
427
+ generated_breaks += note_count
428
+
429
+ # ── Snap duration ──
430
+ if len(tokens) >= 4 and tokens[-4] not in (DUR_TOKEN, PAD) and tokens[-3] == DUR_TOKEN:
431
+ raw_num = ID_TO_DUR_NUM.get(tokens[-2], tokens[-2])
432
+ raw_den = ID_TO_DUR_DEN.get(next_tok, max(next_tok, 1))
433
+ beat, subdiv = snap_duration(raw_num, raw_den,
434
+ decode_state.remaining_duration_beats(),
435
+ decode_state.max_duration_beats)
436
+ tokens[-2], tokens[-1] = DUR_NUM_TO_ID[beat], DUR_DEN_TO_ID[subdiv]
437
+ next_tok = tokens[-1]
438
+
439
+ # ── Record position ──
440
+ bpm_v = max(bpm, 30.0)
441
+ time_sec = decode_state.current_beat * 60.0 / bpm_v
442
+ new_pos = time_sec * AUDIO_FRAME_RATE / model.audio_downsample
443
+ pos_cache.append(new_pos)
444
+
445
+ decode_state.step(next_tok)
446
+
447
+ # ── Incremental decoder step (KV-cached) ──
448
+ tok_t = torch.tensor([[next_tok]], device=device)
449
+ emb = model.chart_embed(tok_t) + cond
450
+ chart_pos = torch.tensor([[new_pos]], device=device, dtype=torch.float32)
451
+
452
+ x = emb
453
+ for blk in model.chart_decoder:
454
+ x = blk(x, enc_out=aud, self_positions=chart_pos,
455
+ diff_emb=diff_vec, use_cache=True)
456
+
457
+ pbar.update(1)
458
+ if len(tokens) % 200 == 0:
459
+ pct = min(100, decode_state.current_beat / total_beats * 100)
460
+ pbar.set_postfix(beat=f"{decode_state.current_beat:.0f}/{total_beats:.0f}",
461
+ pct=f"{pct:.0f}%", div=f"{decode_state.div_value}")
462
+ pbar.close()
463
+
464
+ tokens = sanitize_sim_tokens(tokens, max_sim=max_sim)
465
+ return clamp_duration_tokens(tokens, total_beats, start_offset_beats, min_division)
466
+
467
+
468
+ # ═══════════════════════════════════════════════════════════════════════
469
+ # CLI
470
+ # ═══════════════════════════════════════════════════════════════════════
471
+
472
+ DIFF_MAP = {"BASIC": 0, "ADVANCED": 1, "EXPERT": 2, "MASTER": 3, "REMASTER": 4}
473
+
474
+
475
+ def main():
476
+ parser = argparse.ArgumentParser(description="Generate maimai chart from audio")
477
+ parser.add_argument("--checkpoint", type=str, required=True,
478
+ help="Path to model checkpoint")
479
+ parser.add_argument("--audio", type=str, default=None,
480
+ help="Path to audio file (mp3/wav)")
481
+ parser.add_argument("--song-id", type=str, default=None,
482
+ help="Song ID in datasets/ (uses datasets/<id>/track.mp3)")
483
+ parser.add_argument("--bpm", type=float, default=None,
484
+ help="BPM (auto-detect from maidata.txt if --song-id)")
485
+ parser.add_argument("--diff", type=str, default="MASTER",
486
+ choices=list(DIFF_MAP.keys()))
487
+ parser.add_argument("--level", type=float, default=12.0,
488
+ help="Target level value")
489
+ parser.add_argument("--genre", type=int, default=0)
490
+ parser.add_argument("--max-tokens", type=int, default=4096)
491
+ parser.add_argument("--temperature", type=float, default=0.8)
492
+ parser.add_argument("--top-k", type=int, default=50)
493
+ parser.add_argument("--top-p", type=float, default=0.95)
494
+ parser.add_argument("--eos-suppress", type=float, default=1.5,
495
+ help="Subtract from EOS logit (0=off)")
496
+ parser.add_argument("--min-division", type=int, default=4,
497
+ help="Minimum generated beat division; blocks div_1/div_2 by default")
498
+ parser.add_argument("--max-duration-beats", type=float, default=4.0,
499
+ help="Maximum generated hold/slide duration in beats")
500
+ parser.add_argument("--max-sim", type=int, default=2,
501
+ help="Maximum simultaneous press count")
502
+ parser.add_argument("--sim-penalty", type=float, default=1.0,
503
+ help="Subtract from SIM_BEG logit before constrained sampling")
504
+ parser.add_argument("--rep-penalty", type=float, default=1.0,
505
+ help="Repetition penalty (>1.0=penalize repeats, 1.0=off)")
506
+ parser.add_argument("--rest-streak-penalty", type=float, default=0.0,
507
+ help="Penalize RST when last N tokens are all rests (try 2.0-5.0)")
508
+ parser.add_argument("--rest-streak-threshold", type=int, default=4,
509
+ help="Consecutive RSTs before applying rest-streak-penalty")
510
+ parser.add_argument("--target-notes", type=int, default=None,
511
+ help="Optional soft target note count for density-guided sampling")
512
+ parser.add_argument("--density-bias", type=float, default=0.0,
513
+ help="Softly boost note/config tokens when generated density lags target")
514
+ parser.add_argument("--break-penalty", type=float, default=1.5,
515
+ help="Type-level penalty when BREAK/绝赞 ratio is too high")
516
+ parser.add_argument("--break-window", type=int, default=64,
517
+ help="Recent token window used by BREAK ratio penalty")
518
+ parser.add_argument("--max-break-ratio", type=float, default=0.08,
519
+ help="Soft maximum BREAK/绝赞 ratio before penalty kicks in")
520
+ parser.add_argument("--tap-bias", type=float, default=0.0,
521
+ help="Logit bias for TAP config/tokens (+ increases, - decreases)")
522
+ parser.add_argument("--hold-bias", type=float, default=0.0,
523
+ help="Logit bias for HOLD config/tokens")
524
+ parser.add_argument("--slide-bias", type=float, default=0.0,
525
+ help="Logit bias for SLIDE config/tokens")
526
+ parser.add_argument("--break-bias", type=float, default=0.0,
527
+ help="Logit bias for BREAK/绝赞 config/tokens")
528
+ parser.add_argument("--touch-bias", type=float, default=0.0,
529
+ help="Logit bias for TOUCH config/tokens")
530
+ parser.add_argument("--rest-bias", type=float, default=0.0,
531
+ help="Logit bias for REST tokens")
532
+ parser.add_argument("--sim-bias", type=float, default=0.0,
533
+ help="Logit bias for simultaneous group start")
534
+ parser.add_argument("--config-density-bias", type=float, default=0.0,
535
+ help="Bias dense config tokens by extra notes per time slot")
536
+ parser.add_argument("--config-density-include-break", action="store_true",
537
+ help="Allow config-density-bias to boost BREAK/绝赞 configs too")
538
+ parser.add_argument("--output", type=str, default=None,
539
+ help="Output path for generated maidata.txt")
540
+ parser.add_argument("--device", type=str, default="cuda")
541
+ parser.add_argument("--precision", type=str, default="float32",
542
+ choices=["float32", "float16", "bfloat16"],
543
+ help="Model precision (float16/bfloat16 ≈2x faster on supported GPUs)")
544
+ parser.add_argument("--compile", action="store_true",
545
+ help="Use torch.compile for faster execution (PyTorch 2.0+)")
546
+ args = parser.parse_args()
547
+
548
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
549
+ print(f"Device: {device}")
550
+
551
+ # ── Load model ──
552
+ ckpt = torch.load(args.checkpoint, map_location=device, weights_only=False)
553
+ config = ckpt.get("config", {})
554
+ if "config_vocab" in config:
555
+ load_config_vocab(config["config_vocab"])
556
+ print(f"Loaded config vocab: total={MaiChartTokenizer.vocab_size}")
557
+ model = MaiGenerator(
558
+ d_model=config.get("d_model", 512),
559
+ enc_layers=config.get("enc_layers", 6),
560
+ dec_layers=config.get("dec_layers", 8),
561
+ heads=config.get("heads", 8),
562
+ d_ff=config.get("d_ff", 2048),
563
+ use_moe=config.get("use_moe", True),
564
+ n_experts=config.get("n_experts", 6),
565
+ moe_layers=config.get("moe_layers", None),
566
+ ).to(device)
567
+ model_state = model.state_dict()
568
+ filtered_state = {
569
+ k: v for k, v in ckpt["model_state"].items()
570
+ if k in model_state and tuple(v.shape) == tuple(model_state[k].shape)
571
+ }
572
+ skipped = len(ckpt["model_state"]) - len(filtered_state)
573
+ incompatible = model.load_state_dict(filtered_state, strict=False)
574
+ if skipped:
575
+ print(f"Model warm-start: skipped {skipped} shape-mismatched params")
576
+ if incompatible.missing_keys:
577
+ print(f"Model warm-start: initialized {len(incompatible.missing_keys)} new params")
578
+ if incompatible.unexpected_keys:
579
+ print(f"Model warm-start: ignored {len(incompatible.unexpected_keys)} checkpoint params")
580
+ model.eval()
581
+ print(f"Model loaded (epoch {ckpt.get('epoch', '?')})")
582
+
583
+ # ── Precision ──
584
+ if args.precision != "float32":
585
+ dt = {"float16": torch.float16, "bfloat16": torch.bfloat16}[args.precision]
586
+ model = model.to(dtype=dt)
587
+ print(f"Precision: {args.precision}")
588
+
589
+ # ── torch.compile ──
590
+ if args.compile:
591
+ try:
592
+ model = torch.compile(model, mode="reduce-overhead")
593
+ print("Model compiled with torch.compile (reduce-overhead)")
594
+ except Exception as e:
595
+ print(f"torch.compile failed: {e}, continuing without compile")
596
+
597
+ # ── Load / tokenize audio ──
598
+ audio_tok = MaiTrackTokenizer(n_layers=2, device=str(device))
599
+
600
+ if args.song_id:
601
+ audio_path = Path("datasets") / args.song_id / "track.mp3"
602
+ if not audio_path.exists():
603
+ print(f"ERROR: {audio_path} not found")
604
+ return
605
+ print(f"Audio: {audio_path}")
606
+ audio_tokens = audio_tok.encode(str(audio_path), add_bos=False, add_eos=False)
607
+
608
+ # Auto-detect BPM from maidata
609
+ if args.bpm is None:
610
+ mdata = Path("datasets") / args.song_id / "maidata.txt"
611
+ if mdata.exists():
612
+ song = parse_maidata_file(str(mdata))
613
+ bpm = song.bpm if song.bpm > 0 else 150.0
614
+ print(f"Auto-detected BPM: {bpm}")
615
+ else:
616
+ bpm = 150.0
617
+ else:
618
+ bpm = args.bpm
619
+ elif args.audio:
620
+ print(f"Audio: {args.audio}")
621
+ audio_tokens = audio_tok.encode(args.audio, add_bos=False, add_eos=False)
622
+ bpm = args.bpm or 150.0
623
+ else:
624
+ print("ERROR: need --audio or --song-id")
625
+ return
626
+
627
+ print(f"Audio tokens: {len(audio_tokens)}")
628
+
629
+ # ── Generate ──
630
+ difficulty = DIFF_MAP[args.diff.upper()]
631
+ print(f"Params: BPM={bpm}, Diff={args.diff}({difficulty}), Level={args.level}")
632
+ print(f"Generating (max {args.max_tokens} tokens)...")
633
+
634
+ audio_t = torch.tensor([audio_tokens], dtype=torch.long)
635
+ chart_tokens = generate_chart(
636
+ model, audio_t, bpm=bpm, difficulty=difficulty,
637
+ level_value=args.level, genre=args.genre,
638
+ max_tokens=args.max_tokens,
639
+ temperature=args.temperature, top_k=args.top_k, top_p=args.top_p,
640
+ eos_suppress=args.eos_suppress,
641
+ min_division=args.min_division,
642
+ max_duration_beats=args.max_duration_beats,
643
+ max_sim=args.max_sim,
644
+ sim_penalty=args.sim_penalty,
645
+ repetition_penalty=args.rep_penalty,
646
+ rest_streak_penalty=args.rest_streak_penalty,
647
+ rest_streak_threshold=args.rest_streak_threshold,
648
+ target_notes=args.target_notes,
649
+ density_bias=args.density_bias,
650
+ break_penalty=args.break_penalty,
651
+ break_window=args.break_window,
652
+ max_break_ratio=args.max_break_ratio,
653
+ type_biases={
654
+ "tap": args.tap_bias,
655
+ "hold": args.hold_bias,
656
+ "slide": args.slide_bias,
657
+ "break": args.break_bias,
658
+ "touch": args.touch_bias,
659
+ "rest": args.rest_bias,
660
+ "sim": args.sim_bias,
661
+ },
662
+ config_density_bias=args.config_density_bias,
663
+ config_density_include_break=args.config_density_include_break,
664
+ )
665
+
666
+ print(f"Generated {len(chart_tokens)} tokens")
667
+ audio_duration_sec = len(audio_tokens) / 2 / AUDIO_FRAME_RATE
668
+ total_beats = audio_duration_sec * bpm / 60.0
669
+ start_offset_beats = 2.0 * max(bpm, 30.0) / 60.0
670
+ dur_stats = duration_report(chart_tokens, total_beats, start_offset_beats, args.min_division)
671
+ print(f"Duration stats: count={dur_stats['durations']} "
672
+ f"max={dur_stats['max_beats']:.3f} beats overrun={dur_stats['overrun']}")
673
+ sim_stats = simultaneous_report(chart_tokens, args.max_sim)
674
+ print(f"Sim stats: groups={sim_stats['groups']} "
675
+ f"max_count={sim_stats['max_count']} over_limit={sim_stats['over_limit']} "
676
+ f"first_bad={sim_stats['first_bad']}")
677
+
678
+ # ── Decode to human-readable ──
679
+ chart_tok = MaiChartTokenizer()
680
+ print(f"\nTokens: {chart_tok.tokens_to_str(chart_tokens, 60)}")
681
+
682
+ # Decode to chart
683
+ decoded = chart_tok.decode(chart_tokens)
684
+ print(f"\nChart stats: TAP={decoded.tap_count} HOLD={decoded.hold_count} "
685
+ f"SLIDE={decoded.slide_count} BREAK={decoded.break_count} "
686
+ f"TOUCH={decoded.touch_count} TOTAL={decoded.note_count}")
687
+ print(f"Break ratio: {decoded.break_count / max(decoded.note_count, 1):.2%}")
688
+ print(f"Density: {decoded.note_count / max(audio_duration_sec, 1e-6):.2f} notes/s, "
689
+ f"{decoded.note_count / max(total_beats, 1e-6):.2f} notes/beat")
690
+
691
+ # ── Save ──
692
+ if args.output:
693
+ out_path = Path(args.output)
694
+ # Convert tokens → proper maidata chart text
695
+ chart_text = tokens_to_maitext(chart_tokens, bpm)
696
+ lines = [f"&title=Generated Chart",
697
+ f"&wholebpm={bpm}",
698
+ f"&artist=MaiGenerator",
699
+ f"&lv_{difficulty+1}={args.level}",
700
+ f"&inote_{difficulty+1}="]
701
+ lines.append(chart_text)
702
+ out_path.write_text("\n".join(lines).rstrip("\n"), encoding="utf-8")
703
+ print(f"\nSaved to {out_path}")
704
+
705
+
706
+ if __name__ == "__main__":
707
+ main()
mai_parser/__init__.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ mai_parser - Maimai (舞萌) chart data parser for ML training.
3
+
4
+ Usage:
5
+ from mai_parser import SongDataset, parse_maidata_file
6
+
7
+ # Load entire dataset
8
+ ds = SongDataset("datasets").load()
9
+ print(ds.stats())
10
+
11
+ # Parse single file
12
+ song = parse_maidata_file("datasets/10/maidata.txt")
13
+ print(song.title_clean, song.artist, song.bpm)
14
+
15
+ # Convert chart to tensor for training
16
+ chart = song.get_chart(Difficulty.MASTER)
17
+ tensor = chart_to_tensor(chart)
18
+ """
19
+
20
+ from .models import Cabinet, Chart, Difficulty, Song, TouchNote
21
+ from .parser import parse_maidata, parse_maidata_file
22
+ from .dataset import (
23
+ ChartDataset,
24
+ SongDataset,
25
+ chart_to_sequence,
26
+ chart_to_tensor,
27
+ note_to_vector,
28
+ NOTE_TYPE,
29
+ NOTE_TYPE_INV,
30
+ NUM_POSITIONS,
31
+ HAS_TORCH,
32
+ )
33
+
34
+ __all__ = [
35
+ # Models
36
+ "Cabinet",
37
+ "Chart",
38
+ "Difficulty",
39
+ "Song",
40
+ "TouchNote",
41
+ # Parser
42
+ "parse_maidata",
43
+ "parse_maidata_file",
44
+ # Dataset
45
+ "ChartDataset",
46
+ "SongDataset",
47
+ "chart_to_sequence",
48
+ "chart_to_tensor",
49
+ "note_to_vector",
50
+ # Constants
51
+ "NOTE_TYPE",
52
+ "NOTE_TYPE_INV",
53
+ "NUM_POSITIONS",
54
+ "HAS_TORCH",
55
+ ]
mai_parser/dataset.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dataset loader for maimai training data.
3
+
4
+ Provides:
5
+ - SongDataset: scans datasets/ folder, parses all songs
6
+ - ChartDataset: PyTorch Dataset for chart note sequences
7
+ - Utility functions for note-to-tensor conversion
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import pickle
14
+ from pathlib import Path
15
+ from typing import Iterator, Optional, Union
16
+
17
+ from .models import Cabinet, Chart, Difficulty, Song, TouchNote
18
+ from .parser import parse_maidata_file
19
+
20
+ # Try importing PyTorch (optional)
21
+ try:
22
+ import torch
23
+ from torch.utils.data import Dataset as TorchDataset
24
+ HAS_TORCH = True
25
+ except (ImportError, OSError):
26
+ HAS_TORCH = False
27
+
28
+
29
+ class SongDataset:
30
+ """
31
+ A collection of parsed Song objects from the datasets/ directory.
32
+
33
+ Usage:
34
+ ds = SongDataset("datasets")
35
+ ds.scan() # discover all songs
36
+ ds.load(use_cache=True) # parse all songs (with caching)
37
+ print(len(ds)) # total songs
38
+ song = ds[0] # first song
39
+ """
40
+
41
+ def __init__(self, root_dir: str | Path):
42
+ self.root_dir = Path(root_dir)
43
+ self.songs: list[Song] = []
44
+ self._by_id: dict[str, Song] = {}
45
+ self._cache_path = self.root_dir / ".mai_parser_cache.pkl"
46
+
47
+ def scan(self) -> list[Path]:
48
+ """
49
+ Scan the dataset directory for all song folders (containing maidata.txt).
50
+
51
+ Returns list of found song folder paths.
52
+ """
53
+ folders = []
54
+ for item in sorted(self.root_dir.iterdir()):
55
+ if item.is_dir() and (item / "maidata.txt").exists():
56
+ folders.append(item)
57
+ return folders
58
+
59
+ def load(self, use_cache: bool = True, max_workers: int = 8,
60
+ progress: bool = True) -> "SongDataset":
61
+ """
62
+ Parse all songs from discovered folders.
63
+
64
+ Args:
65
+ use_cache: If True, try loading from cache first; save after parsing.
66
+ max_workers: Number of threads for parallel parsing.
67
+ progress: Print progress information.
68
+ """
69
+ if use_cache and self._cache_path.exists():
70
+ try:
71
+ with open(self._cache_path, "rb") as f:
72
+ self.songs = pickle.load(f)
73
+ self._build_index()
74
+ if progress:
75
+ print(f"Loaded {len(self.songs)} songs from cache.")
76
+ return self
77
+ except (pickle.UnpicklingError, EOFError, KeyError) as e:
78
+ if progress:
79
+ print(f"Cache invalid ({e}), re-parsing...")
80
+
81
+ folders = self.scan()
82
+ if progress:
83
+ print(f"Found {len(folders)} song folders. Parsing...")
84
+
85
+ from concurrent.futures import ThreadPoolExecutor, as_completed
86
+
87
+ songs: list[Song] = []
88
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
89
+ futures = {
90
+ executor.submit(parse_maidata_file, f / "maidata.txt"): f
91
+ for f in folders
92
+ }
93
+ for i, future in enumerate(as_completed(futures)):
94
+ folder = futures[future]
95
+ try:
96
+ song = future.result()
97
+ song.maidata_path = str(folder / "maidata.txt")
98
+ song.audio_path = str(folder / "track.mp3")
99
+ songs.append(song)
100
+ except Exception as e:
101
+ if progress:
102
+ print(f" [WARN] Failed to parse {folder.name}: {e}")
103
+
104
+ if progress and (i + 1) % 200 == 0:
105
+ print(f" ... parsed {i + 1}/{len(folders)}")
106
+
107
+ # Sort by song_id numerically if possible
108
+ songs.sort(key=lambda s: _sort_key(s.song_id))
109
+
110
+ self.songs = songs
111
+ self._build_index()
112
+
113
+ if use_cache:
114
+ try:
115
+ with open(self._cache_path, "wb") as f:
116
+ pickle.dump(self.songs, f, protocol=pickle.HIGHEST_PROTOCOL)
117
+ if progress:
118
+ print(f"Saved cache to {self._cache_path}")
119
+ except OSError as e:
120
+ if progress:
121
+ print(f" [WARN] Could not save cache: {e}")
122
+
123
+ if progress:
124
+ print(f"Loaded {len(self.songs)} songs.")
125
+ return self
126
+
127
+ def _build_index(self) -> None:
128
+ self._by_id = {s.song_id: s for s in self.songs}
129
+
130
+ def __len__(self) -> int:
131
+ return len(self.songs)
132
+
133
+ def __getitem__(self, idx: int) -> Song:
134
+ return self.songs[idx]
135
+
136
+ def __iter__(self) -> Iterator[Song]:
137
+ return iter(self.songs)
138
+
139
+ def get_by_id(self, song_id: str) -> Song | None:
140
+ """Look up a song by its folder name / ID."""
141
+ return self._by_id.get(song_id)
142
+
143
+ def filter(
144
+ self,
145
+ cabinet: Cabinet | None = None,
146
+ min_level: float | None = None,
147
+ max_level: float | None = None,
148
+ difficulty: Difficulty | None = None,
149
+ genre: str | None = None,
150
+ has_audio: bool = False,
151
+ ) -> list[Song]:
152
+ """
153
+ Filter songs by various criteria.
154
+
155
+ Args:
156
+ cabinet: Filter by cabinet type (SD/DX).
157
+ min_level: Minimum chart level.
158
+ max_level: Maximum chart level.
159
+ difficulty: Only songs with this difficulty.
160
+ genre: Filter by genre name (partial match).
161
+ has_audio: Only songs where track.mp3 exists.
162
+ """
163
+ result = []
164
+ for song in self.songs:
165
+ if cabinet is not None and song.cabinet != cabinet:
166
+ continue
167
+ if genre is not None and genre.lower() not in song.genre.lower():
168
+ continue
169
+ if has_audio:
170
+ audio_path = self.root_dir / song.song_id / "track.mp3"
171
+ if not audio_path.exists():
172
+ continue
173
+ if difficulty is not None:
174
+ chart = song.get_chart(difficulty)
175
+ if chart is None:
176
+ continue
177
+ if min_level is not None and chart.level_value < min_level:
178
+ continue
179
+ if max_level is not None and chart.level_value > max_level:
180
+ continue
181
+ result.append(song)
182
+ return result
183
+
184
+ def stats(self) -> dict:
185
+ """Return summary statistics of the dataset."""
186
+ total = len(self.songs)
187
+ sd_count = sum(1 for s in self.songs if s.cabinet == Cabinet.SD)
188
+ dx_count = sum(1 for s in self.songs if s.cabinet == Cabinet.DX)
189
+ unknown_cab = total - sd_count - dx_count
190
+ total_charts = sum(len(s.charts) for s in self.songs)
191
+ genres = list(set(s.genre for s in self.songs if s.genre))
192
+
193
+ # Level distribution per difficulty
194
+ level_dist: dict[str, list[float]] = {}
195
+ for song in self.songs:
196
+ for idx, chart in song.charts.items():
197
+ key = f"lv_{idx}"
198
+ if key not in level_dist:
199
+ level_dist[key] = []
200
+ level_dist[key].append(chart.level_value)
201
+
202
+ return {
203
+ "total_songs": total,
204
+ "sd_songs": sd_count,
205
+ "dx_songs": dx_count,
206
+ "unknown_cabinet": unknown_cab,
207
+ "total_charts": total_charts,
208
+ "genres": sorted(genres),
209
+ "level_distribution": {
210
+ k: {
211
+ "min": min(v) if v else 0,
212
+ "max": max(v) if v else 0,
213
+ "avg": sum(v) / len(v) if v else 0,
214
+ }
215
+ for k, v in level_dist.items()
216
+ },
217
+ }
218
+
219
+
220
+ def _sort_key(song_id: str) -> tuple:
221
+ """Sort: numeric IDs first, then strings."""
222
+ try:
223
+ return (0, int(song_id))
224
+ except ValueError:
225
+ return (1, song_id)
226
+
227
+
228
+ # ─── Note to Tensor Conversion ──────────────────────────────────────────────
229
+
230
+ # Note type encoding for training
231
+ NOTE_TYPE = {
232
+ "rest": 0,
233
+ "tap": 1,
234
+ "hold": 2,
235
+ "slide": 3,
236
+ "break": 4,
237
+ "touch": 5,
238
+ "end": 6,
239
+ }
240
+
241
+ # Reverse mapping
242
+ NOTE_TYPE_INV = {v: k for k, v in NOTE_TYPE.items()}
243
+
244
+ # Number of button positions (1-8)
245
+ NUM_POSITIONS = 8
246
+
247
+
248
+ def note_to_vector(note: TouchNote, num_positions: int = NUM_POSITIONS) -> list[float]:
249
+ """
250
+ Convert a single TouchNote to a feature vector.
251
+
252
+ Format (fixed-length):
253
+ [type_rest, type_tap, type_hold, type_slide, type_break, type_touch, type_end,
254
+ pos_1, pos_2, ..., pos_N,
255
+ is_simultaneous, has_slide_path_len_2, has_slide_path_len_3, has_slide_path_len_4,
256
+ hold_beat, hold_subdiv,
257
+ beat_div_normalized]
258
+
259
+ Returns a flat list of floats.
260
+ """
261
+ vec = [0.0] * (7 + num_positions + 4 + 2 + 1)
262
+
263
+ # Note type (one-hot)
264
+ if note.is_end:
265
+ vec[6] = 1.0
266
+ elif note.is_rest:
267
+ vec[0] = 1.0
268
+ elif note.is_touch:
269
+ vec[5] = 1.0
270
+ elif note.is_break:
271
+ vec[4] = 1.0
272
+ elif note.is_hold:
273
+ vec[2] = 1.0
274
+ elif note.is_slide:
275
+ vec[3] = 1.0
276
+ else:
277
+ vec[1] = 1.0 # tap
278
+
279
+ # Button positions (multi-hot)
280
+ offset = 7
281
+ for pos in note.positions:
282
+ if 1 <= pos <= num_positions:
283
+ vec[offset + pos - 1] = 1.0
284
+
285
+ # Slide path length flag
286
+ offset += num_positions
287
+ sl = len(note.slide_path)
288
+ if sl >= 2:
289
+ vec[offset] = 1.0
290
+ if sl >= 3:
291
+ vec[offset + 1] = 1.0
292
+ if sl >= 4:
293
+ vec[offset + 2] = 1.0
294
+ vec[offset + 3] = 1.0 if note.is_simultaneous else 0.0
295
+
296
+ # Hold duration
297
+ offset += 4
298
+ if note.hold_duration:
299
+ vec[offset] = float(note.hold_duration[0])
300
+ vec[offset + 1] = float(note.hold_duration[1])
301
+
302
+ # Beat division (normalized)
303
+ vec[-1] = note.beat_div
304
+
305
+ return vec
306
+
307
+
308
+ def chart_to_tensor(chart: Chart) -> "torch.Tensor":
309
+ """
310
+ Convert an entire chart's notes to a PyTorch tensor.
311
+
312
+ Returns tensor of shape (num_notes, feature_dim).
313
+ """
314
+ if not HAS_TORCH:
315
+ raise ImportError("PyTorch is required for tensor conversion. "
316
+ "Install with: pip install torch")
317
+ vectors = [note_to_vector(n) for n in chart.notes]
318
+ return torch.tensor(vectors, dtype=torch.float32)
319
+
320
+
321
+ def chart_to_sequence(chart: Chart) -> list[list[float]]:
322
+ """Convert a chart to a list of feature vectors (no PyTorch dependency)."""
323
+ return [note_to_vector(n) for n in chart.notes]
324
+
325
+
326
+ # ─── PyTorch Dataset (if torch available) ───────────────────────────────────
327
+
328
+ if HAS_TORCH:
329
+
330
+ class ChartDataset(TorchDataset):
331
+ """
332
+ PyTorch Dataset for maimai chart training.
333
+
334
+ Yields (note_sequence, metadata) pairs where:
335
+ - note_sequence is a tensor of shape (seq_len, feature_dim)
336
+ - metadata is a dict with song info
337
+
338
+ Usage:
339
+ from torch.utils.data import DataLoader
340
+ ds = ChartDataset(song_dataset, difficulty=Difficulty.MASTER)
341
+ loader = DataLoader(ds, batch_size=32, collate_fn=ds.collate_fn)
342
+ """
343
+
344
+ def __init__(
345
+ self,
346
+ song_dataset: SongDataset,
347
+ difficulty: Difficulty | None = None,
348
+ min_level: float | None = None,
349
+ max_level: float | None = None,
350
+ pad_to: int | None = None,
351
+ include_metadata: bool = True,
352
+ ):
353
+ self.song_dataset = song_dataset
354
+ self.difficulty = difficulty
355
+ self.min_level = min_level
356
+ self.max_level = max_level
357
+ self.pad_to = pad_to
358
+ self.include_metadata = include_metadata
359
+
360
+ # Build filtered list of (song_index, chart_index)
361
+ self._items: list[tuple[int, int]] = []
362
+ for si, song in enumerate(song_dataset.songs):
363
+ for idx, chart in song.charts.items():
364
+ if difficulty is not None and idx != difficulty.value:
365
+ continue
366
+ if min_level is not None and chart.level_value < min_level:
367
+ continue
368
+ if max_level is not None and chart.level_value > max_level:
369
+ continue
370
+ self._items.append((si, idx))
371
+
372
+ def __len__(self) -> int:
373
+ return len(self._items)
374
+
375
+ def __getitem__(self, idx: int):
376
+ si, ci = self._items[idx]
377
+ song = self.song_dataset[si]
378
+ chart = song.charts[ci]
379
+
380
+ tensor = chart_to_tensor(chart)
381
+
382
+ if self.pad_to is not None:
383
+ if tensor.shape[0] < self.pad_to:
384
+ pad = torch.zeros(
385
+ self.pad_to - tensor.shape[0], tensor.shape[1]
386
+ )
387
+ tensor = torch.cat([tensor, pad], dim=0)
388
+
389
+ if self.include_metadata:
390
+ return tensor, {
391
+ "song_id": song.song_id,
392
+ "title": song.title_clean,
393
+ "artist": song.artist,
394
+ "bpm": song.bpm,
395
+ "level": chart.level_value,
396
+ "difficulty": chart.difficulty_index,
397
+ "cabinet": song.cabinet.value,
398
+ "note_count": chart.note_count,
399
+ }
400
+ return tensor
401
+
402
+ @staticmethod
403
+ def collate_fn(batch):
404
+ """Custom collate for variable-length sequences."""
405
+ if isinstance(batch[0], tuple):
406
+ sequences = [item[0] for item in batch]
407
+ metadata = [item[1] for item in batch]
408
+ else:
409
+ sequences = batch
410
+ metadata = None
411
+
412
+ # Pad sequences to max length in batch
413
+ lengths = torch.tensor([s.shape[0] for s in sequences])
414
+ max_len = lengths.max().item()
415
+ feat_dim = sequences[0].shape[1]
416
+
417
+ padded = torch.zeros(len(sequences), max_len, feat_dim)
418
+ for i, seq in enumerate(sequences):
419
+ padded[i, :seq.shape[0]] = seq
420
+
421
+ if metadata is not None:
422
+ return padded, lengths, metadata
423
+ return padded, lengths
424
+
425
+ def stats(self) -> dict:
426
+ """Return dataset statistics."""
427
+ levels = []
428
+ note_counts = []
429
+ for _, ci in self._items:
430
+ song_idx, chart_idx = self._items[0]
431
+ for si, ci in self._items:
432
+ chart = self.song_dataset[si].charts[ci]
433
+ levels.append(chart.level_value)
434
+ note_counts.append(chart.note_count)
435
+
436
+ return {
437
+ "total_charts": len(self._items),
438
+ "level_min": min(levels) if levels else 0,
439
+ "level_max": max(levels) if levels else 0,
440
+ "level_avg": sum(levels) / len(levels) if levels else 0,
441
+ "note_count_min": min(note_counts) if note_counts else 0,
442
+ "note_count_max": max(note_counts) if note_counts else 0,
443
+ "note_count_avg": sum(note_counts) / len(note_counts)
444
+ if note_counts else 0,
445
+ }
446
+
447
+ else:
448
+
449
+ class ChartDataset:
450
+ """Stub when PyTorch is not installed."""
451
+ def __init__(self, *args, **kwargs):
452
+ raise ImportError(
453
+ "PyTorch is required for ChartDataset. "
454
+ "Install with: pip install torch"
455
+ )
mai_parser/models.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for maimai chart parsing.
3
+
4
+ Represents the full structure of a maimai song entry:
5
+ - Song metadata (title, artist, BPM, version, etc.)
6
+ - Difficulty configs (level, charter)
7
+ - Chart note sequences
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Optional
14
+
15
+
16
+ class Cabinet(Enum):
17
+ """Cabinet / hardware type."""
18
+ SD = "SD"
19
+ DX = "DX"
20
+ UNKNOWN = "UNKNOWN"
21
+
22
+
23
+ class Difficulty(Enum):
24
+ """Difficulty tiers (1-indexed matching &inote_N)."""
25
+ BASIC = 1 # lv_1 / inote_1
26
+ ADVANCED = 2 # lv_2 / inote_2
27
+ EXPERT = 3 # lv_3 / inote_3
28
+ MASTER = 4 # lv_4 / inote_4
29
+ ReMASTER = 5 # lv_5 / inote_5 → SD Re:MASTER is inote_6
30
+ # DX uses lv_6 for Re:MASTER (inote_6)
31
+ # Some charts use lv_7 / inote_7 for Ura/裏 or extra
32
+
33
+ @classmethod
34
+ def from_index(cls, idx: int) -> "Difficulty":
35
+ for d in cls:
36
+ if d.value == idx:
37
+ return d
38
+ return cls.ReMASTER # fallback for 5+
39
+
40
+
41
+ @dataclass
42
+ class TouchNote:
43
+ """
44
+ A single parsed note/event.
45
+
46
+ Raw format examples:
47
+ {4}1 → beat_div=4, pos=1
48
+ {4}1/8 → beat_div=4, pos=1/8 (simultaneous press)
49
+ {4}1h[4:1] → beat_div=4, pos=1, modifier=hold, duration=4:1
50
+ {4}1b → beat_div=4, pos=1, is_break=True
51
+ {4}3>6[4:1]→ beat_div=4, pos=3>6 (slide), modifier=slide, duration=4:1
52
+ {1} → beat_div=1, rest
53
+ E → end marker
54
+ """
55
+ beat_div: int = 4 # {4}, {8}, {16}, {1}, etc.
56
+ raw: str = "" # original text
57
+ is_rest: bool = False
58
+ is_end: bool = False
59
+ is_break: bool = False # 'b' suffix on note
60
+ is_star: bool = False # '*' suffix (EX note)
61
+ is_slide: bool = False # '>' or 'V' or other slide patterns
62
+ is_hold: bool = False # 'h[...]' modifier
63
+ is_touch: bool = False # touch panel notes (C, Cf, Ch, etc.)
64
+ is_simultaneous: bool = False # contains '/' for multi-tap
65
+ positions: list[int] = field(default_factory=list) # 1-8 button positions
66
+ touch_regions: list[str] = field(default_factory=list) # e.g. ["C", "B7", "E8"]
67
+ hold_duration: tuple[int, int] | None = None # (beat, subdiv) from h[beat:subdiv]
68
+ slide_path: list[int] = field(default_factory=list) # slide positions
69
+ firework: bool = False # 'x' suffix
70
+ ex_notes: list[str] = field(default_factory=list) # EX modifiers
71
+
72
+
73
+ @dataclass
74
+ class Chart:
75
+ """
76
+ A single difficulty chart (one &inote_N block).
77
+
78
+ Contains the parsed note sequence and metadata about the chart.
79
+ """
80
+ difficulty_index: int # 1-7, maps to &inote_N
81
+ difficulty: Difficulty # enum value
82
+ level: str = "" # e.g. "12.4", "13+", "14.6?"
83
+ level_value: float = 0.0 # parsed numeric level
84
+ is_plus: bool = False # has '+' suffix
85
+ is_ura: bool = False # has '?' suffix (裏譜面)
86
+ charter: str = "" # &des_N, charter/description
87
+ notes: list[TouchNote] = field(default_factory=list)
88
+ # Computed stats
89
+ note_count: int = 0 # total non-rest notes
90
+ tap_count: int = 0
91
+ hold_count: int = 0
92
+ slide_count: int = 0
93
+ break_count: int = 0
94
+ touch_count: int = 0
95
+ total_beats: float = 0.0 # total chart duration in beats
96
+
97
+ def compute_stats(self) -> None:
98
+ """Recalculate computed statistics from notes."""
99
+ self.note_count = 0
100
+ self.tap_count = 0
101
+ self.hold_count = 0
102
+ self.slide_count = 0
103
+ self.break_count = 0
104
+ self.touch_count = 0
105
+
106
+ for n in self.notes:
107
+ if n.is_rest or n.is_end:
108
+ continue
109
+ self.note_count += 1
110
+ if n.is_touch:
111
+ self.touch_count += 1
112
+ elif n.is_break:
113
+ self.break_count += 1
114
+ elif n.is_hold:
115
+ self.hold_count += 1
116
+ elif n.is_slide:
117
+ self.slide_count += 1
118
+ else:
119
+ self.tap_count += 1
120
+
121
+
122
+ @dataclass
123
+ class Song:
124
+ """
125
+ Full parsed song entry — maps to one folder in datasets/.
126
+ """
127
+ # Identifiers
128
+ song_id: str = "" # folder name (e.g. "10", "10021")
129
+ title: str = "" # &title (raw, may include [SD] etc.)
130
+ title_clean: str = "" # cleaned title without tags
131
+ artist: str = "" # &artist
132
+ artist_id: int = 0 # &artistid
133
+ genre: str = "" # &genre
134
+ genre_id: int = 0 # &genreid
135
+
136
+ # Music info
137
+ bpm: float = 0.0 # &wholebpm
138
+ first: float = 0.0 # &first (offset in seconds)
139
+
140
+ # Cabinet & version
141
+ cabinet: Cabinet = Cabinet.UNKNOWN
142
+ version: str = "" # &version
143
+ short_id: int = 0 # &shortid
144
+
145
+ # Chart converter metadata
146
+ converter: str = "" # &ChartConverter
147
+ converter_tool: str = "" # &ChartConvertTool
148
+ converter_version: str = "" # &ChartConvertToolVersion
149
+
150
+ # Raw header
151
+ description: str = "" # &des
152
+
153
+ # Difficulty info (keyed by difficulty index 1-7)
154
+ levels: dict[int, str] = field(default_factory=dict) # &lv_N
155
+ charters: dict[int, str] = field(default_factory=dict) # &des_N
156
+
157
+ # Parsed charts (keyed by difficulty index 1-7)
158
+ charts: dict[int, Chart] = field(default_factory=dict)
159
+
160
+ # Flags
161
+ is_fulltouch: bool = False
162
+ is_full: bool = False # [FULL] in title
163
+ is_utage: bool = False # 宴会場 genre
164
+ tags: list[str] = field(default_factory=list) # [SD], [DX], [宴], etc.
165
+
166
+ # File paths (relative to dataset root)
167
+ maidata_path: str = ""
168
+ audio_path: str = ""
169
+
170
+ def has_chart(self, diff: Difficulty) -> bool:
171
+ """Check if a difficulty level exists."""
172
+ return diff.value in self.charts
173
+
174
+ def get_chart(self, diff: Difficulty) -> Chart | None:
175
+ return self.charts.get(diff.value)
mai_parser/parser.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Parser for maidata.txt files (MaichartConverter format).
3
+
4
+ Parses:
5
+ 1. Header key-value pairs (&key=value)
6
+ 2. Note sequence data (&inote_N= blocks)
7
+
8
+ The note format is measure-based:
9
+ {beat_div}pos1,pos2,pos3,...
10
+
11
+ Where each position can be:
12
+ - A number 1-8 (button position)
13
+ - Empty (rest)
14
+ - Number/Number (simultaneous tap)
15
+ - Number + modifiers (h=hold, b=break, x=firework, >=/</V/slide patterns)
16
+ - Touch region (C, B1-B8, E1-E8, A1-A8, D1-D8)
17
+ - End marker: "E"
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from .models import Cabinet, Chart, Difficulty, Song, TouchNote
27
+
28
+ # ─── Regex patterns for note parsing ────────────────────────────────────────
29
+
30
+ # Matches {beat_div} at the start of a measure
31
+ _RE_BEAT_DIV = re.compile(r"\{(\d+)\}")
32
+
33
+ # Matches inline BPM changes: (200)
34
+ _RE_BPM_CHANGE = re.compile(r"\((\d+(?:\.\d+)?)\)")
35
+
36
+ # Matches hold duration: h[beat:subdiv]
37
+ _RE_HOLD = re.compile(r"h\[(\d+):(\d+)\]")
38
+
39
+ # Matches slide pattern with duration: >, <, -, V, v, ^
40
+ # Examples: 3>6[4:1], 6<3[8:11], 3-6[4:1], 3v6[16:3], 5>2[8:11], 3>8-6v3[8:9]
41
+ _RE_SLIDE_DUR = re.compile(r"([><Vv\-\^])(\d+)\[(\d+):(\d+)\]")
42
+
43
+ # Matches simple slide (no duration): 3>6, 4-1
44
+ _RE_SIMPLE_SLIDE = re.compile(r"(\d+)[><Vv\-\^](\d+)")
45
+
46
+ # Matches break suffix: b or $b at end
47
+ _RE_BREAK = re.compile(r"b$|\$b$")
48
+
49
+ # Matches firework/EX: x suffix
50
+ _RE_FIREWORK = re.compile(r"x$")
51
+
52
+ # Matches touch note identifiers: C, Cf, Ch, B1-B8, E1-E8, A1-A8, D1-D8
53
+ _RE_TOUCH = re.compile(r"^(C[fh]?|[ABDE][1-8])$")
54
+
55
+ # Matches simultaneous tap: 1/8, 2/5/7
56
+ _RE_SIMUL = re.compile(r"^(\d+)/(\d+)(?:/(\d+))?(?:/(\d+))?$")
57
+
58
+ # Matches a number position (1-8)
59
+ _RE_POS = re.compile(r"^\d+$")
60
+
61
+
62
+ def parse_header_line(line: str) -> tuple[str, str]:
63
+ """Parse a single &key=value header line. Returns (key, value)."""
64
+ line = line.strip()
65
+ if line.startswith("&"):
66
+ line = line[1:] # strip &
67
+ if "=" in line:
68
+ key, value = line.split("=", 1)
69
+ return key.strip(), value.strip()
70
+ return line.strip(), ""
71
+
72
+
73
+ def _extract_bpm_changes(text: str) -> list[tuple[int, float]]:
74
+ """Extract inline BPM changes from a measure line.
75
+ Returns list of (position_in_measure, bpm_value).
76
+ """
77
+ changes = []
78
+ for m in _RE_BPM_CHANGE.finditer(text):
79
+ changes.append((m.start(), float(m.group(1))))
80
+ return changes
81
+
82
+
83
+ def _parse_single_note(token: str, current_beat_div: int) -> TouchNote:
84
+ """
85
+ Parse a single note token (one comma-separated segment).
86
+
87
+ Examples:
88
+ "" → rest
89
+ "E" → end marker
90
+ "1" → tap at position 1
91
+ "1/8" → simultaneous tap at 1 and 8
92
+ "1b" → break at position 1
93
+ "1x" → firework at position 1
94
+ "1h[4:1]" → hold at position 1, duration 4:1
95
+ "3>6[4:1]"→ slide from 3 to 6, duration 4:1
96
+ "3-6" → simple slide 3→6
97
+ "C" → touch center
98
+ "B7/B6" → touch simultaneous
99
+ "Ch[2:1]" → touch hold
100
+ """
101
+ note = TouchNote(beat_div=current_beat_div, raw=token)
102
+
103
+ # Empty = rest
104
+ if not token:
105
+ note.is_rest = True
106
+ return note
107
+
108
+ # End marker
109
+ if token.strip() == "E":
110
+ note.is_end = True
111
+ return note
112
+
113
+ # Work on the cleaned token (remove beat division, BPM)
114
+ t = token.strip()
115
+
116
+ # Remove inline BPM changes for parsing: (200), (150.5)
117
+ t = _RE_BPM_CHANGE.sub("", t).strip()
118
+ if not t:
119
+ note.is_rest = True
120
+ return note
121
+
122
+ # --- Check for touch notes ---
123
+ # Touch notes can have modifiers like Ch[2:1], C, B7/B6
124
+ # They use letters A-E + number, or just C
125
+ touch_parts = re.split(r"[/]", t)
126
+ all_touch = all(
127
+ _RE_TOUCH.match(re.sub(r"\[.*?\]", "", p).strip("hbfxqpb$Vv*^-><"))
128
+ for p in touch_parts
129
+ )
130
+
131
+ if all_touch and any(
132
+ re.match(r"^[A-E]", p.strip("hbfxqpb$Vv*^-><"))
133
+ for p in touch_parts
134
+ ):
135
+ note.is_touch = True
136
+ # Extract touch regions (strip modifiers)
137
+ for p in touch_parts:
138
+ clean = re.sub(r"\[.*?\]", "", p).strip("hbfxqpb$Vv*^-><")
139
+ if clean:
140
+ note.touch_regions.append(clean)
141
+ # Parse hold/touch-hold
142
+ hold_m = _RE_HOLD.search(t)
143
+ if hold_m:
144
+ note.is_hold = True
145
+ note.hold_duration = (int(hold_m.group(1)), int(hold_m.group(2)))
146
+ return note
147
+
148
+ # --- Parse button note ---
149
+
150
+ # Break suffix
151
+ if _RE_BREAK.search(t):
152
+ note.is_break = True
153
+ t = _RE_BREAK.sub("", t).rstrip("$")
154
+
155
+ # Firework suffix
156
+ if t.endswith("x"):
157
+ note.is_firework = True
158
+ t = t[:-1]
159
+
160
+ # Hold
161
+ hold_m = _RE_HOLD.search(t)
162
+ if hold_m:
163
+ note.is_hold = True
164
+ note.hold_duration = (int(hold_m.group(1)), int(hold_m.group(2)))
165
+ t = _RE_HOLD.sub("", t)
166
+
167
+ # Star/EX note (* suffix)
168
+ if t.endswith("*"):
169
+ note.is_star = True
170
+ t = t[:-1]
171
+
172
+ # Slide patterns
173
+ # Pattern with duration: 3>6[4:1], 3-6[8:11], 3v6[16:3], 3>8-6v3[8:9]
174
+ slide_m = _RE_SLIDE_DUR.search(t)
175
+ if slide_m:
176
+ note.is_slide = True
177
+ note.slide_path = _extract_slide_path(t)
178
+ hold_m2 = _RE_HOLD.search(t)
179
+ if not hold_m2:
180
+ # Duration is on slide: [beat:subdiv]
181
+ note.hold_duration = (int(slide_m.group(3)), int(slide_m.group(4)))
182
+ # Extract positions from slide
183
+ pos_match = re.findall(r"(\d+)", t)
184
+ note.positions = [int(p) for p in pos_match[:4]]
185
+ return note
186
+
187
+ # Simple slide: 3>6, 4-1, 8<5
188
+ simple_slide = _RE_SIMPLE_SLIDE.search(t)
189
+ if simple_slide:
190
+ note.is_slide = True
191
+ pos_match = re.findall(r"(\d+)", t)
192
+ note.positions = [int(p) for p in pos_match[:4]]
193
+ return note
194
+
195
+ # Single V (slide continuation / endpoint)
196
+ if re.match(r"^V\d*$", t):
197
+ note.is_slide = True
198
+ return note
199
+
200
+ # Simultaneous tap: 1/8, 2/6/7
201
+ simul_m = _RE_SIMUL.match(t)
202
+ if simul_m:
203
+ note.is_simultaneous = True
204
+ note.positions = [int(g) for g in simul_m.groups() if g is not None]
205
+ return note
206
+
207
+ # Simple tap: 1-8
208
+ if _RE_POS.match(t):
209
+ note.positions = [int(t)]
210
+ return note
211
+
212
+ # Special modifiers: 'q', 'p', 'w', 'z', '$' etc. — treat as tap if has number
213
+ num_match = re.findall(r"(\d+)", t)
214
+ if num_match:
215
+ note.positions = [int(p) for p in num_match[:4]]
216
+ return note
217
+
218
+ # Unknown / rest
219
+ note.is_rest = True
220
+ return note
221
+
222
+
223
+ def _extract_slide_path(token: str) -> list[int]:
224
+ """Extract all position numbers from a slide pattern like '3>8-6v3[8:9]' → [3,8,6,3]."""
225
+ nums = re.findall(r"(?<![a-zA-Z\[:])(\d+)(?![a-zA-Z\]])", token)
226
+ return [int(n) for n in nums]
227
+
228
+
229
+ def _parse_note_sequence(raw_text: str) -> list[TouchNote]:
230
+ """
231
+ Parse the full note sequence from an &inote_N block.
232
+
233
+ Handles:
234
+ - Beat division changes: {4}, {8}, {16}, etc.
235
+ - Inline BPM changes: (200)
236
+ - Measure-by-measure comma-separated notes
237
+ - End marker: E
238
+ """
239
+ notes: list[TouchNote] = []
240
+ current_div = 4 # default beat division
241
+
242
+ # Normalize: join continuation lines, split by newline
243
+ text = raw_text.strip()
244
+
245
+ for line in text.split("\n"):
246
+ line = line.strip()
247
+ if not line:
248
+ continue
249
+
250
+ # Check for beat division change at start of line
251
+ bd_match = _RE_BEAT_DIV.match(line)
252
+ if bd_match:
253
+ current_div = int(bd_match.group(1))
254
+ line = line[bd_match.end():] # remove {beat_div} prefix
255
+
256
+ # If the line is empty after removing beat div, it's just a beat division
257
+ # announcement; subsequent lines use this division
258
+ if not line:
259
+ continue
260
+
261
+ # The line may start with an inline BPM change like (173){1},
262
+ bpm_match = _RE_BPM_CHANGE.match(line)
263
+ if bpm_match:
264
+ line = line[bpm_match.end():]
265
+ # Check for beat div inside the BPM line
266
+ bd2 = _RE_BEAT_DIV.match(line) if line else None
267
+ if bd2:
268
+ current_div = int(bd2.group(1))
269
+ line = line[bd2.end():]
270
+
271
+ if not line:
272
+ continue
273
+
274
+ # Split by commas for individual notes
275
+ tokens = line.split(",")
276
+
277
+ for token in tokens:
278
+ # Check for inline beat division change
279
+ bd_inline = _RE_BEAT_DIV.search(token) if token else None
280
+ local_div = current_div
281
+ if bd_inline:
282
+ local_div = int(bd_inline.group(1))
283
+ token = _RE_BEAT_DIV.sub("", token).strip()
284
+
285
+ note = _parse_single_note(token, local_div)
286
+ notes.append(note)
287
+
288
+ if note.is_end:
289
+ return notes # stop at E marker
290
+
291
+ return notes
292
+
293
+
294
+ def parse_level_value(level_str: str) -> tuple[float, bool, bool]:
295
+ """
296
+ Parse a level string.
297
+ Returns (numeric_value, is_plus, is_ura).
298
+
299
+ Examples:
300
+ "12.4" → (12.4, False, False)
301
+ "13+" → (13.0, True, False)
302
+ "14.6?" → (14.6, False, True)
303
+ "7+" → (7.0, True, False)
304
+ "耐" → (-1.0, False, False) # non-numeric
305
+ "" → (0.0, False, False)
306
+ """
307
+ is_plus = "+" in level_str
308
+ is_ura = "?" in level_str
309
+ # Strip non-numeric except . + ?
310
+ clean = level_str.replace("+", "").replace("?", "").strip()
311
+ try:
312
+ val = float(clean) if clean else 0.0
313
+ except ValueError:
314
+ val = -1.0
315
+ return val, is_plus, is_ura
316
+
317
+
318
+ def parse_maidata(content: str, song_id: str = "",
319
+ maidata_path: str = "", audio_path: str = "") -> Song:
320
+ """
321
+ Parse a complete maidata.txt content string into a Song object.
322
+
323
+ Args:
324
+ content: The full text content of maidata.txt
325
+ song_id: Folder name / song identifier
326
+ maidata_path: Relative path to maidata.txt
327
+ audio_path: Relative path to track.mp3
328
+
329
+ Returns:
330
+ Song object with all parsed data
331
+ """
332
+ song = Song(song_id=song_id, maidata_path=maidata_path, audio_path=audio_path)
333
+
334
+ # Strip BOM if present
335
+ if content.startswith("\ufeff"):
336
+ content = content[1:]
337
+
338
+ lines = content.split("\n")
339
+ raw_notes: dict[int, str] = {} # difficulty_index → raw note text
340
+ current_note_idx: Optional[int] = None
341
+ current_note_lines: list[str] = []
342
+
343
+ for line in lines:
344
+ line_stripped = line.strip()
345
+ if not line_stripped:
346
+ continue
347
+
348
+ if line_stripped.startswith("&"):
349
+ key, value = parse_header_line(line_stripped)
350
+
351
+ # If we were collecting note data, flush it
352
+ if current_note_idx is not None:
353
+ raw_notes[current_note_idx] = "\n".join(current_note_lines)
354
+ current_note_idx = None
355
+ current_note_lines = []
356
+
357
+ if key.startswith("inote_"):
358
+ # Note data block
359
+ idx_str = key.replace("inote_", "")
360
+ try:
361
+ current_note_idx = int(idx_str)
362
+ except ValueError:
363
+ current_note_idx = None
364
+ if value:
365
+ current_note_lines.append(value)
366
+ elif key.startswith("lv_"):
367
+ idx_str = key.replace("lv_", "")
368
+ if not idx_str.isdigit():
369
+ continue
370
+ idx = int(idx_str)
371
+ if not value:
372
+ continue # skip empty levels (no chart)
373
+ song.levels[idx] = value
374
+ level_val, is_plus, is_ura = parse_level_value(value)
375
+ if level_val > 0:
376
+ # Create chart placeholder (notes parsed later)
377
+ diff = Difficulty.from_index(idx)
378
+ chart = Chart(
379
+ difficulty_index=idx,
380
+ difficulty=diff,
381
+ level=value,
382
+ level_value=level_val,
383
+ is_plus=is_plus,
384
+ is_ura=is_ura,
385
+ )
386
+ song.charts[idx] = chart
387
+ elif key.startswith("des_"):
388
+ idx_str = key.replace("des_", "")
389
+ try:
390
+ idx = int(idx_str)
391
+ except ValueError:
392
+ continue
393
+ song.charters[idx] = value
394
+ if idx in song.charts:
395
+ song.charts[idx].charter = value
396
+ elif key == "title":
397
+ song.title = value
398
+ song.title_clean = _clean_title(value)
399
+ song.tags = _extract_tags(value)
400
+ song.is_full = "[FULL]" in value or "_FULLTOUCH" in value
401
+ song.is_fulltouch = "_FULLTOUCH" in value
402
+ elif key == "artist":
403
+ song.artist = value
404
+ elif key == "artistid":
405
+ try:
406
+ song.artist_id = int(value)
407
+ except ValueError:
408
+ pass
409
+ elif key == "wholebpm":
410
+ try:
411
+ song.bpm = float(value)
412
+ except ValueError:
413
+ pass
414
+ elif key == "first":
415
+ try:
416
+ song.first = float(value)
417
+ except ValueError:
418
+ pass
419
+ elif key == "genre":
420
+ song.genre = value
421
+ song.is_utage = "宴会場" in value
422
+ elif key == "genreid":
423
+ try:
424
+ song.genre_id = int(value)
425
+ except ValueError:
426
+ pass
427
+ elif key == "cabinet":
428
+ try:
429
+ song.cabinet = Cabinet(value.upper())
430
+ except ValueError:
431
+ song.cabinet = Cabinet.UNKNOWN
432
+ elif key == "version":
433
+ song.version = value
434
+ elif key == "shortid":
435
+ try:
436
+ song.short_id = int(value)
437
+ except ValueError:
438
+ pass
439
+ elif key == "des":
440
+ song.description = value
441
+ elif key == "ChartConverter":
442
+ song.converter = value
443
+ elif key == "ChartConvertTool":
444
+ song.converter_tool = value
445
+ elif key == "ChartConvertToolVersion":
446
+ song.converter_version = value
447
+ elif current_note_idx is not None:
448
+ # Collect note data lines
449
+ current_note_lines.append(line_stripped)
450
+
451
+ # Flush last note block
452
+ if current_note_idx is not None:
453
+ raw_notes[current_note_idx] = "\n".join(current_note_lines)
454
+
455
+ # ── Parse all collected note sequences ──
456
+ for idx, raw in raw_notes.items():
457
+ notes = _parse_note_sequence(raw)
458
+
459
+ if idx in song.charts:
460
+ song.charts[idx].notes = notes
461
+ song.charts[idx].compute_stats()
462
+ elif idx in song.levels and song.levels[idx]:
463
+ # Chart has notes + level but was skipped due to level_val being non-numeric (e.g. "耐")
464
+ level_val, is_plus, is_ura = parse_level_value(song.levels[idx])
465
+ diff = Difficulty.from_index(idx)
466
+ chart = Chart(
467
+ difficulty_index=idx,
468
+ difficulty=diff,
469
+ level=song.levels[idx],
470
+ level_value=level_val,
471
+ is_plus=is_plus,
472
+ is_ura=is_ura,
473
+ )
474
+ chart.notes = notes
475
+ chart.compute_stats()
476
+ song.charts[idx] = chart
477
+
478
+ return song
479
+
480
+
481
+ def _clean_title(title: str) -> str:
482
+ """Remove bracket tags like [SD], [DX], [宴] from title."""
483
+ return re.sub(r"\[.*?\]", "", title).strip()
484
+
485
+
486
+ def _extract_tags(title: str) -> list[str]:
487
+ """Extract bracket tags from title, e.g. [SD], [DX], [宴]."""
488
+ return re.findall(r"\[(.*?)\]", title)
489
+
490
+
491
+ def parse_maidata_file(filepath: str | Path) -> Song:
492
+ """
493
+ Parse a maidata.txt file from disk.
494
+
495
+ Args:
496
+ filepath: Path to the maidata.txt file
497
+
498
+ Returns:
499
+ Parsed Song object
500
+ """
501
+ filepath = Path(filepath)
502
+ song_id = filepath.parent.name
503
+ content = filepath.read_text(encoding="utf-8")
504
+
505
+ # Build relative paths
506
+ maidata_rel = filepath.name
507
+ audio_rel = "track.mp3"
508
+
509
+ return parse_maidata(
510
+ content,
511
+ song_id=song_id,
512
+ maidata_path=maidata_rel,
513
+ audio_path=audio_rel,
514
+ )
model.py ADDED
@@ -0,0 +1,681 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MaiGenerator — Audio-conditioned autoregressive maimai chart generator.
3
+
4
+ Architecture: Encoder-Decoder Transformer with time-aligned RoPE.
5
+
6
+ Audio tokens → AudioEncoder → audio_feat [T_aud, d]
7
+ │ Cross-Attention
8
+ Chart tokens → ChartDecoder ───────┘
9
+ (autoregressive, + BPM/Diff/Genre conditioning
10
+ causal mask) + time-aligned RoPE positions
11
+
12
+ Key design: Chart RoPE uses audio frame indices (via BPM translation),
13
+ ensuring strong time alignment between music and generated notes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+ import tokenizer as chart_tokenizer
25
+ from tokenizer import CONFIG_BASE
26
+
27
+ # ═══════════════════════════════════════════════════════════════════════
28
+ # Constants (aligned with tokenizers)
29
+ # ═══════════════════════════════════════════════════════════════════════
30
+
31
+ CHART_VOCAB_SIZE = chart_tokenizer.VOCAB_SIZE
32
+ AUDIO_VOCAB_SIZE = 2051 # MaiTrackTokenizer (2-layer EnCodec)
33
+ AUDIO_FRAME_RATE = 75 # EnCodec 24kHz / 320 stride
34
+
35
+ BOS, EOS, PAD = 1, 2, 0
36
+
37
+ DIFF_NAMES = ["BASIC", "ADVANCED", "EXPERT", "MASTER", "ReMASTER"]
38
+ NUM_DIFFICULTIES = len(DIFF_NAMES)
39
+
40
+ # Beat division token → value
41
+ DIV_MAP = {5: 1, 6: 2, 7: 4, 8: 8, 9: 16, 10: 32,
42
+ 11: 48, 12: 64, 13: 128, 14: 192, 15: 384}
43
+ DUR_TOKEN = 17 # [DUR] in chart vocab
44
+ RST_TOKEN = 16
45
+ TAP_BASE, TAP_END = 18, 26
46
+ BRK_BASE, BRK_END = 26, 34
47
+ HLD_BASE, HLD_END = 34, 42
48
+ SLD_BASE, SLD_END = 42, 50
49
+ SLD_BEG_TOKEN = 50
50
+ SLD_END_TOKEN = 51
51
+ SIM_BEG_TOKEN = 52
52
+ SIM_END_TOKEN = 53
53
+ TCH_BASE, TCH_END = 54, 95
54
+
55
+ TYPE_REST = 0
56
+ TYPE_TAP = 1
57
+ TYPE_HOLD = 2
58
+ TYPE_SLIDE = 3
59
+ TYPE_BREAK = 4
60
+ TYPE_TOUCH = 5
61
+ TYPE_CONTROL = 6
62
+ NUM_TOKEN_TYPES = 7
63
+ NUM_POSITIONS = 9 # 0-7 real positions, 8 = none/control
64
+ NUM_DIV_CLASSES = len(DIV_MAP)
65
+
66
+
67
+ def _sdpa(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor,
68
+ dropout_p: float = 0.0, is_causal: bool = False) -> torch.Tensor:
69
+ """Use PyTorch SDPA so CUDA can pick Flash/mem-efficient attention kernels."""
70
+ return F.scaled_dot_product_attention(
71
+ q, k, v,
72
+ dropout_p=dropout_p if torch.is_grad_enabled() else 0.0,
73
+ is_causal=is_causal,
74
+ )
75
+
76
+
77
+ def is_timeline_token(tok: torch.Tensor) -> torch.Tensor:
78
+ """Tokens that correspond to one chart time slot after decoding."""
79
+ is_note = (((tok >= TAP_BASE) & (tok < TAP_END)) |
80
+ ((tok >= BRK_BASE) & (tok < BRK_END)) |
81
+ ((tok >= HLD_BASE) & (tok < HLD_END)) |
82
+ ((tok >= SLD_BASE) & (tok < SLD_END)) |
83
+ ((tok >= TCH_BASE) & (tok < TCH_END)) |
84
+ (tok >= CONFIG_BASE))
85
+ return (tok == RST_TOKEN) | (tok == SIM_BEG_TOKEN) | (tok == SLD_BEG_TOKEN) | is_note
86
+
87
+
88
+ def token_structure_features(tokens: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
89
+ """Return token type ids and 0-based position ids for structure-aware embeds."""
90
+ typ = torch.full_like(tokens, TYPE_CONTROL)
91
+ pos = torch.full_like(tokens, NUM_POSITIONS - 1)
92
+
93
+ typ = torch.where(tokens == RST_TOKEN, torch.full_like(typ, TYPE_REST), typ)
94
+
95
+ ranges = [
96
+ (TAP_BASE, TAP_END, TYPE_TAP),
97
+ (HLD_BASE, HLD_END, TYPE_HOLD),
98
+ (SLD_BASE, SLD_END, TYPE_SLIDE),
99
+ (BRK_BASE, BRK_END, TYPE_BREAK),
100
+ (TCH_BASE, TCH_END, TYPE_TOUCH),
101
+ ]
102
+ for start, end, token_type in ranges:
103
+ mask = (tokens >= start) & (tokens < end)
104
+ typ = torch.where(mask, torch.full_like(typ, token_type), typ)
105
+ pos = torch.where(mask, (tokens - start) % 8, pos)
106
+ return typ, pos
107
+
108
+
109
+ # ═══════════════════════════════════════════════════════════════════════
110
+ # RoPE with custom positions
111
+ # ═══════════════════════════════════════════════════════════════════════
112
+
113
+ class RoPE(nn.Module):
114
+ """Rotary Position Embedding supporting custom position indices."""
115
+
116
+ def __init__(self, dim: int, base: float = 10000.0):
117
+ super().__init__()
118
+ self.dim = dim
119
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
120
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
121
+
122
+ def forward(self, x: torch.Tensor,
123
+ positions: Optional[torch.Tensor] = None) -> torch.Tensor:
124
+ """
125
+ Args:
126
+ x: [B, H, T, D] — query or key.
127
+ positions: [T] or [B, T] — custom position indices.
128
+
129
+ Returns:
130
+ Rotated tensor [B, H, T, D].
131
+ """
132
+ B, H, T, D = x.shape
133
+ device = x.device
134
+
135
+ if positions is None:
136
+ positions = torch.arange(T, device=device, dtype=torch.float32)
137
+
138
+ if positions.dim() == 1:
139
+ positions = positions.unsqueeze(0)
140
+ positions = positions.unsqueeze(1).unsqueeze(-1) # [B, 1, T, 1]
141
+
142
+ angles = positions * self.inv_freq.to(device) # [B, 1, T, D/2]
143
+ sin, cos = angles.sin(), angles.cos()
144
+
145
+ x_even, x_odd = x[..., 0::2], x[..., 1::2]
146
+ out = torch.empty_like(x)
147
+ out[..., 0::2] = x_even * cos - x_odd * sin
148
+ out[..., 1::2] = x_even * sin + x_odd * cos
149
+ return out
150
+
151
+
152
+ # ═══════════════════════════════════════════════════════════════════════
153
+ # Onset Feature Injection (FiLM)
154
+ # ═══════════════════════════════════════════════════════════════════════
155
+
156
+ class OnsetFiLM(nn.Module):
157
+ """Beat-prior injection: modulates encoder features at beat positions."""
158
+
159
+ def __init__(self, d_model: int = 512):
160
+ super().__init__()
161
+ self.gamma = nn.Linear(1, d_model)
162
+ self.beta = nn.Linear(1, d_model)
163
+
164
+ def forward(self, enc_out, onset):
165
+ """enc_out: [B, T_enc, D], onset: [B, T_enc, 1]"""
166
+ g = torch.tanh(self.gamma(onset)) * 0.5 # [B, T_enc, D]
167
+ b = self.beta(onset) * 0.1
168
+ return enc_out * (1.0 + g) + b
169
+
170
+
171
+ # ═══════════════════════════════════════════════════════════════════════
172
+ # MoE FFN (Mixture of Experts for difficulty routing)
173
+ # ═══════════════════════════════════════════════════════════════════════
174
+
175
+ class MoEFFN(nn.Module):
176
+ """MoE FFN: routes input through N experts weighted by difficulty."""
177
+
178
+ def __init__(self, d_model=512, d_ff=2048, n_experts=6, dropout=0.1):
179
+ super().__init__()
180
+ self.n_experts = n_experts
181
+ self.experts = nn.ModuleList([
182
+ nn.Sequential(
183
+ nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout),
184
+ nn.Linear(d_ff, d_model), nn.Dropout(dropout),
185
+ ) for _ in range(n_experts)
186
+ ])
187
+ self.router = nn.Linear(d_model, n_experts)
188
+
189
+ def forward(self, x, diff_emb):
190
+ """x: [B, T, d], diff_emb: [B, d]"""
191
+ weights = F.softmax(self.router(diff_emb), dim=-1) # [B, N]
192
+ out = sum(weights[:, i:i+1, None] * self.experts[i](x)
193
+ for i in range(self.n_experts))
194
+ return out
195
+
196
+
197
+ # ═══════════════════════════════════════════════════════════════════════
198
+ # Encoder Block
199
+ # ═══════════════════════════════════════════════════════════════════════
200
+
201
+ class EncoderBlock(nn.Module):
202
+ """Pre-LN encoder: Self-Attn + FFN. Uses FlashAttention for O(T) memory."""
203
+
204
+ def __init__(self, d_model: int = 512, heads: int = 8,
205
+ d_ff: int = 2048, dropout: float = 0.1):
206
+ super().__init__()
207
+ self.heads = heads
208
+ self.head_dim = d_model // heads
209
+
210
+ self.q_proj = nn.Linear(d_model, d_model)
211
+ self.k_proj = nn.Linear(d_model, d_model)
212
+ self.v_proj = nn.Linear(d_model, d_model)
213
+ self.out_proj = nn.Linear(d_model, d_model)
214
+ self.rope = RoPE(self.head_dim)
215
+
216
+ self.ffn = nn.Sequential(
217
+ nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout),
218
+ nn.Linear(d_ff, d_model), nn.Dropout(dropout),
219
+ )
220
+ self.norm1 = nn.LayerNorm(d_model)
221
+ self.norm2 = nn.LayerNorm(d_model)
222
+ self.dropout = nn.Dropout(dropout)
223
+
224
+ def forward(self, x: torch.Tensor,
225
+ positions: Optional[torch.Tensor] = None,
226
+ mask: Optional[torch.Tensor] = None) -> torch.Tensor:
227
+ B, T, D = x.shape
228
+
229
+ residual = x
230
+ x_norm = self.norm1(x)
231
+
232
+ Q = self.q_proj(x_norm).view(B, T, self.heads, self.head_dim).transpose(1, 2)
233
+ K = self.k_proj(x_norm).view(B, T, self.heads, self.head_dim).transpose(1, 2)
234
+ V = self.v_proj(x_norm).view(B, T, self.heads, self.head_dim).transpose(1, 2)
235
+
236
+ # RoPE time-aligned positions.
237
+ if positions is not None:
238
+ Q = self.rope(Q, positions)
239
+ K = self.rope(K, positions)
240
+
241
+ if mask is not None:
242
+ attn_mask = mask
243
+ if attn_mask.dtype != torch.bool:
244
+ attn_mask = attn_mask.to(torch.bool)
245
+ attn_out = F.scaled_dot_product_attention(
246
+ Q, K, V,
247
+ attn_mask=attn_mask,
248
+ dropout_p=self.dropout.p if self.training else 0.0,
249
+ )
250
+ else:
251
+ attn_out = _sdpa(Q, K, V, self.dropout.p if self.training else 0.0)
252
+ attn_out = attn_out.transpose(1, 2).contiguous().view(B, T, D)
253
+ x = residual + self.dropout(self.out_proj(attn_out))
254
+
255
+ residual = x
256
+ x = residual + self.ffn(self.norm2(x))
257
+ return x
258
+
259
+
260
+ # ═══════════════════════════════════════════════════════════════════════
261
+ # Decoder Block
262
+ # ═══════════════════════════════════════════════════════════════════════
263
+
264
+ class DecoderBlock(nn.Module):
265
+ """Pre-LN decoder: Causal Self-Attn + Cross-Attn + FFN.
266
+
267
+ Supports KV-cache for fast autoregressive inference.
268
+ """
269
+
270
+ def __init__(self, d_model: int = 512, heads: int = 8,
271
+ d_ff: int = 2048, dropout: float = 0.1,
272
+ use_moe: bool = False, n_experts: int = 6):
273
+ super().__init__()
274
+ self.heads = heads
275
+ self.head_dim = d_model // heads
276
+
277
+ # Self-attn
278
+ self.q_proj = nn.Linear(d_model, d_model)
279
+ self.k_proj = nn.Linear(d_model, d_model)
280
+ self.v_proj = nn.Linear(d_model, d_model)
281
+ self.out_proj = nn.Linear(d_model, d_model)
282
+ self.rope = RoPE(self.head_dim)
283
+
284
+ # Cross-attn
285
+ self.cross_q = nn.Linear(d_model, d_model)
286
+ self.cross_k = nn.Linear(d_model, d_model)
287
+ self.cross_v = nn.Linear(d_model, d_model)
288
+ self.cross_out = nn.Linear(d_model, d_model)
289
+
290
+ # FFN (standard or MoE)
291
+ if use_moe:
292
+ self.ffn = MoEFFN(d_model, d_ff, n_experts, dropout)
293
+ self.is_moe = True
294
+ else:
295
+ self.ffn = nn.Sequential(
296
+ nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout),
297
+ nn.Linear(d_ff, d_model), nn.Dropout(dropout),
298
+ )
299
+ self.is_moe = False
300
+
301
+ self.norm1 = nn.LayerNorm(d_model)
302
+ self.norm2 = nn.LayerNorm(d_model)
303
+ self.norm3 = nn.LayerNorm(d_model)
304
+ self.dropout = nn.Dropout(dropout)
305
+
306
+ def _init_self_kv_cache(self, batch_size: int, max_len: int, device):
307
+ """Pre-allocate self-attention KV cache: tuple of (K, V) tensors."""
308
+ D = self.head_dim
309
+ H = self.heads
310
+ self._self_k_cache = torch.zeros(batch_size, H, max_len, D, device=device)
311
+ self._self_v_cache = torch.zeros(batch_size, H, max_len, D, device=device)
312
+ self._self_cache_len = 0
313
+
314
+ def _init_cross_kv_cache(self, enc_out: torch.Tensor):
315
+ """Precompute cross-attention K, V from encoder output (fixed during generation).
316
+ Note: enc_out is the raw encoder output, NOT pre-normed (norm2 is for decoder hidden)."""
317
+ B, T_enc, D = enc_out.shape
318
+ Kc = self.cross_k(enc_out).view(B, T_enc, self.heads, self.head_dim).transpose(1, 2)
319
+ Vc = self.cross_v(enc_out).view(B, T_enc, self.heads, self.head_dim).transpose(1, 2)
320
+ self._cross_k_cache = Kc
321
+ self._cross_v_cache = Vc
322
+
323
+ def forward(self, x, enc_out, self_positions=None, diff_emb=None,
324
+ onset_film=None, onset_kv=None,
325
+ use_cache: bool = False):
326
+ B, T_dec, D = x.shape
327
+
328
+ # ── Causal Self-Attn ──
329
+ residual = x
330
+ x_norm = self.norm1(x)
331
+ Q = self.q_proj(x_norm).view(B, T_dec, self.heads, self.head_dim).transpose(1, 2)
332
+ K = self.k_proj(x_norm).view(B, T_dec, self.heads, self.head_dim).transpose(1, 2)
333
+ V = self.v_proj(x_norm).view(B, T_dec, self.heads, self.head_dim).transpose(1, 2)
334
+
335
+ # RoPE time-aligned positions (full sequence positions for cache mode)
336
+ if self_positions is not None:
337
+ Q = self.rope(Q, self_positions)
338
+ K = self.rope(K, self_positions)
339
+
340
+ if use_cache and hasattr(self, '_self_k_cache'):
341
+ # Append new K, V to cache
342
+ cache_len = self._self_cache_len
343
+ new_len = T_dec
344
+ self._self_k_cache[:, :, cache_len:cache_len + new_len] = K
345
+ self._self_v_cache[:, :, cache_len:cache_len + new_len] = V
346
+ K = self._self_k_cache[:, :, :cache_len + new_len]
347
+ V = self._self_v_cache[:, :, :cache_len + new_len]
348
+ self._self_cache_len = cache_len + new_len
349
+
350
+ attn = _sdpa(Q, K, V, self.dropout.p if self.training else 0.0,
351
+ is_causal=(not use_cache))
352
+ x = residual + self.dropout(self.out_proj(
353
+ attn.transpose(1, 2).contiguous().view(B, T_dec, D)))
354
+
355
+ # ── Cross-Attn (with optional OnsetFiLM modulation) ──
356
+ residual = x
357
+ x_norm = self.norm2(x)
358
+ Qc = self.cross_q(x_norm).view(B, T_dec, self.heads, self.head_dim).transpose(1, 2)
359
+
360
+ if use_cache and hasattr(self, '_cross_k_cache'):
361
+ Kc = self._cross_k_cache
362
+ Vc = self._cross_v_cache
363
+ else:
364
+ _enc = enc_out
365
+ if onset_film is not None and onset_kv is not None:
366
+ _enc = onset_film(_enc, onset_kv)
367
+ T_enc = _enc.shape[1]
368
+ Kc = self.cross_k(_enc).view(B, T_enc, self.heads, self.head_dim).transpose(1, 2)
369
+ Vc = self.cross_v(_enc).view(B, T_enc, self.heads, self.head_dim).transpose(1, 2)
370
+
371
+ attn_c = _sdpa(Qc, Kc, Vc, self.dropout.p if self.training else 0.0)
372
+ x = residual + self.dropout(self.cross_out(
373
+ attn_c.transpose(1, 2).contiguous().view(B, T_dec, D)))
374
+
375
+ # ── FFN ──
376
+ residual = x
377
+ if self.is_moe and diff_emb is not None:
378
+ x = residual + self.ffn(self.norm3(x), diff_emb)
379
+ else:
380
+ x = residual + self.ffn(self.norm3(x))
381
+ return x
382
+
383
+
384
+ # ═══════════════════════════════════════════════════════════════════════
385
+ # MaiGenerator
386
+ # ═══════════════════════════════════════════════════════════════════════
387
+
388
+ class MaiGenerator(nn.Module):
389
+ """Audio-conditioned autoregressive maimai chart generator.
390
+
391
+ Input:
392
+ audio_tokens: [B, T_aud] — EnCodec tokens
393
+ chart_tokens: [B, T_chart] — chart token sequence (train: full[:-1])
394
+ bpm: [B, 1] — BPM value
395
+ difficulty: [B, 1] — difficulty enum (0..4)
396
+ level_value: [B, 1] — numeric level (e.g. 12.4)
397
+ genre: [B, 1] — genre index (optional)
398
+
399
+ Output:
400
+ logits: [B, T_chart, chart_vocab] — next-token prediction
401
+ """
402
+
403
+ def __init__(self, d_model: int = 512, enc_layers: int = 6,
404
+ dec_layers: int = 8, heads: int = 8, d_ff: int = 2048,
405
+ dropout: float = 0.1, chart_vocab: int | None = None,
406
+ audio_vocab: int = AUDIO_VOCAB_SIZE,
407
+ num_genres: int = 16, max_audio_len: int = 32768,
408
+ audio_downsample: int = 8, use_moe: bool = True,
409
+ n_experts: int = 6, moe_layers: list = None):
410
+ """moe_layers: indices of decoder layers that use MoE (e.g. [8,9,10,11])
411
+ Other decoder layers use shared FFN. None = all decoder layers use MoE."""
412
+ super().__init__()
413
+ if chart_vocab is None:
414
+ chart_vocab = chart_tokenizer.VOCAB_SIZE
415
+ self.d_model = d_model
416
+ self.chart_vocab_size = chart_vocab
417
+ self.audio_downsample = audio_downsample
418
+
419
+ if moe_layers is None:
420
+ moe_layers = list(range(dec_layers)) # all MoE
421
+
422
+ # Embeddings
423
+ self.audio_embed = nn.Embedding(audio_vocab, d_model)
424
+ self.chart_embed = nn.Embedding(chart_vocab, d_model)
425
+ self.chart_type_embed = nn.Embedding(NUM_TOKEN_TYPES, d_model)
426
+ self.chart_pos_embed = nn.Embedding(NUM_POSITIONS, d_model)
427
+
428
+ # Onset feature injection
429
+ self.onset_film = OnsetFiLM(d_model)
430
+
431
+ # Audio downsampling (Conv1D + LayerNorm to reduce seq len)
432
+ if audio_downsample > 1:
433
+ self.audio_down = nn.Sequential(
434
+ nn.Conv1d(d_model, d_model, kernel_size=audio_downsample,
435
+ stride=audio_downsample, padding=0),
436
+ nn.GELU(),
437
+ )
438
+ else:
439
+ self.audio_down = nn.Identity()
440
+
441
+ self.audio_pos_embed = nn.Embedding(max_audio_len, d_model)
442
+
443
+ # Conditions
444
+ self.bpm_proj = nn.Sequential(nn.Linear(1, d_model), nn.SiLU(),
445
+ nn.Linear(d_model, d_model))
446
+ self.diff_embed = nn.Embedding(NUM_DIFFICULTIES, d_model)
447
+ self.level_proj = nn.Sequential(nn.Linear(1, d_model), nn.SiLU(),
448
+ nn.Linear(d_model, d_model))
449
+ self.genre_embed = nn.Embedding(num_genres, d_model)
450
+
451
+ # Encoder / Decoder (hybrid: shared FFN + MoE layers)
452
+ self.audio_encoder = nn.ModuleList([
453
+ EncoderBlock(d_model, heads, d_ff, dropout) for _ in range(enc_layers)])
454
+ self.moe_layers = set(moe_layers)
455
+ n_shared = dec_layers - len(self.moe_layers)
456
+ n_moe = len(self.moe_layers)
457
+ print(f"Decoder: {n_shared} shared + {n_moe} MoE ×{n_experts} experts")
458
+ self.chart_decoder = nn.ModuleList([
459
+ DecoderBlock(d_model, heads, d_ff, dropout,
460
+ use_moe=(i in self.moe_layers), n_experts=n_experts)
461
+ for i in range(dec_layers)])
462
+
463
+ self.output_head = nn.Linear(d_model, chart_vocab)
464
+ self.presence_head = nn.Linear(d_model, 2)
465
+ self.type_head = nn.Linear(d_model, NUM_TOKEN_TYPES)
466
+ self.position_head = nn.Linear(d_model, 8)
467
+ self.division_head = nn.Linear(d_model, NUM_DIV_CLASSES)
468
+ self.sim_head = nn.Linear(d_model, 2)
469
+ self.duration_head = nn.Linear(d_model, 2)
470
+ self.enc_norm = nn.LayerNorm(d_model)
471
+ self.dec_norm = nn.LayerNorm(d_model)
472
+ self.dropout = nn.Dropout(dropout)
473
+
474
+ self._init_weights()
475
+
476
+ def _init_weights(self):
477
+ for m in self.modules():
478
+ if isinstance(m, nn.Linear):
479
+ nn.init.xavier_uniform_(m.weight, gain=0.5)
480
+ if m.bias is not None:
481
+ nn.init.zeros_(m.bias)
482
+ elif isinstance(m, nn.Embedding):
483
+ nn.init.normal_(m.weight, std=0.02)
484
+
485
+ def init_kv_cache(self, batch_size: int, max_len: int, enc_out: torch.Tensor):
486
+ """Initialize KV caches for all decoder blocks. Call before incremental generation.
487
+
488
+ Args:
489
+ batch_size: Batch size (usually 1 for inference).
490
+ max_len: Maximum generation length for pre-allocation.
491
+ enc_out: Encoder output [B, T_enc, D] for cross-attention cache.
492
+ """
493
+ for blk in self.chart_decoder:
494
+ blk._init_self_kv_cache(batch_size, max_len, enc_out.device)
495
+ blk._init_cross_kv_cache(enc_out)
496
+
497
+ # ── Time-aligned positions (核心) ──────────────────────────────
498
+
499
+ @staticmethod
500
+ def compute_chart_positions(chart_tokens: torch.Tensor,
501
+ bpm: torch.Tensor,
502
+ downsample: int = 4) -> torch.Tensor:
503
+ """Compute downsampled audio-frame positions for chart tokens.
504
+
505
+ Args:
506
+ chart_tokens: [B, T]
507
+ bpm: [B, 1]
508
+ downsample: Audio downsampling factor.
509
+
510
+ Returns:
511
+ positions: [B, T] — downsampled frame indices (float).
512
+ """
513
+ B, T = chart_tokens.shape
514
+ device = chart_tokens.device
515
+ bpm_v = bpm.view(B).float().clamp(min=30.0)
516
+
517
+ div_values = torch.full((B,), 4.0, device=device)
518
+ positions = torch.zeros(B, T, device=device)
519
+ current_beat = torch.zeros(B, device=device)
520
+ dur_param_skip = torch.zeros(B, dtype=torch.long, device=device)
521
+ sim_skip = torch.zeros(B, dtype=torch.long, device=device)
522
+ slide_active = torch.zeros(B, dtype=torch.bool, device=device)
523
+
524
+ for i in range(T):
525
+ tok = chart_tokens[:, i]
526
+
527
+ # Update beat division
528
+ for div_id, div_val in DIV_MAP.items():
529
+ div_values = torch.where(tok == div_id,
530
+ torch.tensor(float(div_val), device=device),
531
+ div_values)
532
+
533
+ beat_per_token = 4.0 / div_values
534
+
535
+ # Record position: beat → seconds → audio frame → downsampled
536
+ time_sec = current_beat * 60.0 / bpm_v
537
+ positions[:, i] = time_sec * AUDIO_FRAME_RATE / downsample
538
+
539
+ # Advance beat only for decoded timeline slots. SIM/SLD groups
540
+ # occupy one slot at their begin token; their contents are structural.
541
+ is_dur_param = dur_param_skip > 0
542
+ is_dur = (tok == DUR_TOKEN)
543
+ in_sim_body = sim_skip > 0
544
+ in_slide_body = slide_active & (tok != SLD_BEG_TOKEN)
545
+ group_body = in_sim_body | in_slide_body
546
+ advances_time = is_timeline_token(tok) & ~is_dur_param & ~is_dur & ~group_body
547
+ current_beat = torch.where(advances_time,
548
+ current_beat + beat_per_token,
549
+ current_beat)
550
+
551
+ is_sim_beg = tok == SIM_BEG_TOKEN
552
+ is_sim_end = tok == SIM_END_TOKEN
553
+ count_after_sim_beg = is_sim_beg & (i + 1 < T)
554
+ next_tok = chart_tokens[:, i + 1] if i + 1 < T else torch.zeros_like(tok)
555
+ sim_skip = torch.where(count_after_sim_beg,
556
+ torch.clamp(next_tok + 2, min=0),
557
+ torch.clamp(sim_skip - 1, min=0))
558
+ sim_skip = torch.where(is_sim_end, torch.zeros_like(sim_skip), sim_skip)
559
+
560
+ slide_active = torch.where(tok == SLD_BEG_TOKEN, torch.ones_like(slide_active), slide_active)
561
+ slide_active = torch.where(tok == SLD_END_TOKEN, torch.zeros_like(slide_active), slide_active)
562
+
563
+ dur_param_skip = torch.where(is_dur,
564
+ torch.full_like(dur_param_skip, 2),
565
+ torch.clamp(dur_param_skip - 1, min=0))
566
+
567
+ return positions
568
+
569
+ # ── Forward ────────────────────────────────────────────────────
570
+
571
+ def forward(self, audio_tokens: torch.Tensor,
572
+ chart_tokens: torch.Tensor, bpm: torch.Tensor,
573
+ difficulty: torch.Tensor, level_value: torch.Tensor,
574
+ genre: Optional[torch.Tensor] = None,
575
+ onset_curve: Optional[torch.Tensor] = None,
576
+ return_aux: bool = False,
577
+ return_hidden: bool = False) -> torch.Tensor | dict[str, torch.Tensor]:
578
+ """Training forward pass (teacher forcing).
579
+
580
+ Returns:
581
+ logits: [B, T_chart, 256]
582
+ """
583
+ B, T_chart = chart_tokens.shape
584
+ device = chart_tokens.device
585
+
586
+ # ── Encode audio ──
587
+ T_aud = audio_tokens.shape[1]
588
+ aud = self.audio_embed(audio_tokens)
589
+ aud = aud + self.audio_pos_embed(
590
+ torch.arange(T_aud, device=device).unsqueeze(0).expand(B, -1))
591
+
592
+ # Downsample: [B, T, D] → [B, T//stride, D]
593
+ if self.audio_downsample > 1:
594
+ aud = aud.transpose(1, 2) # [B, D, T]
595
+ aud = self.audio_down(aud) # [B, D, T']
596
+ aud = aud.transpose(1, 2) # [B, T', D]
597
+ T_aud = aud.shape[1]
598
+
599
+ if onset_curve is None:
600
+ delta = torch.zeros(B, T_aud, device=device, dtype=aud.dtype)
601
+ if T_aud > 1:
602
+ delta[:, 1:] = (aud[:, 1:] - aud[:, :-1]).pow(2).mean(dim=-1).sqrt()
603
+ denom = delta.amax(dim=1, keepdim=True).clamp_min(1e-6)
604
+ delta = delta / denom
605
+ onset_curve = delta.unsqueeze(-1)
606
+ elif onset_curve.dim() == 2:
607
+ onset_curve = onset_curve.unsqueeze(-1)
608
+ if onset_curve.shape[1] != T_aud:
609
+ onset_curve = F.interpolate(onset_curve.transpose(1, 2), size=T_aud,
610
+ mode="linear", align_corners=False).transpose(1, 2)
611
+
612
+ aud = self.dropout(aud)
613
+ aud_pos = torch.arange(T_aud, device=device, dtype=torch.float32)
614
+ for blk in self.audio_encoder:
615
+ aud = blk(aud, positions=aud_pos)
616
+ aud = self.enc_norm(aud)
617
+
618
+ # Diff vector for decoder MoE routing
619
+ diff_vec = self.diff_embed(difficulty.squeeze(-1)) # [B, d_model]
620
+
621
+ # Embed chart + structural token features + conditions
622
+ token_type, token_pos = token_structure_features(chart_tokens)
623
+ emb = (self.chart_embed(chart_tokens) +
624
+ self.chart_type_embed(token_type) +
625
+ self.chart_pos_embed(token_pos))
626
+
627
+ bpm_emb = self.bpm_proj(bpm.float()).unsqueeze(1)
628
+ diff_emb = self.diff_embed(difficulty.squeeze(-1)).unsqueeze(1)
629
+ level_emb = self.level_proj(level_value.float()).unsqueeze(1)
630
+ genre_emb = torch.zeros(B, 1, self.d_model, device=device)
631
+ if genre is not None:
632
+ genre_emb = self.genre_embed(genre.squeeze(-1)).unsqueeze(1)
633
+
634
+ emb = emb + bpm_emb + diff_emb + level_emb + genre_emb
635
+
636
+ # ── Time-aligned positions ──
637
+ chart_pos = self.compute_chart_positions(chart_tokens, bpm,
638
+ self.audio_downsample)
639
+
640
+ # ── Decode ──
641
+ x = emb
642
+ onset_kv = onset_curve if onset_curve is not None else None
643
+ for blk in self.chart_decoder:
644
+ x = blk(x, enc_out=aud, self_positions=chart_pos,
645
+ diff_emb=diff_vec, onset_film=self.onset_film, onset_kv=onset_kv)
646
+ x = self.dec_norm(x)
647
+
648
+ if return_hidden:
649
+ result = {"hidden": x}
650
+ if return_aux:
651
+ result.update({
652
+ "presence": self.presence_head(x),
653
+ "type": self.type_head(x),
654
+ "position": self.position_head(x),
655
+ "division": self.division_head(x),
656
+ "sim": self.sim_head(x),
657
+ "duration": self.duration_head(x),
658
+ })
659
+ return result
660
+
661
+ token_logits = self.output_head(x)
662
+ if not return_aux:
663
+ return token_logits
664
+
665
+ return {
666
+ "token": token_logits,
667
+ "presence": self.presence_head(x),
668
+ "type": self.type_head(x),
669
+ "position": self.position_head(x),
670
+ "division": self.division_head(x),
671
+ "sim": self.sim_head(x),
672
+ "duration": self.duration_head(x),
673
+ }
674
+
675
+ @property
676
+ def device(self) -> torch.device:
677
+ return next(self.parameters()).device
678
+
679
+ @property
680
+ def device(self) -> torch.device:
681
+ return next(self.parameters()).device
targets.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from model import DIV_MAP
6
+ from tokenizer import (
7
+ PAD, RST, SIM_BEG, SIM_END, SLD_BEG, DUR, SIM_COUNT_2,
8
+ BRK_BASE, BRK_END, HLD_BASE, HLD_END, ID_TO_DIV,
9
+ DUR_NUM_TO_ID, DUR_DEN_TO_ID,
10
+ SLD_BASE, SLD_END, TAP_BASE, TAP_END, TCH_BASE, TCH_END,
11
+ CONFIG_BASE,
12
+ )
13
+
14
+ TYPE_REST = 0
15
+ TYPE_TAP = 1
16
+ TYPE_HOLD = 2
17
+ TYPE_SLIDE = 3
18
+ TYPE_BREAK = 4
19
+ TYPE_TOUCH = 5
20
+ TYPE_CONTROL = 6
21
+ NUM_TOKEN_TYPES = 7
22
+
23
+ DIV_VALUES = [1, 2, 4, 8, 16, 32, 48, 64, 128, 192, 384]
24
+ DIV_VALUE_TO_CLASS = {v: i for i, v in enumerate(DIV_VALUES)}
25
+ NUM_DIV_CLASSES = len(DIV_VALUES)
26
+
27
+
28
+ def build_aux_targets(tokens: torch.Tensor) -> dict[str, torch.Tensor]:
29
+ """Build auxiliary labels from next-token targets.
30
+
31
+ Args:
32
+ tokens: [B, T] target token ids, usually chart_tokens[:, 1:].
33
+ """
34
+ device = tokens.device
35
+ B, T = tokens.shape
36
+
37
+ presence = torch.zeros(B, T, dtype=torch.long, device=device)
38
+ token_type = torch.full((B, T), TYPE_CONTROL, dtype=torch.long, device=device)
39
+ position = torch.full((B, T), -100, dtype=torch.long, device=device)
40
+ division = torch.full((B, T), -100, dtype=torch.long, device=device)
41
+ is_sim = torch.zeros(B, T, dtype=torch.long, device=device)
42
+ needs_duration = torch.zeros(B, T, dtype=torch.long, device=device)
43
+
44
+ is_pad = tokens == PAD
45
+ token_type = torch.where(tokens == RST, torch.full_like(token_type, TYPE_REST), token_type)
46
+
47
+ ranges = [
48
+ (TAP_BASE, TAP_END, TYPE_TAP),
49
+ (HLD_BASE, HLD_END, TYPE_HOLD),
50
+ (SLD_BASE, SLD_END, TYPE_SLIDE),
51
+ (BRK_BASE, BRK_END, TYPE_BREAK),
52
+ (TCH_BASE, TCH_END, TYPE_TOUCH),
53
+ ]
54
+ for start, end, typ in ranges:
55
+ mask = (tokens >= start) & (tokens < end)
56
+ token_type = torch.where(mask, torch.full_like(token_type, typ), token_type)
57
+ presence = torch.where(mask, torch.ones_like(presence), presence)
58
+ position = torch.where(mask, (tokens - start) % 8, position)
59
+
60
+ group_event = (tokens == SIM_BEG) | (tokens == SLD_BEG)
61
+ presence = torch.where(group_event, torch.ones_like(presence), presence)
62
+ config_event = tokens >= CONFIG_BASE
63
+ presence = torch.where(config_event, torch.ones_like(presence), presence)
64
+ sim_event = (tokens == SIM_BEG) | (tokens == SIM_COUNT_2) | (tokens == SIM_END)
65
+ is_sim = torch.where(sim_event, torch.ones_like(is_sim), is_sim)
66
+ duration_tokens = {DUR} | set(DUR_NUM_TO_ID.values()) | set(DUR_DEN_TO_ID.values())
67
+ duration_event = torch.zeros_like(needs_duration, dtype=torch.bool)
68
+ for tok in duration_tokens:
69
+ duration_event |= tokens == tok
70
+ needs_duration = torch.where(duration_event, torch.ones_like(needs_duration), needs_duration)
71
+
72
+ for div_id, div_value in ID_TO_DIV.items():
73
+ mask = tokens == div_id
74
+ division = torch.where(mask, torch.full_like(division, DIV_VALUE_TO_CLASS[div_value]), division)
75
+
76
+ token_type = torch.where(is_pad, torch.full_like(token_type, -100), token_type)
77
+ presence = torch.where(is_pad, torch.full_like(presence, -100), presence)
78
+ is_sim = torch.where(is_pad, torch.full_like(is_sim, -100), is_sim)
79
+ needs_duration = torch.where(is_pad, torch.full_like(needs_duration, -100), needs_duration)
80
+
81
+ return {
82
+ "presence": presence,
83
+ "type": token_type,
84
+ "position": position,
85
+ "division": division,
86
+ "sim": is_sim,
87
+ "duration": needs_duration,
88
+ }
tokenizer.py ADDED
@@ -0,0 +1,1145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Maimai Chart Tokenizer — rule-based bidirectional chart ↔ token conversion.
3
+
4
+ Design:
5
+ - BPM is NOT tokenized (computed separately by external program)
6
+ - Beat division (div_N) tokens control note granularity
7
+ - Each note event → 1~5 tokens, lossless round-trip
8
+
9
+ Vocabulary size: 256 (0-255), with room for expansion.
10
+
11
+ Usage:
12
+ from tokenizer import MaiChartTokenizer
13
+
14
+ tok = MaiChartTokenizer()
15
+ tokens = tok.encode(chart)
16
+ chart2 = tok.decode(tokens) # lossless
17
+ """
18
+
19
+ from __future__ import annotations
20
+ from dataclasses import dataclass
21
+ import json
22
+ from typing import Optional
23
+
24
+ from mai_parser.models import Chart, TouchNote
25
+
26
+ # ═══════════════════════════════════════════════════════════════════════
27
+ # Vocabulary definition
28
+ # ═══════════════════════════════════════════════════════════════════════
29
+
30
+ # --- Special tokens (0-4) ---
31
+ PAD = 0
32
+ BOS = 1
33
+ EOS = 2
34
+ SEP = 3
35
+ MASK = 4
36
+ _SPECIAL_END = 5
37
+
38
+ # --- Beat division tokens (5-16): {1} {2} {4} {8} {16} {32} {48} {64} {128} {192} {384} ---
39
+ _DIV_VALUES = [1, 2, 4, 8, 16, 32, 48, 64, 128, 192, 384]
40
+ DIV_BASE = _SPECIAL_END
41
+ DIV_TO_ID: dict[int, int] = {v: DIV_BASE + i for i, v in enumerate(_DIV_VALUES)}
42
+ ID_TO_DIV: dict[int, int] = {v: k for k, v in DIV_TO_ID.items()}
43
+ DIV_END = DIV_BASE + len(_DIV_VALUES)
44
+
45
+ # --- Rest token (17) ---
46
+ RST = DIV_END
47
+ _RST_END = RST + 1
48
+
49
+ # --- Duration marker (18): followed by 2 tokens [beat, subdiv] ---
50
+ DUR = _RST_END
51
+ _DUR_END = DUR + 1
52
+
53
+ # --- Tap tokens (19-26): tap_1 ~ tap_8 ---
54
+ TAP_BASE = _DUR_END
55
+ TAP_TO_ID = {i: TAP_BASE + i - 1 for i in range(1, 9)}
56
+ ID_TO_TAP = {v: k for k, v in TAP_TO_ID.items()}
57
+ TAP_END = TAP_BASE + 8
58
+
59
+ # --- Break tokens (27-34): brk_1 ~ brk_8 ---
60
+ BRK_BASE = TAP_END
61
+ BRK_TO_ID = {i: BRK_BASE + i - 1 for i in range(1, 9)}
62
+ ID_TO_BRK = {v: k for k, v in BRK_TO_ID.items()}
63
+ BRK_END = BRK_BASE + 8
64
+
65
+ # --- Hold tokens (35-42): hld_1 ~ hld_8 ---
66
+ HLD_BASE = BRK_END
67
+ HLD_TO_ID = {i: HLD_BASE + i - 1 for i in range(1, 9)}
68
+ ID_TO_HLD = {v: k for k, v in HLD_TO_ID.items()}
69
+ HLD_END = HLD_BASE + 8
70
+
71
+ # --- Slide waypoint tokens (43-50): sld_1 ~ sld_8 ---
72
+ SLD_BASE = HLD_END
73
+ SLD_TO_ID = {i: SLD_BASE + i - 1 for i in range(1, 9)}
74
+ ID_TO_SLD = {v: k for k, v in SLD_TO_ID.items()}
75
+ SLD_END = SLD_BASE + 8
76
+
77
+ # --- Slide control (51-52) ---
78
+ SLD_BEG = SLD_END # slide start, next token = point count
79
+ SLD_END_TOKEN = SLD_BEG + 1 # slide end marker
80
+ _SLD_CTRL_END = SLD_END_TOKEN + 1
81
+
82
+ # --- Simultaneous control (53-54) ---
83
+ SIM_BEG = _SLD_CTRL_END # simultaneous start, next token = note count
84
+ SIM_END = SIM_BEG + 1 # simultaneous end
85
+ _SIM_CTRL_END = SIM_END + 1
86
+
87
+ # --- Touch tokens (55-90): A1-A8, B1-B8, C1-C8, D1-D8, E1-E8 ---
88
+ TCH_BASE = _SIM_CTRL_END
89
+ _TOUCH_ZONES = ["A", "B", "C", "D", "E"]
90
+
91
+ _tch_map: dict[str, int] = {}
92
+ _idx = TCH_BASE
93
+ for zone in _TOUCH_ZONES:
94
+ for pos in range(1, 9):
95
+ _tch_map[f"{zone}{pos}"] = _idx
96
+ _idx += 1
97
+ # C (center touch without position)
98
+ _tch_map["C"] = _idx
99
+ _idx += 1
100
+
101
+ TCH_TO_ID = _tch_map
102
+ ID_TO_TCH = {v: k for k, v in TCH_TO_ID.items()}
103
+ TCH_END = _idx
104
+
105
+ # --- Parameter tokens (96-120): context-safe count/duration values ---
106
+ SIM_COUNT_2 = TCH_END
107
+ _SIM_COUNT_END = SIM_COUNT_2 + 1
108
+
109
+ _DUR_NUM_VALUES = [1, 2, 3, 4, 6, 8, 12, 16]
110
+ DUR_NUM_BASE = _SIM_COUNT_END
111
+ DUR_NUM_TO_ID = {v: DUR_NUM_BASE + i for i, v in enumerate(_DUR_NUM_VALUES)}
112
+ ID_TO_DUR_NUM = {v: k for k, v in DUR_NUM_TO_ID.items()}
113
+ DUR_NUM_END = DUR_NUM_BASE + len(_DUR_NUM_VALUES)
114
+
115
+ _DUR_DEN_VALUES = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64]
116
+ DUR_DEN_BASE = DUR_NUM_END
117
+ DUR_DEN_TO_ID = {v: DUR_DEN_BASE + i for i, v in enumerate(_DUR_DEN_VALUES)}
118
+ ID_TO_DUR_DEN = {v: k for k, v in DUR_DEN_TO_ID.items()}
119
+ DUR_DEN_END = DUR_DEN_BASE + len(_DUR_DEN_VALUES)
120
+
121
+ # --- Metadata/header/helper tokens (220-231): kept for backward compatibility ---
122
+ META_BPM = 220 # followed by BPM//2 as uint8
123
+ META_DIFF = 221 # followed by difficulty enum (0-4)
124
+ META_LEVEL = 222 # followed by level*10 as uint8
125
+ META_GENRE = 223 # followed by genre id
126
+ META_END = 224 # end of metadata header
127
+ SLD_MID = 229 # slide intermediate waypoint
128
+ HLD_ON = 230 # hold is ongoing at this beat position (informational)
129
+ SLD_ON = 231 # slide is ongoing at this beat position (informational)
130
+
131
+ # --- Whole time-slot configuration tokens (256+) ---
132
+ CONFIG_BASE = 256
133
+
134
+
135
+ def _duration_pairs(max_beats: float = 4.0) -> list[tuple[int, int]]:
136
+ pairs: list[tuple[int, int]] = []
137
+ for n in _DUR_NUM_VALUES:
138
+ for d in _DUR_DEN_VALUES:
139
+ if n / d <= max_beats + 1e-9:
140
+ pairs.append((n, d))
141
+ return pairs
142
+
143
+
144
+ CONFIG_DURATIONS = _duration_pairs(4.0)
145
+ CONFIG_TO_ID: dict[tuple, int] = {}
146
+ ID_TO_CONFIG: dict[int, tuple] = {}
147
+
148
+
149
+ def _normalize_config_spec(spec) -> tuple:
150
+ return tuple(tuple(x) if isinstance(x, list) else x for x in spec)
151
+
152
+
153
+ def _add_config(spec: tuple) -> int:
154
+ spec = _normalize_config_spec(spec)
155
+ if spec in CONFIG_TO_ID:
156
+ return CONFIG_TO_ID[spec]
157
+ idx = CONFIG_BASE + len(CONFIG_TO_ID)
158
+ CONFIG_TO_ID[spec] = idx
159
+ ID_TO_CONFIG[idx] = spec
160
+ if "_TOKEN_NAMES" in globals():
161
+ _TOKEN_NAMES[idx] = "cfg_" + "_".join(str(x) for x in spec)
162
+ global VOCAB_SIZE
163
+ VOCAB_SIZE = CONFIG_BASE + len(CONFIG_TO_ID)
164
+ if "MaiChartTokenizer" in globals():
165
+ MaiChartTokenizer.vocab_size = VOCAB_SIZE
166
+ return idx
167
+
168
+
169
+ def _build_config_vocab() -> None:
170
+ # Single button/touch events.
171
+ for pos in range(1, 9):
172
+ _add_config(("tap", pos))
173
+ _add_config(("brk", pos))
174
+ for dur in CONFIG_DURATIONS:
175
+ _add_config(("hld", pos, dur[0], dur[1]))
176
+
177
+ for region in sorted(TCH_TO_ID):
178
+ _add_config(("tch", region))
179
+
180
+ # Two-note simultaneous button configurations. Holds share one duration.
181
+ button_types = ("tap", "brk", "hld")
182
+ for p1 in range(1, 9):
183
+ for p2 in range(p1 + 1, 9):
184
+ for t1 in button_types:
185
+ for t2 in button_types:
186
+ if (t1, p1) > (t2, p2):
187
+ continue
188
+ if "hld" in (t1, t2):
189
+ for dur in CONFIG_DURATIONS:
190
+ _add_config(("pair", t1, p1, t2, p2, dur[0], dur[1]))
191
+ else:
192
+ _add_config(("pair", t1, p1, t2, p2))
193
+
194
+ # Common slide configurations: 2- and 3-point paths with duration.
195
+ for a in range(1, 9):
196
+ for b in range(1, 9):
197
+ if b == a:
198
+ continue
199
+ for dur in CONFIG_DURATIONS:
200
+ _add_config(("sld", a, b, dur[0], dur[1]))
201
+ for c in range(1, 9):
202
+ if c in (a, b):
203
+ continue
204
+ for dur in CONFIG_DURATIONS:
205
+ _add_config(("sld", a, b, c, dur[0], dur[1]))
206
+
207
+
208
+ _build_config_vocab()
209
+
210
+ VOCAB_SIZE = CONFIG_BASE + len(CONFIG_TO_ID)
211
+ TOKENIZER_VERSION = 3
212
+
213
+
214
+ def export_config_vocab() -> list[list]:
215
+ return [list(spec) for spec, _ in sorted(CONFIG_TO_ID.items(), key=lambda x: x[1])]
216
+
217
+
218
+ def load_config_vocab(specs: list) -> None:
219
+ for spec in specs:
220
+ _add_config(tuple(spec))
221
+
222
+
223
+ def save_config_vocab(path: str) -> None:
224
+ with open(path, "w", encoding="utf-8") as f:
225
+ json.dump(export_config_vocab(), f, ensure_ascii=False)
226
+
227
+
228
+ def load_config_vocab_file(path: str) -> None:
229
+ with open(path, "r", encoding="utf-8") as f:
230
+ load_config_vocab(json.load(f))
231
+
232
+ # --- Special tokens that start a multi-token group ---
233
+ _MULTI_TOKEN_STARTS = {DUR, SLD_BEG, SLD_END_TOKEN, SIM_BEG, SIM_END}
234
+
235
+ # ═══════════════════════════════════════════════════════════════════════
236
+ # Token name lookup (for debugging)
237
+ # ═══════════════════════════════════════════════════════════════════════
238
+
239
+ _TOKEN_NAMES: dict[int, str] = {
240
+ PAD: "[PAD]",
241
+ BOS: "[BOS]",
242
+ EOS: "[EOS]",
243
+ SEP: "[SEP]",
244
+ MASK: "[MASK]",
245
+ RST: "[RST]",
246
+ DUR: "[DUR]",
247
+ SLD_BEG: "[SLD_BEG]",
248
+ SLD_END_TOKEN: "[SLD_END]",
249
+ SIM_BEG: "[SIM_BEG]",
250
+ SIM_END: "[SIM_END]",
251
+ META_BPM: "[META_BPM]",
252
+ META_DIFF: "[META_DIFF]",
253
+ META_LEVEL: "[META_LEVEL]",
254
+ META_GENRE: "[META_GENRE]",
255
+ META_END: "[META_END]",
256
+ SLD_MID: "[SLD_MID]",
257
+ HLD_ON: "[HLD_ON]",
258
+ SLD_ON: "[SLD_ON]",
259
+ }
260
+ for v, i in DIV_TO_ID.items():
261
+ _TOKEN_NAMES[i] = f"div_{v}"
262
+ for p, i in TAP_TO_ID.items():
263
+ _TOKEN_NAMES[i] = f"tap_{p}"
264
+ for p, i in BRK_TO_ID.items():
265
+ _TOKEN_NAMES[i] = f"brk_{p}"
266
+ for p, i in HLD_TO_ID.items():
267
+ _TOKEN_NAMES[i] = f"hld_{p}"
268
+ for p, i in SLD_TO_ID.items():
269
+ _TOKEN_NAMES[i] = f"sld_{p}"
270
+ for t, i in TCH_TO_ID.items():
271
+ _TOKEN_NAMES[i] = f"tch_{t}"
272
+ _TOKEN_NAMES[SIM_COUNT_2] = "sim_count_2"
273
+ for v, i in DUR_NUM_TO_ID.items():
274
+ _TOKEN_NAMES[i] = f"dur_num_{v}"
275
+ for v, i in DUR_DEN_TO_ID.items():
276
+ _TOKEN_NAMES[i] = f"dur_den_{v}"
277
+ for spec, i in CONFIG_TO_ID.items():
278
+ _TOKEN_NAMES[i] = "cfg_" + "_".join(str(x) for x in spec)
279
+
280
+
281
+ def token_name(token_id: int) -> str:
282
+ """Human-readable name for a token ID."""
283
+ return _TOKEN_NAMES.get(token_id, f"<{token_id}>")
284
+
285
+
286
+ def _nearest(values: list[int], value: int) -> int:
287
+ return min(values, key=lambda x: abs(x - value))
288
+
289
+
290
+ def encode_duration_tokens(duration: tuple[int, int]) -> list[int]:
291
+ beat = _nearest(_DUR_NUM_VALUES, max(1, int(duration[0])))
292
+ den = _nearest(_DUR_DEN_VALUES, max(1, int(duration[1])))
293
+ return [DUR, DUR_NUM_TO_ID[beat], DUR_DEN_TO_ID[den]]
294
+
295
+
296
+ def read_duration_tokens(tokens: list[int], start: int) -> Optional[tuple[int, int]]:
297
+ if start + 2 >= len(tokens) or tokens[start] != DUR:
298
+ return None
299
+ num_tok = tokens[start + 1]
300
+ den_tok = tokens[start + 2]
301
+ if num_tok in ID_TO_DUR_NUM and den_tok in ID_TO_DUR_DEN:
302
+ return ID_TO_DUR_NUM[num_tok], ID_TO_DUR_DEN[den_tok]
303
+
304
+ # Backward compatibility with old checkpoints/caches that used raw ints.
305
+ beat = _nearest(_DUR_NUM_VALUES, max(1, min(int(num_tok), 16)))
306
+ den = _nearest(_DUR_DEN_VALUES, max(1, int(den_tok)))
307
+ return beat, den
308
+
309
+
310
+ def make_sim_tokens(note_tokens: list[int]) -> list[int]:
311
+ note_tokens = [t for t in note_tokens if t not in (PAD, BOS, EOS)]
312
+ if len(note_tokens) <= 1:
313
+ return note_tokens
314
+ result: list[int] = []
315
+ result.extend([SIM_BEG, SIM_COUNT_2, note_tokens[0], note_tokens[1], SIM_END])
316
+ result.extend(note_tokens[2:])
317
+ return result
318
+
319
+
320
+ def _snap_config_duration(duration: tuple[int, int] | None) -> tuple[int, int]:
321
+ if not duration:
322
+ return (1, 1)
323
+ n = _nearest(_DUR_NUM_VALUES, max(1, int(duration[0])))
324
+ d = _nearest(_DUR_DEN_VALUES, max(1, int(duration[1])))
325
+ if n / d > 4.0:
326
+ return min(CONFIG_DURATIONS, key=lambda x: (abs((x[0] / x[1]) - 4.0), x[1]))
327
+ return n, d
328
+
329
+
330
+ def config_token_for_note(note: TouchNote) -> int | None:
331
+ if note.is_rest or note.is_end:
332
+ return None
333
+ if note.is_touch and len(note.touch_regions) == 1 and not note.is_hold:
334
+ return CONFIG_TO_ID.get(("tch", note.touch_regions[0]))
335
+ if note.is_slide:
336
+ path = note.slide_path or note.positions
337
+ if len(path) >= 2:
338
+ dur = _snap_config_duration(note.hold_duration)
339
+ spec = ("sld", *path, dur[0], dur[1])
340
+ if spec in CONFIG_TO_ID:
341
+ return CONFIG_TO_ID[spec]
342
+ return None
343
+
344
+ if len(note.positions) == 1:
345
+ pos = note.positions[0]
346
+ if not (1 <= pos <= 8):
347
+ return None
348
+ if note.is_hold:
349
+ dur = _snap_config_duration(note.hold_duration)
350
+ return CONFIG_TO_ID.get(("hld", pos, dur[0], dur[1]))
351
+ if note.is_break:
352
+ return CONFIG_TO_ID.get(("brk", pos))
353
+ return CONFIG_TO_ID.get(("tap", pos))
354
+
355
+ if len(note.positions) == 2:
356
+ p1, p2 = sorted(note.positions)
357
+ if not (1 <= p1 <= 8 and 1 <= p2 <= 8):
358
+ return None
359
+ typ = "hld" if note.is_hold else "brk" if note.is_break else "tap"
360
+ if typ == "hld":
361
+ dur = _snap_config_duration(note.hold_duration)
362
+ return CONFIG_TO_ID.get(("pair", "hld", p1, "hld", p2, dur[0], dur[1]))
363
+ return CONFIG_TO_ID.get(("pair", typ, p1, typ, p2))
364
+
365
+ return None
366
+
367
+
368
+ def learn_config_from_note(note: TouchNote) -> int | None:
369
+ """Register a config token from a real chart note, preserving rare shapes."""
370
+ if note.is_rest or note.is_end:
371
+ return None
372
+ existing = config_token_for_note(note)
373
+ if existing is not None:
374
+ return existing
375
+
376
+ if note.is_touch and note.touch_regions and not note.is_hold:
377
+ return _add_config(("touch_multi", *sorted(note.touch_regions)))
378
+
379
+ if note.is_slide:
380
+ path = note.slide_path or note.positions
381
+ if len(path) >= 2:
382
+ dur = _snap_config_duration(note.hold_duration)
383
+ return _add_config(("sld", *path, dur[0], dur[1]))
384
+ return None
385
+
386
+ if len(note.positions) >= 2:
387
+ positions = sorted(p for p in note.positions if 1 <= p <= 8)
388
+ if len(positions) < 2:
389
+ return None
390
+ typ = "hld" if note.is_hold else "brk" if note.is_break else "tap"
391
+ if note.is_hold:
392
+ dur = _snap_config_duration(note.hold_duration)
393
+ spec = ("multi", typ, *positions, dur[0], dur[1])
394
+ else:
395
+ spec = ("multi", typ, *positions)
396
+ if spec in CONFIG_TO_ID:
397
+ return CONFIG_TO_ID[spec]
398
+ if len(positions) == 2:
399
+ return config_token_for_note(note)
400
+ return _add_config(spec)
401
+
402
+ return None
403
+
404
+
405
+ def learn_config_vocab_from_charts(charts) -> int:
406
+ before = len(CONFIG_TO_ID)
407
+ for chart in charts:
408
+ for note in chart.notes:
409
+ learn_config_from_note(note)
410
+ return len(CONFIG_TO_ID) - before
411
+
412
+
413
+ def note_from_config_token(token_id: int, beat_div: int) -> TouchNote | None:
414
+ spec = ID_TO_CONFIG.get(token_id)
415
+ if spec is None:
416
+ return None
417
+ note = TouchNote(beat_div=beat_div)
418
+ kind = spec[0]
419
+ if kind == "tap":
420
+ note.positions = [int(spec[1])]
421
+ elif kind == "brk":
422
+ note.positions = [int(spec[1])]
423
+ note.is_break = True
424
+ elif kind == "hld":
425
+ note.positions = [int(spec[1])]
426
+ note.is_hold = True
427
+ note.hold_duration = (int(spec[2]), int(spec[3]))
428
+ elif kind == "tch":
429
+ note.is_touch = True
430
+ note.touch_regions = [str(spec[1])]
431
+ elif kind == "touch_multi":
432
+ note.is_touch = True
433
+ note.touch_regions = [str(x) for x in spec[1:]]
434
+ note.is_simultaneous = len(note.touch_regions) > 1
435
+ elif kind == "pair":
436
+ t1, p1, t2, p2 = spec[1], int(spec[2]), spec[3], int(spec[4])
437
+ note.positions = [p1, p2]
438
+ note.is_simultaneous = True
439
+ if t1 == "hld" or t2 == "hld":
440
+ note.is_hold = True
441
+ note.hold_duration = (int(spec[5]), int(spec[6]))
442
+ elif t1 == "brk" or t2 == "brk":
443
+ note.is_break = True
444
+ elif kind == "sld":
445
+ *path, n, d = spec[1:]
446
+ note.positions = [int(x) for x in path]
447
+ note.slide_path = list(note.positions)
448
+ note.is_slide = True
449
+ note.hold_duration = (int(n), int(d))
450
+ elif kind == "multi":
451
+ typ = spec[1]
452
+ if typ == "hld":
453
+ *positions, n, d = spec[2:]
454
+ note.positions = [int(x) for x in positions]
455
+ note.is_hold = True
456
+ note.hold_duration = (int(n), int(d))
457
+ else:
458
+ note.positions = [int(x) for x in spec[2:]]
459
+ note.is_break = typ == "brk"
460
+ note.is_simultaneous = len(note.positions) > 1
461
+ else:
462
+ return None
463
+ return note
464
+
465
+
466
+ # ═══════════════════════════════════════════════════════════════════════
467
+ # Tokenizer class
468
+ # ═══════════════════════════════════════════════════════════════════════
469
+
470
+ class MaiChartTokenizer:
471
+ """
472
+ Rule-based bidirectional tokenizer for maimai charts.
473
+
474
+ encode(chart) → list[int] # chart → tokens
475
+ decode(tokens) → Chart # tokens → chart (lossless)
476
+ """
477
+
478
+ vocab_size: int = VOCAB_SIZE
479
+ pad_token_id: int = PAD
480
+ bos_token_id: int = BOS
481
+ eos_token_id: int = EOS
482
+ mask_token_id: int = MASK
483
+
484
+ # ── Encode ──────────────────────────────────────────────────────
485
+
486
+ def encode(self, chart: Chart, add_bos: bool = True,
487
+ add_eos: bool = True) -> list[int]:
488
+ """
489
+ Convert a Chart's notes into a token sequence.
490
+
491
+ Args:
492
+ chart: Parsed Chart from mai_parser.
493
+ add_bos: Prepend [BOS] token.
494
+ add_eos: Append [EOS] token.
495
+
496
+ Returns:
497
+ List of token IDs.
498
+ """
499
+ tokens: list[int] = []
500
+ if add_bos:
501
+ tokens.append(BOS)
502
+
503
+ current_div = 4 # default beat division
504
+
505
+ for note in chart.notes:
506
+ # Update beat division if changed
507
+ if note.beat_div != current_div:
508
+ current_div = note.beat_div
509
+ div_id = DIV_TO_ID.get(current_div)
510
+ if div_id is not None:
511
+ tokens.append(div_id)
512
+
513
+ # Encode the note
514
+ tokens.extend(self._encode_note(note))
515
+
516
+ if add_eos:
517
+ tokens.append(EOS)
518
+
519
+ return tokens
520
+
521
+ def _encode_note(self, note: TouchNote) -> list[int]:
522
+ """Encode a single TouchNote → list of token IDs."""
523
+
524
+ if note.is_end:
525
+ return [EOS]
526
+
527
+ if note.is_rest:
528
+ return [RST]
529
+
530
+ cfg = config_token_for_note(note)
531
+ if cfg is not None:
532
+ return [cfg]
533
+
534
+ # Touch note
535
+ if note.is_touch:
536
+ return self._encode_touch(note)
537
+
538
+ # Break note
539
+ if note.is_break:
540
+ result = []
541
+ for pos in note.positions:
542
+ if 1 <= pos <= 8:
543
+ result.append(BRK_TO_ID[pos])
544
+ return make_sim_tokens(result)
545
+
546
+ # Hold note
547
+ if note.is_hold:
548
+ result = []
549
+ for pos in note.positions:
550
+ if 1 <= pos <= 8:
551
+ result.append(HLD_TO_ID[pos])
552
+ result = make_sim_tokens(result)
553
+ if note.hold_duration:
554
+ result.extend(encode_duration_tokens(note.hold_duration))
555
+ return result
556
+
557
+ # Slide note
558
+ if note.is_slide:
559
+ result = []
560
+ # Collect all positions (slide path)
561
+ positions = list(note.positions)
562
+ if note.slide_path:
563
+ # Use slide_path if available (more accurate)
564
+ positions = note.slide_path
565
+
566
+ if len(positions) >= 2:
567
+ result.append(SLD_BEG)
568
+ result.append(len(positions))
569
+ for pos in positions:
570
+ if 1 <= pos <= 8:
571
+ result.append(SLD_TO_ID[pos])
572
+ result.append(SLD_END_TOKEN)
573
+ elif len(positions) == 1 and 1 <= positions[0] <= 8:
574
+ result.append(SLD_TO_ID[positions[0]])
575
+
576
+ if note.hold_duration:
577
+ result.extend(encode_duration_tokens(note.hold_duration))
578
+ return result
579
+
580
+ # Regular tap
581
+ if len(note.positions) > 1:
582
+ result = []
583
+ for pos in note.positions:
584
+ if 1 <= pos <= 8:
585
+ result.append(TAP_TO_ID[pos])
586
+ return make_sim_tokens(result)
587
+
588
+ # Single tap
589
+ for pos in note.positions:
590
+ if 1 <= pos <= 8:
591
+ return [TAP_TO_ID[pos]]
592
+
593
+ return [RST] # fallback
594
+
595
+ def _encode_touch(self, note: TouchNote) -> list[int]:
596
+ """Encode a touch note."""
597
+ result = []
598
+ for region in note.touch_regions:
599
+ tid = TCH_TO_ID.get(region)
600
+ if tid is not None:
601
+ result.append(tid)
602
+ if len(result) > 1:
603
+ result = make_sim_tokens(result)
604
+ if note.is_hold and note.hold_duration:
605
+ result.extend(encode_duration_tokens(note.hold_duration))
606
+ return result if result else [RST]
607
+
608
+ # ── Decode ──────────────────────────────────────────────────────
609
+
610
+ def decode(self, tokens: list[int]) -> Chart:
611
+ """
612
+ Convert a token sequence back into a Chart.
613
+
614
+ Args:
615
+ tokens: List of token IDs (may include BOS/EOS).
616
+
617
+ Returns:
618
+ Reconstructed Chart (notes only; metadata not recoverable from tokens alone).
619
+ """
620
+ notes: list[TouchNote] = []
621
+ current_div = 4
622
+ i = 0
623
+
624
+ while i < len(tokens):
625
+ tid = tokens[i]
626
+
627
+ # Skip BOS
628
+ if tid == BOS:
629
+ i += 1
630
+ continue
631
+
632
+ # End of sequence
633
+ if tid == EOS:
634
+ note = TouchNote(beat_div=current_div, raw="E")
635
+ note.is_end = True
636
+ notes.append(note)
637
+ i += 1
638
+ continue
639
+
640
+ # Beat division change
641
+ if tid in ID_TO_DIV:
642
+ current_div = ID_TO_DIV[tid]
643
+ i += 1
644
+ continue
645
+
646
+ # Rest
647
+ if tid == RST:
648
+ note = TouchNote(beat_div=current_div, raw="")
649
+ note.is_rest = True
650
+ notes.append(note)
651
+ i += 1
652
+ continue
653
+
654
+ cfg_note = note_from_config_token(tid, current_div)
655
+ if cfg_note is not None:
656
+ notes.append(cfg_note)
657
+ i += 1
658
+ continue
659
+
660
+ # Duration marker → read next 2 tokens as (beat, subdiv)
661
+ # (handled inline in note decoding below)
662
+
663
+ # Slide start
664
+ if tid == SLD_BEG:
665
+ i += 1
666
+ if i >= len(tokens):
667
+ break
668
+ if tokens[i] in ID_TO_SLD or tokens[i] in (SLD_MID, SLD_ON):
669
+ # Unfolded slide format used by training/inference:
670
+ # SLD_BEG [SLD_ON] sld_a [SLD_MID/SLD_ON sld_b ...] SLD_END [DUR beat subdiv]
671
+ positions = []
672
+ while i < len(tokens) and tokens[i] not in (SLD_END_TOKEN, DUR, EOS):
673
+ if tokens[i] in (SLD_MID, SLD_ON):
674
+ i += 1
675
+ continue
676
+ pt = tokens[i]
677
+ if pt in ID_TO_SLD:
678
+ positions.append(ID_TO_SLD[pt])
679
+ i += 1
680
+ continue
681
+ # Malformed slide: stop before consuming unrelated chart events.
682
+ break
683
+ if i < len(tokens) and tokens[i] == SLD_END_TOKEN:
684
+ i += 1
685
+ dur = self._read_dur(tokens, i)
686
+ if dur:
687
+ i += 3
688
+ note = TouchNote(beat_div=current_div, positions=positions)
689
+ note.is_slide = True
690
+ note.slide_path = list(positions)
691
+ if dur:
692
+ note.hold_duration = dur
693
+ notes.append(note)
694
+ continue
695
+ n_pts = tokens[i]
696
+ i += 1
697
+ positions = []
698
+ for _ in range(n_pts):
699
+ if i >= len(tokens):
700
+ break
701
+ pt = tokens[i]
702
+ if pt in ID_TO_SLD:
703
+ positions.append(ID_TO_SLD[pt])
704
+ i += 1
705
+ # Skip SLD_END
706
+ if i < len(tokens) and tokens[i] == SLD_END_TOKEN:
707
+ i += 1
708
+ # Check for optional duration
709
+ dur = self._read_dur(tokens, i)
710
+ if dur:
711
+ i += 3 # DUR + beat + subdiv
712
+ note = TouchNote(beat_div=current_div, positions=positions)
713
+ note.is_slide = True
714
+ note.slide_path = list(positions)
715
+ if dur:
716
+ note.hold_duration = dur
717
+ notes.append(note)
718
+ continue
719
+
720
+ # Simultaneous begin
721
+ if tid == SIM_BEG:
722
+ i += 1
723
+ if i >= len(tokens):
724
+ break
725
+ count_tok = tokens[i]
726
+ n_notes = 2 if count_tok == SIM_COUNT_2 else int(count_tok)
727
+ i += 1
728
+ sub_notes: list[TouchNote] = []
729
+ dur = None
730
+ while i < len(tokens) and tokens[i] not in (SIM_END, EOS):
731
+ sub_tid = tokens[i]
732
+ if sub_tid == DUR:
733
+ dur = self._read_dur(tokens, i)
734
+ break # DUR after SIM group
735
+ sub_note = self._decode_single_note(sub_tid, current_div)
736
+ if sub_note:
737
+ sub_notes.append(sub_note)
738
+ i += 1
739
+ if i < len(tokens) and tokens[i] == SIM_END:
740
+ i += 1
741
+ # Merge sub-notes into one simultaneous note
742
+ if sub_notes:
743
+ merged = sub_notes[0]
744
+ all_pos = []
745
+ has_hold = merged.is_hold
746
+ has_break = merged.is_break
747
+ is_touch = merged.is_touch
748
+ all_touch_regions = list(merged.touch_regions)
749
+ for sn in sub_notes:
750
+ all_pos.extend(sn.positions)
751
+ has_hold = has_hold or sn.is_hold
752
+ has_break = has_break or sn.is_break
753
+ is_touch = is_touch or sn.is_touch
754
+ all_touch_regions.extend(sn.touch_regions)
755
+ merged.positions = all_pos
756
+ merged.is_simultaneous = True
757
+ merged.touch_regions = all_touch_regions
758
+ merged.is_touch = is_touch
759
+ if dur:
760
+ merged.hold_duration = dur
761
+ # For touch holds, don't set is_hold
762
+ if not is_touch:
763
+ has_hold = True
764
+ merged.is_hold = has_hold and not is_touch
765
+ merged.is_break = has_break and not is_touch
766
+ notes.append(merged)
767
+ continue
768
+
769
+ # Duration marker (standalone, should not normally happen)
770
+ if tid == DUR:
771
+ i += 3 # skip DUR + 2 values
772
+ continue
773
+
774
+ # Slide end, SIM end (standalone — skip)
775
+ if tid in (SLD_END_TOKEN, SIM_END):
776
+ i += 1
777
+ continue
778
+
779
+ # Single note token
780
+ note = self._decode_single_note(tid, current_div)
781
+ if note:
782
+ # Check if next token is DUR (for hold/slide duration)
783
+ dur = self._read_dur(tokens, i + 1)
784
+ if dur:
785
+ note.hold_duration = dur
786
+ # Only set is_hold if not already a slide/touch
787
+ if not note.is_slide and not note.is_touch and not note.is_break:
788
+ note.is_hold = True
789
+ i += 3 # skip DUR + beat + subdiv
790
+ notes.append(note)
791
+ i += 1
792
+
793
+ from mai_parser.models import Difficulty
794
+ chart = Chart(difficulty_index=0, difficulty=Difficulty.ReMASTER)
795
+ chart.notes = notes
796
+ chart.compute_stats()
797
+ return chart
798
+
799
+ def _decode_single_note(self, tid: int, beat_div: int) -> Optional[TouchNote]:
800
+ """Decode a single note token (not part of a group)."""
801
+ note = TouchNote(beat_div=beat_div)
802
+
803
+ if tid in ID_TO_TAP:
804
+ note.positions = [ID_TO_TAP[tid]]
805
+ return note
806
+
807
+ if tid in ID_TO_BRK:
808
+ note.positions = [ID_TO_BRK[tid]]
809
+ note.is_break = True
810
+ return note
811
+
812
+ if tid in ID_TO_HLD:
813
+ note.positions = [ID_TO_HLD[tid]]
814
+ note.is_hold = True
815
+ return note
816
+
817
+ if tid in ID_TO_SLD:
818
+ note.positions = [ID_TO_SLD[tid]]
819
+ note.is_slide = True
820
+ return note
821
+
822
+ if tid in ID_TO_TCH:
823
+ region = ID_TO_TCH[tid]
824
+ note.is_touch = True
825
+ note.touch_regions = [region]
826
+ return note
827
+
828
+ return None
829
+
830
+ def _read_dur(self, tokens: list[int], start: int) -> Optional[tuple[int, int]]:
831
+ """Try to read DUR beat subdiv from tokens[start:]. Returns (beat, subdiv) or None.
832
+ Clamps to reasonable ranges to filter out hallucinated durations."""
833
+ return read_duration_tokens(tokens, start)
834
+
835
+ # ── Batch ───────────────────────────────────────────────────────
836
+
837
+ def encode_batch(self, charts: list[Chart], pad_to: Optional[int] = None,
838
+ add_bos: bool = True, add_eos: bool = True,
839
+ return_tensors: bool = False):
840
+ """
841
+ Encode a batch of charts, padding to the same length.
842
+
843
+ Args:
844
+ charts: List of Chart objects.
845
+ pad_to: Pad all sequences to this length (auto-detect max if None).
846
+ add_bos: Prepend BOS.
847
+ add_eos: Append EOS.
848
+ return_tensors: If True, return torch.Tensor (requires torch).
849
+
850
+ Returns:
851
+ If return_tensors=False: (list[list[int]], list[int]) = (token_seqs, lengths)
852
+ If return_tensors=True: (Tensor[batch, max_len], Tensor[batch])
853
+ """
854
+ seqs = [self.encode(c, add_bos=add_bos, add_eos=add_eos) for c in charts]
855
+ lengths = [len(s) for s in seqs]
856
+ max_len = max(lengths) if pad_to is None else pad_to
857
+
858
+ padded = []
859
+ for seq in seqs:
860
+ if len(seq) < max_len:
861
+ seq = seq + [PAD] * (max_len - len(seq))
862
+ padded.append(seq[:max_len])
863
+
864
+ if return_tensors:
865
+ try:
866
+ import torch
867
+ return torch.tensor(padded, dtype=torch.long), torch.tensor(lengths, dtype=torch.long)
868
+ except ImportError:
869
+ raise ImportError("torch required for return_tensors=True")
870
+
871
+ return padded, lengths
872
+
873
+ # ── Debug ───────────────────────────────────────────────────────
874
+
875
+ def tokens_to_str(self, tokens: list[int], max_show: int = 60) -> str:
876
+ """Pretty-print a token sequence with context for raw parameter ids."""
877
+ parts = []
878
+ i = 0
879
+ shown = 0
880
+ while i < len(tokens) and shown < max_show:
881
+ tid = tokens[i]
882
+ if tid == DUR and i + 2 < len(tokens):
883
+ parts.append("[DUR]")
884
+ shown += 1
885
+ if shown < max_show:
886
+ parts.append(token_name(tokens[i + 1]))
887
+ shown += 1
888
+ if shown < max_show:
889
+ parts.append(token_name(tokens[i + 2]))
890
+ shown += 1
891
+ i += 3
892
+ continue
893
+ if tid == SIM_BEG and i + 1 < len(tokens):
894
+ parts.append("[SIM_BEG]")
895
+ shown += 1
896
+ if shown < max_show:
897
+ parts.append(token_name(tokens[i + 1]))
898
+ shown += 1
899
+ i += 2
900
+ continue
901
+ parts.append(token_name(tid))
902
+ shown += 1
903
+ i += 1
904
+ if i < len(tokens):
905
+ parts.append(f"... ({len(tokens) - i} more)")
906
+ return " ".join(parts)
907
+
908
+ def print_tokens(self, tokens: list[int], max_show: int = 60) -> None:
909
+ """Print a token sequence."""
910
+ print(self.tokens_to_str(tokens, max_show))
911
+
912
+
913
+ # ═══════════════════════════════════════════════════════════════════════
914
+ # Metadata header builder
915
+ # ═══════════════════════════════════════════════════════════════════════
916
+
917
+ def build_metadata_header(bpm: float, difficulty: int,
918
+ level_value: float, genre: int = 0) -> list[int]:
919
+ """
920
+ Build a metadata header token sequence.
921
+
922
+ Format: [META_BPM] bpm_byte [META_DIFF] diff [META_LEVEL] level_byte [META_GENRE] genre [META_END]
923
+
924
+ This is prepended to chart tokens during training so the model
925
+ learns to associate metadata with chart style.
926
+
927
+ Args:
928
+ bpm: BPM value (e.g. 173.0)
929
+ difficulty: 0=BASIC..4=ReMASTER
930
+ level_value: e.g. 12.4
931
+ genre: Genre index
932
+
933
+ Returns:
934
+ List of token IDs.
935
+ """
936
+ return [
937
+ META_BPM, int(bpm) // 2, # BPM 0-510 → 0-255
938
+ META_DIFF, difficulty,
939
+ META_LEVEL, min(255, int(level_value * 10)),
940
+ META_GENRE, genre,
941
+ META_END,
942
+ ]
943
+
944
+
945
+ def encode_chart_with_header(chart: Chart, bpm: float, difficulty: int,
946
+ level_value: float, genre: int = 0) -> list[int]:
947
+ """Encode chart with grammar-friendly slides (no metadata header, no EOS).
948
+
949
+ Metadata (BPM, difficulty, level, genre) is passed as separate condition
950
+ inputs to the model — NOT as chart tokens. The model learns difficulty
951
+ from the diff_embed MoE routing, not from token-level metadata.
952
+
953
+ HLD_ON/SLD_ON and SLD_MID are inference context/helper tokens, not targets.
954
+
955
+ Returns: [BOS] + chart_tokens
956
+ """
957
+ tok = MaiChartTokenizer()
958
+ chart_tokens = tok.encode(chart, add_bos=False, add_eos=False)
959
+ # add_eos=False avoids appending a synthetic EOS, but parsed charts may
960
+ # contain a terminal end note. Strip only terminal EOS tokens: raw numeric
961
+ # values 1/2 are also used as SIM counts, so removing all EOS ids corrupts
962
+ # simultaneous groups.
963
+ while chart_tokens and chart_tokens[-1] == EOS:
964
+ chart_tokens.pop()
965
+ chart_tokens = unfold_slides(chart_tokens)
966
+ return [BOS] + chart_tokens
967
+
968
+
969
+ def unfold_slides(tokens):
970
+ """Unfold multi-segment slides into grammar-friendly waypoint tokens.
971
+
972
+ SLD_BEG n sld_a sld_b sld_c SLD_END → SLD_BEG sld_a sld_b sld_c SLD_END
973
+ """
974
+ result, i = [], 0
975
+ while i < len(tokens):
976
+ t = tokens[i]
977
+ if t == SLD_BEG and i + 2 < len(tokens):
978
+ n = tokens[i + 1]
979
+ if 0 < n < 32 and i + 2 + n < len(tokens):
980
+ pts = tokens[i + 2 : i + 2 + n]
981
+ result.append(SLD_BEG)
982
+ result.extend(pts)
983
+ result.append(SLD_END_TOKEN)
984
+ i += 2 + n + 1; continue
985
+ result.append(t); i += 1
986
+ return result
987
+
988
+
989
+ def inject_ongoing_tokens(tokens: list[int]) -> list[int]:
990
+ """Insert HLD_ON/SLD_ON markers at intermediate positions where a hold/slide is active.
991
+
992
+ HLD_n DUR beat subdiv ...tokens... → HLD_ON inserted at each non-DUR position
993
+ while the hold is active. Same for slides.
994
+
995
+ These are informational — the model learns "a hold is ongoing here".
996
+ During inference they are suppressed; the engine doesn't generate them.
997
+ """
998
+ result = []
999
+ current_div = 4.0
1000
+ hold_beats = 0.0 # remaining beats of active hold
1001
+ slide_beats = 0.0 # remaining beats of active slide
1002
+ dur_skip = 0 # skip DUR parameter tokens
1003
+ i = 0
1004
+
1005
+ while i < len(tokens):
1006
+ t = tokens[i]
1007
+ step = 4.0 / current_div
1008
+
1009
+ # ── Inject ON tokens before note-level tokens ──
1010
+ _is_note = (t >= TAP_BASE and t != DUR) or t == RST or t in ID_TO_DIV
1011
+ if _is_note and dur_skip == 0:
1012
+ if hold_beats > 0:
1013
+ result.append(HLD_ON)
1014
+ hold_beats -= step
1015
+ if slide_beats > 0:
1016
+ result.append(SLD_ON)
1017
+ slide_beats -= step
1018
+
1019
+ # ── Track hold/slide duration ──
1020
+ if dur_skip > 0:
1021
+ dur_skip -= 1
1022
+ result.append(t); i += 1
1023
+ continue
1024
+
1025
+ if t in ID_TO_DIV:
1026
+ current_div = float(ID_TO_DIV.get(t, current_div))
1027
+ elif t == DUR:
1028
+ dur_skip = 2
1029
+ elif t in HLD_TO_ID:
1030
+ # Check if followed by DUR
1031
+ if i + 3 < len(tokens) and tokens[i + 1] == DUR:
1032
+ beat = tokens[i + 2]
1033
+ subdiv = max(tokens[i + 3], 1)
1034
+ hold_beats = beat / subdiv
1035
+ elif t == SLD_BEG:
1036
+ # Find DUR after slide waypoints
1037
+ j = i + 2 # skip SLD_BEG + count
1038
+ while j < len(tokens) and tokens[j] != SLD_END_TOKEN and tokens[j] != DUR:
1039
+ j += 1
1040
+ if j < len(tokens) and tokens[j] == SLD_END_TOKEN:
1041
+ j += 1 # skip SLD_END
1042
+ if j + 2 < len(tokens) and tokens[j] == DUR:
1043
+ beat = tokens[j + 1]
1044
+ subdiv = max(tokens[j + 2], 1)
1045
+ slide_beats = beat / subdiv
1046
+
1047
+ result.append(t)
1048
+ i += 1
1049
+
1050
+ return result
1051
+
1052
+
1053
+ # ═══════════════════════════════════════════════════════════════════════
1054
+ # Chart → maidata text conversion
1055
+ # ═══════════════════════════════════════════════════════════════════════
1056
+
1057
+ def notes_to_maitext(notes, bpm=150.0):
1058
+ """Convert TouchNote list back to maidata chart text.
1059
+ Format: (173){4}1,2,3h[4:1],5b/8b,
1060
+ """
1061
+ bpm_int = int(bpm)
1062
+ current_div = 4
1063
+ line = f"({bpm_int})"
1064
+ measure = []
1065
+
1066
+ for note in notes:
1067
+ if note.is_end:
1068
+ if measure:
1069
+ line += "{" + str(current_div) + "}" + ",".join(measure) + ","
1070
+ return line + "\nE"
1071
+
1072
+ if note.beat_div != current_div:
1073
+ if measure:
1074
+ line += "{" + str(current_div) + "}" + ",".join(measure) + ","
1075
+ measure = []
1076
+ else:
1077
+ line += "{" + str(current_div) + "},"
1078
+ current_div = note.beat_div
1079
+
1080
+ measure.append(_note_to_text(note))
1081
+
1082
+ if measure:
1083
+ line += "{" + str(current_div) + "}" + ",".join(measure) + ","
1084
+ return line + "\nE"
1085
+
1086
+
1087
+ def tokens_to_maitext(tokens, bpm=150.0):
1088
+ """Token sequence → maidata.txt chart text."""
1089
+ tok = MaiChartTokenizer()
1090
+ chart = tok.decode(tokens)
1091
+ return notes_to_maitext(chart.notes, bpm)
1092
+
1093
+
1094
+ def _note_to_text(note):
1095
+ """Single TouchNote → maidata text segment."""
1096
+ if note.is_rest:
1097
+ return ""
1098
+ if note.is_touch:
1099
+ # Normalize touch regions: C1..C8 → C, others keep (B7, E2, etc.)
1100
+ regions = []
1101
+ for r in note.touch_regions:
1102
+ if r.startswith("C") and len(r) > 1:
1103
+ regions.append("C")
1104
+ else:
1105
+ regions.append(r)
1106
+ text = "/".join(regions)
1107
+ if note.is_hold and note.hold_duration:
1108
+ text += f"h[{note.hold_duration[0]}:{note.hold_duration[1]}]"
1109
+ return text
1110
+
1111
+ if note.positions:
1112
+ text = "/".join(str(p) for p in note.positions)
1113
+ else:
1114
+ return ""
1115
+
1116
+ if note.is_hold and note.hold_duration:
1117
+ text += f"h[{note.hold_duration[0]}:{note.hold_duration[1]}]"
1118
+ elif note.is_break:
1119
+ text += "b"
1120
+ elif note.is_slide and note.slide_path and len(note.slide_path) >= 2:
1121
+ # Multi-segment slide: all use >
1122
+ start = note.slide_path[0]
1123
+ seg_start = 1
1124
+ while seg_start < len(note.slide_path) and note.slide_path[seg_start] == start:
1125
+ seg_start += 1
1126
+ if seg_start >= len(note.slide_path):
1127
+ return str(start)
1128
+ text = str(start)
1129
+ last_pos = start
1130
+ for p in note.slide_path[seg_start:]:
1131
+ if p != last_pos:
1132
+ text += ">" + str(p)
1133
+ last_pos = p
1134
+ dur = note.hold_duration or (4, 1)
1135
+ text += f"[{dur[0]}:{dur[1]}]"
1136
+ elif note.is_slide and len(note.positions) >= 2:
1137
+ text = str(note.positions[0]) + ">" + str(note.positions[1])
1138
+ dur = note.hold_duration or (4, 1)
1139
+ text += f"[{dur[0]}:{dur[1]}]"
1140
+
1141
+ if note.firework:
1142
+ text += "x"
1143
+ if note.is_star:
1144
+ text += "*"
1145
+ return text