instrumentation.py
python
| 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 | ) |