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