cgcardona / muse public
instrumentation.py python
162 lines 5.4 KB
630bfa59 feat(midi): add 20 new semantic porcelain commands (#120) Gabriel Cardona <cgcardona@gmail.com> 14h 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 json
30 import logging
31 import pathlib
32 from collections import defaultdict
33 from typing import TypedDict
34
35 import typer
36
37 from muse.core.errors import ExitCode
38 from muse.core.repo import require_repo
39 from muse.core.store import 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 app = typer.Typer()
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 def _read_repo_id(root: pathlib.Path) -> str:
86 import json as _json
87
88 return str(_json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
89
90
91 def _read_branch(root: pathlib.Path) -> str:
92 return (root / ".muse" / "HEAD").read_text().strip().removeprefix("refs/heads/").strip()
93
94
95 @app.callback(invoke_without_command=True)
96 def instrumentation(
97 ctx: typer.Context,
98 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
99 ref: str | None = typer.Option(
100 None, "--commit", "-c", metavar="REF",
101 help="Analyse a historical snapshot instead of the working tree.",
102 ),
103 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
104 ) -> None:
105 """Show per-channel note distribution, pitch range, and register.
106
107 ``muse instrumentation`` groups notes by MIDI channel and reports:
108 note count, lowest/highest pitch, register classification, and mean
109 velocity. Use it to verify that instrument roles are coherent — that
110 the bass channel stays low, that the melody channel occupies the right
111 register, and that no channel is accidentally silent.
112
113 For agents coordinating multi-channel scores, this is the fast sanity
114 check before every commit: ``muse instrumentation tracks/score.mid``.
115 """
116 root = require_repo()
117 commit_label = "working tree"
118
119 if ref is not None:
120 repo_id = _read_repo_id(root)
121 branch = _read_branch(root)
122 commit = resolve_commit_ref(root, repo_id, branch, ref)
123 if commit is None:
124 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
125 raise typer.Exit(code=ExitCode.USER_ERROR)
126 result = load_track(root, commit.commit_id, track)
127 commit_label = commit.commit_id[:8]
128 else:
129 result = load_track_from_workdir(root, track)
130
131 if result is None:
132 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
133 raise typer.Exit(code=ExitCode.USER_ERROR)
134
135 notes, _tpb = result
136 if not notes:
137 typer.echo(f" (no notes found in '{track}')")
138 return
139
140 by_channel: dict[int, list[NoteInfo]] = defaultdict(list)
141 for n in notes:
142 by_channel[n.channel].append(n)
143
144 channels = [_channel_info(ch, ch_notes) for ch, ch_notes in sorted(by_channel.items())]
145
146 if as_json:
147 typer.echo(json.dumps(
148 {"track": track, "commit": commit_label, "channels": list(channels)},
149 indent=2,
150 ))
151 return
152
153 typer.echo(f"\nInstrumentation map: {track} — {commit_label}")
154 typer.echo(f"Channels: {len(channels)} · Total notes: {len(notes)}\n")
155 typer.echo(f" {'Ch':>3} {'Notes':>6} {'Range':<14} {'Register':<10} {'Mean vel':>8}")
156 typer.echo(" " + "─" * 50)
157 for ch in channels:
158 rng = f"{ch['pitch_min_name']}–{ch['pitch_max_name']}"
159 typer.echo(
160 f" {ch['channel']:>3} {ch['note_count']:>6} {rng:<14} "
161 f"{ch['register']:<10} {ch['mean_velocity']:>8.1f}"
162 )