"""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}")