diff --git a/docs/chord_format_spec.md b/docs/chord_format_spec.md index 98e3ff1..798a5ee 100644 --- a/docs/chord_format_spec.md +++ b/docs/chord_format_spec.md @@ -1,6 +1,6 @@ # Спецификация формата данных hamori -**Версия:** 2.1 +**Версия:** 2.2 **Дата:** 2026-05-20 --- @@ -18,7 +18,7 @@ Формат двухуровневый: - **Исходный (`.chord`)** — то, что пишется руками. Близок к лид-шиту, человекочитаем, легко правится в любом текстовом редакторе. -- **Токенизированный** — то, что подаётся в модель. Факторизованное представление с фиксированным словарём (81 токен). +- **Токенизированный** — то, что подаётся в модель. Факторизованное представление с фиксированным словарём (85 токенов). Между уровнями стоит детерминированный парсер. @@ -57,7 +57,7 @@ | ------------- | ----------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `title` | да | свободная строка | идентификация периода | | `key` | да | `_major` или `_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` | сколько позиций в одном такте | | `style` | да | любой идентификатор `[A-Za-z][A-Za-z0-9_]*` (например: `H1K0`, `jpop`, `other`) | стилевой тег периода; при токенизации тег должен совпасть с токеном `STYLE_` в словаре, иначе используется `STYLE_other` | | `function` | нет | `verse`, `prechorus`, `chorus`, `bridge`, `intro`, `outro`, `interlude`, `other` | функциональная роль периода в исходной пьесе | @@ -226,7 +226,7 @@ D/F# ← D, бас F♯ **Метаданные периода (выпускаются один раз в начале последовательности, после ``):** - `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 токена - `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 токенов @@ -244,7 +244,7 @@ D/F# ← D, бас F♯ - `NC` — пауза в гармонии - `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 Структура обучающей последовательности @@ -480,7 +480,11 @@ YYYY_NNN__.chord ## 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_user` заменён на `STYLE_H1K0` (авторский тег) в словаре модели. - Неизвестный стиль при токенизации маппируется на `STYLE_other` с предупреждением в лог. diff --git a/src/external_converters/mcgill_to_chord.py b/src/external_converters/mcgill_to_chord.py index 4be362c..d2007fc 100644 --- a/src/external_converters/mcgill_to_chord.py +++ b/src/external_converters/mcgill_to_chord.py @@ -125,7 +125,10 @@ _FUNCTION_MAP: dict[str, str] = { "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( {"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.""" m = metre.strip() 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 try: mapping = {4: ("4/4", 4), 3: ("3/4", 4), 2: ("2/4", 4)} diff --git a/src/tokenizer.py b/src/tokenizer.py index e410ccf..aea6c2e 100644 --- a/src/tokenizer.py +++ b/src/tokenizer.py @@ -67,7 +67,10 @@ _FLAT_TO_SHARP: dict[str, str] = { "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({ "verse", "prechorus", "chorus", "bridge", "intro", "outro", "interlude", "other", @@ -83,8 +86,9 @@ VOCAB: list[str] = [ "", "", "", "", # Mode (2) "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_5/4", "TIME_7/4", "TIME_7/8", "TIME_9/8", # Subdivision (2) "SUB_4", "SUB_8", # Style (5) diff --git a/tests/test_mcgill_converter.py b/tests/test_mcgill_converter.py index e5e661e..f245694 100644 --- a/tests/test_mcgill_converter.py +++ b/tests/test_mcgill_converter.py @@ -276,11 +276,23 @@ class TestParseMetre: def test_6_8(self): 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): assert _parse_metre("4") == ("4/4", 4) def test_unsupported(self): - sig, sub = _parse_metre("7/8") + sig, sub = _parse_metre("11/8") assert sig is None assert sub == 0 diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py index 1b67a0b..9972622 100644 --- a/tests/test_tokenizer.py +++ b/tests/test_tokenizer.py @@ -39,17 +39,17 @@ VALID_FIXTURES = [ class TestVocabulary: - def test_vocab_has_81_tokens(self): - assert len(VOCAB) == 81 + def test_vocab_has_85_tokens(self): + assert len(VOCAB) == 85 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): - assert len(TOKEN_TO_ID) == 81 + assert len(TOKEN_TO_ID) == 85 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): for i, tok in enumerate(VOCAB): @@ -130,7 +130,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 < 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): # F# major: first chord F#maj7 → Cmaj7 after shift=6; ROOT_C is at index 6.