diff --git a/src/midi_export.py b/src/midi_export.py new file mode 100644 index 0000000..6e6e98b --- /dev/null +++ b/src/midi_export.py @@ -0,0 +1,211 @@ +"""MIDI export for .chord files. + +Converts a .chord period to a two-track MIDI file without transposing to +canonical key — the user's original key is preserved. + + Track 0 "Chords" — chord voicings, root anchored at octave 4 (MIDI 60–71). + Track 1 "Bass" — bass note (or root when no slash), octave 2 (MIDI 36–47). + +Public API: + chord_file_to_midi(chord_path, midi_path, tempo=90) + +CLI: + python -m src.midi_export input.chord output.mid [--tempo BPM] + +Example: + python -m src.midi_export data/raw_user/2024_001_sea-glass_chorus.chord \\ + out/sea-glass-chorus.mid --tempo 96 +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +import pretty_midi + +from src.chord_parser import parse_chord_symbol +from src.tokenizer import parse_chord_file + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Music-theory tables +# --------------------------------------------------------------------------- + +_NOTE_SEMITONES: dict[str, int] = { + "C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5, + "F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11, +} + +# Semitone intervals above the root for each of the 18 canonical qualities. +_QUALITY_INTERVALS: dict[str, list[int]] = { + "maj": [0, 4, 7], + "m": [0, 3, 7], + "dim": [0, 3, 6], + "aug": [0, 4, 8], + "sus2": [0, 2, 7], + "sus4": [0, 5, 7], + "maj7": [0, 4, 7, 11], + "m7": [0, 3, 7, 10], + "7": [0, 4, 7, 10], + "m7b5": [0, 3, 6, 10], + "dim7": [0, 3, 6, 9], + "mM7": [0, 3, 7, 11], + "7sus4": [0, 5, 7, 10], + "aug7": [0, 4, 8, 10], + "6": [0, 4, 7, 9], + "m6": [0, 3, 7, 9], + "add9": [0, 4, 7, 14], + "m(add9)": [0, 3, 7, 14], +} + +# Semitones above root for each extension type (added as a fifth voice). +_EXTENSION_SEMITONES: dict[str, int] = { + "9": 14, + "b9": 13, + "#9": 15, + "11": 17, + "#11": 18, + "13": 21, + "b13": 20, +} + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _position_duration(time: str, subdivision: int, tempo: int) -> float: + """Return the duration in seconds of one subdivision position.""" + num, denom = (int(x) for x in time.split("/")) + beats_per_bar = num * 4.0 / denom # quarter-note beats per bar + bar_duration = beats_per_bar * 60.0 / tempo + positions_per_bar = (num * subdivision) // denom + return bar_duration / positions_per_bar + + +def _emit_chord_notes( + symbol: str | None, + start: float, + hold: int, + pos_dur: float, + chord_inst: pretty_midi.Instrument, + bass_inst: pretty_midi.Instrument, +) -> None: + """Append MIDI notes for one held-chord segment to both instruments.""" + if symbol is None or hold == 0: + return + t = parse_chord_symbol(symbol) + end = start + hold * pos_dur + + # Chord track: root in octave 4, intervals laid above. + root_base = 60 + _NOTE_SEMITONES[t.root] + for interval in _QUALITY_INTERVALS[t.quality]: + chord_inst.notes.append( + pretty_midi.Note(velocity=80, pitch=root_base + interval, start=start, end=end) + ) + if t.extension != "none": + chord_inst.notes.append( + pretty_midi.Note( + velocity=70, + pitch=root_base + _EXTENSION_SEMITONES[t.extension], + start=start, + end=end, + ) + ) + + # Bass track: bass note (or root when no slash) in octave 2. + bass_name = t.root if t.bass == "root" else t.bass + bass_inst.notes.append( + pretty_midi.Note( + velocity=90, + pitch=36 + _NOTE_SEMITONES[bass_name], + start=start, + end=end, + ) + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def chord_file_to_midi(chord_path: Path, midi_path: Path, tempo: int = 90) -> None: + """Convert a .chord file to a two-track MIDI file. + + The period is used in its original (user) key without transposing to + canonical. Track 0 contains chord voicings (octave 4–5); track 1 + contains the bass line (octave 2). + + Args: + chord_path: Path to the .chord source file. + midi_path: Destination .mid file (created or overwritten). + tempo: Playback speed in BPM (default 90). + """ + period = parse_chord_file(chord_path) + pos_dur = _position_duration(period.time, period.subdivision, tempo) + + pm = pretty_midi.PrettyMIDI(initial_tempo=float(tempo)) + chord_inst = pretty_midi.Instrument(program=0, name="Chords") + bass_inst = pretty_midi.Instrument(program=32, name="Bass") + + current_chord: str | None = None + current_start = 0.0 + current_hold = 0 + current_time = 0.0 + + for bar in period.bars: + for pos in bar: + if pos == ".": + if current_chord is not None: + current_hold += 1 + elif pos in ("NC", "?"): + _emit_chord_notes( + current_chord, current_start, current_hold, + pos_dur, chord_inst, bass_inst, + ) + current_chord = None + current_hold = 0 + else: + _emit_chord_notes( + current_chord, current_start, current_hold, + pos_dur, chord_inst, bass_inst, + ) + current_chord = pos + current_start = current_time + current_hold = 1 + current_time += pos_dur + + _emit_chord_notes( + current_chord, current_start, current_hold, + pos_dur, chord_inst, bass_inst, + ) + + pm.instruments.extend([chord_inst, bass_inst]) + pm.write(str(midi_path)) + log.info("wrote %s (%d bars, %d BPM)", midi_path.name, len(period.bars), tempo) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Convert a .chord file to MIDI.", + epilog="Example: python -m src.midi_export input.chord output.mid --tempo 120", + ) + parser.add_argument("chord_file", type=Path, metavar="input.chord") + parser.add_argument("midi_file", type=Path, metavar="output.mid") + parser.add_argument( + "--tempo", type=int, default=90, metavar="BPM", + help="playback tempo in BPM (default: 90)", + ) + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(message)s") + chord_file_to_midi(args.chord_file, args.midi_file, tempo=args.tempo) + print(f"Written: {args.midi_file}") diff --git a/tests/fixtures/midi_export_test.chord b/tests/fixtures/midi_export_test.chord new file mode 100644 index 0000000..3a36244 --- /dev/null +++ b/tests/fixtures/midi_export_test.chord @@ -0,0 +1,7 @@ +# title: MIDI export test +# key: C_major +# time: 4/4 +# subdivision: 4 +# style: user + +| C . . . | Am7 . . . | F . . . | G7 . . . | diff --git a/tests/test_midi_export.py b/tests/test_midi_export.py new file mode 100644 index 0000000..fe42046 --- /dev/null +++ b/tests/test_midi_export.py @@ -0,0 +1,115 @@ +"""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() + )