3cd9c29d9f
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>
371 lines
12 KiB
Python
371 lines
12 KiB
Python
"""Tests for src/external_converters/mcgill_to_chord.py.
|
|
|
|
Fixture: tests/fixtures/mcgill_test/0001/salami_chords.txt
|
|
4/4 song in C major, two sections in the real McGill v2 2-column format:
|
|
A, verse : | C:maj | F:maj | G:7 | C:maj | (4 bars)
|
|
B, chorus : | F:maj | C:maj | G:7 | C:maj | (4 bars)
|
|
|
|
Expected output: 2 .chord files, each with 4 bars, key=C_major, time=4/4.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src.external_converters.mcgill_to_chord import (
|
|
_bar_str_to_positions,
|
|
_harte_to_chord_symbol,
|
|
_parse_annotation_line,
|
|
_parse_metre,
|
|
_parse_salami_file,
|
|
convert_song,
|
|
)
|
|
from src.tokenizer import parse_chord_file
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "mcgill_test"
|
|
TEST_SONG = FIXTURES / "0001"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Harte chord symbol conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHarteConversion:
|
|
def test_simple_major(self):
|
|
assert _harte_to_chord_symbol("C:maj") == "Cmaj"
|
|
|
|
def test_flat_minor_seventh(self):
|
|
assert _harte_to_chord_symbol("Bb:min7") == "A#m7"
|
|
|
|
def test_half_diminished(self):
|
|
assert _harte_to_chord_symbol("E:hdim7") == "Em7b5"
|
|
|
|
def test_dominant_seventh(self):
|
|
assert _harte_to_chord_symbol("G:7") == "G7"
|
|
|
|
def test_major_seventh(self):
|
|
assert _harte_to_chord_symbol("D:maj7") == "Dmaj7"
|
|
|
|
def test_minor(self):
|
|
assert _harte_to_chord_symbol("A:min") == "Am"
|
|
|
|
def test_diminished_seventh(self):
|
|
assert _harte_to_chord_symbol("B:dim7") == "Bdim7"
|
|
|
|
def test_augmented(self):
|
|
assert _harte_to_chord_symbol("C:aug") == "Caug"
|
|
|
|
def test_slash_chord_absolute_bass(self):
|
|
assert _harte_to_chord_symbol("C:maj/E") == "Cmaj/E"
|
|
|
|
def test_slash_chord_flat_bass_normalised(self):
|
|
assert _harte_to_chord_symbol("G:maj/Bb") == "Gmaj/A#"
|
|
|
|
def test_slash_chord_interval_fifth(self):
|
|
# '/5' = perfect 5th (7 semitones) above root C → G
|
|
assert _harte_to_chord_symbol("C:maj/5") == "Cmaj/G"
|
|
|
|
def test_slash_chord_interval_b3(self):
|
|
# '/b3' = minor 3rd (3 semitones) above root F → Ab = G#
|
|
assert _harte_to_chord_symbol("F:min/b3") == "Fm/G#"
|
|
|
|
def test_slash_chord_interval_3(self):
|
|
# '/3' = major 3rd (4 semitones) above root C → E
|
|
assert _harte_to_chord_symbol("C:7/3") == "C7/E"
|
|
|
|
def test_no_chord_returns_none(self):
|
|
assert _harte_to_chord_symbol("N") is None
|
|
|
|
def test_unknown_returns_none(self):
|
|
assert _harte_to_chord_symbol("X") is None
|
|
|
|
def test_empty_returns_none(self):
|
|
assert _harte_to_chord_symbol("") is None
|
|
|
|
def test_extended_dominant_ninth(self):
|
|
assert _harte_to_chord_symbol("G:9") == "G79"
|
|
|
|
def test_major_ninth(self):
|
|
assert _harte_to_chord_symbol("C:maj9") == "Cmaj79"
|
|
|
|
def test_parenthetical_flat_nine(self):
|
|
assert _harte_to_chord_symbol("C:7(b9)") == "C7b9"
|
|
|
|
def test_parenthetical_sharp_eleven(self):
|
|
assert _harte_to_chord_symbol("F:maj7(#11)") == "Fmaj7#11"
|
|
|
|
def test_sharp_root(self):
|
|
assert _harte_to_chord_symbol("F#:min7") == "F#m7"
|
|
|
|
def test_output_is_parseable(self):
|
|
from src.chord_parser import parse_chord_symbol
|
|
for harte in ("C:maj", "Bb:min7", "E:hdim7", "G:7", "D:maj7",
|
|
"C:maj/E", "C:maj/5", "F:min/b3"):
|
|
sym = _harte_to_chord_symbol(harte)
|
|
assert sym is not None
|
|
parse_chord_symbol(sym)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Salami file parsing (2-column format)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseSalamiFile:
|
|
def test_header_parsed(self):
|
|
header, _ = _parse_salami_file(TEST_SONG / "salami_chords.txt")
|
|
assert header["artist"] == "Test Artist"
|
|
assert header["title"] == "Test Song"
|
|
assert header["metre"] == "4/4"
|
|
assert header["tonic"] == "C"
|
|
|
|
def test_data_line_count(self):
|
|
_, lines = _parse_salami_file(TEST_SONG / "salami_chords.txt")
|
|
# 4 lines: silence, A/verse, B/chorus, silence
|
|
assert len(lines) == 4
|
|
|
|
def test_first_line_is_silence(self):
|
|
_, lines = _parse_salami_file(TEST_SONG / "salami_chords.txt")
|
|
ts, annotation = lines[0]
|
|
assert ts == 0.0
|
|
assert annotation == "silence"
|
|
|
|
def test_returns_two_tuples(self):
|
|
_, lines = _parse_salami_file(TEST_SONG / "salami_chords.txt")
|
|
for item in lines:
|
|
assert len(item) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Annotation line parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseAnnotationLine:
|
|
def test_silence_returns_empty(self):
|
|
letter, func, bars = _parse_annotation_line("silence")
|
|
assert letter is None and func is None and bars == []
|
|
|
|
def test_end_returns_empty(self):
|
|
letter, func, bars = _parse_annotation_line("end")
|
|
assert letter is None and func is None and bars == []
|
|
|
|
def test_continuation_arrow_returns_empty(self):
|
|
letter, func, bars = _parse_annotation_line("->")
|
|
assert bars == []
|
|
|
|
def test_section_letter_extracted(self):
|
|
letter, _, _ = _parse_annotation_line("A, verse, | C:maj | F:maj |")
|
|
assert letter == "A"
|
|
|
|
def test_function_extracted(self):
|
|
_, func, _ = _parse_annotation_line("A, verse, | C:maj | F:maj |")
|
|
assert func == "verse"
|
|
|
|
def test_chorus_function(self):
|
|
_, func, _ = _parse_annotation_line("B, chorus, | F:maj | C:maj |")
|
|
assert func == "chorus"
|
|
|
|
def test_bar_count(self):
|
|
_, _, bars = _parse_annotation_line(
|
|
"A, verse, | C:maj | F:maj | G:7 | C:maj |"
|
|
)
|
|
assert len(bars) == 4
|
|
|
|
def test_bar_contents(self):
|
|
_, _, bars = _parse_annotation_line(
|
|
"A, verse, | C:maj | F:maj | G:7 | C:maj |"
|
|
)
|
|
assert bars == ["C:maj", "F:maj", "G:7", "C:maj"]
|
|
|
|
def test_continuation_line_no_letter(self):
|
|
letter, func, bars = _parse_annotation_line("| C:maj | F:maj |")
|
|
assert letter is None
|
|
assert func is None
|
|
assert bars == ["C:maj", "F:maj"]
|
|
|
|
def test_repeat_xN(self):
|
|
_, _, bars = _parse_annotation_line("| C:maj | x4")
|
|
assert bars == ["C:maj"] * 4
|
|
|
|
def test_trailing_annotation_ignored(self):
|
|
_, _, bars = _parse_annotation_line(
|
|
"A, intro, | Ab:maj | Db:maj | Ab:maj | G:7 |, (synth)"
|
|
)
|
|
assert len(bars) == 4
|
|
assert bars[0] == "Ab:maj"
|
|
|
|
def test_multi_chord_bar_preserved(self):
|
|
_, _, bars = _parse_annotation_line("| G:hdim7 C:7 | F:min |")
|
|
assert bars[0] == "G:hdim7 C:7"
|
|
assert bars[1] == "F:min"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bar string to positions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBarStrToPositions:
|
|
def test_single_chord_fills_position_zero(self):
|
|
pos = _bar_str_to_positions("C:maj", 4)
|
|
assert pos[0] == "Cmaj"
|
|
|
|
def test_single_chord_rest_are_holds(self):
|
|
pos = _bar_str_to_positions("C:maj", 4)
|
|
assert pos[1:] == [".", ".", "."]
|
|
|
|
def test_two_chords_distributed(self):
|
|
pos = _bar_str_to_positions("C:maj D:min", 4)
|
|
assert pos[0] == "Cmaj"
|
|
assert pos[2] == "Dm"
|
|
assert pos[1] == "."
|
|
assert pos[3] == "."
|
|
|
|
def test_four_chords_direct_map(self):
|
|
# Harte notation: 4 elements → 4 positions, direct 1-to-1 mapping
|
|
pos = _bar_str_to_positions("C:maj A:min F:maj G:7", 4)
|
|
assert pos == ["Cmaj", "Am", "Fmaj", "G7"]
|
|
|
|
def test_explicit_hold_tokens(self):
|
|
pos = _bar_str_to_positions("C:maj . F:maj .", 4)
|
|
assert pos == ["Cmaj", ".", "Fmaj", "."]
|
|
|
|
def test_nc_mapped(self):
|
|
pos = _bar_str_to_positions("N", 4)
|
|
assert pos[0] == "NC"
|
|
|
|
def test_unknown_mapped(self):
|
|
pos = _bar_str_to_positions("X", 4)
|
|
assert pos[0] == "?"
|
|
|
|
def test_unrecognized_returns_none(self):
|
|
# Starts with a note letter so passes filter, but quality is unknown
|
|
assert _bar_str_to_positions("C:xyz", 4) is None
|
|
|
|
def test_performance_annotation_filtered(self):
|
|
# "(voice" is not a chord — should be ignored
|
|
pos = _bar_str_to_positions("C:maj (voice", 4)
|
|
assert pos is not None
|
|
assert pos[0] == "Cmaj"
|
|
|
|
def test_result_length(self):
|
|
for n in (3, 4, 6):
|
|
pos = _bar_str_to_positions("C:maj", n)
|
|
assert len(pos) == n
|
|
|
|
def test_interval_bass_resolved(self):
|
|
# C:maj/5 → Cmaj/G
|
|
pos = _bar_str_to_positions("C:maj/5", 4)
|
|
assert pos[0] == "Cmaj/G"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Metre parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseMetre:
|
|
def test_4_4(self):
|
|
assert _parse_metre("4/4") == ("4/4", 4)
|
|
|
|
def test_3_4(self):
|
|
assert _parse_metre("3/4") == ("3/4", 4)
|
|
|
|
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("11/8")
|
|
assert sig is None
|
|
assert sub == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full period conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFullConversion:
|
|
def test_returns_two_periods(self, tmp_path):
|
|
assert convert_song(TEST_SONG, tmp_path) == 2
|
|
|
|
def test_output_files_exist(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
assert len(list(tmp_path.glob("*.chord"))) == 2
|
|
|
|
def test_output_files_are_parseable(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
for f in tmp_path.glob("*.chord"):
|
|
assert parse_chord_file(f) is not None
|
|
|
|
def test_verse_has_four_bars(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
verse_files = sorted(tmp_path.glob("*verse*.chord"))
|
|
assert len(verse_files) == 1
|
|
assert len(parse_chord_file(verse_files[0]).bars) == 4
|
|
|
|
def test_chorus_has_four_bars(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
chorus_files = sorted(tmp_path.glob("*chorus*.chord"))
|
|
assert len(chorus_files) == 1
|
|
assert len(parse_chord_file(chorus_files[0]).bars) == 4
|
|
|
|
def test_header_time_and_subdivision(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
for f in tmp_path.glob("*.chord"):
|
|
p = parse_chord_file(f)
|
|
assert p.time == "4/4"
|
|
assert p.subdivision == 4
|
|
|
|
def test_style_is_other(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
for f in tmp_path.glob("*.chord"):
|
|
assert parse_chord_file(f).style == "other"
|
|
|
|
def test_key_is_c_major(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
for f in tmp_path.glob("*.chord"):
|
|
assert parse_chord_file(f).key == "C_major"
|
|
|
|
def test_function_tags(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
funcs = {parse_chord_file(f).function for f in tmp_path.glob("*.chord")}
|
|
assert funcs == {"verse", "chorus"}
|
|
|
|
def test_filenames_contain_song_id(self, tmp_path):
|
|
convert_song(TEST_SONG, tmp_path)
|
|
names = {f.name for f in tmp_path.glob("*.chord")}
|
|
assert all("0001" in name for name in names)
|
|
|
|
def test_bar_positions_are_valid_chords(self, tmp_path):
|
|
from src.chord_parser import parse_chord_symbol
|
|
convert_song(TEST_SONG, tmp_path)
|
|
for f in tmp_path.glob("*.chord"):
|
|
p = parse_chord_file(f)
|
|
for bar in p.bars:
|
|
first = bar[0]
|
|
if first not in (".", "NC", "?"):
|
|
parse_chord_symbol(first)
|
|
|
|
def test_missing_salami_returns_zero(self, tmp_path):
|
|
empty_song = tmp_path / "empty"
|
|
empty_song.mkdir()
|
|
assert convert_song(empty_song, tmp_path / "out") == 0
|