feat: extend time signature support to 9 metres (5/4, 7/4, 7/8, 9/8)

Add 5/4, 7/4, 7/8, 9/8 to _VALID_TIMES and VOCAB (TIME_* tokens).
Vocab size grows from 81 to 85 tokens. _parse_metre in the McGill
converter assigns subdivision=8 to 7/8 and 9/8. Spec bumped to v2.2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 00:37:05 +03:00
parent 4fd8ece170
commit 3cd9c29d9f
5 changed files with 40 additions and 17 deletions
+10 -6
View File
@@ -1,6 +1,6 @@
# Спецификация формата данных hamori # Спецификация формата данных hamori
**Версия:** 2.1 **Версия:** 2.2
**Дата:** 2026-05-20 **Дата:** 2026-05-20
--- ---
@@ -18,7 +18,7 @@
Формат двухуровневый: Формат двухуровневый:
- **Исходный (`.chord`)** — то, что пишется руками. Близок к лид-шиту, человекочитаем, легко правится в любом текстовом редакторе. - **Исходный (`.chord`)** — то, что пишется руками. Близок к лид-шиту, человекочитаем, легко правится в любом текстовом редакторе.
- **Токенизированный** — то, что подаётся в модель. Факторизованное представление с фиксированным словарём (81 токен). - **Токенизированный** — то, что подаётся в модель. Факторизованное представление с фиксированным словарём (85 токенов).
Между уровнями стоит детерминированный парсер. Между уровнями стоит детерминированный парсер.
@@ -57,7 +57,7 @@
| ------------- | ----------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `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`, `5/4`, `7/4`, `7/8`, `9/8` | тактовый размер |
| `subdivision` | да | `4` или `8` | сколько позиций в одном такте | | `subdivision` | да | `4` или `8` | сколько позиций в одном такте |
| `style` | да | любой идентификатор `[A-Za-z][A-Za-z0-9_]*` (например: `H1K0`, `jpop`, `other`) | стилевой тег периода; при токенизации тег должен совпасть с токеном `STYLE_<tag>` в словаре, иначе используется `STYLE_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` | функциональная роль периода в исходной пьесе |
@@ -226,7 +226,7 @@ D/F# ← D, бас F♯
**Метаданные периода (выпускаются один раз в начале последовательности, после `<BOS>`):** **Метаданные периода (выпускаются один раз в начале последовательности, после `<BOS>`):**
- `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`, `TIME_5/4`, `TIME_7/4`, `TIME_7/8`, `TIME_9/8`9 токенов
- `SUB_4`, `SUB_8` — 2 токена - `SUB_4`, `SUB_8` — 2 токена
- `STYLE_H1K0`, `STYLE_jpop`, `STYLE_classical`, `STYLE_jazz`, `STYLE_other` — 5 токенов стиля (текущий словарь); неизвестный тег → `STYLE_other` - `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 токенов
@@ -244,7 +244,7 @@ D/F# ← D, бас F♯
- `NC` — пауза в гармонии - `NC` — пауза в гармонии
- `BAR` — конец такта - `BAR` — конец такта
**Итого:** 4 + 2 + 5 + 2 + 5 + 9 + 12 + 18 + 8 + 13 + 3 = **81 токен**. **Итого:** 4 + 2 + 9 + 2 + 5 + 9 + 12 + 18 + 8 + 13 + 3 = **85 токенов**.
### 5.3 Структура обучающей последовательности ### 5.3 Структура обучающей последовательности
@@ -480,7 +480,11 @@ YYYY_NNN_<short-title>_<function>.chord
## 11. История изменений ## 11. История изменений
- **v2.1** (текущая) - **v2.2** (текущая)
- Добавлены тактовые размеры `5/4`, `7/4`, `7/8`, `9/8` — в допустимые значения поля `time` и в словарь (`TIME_*`).
- Размер словаря: 81 → 85 токенов.
- **v2.1**
- Поле `style` теперь принимает любой идентификатор `[A-Za-z][A-Za-z0-9_]*`, а не фиксированный список. - Поле `style` теперь принимает любой идентификатор `[A-Za-z][A-Za-z0-9_]*`, а не фиксированный список.
- `STYLE_user` заменён на `STYLE_H1K0` (авторский тег) в словаре модели. - `STYLE_user` заменён на `STYLE_H1K0` (авторский тег) в словаре модели.
- Неизвестный стиль при токенизации маппируется на `STYLE_other` с предупреждением в лог. - Неизвестный стиль при токенизации маппируется на `STYLE_other` с предупреждением в лог.
+5 -2
View File
@@ -125,7 +125,10 @@ _FUNCTION_MAP: dict[str, str] = {
"other": "other", "other": "other",
} }
_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",
"5/4", "7/4", "7/8", "9/8",
})
_MAJOR_QUALITIES: frozenset[str] = frozenset( _MAJOR_QUALITIES: frozenset[str] = frozenset(
{"maj", "maj7", "6", "add9", "aug", "sus2", "sus4", "7sus4", "aug7"} {"maj", "maj7", "6", "add9", "aug", "sus2", "sus4", "7sus4", "aug7"}
@@ -436,7 +439,7 @@ def _parse_metre(metre: str) -> tuple[Optional[str], int]:
"""Parse metre string → (time_sig, subdivision). Returns (None, 0) if unsupported.""" """Parse metre string → (time_sig, subdivision). Returns (None, 0) if unsupported."""
m = metre.strip() m = metre.strip()
if m in _VALID_TIMES: if m in _VALID_TIMES:
sub = 8 if m in ("6/8", "12/8") else 4 sub = 8 if m in ("6/8", "12/8", "7/8", "9/8") else 4
return m, sub return m, sub
try: try:
mapping = {4: ("4/4", 4), 3: ("3/4", 4), 2: ("2/4", 4)} mapping = {4: ("4/4", 4), 3: ("3/4", 4), 2: ("2/4", 4)}
+6 -2
View File
@@ -67,7 +67,10 @@ _FLAT_TO_SHARP: dict[str, str] = {
"Gb": "F#", "Ab": "G#", "Bb": "A#", "Gb": "F#", "Ab": "G#", "Bb": "A#",
} }
_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",
"5/4", "7/4", "7/8", "9/8",
})
_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",
@@ -83,8 +86,9 @@ VOCAB: list[str] = [
"<BOS>", "<EOS>", "<PAD>", "<UNK>", "<BOS>", "<EOS>", "<PAD>", "<UNK>",
# Mode (2) # Mode (2)
"MODE_major", "MODE_minor", "MODE_major", "MODE_minor",
# Time signature (5) # Time signature (9)
"TIME_4/4", "TIME_3/4", "TIME_6/8", "TIME_2/4", "TIME_12/8", "TIME_4/4", "TIME_3/4", "TIME_6/8", "TIME_2/4", "TIME_12/8",
"TIME_5/4", "TIME_7/4", "TIME_7/8", "TIME_9/8",
# Subdivision (2) # Subdivision (2)
"SUB_4", "SUB_8", "SUB_4", "SUB_8",
# Style (5) # Style (5)
+13 -1
View File
@@ -276,11 +276,23 @@ class TestParseMetre:
def test_6_8(self): def test_6_8(self):
assert _parse_metre("6/8") == ("6/8", 8) assert _parse_metre("6/8") == ("6/8", 8)
def test_5_4(self):
assert _parse_metre("5/4") == ("5/4", 4)
def test_7_4(self):
assert _parse_metre("7/4") == ("7/4", 4)
def test_7_8(self):
assert _parse_metre("7/8") == ("7/8", 8)
def test_9_8(self):
assert _parse_metre("9/8") == ("9/8", 8)
def test_integer_4(self): def test_integer_4(self):
assert _parse_metre("4") == ("4/4", 4) assert _parse_metre("4") == ("4/4", 4)
def test_unsupported(self): def test_unsupported(self):
sig, sub = _parse_metre("7/8") sig, sub = _parse_metre("11/8")
assert sig is None assert sig is None
assert sub == 0 assert sub == 0
+6 -6
View File
@@ -39,17 +39,17 @@ VALID_FIXTURES = [
class TestVocabulary: class TestVocabulary:
def test_vocab_has_81_tokens(self): def test_vocab_has_85_tokens(self):
assert len(VOCAB) == 81 assert len(VOCAB) == 85
def test_no_duplicate_tokens(self): def test_no_duplicate_tokens(self):
assert len(set(VOCAB)) == 81 assert len(set(VOCAB)) == 85
def test_token_to_id_covers_all_vocab(self): def test_token_to_id_covers_all_vocab(self):
assert len(TOKEN_TO_ID) == 81 assert len(TOKEN_TO_ID) == 85
def test_id_to_token_covers_all_vocab(self): def test_id_to_token_covers_all_vocab(self):
assert len(ID_TO_TOKEN) == 81 assert len(ID_TO_TOKEN) == 85
def test_ids_are_contiguous_from_zero(self): def test_ids_are_contiguous_from_zero(self):
for i, tok in enumerate(VOCAB): for i, tok in enumerate(VOCAB):
@@ -130,7 +130,7 @@ class TestTokenizeStructure:
def test_all_ids_in_vocab_range(self): def test_all_ids_in_vocab_range(self):
for fixture_name in VALID_FIXTURES: for fixture_name in VALID_FIXTURES:
p = parse_chord_file(FIXTURES / fixture_name) p = parse_chord_file(FIXTURES / fixture_name)
assert all(0 <= i < 81 for i in tokenize_period(p)) assert all(0 <= i < 85 for i in tokenize_period(p))
def test_non_canonical_key_transposed_before_encoding(self): 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. # F# major: first chord F#maj7 → Cmaj7 after shift=6; ROOT_C is at index 6.