harmony.py
python
| 1 | """muse harmony — chord analysis and key detection for a MIDI track. |
| 2 | |
| 3 | Analyses the harmonic content of a MIDI file — detects implied chords per |
| 4 | bar, estimates the key signature, and reports pitch-class distribution. |
| 5 | |
| 6 | Usage:: |
| 7 | |
| 8 | muse harmony tracks/melody.mid |
| 9 | muse harmony tracks/chords.mid --commit HEAD~5 |
| 10 | muse harmony tracks/piano.mid --json |
| 11 | |
| 12 | Output:: |
| 13 | |
| 14 | Harmonic analysis: tracks/melody.mid — commit cb4afaed |
| 15 | Key signature (estimated): G major |
| 16 | Total notes: 48 · Bars: 16 |
| 17 | |
| 18 | Bar Chord Notes Pitch classes |
| 19 | ──────────────────────────────────────────────────────── |
| 20 | 1 Gmaj 4 G, B, D |
| 21 | 2 Cmaj 4 C, E, G |
| 22 | 3 Amin 3 A, C, E |
| 23 | 4 D7 5 D, F#, A, C |
| 24 | ... |
| 25 | |
| 26 | Pitch class distribution: |
| 27 | G ████████████ 12 (25.0%) |
| 28 | B ██████ 6 (12.5%) |
| 29 | D ████████ 8 (16.7%) |
| 30 | ... |
| 31 | """ |
| 32 | |
| 33 | from __future__ import annotations |
| 34 | |
| 35 | import argparse |
| 36 | import json |
| 37 | import logging |
| 38 | import pathlib |
| 39 | import sys |
| 40 | from collections import Counter |
| 41 | |
| 42 | from muse.core.errors import ExitCode |
| 43 | from muse.core.repo import require_repo |
| 44 | from muse.core.store import read_current_branch, resolve_commit_ref |
| 45 | from muse.plugins.midi._query import ( |
| 46 | NoteInfo, |
| 47 | _PITCH_CLASSES, |
| 48 | detect_chord, |
| 49 | key_signature_guess, |
| 50 | load_track, |
| 51 | load_track_from_workdir, |
| 52 | notes_by_bar, |
| 53 | ) |
| 54 | |
| 55 | logger = logging.getLogger(__name__) |
| 56 | |
| 57 | |
| 58 | def _read_repo_id(root: pathlib.Path) -> str: |
| 59 | return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]) |
| 60 | |
| 61 | |
| 62 | def _read_branch(root: pathlib.Path) -> str: |
| 63 | return read_current_branch(root) |
| 64 | |
| 65 | |
| 66 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 67 | """Register the harmony subcommand.""" |
| 68 | parser = subparsers.add_parser("harmony", help="Detect chords and key signature from a MIDI track's note content.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 69 | parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.") |
| 70 | parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.") |
| 71 | parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.") |
| 72 | parser.set_defaults(func=run) |
| 73 | |
| 74 | |
| 75 | def run(args: argparse.Namespace) -> None: |
| 76 | """Detect chords and key signature from a MIDI track's note content. |
| 77 | |
| 78 | ``muse harmony`` groups notes by bar, detects implied chords using a |
| 79 | template-matching approach, and estimates the overall key signature |
| 80 | using the Krumhansl-Schmuckler algorithm. |
| 81 | |
| 82 | This is fundamentally impossible in Git: Git has no model of what a MIDI |
| 83 | file contains. Muse stores notes as content-addressed semantic data, |
| 84 | enabling musical analysis at any point in history. |
| 85 | |
| 86 | Use ``--commit`` to analyse a historical snapshot. Use ``--json`` for |
| 87 | agent-readable output suitable for further harmonic reasoning. |
| 88 | """ |
| 89 | track: str = args.track |
| 90 | ref: str | None = args.ref |
| 91 | as_json: bool = args.as_json |
| 92 | |
| 93 | root = require_repo() |
| 94 | |
| 95 | result: tuple[list[NoteInfo], int] | None |
| 96 | commit_label = "working tree" |
| 97 | |
| 98 | if ref is not None: |
| 99 | repo_id = _read_repo_id(root) |
| 100 | branch = _read_branch(root) |
| 101 | commit = resolve_commit_ref(root, repo_id, branch, ref) |
| 102 | if commit is None: |
| 103 | print(f"❌ Commit '{ref}' not found.", file=sys.stderr) |
| 104 | raise SystemExit(ExitCode.USER_ERROR) |
| 105 | result = load_track(root, commit.commit_id, track) |
| 106 | commit_label = commit.commit_id[:8] |
| 107 | else: |
| 108 | result = load_track_from_workdir(root, track) |
| 109 | |
| 110 | if result is None: |
| 111 | print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr) |
| 112 | raise SystemExit(ExitCode.USER_ERROR) |
| 113 | |
| 114 | note_list, _tpb = result |
| 115 | if not note_list: |
| 116 | print(f" (no notes found in '{track}')") |
| 117 | return |
| 118 | |
| 119 | key = key_signature_guess(note_list) |
| 120 | bars = notes_by_bar(note_list) |
| 121 | |
| 122 | # Pitch class distribution. |
| 123 | pc_counter: Counter[int] = Counter() |
| 124 | for note in note_list: |
| 125 | pc_counter[note.pitch_class] += 1 |
| 126 | |
| 127 | # Per-bar chord analysis. |
| 128 | bar_chords: list[tuple[int, str, int, list[str]]] = [] |
| 129 | for bar_num in sorted(bars): |
| 130 | bar_notes = bars[bar_num] |
| 131 | pcs = frozenset(n.pitch_class for n in bar_notes) |
| 132 | chord = detect_chord(pcs) |
| 133 | pc_names = sorted(set(_PITCH_CLASSES[pc] for pc in pcs)) |
| 134 | bar_chords.append((bar_num, chord, len(bar_notes), pc_names)) |
| 135 | |
| 136 | if as_json: |
| 137 | total_notes = len(note_list) |
| 138 | print(json.dumps( |
| 139 | { |
| 140 | "track": track, |
| 141 | "commit": commit_label, |
| 142 | "key": key, |
| 143 | "total_notes": total_notes, |
| 144 | "bars": [ |
| 145 | { |
| 146 | "bar": bar_num, |
| 147 | "chord": chord_name, |
| 148 | "note_count": n_count, |
| 149 | "pitch_classes": pc_name_list, |
| 150 | } |
| 151 | for bar_num, chord_name, n_count, pc_name_list in bar_chords |
| 152 | ], |
| 153 | "pitch_class_distribution": { |
| 154 | _PITCH_CLASSES[pc]: count |
| 155 | for pc, count in sorted(pc_counter.items()) |
| 156 | }, |
| 157 | }, |
| 158 | indent=2, |
| 159 | )) |
| 160 | return |
| 161 | |
| 162 | print(f"\nHarmonic analysis: {track} — {commit_label}") |
| 163 | print(f"Key signature (estimated): {key}") |
| 164 | print(f"Total notes: {len(note_list)} · Bars: {len(bars)}") |
| 165 | print("") |
| 166 | print(f" {'Bar':>4} {'Chord':<10} {'Notes':>5} Pitch classes") |
| 167 | print(" " + "─" * 54) |
| 168 | |
| 169 | for bar_num, chord_name, n_count, pc_name_list in bar_chords: |
| 170 | pc_str = ", ".join(pc_name_list) |
| 171 | print(f" {bar_num:>4} {chord_name:<10} {n_count:>5} {pc_str}") |
| 172 | |
| 173 | print("\nPitch class distribution:") |
| 174 | total = max(sum(pc_counter.values()), 1) |
| 175 | for pc in range(12): |
| 176 | count = pc_counter.get(pc, 0) |
| 177 | if count == 0: |
| 178 | continue |
| 179 | bar_len = min(int(count / total * 40), 40) |
| 180 | bar_str = "█" * bar_len |
| 181 | pct = count / total * 100 |
| 182 | print(f" {_PITCH_CLASSES[pc]:<3} {bar_str:<40} {count:>3} ({pct:.1f}%)") |