@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
package/core/sdk/print.ts CHANGED
@@ -8,9 +8,25 @@
8
8
  */
9
9
 
10
10
  import { DRUM_VOICES, isDrumInstrument } from "../drums.ts";
11
+ import type { TrackGranular } from "../granular.ts";
12
+ import { DEFAULT_FIXED_VELOCITY } from "../expression.ts";
11
13
  import type { RhythmRow } from "../euclid.ts";
12
- import { FX_LANES, fxSpec, type FxName, type FxValues } from "../fx.ts";
14
+ import {
15
+ FX_LANES,
16
+ RIG_STAGES,
17
+ fxSpec,
18
+ rigPresetKeys,
19
+ rigPresetOf,
20
+ type FxName,
21
+ type FxValues,
22
+ } from "../fx.ts";
23
+ import { MASTER_SPECS, MASTER_UNITS, type SongMaster } from "../master.ts";
13
24
  import { midiToPitch } from "../pitch.ts";
25
+ import {
26
+ MODAL_INSTRUMENT,
27
+ MODAL_PARAMS,
28
+ type TrackModal,
29
+ } from "../resonators.ts";
14
30
  import { rhythmVoicePitch, rowInSync } from "../rhythm.ts";
15
31
  import {
16
32
  BUILTIN_TABLE_PREFIX,
@@ -23,12 +39,17 @@ import {
23
39
  type WavetableParam,
24
40
  TrackScore,
25
41
  type AutomationPoint,
42
+ type FormEntry,
26
43
  type Note,
27
44
  type SampleRef,
28
45
  type Sampler,
46
+ type Section,
29
47
  type Track,
30
48
  } from "../score.ts";
31
49
  import { trackSlug } from "../slug.ts";
50
+ import { barStartTick } from "../tempo.ts";
51
+ import type { Tuning } from "../tuning.ts";
52
+ import { DEFAULT_STRING_PRESET, type TrackString } from "../strings.ts";
32
53
  import { DEFAULT_HIT_LENGTH, DEFAULT_VELOCITY } from "./v1.ts";
33
54
 
34
55
  const WIDTH = 80;
@@ -131,6 +152,9 @@ const RESERVED = new Set([
131
152
  "every",
132
153
  "sampler",
133
154
  "wavetable",
155
+ "rig",
156
+ "granular",
157
+ "modal",
134
158
  "slices",
135
159
  "euclid",
136
160
  "euclidRot",
@@ -147,7 +171,12 @@ export function trackIdentifiers(
147
171
  const out = new Map<string, string>();
148
172
  for (const track of score.tracks) {
149
173
  let base = dirs.get(track.id)!.replace(/-/g, "_");
150
- if (!/^[a-z_]/.test(base) || RESERVED.has(base)) base = `track_${base}`;
174
+ if (
175
+ !/^[a-z_]/.test(base) ||
176
+ RESERVED.has(base) ||
177
+ (score.time && TIME_HELPERS.includes(base))
178
+ )
179
+ base = `track_${base}`;
151
180
  let name = base;
152
181
  for (let n = 2; used.has(name); n += 1) name = `${base}_${n}`;
153
182
  used.add(name);
@@ -162,7 +191,12 @@ export function printSong(
162
191
  dirs: ReadonlyMap<string, string> = trackDirectories(score),
163
192
  ): string {
164
193
  const ids = trackIdentifiers(score, dirs);
165
- const lines: string[] = ['import { song } from "dawg";'];
194
+ const time = printTime(score);
195
+ const helpers = [
196
+ "song",
197
+ ...TIME_HELPERS.filter((name) => time.used.has(name)),
198
+ ];
199
+ const lines: string[] = [`import { ${helpers.join(", ")} } from "dawg";`];
166
200
  for (const track of score.tracks)
167
201
  lines.push(
168
202
  `import ${ids.get(track.id)!} from "./tracks/${dirs.get(track.id)!}/track.ts";`,
@@ -174,8 +208,14 @@ export function printSong(
174
208
  `bars: ${num(score.bars)}`,
175
209
  ];
176
210
  if (score.key !== null) entries.push(`key: ${str(score.key)}`);
211
+ if (score.tuning)
212
+ entries.push(
213
+ `tuning: ${printTuning(score.tuning, INDENT, "tuning: ".length)}`,
214
+ );
177
215
  if (score.ticksPerBeat !== 480)
178
216
  entries.push(`ticksPerBeat: ${num(score.ticksPerBeat)}`);
217
+ if (time.marks.length > 0)
218
+ entries.push(`time: ${list(time.marks, INDENT, "time: ".length, 1)}`);
179
219
  entries.push(
180
220
  `tracks: ${list(
181
221
  score.tracks.map((track) => ids.get(track.id)!),
@@ -184,12 +224,150 @@ export function printSong(
184
224
  1,
185
225
  )}`,
186
226
  );
227
+ if (score.master) entries.push(printMaster(score.master));
228
+ // Song sections and form (0.5): printed only when the song has them.
229
+ if (score.sections.length > 0)
230
+ entries.push(
231
+ `sections: ${list(
232
+ score.sections.map((section) => printSection(section, INDENT + INDENT)),
233
+ INDENT,
234
+ "sections: ".length,
235
+ 1,
236
+ )}`,
237
+ );
238
+ if (score.form.length > 0)
239
+ entries.push(`form: ${printForm(score.form, INDENT)}`);
240
+ if (score.loopSection !== undefined)
241
+ entries.push(`loopSection: ${str(score.loopSection)}`);
187
242
  lines.push("export default song({");
188
243
  for (const entry of entries) lines.push(`${INDENT}${entry},`);
189
244
  lines.push("});", "");
190
245
  return lines.join("\n");
191
246
  }
192
247
 
248
+ /** SDK time helpers `song.ts` may import, in import order. */
249
+ const TIME_HELPERS: readonly string[] = ["tempo", "ramp", "meter", "fermata"];
250
+
251
+ /** `song({ time })` marks for a score's tempo map, meters and fermatas. */
252
+ function printTime(score: TrackScore): {
253
+ marks: string[];
254
+ used: Set<string>;
255
+ } {
256
+ const marks: string[] = [];
257
+ const used = new Set<string>();
258
+ const time = score.time;
259
+ if (!time) return { marks, used };
260
+ const beat = (tick: number) => num(tick / score.ticksPerBeat);
261
+ type Mark = { tick: number; order: number; text: string };
262
+ const all: Mark[] = [];
263
+ for (const event of time.tempo ?? []) {
264
+ if (event.ramp) {
265
+ used.add("ramp");
266
+ const curve = event.ramp === "exp" ? ', "exp"' : "";
267
+ all.push({
268
+ tick: event.tick,
269
+ order: 1,
270
+ text: `ramp(${beat(event.tick)}, ${num(event.bpm)}${curve})`,
271
+ });
272
+ } else {
273
+ used.add("tempo");
274
+ all.push({
275
+ tick: event.tick,
276
+ order: 1,
277
+ text: `tempo(${beat(event.tick)}, ${num(event.bpm)})`,
278
+ });
279
+ }
280
+ }
281
+ for (const change of time.meter ?? []) {
282
+ used.add("meter");
283
+ const tick = barStartTick(score, change.bar);
284
+ const unit = change.beatUnit ?? 4;
285
+ const value =
286
+ unit === 4
287
+ ? num(change.beatsPerBar)
288
+ : `[${num(change.beatsPerBar)}, ${num(unit)}]`;
289
+ all.push({ tick, order: 0, text: `meter(${beat(tick)}, ${value})` });
290
+ }
291
+ for (const hold of time.fermatas ?? []) {
292
+ used.add("fermata");
293
+ all.push({
294
+ tick: hold.tick,
295
+ order: 2,
296
+ text: `fermata(${beat(hold.tick)}, ${num(hold.beats)})`,
297
+ });
298
+ }
299
+ all.sort((a, b) => a.tick - b.tick || a.order - b.order);
300
+ for (const mark of all) marks.push(mark.text);
301
+ return { marks, used };
302
+ }
303
+
304
+ /**
305
+ * One `sections` item: `{ name: "verse", startBar: 0, bars: 8 }`. `indent`
306
+ * is the item's own line; expanded fields sit one level deeper.
307
+ */
308
+ function printSection(section: Section, indent: string): string {
309
+ const inner = indent + INDENT;
310
+ const entries: (readonly [string, string])[] = [
311
+ ["name", str(section.name)],
312
+ ["startBar", num(section.startBar)],
313
+ ["bars", num(section.bars)],
314
+ ];
315
+ if (section.mute && section.mute.length > 0)
316
+ entries.push([
317
+ "mute",
318
+ list(section.mute.map(str), inner, "mute: ".length, 1),
319
+ ]);
320
+ if (section.vary) {
321
+ const vary = Object.entries(section.vary).map(([id, change]) => {
322
+ const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(id) ? id : str(id);
323
+ const fields: (readonly [string, string])[] = [];
324
+ if (change.transpose !== undefined)
325
+ fields.push(["transpose", num(change.transpose)]);
326
+ if (change.gain !== undefined) fields.push(["gain", num(change.gain)]);
327
+ return [key, obj(fields, inner + INDENT, `${key}: `.length, 1)] as const;
328
+ });
329
+ if (vary.length > 0)
330
+ entries.push(["vary", obj(vary, inner, "vary: ".length, 1)]);
331
+ }
332
+ return obj(entries, indent, 0, 1);
333
+ }
334
+
335
+ /**
336
+ * The form as a string when every name is plain (`"intro verse chorus*2"`),
337
+ * else as a list of entries.
338
+ */
339
+ function printForm(form: readonly FormEntry[], indent: string): string {
340
+ // Word characters only, so the string reads back as the same entries.
341
+ const plain = form.every((entry) => /^\w+$/u.test(entry.section));
342
+ if (plain) {
343
+ const text = form
344
+ .map(
345
+ (entry) =>
346
+ `${entry.section}${(entry.repeat ?? 1) > 1 ? `*${entry.repeat}` : ""}`,
347
+ )
348
+ .join(" ");
349
+ return str(text);
350
+ }
351
+ return list(
352
+ form.map((entry) =>
353
+ (entry.repeat ?? 1) > 1
354
+ ? obj(
355
+ [
356
+ ["section", str(entry.section)],
357
+ ["repeat", num(entry.repeat!)],
358
+ ],
359
+ indent + INDENT,
360
+ 0,
361
+ 1,
362
+ )
363
+ : str(entry.section),
364
+ ),
365
+ indent,
366
+ "form: ".length,
367
+ 1,
368
+ );
369
+ }
370
+
193
371
  /** `tracks/<slug>/track.ts` for one track of a score. */
194
372
  export function printTrack(score: TrackScore, track: Track): string {
195
373
  // Rows whose lane still matches their expansion print as generators and
@@ -213,30 +391,85 @@ export function printTrack(score: TrackScore, track: Track): string {
213
391
  if (slots) for (const [voice, slot] of slots) voiceFor.set(slot, voice);
214
392
  const used = new Set<string>(["track"]);
215
393
  const printed = notes.map((note) => {
216
- const voice = voiceFor.get(note.pitch);
394
+ const voice = note.cents ? undefined : voiceFor.get(note.pitch);
217
395
  if (voice !== undefined) {
218
396
  used.add("hit");
219
- return printHit(score, note, voice);
397
+ return printHit(score, note, voice, INDENT + INDENT);
220
398
  }
221
399
  used.add("note");
222
- return printNote(score, note, !kit && !slots);
400
+ return printNote(score, note, !kit && !slots, INDENT + INDENT);
223
401
  });
402
+ const grained =
403
+ track.instrument === "granular" && track.granular !== undefined;
404
+ if (grained) used.add("granular");
224
405
  if (track.sampler) used.add("sampler");
225
406
  const wavetable = isWavetableInstrument(track.instrument)
226
407
  ? wavetableOf(track)
227
408
  : undefined;
228
409
  if (wavetable) used.add("wavetable");
410
+ const stringed = track.instrument === "string" && track.string !== undefined;
411
+ if (stringed) used.add("stringed");
412
+ const modalCall =
413
+ track.instrument === MODAL_INSTRUMENT && track.modal
414
+ ? printModal(track.modal, INDENT)
415
+ : undefined;
416
+ if (modalCall?.startsWith("modal(")) used.add("modal");
229
417
 
230
418
  const entries: string[] = [
231
419
  `id: ${str(track.id)}`,
232
420
  `name: ${str(track.name)}`,
233
421
  ];
234
- if (track.sampler) {
422
+ if (grained) {
423
+ entries.push(`instrument: ${printGranular(track.granular!, INDENT)}`);
424
+ // The sampler a granular track keeps for `grain off`.
425
+ if (track.sampler)
426
+ entries.push(`sampler: ${printSampler(track.sampler, INDENT)}`);
427
+ } else if (track.sampler) {
235
428
  entries.push(`instrument: ${printSampler(track.sampler, INDENT)}`);
236
429
  } else if (wavetable) {
237
430
  entries.push(`instrument: ${printWavetable(wavetable, INDENT)}`);
431
+ } else if (stringed) {
432
+ entries.push(`instrument: ${printStringed(track.string!, INDENT)}`);
433
+ } else if (modalCall) {
434
+ entries.push(`instrument: ${modalCall}`);
238
435
  } else entries.push(`instrument: ${str(track.instrument)}`);
239
436
  if (track.kit) entries.push(`kit: ${str(track.kit)}`);
437
+ if (track.granular && !grained)
438
+ entries.push(
439
+ `granular: ${obj(
440
+ [
441
+ ...(track.granular.preset
442
+ ? [["preset", str(track.granular.preset)] as const]
443
+ : []),
444
+ ...granularEntries(track.granular, INDENT),
445
+ ],
446
+ INDENT,
447
+ "granular: ".length,
448
+ 1,
449
+ )}`,
450
+ );
451
+ if (track.time) {
452
+ const beats = (ticks: number) => num(ticks / score.ticksPerBeat);
453
+ const fields: (readonly [string, string])[] = [];
454
+ if (track.time.rate !== undefined)
455
+ fields.push(["rate", num(track.time.rate)]);
456
+ if (track.time.phase !== undefined)
457
+ fields.push(["phase", beats(track.time.phase)]);
458
+ if (track.time.cycle !== undefined)
459
+ fields.push(["cycle", beats(track.time.cycle)]);
460
+ if (track.time.steps) {
461
+ const { shift, hold, drift } = track.time.steps;
462
+ fields.push([
463
+ "steps",
464
+ `{ shift: ${beats(shift)}, hold: ${num(hold)}, drift: ${num(drift)} }`,
465
+ ]);
466
+ }
467
+ entries.push(`time: ${obj(fields, INDENT, "time: ".length, 1)}`);
468
+ }
469
+ if (track.tuning)
470
+ entries.push(
471
+ `tuning: ${printTuning(track.tuning, INDENT, "tuning: ".length)}`,
472
+ );
240
473
  if (track.muted) entries.push("muted: true");
241
474
  if (track.solo) entries.push("solo: true");
242
475
  if (track.volume !== 1) entries.push(`volume: ${num(track.volume)}`);
@@ -298,18 +531,31 @@ export function printTrack(score: TrackScore, track: Track): string {
298
531
  )}`,
299
532
  );
300
533
  if (track.fx) {
301
- const effects = Object.entries(track.fx) as [FxName, FxValues][];
534
+ // A whole rig preset prints as `...rig("crunch")` (SDK 1.22.0).
535
+ const rigName = rigPresetOf(track.fx);
536
+ if (rigName) used.add("rig");
537
+ const effects = (Object.entries(track.fx) as [FxName, FxValues][]).filter(
538
+ ([effect]) =>
539
+ !rigName ||
540
+ !(
541
+ (RIG_STAGES as readonly string[]).includes(effect) ||
542
+ rigPresetKeys(rigName).includes(effect)
543
+ ),
544
+ );
302
545
  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
- });
546
+ const rigLine = rigName ? [`${inner}...rig(${str(rigName)}),`] : [];
547
+ const body = rigLine.concat(
548
+ effects.map(([effect, values]) => {
549
+ // Only values that differ from the default: decoding fills the rest.
550
+ const params = Object.entries(fxSpec(effect).params)
551
+ .filter(
552
+ ([key, spec]) =>
553
+ values[key] !== undefined && values[key] !== spec.default,
554
+ )
555
+ .map(([key]) => [key, value(values[key]!)] as const);
556
+ return `${inner}${effect}: ${params.length === 0 ? "{}" : obj(params, inner, `${effect}: `.length, 1)},`;
557
+ }),
558
+ );
313
559
  entries.push(`fx: {\n${body.join("\n")}\n${INDENT}}`);
314
560
  }
315
561
  if (track.synth) {
@@ -324,6 +570,22 @@ export function printTrack(score: TrackScore, track: Track): string {
324
570
  );
325
571
  entries.push(`synth: ${obj(params, INDENT, "synth: ".length, 1)}`);
326
572
  }
573
+ if (track.string && !stringed) {
574
+ const params = Object.entries(track.string).map(
575
+ ([key, v]) => [key, value(v!)] as const,
576
+ );
577
+ entries.push(`string: ${obj(params, INDENT, "string: ".length, 1)}`);
578
+ }
579
+ if (track.keys) {
580
+ // `{}` is meaningful: it turns the modelled piano on.
581
+ const params = Object.entries(track.keys).map(
582
+ ([key, v]) => [key, value(v)] as const,
583
+ );
584
+ entries.push(
585
+ `keys: ${params.length === 0 ? "{}" : obj(params, INDENT, "keys: ".length, 1)}`,
586
+ );
587
+ }
588
+ entries.push(...performanceEntries(score, track));
327
589
  const lanes: [string, readonly AutomationPoint[] | undefined][] = [
328
590
  ["volume", track.volumeAutomation],
329
591
  ["pan", track.panAutomation],
@@ -392,6 +654,10 @@ export function printTrack(score: TrackScore, track: Track): string {
392
654
  "every",
393
655
  "sampler",
394
656
  "wavetable",
657
+ "stringed",
658
+ "rig",
659
+ "granular",
660
+ "modal",
395
661
  "euclid",
396
662
  "grid",
397
663
  ]
@@ -407,23 +673,185 @@ export function printTrack(score: TrackScore, track: Track): string {
407
673
  return lines.join("\n");
408
674
  }
409
675
 
410
- function printNote(score: TrackScore, note: Note, named: boolean): string {
411
- const pitch = named ? str(midiToPitch(note.pitch)) : num(note.pitch);
676
+ function printNote(
677
+ score: TrackScore,
678
+ note: Note,
679
+ named: boolean,
680
+ indent: string,
681
+ ): string {
682
+ // A detuned note always prints by name: `"E4-14c"`.
683
+ const pitch = note.cents
684
+ ? str(
685
+ `${midiToPitch(note.pitch)}${note.cents > 0 ? "+" : ""}${num(note.cents)}c`,
686
+ )
687
+ : named
688
+ ? str(midiToPitch(note.pitch))
689
+ : num(note.pitch);
412
690
  const args = [pitch, num(note.startTick / score.ticksPerBeat)];
413
691
  const length = note.durationTicks / score.ticksPerBeat;
414
- if (length !== 1 || note.velocity !== DEFAULT_VELOCITY)
692
+ const how = expressionEntries(note, indent + INDENT);
693
+ if (length !== 1 || note.velocity !== DEFAULT_VELOCITY || how)
415
694
  args.push(num(length));
416
- if (note.velocity !== DEFAULT_VELOCITY) args.push(num(note.velocity));
417
- return `note(${args.join(", ")})`;
695
+ if (note.velocity !== DEFAULT_VELOCITY || how) args.push(num(note.velocity));
696
+ return call("note", args, how, indent);
418
697
  }
419
698
 
420
- function printHit(score: TrackScore, note: Note, voice: string): string {
699
+ function printHit(
700
+ score: TrackScore,
701
+ note: Note,
702
+ voice: string,
703
+ indent: string,
704
+ ): string {
421
705
  const args = [str(voice), num(note.startTick / score.ticksPerBeat)];
422
706
  const length = note.durationTicks / score.ticksPerBeat;
423
- if (note.velocity !== DEFAULT_VELOCITY || length !== DEFAULT_HIT_LENGTH)
707
+ const how = expressionEntries(note, indent + INDENT);
708
+ if (
709
+ note.velocity !== DEFAULT_VELOCITY ||
710
+ length !== DEFAULT_HIT_LENGTH ||
711
+ how
712
+ )
424
713
  args.push(num(note.velocity));
425
- if (length !== DEFAULT_HIT_LENGTH) args.push(num(length));
426
- return `hit(${args.join(", ")})`;
714
+ if (length !== DEFAULT_HIT_LENGTH || how) args.push(num(length));
715
+ return call("hit", args, how, indent);
716
+ }
717
+
718
+ /**
719
+ * A note's expression as the fields of its `how` argument, values laid out
720
+ * for property lines at `indent`; undefined when it has none.
721
+ */
722
+ function expressionEntries(
723
+ note: Note,
724
+ indent: string,
725
+ ): (readonly [string, string])[] | undefined {
726
+ const entries: (readonly [string, string])[] = [];
727
+ if (note.articulation) entries.push(["art", str(note.articulation)]);
728
+ if (note.glide !== undefined) entries.push(["glide", num(note.glide)]);
729
+ if (note.bend) {
730
+ const points = note.bend.map((p) => `[${num(p.at)}, ${num(p.cents)}]`);
731
+ // Prettier forces a break on arrays of 2+ arrays that each hold 2+ items.
732
+ entries.push([
733
+ "bend",
734
+ list(points, indent, "bend: ".length, 1, points.length > 1),
735
+ ]);
736
+ }
737
+ if (note.vibrato)
738
+ entries.push([
739
+ "vibrato",
740
+ obj(
741
+ [
742
+ ["rate", num(note.vibrato.rate)],
743
+ ["depth", num(note.vibrato.depth)],
744
+ ...(note.vibrato.delay !== undefined
745
+ ? ([["delay", num(note.vibrato.delay)]] as const)
746
+ : []),
747
+ ],
748
+ indent,
749
+ "vibrato: ".length,
750
+ 1,
751
+ ),
752
+ ]);
753
+ if (note.humanize) {
754
+ const { timing, velocity, length } = note.humanize;
755
+ entries.push([
756
+ "humanize",
757
+ obj(
758
+ [
759
+ ...(timing !== undefined ? ([["timing", num(timing)]] as const) : []),
760
+ ...(velocity !== undefined
761
+ ? ([["velocity", num(velocity)]] as const)
762
+ : []),
763
+ ...(length !== undefined ? ([["length", num(length)]] as const) : []),
764
+ ],
765
+ indent,
766
+ "humanize: ".length,
767
+ 1,
768
+ ),
769
+ ]);
770
+ }
771
+ return entries.length === 0 ? undefined : entries;
772
+ }
773
+
774
+ /**
775
+ * `name(args, how)` as a list item at `indent`: on one line when it fits,
776
+ * otherwise with the trailing `how` object hugged and broken one field per
777
+ * line, the way prettier prints a last object argument.
778
+ */
779
+ function call(
780
+ name: string,
781
+ args: readonly string[],
782
+ how: readonly (readonly [string, string])[] | undefined,
783
+ indent: string,
784
+ ): string {
785
+ if (!how) return `${name}(${args.join(", ")})`;
786
+ const inline = `${name}(${[...args, `{ ${how.map(([k, v]) => `${k}: ${v}`).join(", ")} }`].join(", ")})`;
787
+ if (!inline.includes("\n") && indent.length + inline.length + 1 <= WIDTH)
788
+ return inline;
789
+ const inner = indent + INDENT;
790
+ const body = how.map(([k, v]) => property(k, v, inner)).join("\n");
791
+ return `${name}(${[...args, "{"].join(", ")}\n${body}\n${indent}})`;
792
+ }
793
+
794
+ /** A track's performance fields (SDK 1.15.0) as `key: literal` entries. */
795
+ function performanceEntries(score: TrackScore, track: Track): string[] {
796
+ const entries: string[] = [];
797
+ if (track.glide)
798
+ entries.push(
799
+ track.glide.mode === "legato"
800
+ ? `glide: ${num(track.glide.time)}`
801
+ : `glide: ${obj(
802
+ [
803
+ ["time", num(track.glide.time)],
804
+ ["mode", str(track.glide.mode)],
805
+ ],
806
+ INDENT,
807
+ "glide: ".length,
808
+ 1,
809
+ )}`,
810
+ );
811
+ if (track.pedal) {
812
+ const events = track.pedal.map(
813
+ (event) =>
814
+ `[${num(event.tick / score.ticksPerBeat)}, ${str(event.state)}]`,
815
+ );
816
+ entries.push(
817
+ `pedal: ${list(events, INDENT, "pedal: ".length, 1, events.length > 1)}`,
818
+ );
819
+ }
820
+ if (track.velocityCurve)
821
+ entries.push(
822
+ track.velocityCurve.curve !== "fixed" ||
823
+ (track.velocityCurve.fixed ?? DEFAULT_FIXED_VELOCITY) ===
824
+ DEFAULT_FIXED_VELOCITY
825
+ ? `velocityCurve: ${str(track.velocityCurve.curve)}`
826
+ : `velocityCurve: ${obj(
827
+ [
828
+ ["curve", str(track.velocityCurve.curve)],
829
+ ["fixed", num(track.velocityCurve.fixed!)],
830
+ ],
831
+ INDENT,
832
+ "velocityCurve: ".length,
833
+ 1,
834
+ )}`,
835
+ );
836
+ if (track.humanize) {
837
+ const { timing, velocity, length, seed } = track.humanize;
838
+ entries.push(
839
+ `humanize: ${obj(
840
+ [
841
+ ...(timing !== undefined ? ([["timing", num(timing)]] as const) : []),
842
+ ...(velocity !== undefined
843
+ ? ([["velocity", num(velocity)]] as const)
844
+ : []),
845
+ ...(length !== undefined ? ([["length", num(length)]] as const) : []),
846
+ ["seed", num(seed)],
847
+ ],
848
+ INDENT,
849
+ "humanize: ".length,
850
+ 1,
851
+ )}`,
852
+ );
853
+ }
854
+ return entries;
427
855
  }
428
856
 
429
857
  /** `euclid("hat", 7, 16, 2, { velocity: 0.5 })` or `grid("sd", "....x...")`. */
@@ -514,6 +942,76 @@ function printSampler(sampler: Sampler, indent: string): string {
514
942
  return `sampler(\n${inner}${shifted},\n${inner}{ mode: ${str(sampler.mode)} },\n${indent})`;
515
943
  }
516
944
 
945
+ /** `stringed("sitar", { buzz: 0.8 })`: the preset, then its overrides. */
946
+ function printStringed(settings: TrackString, indent: string): string {
947
+ const name = str(settings.preset ?? DEFAULT_STRING_PRESET);
948
+ const params = Object.entries(settings)
949
+ .filter(([key]) => key !== "preset")
950
+ .map(([key, v]) => [key, value(v!)] as const);
951
+ if (params.length === 0) return `stringed(${name})`;
952
+ const inline = `stringed(${name}, { ${params.map(([k, v]) => `${k}: ${v}`).join(", ")} })`;
953
+ if (indent.length + "instrument: ".length + inline.length + 1 <= WIDTH)
954
+ return inline;
955
+ const inner = indent + INDENT;
956
+ return `stringed(${name}, {\n${params.map(([k, v]) => `${inner}${k}: ${v},`).join("\n")}\n${indent}})`;
957
+ }
958
+
959
+ /** `[key, literal]` for each stored granular field but the preset. */
960
+ function granularEntries(
961
+ settings: TrackGranular,
962
+ indent: string,
963
+ ): (readonly [string, string])[] {
964
+ return Object.entries(settings)
965
+ .filter(([key]) => key !== "preset")
966
+ .map(([key, v]) => [
967
+ key,
968
+ typeof v === "object"
969
+ ? printSample(v as SampleRef, indent + INDENT, `${key}: `.length)
970
+ : value(v as number | string | boolean),
971
+ ]);
972
+ }
973
+
974
+ /** `granular("cloud", { scan: 0.1 })`: the preset, then its overrides. */
975
+ function printGranular(settings: TrackGranular, indent: string): string {
976
+ const inner = indent + INDENT;
977
+ const params = granularEntries(settings, indent);
978
+ const name = settings.preset ? str(settings.preset) : undefined;
979
+ if (params.length === 0) return name ? `granular(${name})` : "granular()";
980
+ const head = name ? `granular(${name}, ` : "granular(";
981
+ const inline = `${head}{ ${params.map(([k, v]) => `${k}: ${v}`).join(", ")} })`;
982
+ if (
983
+ !inline.includes("\n") &&
984
+ indent.length + "instrument: ".length + inline.length + 1 <= WIDTH
985
+ )
986
+ return inline;
987
+ return `${head}{\n${params.map(([k, v]) => property(k, v, inner)).join("\n")}\n${indent}})`;
988
+ }
989
+
990
+ /**
991
+ * A modal track's instrument: the preset word alone (`"vibes"`) when there
992
+ * are no overrides, else `modal("vibes", { motor: 4 })` with keys in
993
+ * `MODAL_PARAMS` order. `marimba` is a legacy word, so the modal marimba
994
+ * always prints as `modal("marimba")`.
995
+ */
996
+ function printModal(settings: TrackModal, indent: string): string {
997
+ const params: [string, string][] = [];
998
+ for (const key of Object.keys(MODAL_PARAMS) as (keyof TrackModal)[]) {
999
+ const value = settings[key];
1000
+ if (value === undefined) continue;
1001
+ params.push([key, typeof value === "number" ? num(value) : str(value)]);
1002
+ }
1003
+ const preset = settings.preset;
1004
+ if (params.length === 0 && preset && preset !== "marimba") return str(preset);
1005
+ const head = preset ? str(preset) : "";
1006
+ if (params.length === 0) return `modal(${head})`;
1007
+ const lead = head ? `${head}, ` : "";
1008
+ const inline = `modal(${lead}{ ${params.map(([k, v]) => `${k}: ${v}`).join(", ")} })`;
1009
+ if (indent.length + "instrument: ".length + inline.length + 1 <= WIDTH)
1010
+ return inline;
1011
+ const inner = indent + INDENT;
1012
+ return `modal(${lead}{\n${params.map(([k, v]) => `${inner}${k}: ${v},`).join("\n")}\n${indent}})`;
1013
+ }
1014
+
517
1015
  /** `wavetable("basic", { wt: 0.5 })`; pack tables keep their pin. */
518
1016
  function printWavetable(settings: TrackWavetable, indent: string): string {
519
1017
  const src = settings.table.src;
@@ -570,10 +1068,99 @@ function printSample(ref: SampleRef, indent: string, prefix: number): string {
570
1068
  if (ref.accelerate !== undefined)
571
1069
  entries.push(["accelerate", num(ref.accelerate)]);
572
1070
  if (ref.squiz !== undefined) entries.push(["squiz", num(ref.squiz)]);
1071
+ if (ref.bpm !== undefined) entries.push(["bpm", num(ref.bpm)]);
1072
+ if (ref.fitmode !== undefined) entries.push(["fitmode", str(ref.fitmode)]);
1073
+ if (ref.len !== undefined) entries.push(["len", num(ref.len)]);
573
1074
  if (entries.length === 1) return str(ref.src);
574
1075
  return obj(entries, indent, prefix, 1);
575
1076
  }
576
1077
 
1078
+ /**
1079
+ * A song or track tuning: a bare library name, or an object without the
1080
+ * fields dawg resolved from Scala files (the table of an `scl`, the keymap
1081
+ * of a `kbm`).
1082
+ */
1083
+ function printTuning(tuning: Tuning, indent: string, prefix: number): string {
1084
+ const entries: [string, string][] = [];
1085
+ if (tuning.name !== undefined) entries.push(["name", str(tuning.name)]);
1086
+ if (tuning.scl === undefined) {
1087
+ const inner = indent + INDENT;
1088
+ if (tuning.edo !== undefined) entries.push(["edo", num(tuning.edo)]);
1089
+ if (tuning.ratios)
1090
+ entries.push([
1091
+ "ratios",
1092
+ list(tuning.ratios.map(str), inner, "ratios: ".length, 1),
1093
+ ]);
1094
+ if (tuning.cents)
1095
+ entries.push([
1096
+ "cents",
1097
+ fill(tuning.cents.map(num), inner, "cents: ".length, 1),
1098
+ ]);
1099
+ } else entries.push(["scl", str(tuning.scl)]);
1100
+ if (tuning.kbm !== undefined) entries.push(["kbm", str(tuning.kbm)]);
1101
+ if (tuning.ref !== undefined) entries.push(["ref", num(tuning.ref)]);
1102
+ if (tuning.root !== undefined)
1103
+ entries.push(["root", str(midiToPitch(tuning.root))]);
1104
+ if (tuning.map !== undefined) entries.push(["map", str(tuning.map)]);
1105
+ if (entries.length === 1 && entries[0]![0] === "name") return entries[0]![1];
1106
+ return obj(entries, indent, prefix, 1);
1107
+ }
1108
+
1109
+ /**
1110
+ * Number array the way prettier prints one: inline when it fits, else
1111
+ * filled (as many per line as fit, each followed by a comma).
1112
+ */
1113
+ function fill(
1114
+ items: readonly string[],
1115
+ indent: string,
1116
+ prefix: number,
1117
+ trailing: number,
1118
+ ): string {
1119
+ const inline = `[${items.join(", ")}]`;
1120
+ if (
1121
+ items.length < 2 ||
1122
+ indent.length + prefix + inline.length + trailing <= WIDTH
1123
+ )
1124
+ return inline;
1125
+ const inner = indent + INDENT;
1126
+ const lines: string[] = [];
1127
+ let line = "";
1128
+ for (const item of items) {
1129
+ const next = `${item},`;
1130
+ if (line !== "" && inner.length + line.length + 1 + next.length <= WIDTH)
1131
+ line += ` ${next}`;
1132
+ else {
1133
+ if (line !== "") lines.push(inner + line);
1134
+ line = next;
1135
+ }
1136
+ }
1137
+ lines.push(inner + line);
1138
+ return `[\n${lines.join("\n")}\n${indent}]`;
1139
+ }
1140
+
1141
+ /** `master: { … }`, always expanded like `fx`; units in chain order. */
1142
+ function printMaster(master: SongMaster): string {
1143
+ const inner = INDENT + INDENT;
1144
+ const body: string[] = [];
1145
+ for (const unit of MASTER_UNITS) {
1146
+ const values = master[unit];
1147
+ if (!values) continue;
1148
+ // Only values that differ from the default: loading fills the rest.
1149
+ const params = Object.entries(MASTER_SPECS[unit].params)
1150
+ .filter(
1151
+ ([key, spec]) =>
1152
+ values[key] !== undefined && values[key] !== spec.default,
1153
+ )
1154
+ .map(([key]) => [key, value(values[key]!)] as const);
1155
+ body.push(
1156
+ `${inner}${unit}: ${params.length === 0 ? "{}" : obj(params, inner, `${unit}: `.length, 1)},`,
1157
+ );
1158
+ }
1159
+ if (master.target !== undefined)
1160
+ body.push(`${inner}target: ${num(master.target)},`);
1161
+ return `master: {\n${body.join("\n")}\n${INDENT}}`;
1162
+ }
1163
+
577
1164
  /** Object literal: inline when the line fits, expanded otherwise (prettier keeps both). */
578
1165
  function obj(
579
1166
  entries: readonly (readonly [string, string])[],