gabriel / muse public
velocity_profile.py python
216 lines 7.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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 from __future__ import annotations
33
34 import argparse
35 import json
36 import logging
37 import math
38 import pathlib
39 import sys
40
41 from muse.core.errors import ExitCode
42 from muse.core.repo import read_repo_id, require_repo
43 from muse.core.store import read_current_branch, 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
69 def _velocity_level(velocity: int) -> str:
70 for name, lo, hi in _DYNAMIC_LEVELS:
71 if lo <= velocity <= hi:
72 return name
73 return "fff"
74
75
76 def _rms(values: list[int]) -> float:
77 if not values:
78 return 0.0
79 return math.sqrt(sum(v * v for v in values) / len(values))
80
81
82
83 def _read_branch(root: pathlib.Path) -> str:
84 return read_current_branch(root)
85
86
87 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
88 """Register the velocity-profile subcommand."""
89 parser = subparsers.add_parser("velocity-profile", help="Analyse the dynamic range and velocity distribution of a MIDI track.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
90 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
91 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Analyse a historical snapshot instead of the working tree.")
92 parser.add_argument("--by-bar", "-b", action="store_true", help="Show per-bar average velocity instead of the overall histogram.")
93 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
94 parser.set_defaults(func=run)
95
96
97 def run(args: argparse.Namespace) -> None:
98 """Analyse the dynamic range and velocity distribution of a MIDI track.
99
100 ``muse velocity-profile`` shows peak, average, and RMS velocity, plus
101 a histogram of notes by dynamic level (ppp through fff).
102
103 Use ``--by-bar`` to see per-bar average velocity — useful for spotting
104 which sections of a composition are louder or softer.
105
106 Use ``--commit`` to analyse a historical snapshot. Use ``--json`` for
107 agent-readable output.
108
109 This is fundamentally impossible in Git: Git has no model of what the
110 MIDI velocity values in a binary file mean. Muse stores notes as
111 structured semantic data, enabling musical dynamics analysis at any
112 point in history.
113 """
114 track: str = args.track
115 ref: str | None = args.ref
116 by_bar: bool = args.by_bar
117 as_json: bool = args.as_json
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 print(f"❌ Commit '{ref}' not found.", file=sys.stderr)
130 raise SystemExit(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 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
138 raise SystemExit(ExitCode.USER_ERROR)
139
140 note_list, _tpb = result
141
142 if not note_list:
143 print(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: _IntMap = {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 print(json.dumps(
169 {"track": track, "commit": commit_label, "by_bar": bar_data}, indent=2
170 ))
171 else:
172 print(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 print(f"\nVelocity profile: {track} — {commit_label}")
186 print(
187 f"Notes: {len(note_list)} · Range: {v_min}–{v_max}"
188 f" · Mean: {v_mean:.1f} · RMS: {v_rms:.1f}"
189 )
190 print("")
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 print(
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 print(
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 print(f"\nDynamic character: {dominant}")
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago