gabriel / muse public
note_blame.py python
182 lines 6.8 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 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 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("")
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago