gabriel / muse public
quantize.py python
143 lines 5.4 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 1 day ago
1 """muse quantize — snap note onsets to a rhythmic grid.
2
3 Moves every note's start tick to the nearest multiple of the chosen
4 subdivision (16th, 8th, quarter, etc.). Duration is preserved. An
5 essential post-processing step after human-recorded or agent-generated
6 MIDI that needs to be grid-aligned before mixing.
7
8 Usage::
9
10 muse quantize tracks/piano.mid --grid 16th
11 muse quantize tracks/bass.mid --grid 8th --strength 0.5
12 muse quantize tracks/melody.mid --dry-run
13 muse quantize tracks/drums.mid --grid 32nd
14
15 Grid values: whole, half, quarter, 8th, 16th, 32nd, triplet-8th, triplet-16th
16
17 Output::
18
19 ✅ Quantised tracks/piano.mid → 16th-note grid
20 64 notes adjusted · avg shift: 14 ticks · max shift: 58 ticks
21 Run `muse status` to review, then `muse commit`
22 """
23
24 import argparse
25 import logging
26 import pathlib
27 import sys
28
29 from muse.core.errors import ExitCode
30 from muse.core.validation import contain_path
31 from muse.core.repo import require_repo
32 from muse.plugins.midi._query import NoteInfo, load_track_from_workdir, notes_to_midi_bytes
33
34 type _FloatMap = dict[str, float]
35 logger = logging.getLogger(__name__)
36
37 _GRID_FRACTIONS: _FloatMap = {
38 "whole": 4.0,
39 "half": 2.0,
40 "quarter": 1.0,
41 "8th": 0.5,
42 "16th": 0.25,
43 "32nd": 0.125,
44 "triplet-8th": 1 / 3,
45 "triplet-16th": 1 / 6,
46 }
47
48 def _grid_ticks(tpb: int, grid_name: str) -> int:
49 fraction = _GRID_FRACTIONS.get(grid_name, 0.25)
50 return max(1, round(tpb * fraction))
51
52 def _snap(tick: int, grid: int, strength: float) -> int:
53 """Snap *tick* toward the nearest grid point with *strength* [0, 1]."""
54 nearest = round(tick / grid) * grid
55 return round(tick + (nearest - tick) * strength)
56
57 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
58 """Register the quantize subcommand."""
59 parser = subparsers.add_parser("quantize", help="Snap note onsets to a rhythmic grid.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
60 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
61 parser.add_argument("--grid", "-g", metavar="GRID", default="16th", help="Quantisation grid: whole, half, quarter, 8th, 16th, 32nd, triplet-8th, triplet-16th.")
62 parser.add_argument("--strength", "-s", metavar="S", type=float, default=1.0, help="Quantisation strength 0.0 (no change) – 1.0 (full snap). Default 1.0.")
63 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview without writing.")
64 parser.set_defaults(func=run)
65
66 def run(args: argparse.Namespace) -> None:
67 """Snap note onsets to a rhythmic grid.
68
69 ``muse quantize`` moves each note's start tick to the nearest multiple of
70 the chosen subdivision. Use ``--strength`` < 1.0 for partial quantisation
71 that preserves some human feel while tightening the groove.
72
73 After quantising, run ``muse status`` to inspect the structured delta
74 (which notes moved) and ``muse commit`` to record the operation.
75 """
76 track: str = args.track
77 grid: str = args.grid
78 strength: float = args.strength
79 dry_run: bool = args.dry_run
80
81 if grid not in _GRID_FRACTIONS:
82 print(
83 f"❌ Unknown grid '{grid}'. "
84 f"Valid: {', '.join(_GRID_FRACTIONS)}",
85 file=sys.stderr,
86 )
87 raise SystemExit(ExitCode.USER_ERROR)
88
89 if not 0.0 <= strength <= 1.0:
90 print("❌ --strength must be between 0.0 and 1.0.", file=sys.stderr)
91 raise SystemExit(ExitCode.USER_ERROR)
92
93 root = require_repo()
94 result = load_track_from_workdir(root, track)
95 if result is None:
96 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
97 raise SystemExit(ExitCode.USER_ERROR)
98
99 notes, tpb = result
100 if not notes:
101 print(f" (track '{track}' contains no notes — nothing to quantise)")
102 return
103
104 grid_t = _grid_ticks(tpb, grid)
105 quantised: list[NoteInfo] = []
106 shifts: list[int] = []
107
108 for n in notes:
109 new_tick = _snap(n.start_tick, grid_t, strength)
110 shifts.append(abs(new_tick - n.start_tick))
111 quantised.append(NoteInfo(
112 pitch=n.pitch,
113 velocity=n.velocity,
114 start_tick=new_tick,
115 duration_ticks=n.duration_ticks,
116 channel=n.channel,
117 ticks_per_beat=n.ticks_per_beat,
118 ))
119
120 avg_shift = sum(shifts) / max(len(shifts), 1)
121 max_shift = max(shifts) if shifts else 0
122 moved = sum(1 for s in shifts if s > 0)
123
124 if dry_run:
125 print(f"\n[dry-run] Would quantise {track} → {grid}-note grid (strength={strength:.2f})")
126 print(f" Notes adjusted: {moved} / {len(notes)}")
127 print(f" Avg tick shift: {avg_shift:.1f} · Max: {max_shift}")
128 print(" No changes written (--dry-run).")
129 return
130
131 midi_bytes = notes_to_midi_bytes(quantised, tpb)
132 workdir = root
133 try:
134 work_path = contain_path(workdir, track)
135 except ValueError as exc:
136 print(f"❌ Invalid track path: {exc}")
137 raise SystemExit(ExitCode.USER_ERROR)
138 work_path.parent.mkdir(parents=True, exist_ok=True)
139 work_path.write_bytes(midi_bytes)
140
141 print(f"\n✅ Quantised {track} → {grid}-note grid")
142 print(f" {moved} notes adjusted · avg shift: {avg_shift:.1f} ticks · max shift: {max_shift}")
143 print(" Run `muse status` to review, then `muse commit`")
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 1 day ago