gabriel / muse public
note_hotspots.py python
191 lines 6.5 KB
26a36470 feat(music): 9 new semantic commands — version control that understands music Gabriel Cardona <gabriel@tellurstori.com> 5d ago
1 """muse note-hotspots — bar-level churn leaderboard across MIDI tracks.
2
3 Walks the commit history and counts how many times each bar in each track
4 was touched (had notes added or removed). High churn at the bar level
5 reveals the musical sections under active evolution — the bridge that's
6 always changing, the verse that won't settle.
7
8 Usage::
9
10 muse note-hotspots
11 muse note-hotspots --top 20
12 muse note-hotspots --track tracks/melody.mid
13 muse note-hotspots --from HEAD~30
14
15 Output::
16
17 Note churn — top 10 most-changed bars
18 Commits analysed: 47
19
20 1 tracks/melody.mid bar 8 12 changes
21 2 tracks/melody.mid bar 4 9 changes
22 3 tracks/bass.mid bar 8 7 changes
23 4 tracks/piano.mid bar 12 5 changes
24
25 High churn = compositional instability. Consider locking this section.
26 """
27 from __future__ import annotations
28
29 import json
30 import logging
31 import pathlib
32
33 import typer
34
35 from muse.core.errors import ExitCode
36 from muse.core.repo import require_repo
37 from muse.core.store import resolve_commit_ref
38 from muse.plugins.music._query import NoteInfo, walk_commits_for_track
39
40 logger = logging.getLogger(__name__)
41
42 app = typer.Typer()
43
44
45 def _read_repo_id(root: pathlib.Path) -> str:
46 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
47
48
49 def _read_branch(root: pathlib.Path) -> str:
50 head_ref = (root / ".muse" / "HEAD").read_text().strip()
51 return head_ref.removeprefix("refs/heads/").strip()
52
53
54 def _bar_of_beat_summary(note_summary: str, tpb: int) -> int | None:
55 """Extract bar number from a note summary string like 'C4 vel=80 @beat=3.00 dur=1.00'.
56
57 Returns ``None`` when the beat position cannot be parsed.
58 """
59 for part in note_summary.split():
60 if part.startswith("@beat="):
61 try:
62 beat = float(part.removeprefix("@beat="))
63 bar = int(beat // 4) + 1
64 return bar
65 except ValueError:
66 return None
67 return None
68
69
70 @app.callback(invoke_without_command=True)
71 def note_hotspots(
72 ctx: typer.Context,
73 top: int = typer.Option(20, "--top", "-n", metavar="N", help="Number of bars to show (default: 20)."),
74 track_filter: str | None = typer.Option(
75 None, "--track", "-t", metavar="TRACK",
76 help="Restrict to a specific track file.",
77 ),
78 from_ref: str | None = typer.Option(
79 None, "--from", metavar="REF",
80 help="Exclusive start of the commit range (default: initial commit).",
81 ),
82 to_ref: str | None = typer.Option(
83 None, "--to", metavar="REF",
84 help="Inclusive end of the commit range (default: HEAD).",
85 ),
86 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
87 ) -> None:
88 """Show the musical sections (bars) that change most often.
89
90 ``muse note-hotspots`` walks the commit history and counts note-level
91 changes per bar per track. High churn at the bar level reveals the
92 musical sections under most active revision — the bridge that keeps
93 changing, the chorus that won't settle.
94
95 This is the musical equivalent of ``muse hotspots`` for code: instead
96 of "which function changes most?", it answers "which bar changes most?"
97
98 Use ``--track`` to focus on a specific MIDI file. Use ``--from`` /
99 ``--to`` to scope to a sprint or release window.
100 """
101 root = require_repo()
102 repo_id = _read_repo_id(root)
103 branch = _read_branch(root)
104
105 to_commit = resolve_commit_ref(root, repo_id, branch, to_ref)
106 if to_commit is None:
107 typer.echo(f"❌ Commit '{to_ref or 'HEAD'}' not found.", err=True)
108 raise typer.Exit(code=ExitCode.USER_ERROR)
109
110 from_commit_id: str | None = None
111 if from_ref is not None:
112 from_c = resolve_commit_ref(root, repo_id, branch, from_ref)
113 if from_c is None:
114 typer.echo(f"❌ Commit '{from_ref}' not found.", err=True)
115 raise typer.Exit(code=ExitCode.USER_ERROR)
116 from_commit_id = from_c.commit_id
117
118 # Discover all MIDI tracks touched in history.
119 seen_commit_ids: set[str] = set()
120 commits_count = 0
121
122 # Walk commits from to_commit back.
123 from muse.core.store import read_commit
124 bar_counts: dict[tuple[str, int], int] = {}
125 current_id: str | None = to_commit.commit_id
126
127 while current_id and current_id != from_commit_id:
128 if current_id in seen_commit_ids:
129 break
130 seen_commit_ids.add(current_id)
131 commit = read_commit(root, current_id)
132 if commit is None:
133 break
134 commits_count += 1
135 current_id = commit.parent_commit_id
136
137 if commit.structured_delta is None:
138 continue
139
140 for op in commit.structured_delta["ops"]:
141 track_addr = op["address"]
142 if track_filter and track_addr != track_filter:
143 continue
144 if not track_addr.lower().endswith(".mid"):
145 continue
146 child_ops = op["child_ops"] if op["op"] == "patch" else []
147 for child in child_ops:
148 if child["op"] == "insert":
149 summary: str = child["content_summary"]
150 elif child["op"] == "delete":
151 summary = child["content_summary"]
152 else:
153 continue
154 bar_num = _bar_of_beat_summary(summary, 480) # 480 = standard tpb
155 if bar_num is None:
156 continue
157 key = (track_addr, bar_num)
158 bar_counts[key] = bar_counts.get(key, 0) + 1
159
160 ranked = sorted(bar_counts.items(), key=lambda kv: kv[1], reverse=True)[:top]
161
162 if as_json:
163 typer.echo(json.dumps(
164 {
165 "commits_analysed": commits_count,
166 "hotspots": [
167 {"track": t, "bar": b, "changes": c} for (t, b), c in ranked
168 ],
169 },
170 indent=2,
171 ))
172 return
173
174 track_label = f" track={track_filter}" if track_filter else ""
175 typer.echo(f"\nNote churn — top {len(ranked)} most-changed bars{track_label}")
176 typer.echo(f"Commits analysed: {commits_count}")
177 typer.echo("")
178
179 if not ranked:
180 typer.echo(" (no note-level bar changes found)")
181 return
182
183 width = len(str(len(ranked)))
184 for rank, ((track_addr, bar_num), count) in enumerate(ranked, 1):
185 label = "change" if count == 1 else "changes"
186 typer.echo(
187 f" {rank:>{width}} {track_addr:<40} bar {bar_num:>4} {count:>3} {label}"
188 )
189
190 typer.echo("")
191 typer.echo("High churn = compositional instability. Consider locking this section.")