gabriel / muse public
instrumentation.py python
160 lines 5.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days 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 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._types import short_id
38 from muse.core.errors import ExitCode
39 from muse.core.repo import read_repo_id, require_repo
40 from muse.core.store import read_current_branch, resolve_commit_ref
41 from muse.plugins.midi._query import NoteInfo, load_track, load_track_from_workdir
42 from muse.plugins.midi.midi_diff import _pitch_name
43
44 logger = logging.getLogger(__name__)
45
46
47 class ChannelInfo(TypedDict):
48 """Statistics for one MIDI channel."""
49
50 channel: int
51 note_count: int
52 pitch_min: int
53 pitch_max: int
54 pitch_min_name: str
55 pitch_max_name: str
56 register: str
57 mean_velocity: float
58
59
60 def _register(pitch_min: int, pitch_max: int) -> str:
61 mid = (pitch_min + pitch_max) / 2
62 if mid < 48:
63 return "bass"
64 if mid < 72:
65 return "mid"
66 return "treble"
67
68
69 def _channel_info(channel: int, notes: list[NoteInfo]) -> ChannelInfo:
70 pitches = [n.pitch for n in notes]
71 vels = [n.velocity for n in notes]
72 lo, hi = min(pitches), max(pitches)
73 return ChannelInfo(
74 channel=channel,
75 note_count=len(notes),
76 pitch_min=lo,
77 pitch_max=hi,
78 pitch_min_name=_pitch_name(lo),
79 pitch_max_name=_pitch_name(hi),
80 register=_register(lo, hi),
81 mean_velocity=round(sum(vels) / len(vels), 1),
82 )
83
84
85
86 def _read_branch(root: pathlib.Path) -> str:
87 return read_current_branch(root)
88
89
90 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
91 """Register the instrumentation subcommand."""
92 parser = subparsers.add_parser("instrumentation", help="Show per-channel note distribution, pitch range, and register.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
93 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
94 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
95 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
96 parser.set_defaults(func=run)
97
98
99 def run(args: argparse.Namespace) -> None:
100 """Show per-channel note distribution, pitch range, and register.
101
102 ``muse instrumentation`` groups notes by MIDI channel and reports:
103 note count, lowest/highest pitch, register classification, and mean
104 velocity. Use it to verify that instrument roles are coherent — that
105 the bass channel stays low, that the melody channel occupies the right
106 register, and that no channel is accidentally silent.
107
108 For agents coordinating multi-channel scores, this is the fast sanity
109 check before every commit: ``muse instrumentation tracks/score.mid``.
110 """
111 track: str = args.track
112 ref: str | None = args.ref
113 as_json: bool = args.as_json
114
115 root = require_repo()
116 commit_label = "working tree"
117
118 if ref is not None:
119 repo_id = read_repo_id(root)
120 branch = _read_branch(root)
121 commit = resolve_commit_ref(root, repo_id, branch, ref)
122 if commit is None:
123 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
124 raise SystemExit(ExitCode.USER_ERROR)
125 result = load_track(root, commit.commit_id, track)
126 commit_label = short_id(commit.commit_id)
127 else:
128 result = load_track_from_workdir(root, track)
129
130 if result is None:
131 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
132 raise SystemExit(ExitCode.USER_ERROR)
133
134 notes, _tpb = result
135 if not notes:
136 print(f" (no notes found in '{track}')")
137 return
138
139 by_channel: dict[int, list[NoteInfo]] = defaultdict(list)
140 for n in notes:
141 by_channel[n.channel].append(n)
142
143 channels = [_channel_info(ch, ch_notes) for ch, ch_notes in sorted(by_channel.items())]
144
145 if as_json:
146 print(json.dumps(
147 {"track": track, "commit": commit_label, "channels": list(channels)},
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(f" {'─' * 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 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago