PearlLeeStudio commited on
Commit
fd05683
·
verified ·
1 Parent(s): d4da499

Initial release: TheArtist genre LoRA adapter (F1 base)

Browse files
Files changed (8) hide show
  1. README.md +97 -0
  2. adapter_config.json +47 -0
  3. adapter_model.safetensors +3 -0
  4. config.json +31 -0
  5. embedding_extension.pt +3 -0
  6. model.py +294 -0
  7. tokenizer.json +364 -0
  8. tokenizer.py +388 -0
README.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ library_name: peft
4
+ base_model: PearlLeeStudio/TheArtist-MusicTransformer-ft-pop80
5
+ tags:
6
+ - music
7
+ - music-generation
8
+ - chord-generation
9
+ - symbolic-music
10
+ - music-transformer
11
+ - lora
12
+ - peft
13
+ - rock
14
+ language:
15
+ - en
16
+ pipeline_tag: text-generation
17
+ ---
18
+
19
+ # TheArtist Music Transformer — LoRA Adapter (Rock)
20
+
21
+ **LoRA adapter that conditions the F1 base (PearlLeeStudio/TheArtist-MusicTransformer-ft-pop80) toward rock chord progressions.** One of eleven per-genre adapters released alongside the paper *Empirical Study of Pop and Jazz Mix Ratios for Genre-Adaptive Chord Generation* (Lee, 2026).
22
+
23
+ ## Adapter summary
24
+
25
+ | Field | Value |
26
+ |---|---|
27
+ | Base model | `PearlLeeStudio/TheArtist-MusicTransformer-ft-pop80` (F1, 25.6M params) |
28
+ | Adapter type | LoRA (Q/K/V projections) |
29
+ | LoRA rank | 8 |
30
+ | LoRA alpha | 16 |
31
+ | LoRA dropout | 0.05 |
32
+ | Target modules | `w_q`, `w_k`, `w_v` |
33
+ | Trainable parameters | ~200K (~0.78% of base) |
34
+ | Adapter file size | ~800 KB |
35
+ | Base vocabulary | 351 tokens (jazz/pop) |
36
+ | Vocabulary extension | +13 genre tokens (`embedding_extension.pt`) |
37
+ | Training epochs | 5 |
38
+
39
+ ## Training data
40
+
41
+ 48,910 chord-progression sequences in the **rock** subset of the [Chordonomicon dataset](https://huggingface.co/datasets/ailsntua/Chordonomicon). Chordonomicon is licensed CC BY-NC 4.0; see the dataset card for full terms.
42
+
43
+ ## Genre character
44
+
45
+ Classic rock and modern rock harmony
46
+
47
+ ## License and use
48
+
49
+ The adapter weights are released under **CC BY-NC 4.0** (matching Chordonomicon, the upstream training corpus). Permitted: research, paper replication, portfolio, demo. Not permitted: commercial deployment without separate licensing of upstream data.
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ import torch
55
+ from huggingface_hub import hf_hub_download
56
+ from peft import PeftModel
57
+ from model import MusicTransformer
58
+ from tokenizer import ChordTokenizer
59
+
60
+ # 1. Load the F1 base
61
+ base_path = hf_hub_download(
62
+ repo_id="PearlLeeStudio/TheArtist-MusicTransformer-ft-pop80",
63
+ filename="best.pt",
64
+ )
65
+ base_ckpt = torch.load(base_path, map_location="cpu", weights_only=False)
66
+ tokenizer = ChordTokenizer()
67
+ model = MusicTransformer(
68
+ vocab_size=tokenizer.vocab_size,
69
+ d_model=512, n_heads=8, d_ff=2048, n_layers=8,
70
+ max_seq_len=256, dropout=0.0, pad_id=tokenizer.pad_id,
71
+ )
72
+ model.load_state_dict(base_ckpt["model_state_dict"])
73
+
74
+ # 2. Extend the embedding to fit the LoRA's expanded vocabulary
75
+ ext_path = hf_hub_download(repo_id="PearlLeeStudio/TheArtist-MusicTransformer-lora-rock", filename="embedding_extension.pt")
76
+ ext = torch.load(ext_path, map_location="cpu", weights_only=False)
77
+ # (See model/README.md for the apply-extension recipe.)
78
+
79
+ # 3. Apply the LoRA adapter
80
+ adapter_dir = hf_hub_download(repo_id="PearlLeeStudio/TheArtist-MusicTransformer-lora-rock", filename="adapter_model.safetensors")
81
+ model = PeftModel.from_pretrained(model, adapter_dir.rsplit("/", 1)[0])
82
+ model.eval()
83
+ ```
84
+
85
+ ## Citation
86
+
87
+ Preprint: [arXiv:2605.04998](https://arxiv.org/abs/2605.04998).
88
+
89
+ ```bibtex
90
+ @misc{lee2026chordmix,
91
+ title = {Empirical Study of Pop and Jazz Mix Ratios for Genre-Adaptive Chord Generation},
92
+ author = {Lee, Jinju},
93
+ year = {2026},
94
+ eprint = {2605.04998},
95
+ archivePrefix = {arXiv}
96
+ }
97
+ ```
adapter_config.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "alora_invocation_tokens": null,
3
+ "alpha_pattern": {},
4
+ "arrow_config": null,
5
+ "auto_mapping": {
6
+ "base_model_class": "MusicTransformer",
7
+ "parent_library": "model"
8
+ },
9
+ "base_model_name_or_path": null,
10
+ "bias": "none",
11
+ "corda_config": null,
12
+ "ensure_weight_tying": false,
13
+ "eva_config": null,
14
+ "exclude_modules": null,
15
+ "fan_in_fan_out": false,
16
+ "inference_mode": true,
17
+ "init_lora_weights": true,
18
+ "layer_replication": null,
19
+ "layers_pattern": null,
20
+ "layers_to_transform": null,
21
+ "loftq_config": {},
22
+ "lora_alpha": 16,
23
+ "lora_bias": false,
24
+ "lora_dropout": 0.05,
25
+ "lora_ga_config": null,
26
+ "megatron_config": null,
27
+ "megatron_core": "megatron.core",
28
+ "modules_to_save": null,
29
+ "peft_type": "LORA",
30
+ "peft_version": "0.19.1",
31
+ "qalora_group_size": 16,
32
+ "r": 8,
33
+ "rank_pattern": {},
34
+ "revision": null,
35
+ "target_modules": [
36
+ "w_k",
37
+ "w_q",
38
+ "w_v"
39
+ ],
40
+ "target_parameters": null,
41
+ "task_type": null,
42
+ "trainable_token_indices": null,
43
+ "use_bdlora": null,
44
+ "use_dora": false,
45
+ "use_qalora": false,
46
+ "use_rslora": false
47
+ }
adapter_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8c200d36bce28e4083f5c38425b69ace08e0dcb19788d120162cf67850f28a65
3
+ size 791928
config.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run_name": "ft_f1_lora_rock",
3
+ "base_checkpoint": "checkpoints/ft_jazz_pop80/best.pt",
4
+ "target_genre": "rock",
5
+ "sources": [
6
+ "chordonomicon_rock"
7
+ ],
8
+ "d_model": 512,
9
+ "n_heads": 8,
10
+ "d_ff": 2048,
11
+ "n_layers": 8,
12
+ "max_seq_len": 256,
13
+ "dropout": 0.1,
14
+ "lora_r": 8,
15
+ "lora_alpha": 16,
16
+ "lora_dropout": 0.05,
17
+ "lora_target_modules": [
18
+ "w_q",
19
+ "w_k",
20
+ "w_v"
21
+ ],
22
+ "epochs": 5,
23
+ "batch_size": 32,
24
+ "gradient_accumulation_steps": 2,
25
+ "lr": 0.0003,
26
+ "weight_decay": 0.01,
27
+ "warmup_epochs": 1,
28
+ "max_grad_norm": 1.0,
29
+ "use_amp": true,
30
+ "num_workers": 4
31
+ }
embedding_extension.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fad35cb4ae81de48fd130b764317c922c015bbd25805e0e2bf677124216851dd
3
+ size 1472800
model.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Music Transformer with relative attention for chord generation.
2
+
3
+ Architecture: Transformer decoder (autoregressive) with relative position
4
+ encoding (Shaw et al. 2018, efficient skewing from Huang et al. 2018).
5
+
6
+ Default config (~25M params):
7
+ d_model=512, n_heads=8, d_ff=2048, n_layers=8
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+
19
+ class RelativeMultiHeadAttention(nn.Module):
20
+ """Multi-head self-attention with relative position bias."""
21
+
22
+ def __init__(
23
+ self,
24
+ d_model: int,
25
+ n_heads: int,
26
+ max_seq_len: int,
27
+ dropout: float = 0.1,
28
+ ) -> None:
29
+ super().__init__()
30
+ assert d_model % n_heads == 0
31
+ self.n_heads = n_heads
32
+ self.d_k = d_model // n_heads
33
+ self.scale = math.sqrt(self.d_k)
34
+
35
+ self.w_q = nn.Linear(d_model, d_model)
36
+ self.w_k = nn.Linear(d_model, d_model)
37
+ self.w_v = nn.Linear(d_model, d_model)
38
+ self.w_o = nn.Linear(d_model, d_model)
39
+
40
+ # Learnable relative position embeddings: positions in [-max_len+1, max_len-1]
41
+ self.max_seq_len = max_seq_len
42
+ self.rel_emb = nn.Embedding(2 * max_seq_len - 1, self.d_k)
43
+ self.dropout = nn.Dropout(dropout)
44
+
45
+ def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
46
+ """
47
+ Args:
48
+ x: (B, L, D)
49
+ mask: (L, L) bool — True = masked (don't attend)
50
+ Returns:
51
+ (B, L, D)
52
+ """
53
+ B, L, _ = x.shape
54
+ H, dk = self.n_heads, self.d_k
55
+
56
+ Q = self.w_q(x).view(B, L, H, dk).transpose(1, 2) # (B, H, L, dk)
57
+ K = self.w_k(x).view(B, L, H, dk).transpose(1, 2)
58
+ V = self.w_v(x).view(B, L, H, dk).transpose(1, 2)
59
+
60
+ # Content attention: Q K^T
61
+ content = torch.matmul(Q, K.transpose(-2, -1)) # (B, H, L, L)
62
+
63
+ # Relative position attention: Q R^T via efficient gather
64
+ rel = self._relative_attention(Q, L) # (B, H, L, L)
65
+
66
+ attn = (content + rel) / self.scale
67
+
68
+ if mask is not None:
69
+ attn = attn.masked_fill(mask.unsqueeze(0).unsqueeze(0), float("-inf"))
70
+
71
+ attn = self.dropout(F.softmax(attn, dim=-1))
72
+ out = torch.matmul(attn, V) # (B, H, L, dk)
73
+ out = out.transpose(1, 2).contiguous().view(B, L, -1)
74
+ return self.w_o(out)
75
+
76
+ def _relative_attention(self, Q: torch.Tensor, L: int) -> torch.Tensor:
77
+ """Compute Q @ R^T using relative position embeddings.
78
+
79
+ Uses the index-gather approach: for each (i, j) pair, the relative
80
+ position is j - i, shifted to a non-negative index.
81
+ """
82
+ device = Q.device
83
+ # Relative position indices: rel[i,j] = j - i + max_seq_len - 1
84
+ positions = torch.arange(L, device=device)
85
+ rel_idx = positions.unsqueeze(0) - positions.unsqueeze(1) + self.max_seq_len - 1
86
+ rel_idx = rel_idx.clamp(0, 2 * self.max_seq_len - 2)
87
+
88
+ R = self.rel_emb(rel_idx) # (L, L, dk)
89
+
90
+ # Q: (B, H, L, dk) R: (L, L, dk) → need (B, H, L, L)
91
+ # Reshape Q to (B*H, L, dk), bmm with R^T reshaped
92
+ BH = Q.shape[0] * Q.shape[1]
93
+ Q_flat = Q.reshape(BH, L, self.d_k) # (BH, L, dk)
94
+
95
+ # For each query position i, we want dot(Q[i], R[i, :, :]) → (BH, L, L)
96
+ # R: (L, L, dk) → transpose last two → (L, dk, L)
97
+ # Then Q_flat[:, i, :] @ R[i, :, :].T for each i
98
+ # Efficient: einsum
99
+ rel_score = torch.einsum("bld,lsd->bls", Q_flat, R) # (BH, L, L)
100
+ return rel_score.view(Q.shape[0], Q.shape[1], L, L)
101
+
102
+
103
+ class TransformerBlock(nn.Module):
104
+ """Pre-norm Transformer decoder block."""
105
+
106
+ def __init__(
107
+ self,
108
+ d_model: int,
109
+ n_heads: int,
110
+ d_ff: int,
111
+ max_seq_len: int,
112
+ dropout: float = 0.1,
113
+ ) -> None:
114
+ super().__init__()
115
+ self.norm1 = nn.LayerNorm(d_model)
116
+ self.attn = RelativeMultiHeadAttention(d_model, n_heads, max_seq_len, dropout)
117
+ self.norm2 = nn.LayerNorm(d_model)
118
+ self.ffn = nn.Sequential(
119
+ nn.Linear(d_model, d_ff),
120
+ nn.GELU(),
121
+ nn.Dropout(dropout),
122
+ nn.Linear(d_ff, d_model),
123
+ nn.Dropout(dropout),
124
+ )
125
+ self.drop = nn.Dropout(dropout)
126
+
127
+ def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
128
+ x = x + self.drop(self.attn(self.norm1(x), mask))
129
+ x = x + self.ffn(self.norm2(x))
130
+ return x
131
+
132
+
133
+ class MusicTransformer(nn.Module):
134
+ """Autoregressive Music Transformer for chord generation."""
135
+
136
+ def __init__(
137
+ self,
138
+ vocab_size: int,
139
+ d_model: int = 512,
140
+ n_heads: int = 8,
141
+ d_ff: int = 2048,
142
+ n_layers: int = 8,
143
+ max_seq_len: int = 512,
144
+ dropout: float = 0.1,
145
+ pad_id: int = 0,
146
+ ) -> None:
147
+ super().__init__()
148
+ self.d_model = d_model
149
+ self.max_seq_len = max_seq_len
150
+ self.pad_id = pad_id
151
+
152
+ self.token_emb = nn.Embedding(vocab_size, d_model, padding_idx=pad_id)
153
+ self.drop = nn.Dropout(dropout)
154
+
155
+ self.layers = nn.ModuleList([
156
+ TransformerBlock(d_model, n_heads, d_ff, max_seq_len, dropout)
157
+ for _ in range(n_layers)
158
+ ])
159
+
160
+ self.norm = nn.LayerNorm(d_model)
161
+ self.out_proj = nn.Linear(d_model, vocab_size, bias=False)
162
+
163
+ # Weight tying (embedding ↔ output projection)
164
+ self.out_proj.weight = self.token_emb.weight
165
+
166
+ self._init_weights()
167
+
168
+ def _init_weights(self) -> None:
169
+ for name, p in self.named_parameters():
170
+ if p.dim() > 1 and "token_emb" not in name:
171
+ nn.init.xavier_uniform_(p)
172
+ # Embedding std=1/sqrt(d_model) so that after *sqrt(d_model) scaling
173
+ # inputs have unit variance, and weight-tied output logits stay small
174
+ nn.init.normal_(self.token_emb.weight, mean=0.0, std=self.d_model ** -0.5)
175
+
176
+ @staticmethod
177
+ def _causal_mask(L: int, device: torch.device) -> torch.Tensor:
178
+ """Upper-triangular causal mask (True = masked)."""
179
+ return torch.triu(torch.ones(L, L, device=device, dtype=torch.bool), diagonal=1)
180
+
181
+ def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
182
+ """
183
+ Args:
184
+ input_ids: (B, L) token IDs
185
+ Returns:
186
+ logits: (B, L, vocab_size)
187
+ """
188
+ B, L = input_ids.shape
189
+ x = self.token_emb(input_ids) * math.sqrt(self.d_model)
190
+ x = self.drop(x)
191
+
192
+ mask = self._causal_mask(L, input_ids.device)
193
+ for layer in self.layers:
194
+ x = layer(x, mask)
195
+
196
+ return self.out_proj(self.norm(x))
197
+
198
+ def count_parameters(self) -> int:
199
+ return sum(p.numel() for p in self.parameters() if p.requires_grad)
200
+
201
+ @torch.no_grad()
202
+ def generate(
203
+ self,
204
+ prompt_ids: torch.Tensor,
205
+ max_new_tokens: int = 64,
206
+ temperature: float = 1.0,
207
+ top_k: int = 0,
208
+ top_p: float = 0.9,
209
+ eos_id: int = 2,
210
+ repetition_penalty: float = 1.0,
211
+ no_repeat_ngram_size: int = 0,
212
+ ignore_repeat_token_ids: set[int] | None = None,
213
+ ) -> torch.Tensor:
214
+ """Autoregressive generation from a prompt.
215
+
216
+ Args:
217
+ prompt_ids: (1, L) token IDs including [BOS] and context.
218
+ max_new_tokens: maximum tokens to generate.
219
+ temperature: sampling temperature (lower = more deterministic).
220
+ top_k: keep only top-k logits (0 = disabled).
221
+ top_p: nucleus sampling threshold.
222
+ eos_id: stop token.
223
+ repetition_penalty: divide logits of previously-seen tokens by
224
+ this factor (HF convention). > 1.0 discourages repeats.
225
+ 1.0 disables. Typical: 1.2–1.5.
226
+ no_repeat_ngram_size: ban candidate tokens that would complete
227
+ an n-gram already present in the current sequence (n =
228
+ this value). 0 disables. Typical: 3 for chord sequences.
229
+ ignore_repeat_token_ids: token ids exempt from the two repetition
230
+ controls above — e.g. [BAR] or other separators that
231
+ *should* recur. If None, no exemptions.
232
+
233
+ Returns:
234
+ (1, L') full sequence including prompt and generated tokens.
235
+ """
236
+ self.eval()
237
+ ids = prompt_ids.clone()
238
+ exempt = ignore_repeat_token_ids or set()
239
+
240
+ for _ in range(max_new_tokens):
241
+ ctx = ids[:, -self.max_seq_len :]
242
+ logits = self(ctx)[:, -1, :] / max(temperature, 1e-8)
243
+
244
+ # Repetition penalty (HuggingFace-style): scale already-seen token
245
+ # logits so they are less attractive. Positive logits get divided,
246
+ # negative logits get multiplied (stays "less attractive" either sign).
247
+ if repetition_penalty != 1.0:
248
+ seen = set(ids[0].tolist()) - exempt
249
+ if seen:
250
+ idx = torch.tensor(list(seen), device=logits.device, dtype=torch.long)
251
+ vals = logits[0, idx]
252
+ vals = torch.where(
253
+ vals > 0,
254
+ vals / repetition_penalty,
255
+ vals * repetition_penalty,
256
+ )
257
+ logits[0, idx] = vals
258
+
259
+ # No-repeat n-gram: block any candidate token that would complete
260
+ # an n-gram already present earlier in the sequence.
261
+ if no_repeat_ngram_size > 0 and ids.shape[1] >= no_repeat_ngram_size:
262
+ n = no_repeat_ngram_size
263
+ seq = ids[0].tolist()
264
+ prefix = tuple(seq[-(n - 1):]) if n > 1 else ()
265
+ banned: set[int] = set()
266
+ for i in range(len(seq) - n + 1):
267
+ if tuple(seq[i : i + n - 1]) == prefix:
268
+ banned.add(seq[i + n - 1])
269
+ banned -= exempt
270
+ if banned:
271
+ bidx = torch.tensor(list(banned), device=logits.device, dtype=torch.long)
272
+ logits[0, bidx] = float("-inf")
273
+
274
+ # Top-k
275
+ if top_k > 0:
276
+ topk_vals, _ = torch.topk(logits, min(top_k, logits.size(-1)))
277
+ logits[logits < topk_vals[:, -1:]] = float("-inf")
278
+
279
+ # Top-p (nucleus)
280
+ if 0 < top_p < 1.0:
281
+ sorted_logits, sorted_idx = torch.sort(logits, descending=True)
282
+ cum_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
283
+ remove = cum_probs - F.softmax(sorted_logits, dim=-1) > top_p
284
+ sorted_logits[remove] = float("-inf")
285
+ logits = sorted_logits.scatter(1, sorted_idx, sorted_logits)
286
+
287
+ probs = F.softmax(logits, dim=-1)
288
+ next_id = torch.multinomial(probs, num_samples=1)
289
+ ids = torch.cat([ids, next_id], dim=-1)
290
+
291
+ if (next_id == eos_id).all():
292
+ break
293
+
294
+ return ids
tokenizer.json ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "token2id": {
3
+ "[PAD]": 0,
4
+ "[BOS]": 1,
5
+ "[EOS]": 2,
6
+ "[BAR]": 3,
7
+ "[KEY:Cmaj]": 4,
8
+ "[KEY:Dbmaj]": 5,
9
+ "[KEY:Dmaj]": 6,
10
+ "[KEY:Ebmaj]": 7,
11
+ "[KEY:Emaj]": 8,
12
+ "[KEY:Fmaj]": 9,
13
+ "[KEY:F#maj]": 10,
14
+ "[KEY:Gmaj]": 11,
15
+ "[KEY:Abmaj]": 12,
16
+ "[KEY:Amaj]": 13,
17
+ "[KEY:Bbmaj]": 14,
18
+ "[KEY:Bmaj]": 15,
19
+ "[KEY:Cmin]": 16,
20
+ "[KEY:Dbmin]": 17,
21
+ "[KEY:Dmin]": 18,
22
+ "[KEY:Ebmin]": 19,
23
+ "[KEY:Emin]": 20,
24
+ "[KEY:Fmin]": 21,
25
+ "[KEY:F#min]": 22,
26
+ "[KEY:Gmin]": 23,
27
+ "[KEY:Abmin]": 24,
28
+ "[KEY:Amin]": 25,
29
+ "[KEY:Bbmin]": 26,
30
+ "[KEY:Bmin]": 27,
31
+ "[TIME:4/4]": 28,
32
+ "[TIME:3/4]": 29,
33
+ "[TIME:6/8]": 30,
34
+ "[TIME:2/4]": 31,
35
+ "[TIME:5/4]": 32,
36
+ "[GENRE:jazz]": 33,
37
+ "[GENRE:pop]": 34,
38
+ "[GENRE:rock]": 35,
39
+ "[GENRE:blues]": 36,
40
+ "[GENRE:bossa]": 37,
41
+ "[GENRE:none]": 38,
42
+ "Cmaj": 39,
43
+ "Cm": 40,
44
+ "C7": 41,
45
+ "Cmaj7": 42,
46
+ "Cm7": 43,
47
+ "Cm7b5": 44,
48
+ "Cdim7": 45,
49
+ "Cdim": 46,
50
+ "Caug": 47,
51
+ "Csus4": 48,
52
+ "Csus2": 49,
53
+ "C6": 50,
54
+ "Cm6": 51,
55
+ "C9": 52,
56
+ "Cm9": 53,
57
+ "Cmaj9": 54,
58
+ "C11": 55,
59
+ "Cm11": 56,
60
+ "C13": 57,
61
+ "Cm13": 58,
62
+ "Cadd9": 59,
63
+ "CmMaj7": 60,
64
+ "C7b9": 61,
65
+ "C7#9": 62,
66
+ "C7#11": 63,
67
+ "C7b13": 64,
68
+ "Dbmaj": 65,
69
+ "Dbm": 66,
70
+ "Db7": 67,
71
+ "Dbmaj7": 68,
72
+ "Dbm7": 69,
73
+ "Dbm7b5": 70,
74
+ "Dbdim7": 71,
75
+ "Dbdim": 72,
76
+ "Dbaug": 73,
77
+ "Dbsus4": 74,
78
+ "Dbsus2": 75,
79
+ "Db6": 76,
80
+ "Dbm6": 77,
81
+ "Db9": 78,
82
+ "Dbm9": 79,
83
+ "Dbmaj9": 80,
84
+ "Db11": 81,
85
+ "Dbm11": 82,
86
+ "Db13": 83,
87
+ "Dbm13": 84,
88
+ "Dbadd9": 85,
89
+ "DbmMaj7": 86,
90
+ "Db7b9": 87,
91
+ "Db7#9": 88,
92
+ "Db7#11": 89,
93
+ "Db7b13": 90,
94
+ "Dmaj": 91,
95
+ "Dm": 92,
96
+ "D7": 93,
97
+ "Dmaj7": 94,
98
+ "Dm7": 95,
99
+ "Dm7b5": 96,
100
+ "Ddim7": 97,
101
+ "Ddim": 98,
102
+ "Daug": 99,
103
+ "Dsus4": 100,
104
+ "Dsus2": 101,
105
+ "D6": 102,
106
+ "Dm6": 103,
107
+ "D9": 104,
108
+ "Dm9": 105,
109
+ "Dmaj9": 106,
110
+ "D11": 107,
111
+ "Dm11": 108,
112
+ "D13": 109,
113
+ "Dm13": 110,
114
+ "Dadd9": 111,
115
+ "DmMaj7": 112,
116
+ "D7b9": 113,
117
+ "D7#9": 114,
118
+ "D7#11": 115,
119
+ "D7b13": 116,
120
+ "Ebmaj": 117,
121
+ "Ebm": 118,
122
+ "Eb7": 119,
123
+ "Ebmaj7": 120,
124
+ "Ebm7": 121,
125
+ "Ebm7b5": 122,
126
+ "Ebdim7": 123,
127
+ "Ebdim": 124,
128
+ "Ebaug": 125,
129
+ "Ebsus4": 126,
130
+ "Ebsus2": 127,
131
+ "Eb6": 128,
132
+ "Ebm6": 129,
133
+ "Eb9": 130,
134
+ "Ebm9": 131,
135
+ "Ebmaj9": 132,
136
+ "Eb11": 133,
137
+ "Ebm11": 134,
138
+ "Eb13": 135,
139
+ "Ebm13": 136,
140
+ "Ebadd9": 137,
141
+ "EbmMaj7": 138,
142
+ "Eb7b9": 139,
143
+ "Eb7#9": 140,
144
+ "Eb7#11": 141,
145
+ "Eb7b13": 142,
146
+ "Emaj": 143,
147
+ "Em": 144,
148
+ "E7": 145,
149
+ "Emaj7": 146,
150
+ "Em7": 147,
151
+ "Em7b5": 148,
152
+ "Edim7": 149,
153
+ "Edim": 150,
154
+ "Eaug": 151,
155
+ "Esus4": 152,
156
+ "Esus2": 153,
157
+ "E6": 154,
158
+ "Em6": 155,
159
+ "E9": 156,
160
+ "Em9": 157,
161
+ "Emaj9": 158,
162
+ "E11": 159,
163
+ "Em11": 160,
164
+ "E13": 161,
165
+ "Em13": 162,
166
+ "Eadd9": 163,
167
+ "EmMaj7": 164,
168
+ "E7b9": 165,
169
+ "E7#9": 166,
170
+ "E7#11": 167,
171
+ "E7b13": 168,
172
+ "Fmaj": 169,
173
+ "Fm": 170,
174
+ "F7": 171,
175
+ "Fmaj7": 172,
176
+ "Fm7": 173,
177
+ "Fm7b5": 174,
178
+ "Fdim7": 175,
179
+ "Fdim": 176,
180
+ "Faug": 177,
181
+ "Fsus4": 178,
182
+ "Fsus2": 179,
183
+ "F6": 180,
184
+ "Fm6": 181,
185
+ "F9": 182,
186
+ "Fm9": 183,
187
+ "Fmaj9": 184,
188
+ "F11": 185,
189
+ "Fm11": 186,
190
+ "F13": 187,
191
+ "Fm13": 188,
192
+ "Fadd9": 189,
193
+ "FmMaj7": 190,
194
+ "F7b9": 191,
195
+ "F7#9": 192,
196
+ "F7#11": 193,
197
+ "F7b13": 194,
198
+ "F#maj": 195,
199
+ "F#m": 196,
200
+ "F#7": 197,
201
+ "F#maj7": 198,
202
+ "F#m7": 199,
203
+ "F#m7b5": 200,
204
+ "F#dim7": 201,
205
+ "F#dim": 202,
206
+ "F#aug": 203,
207
+ "F#sus4": 204,
208
+ "F#sus2": 205,
209
+ "F#6": 206,
210
+ "F#m6": 207,
211
+ "F#9": 208,
212
+ "F#m9": 209,
213
+ "F#maj9": 210,
214
+ "F#11": 211,
215
+ "F#m11": 212,
216
+ "F#13": 213,
217
+ "F#m13": 214,
218
+ "F#add9": 215,
219
+ "F#mMaj7": 216,
220
+ "F#7b9": 217,
221
+ "F#7#9": 218,
222
+ "F#7#11": 219,
223
+ "F#7b13": 220,
224
+ "Gmaj": 221,
225
+ "Gm": 222,
226
+ "G7": 223,
227
+ "Gmaj7": 224,
228
+ "Gm7": 225,
229
+ "Gm7b5": 226,
230
+ "Gdim7": 227,
231
+ "Gdim": 228,
232
+ "Gaug": 229,
233
+ "Gsus4": 230,
234
+ "Gsus2": 231,
235
+ "G6": 232,
236
+ "Gm6": 233,
237
+ "G9": 234,
238
+ "Gm9": 235,
239
+ "Gmaj9": 236,
240
+ "G11": 237,
241
+ "Gm11": 238,
242
+ "G13": 239,
243
+ "Gm13": 240,
244
+ "Gadd9": 241,
245
+ "GmMaj7": 242,
246
+ "G7b9": 243,
247
+ "G7#9": 244,
248
+ "G7#11": 245,
249
+ "G7b13": 246,
250
+ "Abmaj": 247,
251
+ "Abm": 248,
252
+ "Ab7": 249,
253
+ "Abmaj7": 250,
254
+ "Abm7": 251,
255
+ "Abm7b5": 252,
256
+ "Abdim7": 253,
257
+ "Abdim": 254,
258
+ "Abaug": 255,
259
+ "Absus4": 256,
260
+ "Absus2": 257,
261
+ "Ab6": 258,
262
+ "Abm6": 259,
263
+ "Ab9": 260,
264
+ "Abm9": 261,
265
+ "Abmaj9": 262,
266
+ "Ab11": 263,
267
+ "Abm11": 264,
268
+ "Ab13": 265,
269
+ "Abm13": 266,
270
+ "Abadd9": 267,
271
+ "AbmMaj7": 268,
272
+ "Ab7b9": 269,
273
+ "Ab7#9": 270,
274
+ "Ab7#11": 271,
275
+ "Ab7b13": 272,
276
+ "Amaj": 273,
277
+ "Am": 274,
278
+ "A7": 275,
279
+ "Amaj7": 276,
280
+ "Am7": 277,
281
+ "Am7b5": 278,
282
+ "Adim7": 279,
283
+ "Adim": 280,
284
+ "Aaug": 281,
285
+ "Asus4": 282,
286
+ "Asus2": 283,
287
+ "A6": 284,
288
+ "Am6": 285,
289
+ "A9": 286,
290
+ "Am9": 287,
291
+ "Amaj9": 288,
292
+ "A11": 289,
293
+ "Am11": 290,
294
+ "A13": 291,
295
+ "Am13": 292,
296
+ "Aadd9": 293,
297
+ "AmMaj7": 294,
298
+ "A7b9": 295,
299
+ "A7#9": 296,
300
+ "A7#11": 297,
301
+ "A7b13": 298,
302
+ "Bbmaj": 299,
303
+ "Bbm": 300,
304
+ "Bb7": 301,
305
+ "Bbmaj7": 302,
306
+ "Bbm7": 303,
307
+ "Bbm7b5": 304,
308
+ "Bbdim7": 305,
309
+ "Bbdim": 306,
310
+ "Bbaug": 307,
311
+ "Bbsus4": 308,
312
+ "Bbsus2": 309,
313
+ "Bb6": 310,
314
+ "Bbm6": 311,
315
+ "Bb9": 312,
316
+ "Bbm9": 313,
317
+ "Bbmaj9": 314,
318
+ "Bb11": 315,
319
+ "Bbm11": 316,
320
+ "Bb13": 317,
321
+ "Bbm13": 318,
322
+ "Bbadd9": 319,
323
+ "BbmMaj7": 320,
324
+ "Bb7b9": 321,
325
+ "Bb7#9": 322,
326
+ "Bb7#11": 323,
327
+ "Bb7b13": 324,
328
+ "Bmaj": 325,
329
+ "Bm": 326,
330
+ "B7": 327,
331
+ "Bmaj7": 328,
332
+ "Bm7": 329,
333
+ "Bm7b5": 330,
334
+ "Bdim7": 331,
335
+ "Bdim": 332,
336
+ "Baug": 333,
337
+ "Bsus4": 334,
338
+ "Bsus2": 335,
339
+ "B6": 336,
340
+ "Bm6": 337,
341
+ "B9": 338,
342
+ "Bm9": 339,
343
+ "Bmaj9": 340,
344
+ "B11": 341,
345
+ "Bm11": 342,
346
+ "B13": 343,
347
+ "Bm13": 344,
348
+ "Badd9": 345,
349
+ "BmMaj7": 346,
350
+ "B7b9": 347,
351
+ "B7#9": 348,
352
+ "B7#11": 349,
353
+ "B7b13": 350,
354
+ "[GENRE:country]": 351,
355
+ "[GENRE:rnb_soul]": 352,
356
+ "[GENRE:hip_hop]": 353,
357
+ "[GENRE:electronic]": 354,
358
+ "[GENRE:funk]": 355,
359
+ "[GENRE:folk]": 356,
360
+ "[GENRE:gospel]": 357,
361
+ "[GENRE:classical]": 358
362
+ },
363
+ "vocab_size": 359
364
+ }
tokenizer.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chord sequence tokenizer for Music Transformer training.
2
+
3
+ Vocabulary (~350 tokens):
4
+ [PAD]=0, [BOS]=1, [EOS]=2, [BAR]=3
5
+ [KEY:Cmaj] ... [KEY:Bmin] (24 keys)
6
+ [TIME:4/4] ... [TIME:5/4] (5 time sigs)
7
+ [GENRE:jazz] ... [GENRE:none] (6 genres)
8
+ Cmaj, Cm, C7, ... B7b13 (12 roots x 26 qualities = 312 chords)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ # Canonical root names (jazz convention: prefer flats)
17
+ ROOTS = ["C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
18
+
19
+ # Root name aliases for normalization
20
+ ROOT_ALIASES: dict[str, str] = {
21
+ "C#": "Db", "D#": "Eb", "E#": "F", "Fb": "E",
22
+ "G#": "Ab", "A#": "Bb", "B#": "C", "Cb": "B",
23
+ "Gb": "F#",
24
+ # Lowercase
25
+ "c": "C", "d": "D", "e": "E", "f": "F", "g": "G", "a": "A", "b": "B",
26
+ "c#": "Db", "db": "Db", "d#": "Eb", "eb": "Eb",
27
+ "f#": "F#", "gb": "F#", "g#": "Ab", "ab": "Ab",
28
+ "a#": "Bb", "bb": "Bb", "cb": "B", "fb": "E",
29
+ }
30
+
31
+ # Chord qualities in our vocabulary
32
+ QUALITIES = [
33
+ "maj", "m", "7", "maj7", "m7", "m7b5", "dim7", "dim", "aug",
34
+ "sus4", "sus2", "6", "m6", "9", "m9", "maj9", "11", "m11",
35
+ "13", "m13", "add9", "mMaj7", "7b9", "7#9", "7#11", "7b13",
36
+ ]
37
+
38
+ # Quality alias mapping → canonical quality
39
+ _QUALITY_ALIASES: dict[str, str] = {
40
+ # Major
41
+ "major": "maj", "M": "maj",
42
+ # Minor
43
+ "min": "m", "minor": "m", "-": "m", "mi": "m",
44
+ # Dominant 7
45
+ "dom7": "7", "dom": "7",
46
+ # Major 7
47
+ "^7": "maj7", "M7": "maj7", "Maj7": "maj7", "major7": "maj7",
48
+ "j7": "maj7", "^": "maj7", "delta": "maj7",
49
+ # Minor 7
50
+ "min7": "m7", "-7": "m7", "mi7": "m7",
51
+ # Half-diminished
52
+ "hdim7": "m7b5", "hdim": "m7b5", "h7": "m7b5",
53
+ "%7": "m7b5", "%": "m7b5",
54
+ # Diminished
55
+ "o": "dim", "o7": "dim7",
56
+ # Augmented
57
+ "+": "aug",
58
+ # Suspended
59
+ "sus": "sus4",
60
+ # 6th
61
+ "min6": "m6", "-6": "m6",
62
+ # 9th
63
+ "min9": "m9", "-9": "m9", "M9": "maj9", "^9": "maj9", "Maj9": "maj9",
64
+ # 11th
65
+ "min11": "m11", "-11": "m11",
66
+ # 13th
67
+ "min13": "m13", "-13": "m13",
68
+ # Minor-major 7
69
+ "minmaj7": "mMaj7", "-^7": "mMaj7", "mM7": "mMaj7",
70
+ # Altered dominants
71
+ "7alt": "7b9",
72
+ }
73
+
74
+ # Keys and metadata
75
+ MAJOR_KEYS = [f"{r}maj" for r in ROOTS]
76
+ MINOR_KEYS = [f"{r}min" for r in ROOTS]
77
+ ALL_KEYS = MAJOR_KEYS + MINOR_KEYS
78
+ TIME_SIGS = ["4/4", "3/4", "6/8", "2/4", "5/4"]
79
+ GENRES = ["jazz", "pop", "rock", "blues", "bossa"]
80
+
81
+ EXTRA_GENRES = [
82
+ "country", "rnb_soul", "hip_hop", "electronic",
83
+ "funk", "folk", "gospel", "classical",
84
+ ]
85
+
86
+
87
+ class ChordTokenizer:
88
+ """Deterministic tokenizer for chord sequences."""
89
+
90
+ PAD = 0
91
+ BOS = 1
92
+ EOS = 2
93
+ BAR = 3
94
+
95
+ def __init__(self, include_extra_genres: bool = False) -> None:
96
+ self.include_extra_genres = include_extra_genres
97
+ self.token2id: dict[str, int] = {}
98
+ self.id2token: dict[int, str] = {}
99
+ self._build_vocab()
100
+
101
+ # ------------------------------------------------------------------
102
+ # Vocab construction
103
+ # ------------------------------------------------------------------
104
+
105
+ def _build_vocab(self) -> None:
106
+ tokens: list[str] = ["[PAD]", "[BOS]", "[EOS]", "[BAR]"]
107
+ for key in ALL_KEYS:
108
+ tokens.append(f"[KEY:{key}]")
109
+ for ts in TIME_SIGS:
110
+ tokens.append(f"[TIME:{ts}]")
111
+ for genre in GENRES:
112
+ tokens.append(f"[GENRE:{genre}]")
113
+ tokens.append("[GENRE:none]")
114
+ for root in ROOTS:
115
+ for quality in QUALITIES:
116
+ tokens.append(f"{root}{quality}")
117
+ if self.include_extra_genres:
118
+ for genre in EXTRA_GENRES:
119
+ tokens.append(f"[GENRE:{genre}]")
120
+ for i, tok in enumerate(tokens):
121
+ self.token2id[tok] = i
122
+ self.id2token[i] = tok
123
+
124
+ @property
125
+ def vocab_size(self) -> int:
126
+ return len(self.token2id)
127
+
128
+ @property
129
+ def pad_id(self) -> int:
130
+ return self.PAD
131
+
132
+ @property
133
+ def bos_id(self) -> int:
134
+ return self.BOS
135
+
136
+ @property
137
+ def eos_id(self) -> int:
138
+ return self.EOS
139
+
140
+ @property
141
+ def bar_id(self) -> int:
142
+ return self.BAR
143
+
144
+ # ------------------------------------------------------------------
145
+ # Encoding helpers
146
+ # ------------------------------------------------------------------
147
+
148
+ def encode_chord(self, chord_str: str) -> int | None:
149
+ token = self.normalize_chord(chord_str)
150
+ return self.token2id.get(token) if token else None
151
+
152
+ def encode_key(self, key_str: str) -> int | None:
153
+ return self.token2id.get(f"[KEY:{key_str}]")
154
+
155
+ def encode_time_sig(self, ts: str) -> int | None:
156
+ return self.token2id.get(f"[TIME:{ts}]")
157
+
158
+ def encode_genre(self, genre: str) -> int | None:
159
+ return self.token2id.get(f"[GENRE:{genre}]")
160
+
161
+ def encode_sequence(self, song: dict) -> list[int]:
162
+ """Encode a unified song dict to a token-ID sequence.
163
+
164
+ Expected *song* format::
165
+
166
+ {
167
+ "key": "Cmaj",
168
+ "time_signature": "4/4",
169
+ "genre": "jazz",
170
+ "bars": [["Cmaj7", "Am7"], ["Dm7", "G7"], ...]
171
+ }
172
+ """
173
+ ids: list[int] = [self.BOS]
174
+
175
+ kid = self.encode_key(song.get("key", "Cmaj"))
176
+ if kid is not None:
177
+ ids.append(kid)
178
+
179
+ tid = self.encode_time_sig(song.get("time_signature", "4/4"))
180
+ if tid is not None:
181
+ ids.append(tid)
182
+
183
+ gid = self.encode_genre(song.get("genre", "none"))
184
+ if gid is not None:
185
+ ids.append(gid)
186
+
187
+ for bar in song.get("bars", []):
188
+ ids.append(self.BAR)
189
+ for chord in bar:
190
+ cid = self.encode_chord(chord)
191
+ if cid is not None:
192
+ ids.append(cid)
193
+
194
+ ids.append(self.EOS)
195
+ return ids
196
+
197
+ def decode(self, ids: list[int]) -> list[str]:
198
+ return [self.id2token.get(i, "[UNK]") for i in ids]
199
+
200
+ # ------------------------------------------------------------------
201
+ # Chord normalization
202
+ # ------------------------------------------------------------------
203
+
204
+ @staticmethod
205
+ def normalize_root(root: str) -> str | None:
206
+ """Normalize a root note name to canonical form."""
207
+ if root in ROOTS:
208
+ return root
209
+ if root in ROOT_ALIASES:
210
+ return ROOT_ALIASES[root]
211
+ # Try capitalize first letter
212
+ cap = root[0].upper() + root[1:] if len(root) > 1 else root.upper()
213
+ if cap in ROOTS:
214
+ return cap
215
+ if cap in ROOT_ALIASES:
216
+ return ROOT_ALIASES[cap]
217
+ return None
218
+
219
+ @staticmethod
220
+ def normalize_chord(chord_str: str) -> str | None:
221
+ """Normalize any chord notation to ``{Root}{quality}`` in our vocab."""
222
+ if not chord_str or chord_str in (
223
+ "N", "NC", "N.C.", "X", "x",
224
+ "pause", "silence", "&pause", "end",
225
+ ):
226
+ return None
227
+
228
+ # Strip slash-chord bass
229
+ if "/" in chord_str:
230
+ chord_str = chord_str.split("/")[0]
231
+
232
+ # Billboard colon format Root:Quality
233
+ if ":" in chord_str:
234
+ root_part, qual_part = chord_str.split(":", 1)
235
+ # qual_part may also have /bass — already stripped above
236
+ else:
237
+ root_part = chord_str[0]
238
+ qual_part = chord_str[1:]
239
+ if qual_part and qual_part[0] in ("b", "#"):
240
+ root_part += qual_part[0]
241
+ qual_part = qual_part[1:]
242
+
243
+ norm_root = ChordTokenizer.normalize_root(root_part)
244
+ if norm_root is None:
245
+ return None
246
+
247
+ quality = ChordTokenizer._normalize_quality(qual_part)
248
+ if quality is None or quality not in QUALITIES:
249
+ return None
250
+
251
+ return f"{norm_root}{quality}"
252
+
253
+ @staticmethod
254
+ def _normalize_quality(q: str) -> str | None:
255
+ """Map various quality notations to our canonical set."""
256
+ if not q:
257
+ return "maj"
258
+
259
+ # Direct hit
260
+ if q in QUALITIES:
261
+ return q
262
+
263
+ # Alias table
264
+ if q in _QUALITY_ALIASES:
265
+ return _QUALITY_ALIASES[q]
266
+
267
+ # Case-insensitive alias search
268
+ for alias, canon in _QUALITY_ALIASES.items():
269
+ if q.lower() == alias.lower():
270
+ return canon
271
+
272
+ # ---- Heuristic fallbacks for unusual notations ----
273
+
274
+ # WJazzD altered dominants: "79b" → 7b9, "79#" → 7#9, etc.
275
+ if q.startswith("7"):
276
+ tail = q[1:]
277
+ if "b9" in tail or "9b" in tail:
278
+ return "7b9"
279
+ if "#9" in tail or "9#" in tail:
280
+ return "7#9"
281
+ if "#11" in tail or "11#" in tail:
282
+ return "7#11"
283
+ if "b13" in tail or "13b" in tail:
284
+ return "7b13"
285
+
286
+ # Compound minor qualities
287
+ if q.startswith("m") or q.startswith("-"):
288
+ inner = q.lstrip("m").lstrip("-")
289
+ if "7" in inner and ("b5" in inner or "b5" in q):
290
+ return "m7b5"
291
+ if "7" in inner:
292
+ return "m7"
293
+ if "9" in inner:
294
+ return "m9"
295
+ if "11" in inner:
296
+ return "m11"
297
+ if "13" in inner:
298
+ return "m13"
299
+ if "6" in inner:
300
+ return "m6"
301
+ return "m"
302
+
303
+ # Bare numbers
304
+ if q in ("7",):
305
+ return "7"
306
+ if q in ("9",):
307
+ return "9"
308
+ if q in ("6",):
309
+ return "6"
310
+ if q in ("11",):
311
+ return "11"
312
+ if q in ("13",):
313
+ return "13"
314
+
315
+ # If nothing matched, approximate as major
316
+ return "maj"
317
+
318
+ # ------------------------------------------------------------------
319
+ # Transposition
320
+ # ------------------------------------------------------------------
321
+
322
+ def transpose_chord_token(self, token: str, semitones: int) -> str | None:
323
+ """Transpose a chord token string by *semitones*."""
324
+ if token.startswith("["):
325
+ return None
326
+ root = token[0]
327
+ rest = token[1:]
328
+ if rest and rest[0] in ("b", "#"):
329
+ root += rest[0]
330
+ rest = rest[1:]
331
+ norm_root = self.normalize_root(root)
332
+ if norm_root is None:
333
+ return None
334
+ new_root = ROOTS[(ROOTS.index(norm_root) + semitones) % 12]
335
+ return f"{new_root}{rest}"
336
+
337
+ def transpose_key_token(self, token: str, semitones: int) -> str:
338
+ """Transpose a key token like ``[KEY:Cmaj]``."""
339
+ inner = token[5:-1] # strip [KEY: and ]
340
+ if inner.endswith("maj"):
341
+ root, mode = inner[:-3], "maj"
342
+ elif inner.endswith("min"):
343
+ root, mode = inner[:-3], "min"
344
+ else:
345
+ return token
346
+ norm = self.normalize_root(root)
347
+ if norm is None:
348
+ return token
349
+ new_root = ROOTS[(ROOTS.index(norm) + semitones) % 12]
350
+ return f"[KEY:{new_root}{mode}]"
351
+
352
+ def transpose_sequence(self, ids: list[int], semitones: int) -> list[int]:
353
+ """Transpose every chord & key token in *ids* by *semitones*."""
354
+ if semitones % 12 == 0:
355
+ return list(ids)
356
+ out: list[int] = []
357
+ for tid in ids:
358
+ tok = self.id2token.get(tid)
359
+ if tok is None:
360
+ out.append(tid)
361
+ elif tok.startswith("[KEY:"):
362
+ new = self.transpose_key_token(tok, semitones)
363
+ out.append(self.token2id.get(new, tid))
364
+ elif tok.startswith("[") or tid <= self.BAR:
365
+ out.append(tid)
366
+ else:
367
+ new = self.transpose_chord_token(tok, semitones)
368
+ out.append(self.token2id[new] if new and new in self.token2id else tid)
369
+ return out
370
+
371
+ # ------------------------------------------------------------------
372
+ # Persistence
373
+ # ------------------------------------------------------------------
374
+
375
+ def save(self, path: str | Path) -> None:
376
+ Path(path).write_text(json.dumps({
377
+ "token2id": self.token2id,
378
+ "vocab_size": self.vocab_size,
379
+ }, indent=2, ensure_ascii=False))
380
+
381
+ @classmethod
382
+ def load(cls, path: str | Path) -> ChordTokenizer:
383
+ tok = cls()
384
+ data = json.loads(Path(path).read_text())
385
+ assert data["vocab_size"] == tok.vocab_size, (
386
+ f"Vocab mismatch: file={data['vocab_size']}, current={tok.vocab_size}"
387
+ )
388
+ return tok