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