@hraness/dawg 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -0,0 +1,423 @@
1
+ /**
2
+ * `/pack list|add <url>|info <name>|remove <name>|use <pack>/<sound>` and
3
+ * `/kit [bank]`: Strudel-style sample packs on dawg tracks.
4
+ *
5
+ * The score edits here are pure and return `ScoreOperation`s, so the window
6
+ * (`src/main.ts`) and the agent tools (`use_sound`) share them. Fetching and
7
+ * pinning live in `src/audio/packs.ts`.
8
+ */
9
+ import {
10
+ SAMPLER_INSTRUMENT,
11
+ isSamplerInstrument,
12
+ parsePackRef,
13
+ samplerVoiceSlots,
14
+ type Sampler,
15
+ type SampleRef,
16
+ type ScoreOperation,
17
+ type TrackScore,
18
+ } from "../../core/score.ts";
19
+ import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
20
+ import {
21
+ ALIASED_PACK,
22
+ DEFAULT_KIT,
23
+ DEFAULT_KITS,
24
+ PackError,
25
+ kitFromBank,
26
+ pinInstrument,
27
+ pinKit,
28
+ type BankAliases,
29
+ type PackInfo,
30
+ type PackStore,
31
+ } from "../audio/packs.ts";
32
+ import { formatBytes, parseByteSize, type CacheUsage } from "../audio/cache.ts";
33
+
34
+ export type PackCommand =
35
+ | Readonly<{ kind: "list" }>
36
+ | Readonly<{ kind: "add"; source: string; name?: string }>
37
+ | Readonly<{ kind: "info"; name: string }>
38
+ | Readonly<{ kind: "remove"; name: string }>
39
+ | Readonly<{ kind: "use"; sound: string; voice?: string }>
40
+ /** Show cache usage; with `pruneTo`, evict down to that many bytes. */
41
+ | Readonly<{ kind: "cache"; pruneTo?: number | "cap" }>
42
+ | Readonly<{ kind: "usage"; message: string }>;
43
+
44
+ export const PACK_USAGE =
45
+ "/pack list · /pack add <url|github:user/repo[/branch]> [as <name>] · /pack info <name> · /pack remove <name> · /pack use <pack>/<sound>[:<n>] [as <voice>] · /pack cache [prune [size]|clear]";
46
+
47
+ /** Parses `/pack …`; undefined when the command is not `/pack`. */
48
+ export function parsePackCommand(command: string): PackCommand | undefined {
49
+ const match = command.trim().match(/^\/packs?(?:\s+(.*))?$/i);
50
+ if (!match) return undefined;
51
+ const words = (match[1] ?? "").trim().split(/\s+/).filter(Boolean);
52
+ const verb = (words[0] ?? "list").toLowerCase();
53
+ const rest = words.slice(1);
54
+ const named = (): { value?: string; as?: string } => {
55
+ const at = rest.findIndex((word) => word.toLowerCase() === "as");
56
+ if (at === -1) return rest[0] ? { value: rest[0] } : {};
57
+ return {
58
+ ...(rest[0] && at > 0 ? { value: rest[0] } : {}),
59
+ ...(rest[at + 1] ? { as: rest[at + 1] } : {}),
60
+ };
61
+ };
62
+ if (verb === "list" || verb === "ls") return { kind: "list" };
63
+ if (verb === "add") {
64
+ const { value, as } = named();
65
+ if (!value)
66
+ return {
67
+ kind: "usage",
68
+ message: `pack · add needs a manifest URL · ${PACK_USAGE}`,
69
+ };
70
+ return as
71
+ ? { kind: "add", source: value, name: as }
72
+ : { kind: "add", source: value };
73
+ }
74
+ if (verb === "info" || verb === "show") {
75
+ if (!rest[0])
76
+ return {
77
+ kind: "usage",
78
+ message: `pack · info needs a pack name · ${PACK_USAGE}`,
79
+ };
80
+ return { kind: "info", name: rest[0] };
81
+ }
82
+ if (verb === "remove" || verb === "rm") {
83
+ if (!rest[0])
84
+ return {
85
+ kind: "usage",
86
+ message: `pack · remove needs a pack name · ${PACK_USAGE}`,
87
+ };
88
+ return { kind: "remove", name: rest[0] };
89
+ }
90
+ if (verb === "cache") {
91
+ const action = (rest[0] ?? "").toLowerCase();
92
+ if (!action) return { kind: "cache" };
93
+ if (action === "clear") return { kind: "cache", pruneTo: 0 };
94
+ if (action === "prune") {
95
+ if (!rest[1]) return { kind: "cache", pruneTo: "cap" };
96
+ const bytes = parseByteSize(rest[1]);
97
+ if (bytes !== undefined) return { kind: "cache", pruneTo: bytes };
98
+ }
99
+ return {
100
+ kind: "usage",
101
+ message:
102
+ "pack · cache shows usage · /pack cache prune [size, e.g. 500M] · /pack cache clear (keeps this project's sounds)",
103
+ };
104
+ }
105
+ if (verb === "use") {
106
+ const { value, as } = named();
107
+ if (!value)
108
+ return {
109
+ kind: "usage",
110
+ message: `pack · use needs <pack>/<sound> · ${PACK_USAGE}`,
111
+ };
112
+ return as
113
+ ? { kind: "use", sound: value, voice: as }
114
+ : { kind: "use", sound: value };
115
+ }
116
+ return {
117
+ kind: "usage",
118
+ message: `pack · unknown ${verb.slice(0, 20)} · ${PACK_USAGE}`,
119
+ };
120
+ }
121
+
122
+ export type KitCommand =
123
+ Readonly<{ kind: "set"; bank: string }> | Readonly<{ kind: "list" }>;
124
+
125
+ /** `/kit [bank]` (default kit) and `/kit list`; undefined for other commands. */
126
+ export function parseKitCommand(command: string): KitCommand | undefined {
127
+ const match = command.trim().match(/^\/kit(?:\s+(\S+))?\s*$/i);
128
+ if (!match) return undefined;
129
+ const bank = match[1];
130
+ if (bank && /^(list|ls)$/i.test(bank)) return { kind: "list" };
131
+ return { kind: "set", bank: bank ?? DEFAULT_KIT };
132
+ }
133
+
134
+ /** `pack:x/y`, `x/y`, `x/y:3` → `pack:x/y[:n]`; undefined when malformed. */
135
+ export function normalizeSoundRef(value: string): string | undefined {
136
+ const src = value.startsWith("pack:") ? value : `pack:${value}`;
137
+ return parsePackRef(src) ? src : undefined;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Score edits
142
+
143
+ /**
144
+ * Operations that give `trackId` the sampler `next`, keeping its hits on the
145
+ * same sound where the new sampler has one: drum tracks map GM pitches to
146
+ * drum voice names (kick, snare, …), oneshot samplers map by voice name.
147
+ * Creates the track when it does not exist.
148
+ */
149
+ export function samplerOperations(
150
+ score: TrackScore,
151
+ trackId: string,
152
+ next: Sampler,
153
+ name?: string,
154
+ ): ScoreOperation[] {
155
+ const track = score.tracks.find((item) => item.id === trackId);
156
+ if (!track)
157
+ return [
158
+ {
159
+ type: "addTrack",
160
+ track: {
161
+ id: trackId,
162
+ name: name ?? trackId,
163
+ instrument: SAMPLER_INSTRUMENT,
164
+ sampler: next,
165
+ },
166
+ },
167
+ ];
168
+ const operations: ScoreOperation[] = [
169
+ {
170
+ type: "updateTrack",
171
+ trackId,
172
+ // A synth kit name does not apply to a sampler track.
173
+ patch: {
174
+ instrument: SAMPLER_INSTRUMENT,
175
+ sampler: next,
176
+ ...(track.kit ? { kit: null } : {}),
177
+ },
178
+ },
179
+ ];
180
+ if (next.mode !== "oneshot") return operations;
181
+ const newSlots = samplerVoiceSlots(next);
182
+ let voiceAt: (pitch: number) => string | undefined;
183
+ if (isDrumInstrument(track.instrument))
184
+ voiceAt = (pitch) => drumVoiceForPitch(pitch);
185
+ else if (
186
+ isSamplerInstrument(track.instrument) &&
187
+ track.sampler?.mode === "oneshot"
188
+ ) {
189
+ const byPitch = new Map<number, string>();
190
+ for (const [voice, slot] of samplerVoiceSlots(track.sampler))
191
+ byPitch.set(slot, voice);
192
+ voiceAt = (pitch) => byPitch.get(pitch);
193
+ } else return operations;
194
+ for (const note of score.notes) {
195
+ if (note.trackId !== trackId) continue;
196
+ const voice = voiceAt(note.pitch);
197
+ const slot = voice === undefined ? undefined : newSlots.get(voice);
198
+ if (slot !== undefined && slot !== note.pitch)
199
+ operations.push({
200
+ type: "updateNote",
201
+ noteId: note.id,
202
+ patch: { pitch: slot },
203
+ });
204
+ }
205
+ return operations;
206
+ }
207
+
208
+ /**
209
+ * Where `/kit` lands: the focused track when it is a drum kit, a sampler or
210
+ * empty; otherwise undefined (a melodic track keeps its notes).
211
+ */
212
+ export function kitTarget(
213
+ score: TrackScore,
214
+ focusedId: string,
215
+ ): string | undefined {
216
+ const track = score.tracks.find((item) => item.id === focusedId);
217
+ if (!track) return focusedId;
218
+ if (
219
+ isDrumInstrument(track.instrument) ||
220
+ isSamplerInstrument(track.instrument)
221
+ )
222
+ return focusedId;
223
+ return score.notes.some((note) => note.trackId === focusedId)
224
+ ? undefined
225
+ : focusedId;
226
+ }
227
+
228
+ /** The focused sampler's voices plus `voice` → `ref` (oneshot). */
229
+ export function withVoice(
230
+ score: TrackScore,
231
+ trackId: string,
232
+ voice: string,
233
+ ref: SampleRef,
234
+ ): Sampler {
235
+ const track = score.tracks.find((item) => item.id === trackId);
236
+ const previous =
237
+ track &&
238
+ isSamplerInstrument(track.instrument) &&
239
+ track.sampler?.mode === "oneshot"
240
+ ? track.sampler
241
+ : undefined;
242
+ return Object.freeze({
243
+ ...(previous ?? {}),
244
+ mode: "oneshot",
245
+ voices: Object.freeze({ ...(previous?.voices ?? {}), [voice]: ref }),
246
+ });
247
+ }
248
+
249
+ /** A sampler voice name from a sound (`RolandTR909_bd:2` → `bd_2`). */
250
+ export function voiceNameForSound(sound: string, n: number | string): string {
251
+ const base = (
252
+ sound.includes("_") ? sound.slice(sound.lastIndexOf("_") + 1) : sound
253
+ )
254
+ .toLowerCase()
255
+ .replace(/[^a-z0-9_]+/g, "_")
256
+ .replace(/^[^a-z]+/, "")
257
+ .slice(0, 24);
258
+ const stem = base || "sound";
259
+ return n === 0
260
+ ? stem
261
+ : `${stem}_${String(n)
262
+ .toLowerCase()
263
+ .replace(/[^a-z0-9]/g, "")}`.slice(0, 32);
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Use a sound (window and agent)
268
+
269
+ export type UseSoundResult = Readonly<{
270
+ operations: ScoreOperation[];
271
+ trackId: string;
272
+ summary: string;
273
+ /** Packs whose sounds the edit pins (for credits). */
274
+ license: string;
275
+ }>;
276
+
277
+ /**
278
+ * Pins a pack sound onto a track. A keyed sound (note zones, e.g.
279
+ * `gm/gm_acoustic_grand_piano`, `piano/piano`) makes the track a keyed
280
+ * instrument; a bank name (`tidal-drum-machines/RolandTR909`, or a `/kit`
281
+ * name) makes it a drum kit; anything else adds one oneshot voice.
282
+ */
283
+ export async function useSound(
284
+ store: PackStore,
285
+ score: TrackScore,
286
+ trackId: string,
287
+ sound: string,
288
+ options: Readonly<{
289
+ voice?: string;
290
+ mode?: "auto" | "keyed" | "oneshot";
291
+ }> = {},
292
+ ): Promise<UseSoundResult> {
293
+ const kitName = sound.replace(/^pack:/, "");
294
+ if (!kitName.includes("/") || DEFAULT_KITS[kitName.toLowerCase()])
295
+ return useKit(store, score, trackId, kitName);
296
+ const src = normalizeSoundRef(sound);
297
+ const ref = src ? parsePackRef(src) : undefined;
298
+ if (!ref)
299
+ throw new PackError(`${sound.slice(0, 80)} · use <pack>/<sound>[:<n>]`);
300
+ const manifest = await store.manifest(ref.pack);
301
+ const key = await store.soundKey(ref.pack, manifest, ref.sound);
302
+ const entry = key === undefined ? undefined : manifest.sounds.get(key);
303
+ if (!entry || key === undefined) {
304
+ const bank = await store.bankKey(ref.pack, manifest, ref.sound);
305
+ if (bank) return useKit(store, score, trackId, bank, ref.pack);
306
+ throw new PackError(
307
+ `${ref.pack} has no sound ${ref.sound} · search_sounds or /pack info ${ref.pack}`,
308
+ );
309
+ }
310
+ const pack = (await store.info(ref.pack)) as PackInfo;
311
+ const keyed =
312
+ options.mode === "keyed" ||
313
+ (options.mode !== "oneshot" && entry.kind === "zones");
314
+ if (keyed) {
315
+ const sampler = await pinInstrument(ref.pack, key, store);
316
+ return {
317
+ operations: samplerOperations(score, trackId, sampler, key),
318
+ trackId,
319
+ summary: `${trackId} plays ${ref.pack}/${key} · ${Object.keys(sampler.voices).length} zones · ${pack.license}`,
320
+ license: pack.license,
321
+ };
322
+ }
323
+ const pinned = await store.pin(ref);
324
+ const voice = options.voice ?? voiceNameForSound(ref.sound, ref.n);
325
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(voice))
326
+ throw new PackError(
327
+ `voice "${voice.slice(0, 40)}" is not a valid name · use a-z, 0-9 and _`,
328
+ );
329
+ const sampler = withVoice(score, trackId, voice, pinned);
330
+ return {
331
+ operations: samplerOperations(score, trackId, sampler),
332
+ trackId,
333
+ summary: `${voice} on ${trackId} · ${pinned.src} · ${pack.license}`,
334
+ license: pack.license,
335
+ };
336
+ }
337
+
338
+ /** `/kit <bank>` on a track: pins one oneshot voice per drum voice. */
339
+ export async function useKit(
340
+ store: PackStore,
341
+ score: TrackScore,
342
+ trackId: string,
343
+ bank: string,
344
+ packName?: string,
345
+ ): Promise<UseSoundResult> {
346
+ const kit = await kitFromBank(
347
+ bank,
348
+ store,
349
+ packName ? { pack: packName } : {},
350
+ );
351
+ if (!kit || (packName && kit.pack !== packName))
352
+ throw new PackError(
353
+ `no kit ${bank.slice(0, 40)} · /kit list shows banks and nicknames (909, TR808, sp12, RolandTR909, …)`,
354
+ );
355
+ const sampler = await pinKit(kit, store);
356
+ const missing = ["kick", "snare", "hat"].filter(
357
+ (voice) => !kit.voices.has(voice as never),
358
+ );
359
+ return {
360
+ operations: samplerOperations(score, trackId, sampler, "drums"),
361
+ trackId,
362
+ summary: `kit ${kit.bank || kit.pack} on ${trackId} · ${kit.voices.size} voices${missing.length ? ` · no ${missing.join("/")}` : ""} · ${kit.pack} (${kit.license})`,
363
+ license: kit.license,
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Lines for `/kit list`: dawg's short names, then every Strudel bank
369
+ * nickname (`TR909 · RolandTR909`), then a hint for full bank names.
370
+ */
371
+ export function kitListLines(aliases: BankAliases): string[] {
372
+ const lines = Object.entries(DEFAULT_KITS).map(
373
+ ([name, kit]) => `${name} · ${kit.bank || "(whole pack)"} · ${kit.pack}`,
374
+ );
375
+ const nicknames = [...aliases.nicknames].sort(([, a], [, b]) =>
376
+ a.toLowerCase() < b.toLowerCase() ? -1 : 1,
377
+ );
378
+ if (nicknames.length) {
379
+ lines.push(`Strudel nicknames (${nicknames.length}, any case):`);
380
+ for (const [bank, nickname] of nicknames)
381
+ lines.push(`${nickname} · ${bank} · ${ALIASED_PACK}`);
382
+ }
383
+ lines.push(
384
+ "any bank of a pack also works: /kit RolandTR727, /kit AkaiMPC60, …",
385
+ );
386
+ return lines;
387
+ }
388
+
389
+ export type CacheReport = Readonly<{
390
+ packs: CacheUsage & { max: number };
391
+ assets: CacheUsage & { max: number };
392
+ freed?: Readonly<{ files: number; bytes: number }>;
393
+ }>;
394
+
395
+ /** Lines for `/pack cache`. */
396
+ export function cacheLines(report: CacheReport): string[] {
397
+ const row = (
398
+ label: string,
399
+ usage: CacheUsage & { max: number },
400
+ env: string,
401
+ ) =>
402
+ `${label} · ${formatBytes(usage.bytes)} of ${formatBytes(usage.max)} · ${usage.files} file${usage.files === 1 ? "" : "s"} · ${env}`;
403
+ return [
404
+ ...(report.freed
405
+ ? [
406
+ `pruned · ${report.freed.files} file${report.freed.files === 1 ? "" : "s"} · ${formatBytes(report.freed.bytes)} freed`,
407
+ ]
408
+ : []),
409
+ row("pack downloads", report.packs, "DAWG_PACKS_CACHE_MAX"),
410
+ row("decoded audio (this project)", report.assets, "DAWG_ASSETS_CACHE_MAX"),
411
+ "least recently used files go first; this project's sounds are never evicted",
412
+ "evicted sounds re-fetch by their pinned sha256 on next use",
413
+ "/pack cache prune [size] · /pack cache clear",
414
+ ];
415
+ }
416
+
417
+ /** Lines for `/pack list`. */
418
+ export function packListLines(packs: readonly PackInfo[]): string[] {
419
+ return packs.map(
420
+ (pack) =>
421
+ `${pack.name} · ${pack.license} · ${pack.title}${pack.builtin ? "" : ` · ${pack.source}`}`,
422
+ );
423
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Prompt grammar for generated rhythm rows (`core/euclid.ts`). Every
3
+ * command is one next score, so one revision and one undo step; the
4
+ * Euclidean editor (`src/tui/euclid.ts`) emits these same lines.
5
+ *
6
+ * euclid <voice> <pulses> [<steps>] [<field> <value> ...]
7
+ * euclid <voice> <field> <value> [<field> <value> ...]
8
+ * euclid <voice> off | freeze remove the row (freeze keeps notes)
9
+ * grid <voice> <steps> explicit steps: x hit, X accent, . rest
10
+ *
11
+ * Fields: steps, pulses, rotate (rot), division (div), repeats (rep), time,
12
+ * pace, ramp, velocity (vel), accent, accents, gate, legato on|off,
13
+ * probability (prob), seed, swing, nudge. `default` resets a field. Named
14
+ * fields merge into the voice's existing row, so `euclid hat rotate 2`
15
+ * only rotates.
16
+ */
17
+ import {
18
+ RHYTHM_DEFAULTS,
19
+ canonicalDivision,
20
+ normalizeRhythmRow,
21
+ rowSummary,
22
+ type RhythmRow,
23
+ } from "../../core/euclid.ts";
24
+ import {
25
+ removeRhythmRow,
26
+ rhythmVoicePitch,
27
+ setRhythmRow,
28
+ } from "../../core/rhythm.ts";
29
+ import { ScoreValidationError, type TrackScore } from "../../core/score.ts";
30
+
31
+ export type RhythmCommand =
32
+ | Readonly<{
33
+ type: "rhythm-set";
34
+ voice: string;
35
+ /** Field values to merge; `null` resets a field to its default. */
36
+ fields: Readonly<Record<string, number | string | boolean | null>>;
37
+ }>
38
+ | Readonly<{ type: "rhythm-remove"; voice: string; keepNotes: boolean }>;
39
+
40
+ const FIELD_ALIASES: Readonly<Record<string, string>> = Object.freeze({
41
+ steps: "steps",
42
+ pulses: "pulses",
43
+ hits: "pulses",
44
+ rotate: "rotate",
45
+ rot: "rotate",
46
+ division: "division",
47
+ div: "division",
48
+ repeats: "repeats",
49
+ rep: "repeats",
50
+ time: "time",
51
+ pace: "pace",
52
+ ramp: "ramp",
53
+ velocity: "velocity",
54
+ vel: "velocity",
55
+ accent: "accent",
56
+ accents: "accents",
57
+ gate: "gate",
58
+ sustain: "gate",
59
+ legato: "legato",
60
+ probability: "probability",
61
+ prob: "probability",
62
+ seed: "seed",
63
+ swing: "swing",
64
+ nudge: "nudge",
65
+ grid: "grid",
66
+ });
67
+
68
+ const DIVISION_FIELDS = new Set(["division", "time"]);
69
+ const NUMBER = /^-?\d+(?:\.\d+)?$/;
70
+ const VOICE = /^[A-Za-z0-9#_-]{1,32}$/;
71
+
72
+ /** Parses one rhythm command, or undefined when `prompt` is not one. */
73
+ export function parseRhythmCommand(prompt: string): RhythmCommand | undefined {
74
+ const text = prompt.trim().replace(/\s+/g, " ");
75
+ if (text.length > 512) return undefined;
76
+ const grid = text.match(/^grid ([A-Za-z0-9#_-]{1,32}) ([xX.\-]{1,64})$/i);
77
+ if (grid)
78
+ return {
79
+ type: "rhythm-set",
80
+ voice: grid[1]!.toLowerCase(),
81
+ fields: { grid: grid[2]! },
82
+ };
83
+ const head = text.match(/^euclid ([A-Za-z0-9#_-]{1,32})(?: (.*))?$/i);
84
+ if (!head) return undefined;
85
+ const voice = head[1]!;
86
+ if (!VOICE.test(voice)) return undefined;
87
+ const tokens = (head[2] ?? "").split(" ").filter((token) => token !== "");
88
+ if (tokens.length === 1 && /^(off|clear|remove)$/i.test(tokens[0]!))
89
+ return { type: "rhythm-remove", voice: lowerDrum(voice), keepNotes: false };
90
+ if (tokens.length === 1 && /^freeze$/i.test(tokens[0]!))
91
+ return { type: "rhythm-remove", voice: lowerDrum(voice), keepNotes: true };
92
+ const fields: Record<string, number | string | boolean | null> = {};
93
+ let index = 0;
94
+ if (tokens[index] !== undefined && /^\d+$/.test(tokens[index]!)) {
95
+ fields.pulses = Number(tokens[index]);
96
+ index += 1;
97
+ if (tokens[index] !== undefined && /^\d+$/.test(tokens[index]!)) {
98
+ fields.steps = Number(tokens[index]);
99
+ index += 1;
100
+ }
101
+ }
102
+ while (index < tokens.length) {
103
+ const key = FIELD_ALIASES[tokens[index]!.toLowerCase()];
104
+ const raw = tokens[index + 1];
105
+ if (!key || raw === undefined) return undefined;
106
+ index += 2;
107
+ if (/^default$/i.test(raw)) fields[key] = null;
108
+ else if (key === "legato") {
109
+ if (!/^(on|off|true|false)$/i.test(raw)) return undefined;
110
+ fields[key] = /^(on|true)$/i.test(raw);
111
+ } else if (DIVISION_FIELDS.has(key)) {
112
+ const division = canonicalDivision(raw);
113
+ if (!division) return undefined;
114
+ fields[key] = division;
115
+ } else if (key === "grid") {
116
+ if (!/^[xX.\-]{1,64}$/.test(raw)) return undefined;
117
+ fields[key] = raw;
118
+ } else {
119
+ if (!NUMBER.test(raw)) return undefined;
120
+ fields[key] = Number(raw);
121
+ }
122
+ }
123
+ return { type: "rhythm-set", voice: lowerDrum(voice), fields };
124
+ }
125
+
126
+ /** Pitch voices keep their case (`C#2`); drum and sampler names are lower-case. */
127
+ function lowerDrum(voice: string): string {
128
+ return /^[A-Ga-g][#b]?-?\d$/.test(voice) ? voice : voice.toLowerCase();
129
+ }
130
+
131
+ export type RhythmResult = Readonly<{
132
+ ok: boolean;
133
+ message: string;
134
+ next?: TrackScore;
135
+ kind?: string;
136
+ payload?: Record<string, unknown>;
137
+ }>;
138
+
139
+ /**
140
+ * The row `command` produces from `current` (the voice's existing row, if
141
+ * any). Throws `Error` naming the field when the result is invalid.
142
+ */
143
+ export function mergeRhythmRow(
144
+ current: RhythmRow | undefined,
145
+ voice: string,
146
+ fields: Readonly<Record<string, number | string | boolean | null>>,
147
+ ): RhythmRow {
148
+ const merged: Record<string, unknown> = { ...(current ?? { voice }) };
149
+ merged.voice = voice;
150
+ for (const [key, value] of Object.entries(fields)) {
151
+ if (value === null) delete merged[key];
152
+ else merged[key] = value;
153
+ }
154
+ // Naming pulses or steps switches a grid row back to a Euclidean one.
155
+ if ("pulses" in fields || "steps" in fields)
156
+ if (!("grid" in fields)) delete merged.grid;
157
+ if ("grid" in fields && fields.grid !== null) {
158
+ delete merged.steps;
159
+ delete merged.pulses;
160
+ delete merged.rotate;
161
+ }
162
+ // Keep pulses inside a shortened row instead of rejecting the edit.
163
+ if (merged.grid === undefined) {
164
+ const steps =
165
+ typeof merged.steps === "number" ? merged.steps : RHYTHM_DEFAULTS.steps;
166
+ const pulses =
167
+ typeof merged.pulses === "number"
168
+ ? merged.pulses
169
+ : RHYTHM_DEFAULTS.pulses;
170
+ if (!("pulses" in fields) && pulses > steps) merged.pulses = steps;
171
+ }
172
+ return normalizeRhythmRow(merged, voice);
173
+ }
174
+
175
+ /** Applies `command` to `trackId`; one next score, one session event. */
176
+ export function applyRhythmCommand(
177
+ score: TrackScore,
178
+ trackId: string,
179
+ command: RhythmCommand,
180
+ ): RhythmResult {
181
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
182
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
183
+ const existing = (track.rhythm ?? []).find(
184
+ (row) => row.voice === command.voice,
185
+ );
186
+ if (command.type === "rhythm-remove") {
187
+ if (!existing)
188
+ return { ok: false, message: `no ${command.voice} row on ${trackId}` };
189
+ return {
190
+ ok: true,
191
+ message: `euclid · ${command.voice} ${command.keepNotes ? "frozen" : "off"}`,
192
+ next: removeRhythmRow(score, trackId, command.voice, command.keepNotes),
193
+ kind: "score.rhythm",
194
+ payload: {
195
+ trackId,
196
+ voice: command.voice,
197
+ remove: true,
198
+ keepNotes: command.keepNotes,
199
+ },
200
+ };
201
+ }
202
+ if (rhythmVoicePitch(track, command.voice) === undefined)
203
+ return {
204
+ ok: false,
205
+ message: `${command.voice} is not a voice on ${trackId} · try instrument kit`,
206
+ };
207
+ let row: RhythmRow;
208
+ try {
209
+ row = mergeRhythmRow(existing, command.voice, command.fields);
210
+ } catch (error) {
211
+ return {
212
+ ok: false,
213
+ message: `euclid · ${error instanceof Error ? error.message : String(error)}`,
214
+ };
215
+ }
216
+ try {
217
+ const next = setRhythmRow(score, trackId, row);
218
+ return {
219
+ ok: true,
220
+ message: `euclid · ${row.voice} ${rowSummary(row)}`,
221
+ next,
222
+ kind: "score.rhythm",
223
+ payload: { trackId, row },
224
+ };
225
+ } catch (error) {
226
+ if (error instanceof ScoreValidationError)
227
+ return { ok: false, message: `euclid · ${error.message}` };
228
+ throw error;
229
+ }
230
+ }