gabriel / muse public
humanize.py python
134 lines 5.2 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 27 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 from __future__ import annotations
23
24 import argparse
25 import logging
26 import pathlib
27 import random
28 import sys
29
30 from muse.core.errors import ExitCode
31 from muse.core.validation import clamp_int, contain_path
32 from muse.core.repo import require_repo
33 from muse.plugins.midi._query import NoteInfo, load_track_from_workdir, notes_to_midi_bytes
34
35 logger = logging.getLogger(__name__)
36
37 _MIDI_VEL_MAX = 127
38 _MIDI_VEL_MIN = 1
39
40
41 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
42 """Register the humanize subcommand."""
43 parser = subparsers.add_parser("humanize", help="Add subtle timing and velocity variation to quantised MIDI.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
44 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
45 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).")
46 parser.add_argument("--velocity", "-v", metavar="VEL", type=int, default=6, help="Max velocity jitter in MIDI units (default 6).")
47 parser.add_argument("--seed", metavar="INT", type=int, default=None, help="Random seed for reproducible humanisation.")
48 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview without writing.")
49 parser.set_defaults(func=run)
50
51
52 def run(args: argparse.Namespace) -> None:
53 """Add subtle timing and velocity variation to quantised MIDI.
54
55 ``muse humanize`` applies small random perturbations drawn from a
56 uniform distribution to each note's onset time and velocity. The
57 ``--timing`` amount is in beats; the ``--velocity`` amount is in raw
58 MIDI units (0–127).
59
60 Use ``--seed`` for reproducible results — important for CI pipelines
61 that need deterministic audio output. After humanising, commit with
62 ``muse commit`` to record the transformation with full attribution.
63 """
64 track: str = args.track
65 timing: float = args.timing
66 velocity: int = clamp_int(args.velocity, 0, 127, 'velocity')
67 seed: int | None = args.seed
68 dry_run: bool = args.dry_run
69
70 if timing < 0:
71 print("❌ --timing must be ≥ 0.", file=sys.stderr)
72 raise SystemExit(ExitCode.USER_ERROR)
73 if timing > 1.0:
74 print("❌ --timing must be ≤ 1.0 beat (to prevent degenerate output).", file=sys.stderr)
75 raise SystemExit(ExitCode.USER_ERROR)
76 if velocity < 0:
77 print("❌ --velocity must be ≥ 0.", file=sys.stderr)
78 raise SystemExit(ExitCode.USER_ERROR)
79 if velocity > 127:
80 print("❌ --velocity must be ≤ 127 (MIDI max).", file=sys.stderr)
81 raise SystemExit(ExitCode.USER_ERROR)
82
83 root = require_repo()
84 result = load_track_from_workdir(root, track)
85 if result is None:
86 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
87 raise SystemExit(ExitCode.USER_ERROR)
88
89 notes, tpb = result
90 if not notes:
91 print(f" (track '{track}' contains no notes — nothing to humanise)")
92 return
93
94 rng = random.Random(seed)
95 timing_ticks = int(timing * tpb)
96 humanised: list[NoteInfo] = []
97
98 for n in notes:
99 tick_jitter = rng.randint(-timing_ticks, timing_ticks)
100 vel_jitter = rng.randint(-velocity, velocity)
101 new_tick = max(0, n.start_tick + tick_jitter)
102 new_vel = max(_MIDI_VEL_MIN, min(_MIDI_VEL_MAX, n.velocity + vel_jitter))
103 humanised.append(NoteInfo(
104 pitch=n.pitch,
105 velocity=new_vel,
106 start_tick=new_tick,
107 duration_ticks=n.duration_ticks,
108 channel=n.channel,
109 ticks_per_beat=n.ticks_per_beat,
110 ))
111
112 if dry_run:
113 print(f"\n[dry-run] Would humanise {track}")
114 print(f" Notes: {len(notes)}")
115 print(f" Timing jitter: ±{timing} beats (±{timing_ticks} ticks)")
116 print(f" Velocity jitter: ±{velocity}")
117 print(f" Seed: {seed!r}")
118 print(" No changes written (--dry-run).")
119 return
120
121 midi_bytes = notes_to_midi_bytes(humanised, tpb)
122 workdir = root
123 try:
124 work_path = contain_path(workdir, track)
125 except ValueError as exc:
126 print(f"❌ Invalid track path: {exc}")
127 raise SystemExit(ExitCode.USER_ERROR)
128 work_path.parent.mkdir(parents=True, exist_ok=True)
129 work_path.write_bytes(midi_bytes)
130
131 print(f"\n✅ Humanised {track}")
132 print(f" {len(humanised)} notes adjusted")
133 print(f" Timing jitter: ±{timing} beats · Velocity jitter: ±{velocity}")
134 print(" Run `muse status` to review, then `muse commit`")
File History 4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 27 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago