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