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>
This commit is contained in:
@@ -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}")
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# title: MIDI export test
|
||||||
|
# key: C_major
|
||||||
|
# time: 4/4
|
||||||
|
# subdivision: 4
|
||||||
|
# style: user
|
||||||
|
|
||||||
|
| C . . . | Am7 . . . | F . . . | G7 . . . |
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user