@hraness/dawg 0.3.0 → 0.4.0

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 +122 -0
  2. package/DAWG.md +605 -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 +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -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 +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -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 +280 -278
  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 +1119 -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 +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -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 +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
package/core/diff.ts CHANGED
@@ -26,7 +26,16 @@ export class DiffError extends Error {
26
26
  }
27
27
  }
28
28
 
29
- const EFFECTS = ["filter", "delay", "reverb", "sampler"] as const;
29
+ const EFFECTS = [
30
+ "filter",
31
+ "delay",
32
+ "reverb",
33
+ "sampler",
34
+ "fx",
35
+ "fxAutomation",
36
+ "synth",
37
+ "wavetable",
38
+ ] as const;
30
39
  const LANES = [
31
40
  "volumeAutomation",
32
41
  "panAutomation",
@@ -34,6 +43,7 @@ const LANES = [
34
43
  "resonanceAutomation",
35
44
  "delayFeedbackAutomation",
36
45
  "delayMixAutomation",
46
+ "wtAutomation",
37
47
  ] as const;
38
48
 
39
49
  /** Operations turning `a` into `b`; empty when they are equal. */
@@ -149,6 +159,8 @@ function trackPatch(a: Track, b: Track): TrackPatch | undefined {
149
159
  if ((a.solo ?? false) !== (b.solo ?? false)) patch.solo = b.solo ?? false;
150
160
  for (const key of EFFECTS)
151
161
  if (!deepEqual(a[key], b[key])) patch[key] = b[key] ?? null;
162
+ for (const key of ["rhythm", "kit"] as const)
163
+ if (!deepEqual(a[key], b[key])) patch[key] = b[key] ?? null;
152
164
  for (const key of LANES)
153
165
  if (!deepEqual(a[key] ?? [], b[key] ?? [])) patch[key] = b[key] ?? [];
154
166
  return Object.keys(patch).length > 0 ? (patch as TrackPatch) : undefined;
package/core/euclid.ts ADDED
@@ -0,0 +1,670 @@
1
+ /**
2
+ * Euclidean rhythms and T-1-style rhythm rows.
3
+ *
4
+ * `bjorklund(pulses, steps)` is a port of Strudel's `bjorklund` (itself
5
+ * Rohan Drape's Haskell `bjorklund`), so `E(3,8)` here is the same
6
+ * `x..x..x.` Strudel plays, and `euclidRot(p, s, r)` rotates the same way
7
+ * Strudel's `euclidRot` does: a positive rotation moves the pattern later
8
+ * (right) by `r` steps.
9
+ *
10
+ * A `RhythmRow` is the stored generator: one voice, T-1 Shape/Groove
11
+ * parameters (steps, pulses, rotate, division, repeats, time, pace, ramp,
12
+ * velocity, accent, gate, probability + seed, swing, nudge, cycles). It is
13
+ * plain JSON on a track (`track.rhythm`) and `expandRow` turns it into hits
14
+ * deterministically: the same row and loop always produce the same notes.
15
+ * The score keeps those hits as ordinary notes, so rendering, diffing and
16
+ * sync never see the generator.
17
+ *
18
+ * Pure and dependency-free: `core/score.ts` imports the types and the
19
+ * validator from here.
20
+ */
21
+
22
+ // ── Bjorklund (Strudel-compatible) ────────────────────────────────────
23
+
24
+ type Groups = [number[][], number[][]];
25
+
26
+ function bjorklundStep(
27
+ counts: [number, number],
28
+ groups: Groups,
29
+ ): [[number, number], Groups] {
30
+ const [ons, offs] = counts;
31
+ if (Math.min(ons, offs) <= 1) return [counts, groups];
32
+ const [xs, ys] = groups;
33
+ if (ons > offs) {
34
+ // Strudel `left`: split the ons at `offs`, append each off to one of them.
35
+ const head = xs.slice(0, offs);
36
+ const rest = xs.slice(offs);
37
+ return bjorklundStep(
38
+ [offs, ons - offs],
39
+ [head.map((a, index) => a.concat(ys[index]!)), rest],
40
+ );
41
+ }
42
+ // Strudel `right`: append the first `ons` offs to the ons.
43
+ const head = ys.slice(0, ons);
44
+ const rest = ys.slice(ons);
45
+ return bjorklundStep(
46
+ [ons, offs - ons],
47
+ [xs.map((a, index) => a.concat(head[index]!)), rest],
48
+ );
49
+ }
50
+
51
+ /**
52
+ * The Euclidean pattern E(pulses, steps) as 0/1, exactly as Strudel's
53
+ * `bjorklund`: `bjorklund(3, 8)` → `[1,0,0,1,0,0,1,0]`. Negative `pulses`
54
+ * inverts the pattern (Strudel's convention); `|pulses| > steps` is clamped.
55
+ */
56
+ export function bjorklund(pulses: number, steps: number): number[] {
57
+ const count = Math.max(0, Math.floor(steps));
58
+ if (count === 0) return [];
59
+ const inverted = pulses < 0;
60
+ const ons = Math.min(count, Math.abs(Math.trunc(pulses)));
61
+ const offs = count - ons;
62
+ const ones = Array.from({ length: ons }, () => [1]);
63
+ const zeros = Array.from({ length: offs }, () => [0]);
64
+ const [, [xs, ys]] = bjorklundStep([ons, offs], [ones, zeros]);
65
+ const pattern = [...xs.flat(), ...ys.flat()];
66
+ return inverted ? pattern.map((value) => 1 - value) : pattern;
67
+ }
68
+
69
+ /**
70
+ * `bjorklund(pulses, steps)` rotated like Strudel's `euclidRot`: positive
71
+ * `rotation` moves every hit `rotation` steps later. Rotations outside
72
+ * `-steps..steps` wrap (Strudel leaves those unrotated; within range both
73
+ * agree).
74
+ */
75
+ export function euclidRot(
76
+ pulses: number,
77
+ steps: number,
78
+ rotation = 0,
79
+ ): number[] {
80
+ const pattern = bjorklund(pulses, steps);
81
+ const length = pattern.length;
82
+ if (length === 0) return pattern;
83
+ const shift = ((Math.trunc(rotation) % length) + length) % length;
84
+ if (shift === 0) return pattern;
85
+ return pattern.slice(length - shift).concat(pattern.slice(0, length - shift));
86
+ }
87
+
88
+ /** `x..x..x.` for a 0/1 pattern. */
89
+ export function patternText(pattern: readonly (number | boolean)[]): string {
90
+ return pattern.map((on) => (on ? "x" : ".")).join("");
91
+ }
92
+
93
+ // ── rhythm rows ───────────────────────────────────────────────────────
94
+
95
+ /** Per-pass overrides (T-1 Cycles): pass `n` of the row uses `cycles[n % length]`. */
96
+ export type RhythmCycle = Readonly<{
97
+ pulses?: number;
98
+ rotate?: number;
99
+ repeats?: number;
100
+ probability?: number;
101
+ velocity?: number;
102
+ }>;
103
+
104
+ /**
105
+ * One generated voice. Every field but `voice` is optional and omitted at
106
+ * its default, so a stored row is as short as what was changed.
107
+ */
108
+ export type RhythmRow = Readonly<{
109
+ /** Drum voice (`kick`), one-shot sampler voice, or a pitch on a melodic track. */
110
+ voice: string;
111
+ /** Length of the row in steps, 1..64 (default 16). Ignored with `grid`. */
112
+ steps?: number;
113
+ /** Hits spread Euclidean-style over `steps`, 0..steps (default 4). */
114
+ pulses?: number;
115
+ /** Steps to shift the pattern later (negative: earlier); default 0. */
116
+ rotate?: number;
117
+ /** Length of one step as a note value: `1/16` (default), `1/8`, `1/8t`, … */
118
+ division?: string;
119
+ /**
120
+ * Explicit steps instead of a Euclidean pattern (T-1 manual pulses):
121
+ * `x` hit, `X` accented hit, `.` or `-` rest. Its length is the step count.
122
+ */
123
+ grid?: string;
124
+ /** Extra triggers after each pulse, 0..16 (T-1 Repeats); default 0. */
125
+ repeats?: number;
126
+ /** Spacing of repeats as a note value (T-1 Time); default `division`. */
127
+ time?: string;
128
+ /** -1..1: repeats speed up (<0) or slow down (>0) progressively (T-1 Pace). */
129
+ pace?: number;
130
+ /** -1..1: velocity ramp across repeats, fade out (<0) or build (>0) (T-1 Ramp). */
131
+ ramp?: number;
132
+ /** Base velocity 0..1 (default 0.8). */
133
+ velocity?: number;
134
+ /** 0..1: how far accented hits rise toward full velocity (default 0). */
135
+ accent?: number;
136
+ /** Which pulses are accented: E(accents, pulses) over each pass (default 1, the first). */
137
+ accents?: number;
138
+ /** Note length as a fraction of a step, 0.05..4 (T-1 Sustain); default 1. */
139
+ gate?: number;
140
+ /** Each pulse lasts until the next one (Strudel `euclidLegato`); overrides `gate`. */
141
+ legato?: boolean;
142
+ /** Chance 0..1 that a pulse (and its repeats) plays (default 1). */
143
+ probability?: number;
144
+ /** Seed for `probability`; the same seed always drops the same hits. */
145
+ seed?: number;
146
+ /** -0.5..0.5 of a step: every second step later (>0) or earlier (T-1 Timing). */
147
+ swing?: number;
148
+ /** -0.5..0.5 of a step: the whole row later or earlier (T-1 Delay). */
149
+ nudge?: number;
150
+ /** Per-pass variations (T-1 Cycles), 1..16 entries. */
151
+ cycles?: readonly RhythmCycle[];
152
+ }>;
153
+
154
+ export const RHYTHM_LIMITS = Object.freeze({
155
+ maxRows: 16,
156
+ maxSteps: 64,
157
+ maxRepeats: 16,
158
+ maxCycles: 16,
159
+ maxRotate: 64,
160
+ maxSeed: 1_000_000,
161
+ minGate: 0.05,
162
+ maxGate: 4,
163
+ maxSwing: 0.5,
164
+ maxNudge: 0.5,
165
+ /** Shortest and longest step, in beats (1/64 triplet … 4 bars of 4/4). */
166
+ minDivisionBeats: 1 / 24,
167
+ maxDivisionBeats: 16,
168
+ } as const);
169
+
170
+ export const RHYTHM_DEFAULTS = Object.freeze({
171
+ steps: 16,
172
+ pulses: 4,
173
+ rotate: 0,
174
+ division: "1/16",
175
+ repeats: 0,
176
+ pace: 0,
177
+ ramp: 0,
178
+ velocity: 0.8,
179
+ accent: 0,
180
+ accents: 1,
181
+ gate: 1,
182
+ probability: 1,
183
+ seed: 0,
184
+ swing: 0,
185
+ nudge: 0,
186
+ } as const);
187
+
188
+ /** Divisions the editor steps through, shortest first. */
189
+ export const DIVISIONS: readonly string[] = Object.freeze([
190
+ "1/32",
191
+ "1/16t",
192
+ "1/16",
193
+ "1/8t",
194
+ "1/8",
195
+ "1/4t",
196
+ "1/4",
197
+ "1/2",
198
+ "1/1",
199
+ ]);
200
+
201
+ /** Beats in a note value: `1/16` → 0.25, `1/8t` → 1/3, `3/16` → 0.75. */
202
+ export function divisionBeats(value: string): number | undefined {
203
+ const match = /^(\d{1,2})\/(\d{1,3})(t?)$/i.exec(value.trim());
204
+ if (!match) return undefined;
205
+ const numerator = Number(match[1]);
206
+ const denominator = Number(match[2]);
207
+ if (numerator < 1 || denominator < 1) return undefined;
208
+ const beats = ((4 * numerator) / denominator) * (match[3] ? 2 / 3 : 1);
209
+ if (
210
+ beats < RHYTHM_LIMITS.minDivisionBeats - 1e-12 ||
211
+ beats > RHYTHM_LIMITS.maxDivisionBeats + 1e-12
212
+ )
213
+ return undefined;
214
+ return beats;
215
+ }
216
+
217
+ /** Canonical spelling: lower-case triplet suffix, no spaces. */
218
+ export function canonicalDivision(value: string): string | undefined {
219
+ return divisionBeats(value) === undefined
220
+ ? undefined
221
+ : value.trim().toLowerCase();
222
+ }
223
+
224
+ const GRID = /^[xX.\-]{1,64}$/;
225
+
226
+ /** Steps in a row (the grid's length when it has one). */
227
+ export function rowSteps(row: RhythmRow): number {
228
+ return row.grid?.length ?? row.steps ?? RHYTHM_DEFAULTS.steps;
229
+ }
230
+
231
+ /** The row's on/off pattern for one pass (cycle overrides applied). */
232
+ export function rowPattern(row: RhythmRow, pass = 0): number[] {
233
+ if (row.grid !== undefined)
234
+ return [...row.grid].map((char) => (char === "x" || char === "X" ? 1 : 0));
235
+ const cycle = cycleFor(row, pass);
236
+ return euclidRot(
237
+ cycle.pulses ?? row.pulses ?? RHYTHM_DEFAULTS.pulses,
238
+ rowSteps(row),
239
+ cycle.rotate ?? row.rotate ?? RHYTHM_DEFAULTS.rotate,
240
+ );
241
+ }
242
+
243
+ function cycleFor(row: RhythmRow, pass: number): RhythmCycle {
244
+ const cycles = row.cycles;
245
+ if (!cycles || cycles.length === 0) return {};
246
+ return cycles[pass % cycles.length]!;
247
+ }
248
+
249
+ /**
250
+ * Validates a row from unknown input and returns it frozen with default
251
+ * fields dropped. Throws `Error` with a message naming the field.
252
+ */
253
+ export function normalizeRhythmRow(
254
+ input: unknown,
255
+ label = "rhythm",
256
+ ): RhythmRow {
257
+ if (typeof input !== "object" || input === null || Array.isArray(input))
258
+ throw new Error(`${label} must be an object`);
259
+ const value = input as Record<string, unknown>;
260
+ for (const key of Object.keys(value))
261
+ if (!ROW_KEYS.has(key))
262
+ throw new Error(`${label} has unknown field ${key}`);
263
+ if (
264
+ typeof value.voice !== "string" ||
265
+ value.voice.trim().length === 0 ||
266
+ value.voice.length > 32
267
+ )
268
+ throw new Error(`${label} voice must be a short non-empty string`);
269
+ const out: Record<string, unknown> = { voice: value.voice.trim() };
270
+ let steps: number = RHYTHM_DEFAULTS.steps;
271
+ if (value.grid !== undefined) {
272
+ if (typeof value.grid !== "string" || !GRID.test(value.grid))
273
+ throw new Error(`${label} grid must be 1..64 of x X . -`);
274
+ out.grid = value.grid.replace(/-/g, ".");
275
+ steps = value.grid.length;
276
+ if (value.steps !== undefined && value.steps !== steps)
277
+ throw new Error(`${label} steps must match the grid length`);
278
+ } else {
279
+ steps = integer(
280
+ value.steps,
281
+ RHYTHM_DEFAULTS.steps,
282
+ 1,
283
+ RHYTHM_LIMITS.maxSteps,
284
+ `${label} steps`,
285
+ );
286
+ if (steps !== RHYTHM_DEFAULTS.steps) out.steps = steps;
287
+ }
288
+ const pulses = integer(
289
+ value.pulses,
290
+ RHYTHM_DEFAULTS.pulses,
291
+ 0,
292
+ RHYTHM_LIMITS.maxSteps,
293
+ `${label} pulses`,
294
+ );
295
+ if (value.grid === undefined && pulses > steps)
296
+ throw new Error(`${label} pulses must be at most steps (${steps})`);
297
+ if (value.grid === undefined && pulses !== RHYTHM_DEFAULTS.pulses)
298
+ out.pulses = pulses;
299
+ const rotate = integer(
300
+ value.rotate,
301
+ 0,
302
+ -RHYTHM_LIMITS.maxRotate,
303
+ RHYTHM_LIMITS.maxRotate,
304
+ `${label} rotate`,
305
+ );
306
+ if (value.grid === undefined && rotate !== 0) out.rotate = rotate;
307
+ for (const key of ["division", "time"] as const) {
308
+ if (value[key] === undefined) continue;
309
+ const text =
310
+ typeof value[key] === "string"
311
+ ? canonicalDivision(value[key])
312
+ : undefined;
313
+ if (text === undefined)
314
+ throw new Error(
315
+ `${label} ${key} must be a note value like "1/16" or "1/8t"`,
316
+ );
317
+ if (key === "division" && text === RHYTHM_DEFAULTS.division) continue;
318
+ out[key] = text;
319
+ }
320
+ const repeats = integer(
321
+ value.repeats,
322
+ 0,
323
+ 0,
324
+ RHYTHM_LIMITS.maxRepeats,
325
+ `${label} repeats`,
326
+ );
327
+ if (repeats !== 0) out.repeats = repeats;
328
+ const ranged: readonly [keyof typeof RHYTHM_DEFAULTS, number, number][] = [
329
+ ["pace", -1, 1],
330
+ ["ramp", -1, 1],
331
+ ["velocity", 0, 1],
332
+ ["accent", 0, 1],
333
+ ["gate", RHYTHM_LIMITS.minGate, RHYTHM_LIMITS.maxGate],
334
+ ["probability", 0, 1],
335
+ ["swing", -RHYTHM_LIMITS.maxSwing, RHYTHM_LIMITS.maxSwing],
336
+ ["nudge", -RHYTHM_LIMITS.maxNudge, RHYTHM_LIMITS.maxNudge],
337
+ ];
338
+ for (const [key, min, max] of ranged) {
339
+ const number = finite(
340
+ value[key],
341
+ RHYTHM_DEFAULTS[key] as number,
342
+ min,
343
+ max,
344
+ `${label} ${key}`,
345
+ );
346
+ if (number !== RHYTHM_DEFAULTS[key]) out[key] = number;
347
+ }
348
+ if (value.legato !== undefined && typeof value.legato !== "boolean")
349
+ throw new Error(`${label} legato must be true or false`);
350
+ if (value.legato === true) out.legato = true;
351
+ const accents = integer(
352
+ value.accents,
353
+ 1,
354
+ 0,
355
+ RHYTHM_LIMITS.maxSteps,
356
+ `${label} accents`,
357
+ );
358
+ if (accents !== 1) out.accents = accents;
359
+ const seed = integer(
360
+ value.seed,
361
+ 0,
362
+ 0,
363
+ RHYTHM_LIMITS.maxSeed,
364
+ `${label} seed`,
365
+ );
366
+ if (seed !== 0) out.seed = seed;
367
+ if (value.cycles !== undefined) {
368
+ if (
369
+ !Array.isArray(value.cycles) ||
370
+ value.cycles.length < 1 ||
371
+ value.cycles.length > RHYTHM_LIMITS.maxCycles
372
+ )
373
+ throw new Error(
374
+ `${label} cycles must be 1..${RHYTHM_LIMITS.maxCycles} entries`,
375
+ );
376
+ out.cycles = Object.freeze(
377
+ value.cycles.map((cycle: unknown, index) =>
378
+ normalizeCycle(cycle, steps, `${label} cycles[${index}]`),
379
+ ),
380
+ );
381
+ }
382
+ return Object.freeze(
383
+ ORDER.filter((key) => key in out).reduce<Record<string, unknown>>(
384
+ (row, key) => {
385
+ row[key] = out[key];
386
+ return row;
387
+ },
388
+ {},
389
+ ),
390
+ ) as RhythmRow;
391
+ }
392
+
393
+ const ORDER = [
394
+ "voice",
395
+ "steps",
396
+ "pulses",
397
+ "rotate",
398
+ "division",
399
+ "grid",
400
+ "repeats",
401
+ "time",
402
+ "pace",
403
+ "ramp",
404
+ "velocity",
405
+ "accent",
406
+ "accents",
407
+ "gate",
408
+ "legato",
409
+ "probability",
410
+ "seed",
411
+ "swing",
412
+ "nudge",
413
+ "cycles",
414
+ ] as const;
415
+ const ROW_KEYS = new Set<string>(ORDER);
416
+ const CYCLE_KEYS = new Set([
417
+ "pulses",
418
+ "rotate",
419
+ "repeats",
420
+ "probability",
421
+ "velocity",
422
+ ]);
423
+
424
+ function normalizeCycle(
425
+ input: unknown,
426
+ steps: number,
427
+ label: string,
428
+ ): RhythmCycle {
429
+ if (typeof input !== "object" || input === null || Array.isArray(input))
430
+ throw new Error(`${label} must be an object`);
431
+ const value = input as Record<string, unknown>;
432
+ const out: Record<string, number> = {};
433
+ for (const key of Object.keys(value))
434
+ if (!CYCLE_KEYS.has(key))
435
+ throw new Error(`${label} has unknown field ${key}`);
436
+ if (value.pulses !== undefined)
437
+ out.pulses = integer(value.pulses, 0, 0, steps, `${label} pulses`);
438
+ if (value.rotate !== undefined)
439
+ out.rotate = integer(
440
+ value.rotate,
441
+ 0,
442
+ -RHYTHM_LIMITS.maxRotate,
443
+ RHYTHM_LIMITS.maxRotate,
444
+ `${label} rotate`,
445
+ );
446
+ if (value.repeats !== undefined)
447
+ out.repeats = integer(
448
+ value.repeats,
449
+ 0,
450
+ 0,
451
+ RHYTHM_LIMITS.maxRepeats,
452
+ `${label} repeats`,
453
+ );
454
+ if (value.probability !== undefined)
455
+ out.probability = finite(
456
+ value.probability,
457
+ 1,
458
+ 0,
459
+ 1,
460
+ `${label} probability`,
461
+ );
462
+ if (value.velocity !== undefined)
463
+ out.velocity = finite(value.velocity, 0.8, 0, 1, `${label} velocity`);
464
+ return Object.freeze(out);
465
+ }
466
+
467
+ function integer(
468
+ value: unknown,
469
+ fallback: number,
470
+ min: number,
471
+ max: number,
472
+ label: string,
473
+ ): number {
474
+ if (value === undefined) return fallback;
475
+ if (
476
+ typeof value !== "number" ||
477
+ !Number.isInteger(value) ||
478
+ value < min ||
479
+ value > max
480
+ )
481
+ throw new Error(`${label} must be an integer ${min}..${max}`);
482
+ return value;
483
+ }
484
+
485
+ function finite(
486
+ value: unknown,
487
+ fallback: number,
488
+ min: number,
489
+ max: number,
490
+ label: string,
491
+ ): number {
492
+ if (value === undefined) return fallback;
493
+ if (
494
+ typeof value !== "number" ||
495
+ !Number.isFinite(value) ||
496
+ value < min ||
497
+ value > max
498
+ )
499
+ throw new Error(`${label} must be a number ${min}..${max}`);
500
+ return value;
501
+ }
502
+
503
+ // ── expansion ─────────────────────────────────────────────────────────
504
+
505
+ /** One generated hit, in score ticks. */
506
+ export type RhythmHit = Readonly<{
507
+ startTick: number;
508
+ durationTicks: number;
509
+ velocity: number;
510
+ /** Pass of the row (0-based) and step within it. */
511
+ pass: number;
512
+ step: number;
513
+ /** 0 for the pulse, 1.. for its repeats. */
514
+ repeat: number;
515
+ }>;
516
+
517
+ export type ExpandContext = Readonly<{
518
+ ticksPerBeat: number;
519
+ /** Loop length in ticks; hits at or past it are dropped. */
520
+ loopTicks: number;
521
+ }>;
522
+
523
+ /** Hits shorter than this velocity are dropped (a ramp fading to nothing). */
524
+ const MIN_VELOCITY = 0.01;
525
+
526
+ /**
527
+ * Every hit of `row` over the loop, sorted by start. Deterministic: depends
528
+ * only on the row and the context. Each pass of the row (steps × division)
529
+ * repeats from tick 0 until the loop ends; a row longer than the loop is cut.
530
+ */
531
+ export function expandRow(row: RhythmRow, context: ExpandContext): RhythmHit[] {
532
+ const tpb = context.ticksPerBeat;
533
+ const steps = rowSteps(row);
534
+ const stepTicks =
535
+ (divisionBeats(row.division ?? RHYTHM_DEFAULTS.division) ?? 0.25) * tpb;
536
+ const timeTicks = row.time
537
+ ? (divisionBeats(row.time) ?? 0.25) * tpb
538
+ : stepTicks;
539
+ const gate = row.gate ?? RHYTHM_DEFAULTS.gate;
540
+ const swing = row.swing ?? 0;
541
+ const nudge = row.nudge ?? 0;
542
+ const pace = row.pace ?? 0;
543
+ const ramp = row.ramp ?? 0;
544
+ const accent = row.accent ?? (row.grid?.includes("X") ? 0.25 : 0);
545
+ const seed = row.seed ?? 0;
546
+ const passTicks = steps * stepTicks;
547
+ if (!(passTicks > 0) || context.loopTicks <= 0) return [];
548
+ const passes = Math.ceil(context.loopTicks / passTicks);
549
+
550
+ type Pulse = {
551
+ start: number;
552
+ velocity: number;
553
+ pass: number;
554
+ step: number;
555
+ repeats: number;
556
+ };
557
+ const pulses: Pulse[] = [];
558
+ for (let pass = 0; pass < passes; pass += 1) {
559
+ const cycle = cycleFor(row, pass);
560
+ const pattern = rowPattern(row, pass);
561
+ const velocity = cycle.velocity ?? row.velocity ?? RHYTHM_DEFAULTS.velocity;
562
+ const probability = cycle.probability ?? row.probability ?? 1;
563
+ const repeats = cycle.repeats ?? row.repeats ?? 0;
564
+ const count = pattern.reduce((sum, on) => sum + on, 0);
565
+ const accented =
566
+ row.grid !== undefined
567
+ ? [...row.grid]
568
+ .filter((char) => char !== ".")
569
+ .map((char) => (char === "X" ? 1 : 0))
570
+ : bjorklund(
571
+ Math.min(row.accents ?? RHYTHM_DEFAULTS.accents, count),
572
+ count,
573
+ );
574
+ let ordinal = 0;
575
+ for (let step = 0; step < steps; step += 1) {
576
+ if (!pattern[step]) continue;
577
+ const index = ordinal;
578
+ ordinal += 1;
579
+ const global = pass * steps + step;
580
+ const offset = nudge + (global % 2 === 1 ? swing : 0);
581
+ const start = Math.max(0, Math.round((global + offset) * stepTicks));
582
+ if (start >= context.loopTicks) continue;
583
+ if (probability < 1 && chance(seed, row.voice, pass, step) >= probability)
584
+ continue;
585
+ const lifted = accented[index]
586
+ ? velocity + (1 - velocity) * accent
587
+ : velocity;
588
+ pulses.push({ start, velocity: lifted, pass, step, repeats });
589
+ }
590
+ }
591
+ pulses.sort((a, b) => a.start - b.start);
592
+
593
+ const hits: RhythmHit[] = [];
594
+ const minInterval = Math.max(1, Math.round(tpb / 32));
595
+ const factor = 1 + 0.5 * pace;
596
+ pulses.forEach((pulse, index) => {
597
+ const cutoff = pulses[index + 1]?.start ?? context.loopTicks;
598
+ const length = row.legato
599
+ ? Math.max(1, cutoff - pulse.start)
600
+ : Math.max(1, Math.round(gate * stepTicks));
601
+ push(hits, pulse.start, length, pulse.velocity, pulse, 0);
602
+ let at = pulse.start;
603
+ let interval = timeTicks;
604
+ for (let repeat = 1; repeat <= pulse.repeats; repeat += 1) {
605
+ at += Math.max(minInterval, interval);
606
+ const tick = Math.round(at);
607
+ if (tick >= cutoff) break;
608
+ const velocity = pulse.velocity * (1 + (ramp * repeat) / pulse.repeats);
609
+ const span = Math.max(
610
+ 1,
611
+ Math.round(
612
+ Math.min(gate * stepTicks, Math.max(minInterval, interval * factor)),
613
+ ),
614
+ );
615
+ push(hits, tick, span, velocity, pulse, repeat);
616
+ interval *= factor;
617
+ }
618
+ });
619
+ return hits;
620
+ }
621
+
622
+ function push(
623
+ hits: RhythmHit[],
624
+ startTick: number,
625
+ durationTicks: number,
626
+ velocity: number,
627
+ pulse: Readonly<{ pass: number; step: number }>,
628
+ repeat: number,
629
+ ): void {
630
+ const clamped = Math.round(Math.min(1, Math.max(0, velocity)) * 1000) / 1000;
631
+ if (clamped < MIN_VELOCITY) return;
632
+ hits.push(
633
+ Object.freeze({
634
+ startTick,
635
+ durationTicks,
636
+ velocity: clamped,
637
+ pass: pulse.pass,
638
+ step: pulse.step,
639
+ repeat,
640
+ }),
641
+ );
642
+ }
643
+
644
+ /** Deterministic [0, 1) from the seed and position (FNV-1a + xorshift mix). */
645
+ export function chance(
646
+ seed: number,
647
+ voice: string,
648
+ pass: number,
649
+ step: number,
650
+ ): number {
651
+ const text = `${seed}|${voice}|${pass}|${step}`;
652
+ let hash = 0x811c9dc5;
653
+ for (let index = 0; index < text.length; index += 1) {
654
+ hash ^= text.charCodeAt(index);
655
+ hash = Math.imul(hash, 0x01000193);
656
+ }
657
+ hash ^= hash >>> 16;
658
+ hash = Math.imul(hash, 0x85ebca6b);
659
+ hash ^= hash >>> 13;
660
+ hash = Math.imul(hash, 0xc2b2ae35);
661
+ hash ^= hash >>> 16;
662
+ return (hash >>> 0) / 0x1_0000_0000;
663
+ }
664
+
665
+ /** `E(4,16)`, `E(3,8,r2)` or `grid` for a status line. */
666
+ export function rowSummary(row: RhythmRow): string {
667
+ if (row.grid !== undefined) return `grid ${row.grid.length}`;
668
+ const rotate = row.rotate ? `,r${row.rotate}` : "";
669
+ return `E(${row.pulses ?? RHYTHM_DEFAULTS.pulses},${rowSteps(row)}${rotate})`;
670
+ }