54be1be9ce
chord_file_to_midi() parses the period in the user's original key (no transposition), accumulates held-chord segments, then writes two pretty_midi tracks: chords with root anchored at octave 4 (MIDI 60–71 + intervals) and bass at octave 2 (MIDI 36–47). Extension notes are added as a fifth voice at their standard interval above the root. Tempo is parameterised; the CLI wrapper (python -m src.midi_export) supports --tempo BPM. 10 tests cover: file creation, parseability, instrument count and names, chord/bass note counts for a 4-chord C-major fixture (14 chord + 4 bass), octave placement assertions, and tempo affecting total duration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
116 lines
3.9 KiB
Python
116 lines
3.9 KiB
Python
"""Tests for chord_file_to_midi() in src/midi_export.py.
|
||
|
||
Fixture: tests/fixtures/midi_export_test.chord
|
||
4 bars, 4/4 sub=4, C major, no function tag.
|
||
Chords: C Am7 F G7 (each held 4 positions = one whole bar).
|
||
|
||
Expected MIDI structure:
|
||
instruments[0] "Chords": 3+4+3+4 = 14 notes, pitches in octave 4–5 [60, 83]
|
||
instruments[1] "Bass": 4 notes (one per chord event), pitches in octave 2 [36, 47]
|
||
"""
|
||
|
||
from pathlib import Path
|
||
|
||
import pretty_midi
|
||
import pytest
|
||
|
||
from src.midi_export import chord_file_to_midi
|
||
|
||
FIXTURES = Path(__file__).parent / "fixtures"
|
||
TEST_CHORD = FIXTURES / "midi_export_test.chord"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared fixture — export once, parse back, share across test methods
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def exported(tmp_path_factory: pytest.TempPathFactory) -> pretty_midi.PrettyMIDI:
|
||
out = tmp_path_factory.mktemp("midi") / "out.mid"
|
||
chord_file_to_midi(TEST_CHORD, out)
|
||
return pretty_midi.PrettyMIDI(str(out))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Basic output
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestOutputFile:
|
||
def test_file_is_created(self, tmp_path):
|
||
out = tmp_path / "out.mid"
|
||
chord_file_to_midi(TEST_CHORD, out)
|
||
assert out.exists() and out.stat().st_size > 0
|
||
|
||
def test_midi_is_parseable(self, tmp_path):
|
||
out = tmp_path / "out.mid"
|
||
chord_file_to_midi(TEST_CHORD, out)
|
||
pm = pretty_midi.PrettyMIDI(str(out))
|
||
assert pm is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Instrument structure
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestInstruments:
|
||
def test_two_instruments(self, exported):
|
||
assert len(exported.instruments) == 2
|
||
|
||
def test_first_instrument_is_chords(self, exported):
|
||
assert exported.instruments[0].name == "Chords"
|
||
|
||
def test_second_instrument_is_bass(self, exported):
|
||
assert exported.instruments[1].name == "Bass"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Note counts
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestNoteCounts:
|
||
def test_chord_track_note_count(self, exported):
|
||
# C(3) + Am7(4) + F(3) + G7(4) = 14
|
||
assert len(exported.instruments[0].notes) == 14
|
||
|
||
def test_bass_track_note_count(self, exported):
|
||
# one bass note per chord event = 4
|
||
assert len(exported.instruments[1].notes) == 4
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Octave placement
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestOctaves:
|
||
def test_chord_notes_in_middle_octave(self, exported):
|
||
# Root anchored at C4=60; triads/7ths add at most 11 st → max B5=83.
|
||
for note in exported.instruments[0].notes:
|
||
assert 60 <= note.pitch <= 83, f"chord pitch {note.pitch} outside [60, 83]"
|
||
|
||
def test_bass_notes_in_low_octave(self, exported):
|
||
# Bass anchored at C2=36; highest pitch class B2=47.
|
||
for note in exported.instruments[1].notes:
|
||
assert 36 <= note.pitch <= 47, f"bass pitch {note.pitch} outside [36, 47]"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tempo
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestTempo:
|
||
def test_higher_tempo_shortens_duration(self, tmp_path):
|
||
slow = tmp_path / "slow.mid"
|
||
fast = tmp_path / "fast.mid"
|
||
chord_file_to_midi(TEST_CHORD, slow, tempo=90)
|
||
chord_file_to_midi(TEST_CHORD, fast, tempo=180)
|
||
assert (
|
||
pretty_midi.PrettyMIDI(str(slow)).get_end_time()
|
||
> pretty_midi.PrettyMIDI(str(fast)).get_end_time()
|
||
)
|