gabriel / muse public
velocity_profile.py python
208 lines 7.4 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days 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
32 import argparse
33 import json
34 import logging
35 import math
36 import pathlib
37 import sys
38
39 from muse.core.types import short_id
40 from muse.core.errors import ExitCode
41 from muse.core.repo import read_repo_id, require_repo
42 from muse.core.store import read_current_branch, resolve_commit_ref
43 from muse.plugins.midi._query import (
44
45 NoteInfo,
46 load_track,
47 load_track_from_workdir,
48 notes_by_bar,
49 )
50
51 type _IntMap = dict[str, int]
52
53 logger = logging.getLogger(__name__)
54
55 _DYNAMIC_LEVELS: list[tuple[str, int, int]] = [
56 ("ppp", 1, 15),
57 ("pp", 16, 31),
58 ("p", 32, 47),
59 ("mp", 48, 63),
60 ("mf", 64, 79),
61 ("f", 80, 95),
62 ("ff", 96, 111),
63 ("fff", 112, 127),
64 ]
65 _BAR_WIDTH = 32 # histogram bar chars
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 def _rms(values: list[int]) -> float:
74 if not values:
75 return 0.0
76 return math.sqrt(sum(v * v for v in values) / len(values))
77
78 def _read_branch(root: pathlib.Path) -> str:
79 return read_current_branch(root)
80
81 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
82 """Register the velocity-profile subcommand."""
83 parser = subparsers.add_parser("velocity-profile", help="Analyse the dynamic range and velocity distribution of a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
84 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
85 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
86 parser.add_argument("--by-bar", "-b", action="store_true", help="Show per-bar average velocity instead of the overall histogram.")
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 """Analyse the dynamic range and velocity distribution of a MIDI track.
92
93 ``muse velocity-profile`` shows peak, average, and RMS velocity, plus
94 a histogram of notes by dynamic level (ppp through fff).
95
96 Use ``--by-bar`` to see per-bar average velocity — useful for spotting
97 which sections of a composition are louder or softer.
98
99 Use ``--commit`` to analyse a historical snapshot. Use ``--json`` for
100 agent-readable output.
101
102 This is fundamentally impossible in Git: Git has no model of what the
103 MIDI velocity values in a binary file mean. Muse stores notes as
104 structured semantic data, enabling musical dynamics analysis at any
105 point in history.
106 """
107 track: str = args.track
108 ref: str | None = args.ref
109 by_bar: bool = args.by_bar
110 as_json: bool = args.as_json
111
112 root = require_repo()
113
114 result: tuple[list[NoteInfo], int] | None
115 commit_label = "working tree"
116
117 if ref is not None:
118 repo_id = read_repo_id(root)
119 branch = _read_branch(root)
120 commit = resolve_commit_ref(root, repo_id, branch, ref)
121 if commit is None:
122 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
123 raise SystemExit(ExitCode.USER_ERROR)
124 result = load_track(root, commit.commit_id, track)
125 commit_label = short_id(commit.commit_id, strip=True)
126 else:
127 result = load_track_from_workdir(root, track)
128
129 if result is None:
130 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
131 raise SystemExit(ExitCode.USER_ERROR)
132
133 note_list, _tpb = result
134
135 if not note_list:
136 print(f" (no notes found in '{track}')")
137 return
138
139 velocities = [n.velocity for n in note_list]
140 v_min = min(velocities)
141 v_max = max(velocities)
142 v_mean = sum(velocities) / len(velocities)
143 v_rms = _rms(velocities)
144
145 # Dynamic level counts.
146 level_counts: _IntMap = {name: 0 for name, _, _ in _DYNAMIC_LEVELS}
147 for v in velocities:
148 level_counts[_velocity_level(v)] += 1
149
150 if as_json:
151 if by_bar:
152 bars = notes_by_bar(note_list)
153 bar_data = [
154 {
155 "bar": bar_num,
156 "mean_velocity": round(sum(n.velocity for n in bar_notes) / len(bar_notes), 1),
157 "note_count": len(bar_notes),
158 }
159 for bar_num, bar_notes in sorted(bars.items())
160 ]
161 print(json.dumps(
162 {"track": track, "commit": commit_label, "by_bar": bar_data}
163 ))
164 else:
165 print(json.dumps(
166 {
167 "track": track,
168 "commit": commit_label,
169 "notes": len(note_list),
170 "min": v_min, "max": v_max,
171 "mean": round(v_mean, 1), "rms": round(v_rms, 1),
172 "histogram": {k: v for k, v in level_counts.items()},
173 },
174 ))
175 return
176
177 print(f"\nVelocity profile: {track} — {commit_label}")
178 print(
179 f"Notes: {len(note_list)} · Range: {v_min}–{v_max}"
180 f" · Mean: {v_mean:.1f} · RMS: {v_rms:.1f}"
181 )
182 print("")
183
184 if by_bar:
185 bars = notes_by_bar(note_list)
186 for bar_num, bar_notes in sorted(bars.items()):
187 bar_vels = [n.velocity for n in bar_notes]
188 bar_mean = sum(bar_vels) / len(bar_vels)
189 bar_len = min(int(bar_mean / 127 * _BAR_WIDTH), _BAR_WIDTH)
190 print(
191 f" bar {bar_num:>4} {'█' * bar_len:<{_BAR_WIDTH}} "
192 f"avg={bar_mean:>5.1f} ({len(bar_notes)} notes)"
193 )
194 return
195
196 total = max(len(velocities), 1)
197 for name, lo, hi in _DYNAMIC_LEVELS:
198 count = level_counts[name]
199 bar_len = min(int(count / total * _BAR_WIDTH), _BAR_WIDTH)
200 pct = count / total * 100
201 print(
202 f" {name:<4}({lo:>3}–{hi:>3}) │{'█' * bar_len:<{_BAR_WIDTH}}│"
203 f" {count:>4} ({pct:>5.1f}%)"
204 )
205
206 # Dominant dynamic level.
207 dominant = max(level_counts, key=lambda k: level_counts[k])
208 print(f"\nDynamic character: {dominant}")
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago