@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
@@ -76,35 +76,47 @@ function diagnostic(error: unknown, project: string): Diagnostic {
76
76
  return out;
77
77
  }
78
78
 
79
- /** Adds `sha256` to sampler voices whose file exists and is within the size limit. */
79
+ /**
80
+ * Adds `sha256` to sampler voices and project wavetable files that exist
81
+ * and are within the size limit.
82
+ */
80
83
  async function withSampleHashes(
81
84
  song: object,
82
85
  project: string,
83
86
  ): Promise<unknown> {
84
87
  const plain = JSON.parse(JSON.stringify(song)) as Record<string, unknown>;
85
88
  const tracks = Array.isArray(plain.tracks) ? plain.tracks : [];
89
+ const refs: Record<string, unknown>[] = [];
90
+ const isObject = (value: unknown): value is Record<string, unknown> =>
91
+ typeof value === "object" && value !== null;
86
92
  for (const track of tracks) {
87
- if (typeof track !== "object" || track === null) continue;
88
- const sampler = (track as Record<string, unknown>).sampler;
89
- if (typeof sampler !== "object" || sampler === null) continue;
90
- const voices = (sampler as Record<string, unknown>).voices;
91
- if (typeof voices !== "object" || voices === null) continue;
92
- for (const voice of Object.values(voices as Record<string, unknown>)) {
93
- if (typeof voice !== "object" || voice === null) continue;
94
- const ref = voice as Record<string, unknown>;
95
- if (typeof ref.src !== "string" || typeof ref.sha256 === "string")
96
- continue;
97
- const path = resolve(project, ref.src);
98
- if (relative(project, path).startsWith("..")) continue;
99
- try {
100
- const info = await stat(path);
101
- if (!info.isFile() || info.size > MAX_SAMPLE_FILE_BYTES) continue;
102
- ref.sha256 = createHash("sha256")
103
- .update(await readFile(path))
104
- .digest("hex");
105
- } catch {
106
- // Missing sample: left for `dawg check` to report.
107
- }
93
+ if (!isObject(track)) continue;
94
+ const sampler = track.sampler;
95
+ if (isObject(sampler) && isObject(sampler.voices))
96
+ for (const voice of Object.values(sampler.voices))
97
+ if (isObject(voice)) refs.push(voice);
98
+ const wavetable = track.wavetable;
99
+ if (isObject(wavetable) && isObject(wavetable.table))
100
+ refs.push(wavetable.table);
101
+ }
102
+ for (const ref of refs) {
103
+ if (
104
+ typeof ref.src !== "string" ||
105
+ typeof ref.sha256 === "string" ||
106
+ ref.src.startsWith("pack:") ||
107
+ ref.src.startsWith("builtin:")
108
+ )
109
+ continue;
110
+ const path = resolve(project, ref.src);
111
+ if (relative(project, path).startsWith("..")) continue;
112
+ try {
113
+ const info = await stat(path);
114
+ if (!info.isFile() || info.size > MAX_SAMPLE_FILE_BYTES) continue;
115
+ ref.sha256 = createHash("sha256")
116
+ .update(await readFile(path))
117
+ .digest("hex");
118
+ } catch {
119
+ // Missing file: left for `dawg check` to report.
108
120
  }
109
121
  }
110
122
  return plain;
package/core/sdk/eval.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { decodeLoopDocument } from "../loop.ts";
14
+ import { refreshRhythm } from "../rhythm.ts";
14
15
  import { ScoreValidationError, TrackScore } from "../score.ts";
15
16
 
16
17
  export const EVAL_TIMEOUT_MS = 10_000;
@@ -158,7 +159,9 @@ export function parseChildOutput(
158
159
  return failure(started, [diagnostic]);
159
160
  }
160
161
  try {
161
- const score = decodeLoopDocument(record.score);
162
+ // Rhythm rows are generators: their lanes are expanded here, on the
163
+ // host, so `song.ts` stores parameters and the score stores notes.
164
+ const score = refreshRhythm(decodeLoopDocument(record.score));
162
165
  return { ok: true, score, ms: elapsed(started) };
163
166
  } catch (error) {
164
167
  return failure(started, [
package/core/sdk/print.ts CHANGED
@@ -8,10 +8,19 @@
8
8
  */
9
9
 
10
10
  import { DRUM_VOICES, isDrumInstrument } from "../drums.ts";
11
+ import type { RhythmRow } from "../euclid.ts";
12
+ import { FX_LANES, fxSpec, type FxName, type FxValues } from "../fx.ts";
11
13
  import { midiToPitch } from "../pitch.ts";
14
+ import { rhythmVoicePitch, rowInSync } from "../rhythm.ts";
12
15
  import {
16
+ BUILTIN_TABLE_PREFIX,
17
+ WAVETABLE_PARAMS,
13
18
  isSamplerInstrument,
19
+ isWavetableInstrument,
14
20
  samplerVoiceSlots,
21
+ wavetableOf,
22
+ type TrackWavetable,
23
+ type WavetableParam,
15
24
  TrackScore,
16
25
  type AutomationPoint,
17
26
  type Note,
@@ -121,7 +130,12 @@ const RESERVED = new Set([
121
130
  "hits",
122
131
  "every",
123
132
  "sampler",
133
+ "wavetable",
124
134
  "slices",
135
+ "euclid",
136
+ "euclidRot",
137
+ "euclidLegato",
138
+ "grid",
125
139
  ]);
126
140
 
127
141
  /** Import identifiers for each track, unique in score order. */
@@ -178,7 +192,15 @@ export function printSong(
178
192
 
179
193
  /** `tracks/<slug>/track.ts` for one track of a score. */
180
194
  export function printTrack(score: TrackScore, track: Track): string {
181
- const notes = score.notes.filter((note) => note.trackId === track.id);
195
+ // Rows whose lane still matches their expansion print as generators and
196
+ // own their notes; a hand-edited lane prints as plain notes instead.
197
+ const rows = (track.rhythm ?? []).filter((row) =>
198
+ rowInSync(score, track, row),
199
+ );
200
+ const owned = new Set(rows.map((row) => rhythmVoicePitch(track, row.voice)));
201
+ const notes = score.notes.filter(
202
+ (note) => note.trackId === track.id && !owned.has(note.pitch),
203
+ );
182
204
  const kit = isDrumInstrument(track.instrument);
183
205
  const voiceSlots =
184
206
  isSamplerInstrument(track.instrument) && track.sampler
@@ -200,6 +222,10 @@ export function printTrack(score: TrackScore, track: Track): string {
200
222
  return printNote(score, note, !kit && !slots);
201
223
  });
202
224
  if (track.sampler) used.add("sampler");
225
+ const wavetable = isWavetableInstrument(track.instrument)
226
+ ? wavetableOf(track)
227
+ : undefined;
228
+ if (wavetable) used.add("wavetable");
203
229
 
204
230
  const entries: string[] = [
205
231
  `id: ${str(track.id)}`,
@@ -207,7 +233,10 @@ export function printTrack(score: TrackScore, track: Track): string {
207
233
  ];
208
234
  if (track.sampler) {
209
235
  entries.push(`instrument: ${printSampler(track.sampler, INDENT)}`);
236
+ } else if (wavetable) {
237
+ entries.push(`instrument: ${printWavetable(wavetable, INDENT)}`);
210
238
  } else entries.push(`instrument: ${str(track.instrument)}`);
239
+ if (track.kit) entries.push(`kit: ${str(track.kit)}`);
211
240
  if (track.muted) entries.push("muted: true");
212
241
  if (track.solo) entries.push("solo: true");
213
242
  if (track.volume !== 1) entries.push(`volume: ${num(track.volume)}`);
@@ -218,6 +247,7 @@ export function printTrack(score: TrackScore, track: Track): string {
218
247
  [
219
248
  ["cutoff", num(track.filter.cutoff)],
220
249
  ["resonance", num(track.filter.resonance)],
250
+ ...optional(track.filter, ["type", "ftype"]),
221
251
  ],
222
252
  INDENT,
223
253
  "filter: ".length,
@@ -231,6 +261,7 @@ export function printTrack(score: TrackScore, track: Track): string {
231
261
  ["beats", num(track.delay.beats)],
232
262
  ["feedback", num(track.delay.feedback)],
233
263
  ["mix", num(track.delay.mix)],
264
+ ...optional(track.delay, ["time", "pingpong", "highcut"]),
234
265
  ],
235
266
  INDENT,
236
267
  "delay: ".length,
@@ -243,12 +274,56 @@ export function printTrack(score: TrackScore, track: Track): string {
243
274
  [
244
275
  ["mix", num(track.reverb.mix)],
245
276
  ["size", num(track.reverb.size)],
277
+ ...optional(track.reverb, ["fade", "lowpass", "dim", "predelay"]),
278
+ ...(track.reverb.ir
279
+ ? ([
280
+ [
281
+ "ir",
282
+ track.reverb.ir.src.startsWith(BUILTIN_TABLE_PREFIX)
283
+ ? str(
284
+ track.reverb.ir.src.slice(BUILTIN_TABLE_PREFIX.length),
285
+ )
286
+ : printSample(
287
+ track.reverb.ir,
288
+ INDENT + INDENT,
289
+ "ir: ".length,
290
+ ),
291
+ ],
292
+ ] as const)
293
+ : []),
246
294
  ],
247
295
  INDENT,
248
296
  "reverb: ".length,
249
297
  1,
250
298
  )}`,
251
299
  );
300
+ if (track.fx) {
301
+ const effects = Object.entries(track.fx) as [FxName, FxValues][];
302
+ const inner = INDENT + INDENT;
303
+ const body = effects.map(([effect, values]) => {
304
+ // Only values that differ from the default: decoding fills the rest.
305
+ const params = Object.entries(fxSpec(effect).params)
306
+ .filter(
307
+ ([key, spec]) =>
308
+ values[key] !== undefined && values[key] !== spec.default,
309
+ )
310
+ .map(([key]) => [key, value(values[key]!)] as const);
311
+ return `${inner}${effect}: ${params.length === 0 ? "{}" : obj(params, inner, `${effect}: `.length, 1)},`;
312
+ });
313
+ entries.push(`fx: {\n${body.join("\n")}\n${INDENT}}`);
314
+ }
315
+ if (track.synth) {
316
+ const params = Object.entries(track.synth).map(
317
+ ([key, v]) =>
318
+ [
319
+ key,
320
+ Array.isArray(v)
321
+ ? `[${(v as readonly number[]).map(num).join(", ")}]`
322
+ : value(v as number | string | boolean),
323
+ ] as const,
324
+ );
325
+ entries.push(`synth: ${obj(params, INDENT, "synth: ".length, 1)}`);
326
+ }
252
327
  const lanes: [string, readonly AutomationPoint[] | undefined][] = [
253
328
  ["volume", track.volumeAutomation],
254
329
  ["pan", track.panAutomation],
@@ -256,23 +331,70 @@ export function printTrack(score: TrackScore, track: Track): string {
256
331
  ["resonance", track.resonanceAutomation],
257
332
  ["delayFeedback", track.delayFeedbackAutomation],
258
333
  ["delayMix", track.delayMixAutomation],
334
+ ["wt", track.wtAutomation],
259
335
  ];
260
336
  const automation = lanes.filter(([, points]) => points && points.length > 0);
261
- if (automation.length > 0) {
337
+ const fxLanes = FX_LANES.filter(
338
+ ({ lane }) => (track.fxAutomation?.[lane]?.length ?? 0) > 0,
339
+ ).map(({ lane }) => [lane, track.fxAutomation![lane]!] as const);
340
+ const laneLine = (
341
+ name: string,
342
+ points: readonly AutomationPoint[],
343
+ indent: string,
344
+ ) => {
345
+ const items = points.map(
346
+ (point) =>
347
+ `[${num(point.tick / score.ticksPerBeat)}, ${num(point.value)}]`,
348
+ );
349
+ // Prettier forces a break on arrays of 2+ arrays that each hold 2+ items.
350
+ return `${indent}${name}: ${list(items, indent, `${name}: `.length, 1, items.length > 1)},`;
351
+ };
352
+ if (automation.length > 0 || fxLanes.length > 0) {
262
353
  const inner = INDENT + INDENT;
263
- const body = automation.map(([lane, points]) => {
264
- const items = points!.map(
265
- (point) =>
266
- `[${num(point.tick / score.ticksPerBeat)}, ${num(point.value)}]`,
354
+ const body = automation.map(([lane, points]) =>
355
+ laneLine(lane, points!, inner),
356
+ );
357
+ if (fxLanes.length > 0) {
358
+ const deeper = inner + INDENT;
359
+ const fxBody = fxLanes.map(([lane, points]) =>
360
+ laneLine(str(lane), points, deeper),
267
361
  );
268
- // Prettier forces a break on arrays of 2+ arrays that each hold 2+ items.
269
- return `${inner}${lane}: ${list(items, inner, `${lane}: `.length, 1, items.length > 1)},`;
270
- });
362
+ body.push(`${inner}fx: {\n${fxBody.join("\n")}\n${inner}},`);
363
+ }
271
364
  entries.push(`automation: {\n${body.join("\n")}\n${INDENT}}`);
272
365
  }
273
- entries.push(`notes: ${list(printed, INDENT, "notes: ".length, 1)}`);
366
+ if (rows.length > 0) {
367
+ const inner = INDENT + INDENT;
368
+ const items = rows.map((row) => {
369
+ const call = printRow(row, inner);
370
+ used.add(call.slice(0, call.indexOf("(")));
371
+ return call;
372
+ });
373
+ entries.push(
374
+ `rhythm: ${list(
375
+ items,
376
+ INDENT,
377
+ "rhythm: ".length,
378
+ 1,
379
+ items.some((item) => item.includes("\n")),
380
+ )}`,
381
+ );
382
+ }
383
+ if (rows.length === 0 || printed.length > 0)
384
+ entries.push(`notes: ${list(printed, INDENT, "notes: ".length, 1)}`);
274
385
 
275
- const names = ["track", "note", "seq", "hit", "hits", "every", "sampler"]
386
+ const names = [
387
+ "track",
388
+ "note",
389
+ "seq",
390
+ "hit",
391
+ "hits",
392
+ "every",
393
+ "sampler",
394
+ "wavetable",
395
+ "euclid",
396
+ "grid",
397
+ ]
276
398
  .filter((name) => used.has(name))
277
399
  .join(", ");
278
400
  const lines = [
@@ -304,23 +426,133 @@ function printHit(score: TrackScore, note: Note, voice: string): string {
304
426
  return `hit(${args.join(", ")})`;
305
427
  }
306
428
 
429
+ /** `euclid("hat", 7, 16, 2, { velocity: 0.5 })` or `grid("sd", "....x...")`. */
430
+ function printRow(row: RhythmRow, indent: string): string {
431
+ const args: string[] = [str(row.voice)];
432
+ const fields: [string, string][] = [];
433
+ if (row.grid !== undefined) args.push(str(row.grid));
434
+ else {
435
+ args.push(num(row.pulses ?? 4), num(row.steps ?? 16));
436
+ if (row.rotate) args.push(num(row.rotate));
437
+ }
438
+ const inner = indent + INDENT;
439
+ for (const key of [
440
+ "division",
441
+ "repeats",
442
+ "time",
443
+ "pace",
444
+ "ramp",
445
+ "velocity",
446
+ "accent",
447
+ "accents",
448
+ "gate",
449
+ "legato",
450
+ "probability",
451
+ "seed",
452
+ "swing",
453
+ "nudge",
454
+ "cycles",
455
+ ] as const) {
456
+ const value = row[key];
457
+ if (value === undefined) continue;
458
+ if (key === "cycles") {
459
+ const cycles = row.cycles!.map((cycle) =>
460
+ Object.keys(cycle).length === 0
461
+ ? "{}"
462
+ : obj(
463
+ Object.entries(cycle).map(
464
+ ([k, v]) => [k, num(v as number)] as const,
465
+ ),
466
+ inner + INDENT,
467
+ 0,
468
+ 1,
469
+ ),
470
+ );
471
+ const force =
472
+ cycles.length > 1 &&
473
+ row.cycles!.every((cycle) => Object.keys(cycle).length > 1);
474
+ fields.push([key, list(cycles, inner, "cycles: ".length, 1, force)]);
475
+ } else
476
+ fields.push([
477
+ key,
478
+ typeof value === "string"
479
+ ? str(value)
480
+ : typeof value === "boolean"
481
+ ? String(value)
482
+ : num(value as number),
483
+ ]);
484
+ }
485
+ // Options are euclid's fifth argument, so rotate 0 is spelled out before them.
486
+ if (row.grid === undefined && !row.rotate && fields.length > 0)
487
+ args.push("0");
488
+ const name = row.grid !== undefined ? "grid" : "euclid";
489
+ const head = `${name}(${args.join(", ")}`;
490
+ if (fields.length === 0) return `${head})`;
491
+ const inlineObject = `{ ${fields.map(([k, v]) => `${k}: ${v}`).join(", ")} }`;
492
+ const inline = `${head}, ${inlineObject})`;
493
+ if (!inline.includes("\n") && indent.length + inline.length + 1 <= WIDTH)
494
+ return inline;
495
+ const body = fields.map(([k, v]) => `${inner}${k}: ${v},`).join("\n");
496
+ return `${head}, {\n${body}\n${indent}})`;
497
+ }
498
+
307
499
  function printSampler(sampler: Sampler, indent: string): string {
308
500
  const inner = indent + INDENT;
309
- const voices = Object.keys(sampler.voices)
310
- .sort()
311
- .map((name) => {
312
- const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : str(name);
313
- return `${inner}${key}: ${printSample(sampler.voices[name]!, inner, `${key}: `.length)},`;
314
- });
315
- const body = `{\n${voices.join("\n")}\n${indent}}`;
316
- if (sampler.mode === "oneshot") return `sampler(${body})`;
317
- // Prettier breaks every argument out when an earlier one contains a break.
318
- const shifted = `{\n${voices.map((line) => INDENT + line).join("\n")}\n${inner}}`;
501
+ const voices = (at: string) =>
502
+ Object.keys(sampler.voices)
503
+ .sort()
504
+ .map((name) => {
505
+ const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : str(name);
506
+ return `${at}${key}: ${printSample(sampler.voices[name]!, at, `${key}: `.length)},`;
507
+ })
508
+ .join("\n");
509
+ if (sampler.mode === "oneshot")
510
+ return `sampler({\n${voices(inner)}\n${indent}})`;
511
+ // Prettier breaks every argument out when an earlier one contains a break,
512
+ // so the voices sit one level deeper (multi-line voices included).
513
+ const shifted = `{\n${voices(inner + INDENT)}\n${inner}}`;
319
514
  return `sampler(\n${inner}${shifted},\n${inner}{ mode: ${str(sampler.mode)} },\n${indent})`;
320
515
  }
321
516
 
517
+ /** `wavetable("basic", { wt: 0.5 })`; pack tables keep their pin. */
518
+ function printWavetable(settings: TrackWavetable, indent: string): string {
519
+ const src = settings.table.src;
520
+ const name = src.startsWith(BUILTIN_TABLE_PREFIX)
521
+ ? str(src.slice(BUILTIN_TABLE_PREFIX.length))
522
+ : printSample(settings.table, indent, "wavetable(".length);
523
+ const params: [string, string][] = [];
524
+ for (const key of Object.keys(WAVETABLE_PARAMS) as WavetableParam[])
525
+ if (settings[key] !== undefined) params.push([key, num(settings[key])]);
526
+ if (settings.warpmode) params.push(["warpmode", str(settings.warpmode)]);
527
+ if (params.length === 0) return `wavetable(${name})`;
528
+ const inline = `wavetable(${name}, { ${params.map(([k, v]) => `${k}: ${v}`).join(", ")} })`;
529
+ if (
530
+ !inline.includes("\n") &&
531
+ indent.length + "instrument: ".length + inline.length + 1 <= WIDTH
532
+ )
533
+ return inline;
534
+ const inner = indent + INDENT;
535
+ if (
536
+ !name.includes("\n") &&
537
+ indent.length + "instrument: wavetable(".length + name.length + 4 <= WIDTH
538
+ )
539
+ return `wavetable(${name}, {\n${params.map(([k, v]) => `${inner}${k}: ${v},`).join("\n")}\n${indent}})`;
540
+ // Prettier breaks every argument out when the first one breaks.
541
+ const deeper = inner + INDENT;
542
+ const table = src.startsWith(BUILTIN_TABLE_PREFIX)
543
+ ? name
544
+ : printSample(settings.table, inner, 0);
545
+ return `wavetable(\n${inner}${table},\n${inner}{\n${params.map(([k, v]) => `${deeper}${k}: ${v},`).join("\n")}\n${inner}},\n${indent})`;
546
+ }
547
+
322
548
  function printSample(ref: SampleRef, indent: string, prefix: number): string {
323
549
  const entries: [string, string][] = [["src", str(ref.src)]];
550
+ // Pack sounds keep their pin in the file; local files are hashed on eval.
551
+ if (ref.src.startsWith("pack:")) {
552
+ if (ref.sha256 !== undefined) entries.push(["sha256", str(ref.sha256)]);
553
+ if (ref.url !== undefined) entries.push(["url", str(ref.url)]);
554
+ if (ref.license !== undefined) entries.push(["license", str(ref.license)]);
555
+ }
324
556
  if (ref.root !== undefined)
325
557
  entries.push(["root", str(midiToPitch(ref.root))]);
326
558
  if (ref.begin !== undefined) entries.push(["begin", num(ref.begin)]);
@@ -329,6 +561,15 @@ function printSample(ref: SampleRef, indent: string, prefix: number): string {
329
561
  if (ref.speed !== undefined) entries.push(["speed", num(ref.speed)]);
330
562
  if (ref.loop !== undefined) entries.push(["loop", String(ref.loop)]);
331
563
  if (ref.choke !== undefined) entries.push(["choke", str(ref.choke)]);
564
+ if (ref.loopBegin !== undefined)
565
+ entries.push(["loopBegin", num(ref.loopBegin)]);
566
+ if (ref.loopEnd !== undefined) entries.push(["loopEnd", num(ref.loopEnd)]);
567
+ if (ref.clip !== undefined) entries.push(["clip", num(ref.clip)]);
568
+ if (ref.unit !== undefined) entries.push(["unit", str(ref.unit)]);
569
+ if (ref.fit !== undefined) entries.push(["fit", String(ref.fit)]);
570
+ if (ref.accelerate !== undefined)
571
+ entries.push(["accelerate", num(ref.accelerate)]);
572
+ if (ref.squiz !== undefined) entries.push(["squiz", num(ref.squiz)]);
332
573
  if (entries.length === 1) return str(ref.src);
333
574
  return obj(entries, indent, prefix, 1);
334
575
  }
@@ -343,7 +584,24 @@ function obj(
343
584
  const inline = `{ ${entries.map(([k, v]) => `${k}: ${v}`).join(", ")} }`;
344
585
  if (indent.length + prefix + inline.length + trailing <= WIDTH) return inline;
345
586
  const inner = indent + INDENT;
346
- return `{\n${entries.map(([k, v]) => `${inner}${k}: ${v},`).join("\n")}\n${indent}}`;
587
+ return `{\n${entries.map(([k, v]) => property(k, v, inner)).join("\n")}\n${indent}}`;
588
+ }
589
+
590
+ /**
591
+ * One expanded `key: value,` line. Like prettier, a string too long for the
592
+ * line moves under its key (a 64-hex sha256 pin in a nested object does).
593
+ */
594
+ function property(key: string, value: string, indent: string): string {
595
+ const line = `${indent}${key}: ${value},`;
596
+ // Prettier keeps a short key (under tabWidth + 3 = 5 characters) inline.
597
+ if (
598
+ line.length <= WIDTH ||
599
+ key.length < 5 ||
600
+ !value.startsWith('"') ||
601
+ value.includes("\n")
602
+ )
603
+ return line;
604
+ return `${indent}${key}:\n${indent}${INDENT}${value},`;
347
605
  }
348
606
 
349
607
  /** Array literal: inline when the line fits (and no forced break), else one item per line. */
@@ -366,6 +624,30 @@ function list(
366
624
  return `[\n${items.map((item) => `${inner}${item},`).join("\n")}\n${indent}]`;
367
625
  }
368
626
 
627
+ /** Effect parameter literal. */
628
+ function value(v: number | string | boolean): string {
629
+ return typeof v === "number"
630
+ ? num(v)
631
+ : typeof v === "string"
632
+ ? str(v)
633
+ : `${v}`;
634
+ }
635
+
636
+ /** `[key, literal]` for each optional effect field that is set. */
637
+ function optional(
638
+ record: object,
639
+ keys: readonly string[],
640
+ ): (readonly [string, string])[] {
641
+ const out: (readonly [string, string])[] = [];
642
+ for (const key of keys) {
643
+ const v = (record as Record<string, number | string | boolean | undefined>)[
644
+ key
645
+ ];
646
+ if (v !== undefined) out.push([key, value(v)]);
647
+ }
648
+ return out;
649
+ }
650
+
369
651
  /** Number the way prettier normalizes literals. */
370
652
  export function num(value: number): string {
371
653
  if (!Number.isFinite(value)) throw new RangeError(`cannot print ${value}`);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The SDK is one vendored file with no imports, so `core/sdk/v1.ts` carries
3
+ * a copy of the chord engine (`core/chords.ts`) between two marker lines.
4
+ * This keeps the copy exact: `bun core/sdk/sync-chords.ts` rewrites it and
5
+ * `sync-chords.test.ts` fails when it is stale.
6
+ *
7
+ * The copy is the module body from its first section rule up to
8
+ * `CHORD_PROCESS` (agent prose the SDK does not need), with `export`
9
+ * dropped so the engine stays private to the SDK.
10
+ */
11
+
12
+ import { readFile, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { format } from "prettier";
15
+
16
+ export const BEGIN_MARKER =
17
+ "// BEGIN chord engine: generated from core/chords.ts by core/sdk/sync-chords.ts";
18
+ export const END_MARKER = "// END chord engine";
19
+
20
+ const ROOT = join(import.meta.dir, "..", "..");
21
+ export const CHORDS_PATH = join(ROOT, "core", "chords.ts");
22
+ export const SDK_PATH = join(ROOT, "core", "sdk", "v1.ts");
23
+
24
+ /** The engine block (markers included) for a `core/chords.ts` source. */
25
+ export function chordBlock(source: string): string {
26
+ const start = source.indexOf("// ----");
27
+ const end = source.indexOf("/** How dawg builds chords");
28
+ if (start < 0 || end < 0) throw new Error("core/chords.ts layout changed");
29
+ const body = source
30
+ .slice(start, end)
31
+ .replace(/^export /gm, "")
32
+ .trimEnd();
33
+ return `${BEGIN_MARKER}\n${body}\n${END_MARKER}`;
34
+ }
35
+
36
+ /** `sdk` with its engine block replaced, formatted like the repo. */
37
+ export async function syncedSdk(sdk: string, chords: string): Promise<string> {
38
+ const from = sdk.indexOf(BEGIN_MARKER);
39
+ const to = sdk.indexOf(END_MARKER);
40
+ if (from < 0 || to < 0) throw new Error("v1.ts has no chord engine markers");
41
+ const next =
42
+ sdk.slice(0, from) + chordBlock(chords) + sdk.slice(to + END_MARKER.length);
43
+ return format(next, { parser: "typescript", filepath: SDK_PATH });
44
+ }
45
+
46
+ if (import.meta.main) {
47
+ const [sdk, chords] = await Promise.all([
48
+ readFile(SDK_PATH, "utf8"),
49
+ readFile(CHORDS_PATH, "utf8"),
50
+ ]);
51
+ const next = await syncedSdk(sdk, chords);
52
+ if (next !== sdk) await writeFile(SDK_PATH, next);
53
+ console.log(next === sdk ? "v1.ts chord engine up to date" : "v1.ts updated");
54
+ }