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