Files
hamori/tests/test_midi_export.py
T
H1K0 54be1be9ce feat: implement src/midi_export.py — .chord → two-track MIDI
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>
2026-05-19 15:56:44 +03:00

116 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 45 [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()
)