Upload MaiGenerator model (epoch 10) and inference code
Browse files- .gitignore +1 -0
- README.md +153 -0
- Tokenizer/MaiChartTokenizer.py +201 -0
- Tokenizer/MaiTrackTokenizer.py +422 -0
- best.pt +3 -0
- constrained_decode.py +389 -0
- grammar.py +180 -0
- inference.py +707 -0
- mai_parser/__init__.py +55 -0
- mai_parser/dataset.py +455 -0
- mai_parser/models.py +175 -0
- mai_parser/parser.py +514 -0
- model.py +681 -0
- targets.py +88 -0
- tokenizer.py +1145 -0
.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
|