gabriel / muse public

note_blame.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 chore(timeline): remove unused RationalRate import in entity.py · · Jul 10, 2026
1 """muse note-blame — per-bar attribution for a MIDI track.
2
3 Shows which commit introduced the notes currently in a specific bar.
4 The music-domain equivalent of ``muse blame`` — instead of "which commit
5 last touched this function?", it answers "which commit wrote the notes
6 in bar 8 of the melody?"
7
8 Usage::
9
10 muse note-blame tracks/melody.mid --bar 4
11 muse note-blame tracks/melody.mid --bar 12 --json
12
13 Output::
14
15 Note attribution: tracks/melody.mid bar 4
16
17 C4 vel=80 @beat=1.00 dur=1.00 ch 0
18 E4 vel=75 @beat=2.00 dur=0.50 ch 0
19 G4 vel=72 @beat=2.50 dur=0.50 ch 0
20 E4 vel=75 @beat=3.00 dur=1.00 ch 0
21
22 2 notes in bar 4 introduced by:
23 cb4afaed 2026-03-16 alice "Add chord arpeggiation in bar 4"
24 """
25
26 import argparse
27 import json
28 import logging
29 import pathlib
30 import sys
31
32 from muse.core.errors import ExitCode
33 from muse.core.repo import require_repo
34 from muse.core.refs import read_current_branch
35 from muse.core.commits import resolve_commit_ref
36 from muse.domain import DomainOp
37 from muse.plugins.midi._query import (
38 NoteInfo,
39 load_track,
40 walk_commits_for_track,
41 )
42 from muse.plugins.midi.midi_diff import NoteKey, _note_content_id
43 from muse.core.validation import clamp_int
44 from muse.core.validation import sanitize_display
45
46 type _OriginMap = dict[str, tuple[str, str, str, str]]
47 logger = logging.getLogger(__name__)
48
49 def _read_branch(root: pathlib.Path) -> str:
50 return read_current_branch(root)
51
52 def _cid_for_note(note: NoteInfo) -> str:
53 """Return the content ID for a ``NoteInfo`` (delegates to midi_diff logic)."""
54 key = NoteKey(
55 pitch=note.pitch,
56 velocity=note.velocity,
57 start_tick=note.start_tick,
58 duration_ticks=note.duration_ticks,
59 channel=note.channel,
60 )
61 return _note_content_id(key)
62
63 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
64 """Register the note-blame subcommand."""
65 parser = subparsers.add_parser("note-blame", help="Show which commit introduced the notes in a specific bar.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
66 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
67 parser.add_argument("--bar", "-b", metavar="N", type=int, required=True, help="Bar number (1-indexed, assumes 4/4 time).")
68 parser.add_argument("--from", metavar="REF", default=None, dest="from_ref", help="Start search from this commit (default: HEAD).")
69 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
70 parser.set_defaults(func=run)
71
72 def run(args: argparse.Namespace) -> None:
73 """Show which commit introduced the notes in a specific bar.
74
75 ``muse note-blame`` walks the commit history and finds the commit that
76 first inserted each note currently in bar N. This gives per-note
77 attribution at the musical level — not the line level.
78
79 This is strictly impossible in Git: Git cannot tell you "these notes in
80 bar 4 were added in commit X" because Git has no concept of notes or bars.
81
82 Use ``--bar`` to specify the bar number (1-indexed, 4/4 time assumed).
83 Use ``--from`` to start the search at a different point in history.
84 """
85 track: str = args.track
86 bar: int = clamp_int(args.bar, 0, 10000, 'bar')
87 from_ref: str | None = args.from_ref
88 as_json: bool = args.as_json
89
90 root = require_repo()
91 branch = _read_branch(root)
92
93 start_commit = resolve_commit_ref(root, branch, from_ref)
94 if start_commit is None:
95 print(f"❌ Commit '{from_ref or 'HEAD'}' not found.", file=sys.stderr)
96 raise SystemExit(ExitCode.USER_ERROR)
97
98 track_result = load_track(root, start_commit.commit_id, track)
99 if track_result is None:
100 print(f"❌ Track '{track}' not found in this commit.", file=sys.stderr)
101 raise SystemExit(ExitCode.USER_ERROR)
102
103 current_notes, _tpb = track_result
104 bar_notes = [n for n in current_notes if n.bar == bar]
105
106 if not bar_notes:
107 print(f" (no notes found in bar {bar} of '{track}')")
108 return
109
110 # Build a set of content IDs for the bar's notes.
111 target_ids: set[str] = {_cid_for_note(n) for n in bar_notes}
112
113 # Walk commits to find when each note was first inserted.
114 note_origins: _OriginMap = {}
115 commits_data = walk_commits_for_track(root, start_commit.commit_id, track)
116
117 for commit, _manifest in commits_data:
118 if commit.structured_delta is None:
119 continue
120 for op in commit.structured_delta["ops"]:
121 if op["address"] != track:
122 continue
123 child_ops: list[DomainOp] = op["child_ops"] if op["op"] == "patch" else []
124 for child in child_ops:
125 if child["op"] == "insert":
126 cid = child["content_id"]
127 if cid in target_ids and cid not in note_origins:
128 date_str = commit.committed_at.strftime("%Y-%m-%d")
129 note_origins[cid] = (
130 commit.commit_id,
131 date_str,
132 commit.author or "unknown",
133 commit.message,
134 )
135
136 if as_json:
137 print(json.dumps(
138 {
139 "track": track,
140 "bar": bar,
141 "notes": [
142 {
143 "pitch_name": n.pitch_name,
144 "velocity": n.velocity,
145 "beat_in_bar": round(n.beat_in_bar, 2),
146 "beat_duration": round(n.beat_duration, 2),
147 "channel": n.channel,
148 "introduced_by": note_origins.get(_cid_for_note(n), ("unknown", "", "", ""))[0],
149 }
150 for n in bar_notes
151 ],
152 },
153 ))
154 return
155
156 print(f"\nNote attribution: {sanitize_display(track)} bar {bar}")
157 print("")
158 for n in bar_notes:
159 print(
160 f" {n.pitch_name:<5} vel={n.velocity:<3} "
161 f"@beat={n.beat_in_bar:.2f} dur={n.beat_duration:.2f} ch {n.channel}"
162 )
163
164 print("")
165
166 commit_counts: dict[tuple[str, str, str, str], int] = {}
167 for n in bar_notes:
168 origin = note_origins.get(_cid_for_note(n))
169 if origin:
170 commit_counts[origin] = commit_counts.get(origin, 0) + 1
171
172 if not commit_counts:
173 print(" (could not trace origin — notes may predate the tracked history)")
174 return
175
176 for (commit_id, date, author, message), count in sorted(
177 commit_counts.items(), key=lambda kv: kv[1], reverse=True
178 ):
179 label = "note" if count == 1 else "notes"
180 print(f" {count} {label} in bar {bar} introduced by:")
181 print(f" {commit_id} {date} {sanitize_display(author)} \"{sanitize_display(message)}\"")
182 print("")