feat: remove BAR token; bump spec to v2.3; fix max_seq_len
Bar boundaries are now implicit — the detokenizer counts positions per bar using TIME × SUB, and the generator gates EOS to bar boundaries only. Removing the deterministic BAR token reduces vocab size from 85 to 84 and lets the model focus on meaningful predictions. - src/tokenizer.py: drop BAR from VOCAB (85→84); replace BAR-based detokenize_to_period with position-counting logic; add write_chord_file; fix _tokens_to_symbol for add9/m(add9) qualities - tests/test_tokenizer.py: update vocab-size assertions to 84, structural token test, remove bar-count test, add test_no_bar_token_in_vocab - docs/chord_format_spec.md: bump to v2.3; document BAR removal in §5.2, §5.3, §5.4, §5.5, §5.6, §6.2, and changelog - CLAUDE.md: remove stale BAR reference, update vocab size to 84 - scripts/pretrain.py: raise max_seq_len 256→320 to cover regenerated McGill data (mean=83, max=283 tokens with BAR-free tokenizer) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,9 +90,9 @@ The authoritative specification is in `docs/chord_format_spec.md`. **Always read
|
||||
- Body: bars separated by `|`, exactly `subdivision` positions per bar (for 4/4), positions separated by single spaces.
|
||||
- A position holds: chord symbol, `.` (hold previous), `NC` (no chord), or `?` (unknown).
|
||||
- Chord symbols: `<root><quality?><extension?>(/<bass>)?`. 18 qualities, 7 extensions, slash inversions are mandatory and meaningful.
|
||||
- Tokenization: each new chord becomes exactly 4 tokens (`ROOT_x`, `QUAL_x`, `EXT_x`, `BASS_x`). Hold = `HOLD`. Bar end = `BAR`. Plus metadata tokens at the start.
|
||||
- Tokenization: each new chord becomes exactly 4 tokens (`ROOT_x`, `QUAL_x`, `EXT_x`, `BASS_x`). Hold = `HOLD`. Bar boundaries are **not tokens** — the detokenizer reconstructs them by counting positions (`TIME` × `SUB`). Plus metadata tokens at the start.
|
||||
- **Keys are normalized.** Before tokenization, the entire period is transposed: majors → C major, minors → A minor. The model never sees absolute keys. The vocabulary contains `MODE_major`/`MODE_minor` but no `KEY_x` tokens.
|
||||
- Vocabulary size: ~81 tokens.
|
||||
- Vocabulary size: 84 tokens.
|
||||
|
||||
## Model
|
||||
|
||||
|
||||
+31
-27
@@ -1,6 +1,6 @@
|
||||
# Спецификация формата данных hamori
|
||||
|
||||
**Версия:** 2.2
|
||||
**Версия:** 2.3
|
||||
**Дата:** 2026-05-20
|
||||
|
||||
---
|
||||
@@ -18,7 +18,7 @@
|
||||
Формат двухуровневый:
|
||||
|
||||
- **Исходный (`.chord`)** — то, что пишется руками. Близок к лид-шиту, человекочитаем, легко правится в любом текстовом редакторе.
|
||||
- **Токенизированный** — то, что подаётся в модель. Факторизованное представление с фиксированным словарём (85 токенов).
|
||||
- **Токенизированный** — то, что подаётся в модель. Факторизованное представление с фиксированным словарём (84 токена).
|
||||
|
||||
Между уровнями стоит детерминированный парсер.
|
||||
|
||||
@@ -238,13 +238,14 @@ D/F# ← D, бас F♯
|
||||
- `EXT_none`, `EXT_9`, `EXT_b9`, `EXT_#9`, `EXT_11`, `EXT_#11`, `EXT_13`, `EXT_b13` — 8 токенов
|
||||
- `BASS_root`, `BASS_C`, `BASS_C#`, ..., `BASS_B` — 13 токенов
|
||||
|
||||
**Временные/структурные:**
|
||||
**Временные/структурные (2):**
|
||||
|
||||
- `HOLD` — позиция продолжает предыдущий аккорд
|
||||
- `NC` — пауза в гармонии
|
||||
- `BAR` — конец такта
|
||||
|
||||
**Итого:** 4 + 2 + 9 + 2 + 5 + 9 + 12 + 18 + 8 + 13 + 3 = **85 токенов**.
|
||||
Граница такта **не является токеном** — детокенизатор восстанавливает её по счётчику позиций на основе `TIME` и `SUB`.
|
||||
|
||||
**Итого:** 4 + 2 + 9 + 2 + 5 + 9 + 12 + 18 + 8 + 13 + 2 = **84 токена**.
|
||||
|
||||
### 5.3 Структура обучающей последовательности
|
||||
|
||||
@@ -256,19 +257,19 @@ ROOT_<x> QUAL_<x> EXT_<x> BASS_<x> ← новый аккорд = 4 ток
|
||||
HOLD ← удержание = 1 токен
|
||||
HOLD
|
||||
HOLD
|
||||
BAR
|
||||
|
||||
← граница такта не токенизируется
|
||||
ROOT_<x> QUAL_<x> EXT_<x> BASS_<x>
|
||||
HOLD
|
||||
ROOT_<x> QUAL_<x> EXT_<x> BASS_<x>
|
||||
HOLD
|
||||
BAR
|
||||
|
||||
...
|
||||
|
||||
<EOS>
|
||||
```
|
||||
|
||||
Детокенизатор считает позиции самостоятельно: как только накоплено `positions_per_bar(TIME, SUB)` позиций, текущий такт закрывается и открывается новый. `<EOS>` допускается только в начале такта (на нулевой позиции).
|
||||
|
||||
### 5.4 Алгоритм токенизации (источник → токены)
|
||||
|
||||
1. Прочитать шапку.
|
||||
@@ -281,14 +282,19 @@ BAR
|
||||
- Если `.`: выпустить `HOLD`.
|
||||
- Если `NC`: выпустить `NC`.
|
||||
- Если `?`: выпустить `<UNK>`.
|
||||
- После последней позиции — `BAR`.
|
||||
- Границу такта не токенизировать.
|
||||
6. Выпустить `<EOS>`.
|
||||
|
||||
### 5.5 Алгоритм детокенизации (токены → MIDI)
|
||||
### 5.5 Алгоритм детокенизации (токены → период)
|
||||
|
||||
1. Считать метатокены, восстановить параметры периода.
|
||||
2. Группировать аккордовые токены по 4 (root, quality, extension, bass).
|
||||
3. Развернуть в последовательность аккордов с длительностями, считая HOLD-ы как продолжение предыдущего.
|
||||
2. Вычислить `positions_per_bar` = число позиций на такт по `TIME` и `SUB`.
|
||||
3. Читать тело последовательности, поддерживая счётчик `pos_in_bar`:
|
||||
- `ROOT_*` + следующие 3 токена (QUAL, EXT, BASS) → новый аккорд, `pos_in_bar += 1`.
|
||||
- `HOLD` → удержать аккорд, `pos_in_bar += 1`.
|
||||
- `NC` → пауза, `pos_in_bar += 1`.
|
||||
- Когда `pos_in_bar == positions_per_bar` → закрыть текущий такт, сбросить `pos_in_bar = 0`.
|
||||
- `<EOS>` → завершить (только при `pos_in_bar == 0`).
|
||||
4. Транспонировать обратно из C/Am в целевую тональность (задаваемую пользователем на инференсе).
|
||||
5. Сгенерировать MIDI через `pretty_midi`: для каждого аккорда выложить ноты в один трек, бас — отдельной линией в нижней октаве.
|
||||
|
||||
@@ -297,10 +303,10 @@ BAR
|
||||
Период 8 тактов в 4/4 с subdivision=4, в среднем 2 смены аккорда на такт:
|
||||
|
||||
- Метатокены: 1 (BOS) + 5 = 6 токенов
|
||||
- На такт: 2 аккорда × 4 + 2 HOLD-а + 1 BAR = 11 токенов
|
||||
- 8 тактов: ~88 токенов
|
||||
- На такт: 2 аккорда × 4 + 2 HOLD-а = 10 токенов
|
||||
- 8 тактов: ~80 токенов
|
||||
- EOS: 1
|
||||
- **Итого: ~95 токенов на типичный период.**
|
||||
- **Итого: ~87 токенов на типичный период.**
|
||||
|
||||
Длинный период (16 тактов с частой сменой): редко превышает 250 токенов. Контекстное окно 512 токенов более чем достаточно.
|
||||
|
||||
@@ -349,46 +355,40 @@ BAR
|
||||
<BOS>
|
||||
MODE_major TIME_4/4 SUB_4 STYLE_H1K0 FUNC_chorus
|
||||
|
||||
ROOT_C QUAL_maj7 EXT_none BASS_root
|
||||
HOLD HOLD HOLD
|
||||
BAR
|
||||
ROOT_C QUAL_maj7 EXT_none BASS_root ← такт 1, поз. 0
|
||||
HOLD ← такт 1, поз. 1
|
||||
HOLD ← такт 1, поз. 2
|
||||
HOLD ← такт 1, поз. 3 (4/4 → новый такт)
|
||||
|
||||
ROOT_E QUAL_m7 EXT_none BASS_root
|
||||
HOLD HOLD HOLD
|
||||
BAR
|
||||
|
||||
ROOT_A QUAL_m7 EXT_none BASS_root
|
||||
HOLD HOLD HOLD
|
||||
BAR
|
||||
|
||||
ROOT_F QUAL_maj EXT_none BASS_root
|
||||
HOLD
|
||||
ROOT_G QUAL_maj EXT_none BASS_root
|
||||
HOLD
|
||||
BAR
|
||||
|
||||
ROOT_F QUAL_maj7 EXT_none BASS_root
|
||||
HOLD HOLD HOLD
|
||||
BAR
|
||||
|
||||
ROOT_C QUAL_maj EXT_none BASS_E
|
||||
HOLD HOLD HOLD
|
||||
BAR
|
||||
|
||||
ROOT_D QUAL_m7 EXT_none BASS_root
|
||||
HOLD
|
||||
ROOT_G QUAL_maj EXT_none BASS_root
|
||||
HOLD
|
||||
BAR
|
||||
|
||||
ROOT_C QUAL_maj EXT_none BASS_root
|
||||
HOLD HOLD HOLD
|
||||
BAR
|
||||
|
||||
<EOS>
|
||||
```
|
||||
|
||||
(Переносы строк здесь для читаемости; в реальности — один поток.)
|
||||
(Переносы строк здесь для читаемости; в реальности — один поток. Граница такта определяется счётчиком позиций — каждые 4 позиции при `TIME_4/4 SUB_4`.)
|
||||
|
||||
---
|
||||
|
||||
@@ -477,7 +477,11 @@ sea_glass-bridge.chord
|
||||
|
||||
## 11. История изменений
|
||||
|
||||
- **v2.2** (текущая)
|
||||
- **v2.3** (текущая)
|
||||
- Удалён токен `BAR`. Граница такта теперь восстанавливается детокенизатором по счётчику позиций (`TIME` × `SUB`). Генератор отслеживает `pos_in_bar` и разрешает `<EOS>` только на нулевой позиции.
|
||||
- Размер словаря: 85 → 84 токена.
|
||||
|
||||
- **v2.2**
|
||||
- Добавлены тактовые размеры `5/4`, `7/4`, `7/8`, `9/8` — в допустимые значения поля `time` и в словарь (`TIME_*`).
|
||||
- Размер словаря: 81 → 85 токенов.
|
||||
|
||||
|
||||
+3
-3
@@ -56,9 +56,9 @@ TRAIN_CFG = TrainConfig(
|
||||
warmup_steps=200,
|
||||
seed=42,
|
||||
device="auto",
|
||||
# Real McGill sequences are ≤ 195 tokens (p95 = 146, mean = 92).
|
||||
# Using 256 instead of the 512 default cuts attention cost ~4x.
|
||||
max_seq_len=256,
|
||||
# Regenerated McGill sequences: mean=83, max=283 (BAR-free tokenizer).
|
||||
# 320 covers the full distribution with headroom; still ~2.5x cheaper than 512.
|
||||
max_seq_len=320,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+46
-10
@@ -2,6 +2,7 @@
|
||||
|
||||
Public API:
|
||||
parse_chord_file(path: Path) -> ChordPeriod
|
||||
write_chord_file(period: ChordPeriod, path: Path) -> None
|
||||
transpose_to_canonical(period: ChordPeriod) -> ChordPeriod
|
||||
tokenize_period(period: ChordPeriod) -> list[int]
|
||||
detokenize_to_period(token_ids: list[int]) -> ChordPeriod
|
||||
@@ -108,8 +109,8 @@ VOCAB: list[str] = [
|
||||
# Bass note — 'root' sentinel + 12 pitch classes (13)
|
||||
"BASS_root", "BASS_C", "BASS_C#", "BASS_D", "BASS_D#", "BASS_E", "BASS_F",
|
||||
"BASS_F#", "BASS_G", "BASS_G#", "BASS_A", "BASS_A#", "BASS_B",
|
||||
# Structural (3)
|
||||
"HOLD", "NC", "BAR",
|
||||
# Structural (2)
|
||||
"HOLD", "NC",
|
||||
]
|
||||
|
||||
TOKEN_TO_ID: dict[str, int] = {tok: i for i, tok in enumerate(VOCAB)}
|
||||
@@ -146,6 +147,11 @@ def _expected_positions(time: str, subdivision: int) -> int:
|
||||
|
||||
def _tokens_to_symbol(t: ChordTokens) -> str:
|
||||
"""Reconstruct a canonical, parseable chord symbol string from ChordTokens."""
|
||||
# add9/m(add9) already encode the extension; appending another EXT would be
|
||||
# unparseable. The grammar mask prevents this during generation, but guard here too.
|
||||
if t.quality in ("add9", "m(add9)"):
|
||||
quality_ext = t.quality
|
||||
else:
|
||||
quality_ext = t.quality + ("" if t.extension == "none" else t.extension)
|
||||
bass_part = "" if t.bass == "root" else f"/{t.bass}"
|
||||
return t.root + quality_ext + bass_part
|
||||
@@ -347,8 +353,9 @@ def tokenize_period(period: ChordPeriod) -> list[int]:
|
||||
period: A ChordPeriod as returned by parse_chord_file.
|
||||
|
||||
Returns:
|
||||
List of integer token IDs: <BOS>, metadata tokens, per-bar chord
|
||||
tokens interleaved with HOLD/NC, each bar closed by BAR, then <EOS>.
|
||||
List of integer token IDs: <BOS>, metadata tokens, a flat sequence of
|
||||
chord/HOLD/NC tokens for every position across all bars, then <EOS>.
|
||||
Bar boundaries are implicit: every positions_per_bar positions form one bar.
|
||||
|
||||
Raises:
|
||||
ChordFormatError: If a chord symbol cannot be parsed during transposition.
|
||||
@@ -381,12 +388,32 @@ def tokenize_period(period: ChordPeriod) -> list[int]:
|
||||
ids.append(TOKEN_TO_ID[_qual_token(t.quality)])
|
||||
ids.append(TOKEN_TO_ID[f"EXT_{t.extension}"])
|
||||
ids.append(TOKEN_TO_ID[f"BASS_{t.bass}"])
|
||||
ids.append(TOKEN_TO_ID["BAR"])
|
||||
|
||||
ids.append(TOKEN_TO_ID["<EOS>"])
|
||||
return ids
|
||||
|
||||
|
||||
def write_chord_file(period: ChordPeriod, path: Path) -> None:
|
||||
"""Serialise a ChordPeriod to a .chord file.
|
||||
|
||||
Args:
|
||||
period: ChordPeriod to write.
|
||||
path: Destination path (created or overwritten).
|
||||
"""
|
||||
lines: list[str] = [
|
||||
f"# title: {period.title}",
|
||||
f"# key: {period.key}",
|
||||
f"# time: {period.time}",
|
||||
f"# subdivision: {period.subdivision}",
|
||||
f"# style: {period.style}",
|
||||
f"# function: {period.function}",
|
||||
"",
|
||||
"| " + " | ".join(" ".join(bar) for bar in period.bars) + " |",
|
||||
]
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def detokenize_to_period(token_ids: list[int]) -> ChordPeriod:
|
||||
"""Convert a token ID sequence back to a ChordPeriod in canonical key (C/Am).
|
||||
|
||||
@@ -429,9 +456,11 @@ def detokenize_to_period(token_ids: list[int]) -> ChordPeriod:
|
||||
function = _consume("FUNC_")
|
||||
|
||||
key = "C_major" if mode == "major" else "A_minor"
|
||||
positions_per_bar = _expected_positions(time, subdivision)
|
||||
|
||||
bars: list[list[str]] = []
|
||||
current_bar: list[str] = []
|
||||
pos_in_bar = 0
|
||||
|
||||
while idx < n:
|
||||
tok = tokens[idx]
|
||||
@@ -439,15 +468,15 @@ def detokenize_to_period(token_ids: list[int]) -> ChordPeriod:
|
||||
|
||||
if tok == "<EOS>":
|
||||
break
|
||||
elif tok == "BAR":
|
||||
bars.append(current_bar)
|
||||
current_bar = []
|
||||
elif tok == "HOLD":
|
||||
current_bar.append(".")
|
||||
pos_in_bar += 1
|
||||
elif tok == "NC":
|
||||
current_bar.append("NC")
|
||||
pos_in_bar += 1
|
||||
elif tok == "<UNK>":
|
||||
current_bar.append("?")
|
||||
pos_in_bar += 1
|
||||
elif tok.startswith("ROOT_"):
|
||||
if idx + 3 > n:
|
||||
raise ChordFormatError(
|
||||
@@ -463,12 +492,19 @@ def detokenize_to_period(token_ids: list[int]) -> ChordPeriod:
|
||||
current_bar.append(
|
||||
_tokens_to_symbol(ChordTokens(root, quality, extension, bass))
|
||||
)
|
||||
pos_in_bar += 1
|
||||
else:
|
||||
raise ChordFormatError(f"unexpected token in bar body: {tok!r}")
|
||||
|
||||
if pos_in_bar == positions_per_bar:
|
||||
bars.append(current_bar)
|
||||
current_bar = []
|
||||
pos_in_bar = 0
|
||||
|
||||
if current_bar:
|
||||
raise ChordFormatError(
|
||||
"token sequence ended without closing BAR before <EOS>"
|
||||
log.warning(
|
||||
"detokenize: discarding partial bar (%d/%d positions filled)",
|
||||
pos_in_bar, positions_per_bar,
|
||||
)
|
||||
|
||||
return ChordPeriod(
|
||||
|
||||
+9
-11
@@ -39,17 +39,17 @@ VALID_FIXTURES = [
|
||||
|
||||
|
||||
class TestVocabulary:
|
||||
def test_vocab_has_85_tokens(self):
|
||||
assert len(VOCAB) == 85
|
||||
def test_vocab_has_84_tokens(self):
|
||||
assert len(VOCAB) == 84
|
||||
|
||||
def test_no_duplicate_tokens(self):
|
||||
assert len(set(VOCAB)) == 85
|
||||
assert len(set(VOCAB)) == 84
|
||||
|
||||
def test_token_to_id_covers_all_vocab(self):
|
||||
assert len(TOKEN_TO_ID) == 85
|
||||
assert len(TOKEN_TO_ID) == 84
|
||||
|
||||
def test_id_to_token_covers_all_vocab(self):
|
||||
assert len(ID_TO_TOKEN) == 85
|
||||
assert len(ID_TO_TOKEN) == 84
|
||||
|
||||
def test_ids_are_contiguous_from_zero(self):
|
||||
for i, tok in enumerate(VOCAB):
|
||||
@@ -63,7 +63,7 @@ class TestVocabulary:
|
||||
assert VOCAB[:4] == ["<BOS>", "<EOS>", "<PAD>", "<UNK>"]
|
||||
|
||||
def test_structural_tokens_at_end(self):
|
||||
assert VOCAB[-3:] == ["HOLD", "NC", "BAR"]
|
||||
assert VOCAB[-2:] == ["HOLD", "NC"]
|
||||
|
||||
def test_all_roots_present(self):
|
||||
for note in ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"):
|
||||
@@ -112,10 +112,8 @@ class TestTokenizeStructure:
|
||||
assert toks[4] == "STYLE_other" # 'unspecified' is not in VOCAB → falls back to STYLE_other
|
||||
assert toks[5] == "FUNC_chorus"
|
||||
|
||||
def test_bar_token_count_matches_bar_count(self):
|
||||
p = parse_chord_file(FIXTURES / "valid_c_major.chord")
|
||||
ids = tokenize_period(p)
|
||||
assert sum(1 for i in ids if i == TOKEN_TO_ID["BAR"]) == len(p.bars)
|
||||
def test_no_bar_token_in_vocab(self):
|
||||
assert "BAR" not in TOKEN_TO_ID
|
||||
|
||||
def test_minor_period_emits_mode_minor(self):
|
||||
p = parse_chord_file(FIXTURES / "valid_b_minor.chord")
|
||||
@@ -130,7 +128,7 @@ class TestTokenizeStructure:
|
||||
def test_all_ids_in_vocab_range(self):
|
||||
for fixture_name in VALID_FIXTURES:
|
||||
p = parse_chord_file(FIXTURES / fixture_name)
|
||||
assert all(0 <= i < 85 for i in tokenize_period(p))
|
||||
assert all(0 <= i < 84 for i in tokenize_period(p))
|
||||
|
||||
def test_non_canonical_key_transposed_before_encoding(self):
|
||||
# F# major: first chord F#maj7 → Cmaj7 after shift=6; ROOT_C is at index 6.
|
||||
|
||||
Reference in New Issue
Block a user