gabriel / muse public
harmony.py python
182 lines 6.3 KB
6acddccb fix: restore structured --help output for all CLI commands Gabriel Cardona <gabriel@tellurstori.com> 1d 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 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}%)")