cgcardona / muse public
velocity_profile.py python
216 lines 7.2 KB
26a36470 feat(music): 9 new semantic commands — version control that understands music Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """muse velocity-profile — dynamic range analysis for a MIDI track.
2
3 Shows the velocity distribution of a MIDI track — peak, average, RMS,
4 and a per-velocity-bucket histogram. Reveals the dynamic character of
5 a composition: is it always forte? Does it have a wide dynamic range?
6 Are some bars particularly loud or soft?
7
8 Usage::
9
10 muse velocity-profile tracks/melody.mid
11 muse velocity-profile tracks/piano.mid --commit HEAD~5
12 muse velocity-profile tracks/drums.mid --by-bar
13 muse velocity-profile tracks/melody.mid --json
14
15 Output::
16
17 Velocity profile: tracks/melody.mid — cb4afaed
18 Notes: 23 · Range: 48–96 · Mean: 78.3 · RMS: 79.1
19
20 ppp ( 1–15) │ │ 0
21 pp (16–31) │ │ 0
22 p (32–47) │ │ 0
23 mp (48–63) │████ │ 2 ( 8.7%)
24 mf (64–79) │████████████████████████ │ 12 (52.2%)
25 f (80–95) │████████████ │ 8 (34.8%)
26 ff (96–111) │██ │ 1 ( 4.3%)
27 fff (112–127)│ │ 0
28
29 Dynamic character: mf–f (moderate-loud)
30 """
31 from __future__ import annotations
32
33 import json
34 import logging
35 import math
36 import pathlib
37
38 import typer
39
40 from muse.core.errors import ExitCode
41 from muse.core.repo import require_repo
42 from muse.core.store import resolve_commit_ref
43 from muse.plugins.music._query import (
44 NoteInfo,
45 load_track,
46 load_track_from_workdir,
47 notes_by_bar,
48 )
49
50 logger = logging.getLogger(__name__)
51
52 app = typer.Typer()
53
54 _DYNAMIC_LEVELS: list[tuple[str, int, int]] = [
55 ("ppp", 1, 15),
56 ("pp", 16, 31),
57 ("p", 32, 47),
58 ("mp", 48, 63),
59 ("mf", 64, 79),
60 ("f", 80, 95),
61 ("ff", 96, 111),
62 ("fff", 112, 127),
63 ]
64 _BAR_WIDTH = 32 # histogram bar chars
65
66
67 def _velocity_level(velocity: int) -> str:
68 for name, lo, hi in _DYNAMIC_LEVELS:
69 if lo <= velocity <= hi:
70 return name
71 return "fff"
72
73
74 def _rms(values: list[int]) -> float:
75 if not values:
76 return 0.0
77 return math.sqrt(sum(v * v for v in values) / len(values))
78
79
80 def _read_repo_id(root: pathlib.Path) -> str:
81 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
82
83
84 def _read_branch(root: pathlib.Path) -> str:
85 head_ref = (root / ".muse" / "HEAD").read_text().strip()
86 return head_ref.removeprefix("refs/heads/").strip()
87
88
89 @app.callback(invoke_without_command=True)
90 def velocity_profile(
91 ctx: typer.Context,
92 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
93 ref: str | None = typer.Option(
94 None, "--commit", "-c", metavar="REF",
95 help="Analyse a historical snapshot instead of the working tree.",
96 ),
97 by_bar: bool = typer.Option(
98 False, "--by-bar", "-b",
99 help="Show per-bar average velocity instead of the overall histogram.",
100 ),
101 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
102 ) -> None:
103 """Analyse the dynamic range and velocity distribution of a MIDI track.
104
105 ``muse velocity-profile`` shows peak, average, and RMS velocity, plus
106 a histogram of notes by dynamic level (ppp through fff).
107
108 Use ``--by-bar`` to see per-bar average velocity — useful for spotting
109 which sections of a composition are louder or softer.
110
111 Use ``--commit`` to analyse a historical snapshot. Use ``--json`` for
112 agent-readable output.
113
114 This is fundamentally impossible in Git: Git has no model of what the
115 MIDI velocity values in a binary file mean. Muse stores notes as
116 structured semantic data, enabling musical dynamics analysis at any
117 point in history.
118 """
119 root = require_repo()
120
121 result: tuple[list[NoteInfo], int] | None
122 commit_label = "working tree"
123
124 if ref is not None:
125 repo_id = _read_repo_id(root)
126 branch = _read_branch(root)
127 commit = resolve_commit_ref(root, repo_id, branch, ref)
128 if commit is None:
129 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
130 raise typer.Exit(code=ExitCode.USER_ERROR)
131 result = load_track(root, commit.commit_id, track)
132 commit_label = commit.commit_id[:8]
133 else:
134 result = load_track_from_workdir(root, track)
135
136 if result is None:
137 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
138 raise typer.Exit(code=ExitCode.USER_ERROR)
139
140 note_list, _tpb = result
141
142 if not note_list:
143 typer.echo(f" (no notes found in '{track}')")
144 return
145
146 velocities = [n.velocity for n in note_list]
147 v_min = min(velocities)
148 v_max = max(velocities)
149 v_mean = sum(velocities) / len(velocities)
150 v_rms = _rms(velocities)
151
152 # Dynamic level counts.
153 level_counts: dict[str, int] = {name: 0 for name, _, _ in _DYNAMIC_LEVELS}
154 for v in velocities:
155 level_counts[_velocity_level(v)] += 1
156
157 if as_json:
158 if by_bar:
159 bars = notes_by_bar(note_list)
160 bar_data: list[dict[str, int | float]] = [
161 {
162 "bar": bar_num,
163 "mean_velocity": round(sum(n.velocity for n in bar_notes) / len(bar_notes), 1),
164 "note_count": len(bar_notes),
165 }
166 for bar_num, bar_notes in sorted(bars.items())
167 ]
168 typer.echo(json.dumps(
169 {"track": track, "commit": commit_label, "by_bar": bar_data}, indent=2
170 ))
171 else:
172 typer.echo(json.dumps(
173 {
174 "track": track,
175 "commit": commit_label,
176 "notes": len(note_list),
177 "min": v_min, "max": v_max,
178 "mean": round(v_mean, 1), "rms": round(v_rms, 1),
179 "histogram": {k: v for k, v in level_counts.items()},
180 },
181 indent=2,
182 ))
183 return
184
185 typer.echo(f"\nVelocity profile: {track} — {commit_label}")
186 typer.echo(
187 f"Notes: {len(note_list)} · Range: {v_min}–{v_max}"
188 f" · Mean: {v_mean:.1f} · RMS: {v_rms:.1f}"
189 )
190 typer.echo("")
191
192 if by_bar:
193 bars = notes_by_bar(note_list)
194 for bar_num, bar_notes in sorted(bars.items()):
195 bar_vels = [n.velocity for n in bar_notes]
196 bar_mean = sum(bar_vels) / len(bar_vels)
197 bar_len = min(int(bar_mean / 127 * _BAR_WIDTH), _BAR_WIDTH)
198 typer.echo(
199 f" bar {bar_num:>4} {'█' * bar_len:<{_BAR_WIDTH}} "
200 f"avg={bar_mean:>5.1f} ({len(bar_notes)} notes)"
201 )
202 return
203
204 total = max(len(velocities), 1)
205 for name, lo, hi in _DYNAMIC_LEVELS:
206 count = level_counts[name]
207 bar_len = min(int(count / total * _BAR_WIDTH), _BAR_WIDTH)
208 pct = count / total * 100
209 typer.echo(
210 f" {name:<4}({lo:>3}–{hi:>3}) │{'█' * bar_len:<{_BAR_WIDTH}}│"
211 f" {count:>4} ({pct:>5.1f}%)"
212 )
213
214 # Dominant dynamic level.
215 dominant = max(level_counts, key=lambda k: level_counts[k])
216 typer.echo(f"\nDynamic character: {dominant}")