gabriel / muse public
midi_compare.py python
147 lines 6.3 KB
Raw
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b docs(mwp-master): tick all ACs green; mark MWP-7 complete w… Sonnet 4.6 20 days ago
1 """muse compare — semantic comparison between two MIDI snapshots.
2
3 Diffs two commits (or a commit and the working tree) on multiple musical
4 dimensions: note count, harmonic content, rhythmic feel, pitch range, and
5 density. Where ``muse diff`` shows note-level insertions/deletions, this
6 command shows the *musical meaning* of what changed.
7
8 Usage::
9
10 muse compare tracks/melody.mid HEAD~1 HEAD
11 muse compare tracks/piano.mid HEAD~3 HEAD~1
12 muse compare tracks/bass.mid HEAD --working-tree
13 muse compare tracks/chords.mid HEAD~1 HEAD --json
14
15 Output::
16
17 Semantic comparison: tracks/melody.mid
18 A: HEAD~1 (cb4afaed) B: HEAD (9f3a12e7)
19
20 Dimension A B Δ
21 ──────────────────────────────────────────────────────────
22 Notes 48 56 +8
23 Bars 16 16 0
24 Key G major G major =
25 Density avg 3.0/beat 3.5/beat +0.5
26 Swing ratio 1.00 1.38 +0.38 (swing added)
27 Syncopation 0.12 0.31 +0.19 (more syncopated)
28 Quantisation 0.98 0.84 -0.14 (more human)
29 """
30
31 import argparse
32 import json
33 import logging
34 import pathlib
35 import sys
36
37 from muse.core.errors import ExitCode
38 from muse.core.repo import require_repo
39 from muse.core.refs import read_current_branch
40 from muse.core.commits import resolve_commit_ref
41 from muse.plugins.midi._analysis import analyze_rhythm, analyze_density
42 from muse.plugins.midi._query import (
43 NoteInfo,
44 key_signature_guess,
45 load_track,
46 load_track_from_workdir,
47 )
48
49 logger = logging.getLogger(__name__)
50
51 def _read_branch(root: pathlib.Path) -> str:
52 return read_current_branch(root)
53
54 def _load(
55 root: pathlib.Path,
56 track: str,
57 ref: str,
58 branch: str,
59 ) -> tuple[list[NoteInfo], int, str]:
60 commit = resolve_commit_ref(root, branch, ref)
61 if commit is None:
62 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
63 raise SystemExit(ExitCode.USER_ERROR)
64 result = load_track(root, commit.commit_id, track)
65 if result is None:
66 print(f"❌ Track '{track}' not found in commit '{ref}'.", file=sys.stderr)
67 raise SystemExit(ExitCode.USER_ERROR)
68 return result[0], result[1], commit.commit_id
69
70 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
71 """Register the compare subcommand."""
72 parser = subparsers.add_parser("compare", help="Compare two MIDI snapshots across musical dimensions.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
73 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
74 parser.add_argument("ref_a", metavar="REF_A", help="First commit reference (older).")
75 parser.add_argument("ref_b", nargs="?", metavar="REF_B", default=None, help="Second commit reference. Omit to compare REF_A against the working tree.")
76 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
77 parser.set_defaults(func=run)
78
79 def run(args: argparse.Namespace) -> None:
80 """Compare two MIDI snapshots across musical dimensions.
81
82 ``muse compare`` goes beyond raw note diffs — it shows how key, density,
83 swing, syncopation, and quantisation changed between two points in history.
84
85 For agents: use this after a merge to verify that the merged result
86 preserves the intended musical character of both parent branches.
87 """
88 track: str = args.track
89 ref_a: str = args.ref_a
90 ref_b: str | None = args.ref_b
91 as_json: bool = args.as_json
92
93 root = require_repo()
94 branch = _read_branch(root)
95
96 notes_a, _tpb_a, label_a = _load(root, track, ref_a, branch)
97
98 if ref_b is not None:
99 notes_b, _tpb_b, label_b = _load(root, track, ref_b, branch)
100 else:
101 raw_b = load_track_from_workdir(root, track)
102 if raw_b is None:
103 print(f"❌ Track '{track}' not found in working tree.", file=sys.stderr)
104 raise SystemExit(ExitCode.USER_ERROR)
105 notes_b, _tpb_b = raw_b
106 label_b = "working tree"
107
108 rh_a = analyze_rhythm(notes_a)
109 rh_b = analyze_rhythm(notes_b)
110 dens_a = analyze_density(notes_a)
111 dens_b = analyze_density(notes_b)
112 avg_dens_a = sum(d["notes_per_beat"] for d in dens_a) / max(len(dens_a), 1)
113 avg_dens_b = sum(d["notes_per_beat"] for d in dens_b) / max(len(dens_b), 1)
114 key_a = key_signature_guess(notes_a)
115 key_b = key_signature_guess(notes_b)
116
117 if as_json:
118 print(json.dumps({
119 "track": track,
120 "a": {"ref": ref_a, "sha": label_a, "rhythm": rh_a, "key": key_a, "density_avg": round(avg_dens_a, 2)},
121 "b": {"ref": ref_b or "working tree", "sha": label_b, "rhythm": rh_b, "key": key_b, "density_avg": round(avg_dens_b, 2)},
122 }))
123 return
124
125 print(f"\nSemantic comparison: {track}")
126 print(f"A: {ref_a} ({label_a}) B: {ref_b or 'working tree'} ({label_b})\n")
127 print(f" {'Dimension':<22} {'A':>16} {'B':>16} {'Δ':<30}")
128 print(f" {'─' * 90}")
129
130 def row(dim: str, va: str, vb: str, delta: str) -> None:
131 print(f" {dim:<22} {va:>16} {vb:>16} {delta:<30}")
132
133 row("Notes", str(rh_a["total_notes"]), str(rh_b["total_notes"]),
134 f"{rh_b['total_notes'] - rh_a['total_notes']:+d}")
135 row("Bars", str(rh_a["bars"]), str(rh_b["bars"]),
136 f"{rh_b['bars'] - rh_a['bars']:+d}")
137 row("Key", key_a, key_b, "=" if key_a == key_b else f"{key_a} → {key_b}")
138 row("Density avg", f"{avg_dens_a:.2f}/beat", f"{avg_dens_b:.2f}/beat",
139 f"{avg_dens_b - avg_dens_a:+.2f}")
140 row("Swing ratio", f"{rh_a['swing_ratio']:.3f}", f"{rh_b['swing_ratio']:.3f}",
141 f"{rh_b['swing_ratio'] - rh_a['swing_ratio']:+.3f}")
142 row("Syncopation", f"{rh_a['syncopation_score']:.3f}", f"{rh_b['syncopation_score']:.3f}",
143 f"{rh_b['syncopation_score'] - rh_a['syncopation_score']:+.3f}")
144 row("Quantisation", f"{rh_a['quantization_score']:.3f}", f"{rh_b['quantization_score']:.3f}",
145 f"{rh_b['quantization_score'] - rh_a['quantization_score']:+.3f}")
146 row("Subdivision", rh_a["dominant_subdivision"], rh_b["dominant_subdivision"],
147 "=" if rh_a["dominant_subdivision"] == rh_b["dominant_subdivision"] else "changed")
File History 1 commit
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b docs(mwp-master): tick all ACs green; mark MWP-7 complete w… Sonnet 4.6 20 days ago