gabriel / muse public
midi_harmony.py python
172 lines 6.1 KB
Raw
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