From 4aead2ea203302d26e684ccd0a1185389e767b53 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Wed, 20 May 2026 13:56:34 +0300 Subject: [PATCH] feat: remove BAR token; bump spec to v2.3; fix max_seq_len MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 4 +-- docs/chord_format_spec.md | 58 +++++++++++++++++++++------------------ scripts/pretrain.py | 6 ++-- src/tokenizer.py | 58 +++++++++++++++++++++++++++++++-------- tests/test_tokenizer.py | 20 ++++++-------- 5 files changed, 92 insertions(+), 54 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c735fec..fb0960e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: `(/)?`. 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 diff --git a/docs/chord_format_spec.md b/docs/chord_format_spec.md index 131c18d..ba36031 100644 --- a/docs/chord_format_spec.md +++ b/docs/chord_format_spec.md @@ -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_ QUAL_ EXT_ BASS_ ← новый аккорд = 4 ток HOLD ← удержание = 1 токен HOLD HOLD -BAR - + ← граница такта не токенизируется ROOT_ QUAL_ EXT_ BASS_ HOLD ROOT_ QUAL_ EXT_ BASS_ HOLD -BAR ... ``` +Детокенизатор считает позиции самостоятельно: как только накоплено `positions_per_bar(TIME, SUB)` позиций, текущий такт закрывается и открывается новый. `` допускается только в начале такта (на нулевой позиции). + ### 5.4 Алгоритм токенизации (источник → токены) 1. Прочитать шапку. @@ -281,14 +282,19 @@ BAR - Если `.`: выпустить `HOLD`. - Если `NC`: выпустить `NC`. - Если `?`: выпустить ``. - - После последней позиции — `BAR`. + - Границу такта не токенизировать. 6. Выпустить ``. -### 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`. + - `` → завершить (только при `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 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 ``` -(Переносы строк здесь для читаемости; в реальности — один поток.) +(Переносы строк здесь для читаемости; в реальности — один поток. Граница такта определяется счётчиком позиций — каждые 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` и разрешает `` только на нулевой позиции. + - Размер словаря: 85 → 84 токена. + +- **v2.2** - Добавлены тактовые размеры `5/4`, `7/4`, `7/8`, `9/8` — в допустимые значения поля `time` и в словарь (`TIME_*`). - Размер словаря: 81 → 85 токенов. diff --git a/scripts/pretrain.py b/scripts/pretrain.py index ab07cea..65e1e12 100644 --- a/scripts/pretrain.py +++ b/scripts/pretrain.py @@ -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, ) diff --git a/src/tokenizer.py b/src/tokenizer.py index aea6c2e..a412494 100644 --- a/src/tokenizer.py +++ b/src/tokenizer.py @@ -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,7 +147,12 @@ def _expected_positions(time: str, subdivision: int) -> int: def _tokens_to_symbol(t: ChordTokens) -> str: """Reconstruct a canonical, parseable chord symbol string from ChordTokens.""" - quality_ext = t.quality + ("" if t.extension == "none" else t.extension) + # 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: , metadata tokens, per-bar chord - tokens interleaved with HOLD/NC, each bar closed by BAR, then . + List of integer token IDs: , metadata tokens, a flat sequence of + chord/HOLD/NC tokens for every position across all bars, then . + 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[""]) 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 == "": 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 == "": 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 " + log.warning( + "detokenize: discarding partial bar (%d/%d positions filled)", + pos_in_bar, positions_per_bar, ) return ChordPeriod( diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py index 9972622..3abe24f 100644 --- a/tests/test_tokenizer.py +++ b/tests/test_tokenizer.py @@ -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] == ["", "", "", ""] 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.