velocity_normalize.py python
133 lines 5.2 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """muse normalize — normalize MIDI velocities to a target range.
2
3 Rescales all note velocities so the softest note maps to --min and the loudest
4 to --max. Preserves the relative dynamics while adjusting the overall level.
5 Essential when merging tracks from multiple agents that were recorded at
6 different volumes.
7
8 Usage::
9
10 muse normalize tracks/melody.mid
11 muse normalize tracks/drums.mid --min 50 --max 110
12 muse normalize tracks/piano.mid --target-mean 80
13 muse normalize tracks/bass.mid --dry-run
14
15 Output::
16
17 ✅ Normalised tracks/melody.mid
18 48 notes rescaled · range: 32–78 → 40–100
19 Mean velocity: 61.3 → 72.0
20 Run `muse status` to review, then `muse commit`
21 """
22
23 from __future__ import annotations
24
25 import argparse
26 import logging
27 import pathlib
28 import sys
29
30 from muse.core.errors import ExitCode
31 from muse.core.validation import clamp_int, contain_path
32 from muse.core.repo import require_repo
33 from muse.plugins.midi._query import NoteInfo, load_track_from_workdir, notes_to_midi_bytes
34
35 logger = logging.getLogger(__name__)
36
37 _MIDI_MIN = 1
38 _MIDI_MAX = 127
39
40
41 def _rescale(velocity: int, src_lo: int, src_hi: int, dst_lo: int, dst_hi: int) -> int:
42 if src_hi == src_lo:
43 return (dst_lo + dst_hi) // 2
44 ratio = (velocity - src_lo) / (src_hi - src_lo)
45 return round(dst_lo + ratio * (dst_hi - dst_lo))
46
47
48 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
49 """Register the normalize subcommand."""
50 parser = subparsers.add_parser("normalize", help="Rescale note velocities to a target dynamic range.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
51 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
52 parser.add_argument("--min", metavar="VEL", type=int, default=40, dest="min_vel", help="Target minimum velocity (default 40).")
53 parser.add_argument("--max", metavar="VEL", type=int, default=110, dest="max_vel", help="Target maximum velocity (default 110).")
54 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview without writing.")
55 parser.set_defaults(func=run)
56
57
58 def run(args: argparse.Namespace) -> None:
59 """Rescale note velocities to a target dynamic range.
60
61 ``muse normalize`` linearly maps the existing velocity range to
62 [--min, --max], preserving the relative dynamic contour while adjusting
63 the absolute level. This is the standard first step when integrating
64 tracks from multiple agents that were written at different volume levels.
65
66 Use ``--min 64 --max 96`` for a narrow, compressed dynamic range;
67 use ``--min 20 --max 127`` for the full MIDI spectrum.
68 """
69 track: str = args.track
70 min_vel: int = clamp_int(args.min_vel, 0, 127, 'min_vel')
71 max_vel: int = clamp_int(args.max_vel, 0, 127, 'max_vel')
72 dry_run: bool = args.dry_run
73
74 if not _MIDI_MIN <= min_vel <= _MIDI_MAX:
75 print(f"❌ --min must be between {_MIDI_MIN} and {_MIDI_MAX}.", file=sys.stderr)
76 raise SystemExit(ExitCode.USER_ERROR)
77 if not _MIDI_MIN <= max_vel <= _MIDI_MAX:
78 print(f"❌ --max must be between {_MIDI_MIN} and {_MIDI_MAX}.", file=sys.stderr)
79 raise SystemExit(ExitCode.USER_ERROR)
80 if min_vel >= max_vel:
81 print("❌ --min must be less than --max.", file=sys.stderr)
82 raise SystemExit(ExitCode.USER_ERROR)
83
84 root = require_repo()
85 result = load_track_from_workdir(root, track)
86 if result is None:
87 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
88 raise SystemExit(ExitCode.USER_ERROR)
89
90 notes, tpb = result
91 if not notes:
92 print(f" (track '{track}' contains no notes — nothing to normalise)")
93 return
94
95 vels = [n.velocity for n in notes]
96 src_lo, src_hi = min(vels), max(vels)
97 old_mean = sum(vels) / len(vels)
98
99 normalised: list[NoteInfo] = [
100 NoteInfo(
101 pitch=n.pitch,
102 velocity=_rescale(n.velocity, src_lo, src_hi, min_vel, max_vel),
103 start_tick=n.start_tick,
104 duration_ticks=n.duration_ticks,
105 channel=n.channel,
106 ticks_per_beat=n.ticks_per_beat,
107 )
108 for n in notes
109 ]
110 new_mean = sum(n.velocity for n in normalised) / len(normalised)
111
112 if dry_run:
113 print(f"\n[dry-run] Would normalise {track}")
114 print(f" Notes: {len(notes)}")
115 print(f" Range: {src_lo}–{src_hi} → {min_vel}–{max_vel}")
116 print(f" Mean velocity: {old_mean:.1f} → {new_mean:.1f}")
117 print(" No changes written (--dry-run).")
118 return
119
120 midi_bytes = notes_to_midi_bytes(normalised, tpb)
121 workdir = root
122 try:
123 work_path = contain_path(workdir, track)
124 except ValueError as exc:
125 print(f"❌ Invalid track path: {exc}")
126 raise SystemExit(ExitCode.USER_ERROR)
127 work_path.parent.mkdir(parents=True, exist_ok=True)
128 work_path.write_bytes(midi_bytes)
129
130 print(f"\n✅ Normalised {track}")
131 print(f" {len(normalised)} notes rescaled · range: {src_lo}–{src_hi} → {min_vel}–{max_vel}")
132 print(f" Mean velocity: {old_mean:.1f} → {new_mean:.1f}")
133 print(" Run `muse status` to review, then `muse commit`")
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago