harmony.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 read_repo_id, 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
59 def _read_branch(root: pathlib.Path) -> str:
60 return read_current_branch(root)
61
62
63 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
64 """Register the harmony subcommand."""
65 parser = subparsers.add_parser("harmony", help="Detect chords and key signature from a MIDI track's note content.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
66 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
67 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
68 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
69 parser.set_defaults(func=run)
70
71
72 def run(args: argparse.Namespace) -> None:
73 """Detect chords and key signature from a MIDI track's note content.
74
75 ``muse harmony`` groups notes by bar, detects implied chords using a
76 template-matching approach, and estimates the overall key signature
77 using the Krumhansl-Schmuckler algorithm.
78
79 This is fundamentally impossible in Git: Git has no model of what a MIDI
80 file contains. Muse stores notes as content-addressed semantic data,
81 enabling musical analysis at any point in history.
82
83 Use ``--commit`` to analyse a historical snapshot. Use ``--json`` for
84 agent-readable output suitable for further harmonic reasoning.
85 """
86 track: str = args.track
87 ref: str | None = args.ref
88 as_json: bool = args.as_json
89
90 root = require_repo()
91
92 result: tuple[list[NoteInfo], int] | None
93 commit_label = "working tree"
94
95 if ref is not None:
96 repo_id = read_repo_id(root)
97 branch = _read_branch(root)
98 commit = resolve_commit_ref(root, repo_id, branch, ref)
99 if commit is None:
100 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
101 raise SystemExit(ExitCode.USER_ERROR)
102 result = load_track(root, commit.commit_id, track)
103 commit_label = commit.commit_id[:8]
104 else:
105 result = load_track_from_workdir(root, track)
106
107 if result is None:
108 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
109 raise SystemExit(ExitCode.USER_ERROR)
110
111 note_list, _tpb = result
112 if not note_list:
113 print(f" (no notes found in '{track}')")
114 return
115
116 key = key_signature_guess(note_list)
117 bars = notes_by_bar(note_list)
118
119 # Pitch class distribution.
120 pc_counter: Counter[int] = Counter()
121 for note in note_list:
122 pc_counter[note.pitch_class] += 1
123
124 # Per-bar chord analysis.
125 bar_chords: list[tuple[int, str, int, list[str]]] = []
126 for bar_num in sorted(bars):
127 bar_notes = bars[bar_num]
128 pcs = frozenset(n.pitch_class for n in bar_notes)
129 chord = detect_chord(pcs)
130 pc_names = sorted(set(_PITCH_CLASSES[pc] for pc in pcs))
131 bar_chords.append((bar_num, chord, len(bar_notes), pc_names))
132
133 if as_json:
134 total_notes = len(note_list)
135 print(json.dumps(
136 {
137 "track": track,
138 "commit": commit_label,
139 "key": key,
140 "total_notes": total_notes,
141 "bars": [
142 {
143 "bar": bar_num,
144 "chord": chord_name,
145 "note_count": n_count,
146 "pitch_classes": pc_name_list,
147 }
148 for bar_num, chord_name, n_count, pc_name_list in bar_chords
149 ],
150 "pitch_class_distribution": {
151 _PITCH_CLASSES[pc]: count
152 for pc, count in sorted(pc_counter.items())
153 },
154 },
155 indent=2,
156 ))
157 return
158
159 print(f"\nHarmonic analysis: {track} β€” {commit_label}")
160 print(f"Key signature (estimated): {key}")
161 print(f"Total notes: {len(note_list)} Β· Bars: {len(bars)}")
162 print("")
163 print(f" {'Bar':>4} {'Chord':<10} {'Notes':>5} Pitch classes")
164 print(" " + "─" * 54)
165
166 for bar_num, chord_name, n_count, pc_name_list in bar_chords:
167 pc_str = ", ".join(pc_name_list)
168 print(f" {bar_num:>4} {chord_name:<10} {n_count:>5} {pc_str}")
169
170 print("\nPitch class distribution:")
171 total = max(sum(pc_counter.values()), 1)
172 for pc in range(12):
173 count = pc_counter.get(pc, 0)
174 if count == 0:
175 continue
176 bar_len = min(int(count / total * 40), 40)
177 bar_str = "β–ˆ" * bar_len
178 pct = count / total * 100
179 print(f" {_PITCH_CLASSES[pc]:<3} {bar_str:<40} {count:>3} ({pct:.1f}%)")