gabriel / muse public
velocity_normalize.py python
128 lines 5.1 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 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 11 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 57 days ago