gabriel / muse public
humanize.py python
135 lines 4.9 KB
59a915a4 refactor: repo root is the working tree — remove state/ subdirectory Gabriel Cardona <gabriel@tellurstori.com> 4d 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 logging
25 import pathlib
26 import random
27
28 import typer
29
30 from muse.core.errors import ExitCode
31 from muse.core.validation import 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 app = typer.Typer()
37
38 _MIDI_VEL_MAX = 127
39 _MIDI_VEL_MIN = 1
40
41
42 @app.callback(invoke_without_command=True)
43 def humanize(
44 ctx: typer.Context,
45 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
46 timing: float = typer.Option(
47 0.01, "--timing", "-t", metavar="BEATS",
48 help="Max timing jitter in beats (default 0.01 = 1% of a beat).",
49 ),
50 velocity: int = typer.Option(
51 6, "--velocity", "-v", metavar="VEL",
52 help="Max velocity jitter in MIDI units (default 6).",
53 ),
54 seed: int | None = typer.Option(
55 None, "--seed", metavar="INT",
56 help="Random seed for reproducible humanisation.",
57 ),
58 dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Preview without writing."),
59 ) -> None:
60 """Add subtle timing and velocity variation to quantised MIDI.
61
62 ``muse humanize`` applies small random perturbations drawn from a
63 uniform distribution to each note's onset time and velocity. The
64 ``--timing`` amount is in beats; the ``--velocity`` amount is in raw
65 MIDI units (0–127).
66
67 Use ``--seed`` for reproducible results — important for CI pipelines
68 that need deterministic audio output. After humanising, commit with
69 ``muse commit`` to record the transformation with full attribution.
70 """
71 if timing < 0:
72 typer.echo("❌ --timing must be ≥ 0.", err=True)
73 raise typer.Exit(code=ExitCode.USER_ERROR)
74 if timing > 1.0:
75 typer.echo("❌ --timing must be ≤ 1.0 beat (to prevent degenerate output).", err=True)
76 raise typer.Exit(code=ExitCode.USER_ERROR)
77 if velocity < 0:
78 typer.echo("❌ --velocity must be ≥ 0.", err=True)
79 raise typer.Exit(code=ExitCode.USER_ERROR)
80 if velocity > 127:
81 typer.echo("❌ --velocity must be ≤ 127 (MIDI max).", err=True)
82 raise typer.Exit(code=ExitCode.USER_ERROR)
83
84 root = require_repo()
85 result = load_track_from_workdir(root, track)
86 if result is None:
87 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
88 raise typer.Exit(code=ExitCode.USER_ERROR)
89
90 notes, tpb = result
91 if not notes:
92 typer.echo(f" (track '{track}' contains no notes — nothing to humanise)")
93 return
94
95 rng = random.Random(seed)
96 timing_ticks = int(timing * tpb)
97 humanised: list[NoteInfo] = []
98
99 for n in notes:
100 tick_jitter = rng.randint(-timing_ticks, timing_ticks)
101 vel_jitter = rng.randint(-velocity, velocity)
102 new_tick = max(0, n.start_tick + tick_jitter)
103 new_vel = max(_MIDI_VEL_MIN, min(_MIDI_VEL_MAX, n.velocity + vel_jitter))
104 humanised.append(NoteInfo(
105 pitch=n.pitch,
106 velocity=new_vel,
107 start_tick=new_tick,
108 duration_ticks=n.duration_ticks,
109 channel=n.channel,
110 ticks_per_beat=n.ticks_per_beat,
111 ))
112
113 if dry_run:
114 typer.echo(f"\n[dry-run] Would humanise {track}")
115 typer.echo(f" Notes: {len(notes)}")
116 typer.echo(f" Timing jitter: ±{timing} beats (±{timing_ticks} ticks)")
117 typer.echo(f" Velocity jitter: ±{velocity}")
118 typer.echo(f" Seed: {seed!r}")
119 typer.echo(" No changes written (--dry-run).")
120 return
121
122 midi_bytes = notes_to_midi_bytes(humanised, tpb)
123 workdir = root
124 try:
125 work_path = contain_path(workdir, track)
126 except ValueError as exc:
127 typer.echo(f"❌ Invalid track path: {exc}")
128 raise typer.Exit(code=ExitCode.USER_ERROR)
129 work_path.parent.mkdir(parents=True, exist_ok=True)
130 work_path.write_bytes(midi_bytes)
131
132 typer.echo(f"\n✅ Humanised {track}")
133 typer.echo(f" {len(humanised)} notes adjusted")
134 typer.echo(f" Timing jitter: ±{timing} beats · Velocity jitter: ±{velocity}")
135 typer.echo(" Run `muse status` to review, then `muse commit`")