note_blame.py python
191 lines 6.9 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
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 from __future__ import annotations
27
28 import argparse
29 import json
30 import logging
31 import pathlib
32 import sys
33
34 from muse.core.errors import ExitCode
35 from muse.core.repo import read_repo_id, require_repo
36 from muse.core.store import read_current_branch, resolve_commit_ref
37 from muse.domain import DomainOp
38 from muse.plugins.midi._query import (
39 NoteInfo,
40 load_track,
41 walk_commits_for_track,
42 )
43 from muse.plugins.midi.midi_diff import NoteKey, _note_content_id
44 from muse.core.validation import clamp_int
45 from muse.core.validation import sanitize_display
46
47
48 type _OriginMap = dict[str, tuple[str, str, str, str]]
49 logger = logging.getLogger(__name__)
50
51
52
53 def _read_branch(root: pathlib.Path) -> str:
54 return read_current_branch(root)
55
56
57 def _cid_for_note(note: NoteInfo) -> str:
58 """Return the content ID for a ``NoteInfo`` (delegates to midi_diff logic)."""
59 key = NoteKey(
60 pitch=note.pitch,
61 velocity=note.velocity,
62 start_tick=note.start_tick,
63 duration_ticks=note.duration_ticks,
64 channel=note.channel,
65 )
66 return _note_content_id(key)
67
68
69 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
70 """Register the note-blame subcommand."""
71 parser = subparsers.add_parser("note-blame", help="Show which commit introduced the notes in a specific bar.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
72 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
73 parser.add_argument("--bar", "-b", metavar="N", type=int, required=True, help="Bar number (1-indexed, assumes 4/4 time).")
74 parser.add_argument("--from", metavar="REF", default=None, dest="from_ref", help="Start search from this commit (default: HEAD).")
75 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
76 parser.set_defaults(func=run)
77
78
79 def run(args: argparse.Namespace) -> None:
80 """Show which commit introduced the notes in a specific bar.
81
82 ``muse note-blame`` walks the commit history and finds the commit that
83 first inserted each note currently in bar N. This gives per-note
84 attribution at the musical level — not the line level.
85
86 This is strictly impossible in Git: Git cannot tell you "these notes in
87 bar 4 were added in commit X" because Git has no concept of notes or bars.
88
89 Use ``--bar`` to specify the bar number (1-indexed, 4/4 time assumed).
90 Use ``--from`` to start the search at a different point in history.
91 """
92 track: str = args.track
93 bar: int = clamp_int(args.bar, 0, 10000, 'bar')
94 from_ref: str | None = args.from_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 start_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
102 if start_commit is None:
103 print(f"❌ Commit '{from_ref or 'HEAD'}' not found.", file=sys.stderr)
104 raise SystemExit(ExitCode.USER_ERROR)
105
106 track_result = load_track(root, start_commit.commit_id, track)
107 if track_result is None:
108 print(f"❌ Track '{track}' not found in this commit.", file=sys.stderr)
109 raise SystemExit(ExitCode.USER_ERROR)
110
111 current_notes, _tpb = track_result
112 bar_notes = [n for n in current_notes if n.bar == bar]
113
114 if not bar_notes:
115 print(f" (no notes found in bar {bar} of '{track}')")
116 return
117
118 # Build a set of content IDs for the bar's notes.
119 target_ids: set[str] = {_cid_for_note(n) for n in bar_notes}
120
121 # Walk commits to find when each note was first inserted.
122 note_origins: _OriginMap = {}
123 commits_data = walk_commits_for_track(root, start_commit.commit_id, track)
124
125 for commit, _manifest in commits_data:
126 if commit.structured_delta is None:
127 continue
128 for op in commit.structured_delta["ops"]:
129 if op["address"] != track:
130 continue
131 child_ops: list[DomainOp] = op["child_ops"] if op["op"] == "patch" else []
132 for child in child_ops:
133 if child["op"] == "insert":
134 cid = child["content_id"]
135 if cid in target_ids and cid not in note_origins:
136 date_str = commit.committed_at.strftime("%Y-%m-%d")
137 note_origins[cid] = (
138 commit.commit_id[:8],
139 date_str,
140 commit.author or "unknown",
141 commit.message,
142 )
143
144 if as_json:
145 print(json.dumps(
146 {
147 "track": track,
148 "bar": bar,
149 "notes": [
150 {
151 "pitch_name": n.pitch_name,
152 "velocity": n.velocity,
153 "beat_in_bar": round(n.beat_in_bar, 2),
154 "beat_duration": round(n.beat_duration, 2),
155 "channel": n.channel,
156 "introduced_by": note_origins.get(_cid_for_note(n), ("unknown", "", "", ""))[0],
157 }
158 for n in bar_notes
159 ],
160 },
161 indent=2,
162 ))
163 return
164
165 print(f"\nNote attribution: {sanitize_display(track)} bar {bar}")
166 print("")
167 for n in bar_notes:
168 print(
169 f" {n.pitch_name:<5} vel={n.velocity:<3} "
170 f"@beat={n.beat_in_bar:.2f} dur={n.beat_duration:.2f} ch {n.channel}"
171 )
172
173 print("")
174
175 commit_counts: dict[tuple[str, str, str, str], int] = {}
176 for n in bar_notes:
177 origin = note_origins.get(_cid_for_note(n))
178 if origin:
179 commit_counts[origin] = commit_counts.get(origin, 0) + 1
180
181 if not commit_counts:
182 print(" (could not trace origin — notes may predate the tracked history)")
183 return
184
185 for (short_id, date, author, message), count in sorted(
186 commit_counts.items(), key=lambda kv: kv[1], reverse=True
187 ):
188 label = "note" if count == 1 else "notes"
189 print(f" {count} {label} in bar {bar} introduced by:")
190 print(f" {short_id} {date} {sanitize_display(author)} \"{sanitize_display(message)}\"")
191 print("")
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago