instrumentation.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """muse instrumentation β€” MIDI channel and note-range map for a track.
2
3 Shows which MIDI channels carry notes, the pitch range each channel spans,
4 velocity statistics per channel, and the approximate register (bass/mid/treble).
5 Agents handling multi-channel orchestration use this to verify that instrument
6 assignments are coherent before committing.
7
8 Usage::
9
10 muse instrumentation tracks/full_score.mid
11 muse instrumentation tracks/orchestra.mid --commit HEAD~3
12 muse instrumentation tracks/ensemble.mid --json
13
14 Output::
15
16 Instrumentation map: tracks/full_score.mid β€” working tree
17 Channels: 4 Β· Total notes: 128
18
19 Ch Notes Range Register Mean vel
20 ───────────────────────────────────────────────
21 0 32 C2–G2 bass 78.4
22 1 40 C3–C5 mid 72.1
23 2 28 G4–E6 treble 65.3
24 3 28 F#3–D5 mid 80.0
25 """
26
27 from __future__ import annotations
28
29 import argparse
30 import json
31 import logging
32 import pathlib
33 import sys
34 from collections import defaultdict
35 from typing import TypedDict
36
37 from muse.core.errors import ExitCode
38 from muse.core.repo import read_repo_id, require_repo
39 from muse.core.store import read_current_branch, resolve_commit_ref
40 from muse.plugins.midi._query import NoteInfo, load_track, load_track_from_workdir
41 from muse.plugins.midi.midi_diff import _pitch_name
42
43 logger = logging.getLogger(__name__)
44
45
46 class ChannelInfo(TypedDict):
47 """Statistics for one MIDI channel."""
48
49 channel: int
50 note_count: int
51 pitch_min: int
52 pitch_max: int
53 pitch_min_name: str
54 pitch_max_name: str
55 register: str
56 mean_velocity: float
57
58
59 def _register(pitch_min: int, pitch_max: int) -> str:
60 mid = (pitch_min + pitch_max) / 2
61 if mid < 48:
62 return "bass"
63 if mid < 72:
64 return "mid"
65 return "treble"
66
67
68 def _channel_info(channel: int, notes: list[NoteInfo]) -> ChannelInfo:
69 pitches = [n.pitch for n in notes]
70 vels = [n.velocity for n in notes]
71 lo, hi = min(pitches), max(pitches)
72 return ChannelInfo(
73 channel=channel,
74 note_count=len(notes),
75 pitch_min=lo,
76 pitch_max=hi,
77 pitch_min_name=_pitch_name(lo),
78 pitch_max_name=_pitch_name(hi),
79 register=_register(lo, hi),
80 mean_velocity=round(sum(vels) / len(vels), 1),
81 )
82
83
84
85 def _read_branch(root: pathlib.Path) -> str:
86 return read_current_branch(root)
87
88
89 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
90 """Register the instrumentation subcommand."""
91 parser = subparsers.add_parser("instrumentation", help="Show per-channel note distribution, pitch range, and register.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
92 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
93 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
94 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
95 parser.set_defaults(func=run)
96
97
98 def run(args: argparse.Namespace) -> None:
99 """Show per-channel note distribution, pitch range, and register.
100
101 ``muse instrumentation`` groups notes by MIDI channel and reports:
102 note count, lowest/highest pitch, register classification, and mean
103 velocity. Use it to verify that instrument roles are coherent β€” that
104 the bass channel stays low, that the melody channel occupies the right
105 register, and that no channel is accidentally silent.
106
107 For agents coordinating multi-channel scores, this is the fast sanity
108 check before every commit: ``muse instrumentation tracks/score.mid``.
109 """
110 track: str = args.track
111 ref: str | None = args.ref
112 as_json: bool = args.as_json
113
114 root = require_repo()
115 commit_label = "working tree"
116
117 if ref is not None:
118 repo_id = read_repo_id(root)
119 branch = _read_branch(root)
120 commit = resolve_commit_ref(root, repo_id, branch, ref)
121 if commit is None:
122 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
123 raise SystemExit(ExitCode.USER_ERROR)
124 result = load_track(root, commit.commit_id, track)
125 commit_label = commit.commit_id[:8]
126 else:
127 result = load_track_from_workdir(root, track)
128
129 if result is None:
130 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
131 raise SystemExit(ExitCode.USER_ERROR)
132
133 notes, _tpb = result
134 if not notes:
135 print(f" (no notes found in '{track}')")
136 return
137
138 by_channel: dict[int, list[NoteInfo]] = defaultdict(list)
139 for n in notes:
140 by_channel[n.channel].append(n)
141
142 channels = [_channel_info(ch, ch_notes) for ch, ch_notes in sorted(by_channel.items())]
143
144 if as_json:
145 print(json.dumps(
146 {"track": track, "commit": commit_label, "channels": list(channels)},
147 indent=2,
148 ))
149 return
150
151 print(f"\nInstrumentation map: {track} β€” {commit_label}")
152 print(f"Channels: {len(channels)} Β· Total notes: {len(notes)}\n")
153 print(f" {'Ch':>3} {'Notes':>6} {'Range':<14} {'Register':<10} {'Mean vel':>8}")
154 print(" " + "─" * 50)
155 for ch in channels:
156 rng = f"{ch['pitch_min_name']}–{ch['pitch_max_name']}"
157 print(
158 f" {ch['channel']:>3} {ch['note_count']:>6} {rng:<14} "
159 f"{ch['register']:<10} {ch['mean_velocity']:>8.1f}"
160 )