gabriel / muse public
arpeggiate.py python
162 lines 5.7 KB
59a915a4 refactor: repo root is the working tree — remove state/ subdirectory Gabriel Cardona <gabriel@tellurstori.com> 4d ago
1 """muse arpeggiate — convert simultaneous chord notes into a sequential arpeggio.
2
3 Takes notes that overlap in time (chord voicings) and spreads them out
4 sequentially at a specified rhythmic rate. Agents that receive a chord-pad
5 track and want to convert it into a rolling arpeggio pattern can do this in
6 one command.
7
8 Usage::
9
10 muse arpeggiate tracks/chords.mid --rate 16th
11 muse arpeggiate tracks/pads.mid --rate 8th --order up
12 muse arpeggiate tracks/piano.mid --rate 8th --order down
13 muse arpeggiate tracks/chords.mid --rate 16th --order random --seed 7
14 muse arpeggiate tracks/chords.mid --rate 16th --dry-run
15
16 Order values: up (low→high), down (high→low), up-down, random
17
18 Output::
19
20 ✅ Arpeggiated tracks/chords.mid (16th-note rate, up order)
21 12 chord clusters → 48 arpeggio notes
22 Run `muse status` to review, then `muse commit`
23 """
24
25 from __future__ import annotations
26
27 import logging
28 import pathlib
29 import random
30
31 import typer
32
33 from muse.core.errors import ExitCode
34 from muse.core.validation import contain_path
35 from muse.core.repo import require_repo
36 from muse.plugins.midi._query import NoteInfo, load_track_from_workdir, notes_to_midi_bytes
37
38 logger = logging.getLogger(__name__)
39 app = typer.Typer()
40
41 _RATE_FRACTIONS: dict[str, float] = {
42 "quarter": 1.0,
43 "8th": 0.5,
44 "16th": 0.25,
45 "32nd": 0.125,
46 }
47
48 _VALID_ORDERS = ("up", "down", "up-down", "random")
49
50
51 def _cluster_notes(notes: list[NoteInfo]) -> list[list[NoteInfo]]:
52 """Group notes into time-overlapping clusters (chords)."""
53 by_time = sorted(notes, key=lambda n: n.start_tick)
54 clusters: list[list[NoteInfo]] = []
55 current: list[NoteInfo] = []
56 window = max(n.ticks_per_beat // 8 for n in notes) if notes else 1
57
58 for note in by_time:
59 if current and note.start_tick > current[0].start_tick + window:
60 clusters.append(current)
61 current = [note]
62 else:
63 current.append(note)
64 if current:
65 clusters.append(current)
66 return clusters
67
68
69 def _order_cluster(cluster: list[NoteInfo], order: str, rng: random.Random) -> list[NoteInfo]:
70 s = sorted(cluster, key=lambda n: n.pitch)
71 if order == "up":
72 return s
73 if order == "down":
74 return list(reversed(s))
75 if order == "up-down":
76 return s + list(reversed(s[1:-1]))
77 # random
78 shuffled = list(s)
79 rng.shuffle(shuffled)
80 return shuffled
81
82
83 @app.callback(invoke_without_command=True)
84 def arpeggiate(
85 ctx: typer.Context,
86 track: str = typer.Argument(..., metavar="TRACK", help="Workspace-relative path to a .mid file."),
87 rate: str = typer.Option(
88 "16th", "--rate", "-r", metavar="RATE",
89 help="Arpeggio note rate: quarter, 8th, 16th, 32nd.",
90 ),
91 order: str = typer.Option(
92 "up", "--order", "-o", metavar="ORDER",
93 help="Arpeggio order: up, down, up-down, random.",
94 ),
95 seed: int | None = typer.Option(None, "--seed", metavar="INT", help="Random seed (for --order random)."),
96 dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Preview without writing."),
97 ) -> None:
98 """Spread chord voicings into a sequential arpeggio pattern.
99
100 ``muse arpeggiate`` groups overlapping notes into chord clusters, then
101 replaces each cluster with an arpeggio — sequential notes at the specified
102 rhythmic rate in the specified pitch order.
103
104 Durations are set to one grid step; original velocities are preserved.
105 Use ``--order up-down`` for a ping-pong arpeggio.
106 """
107 if rate not in _RATE_FRACTIONS:
108 typer.echo(f"❌ Unknown rate '{rate}'. Valid: {', '.join(_RATE_FRACTIONS)}", err=True)
109 raise typer.Exit(code=ExitCode.USER_ERROR)
110 if order not in _VALID_ORDERS:
111 typer.echo(f"❌ Unknown order '{order}'. Valid: {', '.join(_VALID_ORDERS)}", err=True)
112 raise typer.Exit(code=ExitCode.USER_ERROR)
113
114 root = require_repo()
115 result = load_track_from_workdir(root, track)
116 if result is None:
117 typer.echo(f"❌ Track '{track}' not found or not a valid MIDI file.", err=True)
118 raise typer.Exit(code=ExitCode.USER_ERROR)
119
120 notes, tpb = result
121 if not notes:
122 typer.echo(f" (track '{track}' contains no notes — nothing to arpeggiate)")
123 return
124
125 rng = random.Random(seed)
126 step = max(1, round(tpb * _RATE_FRACTIONS[rate]))
127 clusters = _cluster_notes(notes)
128 arpeggiated: list[NoteInfo] = []
129
130 for cluster in clusters:
131 ordered = _order_cluster(cluster, order, rng)
132 base_tick = ordered[0].start_tick
133 for i, note in enumerate(ordered):
134 arpeggiated.append(NoteInfo(
135 pitch=note.pitch,
136 velocity=note.velocity,
137 start_tick=base_tick + i * step,
138 duration_ticks=step,
139 channel=note.channel,
140 ticks_per_beat=note.ticks_per_beat,
141 ))
142
143 if dry_run:
144 typer.echo(f"\n[dry-run] Would arpeggiate {track} ({rate}-note rate, {order} order)")
145 typer.echo(f" Chord clusters: {len(clusters)}")
146 typer.echo(f" Output notes: {len(arpeggiated)}")
147 typer.echo(" No changes written (--dry-run).")
148 return
149
150 midi_bytes = notes_to_midi_bytes(arpeggiated, tpb)
151 workdir = root
152 try:
153 work_path = contain_path(workdir, track)
154 except ValueError as exc:
155 typer.echo(f"❌ Invalid track path: {exc}")
156 raise typer.Exit(code=ExitCode.USER_ERROR)
157 work_path.parent.mkdir(parents=True, exist_ok=True)
158 work_path.write_bytes(midi_bytes)
159
160 typer.echo(f"\n✅ Arpeggiated {track} ({rate}-note rate, {order} order)")
161 typer.echo(f" {len(clusters)} chord clusters → {len(arpeggiated)} arpeggio notes")
162 typer.echo(" Run `muse status` to review, then `muse commit`")