feat: implement .chord file parser and canonical transposer; freeze requirements

src/tokenizer.py:
  - parse_chord_file(Path) → ChordPeriod: reads header + bar body, strips //
    comments, validates bar position counts and chord symbols, raises
    ChordFormatError with filename and bar number on any violation.
  - transpose_to_canonical(ChordPeriod) → ChordPeriod: shifts all chord roots
    and bass notes by the semitone offset to C major / A minor; fast-path
    returns the original object when shift == 0.

tests/test_chord_file_parser.py: 39 tests covering parsing of 4 valid fixtures
  (C major, F# major, B minor, G# minor), error messages for 2 invalid
  fixtures, and transposition correctness including slash chord root+bass.

tests/fixtures/: 6 .chord fixture files (4 valid, 2 invalid).

requirements.txt: pinned to current latest stable versions
  (torch 2.12.0, music21 10.1.0, pretty_midi 0.2.11, matplotlib 3.10.9,
  numpy 2.4.6, pandas 3.0.3, pytest 9.0.3); Python >= 3.11 noted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 15:27:57 +03:00
parent dd77de00d0
commit a473499fac
9 changed files with 612 additions and 7 deletions
+272
View File
@@ -0,0 +1,272 @@
"""Tests for parse_chord_file() and transpose_to_canonical() in src/tokenizer.py.
Fixture files live in tests/fixtures/:
valid_c_major.chord — already canonical (C major, 8 bars, has a // comment)
valid_fsharp_major.chord — F# major with a slash chord (F#/A#)
valid_b_minor.chord — B minor
valid_gsharp_minor.chord — G# minor with a slash chord (Bmaj7/F#)
invalid_bar_count.chord — bar 1 has 5 positions instead of 4
invalid_chord_symbol.chord — bar 2 contains the invalid symbol 'Xyz'
"""
from pathlib import Path
import pytest
from src.chord_parser import ChordTokens, parse_chord_symbol
from src.tokenizer import ChordFormatError, ChordPeriod, parse_chord_file, transpose_to_canonical
FIXTURES = Path(__file__).parent / "fixtures"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def fixture(name: str) -> Path:
return FIXTURES / name
# ---------------------------------------------------------------------------
# parse_chord_file — valid files
# ---------------------------------------------------------------------------
class TestParseChordFile:
def test_c_major_header_fields(self):
p = parse_chord_file(fixture("valid_c_major.chord"))
assert p.title == "C major test"
assert p.key == "C_major"
assert p.time == "4/4"
assert p.subdivision == 4
assert p.style == "user"
assert p.function == "chorus"
def test_c_major_bar_count(self):
p = parse_chord_file(fixture("valid_c_major.chord"))
assert len(p.bars) == 8
def test_c_major_bar_positions(self):
p = parse_chord_file(fixture("valid_c_major.chord"))
assert p.bars[0] == ["C", ".", ".", "."]
assert p.bars[2] == ["F/A", ".", ".", "."] # slash chord preserved verbatim
assert p.bars[7] == ["G7", ".", ".", "."]
def test_c_major_comments_stripped(self):
# The first bar line ends with '// first half'; no '//' should bleed into positions.
p = parse_chord_file(fixture("valid_c_major.chord"))
for bar in p.bars:
assert not any("//" in pos for pos in bar)
def test_each_bar_has_expected_position_count(self):
p = parse_chord_file(fixture("valid_c_major.chord"))
for bar in p.bars:
assert len(bar) == 4 # 4/4, subdivision=4
def test_fsharp_major_parses(self):
p = parse_chord_file(fixture("valid_fsharp_major.chord"))
assert p.key == "F#_major"
assert len(p.bars) == 4
assert p.bars[0][0] == "F#maj7"
assert p.bars[2][2] == "F#/A#" # slash chord preserved verbatim
def test_b_minor_parses(self):
p = parse_chord_file(fixture("valid_b_minor.chord"))
assert p.key == "B_minor"
assert p.function == "unspecified" # no function header field
assert p.bars[0][0] == "Bm"
assert p.bars[1][0] == "C#m7b5"
def test_gsharp_minor_parses(self):
p = parse_chord_file(fixture("valid_gsharp_minor.chord"))
assert p.key == "G#_minor"
assert len(p.bars) == 4
assert p.bars[2][0] == "Bmaj7/F#" # slash chord
def test_hold_positions_preserved(self):
p = parse_chord_file(fixture("valid_c_major.chord"))
# Every position after the first in each bar is '.'
for bar in p.bars:
assert bar[1] == "."
assert bar[2] == "."
assert bar[3] == "."
# ---------------------------------------------------------------------------
# parse_chord_file — error cases
# ---------------------------------------------------------------------------
class TestParseChordFileErrors:
def test_invalid_bar_count_raises(self):
with pytest.raises(ChordFormatError):
parse_chord_file(fixture("invalid_bar_count.chord"))
def test_invalid_bar_count_error_mentions_bar_number(self):
with pytest.raises(ChordFormatError) as exc_info:
parse_chord_file(fixture("invalid_bar_count.chord"))
assert "bar 1" in str(exc_info.value)
def test_invalid_bar_count_error_mentions_filename(self):
with pytest.raises(ChordFormatError) as exc_info:
parse_chord_file(fixture("invalid_bar_count.chord"))
assert "invalid_bar_count.chord" in str(exc_info.value)
def test_invalid_chord_symbol_raises(self):
with pytest.raises(ChordFormatError):
parse_chord_file(fixture("invalid_chord_symbol.chord"))
def test_invalid_chord_symbol_error_mentions_bar_number(self):
with pytest.raises(ChordFormatError) as exc_info:
parse_chord_file(fixture("invalid_chord_symbol.chord"))
assert "bar 2" in str(exc_info.value)
def test_invalid_chord_symbol_error_mentions_filename(self):
with pytest.raises(ChordFormatError) as exc_info:
parse_chord_file(fixture("invalid_chord_symbol.chord"))
assert "invalid_chord_symbol.chord" in str(exc_info.value)
# ---------------------------------------------------------------------------
# transpose_to_canonical — F# major → C major (shift = 6)
# ---------------------------------------------------------------------------
class TestTransposeFsharpMajor:
def setup_method(self):
self._period = parse_chord_file(fixture("valid_fsharp_major.chord"))
self._t = transpose_to_canonical(self._period)
def test_key_updated_to_c_major(self):
assert self._t.key == "C_major"
def test_tonic_chord_becomes_c(self):
# F#maj7 (bar 0) → Cmaj7
assert self._t.bars[0][0] == "Cmaj7"
def test_second_degree_chord(self):
# D#m7 (bar 1) → Am7 (D#=3, 3+6=9=A)
assert self._t.bars[1][0] == "Am7"
def test_fourth_degree_chord(self):
# Bmaj7 (bar 2 pos 0) → Fmaj7 (B=11, 11+6=17→5=F)
assert self._t.bars[2][0] == "Fmaj7"
def test_fifth_degree_chord(self):
# C# (bar 3) → G (C#=1, 1+6=7=G)
tokens = parse_chord_symbol(self._t.bars[3][0])
assert tokens.root == "G"
assert tokens.quality == "maj"
def test_slash_chord_root_transposed(self):
# F#/A# (bar 2, pos 2): root F# → C
tokens = parse_chord_symbol(self._t.bars[2][2])
assert tokens.root == "C"
def test_slash_chord_bass_transposed(self):
# F#/A# (bar 2, pos 2): bass A#(=10) → E (10+6=16→4=E)
tokens = parse_chord_symbol(self._t.bars[2][2])
assert tokens.bass == "E"
def test_slash_chord_full_tokens(self):
tokens = parse_chord_symbol(self._t.bars[2][2])
assert tokens == ChordTokens("C", "maj", "none", "E")
def test_hold_positions_unchanged(self):
# Bars 0, 1, 3 are single-chord bars: positions 13 must remain '.'.
# Bar 2 has a chord at position 2 (F#/A# → Cmaj/E) — tested separately.
for bar_idx in (0, 1, 3):
assert all(pos == "." for pos in self._t.bars[bar_idx][1:])
def test_bar_count_preserved(self):
assert len(self._t.bars) == len(self._period.bars)
# ---------------------------------------------------------------------------
# transpose_to_canonical — G# minor → A minor (shift = 1)
# ---------------------------------------------------------------------------
class TestTransposeGsharpMinor:
def setup_method(self):
self._period = parse_chord_file(fixture("valid_gsharp_minor.chord"))
self._t = transpose_to_canonical(self._period)
def test_key_updated_to_a_minor(self):
assert self._t.key == "A_minor"
def test_tonic_becomes_a(self):
# G#m (bar 0) → Am (G#=8, 8+1=9=A)
assert self._t.bars[0][0] == "Am"
def test_second_degree_chord(self):
# A#maj7 (bar 1) → Bmaj7 (A#=10, 10+1=11=B)
assert self._t.bars[1][0] == "Bmaj7"
def test_slash_chord_root_transposed(self):
# Bmaj7/F# (bar 2): root B(=11) → C (11+1=0=C)
tokens = parse_chord_symbol(self._t.bars[2][0])
assert tokens.root == "C"
def test_slash_chord_bass_transposed(self):
# Bmaj7/F# (bar 2): bass F#(=6) → G (6+1=7=G)
tokens = parse_chord_symbol(self._t.bars[2][0])
assert tokens.bass == "G"
def test_slash_chord_full_tokens(self):
tokens = parse_chord_symbol(self._t.bars[2][0])
assert tokens == ChordTokens("C", "maj7", "none", "G")
def test_fourth_bar(self):
# D#7 (bar 3) → E7 (D#=3, 3+1=4=E)
assert self._t.bars[3][0] == "E7"
# ---------------------------------------------------------------------------
# transpose_to_canonical — already canonical (C major)
# ---------------------------------------------------------------------------
class TestTransposeCMajorIdentity:
def test_returns_same_object(self):
# Fast path: shift == 0, original period returned unchanged.
p = parse_chord_file(fixture("valid_c_major.chord"))
t = transpose_to_canonical(p)
assert t is p
def test_key_unchanged(self):
p = parse_chord_file(fixture("valid_c_major.chord"))
assert transpose_to_canonical(p).key == "C_major"
# ---------------------------------------------------------------------------
# transpose_to_canonical — B minor → A minor (shift = 10)
# ---------------------------------------------------------------------------
class TestTransposeBMinor:
def setup_method(self):
self._period = parse_chord_file(fixture("valid_b_minor.chord"))
self._t = transpose_to_canonical(self._period)
def test_key_updated_to_a_minor(self):
assert self._t.key == "A_minor"
def test_tonic_becomes_a(self):
# Bm (B=11): 11+10=21→9=A
assert self._t.bars[0][0] == "Am"
def test_half_diminished_chord(self):
# C#m7b5 (C#=1): 1+10=11=B → Bm7b5
assert self._t.bars[1][0] == "Bm7b5"
def test_major_chord_transposed(self):
# D (D=2): 2+10=12→0=C → Cmaj
tokens = parse_chord_symbol(self._t.bars[2][0])
assert tokens.root == "C"
assert tokens.quality == "maj"
def test_dominant_seventh_transposed(self):
# F#7 (F#=6): 6+10=16→4=E → E7
assert self._t.bars[3][0] == "E7"