Files
hamori/src/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

212 lines
6.8 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.
"""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}")