@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/v1.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  /** SDK release; dawg refreshes the vendored copy when its own is newer. */
30
- export const SDK_VERSION = "1.13.0";
30
+ export const SDK_VERSION = "1.25.0";
31
31
  /** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
32
32
  export const SDK_MAJOR = 1;
33
33
 
@@ -90,6 +90,26 @@ export function midi(pitch: Pitch): number {
90
90
  return value;
91
91
  }
92
92
 
93
+ /**
94
+ * A pitch with an optional cents suffix (SDK 1.16.0): `"E4-14c"` is E4
95
+ * fourteen cents flat, `"A3+50c"` a quarter tone sharp. The offset is
96
+ * static and sits on top of the song or track tuning; ±1200 at most.
97
+ */
98
+ export function pitchCents(pitch: Pitch): Readonly<{
99
+ pitch: number;
100
+ cents: number;
101
+ }> {
102
+ const match =
103
+ typeof pitch === "string"
104
+ ? pitch.trim().match(/^(.+?)([+-]\d+(?:\.\d+)?)c$/)
105
+ : null;
106
+ if (!match) return { pitch: midi(pitch), cents: 0 };
107
+ const cents = Number(match[2]);
108
+ if (!(Math.abs(cents) <= 1200))
109
+ throw new DawgSdkError(`pitch cents must be within ±1200: ${pitch}`);
110
+ return { pitch: midi(match[1]!), cents };
111
+ }
112
+
93
113
  // ---------------------------------------------------------------------------
94
114
  // Drum voices (General MIDI numbers, same table as dawg's `kit`)
95
115
 
@@ -161,7 +181,10 @@ export type NoteSpec = Readonly<{
161
181
  length: number;
162
182
  /** 0..1. */
163
183
  velocity: number;
164
- }>;
184
+ /** Static offset in cents from a `"E4-14c"` pitch (SDK 1.16.0); absent is 0. */
185
+ cents?: number;
186
+ }> &
187
+ NoteExpressionSpec;
165
188
 
166
189
  /** A drum or sampler hit addressed by voice name; resolved to a pitch slot by `track()`. */
