@hraness/dawg 0.5.0 → 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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
package/core/score.ts CHANGED
@@ -20,7 +20,19 @@ import {
20
20
  type FxLane,
21
21
  type TrackFx,
22
22
  } from "./fx.ts";
23
+ import {
24
+ MODAL_INSTRUMENT,
25
+ normalizeModal,
26
+ type TrackModal,
27
+ } from "./resonators.ts";
23
28
  import { normalizeSynth, type TrackSynth } from "./synth.ts";
29
+ import { normalizeString, type TrackString } from "./strings.ts";
30
+ import {
31
+ isGranularInstrument,
32
+ normalizeGranular,
33
+ type TrackGranular,
34
+ } from "./granular.ts";
35
+ import { normalizeKeys, type TrackKeys } from "./keys.ts";
24
36
  import {
25
37
  checkSongTime,
26
38
  normalizeSongTime,
@@ -99,6 +111,11 @@ export const SCORE_LIMITS = Object.freeze({
99
111
  maxSampleAccelerate: 8,
100
112
  /** Sampler `squiz` ratio bound. */
101
113
  maxSampleSquiz: 32,
114
+ /** Sampler `bpm` (source tempo) bounds. */
115
+ minSampleBpm: 20,
116
+ maxSampleBpm: 400,
117
+ /** Sampler `len` (window length in beats) bound. */
118
+ maxSampleLenBeats: 1024,
102
119
  /** Per sample file, enforced by the decoder and the import tool. */
103
120
  maxSampleFileBytes: 50 * 1024 * 1024,
104
121
  maxSampleSeconds: 600,
@@ -373,6 +390,29 @@ export type Track = Readonly<{
373
390
  * `ref`/`root` over the song tuning. Absent follows the song tuning.
374
391
  */
375
392
  tuning?: Tuning;
393
+ /**
394
+ * String engine settings (`core/strings.ts`): a preset plus overrides.
395
+ * Played only when `instrument` is "string"; absent keeps today's voice.
396
+ */
397
+ string?: TrackString;
398
+ /**
399
+ * Granular instrument settings (0.6, `core/granular.ts`). Played when
400
+ * `instrument` is `"granular"`; kept when the instrument changes.
401
+ */
402
+ granular?: TrackGranular;
403
+ /**
404
+ * Optional (0.6): modelled piano settings (`core/keys.ts`), `{ preset?,
405
+ * ...overrides }`. The modelled piano plays only when `instrument` is a
406
+ * piano family (grand upright felt honkytonk prepared) and this is
407
+ * present; `{}` is the family's defaults.
408
+ */
409
+ keys?: TrackKeys;
410
+ /**
411
+ * Optional (0.6): modal percussion (`core/resonators.ts`), a preset plus
412
+ * overrides. Plays only when `instrument` is `"modal"`; any other
413
+ * instrument with this field is rejected. Absent keeps today's tone.
414
+ */
415
+ modal?: TrackModal;
376
416
  }> &
377
417
  /**
378
418
  * Performance (`core/expression.ts`): glide default, sustain pedal
@@ -449,8 +489,35 @@ export type SampleRef = Readonly<{
449
489
  * (1 is off, up to 32).
450
490
  */
451
491
  squiz?: number;
492
+ /**
493
+ * Optional: the sample's own tempo (20..400). The window then follows
494
+ * the song's tempo map (ramps included): a 174 BPM break in a 128 BPM
495
+ * song plays 128/174 as fast. `fitmode` picks how.
496
+ */
497
+ bpm?: number;
498
+ /**
499
+ * Optional: how a fitted sample changes its time. `repitch` (default)
500
+ * changes speed and pitch together like tape; `beats` cuts the window at
501
+ * its onsets and places each slice on its new time, unstretched (drums,
502
+ * speech); `tones` time-stretches with a phase vocoder and keeps the
503
+ * pitch (pads, loops, vocals). Needs `bpm`, `len` or `fit`.
504
+ */
505
+ fitmode?: SampleFitMode;
506
+ /**
507
+ * Optional: the window's length in beats (0 < len ≤ 1024), followed
508
+ * through the song's tempo map. `fit` wins over `bpm`, `bpm` over `len`.
509
+ */
510
+ len?: number;
452
511
  }>;
453
512
 
513
+ /** Sampler `fitmode`: how a fitted window changes its time. */
514
+ export type SampleFitMode = "repitch" | "beats" | "tones";
515
+ export const SAMPLE_FIT_MODES: readonly SampleFitMode[] = Object.freeze([
516
+ "repitch",
517
+ "beats",
518
+ "tones",
519
+ ]);
520
+
454
521
  /** Sample `unit`: rate, cycles (bars) or seconds. */
455
522
  export type SampleUnit = "r" | "c" | "s";
456
523
  export const SAMPLE_UNITS: readonly SampleUnit[] = Object.freeze([
@@ -683,6 +750,10 @@ export type TrackPatch = Readonly<
683
750
  velocityCurve?: Track["velocityCurve"] | null;
684
751
  humanize?: Track["humanize"] | null;
685
752
  tuning?: Tuning | null;
753
+ string?: TrackString | null;
754
+ granular?: TrackGranular | null;
755
+ keys?: TrackKeys | null;
756
+ modal?: TrackModal | null;
686
757
  }
687
758
  >;
688
759
 
@@ -727,6 +798,10 @@ export type TrackInput = Readonly<
727
798
  | "velocityCurve"
728
799
  | "humanize"
729
800
  | "tuning"
801
+ | "string"
802
+ | "granular"
803
+ | "keys"
804
+ | "modal"
730
805
  > &
731
806
  Pick<Track, "id"> & {
732
807
  filter?: TrackFilter | null;
@@ -745,6 +820,10 @@ export type TrackInput = Readonly<
745
820
  velocityCurve?: Track["velocityCurve"] | string | null;
746
821
  humanize?: Track["humanize"] | null;
747
822
  tuning?: Tuning | null;
823
+ string?: TrackString | null;
824
+ granular?: TrackGranular | null;
825
+ keys?: TrackKeys | null;
826
+ modal?: TrackModal | null;
748
827
  }
749
828
  >;
750
829
 
@@ -1134,9 +1213,19 @@ export function updateTrack(
1134
1213
  ): TrackScore {
1135
1214
  if (!score.tracks.some((track) => track.id === trackId)) return score;
1136
1215
  return score.withTracks(
1137
- score.tracks.map((track) =>
1138
- track.id === trackId ? { ...track, ...patch } : track,
1139
- ),
1216
+ score.tracks.map((track) => {
1217
+ if (track.id !== trackId) return track;
1218
+ const next: Record<string, unknown> = { ...track, ...patch };
1219
+ // A modal field only belongs to a modal track: switching the
1220
+ // instrument away drops it unless the patch sets it too.
1221
+ if (
1222
+ patch.instrument !== undefined &&
1223
+ patch.modal === undefined &&
1224
+ patch.instrument !== MODAL_INSTRUMENT
1225
+ )
1226
+ delete next.modal;
1227
+ return next as Track;
1228
+ }),
1140
1229
  );
1141
1230
  }
1142
1231
 
@@ -1717,7 +1806,14 @@ function normalizeTrack(input: unknown): Track {
1717
1806
  const fx = fxOrThrow(() => normalizeFx(input.fx));
1718
1807
  const fxAutomation = normalizeFxAutomation(input.fxAutomation);
1719
1808
  const synth = fxOrThrow(() => normalizeSynth(input.synth));
1809
+ const keys = fxOrThrow(() => normalizeKeys(input.keys));
1720
1810
  const wavetable = normalizeWavetable(input.wavetable);
1811
+ const modal = fxOrThrow(() => normalizeModal(input.modal));
1812
+ if (modal && instrument !== MODAL_INSTRUMENT)
1813
+ throw new ScoreValidationError(
1814
+ `track ${id} has modal settings but its instrument is "${instrument}"`,
1815
+ "invalid-track",
1816
+ );
1721
1817
  const sampler = normalizeSampler(input.sampler);
1722
1818
  const rhythm = normalizeRhythm(input.rhythm, id);
1723
1819
  const time = timeOrThrow(
@@ -1739,6 +1835,15 @@ function normalizeTrack(input: unknown): Track {
1739
1835
  () => normalizeTuning(input.tuning, `track ${id} tuning`),
1740
1836
  "invalid-track",
1741
1837
  );
1838
+ const string = fxOrThrow(() => normalizeString(input.string));
1839
+ // A bare `granular` instrument (set_instrument, `instrument granular`)
1840
+ // gets an empty object, so the engine plays its defaults.
1841
+ const granular =
1842
+ fxOrThrow(() =>
1843
+ normalizeGranular(input.granular, (ref) =>
1844
+ normalizeSampleRef(ref, "granular src"),
1845
+ ),
1846
+ ) ?? (isGranularInstrument(instrument) ? Object.freeze({}) : undefined);
1742
1847
  let kit: string | undefined;
1743
1848
  if (input.kit !== undefined && input.kit !== null) {
1744
1849
  const found =
@@ -1760,7 +1865,12 @@ function normalizeTrack(input: unknown): Track {
1760
1865
  `track ${id} instrument "sampler" needs a sampler`,
1761
1866
  "invalid-track",
1762
1867
  );
1763
- if (sampler && !isSamplerInstrument(instrument))
1868
+ // A granular track keeps the sampler it grains (`grain off` goes back).
1869
+ if (
1870
+ sampler &&
1871
+ !isSamplerInstrument(instrument) &&
1872
+ !isGranularInstrument(instrument)
1873
+ )
1764
1874
  throw new ScoreValidationError(
1765
1875
  `track ${id} has a sampler but its instrument is "${instrument}"`,
1766
1876
  "invalid-track",
@@ -1793,6 +1903,10 @@ function normalizeTrack(input: unknown): Track {
1793
1903
  ...(time ? { time } : {}),
1794
1904
  ...performance,
1795
1905
  ...(tuning ? { tuning } : {}),
1906
+ ...(string ? { string } : {}),
1907
+ ...(granular ? { granular } : {}),
1908
+ ...(keys ? { keys } : {}),
1909
+ ...(modal ? { modal } : {}),
1796
1910
  });
1797
1911
  }
1798
1912
 
@@ -2000,6 +2114,9 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2000
2114
  fit?: boolean;
2001
2115
  accelerate?: number;
2002
2116
  squiz?: number;
2117
+ bpm?: number;
2118
+ fitmode?: SampleFitMode;
2119
+ len?: number;
2003
2120
  } = { src };
2004
2121
  if (input.sha256 !== undefined) {
2005
2122
  if (typeof input.sha256 !== "string" || !SHA256_HEX.test(input.sha256))
@@ -2163,6 +2280,46 @@ export function normalizeSampleRef(input: unknown, name: string): SampleRef {
2163
2280
  1,
2164
2281
  SCORE_LIMITS.maxSampleSquiz,
2165
2282
  );
2283
+ if (input.bpm !== undefined)
2284
+ ref.bpm = boundedNumber(
2285
+ input.bpm,
2286
+ `${label} bpm`,
2287
+ SCORE_LIMITS.minSampleBpm,
2288
+ SCORE_LIMITS.maxSampleBpm,
2289
+ );
2290
+ if (input.fitmode !== undefined) {
2291
+ if (!SAMPLE_FIT_MODES.includes(input.fitmode as SampleFitMode))
2292
+ throw new ScoreValidationError(
2293
+ `${label} fitmode must be "repitch", "beats" or "tones"`,
2294
+ "invalid-track",
2295
+ );
2296
+ ref.fitmode = input.fitmode as SampleFitMode;
2297
+ }
2298
+ if (input.len !== undefined) {
2299
+ const len = boundedNumber(
2300
+ input.len,
2301
+ `${label} len`,
2302
+ 0,
2303
+ SCORE_LIMITS.maxSampleLenBeats,
2304
+ );
2305
+ if (len === 0)
2306
+ throw new ScoreValidationError(
2307
+ `${label} len must be greater than 0`,
2308
+ "invalid-track",
2309
+ );
2310
+ ref.len = len;
2311
+ }
2312
+ if (
2313
+ ref.fitmode !== undefined &&
2314
+ ref.fitmode !== "repitch" &&
2315
+ ref.bpm === undefined &&
2316
+ ref.len === undefined &&
2317
+ ref.fit !== true
2318
+ )
2319
+ throw new ScoreValidationError(
2320
+ `${label} fitmode ${ref.fitmode} needs bpm, len or fit`,
2321
+ "invalid-track",
2322
+ );
2166
2323
  return Object.freeze(ref);
2167
2324
  }
2168
2325
 
@@ -2403,10 +2560,28 @@ function normalizeAutomation(
2403
2560
  return freezeArray(points);
2404
2561
  }
2405
2562
 
2563
+ /** The note cap `normalizeNotes` enforces; see `withNoteCap`. */
2564
+ let noteCap: number = SCORE_LIMITS.maxNotes;
2565
+
2566
+ /**
2567
+ * Runs `fn` with the note cap raised to `cap`. Stored scores keep
2568
+ * SCORE_LIMITS.maxNotes; the arranged renderer bakes timed tracks (cycle,
2569
+ * rate) into plain notes and may hold more than a stored score allows.
2570
+ */
2571
+ export function withNoteCap<T>(cap: number, fn: () => T): T {
2572
+ const previous = noteCap;
2573
+ noteCap = Math.max(previous, cap);
2574
+ try {
2575
+ return fn();
2576
+ } finally {
2577
+ noteCap = previous;
2578
+ }
2579
+ }
2580
+
2406
2581
  function normalizeNotes(inputs: readonly unknown[]): Note[] {
2407
- if (!Array.isArray(inputs) || inputs.length > SCORE_LIMITS.maxNotes) {
2582
+ if (!Array.isArray(inputs) || inputs.length > noteCap) {
2408
2583
  throw new ScoreValidationError(
2409
- `score cannot contain more than ${SCORE_LIMITS.maxNotes} notes`,
2584
+ `score cannot contain more than ${noteCap} notes`,
2410
2585
  "score-limit",
2411
2586
  );
2412
2587
  }
@@ -104,6 +104,8 @@ async function withSampleHashes(
104
104
  const wavetable = track.wavetable;
105
105
  if (isObject(wavetable) && isObject(wavetable.table))
106
106
  refs.push(wavetable.table);
107
+ const granular = track.granular;
108
+ if (isObject(granular) && isObject(granular.src)) refs.push(granular.src);
107
109
  }
108
110
  for (const ref of refs) {
109
111
  if (
package/core/sdk/print.ts CHANGED
@@ -8,11 +8,25 @@
8
8
  */
9
9
 
10
10
  import { DRUM_VOICES, isDrumInstrument } from "../drums.ts";
11
+ import type { TrackGranular } from "../granular.ts";
11
12
  import { DEFAULT_FIXED_VELOCITY } from "../expression.ts";
12
13
  import type { RhythmRow } from "../euclid.ts";
13
- 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";
14
23
  import { MASTER_SPECS, MASTER_UNITS, type SongMaster } from "../master.ts";
15
24
  import { midiToPitch } from "../pitch.ts";
25
+ import {
26
+ MODAL_INSTRUMENT,
27
+ MODAL_PARAMS,
28
+ type TrackModal,
29
+ } from "../resonators.ts";
16
30
  import { rhythmVoicePitch, rowInSync } from "../rhythm.ts";
17
31
  import {
18
32
  BUILTIN_TABLE_PREFIX,
@@ -35,6 +49,7 @@ import {
35
49
  import { trackSlug } from "../slug.ts";
36
50
  import { barStartTick } from "../tempo.ts";
37
51
  import type { Tuning } from "../tuning.ts";
52
+ import { DEFAULT_STRING_PRESET, type TrackString } from "../strings.ts";
38
53
  import { DEFAULT_HIT_LENGTH, DEFAULT_VELOCITY } from "./v1.ts";
39
54
 
40
55
  const WIDTH = 80;
@@ -137,6 +152,9 @@ const RESERVED = new Set([
137
152
  "every",
138
153
  "sampler",
139
154
  "wavetable",
155
+ "rig",
156
+ "granular",
157
+ "modal",
140
158
  "slices",
141
159
  "euclid",
142
160
  "euclidRot",
@@ -381,22 +399,55 @@ export function printTrack(score: TrackScore, track: Track): string {
381
399
  used.add("note");
382
400
  return printNote(score, note, !kit && !slots, INDENT + INDENT);
383
401
  });
402
+ const grained =
403
+ track.instrument === "granular" && track.granular !== undefined;
404
+ if (grained) used.add("granular");
384
405
  if (track.sampler) used.add("sampler");
385
406
  const wavetable = isWavetableInstrument(track.instrument)
386
407
  ? wavetableOf(track)
387
408
  : undefined;
388
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");
389
417
 
390
418
  const entries: string[] = [
391
419
  `id: ${str(track.id)}`,
392
420
  `name: ${str(track.name)}`,
393
421
  ];
394
- 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) {
395
428
  entries.push(`instrument: ${printSampler(track.sampler, INDENT)}`);
396
429
  } else if (wavetable) {
397
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}`);
398
435
  } else entries.push(`instrument: ${str(track.instrument)}`);
399
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
+ );
400
451
  if (track.time) {
401
452
  const beats = (ticks: number) => num(ticks / score.ticksPerBeat);
402
453
  const fields: (readonly [string, string])[] = [];
@@ -480,18 +531,31 @@ export function printTrack(score: TrackScore, track: Track): string {
480
531
  )}`,
481
532
  );
482
533
  if (track.fx) {
483
- 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
+ );
484
545
  const inner = INDENT + INDENT;
485
- const body = effects.map(([effect, values]) => {
486
- // Only values that differ from the default: decoding fills the rest.
487
- const params = Object.entries(fxSpec(effect).params)
488
- .filter(
489
- ([key, spec]) =>
490
- values[key] !== undefined && values[key] !== spec.default,
491
- )
492
- .map(([key]) => [key, value(values[key]!)] as const);
493
- return `${inner}${effect}: ${params.length === 0 ? "{}" : obj(params, inner, `${effect}: `.length, 1)},`;
494
- });
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
+ );
495
559
  entries.push(`fx: {\n${body.join("\n")}\n${INDENT}}`);
496
560
  }
497
561
  if (track.synth) {
@@ -506,6 +570,21 @@ export function printTrack(score: TrackScore, track: Track): string {
506
570
  );
507
571
  entries.push(`synth: ${obj(params, INDENT, "synth: ".length, 1)}`);
508
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
+ }
509
588
  entries.push(...performanceEntries(score, track));
510
589
  const lanes: [string, readonly AutomationPoint[] | undefined][] = [
511
590
  ["volume", track.volumeAutomation],
@@ -575,6 +654,10 @@ export function printTrack(score: TrackScore, track: Track): string {
575
654
  "every",
576
655
  "sampler",
577
656
  "wavetable",
657
+ "stringed",
658
+ "rig",
659
+ "granular",
660
+ "modal",
578
661
  "euclid",
579
662
  "grid",
580
663
  ]
@@ -859,6 +942,76 @@ function printSampler(sampler: Sampler, indent: string): string {
859
942
  return `sampler(\n${inner}${shifted},\n${inner}{ mode: ${str(sampler.mode)} },\n${indent})`;
860
943
  }
861
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
+
862
1015
  /** `wavetable("basic", { wt: 0.5 })`; pack tables keep their pin. */
863
1016
  function printWavetable(settings: TrackWavetable, indent: string): string {
864
1017
  const src = settings.table.src;
@@ -915,6 +1068,9 @@ function printSample(ref: SampleRef, indent: string, prefix: number): string {
915
1068
  if (ref.accelerate !== undefined)
916
1069
  entries.push(["accelerate", num(ref.accelerate)]);
917
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)]);
918
1074
  if (entries.length === 1) return str(ref.src);
919
1075
  return obj(entries, indent, prefix, 1);
920
1076
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The SDK is one vendored file with no imports, so `core/sdk/v1.ts` carries
3
+ * a copy of the instrument word resolver (`core/instruments.ts`) between two
4
+ * marker lines. `bun core/sdk/sync-instruments.ts` rewrites it and
5
+ * `sync-instruments.test.ts` fails when it is stale. Same scheme as
6
+ * `sync-chords.ts`.
7
+ */
8
+
9
+ import { readFile, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { format } from "prettier";
12
+
13
+ export const BEGIN_MARKER =
14
+ "// BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts";
15
+ export const END_MARKER = "// END instrument words";
16
+
17
+ const ROOT = join(import.meta.dir, "..", "..");
18
+ export const INSTRUMENTS_PATH = join(ROOT, "core", "instruments.ts");
19
+ export const SDK_PATH = join(ROOT, "core", "sdk", "v1.ts");
20
+
21
+ /** The resolver block (markers included) for a `core/instruments.ts` source. */
22
+ export function instrumentBlock(source: string): string {
23
+ const start = source.indexOf("// ---- instrument words");
24
+ if (start < 0) throw new Error("core/instruments.ts layout changed");
25
+ const body = source
26
+ .slice(source.indexOf("\n", start) + 1)
27
+ .replace(/^export /gm, "")
28
+ .trim();
29
+ return `${BEGIN_MARKER}\n${body}\n${END_MARKER}`;
30
+ }
31
+
32
+ /** `sdk` with its resolver block replaced, formatted like the repo. */
33
+ export async function syncedSdk(
34
+ sdk: string,
35
+ instruments: string,
36
+ ): Promise<string> {
37
+ const from = sdk.indexOf(BEGIN_MARKER);
38
+ const to = sdk.indexOf(END_MARKER);
39
+ if (from < 0 || to < 0)
40
+ throw new Error("v1.ts has no instrument word markers");
41
+ const next =
42
+ sdk.slice(0, from) +
43
+ instrumentBlock(instruments) +
44
+ sdk.slice(to + END_MARKER.length);
45
+ return format(next, { parser: "typescript", filepath: SDK_PATH });
46
+ }
47
+
48
+ if (import.meta.main) {
49
+ const [sdk, instruments] = await Promise.all([
50
+ readFile(SDK_PATH, "utf8"),
51
+ readFile(INSTRUMENTS_PATH, "utf8"),
52
+ ]);
53
+ const next = await syncedSdk(sdk, instruments);
54
+ if (next !== sdk) await writeFile(SDK_PATH, next);
55
+ console.log(
56
+ next === sdk ? "v1.ts instrument words up to date" : "v1.ts updated",
57
+ );
58
+ }