@hraness/dawg 0.2.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Deterministic printer: score → `song.ts` + `tracks/<slug>/track.ts`.
3
+ *
4
+ * Output is already in prettier's shape (80 columns, double quotes, trailing
5
+ * commas, two-space indent, objects kept expanded) so formatting it again is
6
+ * a no-op, and printing the evaluation of a printed project reproduces the
7
+ * same bytes. Note ids are never printed: `song()` derives them from content.
8
+ */
9
+
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";
13
+ import { midiToPitch } from "../pitch.ts";
14
+ import { rhythmVoicePitch, rowInSync } from "../rhythm.ts";
15
+ import {
16
+ BUILTIN_TABLE_PREFIX,
17
+ WAVETABLE_PARAMS,
18
+ isSamplerInstrument,
19
+ isWavetableInstrument,
20
+ samplerVoiceSlots,
21
+ wavetableOf,
22
+ type TrackWavetable,
23
+ type WavetableParam,
24
+ TrackScore,
25
+ type AutomationPoint,
26
+ type Note,
27
+ type SampleRef,
28
+ type Sampler,
29
+ type Track,
30
+ } from "../score.ts";
31
+ import { trackSlug } from "../slug.ts";
32
+ import { DEFAULT_HIT_LENGTH, DEFAULT_VELOCITY } from "./v1.ts";
33
+
34
+ const WIDTH = 80;
35
+ const INDENT = " ";
36
+
37
+ /** One file the printer produces, path relative to the project root. */
38
+ export type PrintedFile = Readonly<{ path: string; text: string }>;
39
+
40
+ /** Everything a project needs for a score: `song.ts` first, then one file per track in score order. */
41
+ export type PrintedProject = Readonly<{
42
+ files: readonly PrintedFile[];
43
+ /** Track id → `tracks/<dir>/track.ts`. */
44
+ trackFiles: ReadonlyMap<string, string>;
45
+ }>;
46
+
47
+ /** Directory under `tracks/` for each track, unique in score order. */
48
+ export function trackDirectories(
49
+ score: TrackScore,
50
+ ): ReadonlyMap<string, string> {
51
+ const used = new Set<string>();
52
+ const out = new Map<string, string>();
53
+ for (const track of score.tracks) {
54
+ const base = trackSlug(track.name);
55
+ let dir = base;
56
+ for (let n = 2; used.has(dir); n += 1) dir = `${base}-${n}`;
57
+ used.add(dir);
58
+ out.set(track.id, dir);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Prints the whole project. Pure; returns frozen data. */
64
+ export function printProject(score: TrackScore): PrintedProject {
65
+ const dirs = trackDirectories(score);
66
+ const trackFiles = new Map<string, string>();
67
+ const files: PrintedFile[] = [
68
+ Object.freeze({ path: "song.ts", text: printSong(score, dirs) }),
69
+ ];
70
+ for (const track of score.tracks) {
71
+ const path = `tracks/${dirs.get(track.id)!}/track.ts`;
72
+ trackFiles.set(track.id, path);
73
+ files.push(Object.freeze({ path, text: printTrack(score, track) }));
74
+ }
75
+ return Object.freeze({ files: Object.freeze(files), trackFiles });
76
+ }
77
+
78
+ const RESERVED = new Set([
79
+ "break",
80
+ "case",
81
+ "catch",
82
+ "class",
83
+ "const",
84
+ "continue",
85
+ "debugger",
86
+ "default",
87
+ "delete",
88
+ "do",
89
+ "else",
90
+ "enum",
91
+ "export",
92
+ "extends",
93
+ "false",
94
+ "finally",
95
+ "for",
96
+ "function",
97
+ "if",
98
+ "import",
99
+ "in",
100
+ "instanceof",
101
+ "new",
102
+ "null",
103
+ "return",
104
+ "super",
105
+ "switch",
106
+ "this",
107
+ "throw",
108
+ "true",
109
+ "try",
110
+ "typeof",
111
+ "var",
112
+ "void",
113
+ "while",
114
+ "with",
115
+ "yield",
116
+ "let",
117
+ "static",
118
+ "implements",
119
+ "interface",
120
+ "package",
121
+ "private",
122
+ "protected",
123
+ "public",
124
+ "await",
125
+ "song",
126
+ "track",
127
+ "note",
128
+ "seq",
129
+ "hit",
130
+ "hits",
131
+ "every",
132
+ "sampler",
133
+ "wavetable",
134
+ "slices",
135
+ "euclid",
136
+ "euclidRot",
137
+ "euclidLegato",
138
+ "grid",
139
+ ]);
140
+
141
+ /** Import identifiers for each track, unique in score order. */
142
+ export function trackIdentifiers(
143
+ score: TrackScore,
144
+ dirs: ReadonlyMap<string, string> = trackDirectories(score),
145
+ ): ReadonlyMap<string, string> {
146
+ const used = new Set<string>();
147
+ const out = new Map<string, string>();
148
+ for (const track of score.tracks) {
149
+ let base = dirs.get(track.id)!.replace(/-/g, "_");
150
+ if (!/^[a-z_]/.test(base) || RESERVED.has(base)) base = `track_${base}`;
151
+ let name = base;
152
+ for (let n = 2; used.has(name); n += 1) name = `${base}_${n}`;
153
+ used.add(name);
154
+ out.set(track.id, name);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /** `song.ts` for a score. */
160
+ export function printSong(
161
+ score: TrackScore,
162
+ dirs: ReadonlyMap<string, string> = trackDirectories(score),
163
+ ): string {
164
+ const ids = trackIdentifiers(score, dirs);
165
+ const lines: string[] = ['import { song } from "dawg";'];
166
+ for (const track of score.tracks)
167
+ lines.push(
168
+ `import ${ids.get(track.id)!} from "./tracks/${dirs.get(track.id)!}/track.ts";`,
169
+ );
170
+ lines.push("");
171
+ const entries: string[] = [
172
+ `tempo: ${num(score.tempoBpm)}`,
173
+ `meter: [${num(score.beatsPerBar)}, 4]`,
174
+ `bars: ${num(score.bars)}`,
175
+ ];
176
+ if (score.key !== null) entries.push(`key: ${str(score.key)}`);
177
+ if (score.ticksPerBeat !== 480)
178
+ entries.push(`ticksPerBeat: ${num(score.ticksPerBeat)}`);
179
+ entries.push(
180
+ `tracks: ${list(
181
+ score.tracks.map((track) => ids.get(track.id)!),
182
+ INDENT,
183
+ "tracks: ".length,
184
+ 1,
185
+ )}`,
186
+ );
187
+ lines.push("export default song({");
188
+ for (const entry of entries) lines.push(`${INDENT}${entry},`);
189
+ lines.push("});", "");
190
+ return lines.join("\n");
191
+ }
192
+
193
+ /** `tracks/<slug>/track.ts` for one track of a score. */
194
+ export function printTrack(score: TrackScore, track: Track): string {
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
+ );
204
+ const kit = isDrumInstrument(track.instrument);
205
+ const voiceSlots =
206
+ isSamplerInstrument(track.instrument) && track.sampler
207
+ ? samplerVoiceSlots(track.sampler)
208
+ : undefined;
209
+ // Keyed samplers have no slots: their notes print as named pitches.
210
+ const slots = voiceSlots && voiceSlots.size > 0 ? voiceSlots : undefined;
211
+ const voiceFor = new Map<number, string>();
212
+ if (kit) for (const info of DRUM_VOICES) voiceFor.set(info.pitch, info.voice);
213
+ if (slots) for (const [voice, slot] of slots) voiceFor.set(slot, voice);
214
+ const used = new Set<string>(["track"]);
215
+ const printed = notes.map((note) => {
216
+ const voice = voiceFor.get(note.pitch);
217
+ if (voice !== undefined) {
218
+ used.add("hit");
219
+ return printHit(score, note, voice);
220
+ }
221
+ used.add("note");
222
+ return printNote(score, note, !kit && !slots);
223
+ });
224
+ if (track.sampler) used.add("sampler");
225
+ const wavetable = isWavetableInstrument(track.instrument)
226
+ ? wavetableOf(track)
227
+ : undefined;
228
+ if (wavetable) used.add("wavetable");
229
+
230
+ const entries: string[] = [
231
+ `id: ${str(track.id)}`,
232
+ `name: ${str(track.name)}`,
233
+ ];
234
+ if (track.sampler) {
235
+ entries.push(`instrument: ${printSampler(track.sampler, INDENT)}`);
236
+ } else if (wavetable) {
237
+ entries.push(`instrument: ${printWavetable(wavetable, INDENT)}`);
238
+ } else entries.push(`instrument: ${str(track.instrument)}`);
239
+ if (track.kit) entries.push(`kit: ${str(track.kit)}`);
240
+ if (track.muted) entries.push("muted: true");
241
+ if (track.solo) entries.push("solo: true");
242
+ if (track.volume !== 1) entries.push(`volume: ${num(track.volume)}`);
243
+ if (track.pan !== 0) entries.push(`pan: ${num(track.pan)}`);
244
+ if (track.filter)
245
+ entries.push(
246
+ `filter: ${obj(
247
+ [
248
+ ["cutoff", num(track.filter.cutoff)],
249
+ ["resonance", num(track.filter.resonance)],
250
+ ...optional(track.filter, ["type", "ftype"]),
251
+ ],
252
+ INDENT,
253
+ "filter: ".length,
254
+ 1,
255
+ )}`,
256
+ );
257
+ if (track.delay)
258
+ entries.push(
259
+ `delay: ${obj(
260
+ [
261
+ ["beats", num(track.delay.beats)],
262
+ ["feedback", num(track.delay.feedback)],
263
+ ["mix", num(track.delay.mix)],
264
+ ...optional(track.delay, ["time", "pingpong", "highcut"]),
265
+ ],
266
+ INDENT,
267
+ "delay: ".length,
268
+ 1,
269
+ )}`,
270
+ );
271
+ if (track.reverb)
272
+ entries.push(
273
+ `reverb: ${obj(
274
+ [
275
+ ["mix", num(track.reverb.mix)],
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
+ : []),
294
+ ],
295
+ INDENT,
296
+ "reverb: ".length,
297
+ 1,
298
+ )}`,
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
+ }
327
+ const lanes: [string, readonly AutomationPoint[] | undefined][] = [
328
+ ["volume", track.volumeAutomation],
329
+ ["pan", track.panAutomation],
330
+ ["filter", track.filterAutomation],
331
+ ["resonance", track.resonanceAutomation],
332
+ ["delayFeedback", track.delayFeedbackAutomation],
333
+ ["delayMix", track.delayMixAutomation],
334
+ ["wt", track.wtAutomation],
335
+ ];
336
+ const automation = lanes.filter(([, points]) => points && points.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) {
353
+ const inner = INDENT + INDENT;
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),
361
+ );
362
+ body.push(`${inner}fx: {\n${fxBody.join("\n")}\n${inner}},`);
363
+ }
364
+ entries.push(`automation: {\n${body.join("\n")}\n${INDENT}}`);
365
+ }
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)}`);
385
+
386
+ const names = [
387
+ "track",
388
+ "note",
389
+ "seq",
390
+ "hit",
391
+ "hits",
392
+ "every",
393
+ "sampler",
394
+ "wavetable",
395
+ "euclid",
396
+ "grid",
397
+ ]
398
+ .filter((name) => used.has(name))
399
+ .join(", ");
400
+ const lines = [
401
+ `import { ${names} } from "dawg";`,
402
+ "",
403
+ "export default track({",
404
+ ];
405
+ for (const entry of entries) lines.push(`${INDENT}${entry},`);
406
+ lines.push("});", "");
407
+ return lines.join("\n");
408
+ }
409
+
410
+ function printNote(score: TrackScore, note: Note, named: boolean): string {
411
+ const pitch = named ? str(midiToPitch(note.pitch)) : num(note.pitch);
412
+ const args = [pitch, num(note.startTick / score.ticksPerBeat)];
413
+ const length = note.durationTicks / score.ticksPerBeat;
414
+ if (length !== 1 || note.velocity !== DEFAULT_VELOCITY)
415
+ args.push(num(length));
416
+ if (note.velocity !== DEFAULT_VELOCITY) args.push(num(note.velocity));
417
+ return `note(${args.join(", ")})`;
418
+ }
419
+
420
+ function printHit(score: TrackScore, note: Note, voice: string): string {
421
+ const args = [str(voice), num(note.startTick / score.ticksPerBeat)];
422
+ const length = note.durationTicks / score.ticksPerBeat;
423
+ if (note.velocity !== DEFAULT_VELOCITY || length !== DEFAULT_HIT_LENGTH)
424
+ args.push(num(note.velocity));
425
+ if (length !== DEFAULT_HIT_LENGTH) args.push(num(length));
426
+ return `hit(${args.join(", ")})`;
427
+ }
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
+
499
+ function printSampler(sampler: Sampler, indent: string): string {
500
+ const inner = indent + INDENT;
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}}`;
514
+ return `sampler(\n${inner}${shifted},\n${inner}{ mode: ${str(sampler.mode)} },\n${indent})`;
515
+ }
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
+
548
+ function printSample(ref: SampleRef, indent: string, prefix: number): string {
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
+ }
556
+ if (ref.root !== undefined)
557
+ entries.push(["root", str(midiToPitch(ref.root))]);
558
+ if (ref.begin !== undefined) entries.push(["begin", num(ref.begin)]);
559
+ if (ref.end !== undefined) entries.push(["end", num(ref.end)]);
560
+ if (ref.gain !== undefined) entries.push(["gain", num(ref.gain)]);
561
+ if (ref.speed !== undefined) entries.push(["speed", num(ref.speed)]);
562
+ if (ref.loop !== undefined) entries.push(["loop", String(ref.loop)]);
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)]);
573
+ if (entries.length === 1) return str(ref.src);
574
+ return obj(entries, indent, prefix, 1);
575
+ }
576
+
577
+ /** Object literal: inline when the line fits, expanded otherwise (prettier keeps both). */
578
+ function obj(
579
+ entries: readonly (readonly [string, string])[],
580
+ indent: string,
581
+ prefix: number,
582
+ trailing: number,
583
+ ): string {
584
+ const inline = `{ ${entries.map(([k, v]) => `${k}: ${v}`).join(", ")} }`;
585
+ if (indent.length + prefix + inline.length + trailing <= WIDTH) return inline;
586
+ const inner = indent + 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},`;
605
+ }
606
+
607
+ /** Array literal: inline when the line fits (and no forced break), else one item per line. */
608
+ function list(
609
+ items: readonly string[],
610
+ indent: string,
611
+ prefix: number,
612
+ trailing: number,
613
+ forceBreak = false,
614
+ ): string {
615
+ if (items.length === 0) return "[]";
616
+ const inline = `[${items.join(", ")}]`;
617
+ if (
618
+ !forceBreak &&
619
+ !inline.includes("\n") &&
620
+ indent.length + prefix + inline.length + trailing <= WIDTH
621
+ )
622
+ return inline;
623
+ const inner = indent + INDENT;
624
+ return `[\n${items.map((item) => `${inner}${item},`).join("\n")}\n${indent}]`;
625
+ }
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
+
651
+ /** Number the way prettier normalizes literals. */
652
+ export function num(value: number): string {
653
+ if (!Number.isFinite(value)) throw new RangeError(`cannot print ${value}`);
654
+ return String(value)
655
+ .replace(/e\+/, "e")
656
+ .replace(/^(-?)\./, "$10.");
657
+ }
658
+
659
+ /** Double-quoted string unless it holds more `"` than `'`, like prettier. */
660
+ export function str(value: string): string {
661
+ const doubles = (value.match(/"/g) ?? []).length;
662
+ const singles = (value.match(/'/g) ?? []).length;
663
+ const quote = doubles > singles ? "'" : '"';
664
+ const escaped = value
665
+ .replace(/\\/g, "\\\\")
666
+ .replace(new RegExp(quote, "g"), `\\${quote}`)
667
+ .replace(/\n/g, "\\n")
668
+ .replace(/\r/g, "\\r")
669
+ .replace(/\t/g, "\\t")
670
+ .replace(
671
+ /[\x00-\x1f\u2028\u2029]/g,
672
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
673
+ );
674
+ return `${quote}${escaped}${quote}`;
675
+ }