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:
2026-05-20 13:56:34 +03:00
parent 329952b02e
commit 4aead2ea20
5 changed files with 92 additions and 54 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.