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