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:
2026-05-19 15:56:44 +03:00
parent 868af4ac42
commit 54be1be9ce
3 changed files with 333 additions and 0 deletions
+211
View File
@@ -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 6071).
Track 1 "Bass" — bass note (or root when no slash), octave 2 (MIDI 3647).
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 45); 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}")