167
190
  export type HitSpec = Readonly<{
@@ -170,15 +193,205 @@ export type HitSpec = Readonly<{
170
193
  start: number;
171
194
  length: number;
172
195
  velocity: number;
196
+ }> &
197
+ NoteExpressionSpec;
198
+
199
+ /** How a note is articulated (SDK 1.15.0). */
200
+ export type Articulation =
201
+ "staccato" | "legato" | "accent" | "tenuto" | "marcato" | "ghost";
202
+
203
+ export const ARTICULATIONS: readonly Articulation[] = Object.freeze([
204
+ "staccato",
205
+ "legato",
206
+ "accent",
207
+ "tenuto",
208
+ "marcato",
209
+ "ghost",
210
+ ]);
211
+
212
+ /** A pitch-bend point: `[at, cents]`, `at` 0..1 through the note. */
213
+ export type BendPoint = readonly [number, number];
214
+
215
+ /**
216
+ * How one note is played (SDK 1.15.0). Every field is optional.
217
+ *
218
+ * ```ts
219
+ * note("C4", 0, 1, 0.8, { art: "staccato" })
220
+ * note("E4", 1, 2, 0.8, { glide: 0.1, vibrato: { depth: 30, delay: 0.3 } })
221
+ * note("G4", 3, 1, 0.8, { bend: [[0, -200], [0.25, 0]] }) // scoop up a tone
222
+ * ```
223
+ */
224
+ export type Expression = Readonly<{
225
+ /**
226
+ * `staccato` (half length), `legato` (held into the next note),
227
+ * `accent` (louder), `tenuto` (full length, a little louder), `marcato`
228
+ * (two-thirds length, much louder) or `ghost` (half length, much softer).
229
+ */
230
+ articulation?: Articulation;
231
+ /** Short alias of `articulation`. */
232
+ art?: Articulation;
233
+ /** Portamento into this note from the track's previous pitch, seconds (0..10). */
234
+ glide?: number;
235
+ /** Pitch curve in cents as `[at, cents]` points, `at` 0..1 through the note; linear between points. */
236
+ bend?: readonly BendPoint[];
237
+ /** Vibrato: `rate` Hz (default 5.5), `depth` cents either side (default 20), `delay` seconds before it fades in. */
238
+ vibrato?: Readonly<{ rate?: number; depth?: number; delay?: number }>;
239
+ /**
240
+ * This note's humanize (SDK 1.15.0), replacing the track's amounts:
241
+ * `{ timing ms, velocity %, length % }`; `{}` keeps the note exact.
242
+ * Humanize just bars 5-8 with `expr({ humanize: { timing: 10 } }, ...)`.
243
+ */
244
+ humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
245
+ }>;
246
+
247
+ /** The expression a built note carries; fields are present only when set. */
248
+ export type NoteExpressionSpec = Readonly<{
249
+ articulation?: Articulation;
250
+ glide?: number;
251
+ bend?: readonly BendPoint[];
252
+ vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
253
+ humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
173
254
  }>;
174
255
 
256
+ const DEFAULT_VIBRATO_RATE = 5.5;
257
+ const DEFAULT_VIBRATO_DEPTH = 20;
258
+
259
+ function expression(
260
+ input: Expression | undefined,
261
+ label: string,
262
+ ): NoteExpressionSpec {
263
+ if (input === undefined) return {};
264
+ if (!isRecord(input))
265
+ throw new DawgSdkError(`${label} expression must be an object`);
266
+ for (const key of Object.keys(input))
267
+ if (
268
+ !["articulation", "art", "glide", "bend", "vibrato", "humanize"].includes(
269
+ key,
270
+ )
271
+ )
272
+ throw new DawgSdkError(
273
+ `${label} expression has an unknown field "${key.slice(0, 32)}" (articulation glide bend vibrato humanize)`,
274
+ );
275
+ const out: {
276
+ articulation?: Articulation;
277
+ glide?: number;
278
+ bend?: readonly BendPoint[];
279
+ vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
280
+ humanize?: Readonly<{
281
+ timing?: number;
282
+ velocity?: number;
283
+ length?: number;
284
+ }>;
285
+ } = {};
286
+ const articulation = input.articulation ?? input.art;
287
+ if (articulation !== undefined) {
288
+ if (!ARTICULATIONS.includes(articulation))
289
+ throw new DawgSdkError(
290
+ `${label} articulation must be one of ${ARTICULATIONS.join(" ")}`,
291
+ );
292
+ out.articulation = articulation;
293
+ }
294
+ if (input.glide !== undefined) {
295
+ const glide = finite(input.glide, `${label} glide`);
296
+ if (glide < 0) throw new DawgSdkError(`${label} glide must be ≥ 0`);
297
+ out.glide = glide;
298
+ }
299
+ if (input.bend !== undefined) {
300
+ if (!Array.isArray(input.bend) || input.bend.length > 32)
301
+ throw new DawgSdkError(
302
+ `${label} bend must be at most 32 [at, cents] points`,
303
+ );
304
+ if (input.bend.length > 0)
305
+ out.bend = Object.freeze(
306
+ input.bend.map((point: unknown, index: number): BendPoint => {
307
+ if (!Array.isArray(point) || point.length !== 2)
308
+ throw new DawgSdkError(
309
+ `${label} bend[${index}] must be [at, cents]`,
310
+ );
311
+ return Object.freeze([
312
+ unit(point[0], `${label} bend[${index}] at`),
313
+ finite(point[1], `${label} bend[${index}] cents`),
314
+ ] as const);
315
+ }),
316
+ );
317
+ }
318
+ if (input.vibrato !== undefined) {
319
+ if (!isRecord(input.vibrato))
320
+ throw new DawgSdkError(`${label} vibrato must be { rate, depth, delay }`);
321
+ const delay =
322
+ input.vibrato.delay === undefined
323
+ ? 0
324
+ : finite(input.vibrato.delay, `${label} vibrato delay`);
325
+ out.vibrato = Object.freeze({
326
+ rate: finite(
327
+ input.vibrato.rate ?? DEFAULT_VIBRATO_RATE,
328
+ `${label} vibrato rate`,
329
+ ),
330
+ depth: finite(
331
+ input.vibrato.depth ?? DEFAULT_VIBRATO_DEPTH,
332
+ `${label} vibrato depth`,
333
+ ),
334
+ ...(delay !== 0 ? { delay } : {}),
335
+ });
336
+ }
337
+ if (input.humanize !== undefined) {
338
+ if (!isRecord(input.humanize))
339
+ throw new DawgSdkError(
340
+ `${label} humanize must be { timing, velocity, length }`,
341
+ );
342
+ const amounts: Record<string, number> = {};
343
+ for (const key of Object.keys(input.humanize)) {
344
+ if (key !== "timing" && key !== "velocity" && key !== "length")
345
+ throw new DawgSdkError(
346
+ `${label} humanize has an unknown field "${key.slice(0, 32)}" (timing velocity length)`,
347
+ );
348
+ const value = finite(input.humanize[key], `${label} humanize ${key}`);
349
+ if (value < 0)
350
+ throw new DawgSdkError(`${label} humanize ${key} must be ≥ 0`);
351
+ if (value > 0) amounts[key] = value;
352
+ }
353
+ out.humanize = Object.freeze(amounts);
354
+ }
355
+ return out;
356
+ }
357
+
358
+ /**
359
+ * The same expression on many notes or hits (SDK 1.15.0); a note's own
360
+ * fields win.
361
+ *
362
+ * ```ts
363
+ * notes: expr(seq("C2 C2 Eb2 C3", { step: 0.25 }), { art: "staccato" })
364
+ * ```
365
+ */
366
+ export function expr<T extends NoteSpec | HitSpec>(
367
+ notes: readonly T[],
368
+ expression_: Expression,
369
+ ): readonly T[] {
370
+ if (!Array.isArray(notes))
371
+ throw new DawgSdkError("expr needs an array of notes or hits");
372
+ const shared = expression(expression_, "expr");
373
+ return Object.freeze(
374
+ notes.map((item, index) => {
375
+ if (!isRecord(item) || (item.kind !== "note" && item.kind !== "hit"))
376
+ throw new DawgSdkError(
377
+ `expr notes[${index}] must come from note(), seq(), hit() or hits()`,
378
+ );
379
+ return Object.freeze({ ...shared, ...item }) as T;
380
+ }),
381
+ );
382
+ }
383
+
175
384
  /**
176
385
  * One note. `pitch` is a name or MIDI number, `start` and `length` are
177
- * beats, `velocity` defaults to 0.8.
386
+ * beats, `velocity` defaults to 0.8, and `how` adds expression
387
+ * (articulation, glide, bend, vibrato; SDK 1.15.0). A cents suffix detunes
388
+ * one note (SDK 1.16.0): `"E4-14c"` (see `pitchCents`).
178
389
  *
179
390
  * ```ts
180
391
  * note("A1", 0, 1) // A1 on the downbeat for one beat
181
392
  * note("A1", 1.5, 0.5, 0.6) // off-beat eighth, softer
393
+ * note("A1", 2, 1, 0.8, { art: "staccato" })
394
+ * note("E4-14c", 2) // a just major third over C, 14 cents flat
182
395
  * ```
183
396
  */
184
397
  export function note(
@@ -186,13 +399,17 @@ export function note(
186
399
  start: number,
187
400
  length = 1,
188
401
  velocity = DEFAULT_VELOCITY,
402
+ how?: Expression,
189
403
  ): NoteSpec {
404
+ const tuned = pitchCents(pitch);
190
405
  return Object.freeze({
191
406
  kind: "note",
192
- pitch: midi(pitch),
407
+ pitch: tuned.pitch,
193
408
  start: beat(start, "note start"),
194
409
  length: positive(length, "note length"),
195
410
  velocity: unit(velocity, "note velocity"),
411
+ ...expression(how, "note"),
412
+ ...(tuned.cents !== 0 ? { cents: tuned.cents } : {}),
196
413
  });
197
414
  }
198
415
 
@@ -245,13 +462,14 @@ export function seq(
245
462
  * One drum or sampler hit. On a `kit` track `voice` is `kick`, `snare`,
246
463
  * `clap`, `rim`, `tom`, `hat` or `openhat` (aliases `bd`, `sd`, `cp`, `hh`,
247
464
  * `oh` work); on a `sampler()` track it is a voice name. `length` defaults
248
- * to a sixteenth.
465
+ * to a sixteenth; `how` adds expression (`{ art: "ghost" }`, SDK 1.15.0).
249
466
  */
250
467
  export function hit(
251
468
  voice: string,
252
469
  start: number,
253
470
  velocity = DEFAULT_VELOCITY,
254
471
  length = DEFAULT_HIT_LENGTH,
472
+ how?: Expression,
255
473
  ): HitSpec {
256
474
  if (typeof voice !== "string" || voice.length === 0 || voice.length > 32)
257
475
  throw new DawgSdkError("hit voice must be a short name");
@@ -261,6 +479,7 @@ export function hit(
261
479
  start: beat(start, "hit start"),
262
480
  length: positive(length, "hit length"),
263
481
  velocity: unit(velocity, "hit velocity"),
482
+ ...expression(how, "hit"),
264
483
  });
265
484
  }
266
485
 
@@ -1058,6 +1277,12 @@ export type SampleSpec = Readonly<{
1058
1277
  accelerate?: number;
1059
1278
  /** Like Tidal `squiz`: pitch-raise ratio per zero-crossing cycle (1..32). */
1060
1279
  squiz?: number;
1280
+ /** The file's own tempo (20..400, SDK 1.20.0): the window follows the song's tempo map. */
1281
+ bpm?: number;
1282
+ /** How a fitted window changes time (SDK 1.20.0): `"repitch"` (tape), `"beats"` (onset slices), `"tones"` (keeps pitch). */
1283
+ fitmode?: "repitch" | "beats" | "tones";
1284
+ /** Window length in beats (SDK 1.20.0); `fit` wins over `bpm`, `bpm` over `len`. */
1285
+ len?: number;
1061
1286
  }>;
1062
1287
 
1063
1288
  /** Result of `sampler()`; pass it as a track's `instrument`. */
@@ -1098,7 +1323,7 @@ export function sampler(
1098
1323
  throw new DawgSdkError(
1099
1324
  `sampler voice "${name}" must be a short identifier`,
1100
1325
  );
1101
- out[name] = sample(voices[name]!, name);
1326
+ out[name] = sampleSpec(voices[name]!, name);
1102
1327
  }
1103
1328
  return Object.freeze({ kind: "sampler", voices: Object.freeze(out), mode });
1104
1329
  }
@@ -1214,6 +1439,314 @@ export function wavetable(
1214
1439
  return Object.freeze(out) as WavetableSpec;
1215
1440
  }
1216
1441
 
1442
+ /** Instrument name of the 0.6 string engine (`Track.string`). */
1443
+ export const STRING_INSTRUMENT = "string";
1444
+
1445
+ /**
1446
+ * String engine settings (SDK 1.21.0): a `preset` (`nylon`, `steel`,
1447
+ * `electric`, `jangle`, `ebass`, `slap`, `upright`, `sitar`, `tanpura`,
1448
+ * `harpsichord`, `lute`, `oud`, `setar`, `tar`, `santur`, `dulcimer`, `koto`,
1449
+ * `harp`, `banjo`, `tres`, `requinto`) plus any parameter to override
1450
+ * (`ring`, `bright`, `damp`, `pos`, `mute`, `buzz`, `body`, `sym`, ...).
1451
+ * dawg validates names and ranges; see **Strings** in DAWG.md.
1452
+ */
1453
+ export type StringInput = Readonly<
1454
+ { preset?: string } & Record<string, number | string | undefined>
1455
+ >;
1456
+
1457
+ /** Result of `stringed()`; pass it as a track's `instrument`. */
1458
+ export type StringSpec = Readonly<{ kind: "string" } & StringInput>;
1459
+
1460
+ /**
1461
+ * A plucked string instrument (SDK 1.21.0): a preset and overrides.
1462
+ *
1463
+ * instrument: stringed("nylon")
1464
+ * instrument: stringed("sitar", { buzz: 0.8, sym: 0.5 })
1465
+ */
1466
+ export function stringed(
1467
+ preset = "nylon",
1468
+ params: Readonly<Record<string, number | string>> = {},
1469
+ ): StringSpec {
1470
+ if (typeof preset !== "string" || preset.length === 0)
1471
+ throw new DawgSdkError("stringed needs a preset name");
1472
+ if (!isRecord(params))
1473
+ throw new DawgSdkError("stringed params must be an object");
1474
+ const out: Record<string, number | string> = { kind: "string", preset };
1475
+ for (const [key, value] of Object.entries(params)) {
1476
+ if (value === undefined || key === "kind" || key === "preset") continue;
1477
+ out[key] =
1478
+ typeof value === "string" ? value : finite(value, `string ${key}`);
1479
+ }
1480
+ return Object.freeze(out) as StringSpec;
1481
+ }
1482
+
1483
+ /** Instrument name of the 0.6 granular engine (`Track.granular`). */
1484
+ export const GRANULAR_INSTRUMENT = "granular";
1485
+
1486
+ /**
1487
+ * Granular settings (SDK 1.23.0). `src` is a sample (`sample(...)` shape,
1488
+ * pinned like a sampler voice) or a built-in synth render
1489
+ * `"synth:<preset>[@note]"` (default `synth:pad`, nothing to download);
1490
+ * `preset` is `cloud`, `hold`, `sparkle`, `swarm`, `stutter`, `microloop`,
1491
+ * `backwards` or `dust`; every other key overrides one parameter (`grain`
1492
+ * seconds, `overlap`, `scan`, `pos`, `begin`, `end`, `spray`, `jitter`,
1493
+ * `pitch`, `detune`, `shimmer`, `shimint`, `spread`, `window`, `reverse`,
1494
+ * `freeze`, `repeat`, `hold`, `drift`, `drate`, `attack`, `release`,
1495
+ * `veltone`, `gain`, `seed`, `root`). dawg validates names and ranges; see
1496
+ * **Granular** in DAWG.md.
1497
+ */
1498
+ export type GranularInput = Readonly<
1499
+ {
1500
+ src?: string | SampleSpec;
1501
+ preset?: string;
1502
+ } & Record<string, number | string | boolean | SampleSpec | undefined>
1503
+ >;
1504
+
1505
+ /** Result of `granular()`; pass it as a track's `instrument`. */
1506
+ export type GranularSpec = Readonly<{ kind: "granular" } & GranularInput>;
1507
+
1508
+ /**
1509
+ * A granular instrument (SDK 1.23.0): an optional preset, then overrides.
1510
+ *
1511
+ * ```ts
1512
+ * instrument: granular("cloud")
1513
+ * instrument: granular("hold", { src: "samples/choir.wav", scan: 0 })
1514
+ * instrument: granular({ src: "synth:bell@72", grain: 0.08, overlap: 6 })
1515
+ * ```
1516
+ */
1517
+ export function granular(
1518
+ preset?: string | GranularInput,
1519
+ params: GranularInput = {},
1520
+ ): GranularSpec {
1521
+ const fields =
1522
+ typeof preset === "object" && preset !== null ? preset : params;
1523
+ if (!isRecord(fields))
1524
+ throw new DawgSdkError("granular params must be an object");
1525
+ const out: Record<string, unknown> = { kind: "granular" };
1526
+ if (typeof preset === "string") {
1527
+ if (preset.length === 0)
1528
+ throw new DawgSdkError("granular needs a preset name");
1529
+ out.preset = preset;
1530
+ } else if (preset !== undefined && (typeof preset !== "object" || !preset))
1531
+ throw new DawgSdkError("granular takes a preset name or params");
1532
+ for (const [key, value] of Object.entries(fields)) {
1533
+ if (value === undefined || key === "kind") continue;
1534
+ if (key === "src")
1535
+ out.src =
1536
+ typeof value === "string" && value.startsWith("synth:")
1537
+ ? value
1538
+ : sampleSpec(value as string | SampleSpec, "granular src");
1539
+ else if (key === "preset" && typeof preset === "string") continue;
1540
+ else if (key === "root" && typeof value === "string")
1541
+ out.root = midi(value as Pitch);
1542
+ else if (
1543
+ typeof value === "number" ||
1544
+ typeof value === "string" ||
1545
+ typeof value === "boolean"
1546
+ )
1547
+ out[key] =
1548
+ typeof value === "number" ? finite(value, `granular ${key}`) : value;
1549
+ else throw new DawgSdkError(`granular ${key} must be a value`);
1550
+ }
1551
+ return Object.freeze(out) as GranularSpec;
1552
+ }
1553
+
1554
+ /** Instrument name that selects the modal mallet-and-bell engine (SDK 1.25.0). */
1555
+ export const MODAL_INSTRUMENT = "modal";
1556
+
1557
+ /** Modal presets (dawg's core/resonators.ts). */
1558
+ export type ModalPresetName =
1559
+ | "marimba"
1560
+ | "vibes"
1561
+ | "xylophone"
1562
+ | "glock"
1563
+ | "celesta"
1564
+ | "chimes"
1565
+ | "kalimba"
1566
+ | "mbira"
1567
+ | "steelpan"
1568
+ | "bowl"
1569
+ | "gong"
1570
+ | "timpani";
1571
+
1572
+ /** Modal overrides; omitted means the preset's value. dawg validates ranges. */
1573
+ export type ModalParams = Readonly<{
1574
+ /** `yarn` `cord` `rubber` `plastic` `brass`: sets hardness. */
1575
+ mallet?: "yarn" | "cord" | "rubber" | "plastic" | "brass";
1576
+ /** Mallet hardness 0..1: brighter, shorter contact. */
1577
+ hardness?: number;
1578
+ /** Strike position 0..1 (0.5 is the bar's centre). */
1579
+ position?: number;
1580
+ /** Fundamental ring time (T60 seconds). */
1581
+ ring?: number;
1582
+ /** How much faster upper modes die (octaves of decay per octave). */
1583
+ tilt?: number;
1584
+ /** Damping on note-off 0..1 (0 lets the bar ring). */
1585
+ damp?: number;
1586
+ /** Choke time after note-off, seconds. */
1587
+ release?: number;
1588
+ /** Vibraphone motor rate Hz and depth 0..1. */
1589
+ motor?: number;
1590
+ motordepth?: number;
1591
+ /** Beat between paired gamelan modes, Hz. */
1592
+ ombak?: number;
1593
+ /** Mbira buzz 0..1 and mallet click 0..1. */
1594
+ buzz?: number;
1595
+ click?: number;
1596
+ /** Strike pitch bend in semitones and its decay seconds (Strudel penv/pdecay). */
1597
+ strikebend?: number;
1598
+ strikedecay?: number;
1599
+ /** Output level 0..2 (1 is the preset level). */
1600
+ gain?: number;
1601
+ /** Mode table override (`marimba`, `bell`, `gong`, …). */
1602
+ body?: string;
1603
+ }>;
1604
+
1605
+ const MODAL_KEYS = Object.freeze([
1606
+ "mallet",
1607
+ "hardness",
1608
+ "position",
1609
+ "ring",
1610
+ "tilt",
1611
+ "damp",
1612
+ "release",
1613
+ "motor",
1614
+ "motordepth",
1615
+ "ombak",
1616
+ "buzz",
1617
+ "click",
1618
+ "strikebend",
1619
+ "strikedecay",
1620
+ "gain",
1621
+ "body",
1622
+ ] as const);
1623
+
1624
+ /** Mallet words (core/resonators.ts MODAL_MALLETS). */
1625
+ const MODAL_MALLET_WORDS: readonly string[] = Object.freeze([
1626
+ "yarn",
1627
+ "cord",
1628
+ "rubber",
1629
+ "plastic",
1630
+ "brass",
1631
+ ]);
1632
+
1633
+ /** Mode tables a `body` override may name (core/resonators.ts MODAL_BODIES). */
1634
+ const MODAL_BODY_WORDS: readonly string[] = Object.freeze([
1635
+ "marimba",
1636
+ "vibraphone",
1637
+ "xylophone",
1638
+ "glockenspiel",
1639
+ "celesta",
1640
+ "chimes",
1641
+ "crotale",
1642
+ "mbira",
1643
+ "kalimba",
1644
+ "musicbox",
1645
+ "toypiano",
1646
+ "saron",
1647
+ "bonang",
1648
+ "gender",
1649
+ "kempul",
1650
+ "gong",
1651
+ "bell",
1652
+ "steelpan",
1653
+ "bowl",
1654
+ "timpani",
1655
+ "tabla",
1656
+ "frame",
1657
+ ]);
1658
+
1659
+ /** Numeric ranges (core/resonators.ts MODAL_PARAMS min..max). */
1660
+ const MODAL_RANGES: Readonly<Record<string, readonly [number, number]>> =
1661
+ Object.freeze({
1662
+ hardness: [0, 1],
1663
+ position: [0, 1],
1664
+ ring: [0.05, 30],
1665
+ tilt: [0, 2],
1666
+ damp: [0, 1],
1667
+ release: [0.005, 2],
1668
+ motor: [0, 12],
1669
+ motordepth: [0, 1],
1670
+ ombak: [0, 12],
1671
+ buzz: [0, 1],
1672
+ click: [0, 1],
1673
+ strikebend: [-24, 24],
1674
+ strikedecay: [0.001, 2],
1675
+ gain: [0, 2],
1676
+ });
1677
+
1678
+ const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
1679
+ "marimba",
1680
+ "vibes",
1681
+ "xylophone",
1682
+ "glock",
1683
+ "celesta",
1684
+ "chimes",
1685
+ "kalimba",
1686
+ "mbira",
1687
+ "steelpan",
1688
+ "bowl",
1689
+ "gong",
1690
+ "timpani",
1691
+ ]);
1692
+
1693
+ /** Result of `modal()`; pass it as a track's `instrument`. */
1694
+ export type ModalSpec = Readonly<
1695
+ { kind: "modal"; preset?: ModalPresetName } & ModalParams
1696
+ >;
1697
+
1698
+ /**
1699
+ * Mallets and bells on the modal engine (SDK 1.25.0): a preset and
1700
+ * optional overrides. A preset word alone (`instrument: "vibes"`) is the
1701
+ * same as `modal("vibes")`, except `"marimba"`, which stays the legacy
1702
+ * marimba voice; `modal("marimba")` is the modal one.
1703
+ *
1704
+ * ```ts
1705
+ * instrument: modal("vibes", { motor: 4, hardness: 0.6 })
1706
+ * instrument: modal("marimba", { mallet: "rubber" })
1707
+ * instrument: modal({ ring: 2 }) // default preset (marimba)
1708
+ * ```
1709
+ */
1710
+ export function modal(
1711
+ preset?: ModalPresetName | ModalParams,
1712
+ params: ModalParams = {},
1713
+ ): ModalSpec {
1714
+ const overrides = isRecord(preset) ? preset : params;
1715
+ const name = isRecord(preset) ? undefined : preset;
1716
+ if (!isRecord(overrides))
1717
+ throw new DawgSdkError("modal params must be an object");
1718
+ const out: Record<string, unknown> = { kind: "modal" };
1719
+ if (name !== undefined) {
1720
+ if (typeof name !== "string" || !MODAL_PRESET_WORDS.includes(name))
1721
+ throw new DawgSdkError(
1722
+ `modal preset "${String(name).slice(0, 32)}" is not one of ${MODAL_PRESET_WORDS.join(" ")}`,
1723
+ );
1724
+ out.preset = name;
1725
+ }
1726
+ for (const key of Object.keys(overrides)) {
1727
+ const value = (overrides as Record<string, unknown>)[key];
1728
+ if (value === undefined) continue;
1729
+ if (key === "mallet" || key === "body") {
1730
+ const words = key === "mallet" ? MODAL_MALLET_WORDS : MODAL_BODY_WORDS;
1731
+ if (typeof value !== "string" || !words.includes(value))
1732
+ throw new DawgSdkError(
1733
+ `modal ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
1734
+ );
1735
+ out[key] = value;
1736
+ } else if ((MODAL_KEYS as readonly string[]).includes(key)) {
1737
+ const number = finite(value, `modal ${key}`);
1738
+ const [min, max] = MODAL_RANGES[key]!;
1739
+ if (number < min || number > max)
1740
+ throw new DawgSdkError(`modal ${key} must be ${min}..${max}`);
1741
+ out[key] = number;
1742
+ } else
1743
+ throw new DawgSdkError(
1744
+ `modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")})`,
1745
+ );
1746
+ }
1747
+ return Object.freeze(out) as ModalSpec;
1748
+ }
1749
+
1217
1750
  /**
1218
1751
  * `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
1219
1752
  * chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
@@ -1226,7 +1759,7 @@ export function slices(
1226
1759
  ): Record<string, SampleSpec> {
1227
1760
  if (!Number.isInteger(count) || count < 1 || count > 64)
1228
1761
  throw new DawgSdkError("slices count must be an integer 1..64");
1229
- const base = sample(src, prefix);
1762
+ const base = sampleSpec(src, prefix);
1230
1763
  const begin = base.begin ?? 0;
1231
1764
  const end = base.end ?? 1;
1232
1765
  const span = (end - begin) / count;
@@ -1241,7 +1774,7 @@ export function slices(
1241
1774
  return voices;
1242
1775
  }
1243
1776
 
1244
- function sample(value: string | SampleSpec, name: string): SampleSpec {
1777
+ function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
1245
1778
  const spec = typeof value === "string" ? { src: value } : value;
1246
1779
  if (!isRecord(spec) || typeof spec.src !== "string" || spec.src.length === 0)
1247
1780
  throw new DawgSdkError(`sampler voice ${name} needs a src path`);
@@ -1264,6 +1797,9 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
1264
1797
  fit?: boolean;
1265
1798
  accelerate?: number;
1266
1799
  squiz?: number;
1800
+ bpm?: number;
1801
+ fitmode?: "repitch" | "beats" | "tones";
1802
+ len?: number;
1267
1803
  } = { src: spec.src };
1268
1804
  if (spec.src.startsWith("pack:")) {
1269
1805
  if (spec.sha256 !== undefined)
@@ -1313,9 +1849,36 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
1313
1849
  if (spec.accelerate !== undefined)
1314
1850
  out.accelerate = finite(spec.accelerate, `${name} accelerate`);
1315
1851
  if (spec.squiz !== undefined) out.squiz = finite(spec.squiz, `${name} squiz`);
1852
+ if (spec.bpm !== undefined) out.bpm = finite(spec.bpm, `${name} bpm`);
1853
+ if (spec.fitmode !== undefined) {
1854
+ if (
1855
+ spec.fitmode !== "repitch" &&
1856
+ spec.fitmode !== "beats" &&
1857
+ spec.fitmode !== "tones"
1858
+ )
1859
+ throw new DawgSdkError(
1860
+ `${name} fitmode must be "repitch", "beats" or "tones"`,
1861
+ );
1862
+ out.fitmode = spec.fitmode;
1863
+ }
1864
+ if (spec.len !== undefined) out.len = finite(spec.len, `${name} len`);
1316
1865
  return Object.freeze(out);
1317
1866
  }
1318
1867
 
1868
+ /**
1869
+ * One sample file with options (SDK 1.20.0), for `sampler({ brk: ... })`:
1870
+ * `sample("samples/break.wav", { bpm: 174, fitmode: "beats" })` plays a
1871
+ * 174 BPM break in time with the song, cut at its hits.
1872
+ */
1873
+ export function sample(
1874
+ src: string,
1875
+ options: Omit<SampleSpec, "src"> = {},
1876
+ ): SampleSpec {
1877
+ if (typeof src !== "string" || src.length === 0)
1878
+ throw new DawgSdkError("sample() needs a src path");
1879
+ return Object.freeze({ ...options, src });
1880
+ }
1881
+
1319
1882
  // ---------------------------------------------------------------------------
1320
1883
  // Tracks
1321
1884
 
@@ -1345,14 +1908,177 @@ export type AutomationInput = Readonly<{
1345
1908
  wt?: readonly Point[];
1346
1909
  }>;
1347
1910
 
1911
+ /**
1912
+ * Every rig preset (SDK 1.22.0), kept equal to `RIG_PRESETS` in
1913
+ * core/fx.ts by `print-rig.test.ts`: the stomp, head and cab stages plus
1914
+ * the companion effects a few rigs need (funk's and wah's autofilter,
1915
+ * bachata's chorus, jangle's compressor). `spring`'s short room is the
1916
+ * track's `reverb`, not `fx`: give it as
1917
+ * `reverb: { mix: 0.3, size: 0.35, fade: 1.5, predelay: 0, dim: 3500 }`.
1918
+ */
1919
+ export const RIG_PRESETS: Readonly<
1920
+ Record<
1921
+ string,
1922
+ Readonly<
1923
+ Record<"stomp" | "head" | "cab", EffectParams | undefined> &
1924
+ Readonly<Record<string, EffectParams | undefined>>
1925
+ >
1926
+ >
1927
+ > = Object.freeze({
1928
+ clean: {
1929
+ stomp: undefined,
1930
+ head: { type: "clean", gain: 3, treble: 6 },
1931
+ cab: { type: "1x12" },
1932
+ },
1933
+ crunch: {
1934
+ stomp: undefined,
1935
+ head: { type: "crunch", gain: 5 },
1936
+ cab: { type: "4x12" },
1937
+ },
1938
+ punk: {
1939
+ stomp: undefined,
1940
+ head: { type: "crunch", gain: 7, mid: 6, master: 6 },
1941
+ cab: { type: "4x12", mic: 0.2 },
1942
+ },
1943
+ ragged: {
1944
+ stomp: { type: "face", gain: 6, tone: 0.6 },
1945
+ head: { type: "chime", gain: 4 },
1946
+ cab: { type: "2x12" },
1947
+ },
1948
+ lead: {
1949
+ stomp: { type: "od", gain: 3, tone: 0.5, level: 3 },
1950
+ head: { type: "lead", gain: 6, mid: 6 },
1951
+ cab: { type: "4x12" },
1952
+ },
1953
+ metal: {
1954
+ stomp: { type: "od", gain: 0, tone: 0.6, level: 6 },
1955
+ head: { type: "high", gain: 7, bass: 6, mid: 3, treble: 7, gate: -55 },
1956
+ cab: { type: "4x12", mic: 0.2 },
1957
+ },
1958
+ fuzz: {
1959
+ stomp: { type: "fuzz", gain: 7, tone: 0.5 },
1960
+ head: { type: "clean", gain: 4 },
1961
+ cab: { type: "2x12" },
1962
+ },
1963
+ octave: {
1964
+ stomp: { type: "octave", gain: 6, tone: 0.6, octave: 0.8 },
1965
+ head: { type: "clean", gain: 3 },
1966
+ cab: { type: "1x12" },
1967
+ },
1968
+ funk: {
1969
+ stomp: undefined,
1970
+ head: { type: "clean", gain: 2, treble: 7, presence: 6 },
1971
+ cab: { type: "2x12" },
1972
+ autofilter: {
1973
+ type: "bpf",
1974
+ sync: 0,
1975
+ rate: 0.01,
1976
+ depth: 0,
1977
+ follow: 3,
1978
+ cutoff: 500,
1979
+ resonance: 0.6,
1980
+ },
1981
+ },
1982
+ wah: {
1983
+ stomp: undefined,
1984
+ head: { type: "crunch", gain: 4 },
1985
+ cab: { type: "2x12" },
1986
+ autofilter: {
1987
+ type: "bpf",
1988
+ sync: 0.5,
1989
+ depth: 2,
1990
+ shape: "sine",
1991
+ cutoff: 700,
1992
+ resonance: 0.6,
1993
+ },
1994
+ },
1995
+ bachata: {
1996
+ stomp: undefined,
1997
+ head: { type: "clean", gain: 2, mid: 6, treble: 7 },
1998
+ cab: { type: "1x12", mic: 0.2 },
1999
+ chorus: { rate: 0.8, depth: 0.25, mix: 0.3 },
2000
+ },
2001
+ spring: {
2002
+ stomp: undefined,
2003
+ head: { type: "clean", gain: 3, treble: 6 },
2004
+ cab: { type: "open" },
2005
+ },
2006
+ bassdrive: {
2007
+ stomp: { type: "od", gain: 4, tone: 0.5, mix: 0.6 },
2008
+ head: { type: "bass", gain: 4 },
2009
+ cab: { type: "8x10" },
2010
+ },
2011
+ reese: {
2012
+ stomp: { type: "rat", gain: 3, tone: 0.3, mix: 0.5 },
2013
+ head: { type: "bass", gain: 6, master: 6 },
2014
+ cab: { type: "1x15" },
2015
+ },
2016
+ jangle: {
2017
+ stomp: undefined,
2018
+ head: { type: "chime", gain: 3, treble: 7 },
2019
+ cab: { type: "2x12", mic: 0.2 },
2020
+ compressor: { threshold: -20, ratio: 4, attack: 0.01, release: 0.15 },
2021
+ },
2022
+ alt: {
2023
+ stomp: { type: "rat", gain: 6, tone: 0.4 },
2024
+ head: { type: "crunch", gain: 4 },
2025
+ cab: { type: "4x12" },
2026
+ },
2027
+ });
2028
+
2029
+ /**
2030
+ * A guitar rig for a track's `fx` (SDK 1.22.0): the stomp → head → cab
2031
+ * stages of rig preset `name` and its companion effects (as the `rig`
2032
+ * command sets them), with optional per-stage overrides. Spread it
2033
+ * into `fx` next to other effects:
2034
+ *
2035
+ * ```ts
2036
+ * fx: { ...rig("crunch"), chorus: {} }
2037
+ * fx: { ...rig("metal", { head: { gain: 9 } }) }
2038
+ * ```
2039
+ */
2040
+ export function rig(
2041
+ name: string,
2042
+ overrides: Readonly<
2043
+ Partial<Record<"stomp" | "head" | "cab", EffectParams>>
2044
+ > = {},
2045
+ ): FxInput {
2046
+ if (
2047
+ typeof name !== "string" ||
2048
+ !Object.prototype.hasOwnProperty.call(RIG_PRESETS, name)
2049
+ )
2050
+ throw new DawgSdkError(
2051
+ `unknown rig "${String(name)}" (rigs: ${Object.keys(RIG_PRESETS).join(", ")})`,
2052
+ );
2053
+ if (!isRecord(overrides))
2054
+ throw new DawgSdkError("rig overrides must be an object");
2055
+ const out: Record<string, EffectParams> = {};
2056
+ // Companion effects first, then the stages in chain order.
2057
+ for (const [effect, values] of Object.entries(RIG_PRESETS[name]!))
2058
+ if (values && effect !== "stomp" && effect !== "head" && effect !== "cab")
2059
+ out[effect] = Object.freeze({ ...values });
2060
+ for (const stage of ["stomp", "head", "cab"] as const) {
2061
+ const base = RIG_PRESETS[name]![stage];
2062
+ const extra = overrides[stage];
2063
+ if (extra !== undefined && !isRecord(extra))
2064
+ throw new DawgSdkError(`rig ${stage} overrides must be an object`);
2065
+ if (base || extra)
2066
+ out[stage] = Object.freeze({ ...(base ?? {}), ...(extra ?? {}) });
2067
+ }
2068
+ return Object.freeze(out);
2069
+ }
2070
+
1348
2071
  /** One effect's parameters; omitted ones take dawg's defaults. */
1349
2072
  export type EffectParams = Readonly<Record<string, number | string | boolean>>;
1350
2073
 
1351
2074
  /**
1352
2075
  * Insert effects by name, rendered in the fixed chain order
1353
- * filter → djf → autofilter → vowel → crush → distort → tremolo →
1354
- * compressor → pan → phaser → chorus → leslie → postgain → delay → reverb.
1355
- * Keys here: djf, autofilter, vowel, crush, distort, tremolo, compressor,
2076
+ * filter → djf → autofilter → vowel → crush → distort → stomp → head →
2077
+ * cab → tremolo → compressor → pan → phaser → chorus → leslie → postgain →
2078
+ * delay → reverb. The guitar rig (stomp, head, cab; SDK 1.22.0) is easiest
2079
+ * as `...rig("crunch")`.
2080
+ * Keys here: djf, autofilter, vowel, crush, distort, stomp, head, cab,
2081
+ * tremolo, compressor,
1356
2082
  * phaser, chorus, leslie, postgain, plus the mix-bus keys `orbit`
1357
2083
  * (`{ orbit: 2 }`, SDK 1.9.0) and `duck` (`{ orbit: 2, depth: 0.85 }`:
1358
2084
  * this track's onsets duck every other track on that orbit). See
@@ -1366,6 +2092,53 @@ export type FxInput = Readonly<Record<string, EffectParams>>;
1366
2092
  * `"synth-<param>"` (e.g. `"synth-lpf"`), read at each note's onset.
1367
2093
  * Every parameter, range and default: **Synth** in DAWG.md.
1368
2094
  */
2095
+ /**
2096
+ * Modelled piano settings (SDK 1.24.0), for a track whose instrument is a
2097
+ * piano family (`"grand"`, `"upright"`, `"felt"`, `"honkytonk"`,
2098
+ * `"prepared"`; the words `"ballad"` and `"lofi"` pick presets). Every field
2099
+ * is optional; `{}` is the family's own sound. Automate a parameter with
2100
+ * `automation.fx["keys-<param>"]` (hardness, touch, decay, release, knock,
2101
+ * noise, felt), read at each note's onset. Ranges: **Keys** in DAWG.md.
2102
+ */
2103
+ export type KeysInput = Readonly<{
2104
+ /** A named preset: grand ballad upright felt lofi honkytonk prepared. */
2105
+ preset?: string;
2106
+ /** Hammer hardness 0..1: brightness at a given velocity (0.5). */
2107
+ hardness?: number;
2108
+ /** Velocity sensitivity 0..1 (1). */
2109
+ touch?: number;
2110
+ /** Inharmonicity multiplier 0..4 (1 grand, 2.5 upright, 0 harmonic). */
2111
+ inharm?: number;
2112
+ /** Unison detune in cents 0..30 (0.7; honkytonk 16). */
2113
+ unison?: number;
2114
+ /** Sustain time multiplier 0.1..4 (1). */
2115
+ decay?: number;
2116
+ /** Damper time multiplier 0.1..4 (1). */
2117
+ release?: number;
2118
+ /** Hammer position along the string 0.04..0.3 (0.12). */
2119
+ strike?: number;
2120
+ /** Aftersound share 0..1 (0.3). */
2121
+ after?: number;
2122
+ /** Soundboard knock 0..1 (0.5). */
2123
+ knock?: number;
2124
+ /** Key and damper mechanics 0..1 (0.25). */
2125
+ noise?: number;
2126
+ /** Felt strip 0..1 (0; felt family 1). */
2127
+ felt?: number;
2128
+ /** Share of prepared keys 0..1 (0; prepared family 0.6). */
2129
+ prep?: number;
2130
+ /** Keyboard stereo width 0..1 (0.6). */
2131
+ width?: number;
2132
+ /** Octave stretch 0..1 (1); 0 keeps every key exactly on its tuning. */
2133
+ stretch?: number;
2134
+ /** Body EQ: grand upright felt honkytonk prepared (the family's own). */
2135
+ body?: string;
2136
+ /** Pitch wobble rate in Hz (tape wow), 0 off. */
2137
+ vib?: number;
2138
+ /** Pitch wobble depth in semitones (0.5). */
2139
+ vibmod?: number;
2140
+ }>;
2141
+
1369
2142
  export type SynthInput = Readonly<{
1370
2143
  attack?: number;
1371
2144
  decay?: number;
@@ -1449,17 +2222,60 @@ export type TrackInput = Readonly<{
1449
2222
  * `triangle`, and Strudel's `sawtooth`, `supersaw`, `pulse`, `user`,
1450
2223
  * `white`, `pink`, `brown`, `crackle`, and the ZzFX sounds `z_sine`,
1451
2224
  * `z_triangle`, `z_sawtooth`, `z_square`, `z_tan`, `z_noise`), `kit` for drums,
1452
- * `sampler(...)` or `wavetable(...)`. Default `sine`.
2225
+ * `sampler(...)` or `wavetable(...)`, or a mallet or bell (`vibes`,
2226
+ * `glock`, `gong`, … or `modal(...)`, SDK 1.25.0). Default `sine`.
2227
+ */
2228
+ instrument?:
2229
+ | string
2230
+ | SamplerSpec
2231
+ | WavetableSpec
2232
+ | StringSpec
2233
+ | GranularSpec
2234
+ | ModalSpec;
2235
+ /**
2236
+ * The sampler a `granular(...)` track keeps while it grains one of its
2237
+ * voices (SDK 1.23.0); `grain off` plays it again.
2238
+ */
2239
+ sampler?: SamplerSpec | null;
2240
+ /**
2241
+ * Granular engine (SDK 1.23.0) for an `instrument: "granular"` track, or
2242
+ * use `instrument: granular("cloud", {...})` or a word (`"cloud"`).
1453
2243
  */
1454
- instrument?: string | SamplerSpec | WavetableSpec;
2244
+ granular?: GranularInput | null;
1455
2245
  /**
1456
2246
  * Synthesized drum kit for an `instrument: "kit"` track: `syn808`,
1457
2247
  * `syn909`, `acoustic`, `lofi`, `electro` or `trap`. Omit for the default
1458
2248
  * voices.
1459
2249
  */
1460
2250
  kit?: string;
2251
+ /**
2252
+ * The track's own clock against the song (SDK 1.14.0): `rate` 1.5 plays
2253
+ * three beats in two, `phase` starts it that many beats later, `cycle`
2254
+ * repeats its first `cycle` beats (a 3-beat cycle over 4/4 is polymeter).
2255
+ * `{ cycle: 3, rate: 13 / 12 }` drifts against a twin and realigns, the
2256
+ * tape phasing of Reich's Come Out; `phasing()` works the rate out for
2257
+ * you, and `stepPhasing()` gives Piano Phase's shift and hold.
2258
+ */
2259
+ time?: TrackTimeInput;
2260
+ /**
2261
+ * This track's tuning over the song's (SDK 1.16.0): a library name such
2262
+ * as `"pelog"` or `{ edo, ratios, cents, scl, kbm, ref, root, map }`.
2263
+ * `{ ref: 432 }` alone keeps the song's table at another pitch.
2264
+ */
2265
+ tuning?: TuningInput | null;
1461
2266
  /** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
1462
2267
  synth?: SynthInput;
2268
+ /**
2269
+ * String engine (SDK 1.21.0) for an `instrument: "string"` track, or use
2270
+ * `instrument: stringed("sitar", {...})` or a preset word (`"nylon"`).
2271
+ */
2272
+ string?: StringInput | null;
2273
+ /**
2274
+ * Modelled piano settings (SDK 1.24.0) for `instrument: "grand"` and the
2275
+ * other piano families; `{}` is the family's sound. The word `"piano"`
2276
+ * keeps the classic 0.4 tone; use `"grand"` for the modelled piano.
2277
+ */
2278
+ keys?: KeysInput;
1463
2279
  muted?: boolean;
1464
2280
  /** When any track is soloed only soloed tracks play. */
1465
2281
  solo?: boolean;
@@ -1476,6 +2292,34 @@ export type TrackInput = Readonly<{
1476
2292
  /** Insert effects by name (`{ distort: { drive: 3 }, chorus: {} }`). */
1477
2293
  fx?: FxInput;
1478
2294
  automation?: AutomationInput;
2295
+ /**
2296
+ * Glide between notes (SDK 1.15.0): seconds (`glide: 0.08`, TB-303 style
2297
+ * legato) or `{ time, mode }`. Mode `legato` glides only into a note that
2298
+ * overlaps the previous one and does not retrigger it; `mono` always
2299
+ * glides and retriggers; `poly` glides every voice of a chord from the
2300
+ * matching voice of the previous one.
2301
+ */
2302
+ glide?: number | Readonly<{ time?: number; mode?: GlideMode }>;
2303
+ /** Sustain pedal changes as `[beat, "down" | "half" | "up"]` (SDK 1.15.0). */
2304
+ pedal?: readonly (readonly [number, PedalState])[];
2305
+ /**
2306
+ * Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
2307
+ * (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
2308
+ * `{ curve: "fixed", fixed: 0.6 }`. Default `linear`.
2309
+ */
2310
+ velocityCurve?:
2311
+ VelocityCurveName | Readonly<{ curve: VelocityCurveName; fixed?: number }>;
2312
+ /**
2313
+ * Seeded humanize applied when dawg renders, so the notes stay as written
2314
+ * (SDK 1.15.0): `timing` ms either side, `velocity` and `length` in
2315
+ * percent, `seed` (default 1) picks another take.
2316
+ */
2317
+ humanize?: Readonly<{
2318
+ timing?: number;
2319
+ velocity?: number;
2320
+ length?: number;
2321
+ seed?: number;
2322
+ }>;
1479
2323
  /** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
1480
2324
  notes?: readonly (NoteSpec | HitSpec)[];
1481
2325
  /**
@@ -1533,6 +2377,22 @@ export type ReverbInput = Readonly<{
1533
2377
  }>;
1534
2378
  }>;
1535
2379
 
2380
+ /** `track({ time })`: every field optional; absent follows the song. */
2381
+ export type TrackTimeInput = Readonly<{
2382
+ /** Tempo ratio against the song, 0.125..8 (1 = in step). */
2383
+ rate?: number;
2384
+ /** Beats the track's pattern starts late (negative: early); it wraps. */
2385
+ phase?: number;
2386
+ /** Beats of the track that repeat, default the whole song loop. */
2387
+ cycle?: number;
2388
+ /**
2389
+ * Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: hold `hold`
2390
+ * cycles in step, then move `shift` beats ahead over `drift` cycles, and
2391
+ * repeat. Needs `cycle`; replaces `rate`. `stepPhasing()` builds it.
2392
+ */
2393
+ steps?: Readonly<{ shift: number; hold: number; drift: number }>;
2394
+ }>;
2395
+
1536
2396
  /** Frozen track built by `track()`; `song()` consumes it. Beats, not ticks. */
1537
2397
  export type TrackSpec = Readonly<{
1538
2398
  kind: "track";
@@ -1555,14 +2415,147 @@ export type TrackSpec = Readonly<{
1555
2415
  synth: SynthInput | null;
1556
2416
  sampler: SamplerSpec | null;
1557
2417
  wavetable: WavetableSpec | null;
2418
+ /** String engine settings (SDK 1.21.0); present only when set. */
2419
+ string?: StringInput;
2420
+ /** Granular engine settings (SDK 1.23.0); null when not granular. */
2421
+ granular?: GranularInput | null;
1558
2422
  automation: Readonly<Required<AutomationInput>>;
1559
2423
  /** Every hit resolved to its pitch slot. */
1560
2424
  notes: readonly NoteSpec[];
1561
2425
  /** Rhythm rows in order (voice names as written). */
1562
2426
  rhythm: readonly RhythmSpec[];
1563
2427
  kit: string | null;
2428
+ /** Present only when `track({ time })` set something. */
2429
+ time?: TrackTimeInput;
2430
+ /** Performance (SDK 1.15.0); present only when set. */
2431
+ glide?: Readonly<{ time: number; mode: GlideMode }>;
2432
+ pedal?: readonly (readonly [number, PedalState])[];
2433
+ velocityCurve?: Readonly<{
2434
+ curve: Exclude<VelocityCurveName, "linear">;
2435
+ fixed?: number;
2436
+ }>;
2437
+ humanize?: Readonly<{
2438
+ timing?: number;
2439
+ velocity?: number;
2440
+ length?: number;
2441
+ seed: number;
2442
+ }>;
2443
+ tuning: ScoreTuning | null;
2444
+ /** Modelled piano settings (SDK 1.24.0); present only when set. */
2445
+ keys?: KeysInput;
2446
+ /** Modal settings (SDK 1.25.0); present only on a modal track. */
2447
+ modal?: Readonly<{ preset?: ModalPresetName } & ModalParams>;
1564
2448
  }>;
1565
2449
 
2450
+ export type GlideMode = "legato" | "mono" | "poly";
2451
+ export type PedalState = "down" | "half" | "up";
2452
+ export type VelocityCurveName = "linear" | "soft" | "hard" | "fixed";
2453
+
2454
+ const DEFAULT_GLIDE_SECONDS = 0.06;
2455
+ const DEFAULT_FIXED_VELOCITY = 0.8;
2456
+
2457
+ /** Normalizes `track()` performance options; dawg validates the ranges. */
2458
+ function trackPerformance(
2459
+ input: TrackInput,
2460
+ name: string,
2461
+ ): Partial<Pick<TrackSpec, "glide" | "pedal" | "velocityCurve" | "humanize">> {
2462
+ const out: {
2463
+ glide?: TrackSpec["glide"];
2464
+ pedal?: TrackSpec["pedal"];
2465
+ velocityCurve?: TrackSpec["velocityCurve"];
2466
+ humanize?: TrackSpec["humanize"];
2467
+ } = {};
2468
+ if (input.glide !== undefined) {
2469
+ const raw =
2470
+ typeof input.glide === "number" ? { time: input.glide } : input.glide;
2471
+ if (!isRecord(raw))
2472
+ throw new DawgSdkError(
2473
+ `track ${name}: glide must be seconds or { time, mode }`,
2474
+ );
2475
+ const mode = raw.mode ?? "legato";
2476
+ if (!["legato", "mono", "poly"].includes(mode))
2477
+ throw new DawgSdkError(
2478
+ `track ${name}: glide mode must be legato, mono or poly`,
2479
+ );
2480
+ out.glide = Object.freeze({
2481
+ time: finite(raw.time ?? DEFAULT_GLIDE_SECONDS, `${name} glide time`),
2482
+ mode,
2483
+ });
2484
+ }
2485
+ if (input.pedal !== undefined) {
2486
+ if (!Array.isArray(input.pedal) || input.pedal.length > 1024)
2487
+ throw new DawgSdkError(
2488
+ `track ${name}: pedal must be at most 1024 [beat, "down" | "half" | "up"] events`,
2489
+ );
2490
+ if (input.pedal.length > 0)
2491
+ out.pedal = Object.freeze(
2492
+ input.pedal.map((event: unknown, index: number) => {
2493
+ if (
2494
+ !Array.isArray(event) ||
2495
+ event.length !== 2 ||
2496
+ !["down", "half", "up"].includes(event[1] as string)
2497
+ )
2498
+ throw new DawgSdkError(
2499
+ `track ${name}: pedal[${index}] must be [beat, "down" | "half" | "up"]`,
2500
+ );
2501
+ return Object.freeze([
2502
+ beat(event[0], `${name} pedal[${index}] beat`),
2503
+ event[1] as PedalState,
2504
+ ] as const);
2505
+ }),
2506
+ );
2507
+ }
2508
+ if (input.velocityCurve !== undefined) {
2509
+ const raw =
2510
+ typeof input.velocityCurve === "string"
2511
+ ? { curve: input.velocityCurve }
2512
+ : input.velocityCurve;
2513
+ if (
2514
+ !isRecord(raw) ||
2515
+ !["linear", "soft", "hard", "fixed"].includes(raw.curve as string)
2516
+ )
2517
+ throw new DawgSdkError(
2518
+ `track ${name}: velocityCurve must be linear, soft, hard or fixed`,
2519
+ );
2520
+ if (raw.curve === "fixed")
2521
+ out.velocityCurve = Object.freeze({
2522
+ curve: "fixed",
2523
+ fixed: unit(
2524
+ raw.fixed ?? DEFAULT_FIXED_VELOCITY,
2525
+ `${name} velocityCurve fixed`,
2526
+ ),
2527
+ });
2528
+ else if (raw.curve !== "linear")
2529
+ out.velocityCurve = Object.freeze({ curve: raw.curve });
2530
+ }
2531
+ if (input.humanize !== undefined) {
2532
+ if (!isRecord(input.humanize))
2533
+ throw new DawgSdkError(
2534
+ `track ${name}: humanize must be { timing, velocity, length, seed }`,
2535
+ );
2536
+ const amount = (key: "timing" | "velocity" | "length") => {
2537
+ const value = input.humanize![key];
2538
+ return value === undefined ? 0 : finite(value, `${name} humanize ${key}`);
2539
+ };
2540
+ const timing = amount("timing");
2541
+ const velocity = amount("velocity");
2542
+ const length = amount("length");
2543
+ const seed = input.humanize.seed ?? 1;
2544
+ if (!Number.isInteger(seed) || seed < 0)
2545
+ throw new DawgSdkError(
2546
+ `track ${name}: humanize seed must be an integer ≥ 0`,
2547
+ );
2548
+ if (timing !== 0 || velocity !== 0 || length !== 0)
2549
+ out.humanize = Object.freeze({
2550
+ ...(timing !== 0 ? { timing } : {}),
2551
+ ...(velocity !== 0 ? { velocity } : {}),
2552
+ ...(length !== 0 ? { length } : {}),
2553
+ seed,
2554
+ });
2555
+ }
2556
+ return out;
2557
+ }
2558
+
1566
2559
  /**
1567
2560
  * A raw ZzFX parameter array (Strudel `zzfx([...])`, ZzFX's own layout:
1568
2561
  * volume, randomness, frequency, attack, sustain, release, shape,
@@ -1653,34 +2646,77 @@ export function track(input: TrackInput): TrackSpec {
1653
2646
  if (typeof id !== "string" || id.length === 0 || id.length > 64)
1654
2647
  throw new DawgSdkError(`track ${name}: id must be 1..64 characters`);
1655
2648
  const rawInstrument = input.instrument ?? "sine";
2649
+ // A granular track may keep the sampler it grains (`grain off` goes back).
2650
+ const keptSampler =
2651
+ isRecord(input.sampler) &&
2652
+ input.sampler.kind === "sampler" &&
2653
+ isRecord(rawInstrument) &&
2654
+ rawInstrument.kind === "granular"
2655
+ ? localizeSampler(input.sampler as SamplerSpec, slug)
2656
+ : null;
2657
+ if (input.sampler !== undefined && input.sampler !== null && !keptSampler)
2658
+ throw new DawgSdkError(
2659
+ `track ${name}: sampler: is only for a granular(...) track; use instrument: sampler({...})`,
2660
+ );
1656
2661
  const samplerSpec =
1657
2662
  isRecord(rawInstrument) && rawInstrument.kind === "sampler"
1658
2663
  ? localizeSampler(rawInstrument as SamplerSpec, slug)
1659
- : null;
2664
+ : keptSampler;
1660
2665
  const wavetableSpec =
1661
2666
  isRecord(rawInstrument) && rawInstrument.kind === "wavetable"
1662
2667
  ? localizeWavetable(rawInstrument as WavetableSpec, slug)
1663
2668
  : null;
1664
- const instrument = samplerSpec
1665
- ? SAMPLER_INSTRUMENT
1666
- : wavetableSpec
1667
- ? WAVETABLE_INSTRUMENT
1668
- : typeof rawInstrument === "string"
1669
- ? rawInstrument
1670
- : undefined;
2669
+ const stringFromInstrument =
2670
+ isRecord(rawInstrument) && rawInstrument.kind === "string"
2671
+ ? stringInput(rawInstrument, name)
2672
+ : null;
2673
+ const granularFromInstrument =
2674
+ isRecord(rawInstrument) && rawInstrument.kind === "granular"
2675
+ ? granularInput(rawInstrument, name, slug)
2676
+ : null;
2677
+ const word =
2678
+ typeof rawInstrument === "string"
2679
+ ? resolveInstrumentWord(rawInstrument)
2680
+ : undefined;
2681
+ const modalSpec = trackModal(rawInstrument);
2682
+ const instrument = granularFromInstrument
2683
+ ? GRANULAR_INSTRUMENT
2684
+ : samplerSpec
2685
+ ? SAMPLER_INSTRUMENT
2686
+ : wavetableSpec
2687
+ ? WAVETABLE_INSTRUMENT
2688
+ : stringFromInstrument
2689
+ ? STRING_INSTRUMENT
2690
+ : modalSpec
2691
+ ? MODAL_INSTRUMENT
2692
+ : typeof rawInstrument === "string"
2693
+ ? (word?.instrument ?? rawInstrument)
2694
+ : undefined;
2695
+ // A granular word (`"cloud"`) turns the engine on with its preset.
2696
+ const granularSpec =
2697
+ granularInput(input.granular, name, slug) ??
2698
+ granularFromInstrument ??
2699
+ (word?.field === "granular" && word.preset
2700
+ ? Object.freeze({ preset: word.preset })
2701
+ : instrument === GRANULAR_INSTRUMENT
2702
+ ? Object.freeze({})
2703
+ : null);
1671
2704
  if (
1672
2705
  instrument === undefined ||
1673
2706
  instrument.length === 0 ||
1674
2707
  instrument.length > 64
1675
2708
  )
1676
2709
  throw new DawgSdkError(
1677
- `track ${name}: instrument must be a voice name, "kit", sampler(...) or wavetable(...)`,
2710
+ `track ${name}: instrument must be a voice name, "kit", sampler(...), wavetable(...), stringed(...), granular(...) or modal(...)`,
1678
2711
  );
1679
2712
  if (instrument === SAMPLER_INSTRUMENT && !samplerSpec)
1680
2713
  throw new DawgSdkError(
1681
2714
  `track ${name}: use instrument: sampler({...}) for a sampler track`,
1682
2715
  );
1683
- const slots = samplerSpec ? voiceSlots(samplerSpec) : undefined;
2716
+ const slots =
2717
+ samplerSpec && instrument === SAMPLER_INSTRUMENT
2718
+ ? voiceSlots(samplerSpec)
2719
+ : undefined;
1684
2720
  const kit = KIT_INSTRUMENTS.includes(instrument.trim().toLowerCase());
1685
2721
  const notes = (input.notes ?? []).map((item, index) => {
1686
2722
  if (!isRecord(item) || (item.kind !== "note" && item.kind !== "hit"))
@@ -1703,13 +2739,8 @@ export function track(input: TrackInput): TrackSpec {
1703
2739
  ? `track ${name}: unknown sampler voice "${spec.voice}" (${[...slots.keys()].join(" ")})`
1704
2740
  : `track ${name}: hit("${spec.voice}") needs instrument "kit" or sampler(...)`,
1705
2741
  );
1706
- return Object.freeze({
1707
- kind: "note" as const,
1708
- pitch,
1709
- start: spec.start,
1710
- length: spec.length,
1711
- velocity: spec.velocity,
1712
- });
2742
+ const { kind: _kind, voice: _voice, ...rest } = spec;
2743
+ return Object.freeze({ ...rest, kind: "note" as const, pitch });
1713
2744
  });
1714
2745
  if (notes.length > 4096)
1715
2746
  throw new DawgSdkError(`track ${name}: at most 4096 notes`);
@@ -1760,8 +2791,25 @@ export function track(input: TrackInput): TrackSpec {
1760
2791
  throw new DawgSdkError(
1761
2792
  `track ${name}: unknown automation lane "${key}" (${AUTOMATION_KEYS.join(" ")})`,
1762
2793
  );
1763
- const filter =
1764
- input.filter === undefined || input.filter === null
2794
+ // A keys preset word (`"lofi"`, `"ballad"`) brings its preset's effects,
2795
+ // as the prompt does; explicit filter, fx and reverb win.
2796
+ const presetFx = keysPresetFx(input.keys, rawInstrument);
2797
+ if (presetFx) {
2798
+ input = {
2799
+ ...input,
2800
+ ...(input.filter === undefined && presetFx.filter
2801
+ ? { filter: presetFx.filter }
2802
+ : {}),
2803
+ ...(input.reverb === undefined && presetFx.reverb
2804
+ ? { reverb: presetFx.reverb }
2805
+ : {}),
2806
+ ...(presetFx.fx && input.fx !== null
2807
+ ? { fx: { ...presetFx.fx, ...(isRecord(input.fx) ? input.fx : {}) } }
2808
+ : {}),
2809
+ };
2810
+ }
2811
+ const filter =
2812
+ input.filter === undefined || input.filter === null
1765
2813
  ? null
1766
2814
  : Object.freeze({
1767
2815
  cutoff: finite(input.filter.cutoff, `${name} filter.cutoff`),
@@ -1802,8 +2850,26 @@ export function track(input: TrackInput): TrackSpec {
1802
2850
  ? {}
1803
2851
  : { ir: reverbIr(input.reverb.ir, name, slug) }),
1804
2852
  });
1805
- const fx = fxInput(input.fx, name);
2853
+ // A guitar alias (`instrument: "jangle"`) also loads its rig; stages and
2854
+ // effects the track's own `fx` names win.
2855
+ const aliasRig =
2856
+ typeof rawInstrument === "string"
2857
+ ? resolveInstrumentWord(rawInstrument)?.fx
2858
+ : undefined;
2859
+ const fx = fxInput(
2860
+ aliasRig === undefined
2861
+ ? input.fx
2862
+ : { ...rig(aliasRig), ...(isRecord(input.fx) ? input.fx : {}) },
2863
+ name,
2864
+ );
1806
2865
  const synth = synthInput(input.synth, name);
2866
+ // A string preset word (`"nylon"`) turns the engine on with its preset.
2867
+ const string =
2868
+ stringInput(input.string, name) ??
2869
+ stringFromInstrument ??
2870
+ (word?.field === "string" && word.preset
2871
+ ? Object.freeze({ preset: word.preset })
2872
+ : null);
1807
2873
  return Object.freeze({
1808
2874
  kind: "track",
1809
2875
  id,
@@ -1821,6 +2887,8 @@ export function track(input: TrackInput): TrackSpec {
1821
2887
  synth,
1822
2888
  sampler: samplerSpec,
1823
2889
  wavetable: wavetableSpec,
2890
+ ...(string ? { string } : {}),
2891
+ ...(granularSpec ? { granular: granularSpec } : {}),
1824
2892
  automation: Object.freeze({
1825
2893
  volume: lane("volume"),
1826
2894
  pan: lane("pan"),
@@ -1834,9 +2902,165 @@ export function track(input: TrackInput): TrackSpec {
1834
2902
  notes: Object.freeze(notes),
1835
2903
  rhythm: Object.freeze([...rhythm]),
1836
2904
  kit: drumKit === null ? null : drumKit.trim(),
2905
+ ...trackTime(input.time, name),
2906
+ ...trackPerformance(input, name),
2907
+ tuning: tuningSpec(input.tuning, `track ${name}`),
2908
+ ...keysSpec(input.keys, rawInstrument, name),
2909
+ ...(modalSpec ? { modal: modalSpec } : {}),
1837
2910
  });
1838
2911
  }
1839
2912
 
2913
+ /**
2914
+ * Effects the keys presets set with the voice: a copy of `KEYS_PRESETS`
2915
+ * filter, fx and reverb in core/keys.ts (core/keys.test.ts checks they
2916
+ * match the prompt's `piano <preset>`).
2917
+ */
2918
+ const KEYS_PRESET_FX: Readonly<
2919
+ Record<
2920
+ string,
2921
+ Readonly<{
2922
+ filter?: FilterInput;
2923
+ fx?: FxInput;
2924
+ reverb?: ReverbInput;
2925
+ }>
2926
+ >
2927
+ > = Object.freeze({
2928
+ ballad: { reverb: { mix: 0.25, size: 0.7 } },
2929
+ felt: { reverb: { mix: 0.2, size: 0.5 } },
2930
+ lofi: {
2931
+ filter: { cutoff: 3500, resonance: 0.1 },
2932
+ fx: { crush: { bits: 10 } },
2933
+ },
2934
+ });
2935
+
2936
+ /**
2937
+ * The preset effects an instrument word brings: a preset word that is not
2938
+ * also its family (`"lofi"`, `"ballad"`), or any preset word without
2939
+ * `keys` (`"felt"`). A printed track names its family and always prints
2940
+ * `keys`, so print → eval never adds them twice.
2941
+ */
2942
+ function keysPresetFx(
2943
+ keys: unknown,
2944
+ word: unknown,
2945
+ ): (typeof KEYS_PRESET_FX)[string] | undefined {
2946
+ if (typeof word !== "string") return undefined;
2947
+ const meaning = resolveInstrumentWord(word);
2948
+ if (meaning?.field !== "keys" || !meaning.preset) return undefined;
2949
+ if (keys !== undefined && keys !== null && word === meaning.instrument)
2950
+ return undefined;
2951
+ return KEYS_PRESET_FX[meaning.preset];
2952
+ }
2953
+
2954
+ /**
2955
+ * `keys` for `track()`: the input as given, or `{ preset }` when the
2956
+ * instrument word names a keys preset (`"grand"`, `"lofi"`), so the word
2957
+ * alone plays the modelled piano. dawg validates the values.
2958
+ */
2959
+ function keysSpec(
2960
+ input: unknown,
2961
+ word: unknown,
2962
+ name: string,
2963
+ ): { keys?: KeysInput } {
2964
+ const meaning =
2965
+ typeof word === "string" ? resolveInstrumentWord(word) : undefined;
2966
+ const preset = meaning?.field === "keys" ? meaning.preset : undefined;
2967
+ if (input === undefined || input === null)
2968
+ return preset ? { keys: Object.freeze({ preset }) } : {};
2969
+ if (!isRecord(input))
2970
+ throw new DawgSdkError(`track ${name}: keys must be an object`);
2971
+ const out: Record<string, EffectValue> = {};
2972
+ // A preset word (`"lofi"`) keeps its preset under given overrides; a
2973
+ // family word (`"upright"`) with `keys` is exactly the given keys.
2974
+ if (preset && input.preset === undefined && word !== meaning?.instrument)
2975
+ out.preset = preset;
2976
+ for (const [key, value] of Object.entries(input)) {
2977
+ if (value === undefined) continue;
2978
+ out[key] = effectValue(value, `${name} keys.${key}`);
2979
+ }
2980
+ return { keys: Object.freeze(out) };
2981
+ }
2982
+
2983
+ /**
2984
+ * The modal field an instrument makes: `modal(...)`, or a modal preset
2985
+ * word (`"vibes"`, `"glockenspiel"`). The bare words `"modal"` and
2986
+ * `"marimba"` keep their pre-0.6 meaning and make none.
2987
+ */
2988
+ function trackModal(
2989
+ raw: unknown,
2990
+ ): Readonly<{ preset?: ModalPresetName } & ModalParams> | undefined {
2991
+ if (isRecord(raw) && raw.kind === "modal") {
2992
+ const { kind: _kind, ...fields } = raw as ModalSpec;
2993
+ return Object.freeze(fields);
2994
+ }
2995
+ if (typeof raw !== "string" || raw === MODAL_INSTRUMENT) return undefined;
2996
+ const meaning = resolveInstrumentWord(raw);
2997
+ if (meaning?.instrument !== MODAL_INSTRUMENT || !meaning.preset)
2998
+ return undefined;
2999
+ return Object.freeze({ preset: meaning.preset as ModalPresetName });
3000
+ }
3001
+
3002
+ function trackTime(input: unknown, name: string): { time?: TrackTimeInput } {
3003
+ if (input === undefined || input === null) return {};
3004
+ if (!isRecord(input))
3005
+ throw new DawgSdkError(`track ${name}: time must be an object`);
3006
+ for (const key of Object.keys(input))
3007
+ if (key !== "rate" && key !== "phase" && key !== "cycle" && key !== "steps")
3008
+ throw new DawgSdkError(
3009
+ `track ${name}: time takes rate, phase, cycle and steps, not ${key}`,
3010
+ );
3011
+ const out: {
3012
+ rate?: number;
3013
+ phase?: number;
3014
+ cycle?: number;
3015
+ steps?: { shift: number; hold: number; drift: number };
3016
+ } = {};
3017
+ if (input.rate !== undefined) {
3018
+ const rate = finite(input.rate, `track ${name} time.rate`);
3019
+ if (rate < 0.125 || rate > 8)
3020
+ throw new DawgSdkError(`track ${name}: time.rate must be 0.125..8`);
3021
+ if (rate !== 1) out.rate = rate;
3022
+ }
3023
+ if (input.phase !== undefined) {
3024
+ const phase = finite(input.phase, `track ${name} time.phase`);
3025
+ if (phase !== 0) out.phase = phase;
3026
+ }
3027
+ if (input.cycle !== undefined) {
3028
+ const cycle = finite(input.cycle, `track ${name} time.cycle`);
3029
+ if (cycle <= 0)
3030
+ throw new DawgSdkError(`track ${name}: time.cycle must be > 0 beats`);
3031
+ out.cycle = cycle;
3032
+ }
3033
+ if (input.steps !== undefined) {
3034
+ if (!isRecord(input.steps))
3035
+ throw new DawgSdkError(`track ${name}: time.steps must be an object`);
3036
+ if (out.cycle === undefined)
3037
+ throw new DawgSdkError(`track ${name}: time.steps needs a cycle`);
3038
+ if (out.rate !== undefined)
3039
+ throw new DawgSdkError(
3040
+ `track ${name}: time takes rate or steps, not both`,
3041
+ );
3042
+ out.steps = phaseSteps(input.steps, out.cycle, `track ${name} time.steps`);
3043
+ }
3044
+ return Object.keys(out).length > 0 ? { time: Object.freeze(out) } : {};
3045
+ }
3046
+
3047
+ function phaseSteps(
3048
+ input: Record<string, unknown>,
3049
+ cycle: number,
3050
+ label: string,
3051
+ ): Readonly<{ shift: number; hold: number; drift: number }> {
3052
+ const shift = positive(input.shift, `${label}.shift`);
3053
+ if (shift > cycle)
3054
+ throw new DawgSdkError(`${label}.shift must be at most the cycle`);
3055
+ const hold = finite(input.hold, `${label}.hold`);
3056
+ if (!Number.isInteger(hold) || hold < 0 || hold > 64)
3057
+ throw new DawgSdkError(`${label}.hold must be a whole 0..64 cycles`);
3058
+ const drift = finite(input.drift, `${label}.drift`);
3059
+ if (!Number.isInteger(drift) || drift < 1 || drift > 64)
3060
+ throw new DawgSdkError(`${label}.drift must be a whole 1..64 cycles`);
3061
+ return Object.freeze({ shift, hold, drift });
3062
+ }
3063
+
1840
3064
  const AUTOMATION_KEYS: readonly (keyof AutomationInput)[] = Object.freeze([
1841
3065
  "volume",
1842
3066
  "pan",
@@ -1885,6 +3109,21 @@ function fxInput(input: unknown, name: string): FxInput | null {
1885
3109
  return Object.keys(out).length > 0 ? Object.freeze(out) : null;
1886
3110
  }
1887
3111
 
3112
+ function stringInput(input: unknown, name: string): StringInput | null {
3113
+ if (input === undefined || input === null) return null;
3114
+ if (!isRecord(input))
3115
+ throw new DawgSdkError(`track ${name}: string must be an object`);
3116
+ const out: Record<string, number | string> = {};
3117
+ for (const [key, value] of Object.entries(input)) {
3118
+ if (value === undefined || key === "kind") continue;
3119
+ out[key] =
3120
+ typeof value === "string"
3121
+ ? value
3122
+ : finite(value, `${name} string.${key}`);
3123
+ }
3124
+ return Object.freeze(out);
3125
+ }
3126
+
1888
3127
  function synthInput(input: unknown, name: string): SynthInput | null {
1889
3128
  if (input === undefined || input === null) return null;
1890
3129
  if (!isRecord(input))
@@ -1970,7 +3209,7 @@ function reverbIr(
1970
3209
  throw new DawgSdkError(`track ${name}: reverb.ir needs a src`);
1971
3210
  const src = spec.src.trim().replace(/^\.\//, "");
1972
3211
  if (src.startsWith("pack:")) {
1973
- const ref = sample(spec as SampleSpec, `${name} reverb.ir`);
3212
+ const ref = sampleSpec(spec as SampleSpec, `${name} reverb.ir`);
1974
3213
  return Object.freeze({
1975
3214
  src: ref.src,
1976
3215
  ...(ref.sha256 ? { sha256: ref.sha256 } : {}),
@@ -1982,6 +3221,41 @@ function reverbIr(
1982
3221
  return src.startsWith("tracks/") ? src : `tracks/${slug}/${src}`;
1983
3222
  }
1984
3223
 
3224
+ /** Validates `granular` input and localizes a sample source like a voice. */
3225
+ function granularInput(
3226
+ input: unknown,
3227
+ name: string,
3228
+ slug: string,
3229
+ ): GranularInput | null {
3230
+ if (input === undefined || input === null) return null;
3231
+ if (!isRecord(input))
3232
+ throw new DawgSdkError(`track ${name}: granular must be an object`);
3233
+ const out: Record<string, unknown> = {};
3234
+ for (const [key, value] of Object.entries(input)) {
3235
+ if (value === undefined || key === "kind") continue;
3236
+ if (key === "src" && isRecord(value)) {
3237
+ const ref = sampleSpec(value as SampleSpec, `${name} granular src`);
3238
+ const src = ref.src.replace(/^\.\//, "");
3239
+ out.src = Object.freeze({
3240
+ ...ref,
3241
+ src:
3242
+ src.startsWith("tracks/") || src.startsWith("pack:")
3243
+ ? src
3244
+ : `tracks/${slug}/${src}`,
3245
+ });
3246
+ } else if (key === "src" && typeof value === "string")
3247
+ out.src = value.startsWith("synth:")
3248
+ ? value
3249
+ : granularInput({ src: { src: value } }, name, slug)!.src;
3250
+ else if (typeof value === "number")
3251
+ out[key] = finite(value, `${name} granular.${key}`);
3252
+ else if (typeof value === "string" || typeof value === "boolean")
3253
+ out[key] = value;
3254
+ else throw new DawgSdkError(`${name} granular.${key} must be a value`);
3255
+ }
3256
+ return Object.freeze(out) as GranularInput;
3257
+ }
3258
+
1985
3259
  /** `./wavetables/x.wav` → `tracks/<slug>/wavetables/x.wav`, like sampler files. */
1986
3260
  function localizeWavetable(spec: WavetableSpec, slug: string): WavetableSpec {
1987
3261
  const src = spec.table.src;
@@ -2018,19 +3292,103 @@ function localizeSampler(spec: SamplerSpec, slug: string): SamplerSpec {
2018
3292
  export type SongInput = Readonly<{
2019
3293
  /** BPM 20..300, default 120. */
2020
3294
  tempo?: number;
2021
- /** `[beatsPerBar, noteValue]` or just `beatsPerBar`; default `[4, 4]`. Only the numerator is stored. */
3295
+ /**
3296
+ * `[beatsPerBar, noteValue]` or just `beatsPerBar`; default `[4, 4]`.
3297
+ * A note value other than 4 is stored as a meter change at bar 1, so
3298
+ * `[6, 8]` is six eighths (three quarter-note beats) per bar.
3299
+ */
2022
3300
  meter?: readonly [number, number] | number;
2023
3301
  /** Loop length in bars, 1..256, default 4. */
2024
3302
  bars?: number;
2025
- /** Free text such as `"A minor"`, or null. */
3303
+ /**
3304
+ * Free text such as `"A minor"`, or null. A scale name after the tonic
3305
+ * picks a scale: `"D dorian"`, `"E hijaz"`, `"C yaman"`, `"C messiaen-3"`.
3306
+ */
2026
3307
  key?: string | null;
3308
+ /**
3309
+ * Song tuning (SDK 1.16.0), default 12-TET at A4 = 440 Hz: a library name
3310
+ * (`"19-edo"`, `"just"`, `"pelog"`, `"yaman"`) or `{ edo, ratios, cents,
3311
+ * scl, kbm, ref, root, map }`. See `TuningInput`.
3312
+ */
3313
+ tuning?: TuningInput | null;
2027
3314
  /** Integer ticks per beat, default 480. Leave it alone unless you know why. */
2028
3315
  ticksPerBeat?: number;
3316
+ /**
3317
+ * Tempo changes, meter changes and fermatas (SDK 1.14.0), in any order:
3318
+ * `tempo()`, `ramp()`, `rit()`, `accel()`, `fermata()` and `meter()`.
3319
+ * `tempo` above stays the opening tempo and `meter` the opening meter.
3320
+ * `rit()` and `accel()` return two marks; list them as they come.
3321
+ */
3322
+ time?: readonly (TimeMark | readonly TimeMark[])[];
2029
3323
  /** Tracks in score order; each from `track()`. */
2030
3324
  tracks: readonly TrackSpec[];
3325
+ /** Master chain and loudness target after every track and orbit bus (SDK 1.17.0); omit for none. */
3326
+ master?: MasterInput;
3327
+ /**
3328
+ * Named bar ranges (SDK 1.18.0): `{ name: "chorus", startBar: 8, bars: 8 }`,
3329
+ * optionally with `mute: ["pad"]` and `vary: { lead: { transpose: 12 } }`.
3330
+ */
3331
+ sections?: readonly SongSection[];
3332
+ /**
3333
+ * The order sections play, with repeats (SDK 1.18.0): `"intro verse
3334
+ * chorus*2 outro"`, or `["intro", { section: "chorus", repeat: 2 }]`.
3335
+ * Absent plays the bars straight through.
3336
+ */
3337
+ form?: string | readonly (string | SongFormEntry)[];
3338
+ /** The section playback loops (SDK 1.18.0); export ignores it. */
3339
+ loopSection?: string;
3340
+ }>;
3341
+
3342
+ /** A song section (SDK 1.18.0); bars are 0-based like beats. */
3343
+ export type SongSection = Readonly<{
3344
+ /** `intro`, `verse`, `chorus 2`, `A`: 1..32 characters, unique ignoring case. */
3345
+ name: string;
3346
+ /** First bar, 0-based. */
3347
+ startBar: number;
3348
+ /** Length in bars, at least 1. */
3349
+ bars: number;
3350
+ /** Track ids silent in this section. */
3351
+ mute?: readonly string[];
3352
+ /** Per-track changes in this section: semitones and a velocity multiplier. */
3353
+ vary?: Readonly<
3354
+ Record<string, Readonly<{ transpose?: number; gain?: number }>>
3355
+ >;
3356
+ }>;
3357
+
3358
+ /** One step of the song form (SDK 1.18.0). */
3359
+ export type SongFormEntry = Readonly<{ section: string; repeat?: number }>;
3360
+
3361
+ /**
3362
+ * The song master (SDK 1.17.0), processed in the fixed order
3363
+ * eq → glue → tape → width → limiter after the tracks and orbit buses are
3364
+ * summed. Each unit present is on; `{}` takes every default. `target` is
3365
+ * an integrated loudness in LUFS (ITU-R BS.1770-4), -40..-3: renders drive
3366
+ * the limiter (or, without one, a clean gain) to reach it. Streaming is
3367
+ * -14, club -8, loud hyperpop or gabber -6, classical -20, broadcast -23.
3368
+ * The SDK takes LUFS numbers only: a target name such as `master target
3369
+ * club` in the prompt also sets a limiter preset, so write that unit out.
3370
+ * DAWG.md "Master and loudness" lists every parameter with its range.
3371
+ *
3372
+ * ```ts
3373
+ * master: { glue: { ratio: 2 }, limiter: { ceiling: -1 }, target: -14 }
3374
+ * ```
3375
+ */
3376
+ export type MasterInput = Readonly<{
3377
+ /** `low`/`high` shelves and `bell1`/`bell2` gains in dB, with `…freq` and `…q`. */
3378
+ eq?: EffectParams;
3379
+ /** Bus compressor: threshold, ratio, attack, release (ms), knee, makeup, mix, hpf. */
3380
+ glue?: EffectParams;
3381
+ /** Saturation: drive (dB), bias, tone (Hz), mix. */
3382
+ tape?: EffectParams;
3383
+ /** Stereo width 0..2 (1 unchanged) and `mono` bass below this many Hz. */
3384
+ width?: EffectParams;
3385
+ /** True-peak limiter: ceiling (dBTP), gain, release, lookahead (ms), truepeak. */
3386
+ limiter?: EffectParams;
3387
+ /** Integrated loudness target in LUFS (a negative number, -40..-3). */
3388
+ target?: number;
2031
3389
  }>;
2032
3390
 
2033
- /** A stored note: integer ticks. */
3391
+ /** A stored note: integer ticks; expression fields only when set. */
2034
3392
  export type ScoreNote = Readonly<{
2035
3393
  id: string;
2036
3394
  trackId: string;
@@ -2038,6 +3396,13 @@ export type ScoreNote = Readonly<{
2038
3396
  durationTicks: number;
2039
3397
  pitch: number;
2040
3398
  velocity: number;
3399
+ articulation?: Articulation;
3400
+ glide?: number;
3401
+ bend?: readonly Readonly<{ at: number; cents: number }>[];
3402
+ vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
3403
+ humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
3404
+ /** Static cents offset (SDK 1.16.0); absent is 0. */
3405
+ cents?: number;
2041
3406
  }>;
2042
3407
 
2043
3408
  /** A stored automation point: integer tick. */
@@ -2063,6 +3428,9 @@ export type ScoreSampleRef = Readonly<{
2063
3428
  fit?: boolean;
2064
3429
  accelerate?: number;
2065
3430
  squiz?: number;
3431
+ bpm?: number;
3432
+ fitmode?: "repitch" | "beats" | "tones";
3433
+ len?: number;
2066
3434
  }>;
2067
3435
 
2068
3436
  /** A stored track; optional fields are present only when set. */
@@ -2086,6 +3454,7 @@ export type ScoreTrack = Readonly<{
2086
3454
  fx?: FxInput;
2087
3455
  fxAutomation?: Readonly<Record<string, readonly ScorePoint[]>>;
2088
3456
  synth?: SynthInput;
3457
+ keys?: KeysInput;
2089
3458
  sampler?: Readonly<{
2090
3459
  voices: Readonly<Record<string, ScoreSampleRef>>;
2091
3460
  mode: "oneshot" | "keyed";
@@ -2094,8 +3463,27 @@ export type ScoreTrack = Readonly<{
2094
3463
  rhythm?: readonly Readonly<Record<string, unknown>>[];
2095
3464
  /** Synth kit name; dawg validates it. */
2096
3465
  kit?: string;
3466
+ /** `rate`, plus `phase` and `cycle` in ticks. */
3467
+ time?: Readonly<{
3468
+ rate?: number;
3469
+ phase?: number;
3470
+ cycle?: number;
3471
+ steps?: Readonly<{ shift: number; hold: number; drift: number }>;
3472
+ }>;
3473
+ /** Track tuning; dawg validates it (SDK 1.16.0). */
3474
+ tuning?: ScoreTuning;
2097
3475
  wavetable?: Readonly<{ table: ScoreSampleRef } & WavetableParams>;
2098
3476
  wtAutomation?: readonly ScorePoint[];
3477
+ /** String engine settings; dawg validates them (SDK 1.21.0). */
3478
+ string?: StringInput;
3479
+ /** Granular settings (SDK 1.23.0); present only when set. */
3480
+ granular?: GranularInput;
3481
+ /** Modal settings (SDK 1.25.0). */
3482
+ modal?: TrackSpec["modal"];
3483
+ glide?: TrackSpec["glide"];
3484
+ pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
3485
+ velocityCurve?: TrackSpec["velocityCurve"];
3486
+ humanize?: TrackSpec["humanize"];
2099
3487
  }>;
2100
3488
 
2101
3489
  /**
@@ -2111,10 +3499,167 @@ export type Song = Readonly<{
2111
3499
  bars: number;
2112
3500
  ticksPerBeat: number;
2113
3501
  key: string | null;
3502
+ /** Present only when `song({ time })` has marks. */
3503
+ time?: ScoreTime;
3504
+ /** Present only when the song sets one (SDK 1.16.0). */
3505
+ tuning?: ScoreTuning;
2114
3506
  tracks: readonly ScoreTrack[];
2115
3507
  notes: readonly ScoreNote[];
3508
+ master?: MasterInput;
3509
+ /** Present only when the song has sections (SDK 1.18.0). */
3510
+ sections?: readonly SongSection[];
3511
+ /** Present only when the song has a form (SDK 1.18.0). */
3512
+ form?: readonly SongFormEntry[];
3513
+ /** Present only when a section loops (SDK 1.18.0). */
3514
+ loopSection?: string;
2116
3515
  }>;
2117
3516
 
3517
+ /** A stored song `time`: ticks, and 0-based bar indexes. */
3518
+ export type ScoreTime = Readonly<{
3519
+ tempo?: readonly Readonly<{
3520
+ tick: number;
3521
+ bpm: number;
3522
+ ramp?: "linear" | "exp";
3523
+ }>[];
3524
+ meter?: readonly Readonly<{
3525
+ bar: number;
3526
+ beatsPerBar: number;
3527
+ beatUnit?: number;
3528
+ }>[];
3529
+ fermatas?: readonly Readonly<{ tick: number; beats: number }>[];
3530
+ }>;
3531
+
3532
+ const MASTER_KEYS = ["eq", "glue", "tape", "width", "limiter", "target"];
3533
+
3534
+ /** Shape checks only; dawg validates every value when it loads the song. */
3535
+ function masterData(input: unknown): MasterInput | undefined {
3536
+ if (input === undefined || input === null) return undefined;
3537
+ if (!isRecord(input)) throw new DawgSdkError("song master must be an object");
3538
+ const out: Record<string, unknown> = {};
3539
+ for (const [key, value] of Object.entries(input)) {
3540
+ if (value === undefined) continue;
3541
+ if (!MASTER_KEYS.includes(key))
3542
+ throw new DawgSdkError(
3543
+ `song master has no "${key}"; use ${MASTER_KEYS.join(", ")}`,
3544
+ );
3545
+ if (key === "target") {
3546
+ if (typeof value === "string")
3547
+ throw new DawgSdkError(
3548
+ `song master target takes LUFS, e.g. target: -14 (streaming), -8 (club, with limiter: { release: 60, lookahead: 2 }), -6 (loud, with limiter: { release: 20, lookahead: 1 }); got "${value}"`,
3549
+ );
3550
+ out.target = finite(value as number, "song master target");
3551
+ continue;
3552
+ }
3553
+ if (!isRecord(value))
3554
+ throw new DawgSdkError(`song master ${key} must be an object`);
3555
+ out[key] = Object.freeze({ ...value });
3556
+ }
3557
+ return Object.keys(out).length > 0
3558
+ ? (Object.freeze(out) as MasterInput)
3559
+ : undefined;
3560
+ }
3561
+
3562
+ /** `"intro verse chorus*2"` (comma separated when a name has a space). */
3563
+ function parseSongForm(
3564
+ form: NonNullable<SongInput["form"]>,
3565
+ ): readonly SongFormEntry[] {
3566
+ const items: (string | SongFormEntry)[] =
3567
+ typeof form === "string"
3568
+ ? (form.includes(",") ? form.split(",") : form.trim().split(/\s+/u))
3569
+ .map((item) => item.trim())
3570
+ .filter((item) => item !== "")
3571
+ : Array.isArray(form)
3572
+ ? [...form]
3573
+ : (() => {
3574
+ throw new DawgSdkError(
3575
+ "song form must be a string or an array of section names",
3576
+ );
3577
+ })();
3578
+ return Object.freeze(
3579
+ items.map((item, index) => {
3580
+ let entry: unknown = item;
3581
+ if (typeof item === "string") {
3582
+ const match = /^(.*?)\s*(?:\*|\bx|×)\s*(\d+)$/iu.exec(item);
3583
+ entry =
3584
+ match && match[1]!.length > 0
3585
+ ? { section: match[1]!, repeat: Number(match[2]) }
3586
+ : { section: item };
3587
+ }
3588
+ if (!isRecord(entry) || typeof entry.section !== "string")
3589
+ throw new DawgSdkError(
3590
+ `song form[${index}] must be a section name or { section, repeat }`,
3591
+ );
3592
+ const repeat = entry.repeat ?? 1;
3593
+ if (
3594
+ !Number.isInteger(repeat) ||
3595
+ (repeat as number) < 1 ||
3596
+ (repeat as number) > 16
3597
+ )
3598
+ throw new DawgSdkError(`song form[${index}] repeat must be 1..16`);
3599
+ return Object.freeze(
3600
+ repeat === 1
3601
+ ? { section: entry.section }
3602
+ : { section: entry.section, repeat: repeat as number },
3603
+ );
3604
+ }),
3605
+ );
3606
+ }
3607
+
3608
+ function songSections(
3609
+ sections: NonNullable<SongInput["sections"]>,
3610
+ ): readonly SongSection[] {
3611
+ if (!Array.isArray(sections))
3612
+ throw new DawgSdkError("song sections must be an array");
3613
+ if (sections.length > 64) throw new DawgSdkError("song has over 64 sections");
3614
+ return Object.freeze(
3615
+ sections.map((section, index) => {
3616
+ const where = `song sections[${index}]`;
3617
+ if (!isRecord(section) || typeof section.name !== "string")
3618
+ throw new DawgSdkError(`${where} needs a name`);
3619
+ const stored: Record<string, unknown> = {
3620
+ name: section.name,
3621
+ startBar: finite(section.startBar, `${where}.startBar`),
3622
+ bars: finite(section.bars, `${where}.bars`),
3623
+ };
3624
+ if (section.mute !== undefined) {
3625
+ if (
3626
+ !Array.isArray(section.mute) ||
3627
+ section.mute.some((id) => typeof id !== "string")
3628
+ )
3629
+ throw new DawgSdkError(`${where}.mute must be track ids`);
3630
+ if (section.mute.length > 0)
3631
+ stored.mute = Object.freeze([...section.mute]);
3632
+ }
3633
+ if (section.vary !== undefined) {
3634
+ if (!isRecord(section.vary))
3635
+ throw new DawgSdkError(`${where}.vary must be an object`);
3636
+ const vary = Object.entries(section.vary);
3637
+ if (vary.length > 0)
3638
+ stored.vary = Object.freeze(
3639
+ Object.fromEntries(
3640
+ vary.map(([id, change]) => {
3641
+ if (!isRecord(change))
3642
+ throw new DawgSdkError(
3643
+ `${where}.vary.${id} must be an object`,
3644
+ );
3645
+ const out: Record<string, number> = {};
3646
+ if (change.transpose !== undefined)
3647
+ out.transpose = finite(
3648
+ change.transpose,
3649
+ `${where}.vary.${id}.transpose`,
3650
+ );
3651
+ if (change.gain !== undefined)
3652
+ out.gain = finite(change.gain, `${where}.vary.${id}.gain`);
3653
+ return [id, Object.freeze(out)];
3654
+ }),
3655
+ ),
3656
+ );
3657
+ }
3658
+ return Object.freeze(stored) as SongSection;
3659
+ }),
3660
+ );
3661
+ }
3662
+
2118
3663
  /**
2119
3664
  * Assemble the song. Beats become ticks (`Math.round(beat * ticksPerBeat)`,
2120
3665
  * lengths at least one tick), and every note gets a deterministic id from
@@ -2127,7 +3672,11 @@ export function song(input: SongInput): Song {
2127
3672
  const beatsPerBar = Array.isArray(meter)
2128
3673
  ? finite(meter[0], "song meter[0]")
2129
3674
  : finite(meter as number, "song meter");
2130
- if (Array.isArray(meter)) finite(meter[1], "song meter[1]");
3675
+ const beatUnit = Array.isArray(meter) ? finite(meter[1], "song meter[1]") : 4;
3676
+ if (![1, 2, 4, 8, 16, 32].includes(beatUnit))
3677
+ throw new DawgSdkError(
3678
+ "song meter note value must be 1, 2, 4, 8, 16 or 32",
3679
+ );
2131
3680
  const bars = finite(input.bars ?? 4, "song bars");
2132
3681
  const ticksPerBeat = input.ticksPerBeat ?? DEFAULT_TICKS_PER_BEAT;
2133
3682
  if (
@@ -2139,6 +3688,8 @@ export function song(input: SongInput): Song {
2139
3688
  const key = input.key ?? null;
2140
3689
  if (key !== null && typeof key !== "string")
2141
3690
  throw new DawgSdkError("song key must be a string or null");
3691
+ const songTuning = tuningSpec(input.tuning, "song");
3692
+ const master = masterData(input.master);
2142
3693
  if (!Array.isArray(input.tracks))
2143
3694
  throw new DawgSdkError("song tracks must be an array of track()");
2144
3695
  if (input.tracks.length > 64)
@@ -2196,12 +3747,46 @@ export function song(input: SongInput): Song {
2196
3747
  stored.wavetable = Object.freeze(fields);
2197
3748
  }
2198
3749
  if (wtAutomation.length > 0) stored.wtAutomation = wtAutomation;
3750
+ if (t.granular) stored.granular = t.granular;
2199
3751
  if (t.sampler)
2200
3752
  stored.sampler = Object.freeze({
2201
3753
  voices: t.sampler.voices,
2202
3754
  mode: t.sampler.mode,
2203
3755
  });
2204
3756
  if (t.kit) stored.kit = t.kit;
3757
+ if (t.time) {
3758
+ const time: Record<string, unknown> = {};
3759
+ if (t.time.rate !== undefined) time.rate = t.time.rate;
3760
+ if (t.time.phase !== undefined) {
3761
+ const phase = ticks(t.time.phase);
3762
+ if (phase !== 0) time.phase = phase;
3763
+ }
3764
+ if (t.time.cycle !== undefined)
3765
+ time.cycle = Math.max(1, ticks(t.time.cycle));
3766
+ if (t.time.steps)
3767
+ time.steps = Object.freeze({
3768
+ ...t.time.steps,
3769
+ shift: Math.max(1, ticks(t.time.steps.shift)),
3770
+ });
3771
+ if (Object.keys(time).length > 0) stored.time = Object.freeze(time);
3772
+ }
3773
+ if (t.glide) stored.glide = t.glide;
3774
+ if (t.pedal && t.pedal.length > 0) {
3775
+ // One event per tick (the last wins), in tick order, as dawg stores it.
3776
+ const byTick = new Map<number, PedalState>();
3777
+ for (const [at, state] of t.pedal) byTick.set(ticks(at), state);
3778
+ stored.pedal = Object.freeze(
3779
+ [...byTick.entries()]
3780
+ .sort((a, b) => a[0] - b[0])
3781
+ .map(([tick, state]) => Object.freeze({ tick, state })),
3782
+ );
3783
+ }
3784
+ if (t.velocityCurve) stored.velocityCurve = t.velocityCurve;
3785
+ if (t.humanize) stored.humanize = t.humanize;
3786
+ if (t.tuning) stored.tuning = t.tuning;
3787
+ if (t.string) stored.string = t.string;
3788
+ if (t.keys) stored.keys = t.keys;
3789
+ if (t.modal) stored.modal = t.modal;
2205
3790
  if (t.rhythm && t.rhythm.length > 0)
2206
3791
  stored.rhythm = Object.freeze(
2207
3792
  t.rhythm.map((row) => {
@@ -2228,10 +3813,42 @@ export function song(input: SongInput): Song {
2228
3813
  durationTicks,
2229
3814
  pitch: n.pitch,
2230
3815
  velocity: n.velocity,
3816
+ ...(n.articulation ? { articulation: n.articulation } : {}),
3817
+ ...(n.glide !== undefined ? { glide: n.glide } : {}),
3818
+ ...(n.bend
3819
+ ? {
3820
+ bend: Object.freeze(
3821
+ [...n.bend]
3822
+ .sort((a, b) => a[0] - b[0])
3823
+ .map(([at, cents]) => Object.freeze({ at, cents })),
3824
+ ),
3825
+ }
3826
+ : {}),
3827
+ ...(n.vibrato ? { vibrato: n.vibrato } : {}),
3828
+ ...(n.humanize ? { humanize: n.humanize } : {}),
3829
+ ...(n.cents ? { cents: n.cents } : {}),
2231
3830
  }),
2232
3831
  );
2233
3832
  }
2234
3833
  });
3834
+ const arrangement: {
3835
+ sections?: readonly SongSection[];
3836
+ form?: readonly SongFormEntry[];
3837
+ loopSection?: string;
3838
+ } = {};
3839
+ if (input.sections !== undefined) {
3840
+ const sections = songSections(input.sections);
3841
+ if (sections.length > 0) arrangement.sections = sections;
3842
+ }
3843
+ if (input.form !== undefined) {
3844
+ const form = parseSongForm(input.form);
3845
+ if (form.length > 0) arrangement.form = form;
3846
+ }
3847
+ if (input.loopSection !== undefined) {
3848
+ if (typeof input.loopSection !== "string")
3849
+ throw new DawgSdkError("song loopSection must be a section name");
3850
+ arrangement.loopSection = input.loopSection;
3851
+ }
2235
3852
  return Object.freeze({
2236
3853
  format: "track.loop/v1",
2237
3854
  version: 1,
@@ -2240,11 +3857,624 @@ export function song(input: SongInput): Song {
2240
3857
  bars,
2241
3858
  ticksPerBeat,
2242
3859
  key,
3860
+ ...songTime(
3861
+ input.time,
3862
+ tempoBpm,
3863
+ ticks,
3864
+ beatsPerBar,
3865
+ ticksPerBeat,
3866
+ beatUnit,
3867
+ bars,
3868
+ ),
3869
+ ...(songTuning ? { tuning: songTuning } : {}),
2243
3870
  tracks: Object.freeze(tracks),
2244
3871
  notes: Object.freeze(notes),
3872
+ ...(master ? { master } : {}),
3873
+ ...arrangement,
2245
3874
  });
2246
3875
  }
2247
3876
 
3877
+ // ---------------------------------------------------------------------------
3878
+ // Time (SDK 1.14.0)
3879
+
3880
+ /** One entry of `song({ time })`; build them with the helpers below. */
3881
+ export type TimeMark =
3882
+ | Readonly<{
3883
+ kind: "tempo";
3884
+ /** Beat the change lands on. */
3885
+ at: number;
3886
+ /** Absent: keep the tempo in effect there, so a ramp can start from it. */
3887
+ bpm?: number;
3888
+ /** Glide into `bpm` from the previous mark instead of stepping. */
3889
+ ramp?: "linear" | "exp";
3890
+ /** Set by `rit()`/`accel()`: the direction `song()` checks. */
3891
+ gradual?: "rit" | "accel";
3892
+ /**
3893
+ * Set by `aTempo()` (the tempo before the last rit/accel) and
3894
+ * `tempoPrimo()` (the song's opening tempo) instead of `bpm`.
3895
+ */
3896
+ back?: "a-tempo" | "primo";
3897
+ }>
3898
+ | Readonly<{
3899
+ kind: "meter";
3900
+ /** Beat of the bar line where the meter starts. */
3901
+ at: number;
3902
+ beatsPerBar: number;
3903
+ beatUnit: number;
3904
+ }>
3905
+ | Readonly<{
3906
+ kind: "fermata";
3907
+ at: number;
3908
+ /** Extra beats the held beat lasts. */
3909
+ beats: number;
3910
+ }>;
3911
+
3912
+ /** `ramp()` curves: `linear` adds the same BPM each beat, `exp` the same ratio. */
3913
+ export type TempoCurve = "linear" | "exp";
3914
+
3915
+ /**
3916
+ * Tempo change at beat `at`: `tempo(32, 140)`. Omit `bpm` to pin the tempo
3917
+ * in effect there, the start of a ramp.
3918
+ */
3919
+ export function tempo(at: number, bpm?: number): TimeMark {
3920
+ const start = beat(at, "tempo() at");
3921
+ if (start <= 0)
3922
+ throw new DawgSdkError("tempo() at must be > 0; song({ tempo }) is beat 0");
3923
+ if (bpm === undefined) return Object.freeze({ kind: "tempo", at: start });
3924
+ return Object.freeze({ kind: "tempo", at: start, bpm: songBpm(bpm) });
3925
+ }
3926
+
3927
+ /**
3928
+ * `a tempo` at beat `at` (SDK 1.19.0): step back to the tempo in effect
3929
+ * before the last `rit()`/`accel()` (or ramp) ending before `at`.
3930
+ */
3931
+ export function aTempo(at: number): TimeMark {
3932
+ const start = beat(at, "aTempo() at");
3933
+ if (start <= 0) throw new DawgSdkError("aTempo() at must be > 0");
3934
+ return Object.freeze({ kind: "tempo", at: start, back: "a-tempo" });
3935
+ }
3936
+
3937
+ /** `tempo primo` at beat `at` (SDK 1.19.0): step back to `song({ tempo })`. */
3938
+ export function tempoPrimo(at: number): TimeMark {
3939
+ const start = beat(at, "tempoPrimo() at");
3940
+ if (start <= 0) throw new DawgSdkError("tempoPrimo() at must be > 0");
3941
+ return Object.freeze({ kind: "tempo", at: start, back: "primo" });
3942
+ }
3943
+
3944
+ /**
3945
+ * Glide from the previous tempo mark (or the song's opening tempo) to `bpm`
3946
+ * at beat `at`: `ramp(64, 90)`. `exp` changes by the same ratio each beat.
3947
+ */
3948
+ export function ramp(
3949
+ at: number,
3950
+ bpm: number,
3951
+ curve: TempoCurve = "linear",
3952
+ ): TimeMark {
3953
+ const end = beat(at, "ramp() at");
3954
+ if (end <= 0) throw new DawgSdkError("ramp() at must be > 0");
3955
+ return Object.freeze({
3956
+ kind: "tempo",
3957
+ at: end,
3958
+ bpm: songBpm(bpm),
3959
+ ramp: tempoCurve(curve, "ramp()"),
3960
+ });
3961
+ }
3962
+
3963
+ /**
3964
+ * Ritardando: slow from the tempo at beat `at` to `bpm` over `beats` beats,
3965
+ * `rit(48, 16, 80)`. Same as `[tempo(at), ramp(at + beats, bpm, curve)]`.
3966
+ */
3967
+ export function rit(
3968
+ at: number,
3969
+ beats: number,
3970
+ bpm: number,
3971
+ curve: TempoCurve = "linear",
3972
+ ): readonly TimeMark[] {
3973
+ return gradual("rit", at, beats, bpm, curve);
3974
+ }
3975
+
3976
+ /** Accelerando: like `rit()`, toward a faster `bpm`. */
3977
+ export function accel(
3978
+ at: number,
3979
+ beats: number,
3980
+ bpm: number,
3981
+ curve: TempoCurve = "linear",
3982
+ ): readonly TimeMark[] {
3983
+ return gradual("accel", at, beats, bpm, curve);
3984
+ }
3985
+
3986
+ function gradual(
3987
+ direction: "rit" | "accel",
3988
+ at: number,
3989
+ beats: number,
3990
+ bpm: number,
3991
+ curve: TempoCurve,
3992
+ ): readonly TimeMark[] {
3993
+ const label = `${direction}()`;
3994
+ const start = beat(at, `${label} at`);
3995
+ const length = positive(beats, `${label} beats`);
3996
+ const target = songBpm(bpm);
3997
+ const shape = tempoCurve(curve, label);
3998
+ const marks: TimeMark[] = [];
3999
+ if (start > 0) marks.push(Object.freeze({ kind: "tempo", at: start }));
4000
+ marks.push(
4001
+ Object.freeze({
4002
+ kind: "tempo",
4003
+ at: start + length,
4004
+ bpm: target,
4005
+ ramp: shape,
4006
+ gradual: direction,
4007
+ }),
4008
+ );
4009
+ return Object.freeze(marks);
4010
+ }
4011
+
4012
+ /** Fermata: the beat at `at` lasts `beats` extra beats (default 2). */
4013
+ export function fermata(at: number, beats = 2): TimeMark {
4014
+ const hold = positive(beats, "fermata() beats");
4015
+ if (hold > 64) throw new DawgSdkError("fermata() beats must be at most 64");
4016
+ return Object.freeze({
4017
+ kind: "fermata",
4018
+ at: beat(at, "fermata() at"),
4019
+ beats: hold,
4020
+ });
4021
+ }
4022
+
4023
+ /**
4024
+ * Meter change on the bar line at beat `at`: `meter(16, [7, 8])` or
4025
+ * `meter(16, 3)` (quarter-note beats). It lasts until the next one.
4026
+ */
4027
+ export function meter(
4028
+ at: number,
4029
+ value: readonly [number, number] | number,
4030
+ ): TimeMark {
4031
+ const start = beat(at, "meter() at");
4032
+ const [beatsPerBar, beatUnit] = Array.isArray(value)
4033
+ ? [value[0], value[1]]
4034
+ : [value as number, 4];
4035
+ if (
4036
+ typeof beatsPerBar !== "number" ||
4037
+ !Number.isInteger(beatsPerBar) ||
4038
+ beatsPerBar < 1 ||
4039
+ beatsPerBar > 16
4040
+ )
4041
+ throw new DawgSdkError("meter() beats per bar must be an integer 1..16");
4042
+ if (![1, 2, 4, 8, 16, 32].includes(beatUnit as number))
4043
+ throw new DawgSdkError("meter() note value must be 1, 2, 4, 8, 16 or 32");
4044
+ return Object.freeze({
4045
+ kind: "meter",
4046
+ at: start,
4047
+ beatsPerBar,
4048
+ beatUnit: beatUnit as number,
4049
+ });
4050
+ }
4051
+
4052
+ /**
4053
+ * Track time for continuous phasing, the tape drift of Reich's It's Gonna
4054
+ * Rain and Come Out: the track's first `cycle` beats repeat a little fast,
4055
+ * gaining `cycles` whole cycles every `over` beats, so it drifts away from
4056
+ * an identical track and lines up again. `over` should divide the song
4057
+ * loop. `track({ ..., time: phasing(3, 48) })`. For Piano Phase's
4058
+ * shift-and-hold, use `stepPhasing()`.
4059
+ */
4060
+ export function phasing(
4061
+ cycle: number,
4062
+ over: number,
4063
+ cycles = 1,
4064
+ ): TrackTimeInput {
4065
+ const length = positive(cycle, "phasing() cycle");
4066
+ const span = positive(over, "phasing() over");
4067
+ const gain = finite(cycles, "phasing() cycles");
4068
+ const repeats = span / length;
4069
+ const rate = (repeats + gain) / repeats;
4070
+ if (!(rate >= 0.125 && rate <= 8))
4071
+ throw new DawgSdkError("phasing() needs a rate between 0.125 and 8");
4072
+ return Object.freeze({ cycle: length, rate });
4073
+ }
4074
+
4075
+ /**
4076
+ * Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: the track's first `cycle`
4077
+ * beats hold in step with a twin for `hold` cycles, then move `shift`
4078
+ * beats ahead over `drift` cycles, and repeat until a whole cycle ahead.
4079
+ * `track({ ..., time: stepPhasing(3, { hold: 8 }) })`.
4080
+ */
4081
+ export function stepPhasing(
4082
+ cycle: number,
4083
+ options: Readonly<{ shift?: number; hold?: number; drift?: number }> = {},
4084
+ ): TrackTimeInput {
4085
+ const length = positive(cycle, "stepPhasing() cycle");
4086
+ const steps = phaseSteps(
4087
+ { shift: 0.25, hold: 8, drift: 2, ...options },
4088
+ length,
4089
+ "stepPhasing()",
4090
+ );
4091
+ return Object.freeze({ cycle: length, steps });
4092
+ }
4093
+
4094
+ function songBpm(value: unknown): number {
4095
+ const bpm = finite(value, "tempo bpm");
4096
+ if (bpm < 20 || bpm > 300)
4097
+ throw new DawgSdkError("tempo bpm must be 20..300");
4098
+ return bpm;
4099
+ }
4100
+
4101
+ function tempoCurve(value: unknown, label: string): TempoCurve {
4102
+ if (value !== "linear" && value !== "exp")
4103
+ throw new DawgSdkError(`${label} curve must be "linear" or "exp"`);
4104
+ return value;
4105
+ }
4106
+
4107
+ /** Same as `TIME_LIMITS.maxFermataSeconds` in core/tempo.ts. */
4108
+ const MAX_FERMATA_SECONDS = 16.777;
4109
+
4110
+ /** Tempo at `tick` through resolved tempo events (ramps glide into theirs). */
4111
+ function bpmAtTick(
4112
+ tempo: readonly { tick: number; bpm: number; ramp?: TempoCurve }[],
4113
+ start: number,
4114
+ tick: number,
4115
+ ): number {
4116
+ let from = { tick: 0, bpm: start };
4117
+ for (const event of tempo) {
4118
+ if (event.tick <= tick) {
4119
+ from = event;
4120
+ continue;
4121
+ }
4122
+ if (!event.ramp) break;
4123
+ const t = (tick - from.tick) / (event.tick - from.tick);
4124
+ return event.ramp === "exp"
4125
+ ? from.bpm * Math.pow(event.bpm / from.bpm, t)
4126
+ : from.bpm + (event.bpm - from.bpm) * t;
4127
+ }
4128
+ return from.bpm;
4129
+ }
4130
+
4131
+ /** Resolves `song({ time })` marks into the stored ticks and bar indexes. */
4132
+ function songTime(
4133
+ input: unknown,
4134
+ tempoBpm: number,
4135
+ ticks: (beats: number) => number,
4136
+ beatsPerBar: number,
4137
+ ticksPerBeat: number,
4138
+ beatUnit = 4,
4139
+ bars = Infinity,
4140
+ ): { time?: ScoreTime } {
4141
+ if ((input === undefined || input === null) && beatUnit === 4) return {};
4142
+ input ??= [];
4143
+ if (!Array.isArray(input))
4144
+ throw new DawgSdkError("song time must be an array of time marks");
4145
+ const marks: TimeMark[] = [];
4146
+ for (const [index, entry] of (input as unknown[]).entries()) {
4147
+ for (const mark of Array.isArray(entry) ? entry : [entry]) {
4148
+ if (
4149
+ !isRecord(mark) ||
4150
+ (mark.kind !== "tempo" &&
4151
+ mark.kind !== "meter" &&
4152
+ mark.kind !== "fermata")
4153
+ )
4154
+ throw new DawgSdkError(
4155
+ `song time[${index}] must come from tempo(), ramp(), rit(), accel(), fermata() or meter()`,
4156
+ );
4157
+ marks.push(mark as TimeMark);
4158
+ }
4159
+ }
4160
+ // Tempo: sort by tick, resolve pins against their neighbours.
4161
+ type Event = {
4162
+ tick: number;
4163
+ bpm?: number;
4164
+ ramp?: TempoCurve;
4165
+ back?: "a-tempo" | "primo";
4166
+ };
4167
+ const byTick = new Map<number, Event>();
4168
+ for (const mark of marks) {
4169
+ if (mark.kind !== "tempo") continue;
4170
+ const tick = ticks(mark.at);
4171
+ const previous = byTick.get(tick);
4172
+ const sets = (e: { bpm?: number; back?: unknown }) =>
4173
+ e.bpm !== undefined || e.back !== undefined;
4174
+ if (previous && sets(previous) && sets(mark)) {
4175
+ // A rit or ramp ending where a tempo change starts (`rit(18, 6, 52)`
4176
+ // with `aTempo(24)`): the ramp lands a tick early, then the step.
4177
+ const ramped = previous.ramp ? previous : mark.ramp ? mark : undefined;
4178
+ const other = ramped === previous ? mark : previous;
4179
+ if (ramped && !other.ramp && tick > 1 && !byTick.has(tick - 1)) {
4180
+ byTick.set(tick - 1, {
4181
+ tick: tick - 1,
4182
+ ...(ramped.bpm !== undefined ? { bpm: ramped.bpm } : {}),
4183
+ ramp: ramped.ramp!,
4184
+ });
4185
+ byTick.set(tick, {
4186
+ tick,
4187
+ ...(other.bpm !== undefined ? { bpm: other.bpm } : {}),
4188
+ ...(other.back ? { back: other.back } : {}),
4189
+ });
4190
+ continue;
4191
+ }
4192
+ throw new DawgSdkError(
4193
+ `song time has two tempo changes at beat ${mark.at}; move one, or end a rit() where the next tempo starts`,
4194
+ );
4195
+ }
4196
+ // A pin and a change on the same beat: the change wins.
4197
+ if (previous && !sets(mark)) continue;
4198
+ byTick.set(tick, {
4199
+ tick,
4200
+ ...(mark.bpm !== undefined ? { bpm: mark.bpm } : {}),
4201
+ ...(mark.ramp ? { ramp: mark.ramp } : {}),
4202
+ ...(mark.back ? { back: mark.back } : {}),
4203
+ });
4204
+ }
4205
+ const events = [...byTick.values()].sort((a, b) => a.tick - b.tick);
4206
+ const tempo: { tick: number; bpm: number; ramp?: TempoCurve }[] = [];
4207
+ events.forEach((event, index) => {
4208
+ if (event.back) {
4209
+ let bpm = tempoBpm;
4210
+ if (event.back === "a-tempo") {
4211
+ let last = -1;
4212
+ tempo.forEach((e, i) => {
4213
+ if (e.ramp !== undefined) last = i;
4214
+ });
4215
+ if (last < 0)
4216
+ throw new DawgSdkError(
4217
+ `aTempo() at beat ${event.tick / ticksPerBeat} has no rit() or accel() before it`,
4218
+ );
4219
+ bpm = last > 0 ? tempo[last - 1]!.bpm : tempoBpm;
4220
+ }
4221
+ tempo.push(Object.freeze({ tick: event.tick, bpm }));
4222
+ return;
4223
+ }
4224
+ if (event.bpm !== undefined) {
4225
+ // rit() and accel() check their direction against the tempo they
4226
+ // start from, as the prompt's `rit` and `accel` do.
4227
+ const mark = marks.find(
4228
+ (m) => m.kind === "tempo" && ticks(m.at) === event.tick && m.gradual,
4229
+ ) as Extract<TimeMark, { kind: "tempo" }> | undefined;
4230
+ if (mark?.gradual) {
4231
+ const from = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
4232
+ if (mark.gradual === "rit" && event.bpm > from)
4233
+ throw new DawgSdkError(
4234
+ `rit() target ${event.bpm} BPM is faster than ${from}; use accel()`,
4235
+ );
4236
+ if (mark.gradual === "accel" && event.bpm < from)
4237
+ throw new DawgSdkError(
4238
+ `accel() target ${event.bpm} BPM is slower than ${from}; use rit()`,
4239
+ );
4240
+ }
4241
+ tempo.push(
4242
+ Object.freeze({
4243
+ tick: event.tick,
4244
+ bpm: event.bpm,
4245
+ ...(event.ramp ? { ramp: event.ramp } : {}),
4246
+ }),
4247
+ );
4248
+ return;
4249
+ }
4250
+ // A pin holds the tempo of the marks before it, and the next ramp
4251
+ // starts from it. Without a ramp after it, it changes nothing.
4252
+ const after = events
4253
+ .slice(index + 1)
4254
+ .find((e) => e.bpm !== undefined || e.back !== undefined);
4255
+ if (!after?.ramp) return;
4256
+ const before = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
4257
+ tempo.push(Object.freeze({ tick: event.tick, bpm: before }));
4258
+ });
4259
+ // Meter: beats to bar indexes, checking each lands on a bar line.
4260
+ const meters = marks
4261
+ .filter((mark) => mark.kind === "meter")
4262
+ .sort((a, b) => a.at - b.at);
4263
+ // `song({ meter: [6, 8] })`: the song meter's note value as a bar-1 change.
4264
+ if (beatUnit !== 4 && !meters.some((mark) => ticks(mark.at) === 0))
4265
+ meters.unshift({ kind: "meter", at: 0, beatsPerBar, beatUnit });
4266
+ const meterOut: { bar: number; beatsPerBar: number; beatUnit?: number }[] =
4267
+ [];
4268
+ let barTick = 0;
4269
+ let barIndex = 0;
4270
+ let barLength = beatsPerBar * ticksPerBeat;
4271
+ for (const mark of meters) {
4272
+ const tick = ticks(mark.at);
4273
+ const bars = (tick - barTick) / barLength;
4274
+ if (!Number.isInteger(bars) || bars < 0)
4275
+ throw new DawgSdkError(`meter() at beat ${mark.at} is not on a bar line`);
4276
+ if (meterOut.length > 0 && bars === 0)
4277
+ throw new DawgSdkError(`song time has two meters at beat ${mark.at}`);
4278
+ barIndex += bars;
4279
+ barTick = tick;
4280
+ barLength = (mark.beatsPerBar * ticksPerBeat * 4) / mark.beatUnit;
4281
+ meterOut.push(
4282
+ Object.freeze({
4283
+ bar: barIndex,
4284
+ beatsPerBar: mark.beatsPerBar,
4285
+ ...(mark.beatUnit !== 4 ? { beatUnit: mark.beatUnit } : {}),
4286
+ }),
4287
+ );
4288
+ }
4289
+ const fermatas = marks
4290
+ .filter((mark) => mark.kind === "fermata")
4291
+ .map((mark) => Object.freeze({ tick: ticks(mark.at), beats: mark.beats }))
4292
+ .sort((a, b) => a.tick - b.tick);
4293
+ for (let i = 1; i < fermatas.length; i += 1)
4294
+ if (fermatas[i]!.tick === fermatas[i - 1]!.tick)
4295
+ throw new DawgSdkError("song time has two fermatas on one beat");
4296
+ // Marks past the song end are never heard; the prompt refuses them too.
4297
+ if (Number.isFinite(bars)) {
4298
+ let end = 0;
4299
+ let fromBar = 0;
4300
+ let length = beatsPerBar * ticksPerBeat;
4301
+ for (const change of meterOut) {
4302
+ if (change.bar >= bars) break;
4303
+ end += (change.bar - fromBar) * length;
4304
+ fromBar = change.bar;
4305
+ length = (change.beatsPerBar * ticksPerBeat * 4) / (change.beatUnit ?? 4);
4306
+ }
4307
+ end += (bars - fromBar) * length;
4308
+ const beatOf = (tick: number) => tick / ticksPerBeat;
4309
+ // A ramp to the final barline (`rit()` over the last bars) lands on
4310
+ // the last tick, the tempo the song ends at.
4311
+ tempo.forEach((event, index) => {
4312
+ if (
4313
+ event.tick === end &&
4314
+ event.ramp &&
4315
+ end - 1 > (tempo[index - 1]?.tick ?? 0)
4316
+ )
4317
+ tempo[index] = Object.freeze({ ...event, tick: end - 1 });
4318
+ });
4319
+ for (const event of tempo)
4320
+ if (event.tick >= end)
4321
+ throw new DawgSdkError(
4322
+ `tempo at beat ${beatOf(event.tick)} is past the song end (${beatOf(end)} beats); add bars`,
4323
+ );
4324
+ for (const change of meterOut)
4325
+ if (change.bar >= bars)
4326
+ throw new DawgSdkError(
4327
+ `meter() at bar ${change.bar + 1} is past the song end (${bars} bars); add bars`,
4328
+ );
4329
+ for (const hold of fermatas)
4330
+ if (hold.tick >= end)
4331
+ throw new DawgSdkError(
4332
+ `fermata() at beat ${beatOf(hold.tick)} is past the song end (${beatOf(end)} beats); add bars`,
4333
+ );
4334
+ }
4335
+ // A fermata may hold its beat at most as long as a MIDI file can write.
4336
+ // The held beat is the meter's felt beat (a dotted quarter in 6/8), as
4337
+ // core/tempo.ts fermataSpan has it.
4338
+ const feltBeats = (tick: number): number => {
4339
+ let at = 0;
4340
+ let fromBar = 0;
4341
+ let meter = { beatsPerBar, beatUnit: 4 };
4342
+ let length = beatsPerBar * ticksPerBeat;
4343
+ for (const change of meterOut) {
4344
+ const start = at + (change.bar - fromBar) * length;
4345
+ if (start > tick) break;
4346
+ at = start;
4347
+ fromBar = change.bar;
4348
+ meter = {
4349
+ beatsPerBar: change.beatsPerBar,
4350
+ beatUnit: change.beatUnit ?? 4,
4351
+ };
4352
+ length = (meter.beatsPerBar * ticksPerBeat * 4) / meter.beatUnit;
4353
+ }
4354
+ if (meterOut.length === 0) return 1;
4355
+ const unit = 4 / meter.beatUnit;
4356
+ const compound =
4357
+ meter.beatUnit >= 8 &&
4358
+ meter.beatsPerBar > 3 &&
4359
+ meter.beatsPerBar % 3 === 0;
4360
+ return Math.max(1, compound ? unit * 3 : unit);
4361
+ };
4362
+ for (const hold of fermatas) {
4363
+ const bpm = bpmAtTick(tempo, tempoBpm, hold.tick);
4364
+ const held = ((1 + hold.beats) * feltBeats(hold.tick) * 60) / bpm;
4365
+ if (held > MAX_FERMATA_SECONDS + 1e-9)
4366
+ throw new DawgSdkError(
4367
+ `fermata() at beat ${hold.tick / ticksPerBeat} holds ${held.toFixed(1)} s; at most ${MAX_FERMATA_SECONDS} s (MIDI tempo limit), so use fewer beats or a faster tempo`,
4368
+ );
4369
+ }
4370
+ const time: Record<string, unknown> = {};
4371
+ if (tempo.length > 0) time.tempo = Object.freeze(tempo);
4372
+ if (meterOut.length > 0) time.meter = Object.freeze(meterOut);
4373
+ if (fermatas.length > 0) time.fermatas = Object.freeze(fermatas);
4374
+ return Object.keys(time).length > 0
4375
+ ? { time: Object.freeze(time) as ScoreTime }
4376
+ : {};
4377
+ }
4378
+
4379
+ // Tuning (SDK 1.16.0)
4380
+
4381
+ /**
4382
+ * A tuning for `song({ tuning })` or `track({ tuning })`: a library name or
4383
+ * an object with at most one table source (`edo`, `ratios`, `cents` or
4384
+ * `scl`). Library names: `12-tet`, `19-edo`, `24-edo`, `31-edo`,
4385
+ * `pythagorean`, `just` (5-limit), `7-limit`, `well-tuned-piano`, `pelog`,
4386
+ * `slendro`, `nyamaropa`, `shruti`, maqam and dastgah sets (`bayati`,
4387
+ * `rast`, `saba`, `shur`, `homayoun`, `chahargah`) and raga intonations
4388
+ * (`yaman`, `bhairav`, `kafi`, `todi`, …); `dawg` lists them with
4389
+ * `/tuning list`. dawg checks every value when the song loads.
4390
+ */
4391
+ export type TuningInput =
4392
+ | string
4393
+ | Readonly<{
4394
+ /** A library tuning, or a label for the table given here. */
4395
+ name?: string;
4396
+ /** Equal divisions of the octave, 1..128. */
4397
+ edo?: number;
4398
+ /** Ratios for degrees 1..n, the last the period: `["9/8", "5/4", "2/1"]`. */
4399
+ ratios?: readonly (string | number)[];
4400
+ /** Cents for degrees 1..n, the last the period: `[240, 480, 720, 960, 1200]`. */
4401
+ cents?: readonly number[];
4402
+ /** A Scala `.scl` file in the project, e.g. `"tunings/slendro.scl"`. */
4403
+ scl?: string;
4404
+ /** A Scala `.kbm` keyboard mapping in the project; it sets its own root and A4. */
4405
+ kbm?: string;
4406
+ /** A4 in Hz, 220..880, default 440. */
4407
+ ref?: number;
4408
+ /** Key of degree 0, `"D4"` or 62; default the song key's tonic in octave 4. */
4409
+ root?: Pitch;
4410
+ /** `linear` (default): one key per step. `nearest`: every key plays the step nearest its 12-TET pitch. */
4411
+ map?: "linear" | "nearest";
4412
+ }>;
4413
+
4414
+ /** A stored tuning: `root` is a MIDI number, `ratios` are strings. */
4415
+ export type ScoreTuning = Readonly<{
4416
+ name?: string;
4417
+ edo?: number;
4418
+ ratios?: readonly string[];
4419
+ cents?: readonly number[];
4420
+ scl?: string;
4421
+ kbm?: string;
4422
+ ref?: number;
4423
+ root?: number;
4424
+ map?: "linear" | "nearest";
4425
+ }>;
4426
+
4427
+ const TUNING_FIELDS: readonly string[] = Object.freeze([
4428
+ "name",
4429
+ "edo",
4430
+ "ratios",
4431
+ "cents",
4432
+ "scl",
4433
+ "kbm",
4434
+ "ref",
4435
+ "root",
4436
+ "map",
4437
+ ]);
4438
+
4439
+ /** Checks a tuning's shape; dawg validates the values when the song loads. */
4440
+ function tuningSpec(
4441
+ input: TuningInput | null | undefined,
4442
+ where: string,
4443
+ ): ScoreTuning | null {
4444
+ if (input === undefined || input === null) return null;
4445
+ if (typeof input === "string") {
4446
+ if (input.trim() === "")
4447
+ throw new DawgSdkError(`${where} tuning must be a name or an object`);
4448
+ return Object.freeze({ name: input.trim() });
4449
+ }
4450
+ if (!isRecord(input))
4451
+ throw new DawgSdkError(`${where} tuning must be a name or an object`);
4452
+ const out: Record<string, unknown> = {};
4453
+ for (const [field, value] of Object.entries(input)) {
4454
+ if (!TUNING_FIELDS.includes(field))
4455
+ throw new DawgSdkError(
4456
+ `${where} tuning has an unknown field "${field}" (use ${TUNING_FIELDS.join(", ")})`,
4457
+ );
4458
+ if (value === undefined || value === null) continue;
4459
+ if (field === "root") out.root = midi(value as Pitch);
4460
+ else if (field === "ratios" || field === "cents") {
4461
+ if (!Array.isArray(value))
4462
+ throw new DawgSdkError(`${where} tuning ${field} must be a list`);
4463
+ out[field] = Object.freeze(
4464
+ field === "ratios" ? value.map((ratio) => String(ratio)) : [...value],
4465
+ );
4466
+ } else out[field] = value;
4467
+ }
4468
+ const sources = ["edo", "ratios", "cents", "scl"].filter(
4469
+ (field) => out[field] !== undefined,
4470
+ );
4471
+ if (sources.length > 1)
4472
+ throw new DawgSdkError(
4473
+ `${where} tuning has ${sources.join(" and ")}; give one table`,
4474
+ );
4475
+ return Object.freeze(out as ScoreTuning);
4476
+ }
4477
+
2248
4478
  // ---------------------------------------------------------------------------
2249
4479
  // Chords
2250
4480
 
@@ -2429,6 +4659,274 @@ export function progression(
2429
4659
  );
2430
4660
  }
2431
4661
 
4662
+ // BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts
4663
+ /** What an instrument word stores on a track. */
4664
+ type InstrumentWord = Readonly<{
4665
+ /** The `Track.instrument` value. */
4666
+ instrument: string;
4667
+ /** The optional Track field the engine reads (created with defaults). */
4668
+ field?: string;
4669
+ /** A preset of that engine to apply. */
4670
+ preset?: string;
4671
+ /** An insert-effect preset to apply with it (rig aliases). */
4672
+ fx?: string;
4673
+ }>;
4674
+
4675
+ /** A word and what it means. */
4676
+ type InstrumentWordRow = Readonly<{
4677
+ word: string;
4678
+ /**
4679
+ * Borrow the voice (instrument, field, preset) of this other word's row
4680
+ * when it exists; `instrument` is the fallback when it does not.
4681
+ */
4682
+ voice?: string;
4683
+ }> &
4684
+ InstrumentWord;
4685
+
4686
+ /**
4687
+ * Words that keep their pre-0.6 meaning forever: they resolve to
4688
+ * themselves, whatever rows the lanes add.
4689
+ */
4690
+ const LEGACY_WORDS: readonly string[] = Object.freeze([
4691
+ "piano",
4692
+ "pluck",
4693
+ "bass",
4694
+ "saw",
4695
+ "square",
4696
+ "triangle",
4697
+ "marimba",
4698
+ "wind",
4699
+ "cello",
4700
+ "contrabass",
4701
+ "ebass",
4702
+ "sitar",
4703
+ "organ",
4704
+ "strings",
4705
+ "bell",
4706
+ "keys",
4707
+ "lead",
4708
+ ]);
4709
+
4710
+ /** 0.6 instrument words; each lane appends its own block. */
4711
+ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
4712
+ // strings (f06-strings): plucked presets of the string engine. Legacy
4713
+ // sitar/ebass keep today's voice (`string preset sitar` reaches the
4714
+ // engine), jangle is the rig alias (the guitar lane maps its 12string to the
4715
+ // preset; `string jangle` reaches it) and
4716
+ // upright is the keys lane's piano (doublebass reaches the preset).
4717
+ { word: "nylon", instrument: "string", field: "string", preset: "nylon" },
4718
+ { word: "steel", instrument: "string", field: "string", preset: "steel" },
4719
+ {
4720
+ word: "electric",
4721
+ instrument: "string",
4722
+ field: "string",
4723
+ preset: "electric",
4724
+ },
4725
+ { word: "slap", instrument: "string", field: "string", preset: "slap" },
4726
+ { word: "motown", instrument: "string", field: "string", preset: "motown" },
4727
+ { word: "tanpura", instrument: "string", field: "string", preset: "tanpura" },
4728
+ {
4729
+ word: "harpsichord",
4730
+ instrument: "string",
4731
+ field: "string",
4732
+ preset: "harpsichord",
4733
+ },
4734
+ { word: "lute", instrument: "string", field: "string", preset: "lute" },
4735
+ { word: "oud", instrument: "string", field: "string", preset: "oud" },
4736
+ { word: "setar", instrument: "string", field: "string", preset: "setar" },
4737
+ { word: "tar", instrument: "string", field: "string", preset: "tar" },
4738
+ { word: "santur", instrument: "string", field: "string", preset: "santur" },
4739
+ {
4740
+ word: "dulcimer",
4741
+ instrument: "string",
4742
+ field: "string",
4743
+ preset: "dulcimer",
4744
+ },
4745
+ { word: "koto", instrument: "string", field: "string", preset: "koto" },
4746
+ { word: "harp", instrument: "string", field: "string", preset: "harp" },
4747
+ { word: "banjo", instrument: "string", field: "string", preset: "banjo" },
4748
+ { word: "tres", instrument: "string", field: "string", preset: "tres" },
4749
+ {
4750
+ word: "requinto",
4751
+ instrument: "string",
4752
+ field: "string",
4753
+ preset: "requinto",
4754
+ },
4755
+ { word: "acoustic", instrument: "string", field: "string", preset: "steel" },
4756
+ { word: "classical", instrument: "string", field: "string", preset: "nylon" },
4757
+ {
4758
+ word: "bassguitar",
4759
+ instrument: "string",
4760
+ field: "string",
4761
+ preset: "ebass",
4762
+ },
4763
+ { word: "fender", instrument: "string", field: "string", preset: "ebass" },
4764
+ {
4765
+ word: "doublebass",
4766
+ instrument: "string",
4767
+ field: "string",
4768
+ preset: "upright",
4769
+ },
4770
+ {
4771
+ word: "cembalo",
4772
+ instrument: "string",
4773
+ field: "string",
4774
+ preset: "harpsichord",
4775
+ },
4776
+ {
4777
+ word: "hammered",
4778
+ instrument: "string",
4779
+ field: "string",
4780
+ preset: "dulcimer",
4781
+ },
4782
+ { word: "sehtar", instrument: "string", field: "string", preset: "setar" },
4783
+ // f06-rig: guitar track aliases, a guitar voice plus a whole rig. The
4784
+ // voice is the strings lane's `electric` row (jangle: its 12-string
4785
+ // `jangle` preset); the pluck only while that row is absent. Never `lead`
4786
+ // or `bass`.
4787
+ {
4788
+ word: "jangle",
4789
+ instrument: "string",
4790
+ field: "string",
4791
+ preset: "jangle",
4792
+ fx: "jangle",
4793
+ },
4794
+ { word: "punk", instrument: "pluck", voice: "electric", fx: "punk" },
4795
+ { word: "funk", instrument: "pluck", voice: "electric", fx: "funk" },
4796
+ { word: "ragged", instrument: "pluck", voice: "electric", fx: "ragged" },
4797
+ { word: "gtr-lead", instrument: "pluck", voice: "electric", fx: "lead" },
4798
+ { word: "gtr-metal", instrument: "pluck", voice: "electric", fx: "metal" },
4799
+ { word: "bachata", instrument: "pluck", voice: "electric", fx: "bachata" },
4800
+ // granular (f06-granular): the instrument and its texture presets. Each
4801
+ // starts from a built-in synth source, so nothing downloads.
4802
+ {
4803
+ word: "granular",
4804
+ instrument: "granular",
4805
+ field: "granular",
4806
+ preset: "cloud",
4807
+ },
4808
+ {
4809
+ word: "grains",
4810
+ instrument: "granular",
4811
+ field: "granular",
4812
+ preset: "cloud",
4813
+ },
4814
+ { word: "cloud", instrument: "granular", field: "granular", preset: "cloud" },
4815
+ {
4816
+ word: "sparkle",
4817
+ instrument: "granular",
4818
+ field: "granular",
4819
+ preset: "sparkle",
4820
+ },
4821
+ { word: "swarm", instrument: "granular", field: "granular", preset: "swarm" },
4822
+ {
4823
+ word: "microloop",
4824
+ instrument: "granular",
4825
+ field: "granular",
4826
+ preset: "microloop",
4827
+ },
4828
+ // keys (f06-piano): modelled pianos. `piano` stays legacy here; the typed
4829
+ // surfaces store a new `piano` as `grand` (core/keys.ts `pianoWrite`).
4830
+ { word: "grand", instrument: "grand", field: "keys", preset: "grand" },
4831
+ { word: "ballad", instrument: "grand", field: "keys", preset: "ballad" },
4832
+ { word: "upright", instrument: "upright", field: "keys", preset: "upright" },
4833
+ { word: "felt", instrument: "felt", field: "keys", preset: "felt" },
4834
+ { word: "lofi", instrument: "felt", field: "keys", preset: "lofi" },
4835
+ {
4836
+ word: "honkytonk",
4837
+ instrument: "honkytonk",
4838
+ field: "keys",
4839
+ preset: "honkytonk",
4840
+ },
4841
+ {
4842
+ word: "prepared",
4843
+ instrument: "prepared",
4844
+ field: "keys",
4845
+ preset: "prepared",
4846
+ },
4847
+ // f06-modal: mallets and bells (core/resonators.ts). `marimba` is legacy;
4848
+ // `modal` alone gives the modal marimba.
4849
+ { word: "modal", instrument: "modal", field: "modal", preset: "marimba" },
4850
+ { word: "vibes", instrument: "modal", field: "modal", preset: "vibes" },
4851
+ { word: "vibraphone", instrument: "modal", field: "modal", preset: "vibes" },
4852
+ {
4853
+ word: "xylophone",
4854
+ instrument: "modal",
4855
+ field: "modal",
4856
+ preset: "xylophone",
4857
+ },
4858
+ { word: "glock", instrument: "modal", field: "modal", preset: "glock" },
4859
+ {
4860
+ word: "glockenspiel",
4861
+ instrument: "modal",
4862
+ field: "modal",
4863
+ preset: "glock",
4864
+ },
4865
+ { word: "celesta", instrument: "modal", field: "modal", preset: "celesta" },
4866
+ { word: "chimes", instrument: "modal", field: "modal", preset: "chimes" },
4867
+ { word: "tubular", instrument: "modal", field: "modal", preset: "chimes" },
4868
+ { word: "kalimba", instrument: "modal", field: "modal", preset: "kalimba" },
4869
+ {
4870
+ word: "thumbpiano",
4871
+ instrument: "modal",
4872
+ field: "modal",
4873
+ preset: "kalimba",
4874
+ },
4875
+ { word: "mbira", instrument: "modal", field: "modal", preset: "mbira" },
4876
+ { word: "steelpan", instrument: "modal", field: "modal", preset: "steelpan" },
4877
+ { word: "bowl", instrument: "modal", field: "modal", preset: "bowl" },
4878
+ { word: "gong", instrument: "modal", field: "modal", preset: "gong" },
4879
+ { word: "gongageng", instrument: "modal", field: "modal", preset: "gong" },
4880
+ { word: "timpani", instrument: "modal", field: "modal", preset: "timpani" },
4881
+ {
4882
+ word: "steeldrum",
4883
+ instrument: "modal",
4884
+ field: "modal",
4885
+ preset: "steelpan",
4886
+ },
4887
+ { word: "singingbowl", instrument: "modal", field: "modal", preset: "bowl" },
4888
+ {
4889
+ word: "kettledrum",
4890
+ instrument: "modal",
4891
+ field: "modal",
4892
+ preset: "timpani",
4893
+ },
4894
+ {
4895
+ word: "tubularbells",
4896
+ instrument: "modal",
4897
+ field: "modal",
4898
+ preset: "chimes",
4899
+ },
4900
+ ]);
4901
+
4902
+ /**
4903
+ * What an instrument word means: a legacy word is itself, a row word is its
4904
+ * row, anything else is undefined (callers keep the word as typed).
4905
+ */
4906
+ function resolveInstrumentWord(word: string): InstrumentWord | undefined {
4907
+ if (LEGACY_WORDS.includes(word)) return Object.freeze({ instrument: word });
4908
+ const row = INSTRUMENT_WORDS.find((entry) => entry.word === word);
4909
+ if (!row) return undefined;
4910
+ const { word: _word, voice, ...meaning } = row;
4911
+ const borrowed =
4912
+ voice === undefined
4913
+ ? undefined
4914
+ : INSTRUMENT_WORDS.find((entry) => entry.word === voice && !entry.voice);
4915
+ if (!borrowed) return Object.freeze(meaning);
4916
+ return Object.freeze({
4917
+ instrument: borrowed.instrument,
4918
+ ...(borrowed.field === undefined ? {} : { field: borrowed.field }),
4919
+ ...(borrowed.preset === undefined ? {} : { preset: borrowed.preset }),
4920
+ ...(meaning.fx === undefined ? {} : { fx: meaning.fx }),
4921
+ });
4922
+ }
4923
+
4924
+ /** The `Track.instrument` value a word stores (the word itself if unknown). */
4925
+ function instrumentForWord(word: string): string {
4926
+ return resolveInstrumentWord(word)?.instrument ?? word;
4927
+ }
4928
+ // END instrument words
4929
+
2432
4930
  // BEGIN chord engine: generated from core/chords.ts by core/sdk/sync-chords.ts
2433
4931
  // ---------------------------------------------------------------------------
2434
4932
  // Vocabulary
@@ -2815,6 +5313,8 @@ const MODES = Object.freeze({
2815
5313
  mixolydian: [0, 2, 4, 5, 7, 9, 10],
2816
5314
  locrian: [0, 1, 3, 5, 6, 8, 10],
2817
5315
  "harmonic-minor": [0, 2, 3, 5, 7, 8, 11],
5316
+ "melodic-minor": [0, 2, 3, 5, 7, 9, 11],
5317
+ "phrygian-dominant": [0, 1, 4, 5, 7, 8, 10],
2818
5318
  } as const);
2819
5319
  type ModeName = keyof typeof MODES;
2820
5320
  const MODE_NAMES = Object.keys(MODES) as ModeName[];
@@ -2837,25 +5337,302 @@ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
2837
5337
  "harmonic-minor": "harmonic-minor",
2838
5338
  "harmonic minor": "harmonic-minor",
2839
5339
  harmonic: "harmonic-minor",
5340
+ "melodic-minor": "melodic-minor",
5341
+ "melodic minor": "melodic-minor",
5342
+ melodic: "melodic-minor",
5343
+ "jazz minor": "melodic-minor",
5344
+ "phrygian-dominant": "phrygian-dominant",
5345
+ "phrygian dominant": "phrygian-dominant",
5346
+ freygish: "phrygian-dominant",
5347
+ spanish: "phrygian-dominant",
5348
+ ajam: "major",
5349
+ mahur: "major",
5350
+ bilawal: "major",
2840
5351
  });
2841
5352
 
2842
- type Key = Readonly<{ tonic: number; mode: ModeName }>;
5353
+ type ScaleFamily =
5354
+ "pentatonic" | "blues" | "maqam" | "dastgah" | "raga" | "messiaen";
5355
+
5356
+ /**
5357
+ * Scales beyond the chord modes, for keys such as `D bayati`, `C yaman` or
5358
+ * `C messiaen-3`. `steps` are semitones above the tonic and may be
5359
+ * fractional (a quarter tone is .5); `mode` is the seven-note mode the
5360
+ * chord engine harmonizes with (the closest one; see DAWG.md). A raga's
5361
+ * `intonation` is each step's traditional just pitch in cents (shruti
5362
+ * offsets), which its named tuning applies (`tuning yaman`): Pythagorean
5363
+ * ati-komal re and dha for Bhairavi, Bhairav, Purvi and Todi, the high
5364
+ * tivra ma (729/512) for Yaman, 9/5 komal ni for Kafi, after Daniélou and
5365
+ * Jairazbhoy. Maqam and dastgah quarter tones follow the 24-tone convention;
5366
+ * Segah and Sikah start on a half-flat note, so their tonic is the key.
5367
+ */
5368
+ type ScaleInfo = Readonly<{
5369
+ steps: readonly number[];
5370
+ mode: ModeName;
5371
+ family: ScaleFamily;
5372
+ intonation?: readonly number[];
5373
+ aliases?: readonly string[];
5374
+ }>;
5375
+
5376
+ const SCALES = Object.freeze({
5377
+ "major-pentatonic": {
5378
+ steps: [0, 2, 4, 7, 9],
5379
+ mode: "major",
5380
+ family: "pentatonic",
5381
+ aliases: ["pentatonic", "major pentatonic", "pent"],
5382
+ },
5383
+ "minor-pentatonic": {
5384
+ steps: [0, 3, 5, 7, 10],
5385
+ mode: "minor",
5386
+ family: "pentatonic",
5387
+ aliases: ["minor pentatonic", "m pentatonic", "min pentatonic"],
5388
+ },
5389
+ blues: {
5390
+ steps: [0, 3, 5, 6, 7, 10],
5391
+ mode: "minor",
5392
+ family: "blues",
5393
+ aliases: ["minor blues"],
5394
+ },
5395
+ "major-blues": {
5396
+ steps: [0, 2, 3, 4, 7, 9],
5397
+ mode: "major",
5398
+ family: "blues",
5399
+ aliases: ["major blues"],
5400
+ },
5401
+ hijaz: {
5402
+ steps: [0, 1, 4, 5, 7, 8, 10],
5403
+ mode: "phrygian-dominant",
5404
+ family: "maqam",
5405
+ },
5406
+ bayati: {
5407
+ steps: [0, 1.5, 3, 5, 7, 8, 10],
5408
+ mode: "phrygian",
5409
+ family: "maqam",
5410
+ },
5411
+ rast: { steps: [0, 2, 3.5, 5, 7, 9, 10.5], mode: "major", family: "maqam" },
5412
+ saba: { steps: [0, 1.5, 3, 4, 7, 8, 10], mode: "phrygian", family: "maqam" },
5413
+ kurd: { steps: [0, 1, 3, 5, 7, 8, 10], mode: "phrygian", family: "maqam" },
5414
+ nahawand: {
5415
+ steps: [0, 2, 3, 5, 7, 8, 11],
5416
+ mode: "harmonic-minor",
5417
+ family: "maqam",
5418
+ },
5419
+ sikah: {
5420
+ steps: [0, 1.5, 3.5, 5.5, 7, 8.5, 10.5],
5421
+ mode: "phrygian",
5422
+ family: "maqam",
5423
+ aliases: ["sika"],
5424
+ },
5425
+ huzam: {
5426
+ steps: [0, 1.5, 3.5, 4.5, 7.5, 8.5, 10.5],
5427
+ mode: "phrygian",
5428
+ family: "maqam",
5429
+ aliases: ["houzam"],
5430
+ },
5431
+ nikriz: { steps: [0, 2, 3, 6, 7, 9, 10], mode: "dorian", family: "maqam" },
5432
+ shur: {
5433
+ steps: [0, 1.5, 3, 5, 7, 8, 10],
5434
+ mode: "phrygian",
5435
+ family: "dastgah",
5436
+ },
5437
+ homayoun: {
5438
+ steps: [0, 1.5, 4, 5, 7, 8, 10],
5439
+ mode: "phrygian-dominant",
5440
+ family: "dastgah",
5441
+ aliases: ["homayun"],
5442
+ },
5443
+ chahargah: {
5444
+ steps: [0, 1.5, 4, 5, 7, 8.5, 11],
5445
+ mode: "phrygian-dominant",
5446
+ family: "dastgah",
5447
+ aliases: ["chahar-gah"],
5448
+ },
5449
+ segah: {
5450
+ steps: [0, 1.5, 3.5, 5, 6.5, 8.5, 10.5],
5451
+ mode: "phrygian",
5452
+ family: "dastgah",
5453
+ aliases: ["sehgah", "se-gah"],
5454
+ },
5455
+ nava: {
5456
+ steps: [0, 2, 3.5, 5, 7, 8, 10],
5457
+ mode: "minor",
5458
+ family: "dastgah",
5459
+ },
5460
+ yaman: {
5461
+ steps: [0, 2, 4, 6, 7, 9, 11],
5462
+ mode: "lydian",
5463
+ family: "raga",
5464
+ intonation: [0, 203.91, 386.31, 611.73, 701.96, 884.36, 1088.27],
5465
+ aliases: ["kalyan", "yaman kalyan"],
5466
+ },
5467
+ bhairav: {
5468
+ steps: [0, 1, 4, 5, 7, 8, 11],
5469
+ mode: "phrygian-dominant",
5470
+ family: "raga",
5471
+ intonation: [0, 90.22, 386.31, 498.04, 701.96, 792.18, 1088.27],
5472
+ },
5473
+ kafi: {
5474
+ steps: [0, 2, 3, 5, 7, 9, 10],
5475
+ mode: "dorian",
5476
+ family: "raga",
5477
+ intonation: [0, 203.91, 315.64, 498.04, 701.96, 884.36, 1017.6],
5478
+ },
5479
+ bhairavi: {
5480
+ steps: [0, 1, 3, 5, 7, 8, 10],
5481
+ mode: "phrygian",
5482
+ family: "raga",
5483
+ intonation: [0, 90.22, 294.13, 498.04, 701.96, 792.18, 996.09],
5484
+ },
5485
+ asavari: {
5486
+ steps: [0, 2, 3, 5, 7, 8, 10],
5487
+ mode: "minor",
5488
+ family: "raga",
5489
+ intonation: [0, 203.91, 315.64, 498.04, 701.96, 813.69, 996.09],
5490
+ },
5491
+ khamaj: {
5492
+ steps: [0, 2, 4, 5, 7, 9, 10],
5493
+ mode: "mixolydian",
5494
+ family: "raga",
5495
+ intonation: [0, 203.91, 386.31, 498.04, 701.96, 884.36, 996.09],
5496
+ },
5497
+ todi: {
5498
+ steps: [0, 1, 3, 6, 7, 8, 11],
5499
+ mode: "phrygian",
5500
+ family: "raga",
5501
+ intonation: [0, 95, 294, 606, 702, 792, 1107],
5502
+ },
5503
+ purvi: {
5504
+ steps: [0, 1, 4, 6, 7, 8, 11],
5505
+ mode: "phrygian-dominant",
5506
+ family: "raga",
5507
+ intonation: [0, 90.22, 386.31, 590.22, 701.96, 792.18, 1088.27],
5508
+ },
5509
+ marwa: {
5510
+ steps: [0, 1, 4, 6, 9, 11],
5511
+ mode: "lydian",
5512
+ family: "raga",
5513
+ intonation: [0, 111.73, 386.31, 590.22, 884.36, 1088.27],
5514
+ },
5515
+ darbari: {
5516
+ steps: [0, 2, 3, 5, 7, 8, 10],
5517
+ mode: "minor",
5518
+ family: "raga",
5519
+ intonation: [0, 203.91, 294.13, 498.04, 701.96, 792.18, 996.09],
5520
+ aliases: ["darbari kanada"],
5521
+ },
5522
+ malkauns: {
5523
+ steps: [0, 3, 5, 8, 10],
5524
+ mode: "minor",
5525
+ family: "raga",
5526
+ intonation: [0, 315.64, 498.04, 813.69, 996.09],
5527
+ },
5528
+ bhupali: {
5529
+ steps: [0, 2, 4, 7, 9],
5530
+ mode: "major",
5531
+ family: "raga",
5532
+ intonation: [0, 203.91, 386.31, 701.96, 884.36],
5533
+ },
5534
+ durga: {
5535
+ steps: [0, 2, 5, 7, 9],
5536
+ mode: "major",
5537
+ family: "raga",
5538
+ intonation: [0, 203.91, 498.04, 701.96, 884.36],
5539
+ },
5540
+ "messiaen-1": {
5541
+ steps: [0, 2, 4, 6, 8, 10],
5542
+ mode: "lydian",
5543
+ family: "messiaen",
5544
+ aliases: ["whole-tone", "whole tone", "wholetone"],
5545
+ },
5546
+ "messiaen-2": {
5547
+ steps: [0, 1, 3, 4, 6, 7, 9, 10],
5548
+ mode: "mixolydian",
5549
+ family: "messiaen",
5550
+ aliases: ["octatonic", "diminished", "half-whole"],
5551
+ },
5552
+ "messiaen-3": {
5553
+ steps: [0, 2, 3, 4, 6, 7, 8, 10, 11],
5554
+ mode: "minor",
5555
+ family: "messiaen",
5556
+ },
5557
+ "messiaen-4": {
5558
+ steps: [0, 1, 2, 5, 6, 7, 8, 11],
5559
+ mode: "harmonic-minor",
5560
+ family: "messiaen",
5561
+ },
5562
+ "messiaen-5": {
5563
+ steps: [0, 1, 5, 6, 7, 11],
5564
+ mode: "lydian",
5565
+ family: "messiaen",
5566
+ },
5567
+ "messiaen-6": {
5568
+ steps: [0, 2, 4, 5, 6, 8, 10, 11],
5569
+ mode: "major",
5570
+ family: "messiaen",
5571
+ },
5572
+ "messiaen-7": {
5573
+ steps: [0, 1, 2, 3, 5, 6, 7, 8, 9, 11],
5574
+ mode: "harmonic-minor",
5575
+ family: "messiaen",
5576
+ },
5577
+ } as const satisfies Record<string, ScaleInfo>);
5578
+ type ScaleName = keyof typeof SCALES;
5579
+ const SCALE_NAMES = Object.keys(SCALES) as ScaleName[];
5580
+
5581
+ const SCALE_ALIASES: Readonly<Record<string, ScaleName>> = (() => {
5582
+ const aliases: Record<string, ScaleName> = {};
5583
+ for (const name of SCALE_NAMES) {
5584
+ const info: ScaleInfo = SCALES[name];
5585
+ aliases[name] = name;
5586
+ aliases[name.replace(/-/g, " ")] = name;
5587
+ for (const alias of info.aliases ?? []) aliases[alias] = name;
5588
+ }
5589
+ return Object.freeze(aliases);
5590
+ })();
5591
+
5592
+ /** The library scale named `text` (case and `-`/space insensitive). */
5593
+ function scaleNamed(text: string): ScaleName | undefined {
5594
+ const word = text.trim().toLowerCase().replace(/\s+/g, " ");
5595
+ return SCALE_ALIASES[word] ?? SCALE_ALIASES[word.replace(/ /g, "-")];
5596
+ }
5597
+
5598
+ /**
5599
+ * A key: a tonic and the seven-note `mode` the chord engine uses, plus the
5600
+ * library `scale` when the key names one (`D bayati`, `C yaman`).
5601
+ */
5602
+ type Key = Readonly<{
5603
+ tonic: number;
5604
+ mode: ModeName;
5605
+ scale?: ScaleName;
5606
+ }>;
2843
5607
 
2844
5608
  /**
2845
- * Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`.
2846
- * Accepts the `<note> <mode>` form `core/key.ts` writes.
5609
+ * Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`,
5610
+ * `D bayati`, `C messiaen-3`. Accepts the `<note> <mode>` form
5611
+ * `core/key.ts` writes.
2847
5612
  */
2848
5613
  function parseKey(text: string | null | undefined): Key | undefined {
2849
5614
  if (typeof text !== "string" || text.length > 40) return undefined;
2850
5615
  const match = text
2851
5616
  .trim()
2852
- .match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z -]*)?$/);
5617
+ .match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z0-9 -]*)?$/);
2853
5618
  if (!match) return undefined;
2854
5619
  const tonic = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
5620
+ if (tonic === undefined) return undefined;
2855
5621
  const word = (match[3] ?? "").trim();
2856
5622
  const mode = MODE_ALIASES[word === "m" ? "m" : word.toLowerCase()];
2857
- if (tonic === undefined || mode === undefined) return undefined;
2858
- return Object.freeze({ tonic, mode });
5623
+ if (mode !== undefined) return Object.freeze({ tonic, mode });
5624
+ const scale = scaleNamed(word);
5625
+ if (scale === undefined) return undefined;
5626
+ return Object.freeze({ tonic, mode: SCALES[scale].mode, scale });
5627
+ }
5628
+
5629
+ /**
5630
+ * Steps of the key's whole scale in semitones above the tonic (fractional
5631
+ * for quarter tones): the library scale when the key names one, else the
5632
+ * mode.
5633
+ */
5634
+ function scaleSteps(key: Key): number[] {
5635
+ return [...(key.scale ? SCALES[key.scale].steps : MODES[key.mode])];
2859
5636
  }
2860
5637
 
2861
5638
  /** True when names in the key read better with flats (F, Bb, Eb, d minor…). */
@@ -2870,6 +5647,8 @@ function keyUsesFlats(key: Key): boolean {
2870
5647
  minor: 9,
2871
5648
  locrian: 11,
2872
5649
  "harmonic-minor": 9,
5650
+ "melodic-minor": 9,
5651
+ "phrygian-dominant": 4,
2873
5652
  };
2874
5653
  const parent = mod12(key.tonic - parentOffset[key.mode]);
2875
5654
  return [5, 10, 3, 8, 1].includes(parent);
@@ -2877,7 +5656,7 @@ function keyUsesFlats(key: Key): boolean {
2877
5656
 
2878
5657
  /** `C major`, `F# dorian`, `Bb minor`. */
2879
5658
  function keyName(key: Key): string {
2880
- return `${noteName(key.tonic, keyUsesFlats(key))} ${key.mode}`;
5659
+ return `${noteName(key.tonic, keyUsesFlats(key))} ${key.scale ?? key.mode}`;
2881
5660
  }
2882
5661
 
2883
5662
  /** Pitch classes of the key's scale, tonic first. */