refactor: replace fixed STYLE_user with open-ended style tag system

- STYLE_user renamed to STYLE_H1K0 in VOCAB (author's personal tag)
- Style field now accepts any [A-Za-z][A-Za-z0-9_]* identifier in .chord files
- Unknown styles fall back to STYLE_other at tokenization time with a log warning
- Test fixtures updated to style: other; drop closed _VALID_STYLES frozenset
- Spec bumped to v2.1: documents open style field, fallback behaviour, and §5.7
  guide on registering a new style token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 00:29:52 +03:00
parent 84ba7b4743
commit 4fd8ece170
12 changed files with 60 additions and 38 deletions
+32 -15
View File
@@ -1,7 +1,7 @@
# Спецификация формата данных hamori # Спецификация формата данных hamori
**Версия:** 2.0 **Версия:** 2.1
**Дата:** 2026-05-16 **Дата:** 2026-05-20
--- ---
@@ -18,7 +18,7 @@
Формат двухуровневый: Формат двухуровневый:
- **Исходный (`.chord`)** — то, что пишется руками. Близок к лид-шиту, человекочитаем, легко правится в любом текстовом редакторе. - **Исходный (`.chord`)** — то, что пишется руками. Близок к лид-шиту, человекочитаем, легко правится в любом текстовом редакторе.
- **Токенизированный** — то, что подаётся в модель. Факторизованное представление с маленьким словарём (~75 токенов). - **Токенизированный** — то, что подаётся в модель. Факторизованное представление с фиксированным словарём (81 токен).
Между уровнями стоит детерминированный парсер. Между уровнями стоит детерминированный парсер.
@@ -33,7 +33,7 @@
# key: D_major # key: D_major
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: H1K0
# function: chorus # function: chorus
| Gmaj7 . . . | A . . . | F#m7 . . . | Bm7 . . . | | Gmaj7 . . . | A . . . | F#m7 . . . | Bm7 . . . |
@@ -54,12 +54,12 @@
### 3.3 Поля шапки ### 3.3 Поля шапки
| Поле | Обязательно | Допустимые значения | Назначение | | Поле | Обязательно | Допустимые значения | Назначение |
| ------------- | ----------- | -------------------------------------------------------------------------------- | -------------------------------------------- | | ------------- | ----------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `title` | да | свободная строка | идентификация периода | | `title` | да | свободная строка | идентификация периода |
| `key` | да | `<note>_major` или `<note>_minor` | для нормализации в C/Am перед обучением | | `key` | да | `<note>_major` или `<note>_minor` | для нормализации в C/Am перед обучением |
| `time` | да | `4/4`, `3/4`, `6/8`, `2/4`, `12/8` | тактовый размер | | `time` | да | `4/4`, `3/4`, `6/8`, `2/4`, `12/8` | тактовый размер |
| `subdivision` | да | `4` или `8` | сколько позиций в одном такте | | `subdivision` | да | `4` или `8` | сколько позиций в одном такте |
| `style` | да | `user`, `jpop`, `classical`, `jazz`, `other` | стилевой тег периода | | `style` | да | любой идентификатор `[A-Za-z][A-Za-z0-9_]*` (например: `H1K0`, `jpop`, `other`) | стилевой тег периода; при токенизации тег должен совпасть с токеном `STYLE_<tag>` в словаре, иначе используется `STYLE_other` |
| `function` | нет | `verse`, `prechorus`, `chorus`, `bridge`, `intro`, `outro`, `interlude`, `other` | функциональная роль периода в исходной пьесе | | `function` | нет | `verse`, `prechorus`, `chorus`, `bridge`, `intro`, `outro`, `interlude`, `other` | функциональная роль периода в исходной пьесе |
**Допустимые ноты для `key`:** `C, C#, D, D#, E, F, F#, G, G#, A, A#, B`. Бемольные написания (`Db`, `Eb`, ...) принимаются и нормализуются к диезной форме. **Допустимые ноты для `key`:** `C, C#, D, D#, E, F, F#, G, G#, A, A#, B`. Бемольные написания (`Db`, `Eb`, ...) принимаются и нормализуются к диезной форме.
@@ -110,10 +110,10 @@
Примеры: Примеры:
- `C` → root=C, quality=maj (по умолчанию), без extension, bass=root - `C` → root=C, quality=maj (по умолчанию), без extension, bass=root
- `Am7` → root=A, quality=min7, без extension, bass=root - `Am7` → root=A, quality=m7, без extension, bass=root
- `Fmaj9` → root=F, quality=maj7, extension=9, bass=root - `Fmaj9` → root=F, quality=maj7, extension=9, bass=root
- `G7/B` → root=G, quality=7, без extension, bass=B - `G7/B` → root=G, quality=7, без extension, bass=B
- `Dm9/F` → root=D, quality=min7, extension=9, bass=F - `Dm9/F` → root=D, quality=m7, extension=9, bass=F
### 4.2 Корневой тон ### 4.2 Корневой тон
@@ -228,7 +228,7 @@ D/F# ← D, бас F♯
- `MODE_major`, `MODE_minor` — 2 токена (наследие тональности; нужны, потому что мажор и минор остаются разделёнными) - `MODE_major`, `MODE_minor` — 2 токена (наследие тональности; нужны, потому что мажор и минор остаются разделёнными)
- `TIME_4/4`, `TIME_3/4`, `TIME_6/8`, `TIME_2/4`, `TIME_12/8` — 5 токенов - `TIME_4/4`, `TIME_3/4`, `TIME_6/8`, `TIME_2/4`, `TIME_12/8` — 5 токенов
- `SUB_4`, `SUB_8` — 2 токена - `SUB_4`, `SUB_8` — 2 токена
- `STYLE_user`, `STYLE_jpop`, `STYLE_classical`, `STYLE_jazz`, `STYLE_other` — 5 токенов - `STYLE_H1K0`, `STYLE_jpop`, `STYLE_classical`, `STYLE_jazz`, `STYLE_other` — 5 токенов стиля (текущий словарь); неизвестный тег → `STYLE_other`
- `FUNC_verse`, `FUNC_prechorus`, `FUNC_chorus`, `FUNC_bridge`, `FUNC_intro`, `FUNC_outro`, `FUNC_interlude`, `FUNC_other`, `FUNC_unspecified` — 9 токенов - `FUNC_verse`, `FUNC_prechorus`, `FUNC_chorus`, `FUNC_bridge`, `FUNC_intro`, `FUNC_outro`, `FUNC_interlude`, `FUNC_other`, `FUNC_unspecified` — 9 токенов
**Аккордовые слоты (новый аккорд = ровно 4 токена в порядке root, quality, extension, bass):** **Аккордовые слоты (новый аккорд = ровно 4 токена в порядке root, quality, extension, bass):**
@@ -274,7 +274,7 @@ BAR
1. Прочитать шапку. 1. Прочитать шапку.
2. Транспонировать все аккорды: если `key = X_major`, транспонировать так, чтобы X стало C; если `key = X_minor` — так, чтобы X стало A. 2. Транспонировать все аккорды: если `key = X_major`, транспонировать так, чтобы X стало C; если `key = X_minor` — так, чтобы X стало A.
3. Выпустить `<BOS>`. 3. Выпустить `<BOS>`.
4. Выпустить метатокены: `MODE_<major|minor>`, `TIME_<x>`, `SUB_<x>`, `STYLE_<x>`, `FUNC_<x>` (если `function` не задан — выпустить `FUNC_unspecified`). 4. Выпустить метатокены: `MODE_<major|minor>`, `TIME_<x>`, `SUB_<x>`, `STYLE_<x>`, `FUNC_<x>` (если `function` не задан — выпустить `FUNC_unspecified`; если `STYLE_<style>` отсутствует в словаре — выпустить `STYLE_other` с предупреждением в лог).
5. Для каждого такта: 5. Для каждого такта:
- Для каждой позиции в такте: - Для каждой позиции в такте:
- Если новый аккорд: разобрать на (root, quality, extension, bass), выпустить 4 токена в этом порядке. - Если новый аккорд: разобрать на (root, quality, extension, bass), выпустить 4 токена в этом порядке.
@@ -304,6 +304,18 @@ BAR
Длинный период (16 тактов с частой сменой): редко превышает 250 токенов. Контекстное окно 512 токенов более чем достаточно. Длинный период (16 тактов с частой сменой): редко превышает 250 токенов. Контекстное окно 512 токенов более чем достаточно.
### 5.7 Добавление нового стилевого тега
Поле `style` в `.chord` файле принимает любой идентификатор — дополнительный код менять не нужно. Однако, чтобы модель кондиционировалась на новый стиль отдельным токеном (а не сваливала его в `STYLE_other`), нужно зарегистрировать токен в словаре:
1. Открыть `src/tokenizer.py`, найти блок `# Style (5)` в списке `VOCAB`.
2. Добавить `"STYLE_<tag>"` в этот блок. Пример: `"STYLE_ALICE"`.
3. Обновить счётчик в §5.2 этой спецификации и в итоговой строке («**N токенов**»).
4. **Переобучить модель с нуля** — изменение размера словаря меняет размер матрицы эмбеддингов и выходного слоя; дообучить существующий чекпоинт не получится.
5. Обновить все `.chord` файлы нужного корпуса, выставив новый тег в поле `style`.
Если токен не зарегистрирован, парсер принимает файл без ошибок, но токенизатор выдаёт `STYLE_other` и пишет предупреждение в лог. Это удобно для экспериментов до переобучения.
--- ---
## 6. Полный пример ## 6. Полный пример
@@ -315,10 +327,10 @@ BAR
# key: G_major # key: G_major
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: H1K0
# function: chorus # function: chorus
| Gmaj7 . . . | Bm7 . . . | Em7 . . . | C . . D . | | Gmaj7 . . . | Bm7 . . . | Em7 . . . | C . D . |
| Cmaj7 . . . | G/B . . . | Am7 . D . | G . . . | | Cmaj7 . . . | G/B . . . | Am7 . D . | G . . . |
``` ```
@@ -327,7 +339,7 @@ BAR
Шаг 1: транспонировать из G major в C major (вниз на 7 полутонов или вверх на 5): Шаг 1: транспонировать из G major в C major (вниз на 7 полутонов или вверх на 5):
``` ```
| Cmaj7 . . . | Em7 . . . | Am7 . . . | F . . G . | | Cmaj7 . . . | Em7 . . . | Am7 . . . | F . G . |
| Fmaj7 . . . | C/E . . . | Dm7 . G . | C . . . | | Fmaj7 . . . | C/E . . . | Dm7 . G . | C . . . |
``` ```
@@ -335,7 +347,7 @@ BAR
``` ```
<BOS> <BOS>
MODE_major TIME_4/4 SUB_4 STYLE_user FUNC_chorus MODE_major TIME_4/4 SUB_4 STYLE_H1K0 FUNC_chorus
ROOT_C QUAL_maj7 EXT_none BASS_root ROOT_C QUAL_maj7 EXT_none BASS_root
HOLD HOLD HOLD HOLD HOLD HOLD
@@ -468,7 +480,12 @@ YYYY_NNN_<short-title>_<function>.chord
## 11. История изменений ## 11. История изменений
- **v2.0** (текущая) - **v2.1** (текущая)
- Поле `style` теперь принимает любой идентификатор `[A-Za-z][A-Za-z0-9_]*`, а не фиксированный список.
- `STYLE_user` заменён на `STYLE_H1K0` (авторский тег) в словаре модели.
- Неизвестный стиль при токенизации маппируется на `STYLE_other` с предупреждением в лог.
- **v2.0**
- Единицей датасета стал **гармонический период**, не пьеса целиком. - Единицей датасета стал **гармонический период**, не пьеса целиком.
- Введена **нормализация тональности** в C major / A minor. Поле `key` сохраняется в шапке для парсера, но в словаре модели нет — есть только `MODE_major` / `MODE_minor`. - Введена **нормализация тональности** в C major / A minor. Поле `key` сохраняется в шапке для парсера, но в словаре модели нет — есть только `MODE_major` / `MODE_minor`.
- Введён тег `function` (необязательный) как метаданные периода. - Введён тег `function` (необязательный) как метаданные периода.
+12 -7
View File
@@ -17,6 +17,7 @@ See docs/chord_format_spec.md §5.2 for the vocabulary specification.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
@@ -67,9 +68,6 @@ _FLAT_TO_SHARP: dict[str, str] = {
} }
_VALID_TIMES: frozenset[str] = frozenset({"4/4", "3/4", "6/8", "2/4", "12/8"}) _VALID_TIMES: frozenset[str] = frozenset({"4/4", "3/4", "6/8", "2/4", "12/8"})
_VALID_STYLES: frozenset[str] = frozenset(
{"user", "jpop", "classical", "jazz", "other"}
)
_VALID_FUNCTIONS: frozenset[str] = frozenset({ _VALID_FUNCTIONS: frozenset[str] = frozenset({
"verse", "prechorus", "chorus", "bridge", "verse", "prechorus", "chorus", "bridge",
"intro", "outro", "interlude", "other", "intro", "outro", "interlude", "other",
@@ -90,7 +88,7 @@ VOCAB: list[str] = [
# Subdivision (2) # Subdivision (2)
"SUB_4", "SUB_8", "SUB_4", "SUB_8",
# Style (5) # Style (5)
"STYLE_user", "STYLE_jpop", "STYLE_classical", "STYLE_jazz", "STYLE_other", "STYLE_H1K0", "STYLE_jpop", "STYLE_classical", "STYLE_jazz", "STYLE_other",
# Function (9) # Function (9)
"FUNC_verse", "FUNC_prechorus", "FUNC_chorus", "FUNC_bridge", "FUNC_verse", "FUNC_prechorus", "FUNC_chorus", "FUNC_bridge",
"FUNC_intro", "FUNC_outro", "FUNC_interlude", "FUNC_other", "FUNC_unspecified", "FUNC_intro", "FUNC_outro", "FUNC_interlude", "FUNC_other", "FUNC_unspecified",
@@ -239,8 +237,11 @@ def parse_chord_file(path: Path) -> ChordPeriod:
) )
style = header["style"] style = header["style"]
if style not in _VALID_STYLES: if not re.match(r'^[A-Za-z][A-Za-z0-9_]*$', style):
raise ChordFormatError(f"{fname}: invalid style '{style}'") raise ChordFormatError(
f"{fname}: invalid style '{style}' — must be a non-empty identifier"
" ([A-Za-z][A-Za-z0-9_]*)"
)
raw_function = header.get("function", "") raw_function = header.get("function", "")
if raw_function and raw_function not in _VALID_FUNCTIONS: if raw_function and raw_function not in _VALID_FUNCTIONS:
@@ -355,7 +356,11 @@ def tokenize_period(period: ChordPeriod) -> list[int]:
ids.append(TOKEN_TO_ID[f"MODE_{mode}"]) ids.append(TOKEN_TO_ID[f"MODE_{mode}"])
ids.append(TOKEN_TO_ID[f"TIME_{p.time}"]) ids.append(TOKEN_TO_ID[f"TIME_{p.time}"])
ids.append(TOKEN_TO_ID[f"SUB_{p.subdivision}"]) ids.append(TOKEN_TO_ID[f"SUB_{p.subdivision}"])
ids.append(TOKEN_TO_ID[f"STYLE_{p.style}"]) style_token = f"STYLE_{p.style}"
if style_token not in TOKEN_TO_ID:
log.warning("unknown style %r — mapping to STYLE_other", p.style)
style_token = "STYLE_other"
ids.append(TOKEN_TO_ID[style_token])
ids.append(TOKEN_TO_ID[f"FUNC_{p.function}"]) ids.append(TOKEN_TO_ID[f"FUNC_{p.function}"])
for bar in p.bars: for bar in p.bars:
+1 -1
View File
@@ -2,6 +2,6 @@
# key: C_major # key: C_major
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: other
| C . . . . | G . . . | | C . . . . | G . . . |
+1 -1
View File
@@ -2,6 +2,6 @@
# key: C_major # key: C_major
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: other
| C . . . | Xyz . . . | | C . . . | Xyz . . . |
+1 -1
View File
@@ -2,6 +2,6 @@
# key: C_major # key: C_major
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: other
| C . . . | Am7 . . . | F . . . | G7 . . . | | C . . . | Am7 . . . | F . . . | G7 . . . |
+1 -1
View File
@@ -2,6 +2,6 @@
# key: B_minor # key: B_minor
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: other
| Bm . . . | C#m7b5 . . . | D . . . | F#7 . . . | | Bm . . . | C#m7b5 . . . | D . . . | F#7 . . . |
+1 -1
View File
@@ -2,7 +2,7 @@
# key: C_major # key: C_major
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: other
# function: chorus # function: chorus
| C . . . | Am7 . . . | F/A . . . | G7 . . . | // first half | C . . . | Am7 . . . | F/A . . . | G7 . . . | // first half
+1 -1
View File
@@ -2,6 +2,6 @@
# key: F#_major # key: F#_major
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: other
| F#maj7 . . . | D#m7 . . . | Bmaj7 . F#/A# . | C# . . . | | F#maj7 . . . | D#m7 . . . | Bmaj7 . F#/A# . | C# . . . |
+1 -1
View File
@@ -2,6 +2,6 @@
# key: G#_minor # key: G#_minor
# time: 4/4 # time: 4/4
# subdivision: 4 # subdivision: 4
# style: user # style: other
| G#m . . . | A#maj7 . . . | Bmaj7/F# . . . | D#7 . . . | | G#m . . . | A#maj7 . . . | Bmaj7/F# . . . | D#7 . . . |
+1 -1
View File
@@ -40,7 +40,7 @@ class TestParseChordFile:
assert p.key == "C_major" assert p.key == "C_major"
assert p.time == "4/4" assert p.time == "4/4"
assert p.subdivision == 4 assert p.subdivision == 4
assert p.style == "user" assert p.style == "other"
assert p.function == "chorus" assert p.function == "chorus"
def test_c_major_bar_count(self): def test_c_major_bar_count(self):
+1 -1
View File
@@ -21,7 +21,7 @@ def _write_pt(tmp_path: Path, stem: str, n_tokens: int) -> Path:
"""Write a dummy .pt file with sequential token IDs.""" """Write a dummy .pt file with sequential token IDs."""
tokens = torch.arange(n_tokens, dtype=torch.long) tokens = torch.arange(n_tokens, dtype=torch.long)
path = tmp_path / f"{stem}.pt" path = tmp_path / f"{stem}.pt"
torch.save({"tokens": tokens, "meta": {"style": "user", "function": "verse"}}, path) torch.save({"tokens": tokens, "meta": {"style": "other", "function": "verse"}}, path)
return path return path
+1 -1
View File
@@ -109,7 +109,7 @@ class TestTokenizeStructure:
assert toks[1] == "MODE_major" assert toks[1] == "MODE_major"
assert toks[2] == "TIME_4/4" assert toks[2] == "TIME_4/4"
assert toks[3] == "SUB_4" assert toks[3] == "SUB_4"
assert toks[4] == "STYLE_user" assert toks[4] == "STYLE_other" # 'unspecified' is not in VOCAB → falls back to STYLE_other
assert toks[5] == "FUNC_chorus" assert toks[5] == "FUNC_chorus"
def test_bar_token_count_matches_bar_count(self): def test_bar_token_count_matches_bar_count(self):