gabriel / muse public
humanize.py python
130 lines 5.2 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 130 days ago
1 """muse humanize — add subtle timing and velocity variation to MIDI.
2
3 Applies controlled randomness to note onset times and velocities — giving
4 machine-quantised MIDI the feel of a human performance. An indispensable
5 post-processing step when agent-generated music sounds too mechanical.
6
7 Usage::
8
9 muse humanize tracks/piano.mid
10 muse humanize tracks/drums.mid --timing 0.02 --velocity 8
11 muse humanize tracks/melody.mid --seed 42
12 muse humanize tracks/bass.mid --dry-run
13
14 Output::
15
16 ✅ Humanised tracks/piano.mid
17 64 notes adjusted
18 Timing jitter: ±0.010 beats · Velocity jitter: ±6
19 Run `muse status` to review, then `muse commit`
20 """
21
22 import argparse
23 import logging
24 import pathlib
25 import random
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_VEL_MAX = 127
36 _MIDI_VEL_MIN = 1
37
38 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
39 """Register the humanize subcommand."""
40 parser = subparsers.add_parser("humanize", help="Add subtle timing and velocity variation to quantised MIDI.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
41 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
42 parser.add_argument("--timing", "-t", metavar="BEATS", type=float, default=0.01, help="Max timing jitter in beats (default 0.01 = 1%% of a beat).")
43 parser.add_argument("--velocity", "-v", metavar="VEL", type=int, default=6, help="Max velocity jitter in MIDI units (default 6).")
44 parser.add_argument("--seed", metavar="INT", type=int, default=None, help="Random seed for reproducible humanisation.")
45 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview without writing.")
46 parser.set_defaults(func=run)
47
48 def run(args: argparse.Namespace) -> None:
49 """Add subtle timing and velocity variation to quantised MIDI.
50
51 ``muse humanize`` applies small random perturbations drawn from a
52 uniform distribution to each note's onset time and velocity. The
53 ``--timing`` amount is in beats; the ``--velocity`` amount is in raw
54 MIDI units (0–127).
55
56 Use ``--seed`` for reproducible results — important for CI pipelines
57 that need deterministic audio output. After humanising, commit with
58 ``muse commit`` to record the transformation with full attribution.
59 """
60 track: str = args.track
61 timing: float = args.timing
62 velocity: int = clamp_int(args.velocity, 0, 127, 'velocity')
63 seed: int | None = args.seed
64 dry_run: bool = args.dry_run
65
66 if timing < 0:
67 print("❌ --timing must be ≥ 0.", file=sys.stderr)
68 raise SystemExit(ExitCode.USER_ERROR)
69 if timing > 1.0:
70 print("❌ --timing must be ≤ 1.0 beat (to prevent degenerate output).", file=sys.stderr)
71 raise SystemExit(ExitCode.USER_ERROR)
72 if velocity < 0:
73 print("❌ --velocity must be ≥ 0.", file=sys.stderr)
74 raise SystemExit(ExitCode.USER_ERROR)
75 if velocity > 127:
76 print("❌ --velocity must be ≤ 127 (MIDI 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 humanise)")
88 return
89
90 rng = random.Random(seed)
91 timing_ticks = int(timing * tpb)
92 humanised: list[NoteInfo] = []
93
94 for n in notes:
95 tick_jitter = rng.randint(-timing_ticks, timing_ticks)
96 vel_jitter = rng.randint(-velocity, velocity)
97 new_tick = max(0, n.start_tick + tick_jitter)
98 new_vel = max(_MIDI_VEL_MIN, min(_MIDI_VEL_MAX, n.velocity + vel_jitter))
99 humanised.append(NoteInfo(
100 pitch=n.pitch,
101 velocity=new_vel,
102 start_tick=new_tick,
103 duration_ticks=n.duration_ticks,
104 channel=n.channel,
105 ticks_per_beat=n.ticks_per_beat,
106 ))
107
108 if dry_run:
109 print(f"\n[dry-run] Would humanise {track}")
110 print(f" Notes: {len(notes)}")
111 print(f" Timing jitter: ±{timing} beats (±{timing_ticks} ticks)")
112 print(f" Velocity jitter: ±{velocity}")
113 print(f" Seed: {seed!r}")
114 print(" No changes written (--dry-run).")
115 return
116
117 midi_bytes = notes_to_midi_bytes(humanised, tpb)
118 workdir = root
119 try:
120 work_path = contain_path(workdir, track)
121 except ValueError as exc:
122 print(f"❌ Invalid track path: {exc}")
123 raise SystemExit(ExitCode.USER_ERROR)
124 work_path.parent.mkdir(parents=True, exist_ok=True)
125 work_path.write_bytes(midi_bytes)
126
127 print(f"\n✅ Humanised {track}")
128 print(f" {len(humanised)} notes adjusted")
129 print(f" Timing jitter: ±{timing} beats · Velocity jitter: ±{velocity}")
130 print(" Run `muse status` to review, then `muse commit`")
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 130 days ago