gabriel / muse public
instrumentation.py python
150 lines 5.5 KB
Raw
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 21 hours ago
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 import argparse
28 import json
29 import logging
30 import pathlib
31 import sys
32 from collections import defaultdict
33 from typing import TypedDict
34
35 from muse.core.errors import ExitCode
36 from muse.core.repo import require_repo
37 from muse.core.refs import read_current_branch
38 from muse.core.commits import resolve_commit_ref
39 from muse.plugins.midi._query import NoteInfo, load_track, load_track_from_workdir
40 from muse.plugins.midi.midi_diff import _pitch_name
41
42 logger = logging.getLogger(__name__)
43
44 class ChannelInfo(TypedDict):
45 """Statistics for one MIDI channel."""
46
47 channel: int
48 note_count: int
49 pitch_min: int
50 pitch_max: int
51 pitch_min_name: str
52 pitch_max_name: str
53 register: str
54 mean_velocity: float
55
56 def _register(pitch_min: int, pitch_max: int) -> str:
57 mid = (pitch_min + pitch_max) / 2
58 if mid < 48:
59 return "bass"
60 if mid < 72:
61 return "mid"
62 return "treble"
63
64 def _channel_info(channel: int, notes: list[NoteInfo]) -> ChannelInfo:
65 pitches = [n.pitch for n in notes]
66 vels = [n.velocity for n in notes]
67 lo, hi = min(pitches), max(pitches)
68 return ChannelInfo(
69 channel=channel,
70 note_count=len(notes),
71 pitch_min=lo,
72 pitch_max=hi,
73 pitch_min_name=_pitch_name(lo),
74 pitch_max_name=_pitch_name(hi),
75 register=_register(lo, hi),
76 mean_velocity=round(sum(vels) / len(vels), 1),
77 )
78
79 def _read_branch(root: pathlib.Path) -> str:
80 return read_current_branch(root)
81
82 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
83 """Register the instrumentation subcommand."""
84 parser = subparsers.add_parser("instrumentation", help="Show per-channel note distribution, pitch range, and register.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
85 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
86 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
87 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
88 parser.set_defaults(func=run)
89
90 def run(args: argparse.Namespace) -> None:
91 """Show per-channel note distribution, pitch range, and register.
92
93 ``muse instrumentation`` groups notes by MIDI channel and reports:
94 note count, lowest/highest pitch, register classification, and mean
95 velocity. Use it to verify that instrument roles are coherent — that
96 the bass channel stays low, that the melody channel occupies the right
97 register, and that no channel is accidentally silent.
98
99 For agents coordinating multi-channel scores, this is the fast sanity
100 check before every commit: ``muse instrumentation tracks/score.mid``.
101 """
102 track: str = args.track
103 ref: str | None = args.ref
104 as_json: bool = args.as_json
105
106 root = require_repo()
107 commit_label = "working tree"
108
109 if ref is not None:
110 branch = _read_branch(root)
111 commit = resolve_commit_ref(root, branch, ref)
112 if commit is None:
113 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
114 raise SystemExit(ExitCode.USER_ERROR)
115 result = load_track(root, commit.commit_id, track)
116 commit_label = commit.commit_id
117 else:
118 result = load_track_from_workdir(root, track)
119
120 if result is None:
121 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
122 raise SystemExit(ExitCode.USER_ERROR)
123
124 notes, _tpb = result
125 if not notes:
126 print(f" (no notes found in '{track}')")
127 return
128
129 by_channel: dict[int, list[NoteInfo]] = defaultdict(list)
130 for n in notes:
131 by_channel[n.channel].append(n)
132
133 channels = [_channel_info(ch, ch_notes) for ch, ch_notes in sorted(by_channel.items())]
134
135 if as_json:
136 print(json.dumps(
137 {"track": track, "commit": commit_label, "channels": list(channels)},
138 ))
139 return
140
141 print(f"\nInstrumentation map: {track} — {commit_label}")
142 print(f"Channels: {len(channels)} · Total notes: {len(notes)}\n")
143 print(f" {'Ch':>3} {'Notes':>6} {'Range':<14} {'Register':<10} {'Mean vel':>8}")
144 print(f" {'─' * 50}")
145 for ch in channels:
146 rng = f"{ch['pitch_min_name']}–{ch['pitch_max_name']}"
147 print(
148 f" {ch['channel']:>3} {ch['note_count']:>6} {rng:<14} "
149 f"{ch['register']:<10} {ch['mean_velocity']:>8.1f}"
150 )
File History 7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 21 hours ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 10 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago