gabriel / muse public

velocity_profile.py file-level

at sha256:a · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c feat: muse domain-info text output articulates every DomainSchema field… · gabriel · Sep 23, 2026
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 require_repo
42 from muse.core.refs import read_current_branch
43 from muse.core.commits import resolve_commit_ref
44 from muse.plugins.midi._query import (
45
46 NoteInfo,
47 load_track,
48 load_track_from_workdir,
49 notes_by_bar,
50 )
51
52 type _IntMap = dict[str, int]
53
54 logger = logging.getLogger(__name__)
55
56 _DYNAMIC_LEVELS: list[tuple[str, int, int]] = [
57 ("ppp", 1, 15),
58 ("pp", 16, 31),
59 ("p", 32, 47),
60 ("mp", 48, 63),
61 ("mf", 64, 79),
62 ("f", 80, 95),
63 ("ff", 96, 111),
64 ("fff", 112, 127),
65 ]
66 _BAR_WIDTH = 32 # histogram bar chars
67
68 def _velocity_level(velocity: int) -> str:
69 for name, lo, hi in _DYNAMIC_LEVELS:
70 if lo <= velocity <= hi:
71 return name
72 return "fff"
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 def _read_branch(root: pathlib.Path) -> str:
80 return read_current_branch(root)
81
82 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
83 """Register the velocity-profile subcommand."""
84 parser = subparsers.add_parser("velocity-profile", help="Analyse the dynamic range and velocity distribution of a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
85 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
86 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
87 parser.add_argument("--by-bar", "-b", action="store_true", help="Show per-bar average velocity instead of the overall histogram.")
88 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
89 parser.set_defaults(func=run)
90
91 def run(args: argparse.Namespace) -> None:
92 """Analyse the dynamic range and velocity distribution of a MIDI track.
93
94 ``muse velocity-profile`` shows peak, average, and RMS velocity, plus
95 a histogram of notes by dynamic level (ppp through fff).
96
97 Use ``--by-bar`` to see per-bar average velocity β€” useful for spotting
98 which sections of a composition are louder or softer.
99
100 Use ``--commit`` to analyse a historical snapshot. Use ``--json`` for
101 agent-readable output.
102
103 This is fundamentally impossible in Git: Git has no model of what the
104 MIDI velocity values in a binary file mean. Muse stores notes as
105 structured semantic data, enabling musical dynamics analysis at any
106 point in history.
107 """
108 track: str = args.track
109 ref: str | None = args.ref
110 by_bar: bool = args.by_bar
111 as_json: bool = args.as_json
112
113 root = require_repo()
114
115 result: tuple[list[NoteInfo], int] | None
116 commit_label = "working tree"
117
118 if ref is not None:
119 branch = _read_branch(root)
120 commit = resolve_commit_ref(root, 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}")