gabriel / muse public
arpeggiate.py python
159 lines 6.0 KB
Raw
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839 chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm… Sonnet 5 patch 13 days 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 import argparse
26 import logging
27 import pathlib
28 import random
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 type _FloatMap = dict[str, float]
37 logger = logging.getLogger(__name__)
38
39 _RATE_FRACTIONS: _FloatMap = {
40 "quarter": 1.0,
41 "8th": 0.5,
42 "16th": 0.25,
43 "32nd": 0.125,
44 }
45
46 _VALID_ORDERS = ("up", "down", "up-down", "random")
47
48 def _cluster_notes(notes: list[NoteInfo]) -> list[list[NoteInfo]]:
49 """Group notes into time-overlapping clusters (chords)."""
50 by_time = sorted(notes, key=lambda n: n.start_tick)
51 clusters: list[list[NoteInfo]] = []
52 current: list[NoteInfo] = []
53 window = max(n.ticks_per_beat // 8 for n in notes) if notes else 1
54
55 for note in by_time:
56 if current and note.start_tick > current[0].start_tick + window:
57 clusters.append(current)
58 current = [note]
59 else:
60 current.append(note)
61 if current:
62 clusters.append(current)
63 return clusters
64
65 def _order_cluster(cluster: list[NoteInfo], order: str, rng: random.Random) -> list[NoteInfo]:
66 s = sorted(cluster, key=lambda n: n.pitch)
67 if order == "up":
68 return s
69 if order == "down":
70 return list(reversed(s))
71 if order == "up-down":
72 return s + list(reversed(s[1:-1]))
73 # random
74 shuffled = list(s)
75 rng.shuffle(shuffled)
76 return shuffled
77
78 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
79 """Register the arpeggiate subcommand."""
80 parser = subparsers.add_parser("arpeggiate", help="Spread chord voicings into a sequential arpeggio pattern.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
81 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to a .mid file.")
82 parser.add_argument("--rate", "-r", metavar="RATE", default="16th", help="Arpeggio note rate: quarter, 8th, 16th, 32nd.")
83 parser.add_argument("--order", "-o", metavar="ORDER", default="up", help="Arpeggio order: up, down, up-down, random.")
84 parser.add_argument("--seed", metavar="INT", type=int, default=None, help="Random seed (for --order random).")
85 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview without writing.")
86 parser.set_defaults(func=run)
87
88 def run(args: argparse.Namespace) -> None:
89 """Spread chord voicings into a sequential arpeggio pattern.
90
91 ``muse arpeggiate`` groups overlapping notes into chord clusters, then
92 replaces each cluster with an arpeggio — sequential notes at the specified
93 rhythmic rate in the specified pitch order.
94
95 Durations are set to one grid step; original velocities are preserved.
96 Use ``--order up-down`` for a ping-pong arpeggio.
97 """
98 track: str = args.track
99 rate: str = args.rate
100 order: str = args.order
101 seed: int | None = args.seed
102 dry_run: bool = args.dry_run
103
104 if rate not in _RATE_FRACTIONS:
105 print(f"❌ Unknown rate '{rate}'. Valid: {', '.join(_RATE_FRACTIONS)}", file=sys.stderr)
106 raise SystemExit(ExitCode.USER_ERROR)
107 if order not in _VALID_ORDERS:
108 print(f"❌ Unknown order '{order}'. Valid: {', '.join(_VALID_ORDERS)}", file=sys.stderr)
109 raise SystemExit(ExitCode.USER_ERROR)
110
111 root = require_repo()
112 result = load_track_from_workdir(root, track)
113 if result is None:
114 print(f"❌ Track '{track}' not found or not a valid MIDI file.", file=sys.stderr)
115 raise SystemExit(ExitCode.USER_ERROR)
116
117 notes, tpb = result
118 if not notes:
119 print(f" (track '{track}' contains no notes — nothing to arpeggiate)")
120 return
121
122 rng = random.Random(seed)
123 step = max(1, round(tpb * _RATE_FRACTIONS[rate]))
124 clusters = _cluster_notes(notes)
125 arpeggiated: list[NoteInfo] = []
126
127 for cluster in clusters:
128 ordered = _order_cluster(cluster, order, rng)
129 base_tick = ordered[0].start_tick
130 for i, note in enumerate(ordered):
131 arpeggiated.append(NoteInfo(
132 pitch=note.pitch,
133 velocity=note.velocity,
134 start_tick=base_tick + i * step,
135 duration_ticks=step,
136 channel=note.channel,
137 ticks_per_beat=note.ticks_per_beat,
138 ))
139
140 if dry_run:
141 print(f"\n[dry-run] Would arpeggiate {track} ({rate}-note rate, {order} order)")
142 print(f" Chord clusters: {len(clusters)}")
143 print(f" Output notes: {len(arpeggiated)}")
144 print(" No changes written (--dry-run).")
145 return
146
147 midi_bytes = notes_to_midi_bytes(arpeggiated, tpb)
148 workdir = root
149 try:
150 work_path = contain_path(workdir, track)
151 except ValueError as exc:
152 print(f"❌ Invalid track path: {exc}")
153 raise SystemExit(ExitCode.USER_ERROR)
154 work_path.parent.mkdir(parents=True, exist_ok=True)
155 work_path.write_bytes(midi_bytes)
156
157 print(f"\n✅ Arpeggiated {track} ({rate}-note rate, {order} order)")
158 print(f" {len(clusters)} chord clusters → {len(arpeggiated)} arpeggio notes")
159 print(" Run `muse status` to review, then `muse commit`")
File History 1 commit
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839 chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm… Sonnet 5 patch 13 days ago