@hraness/dawg 0.4.1 → 0.5.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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. 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.19.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 }>;
173
245
  }>;
174
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 }>;
254
+ }>;
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
 
@@ -1458,6 +1677,21 @@ export type TrackInput = Readonly<{
1458
1677
  * voices.
1459
1678
  */
1460
1679
  kit?: string;
1680
+ /**
1681
+ * The track's own clock against the song (SDK 1.14.0): `rate` 1.5 plays
1682
+ * three beats in two, `phase` starts it that many beats later, `cycle`
1683
+ * repeats its first `cycle` beats (a 3-beat cycle over 4/4 is polymeter).
1684
+ * `{ cycle: 3, rate: 13 / 12 }` drifts against a twin and realigns, the
1685
+ * tape phasing of Reich's Come Out; `phasing()` works the rate out for
1686
+ * you, and `stepPhasing()` gives Piano Phase's shift and hold.
1687
+ */
1688
+ time?: TrackTimeInput;
1689
+ /**
1690
+ * This track's tuning over the song's (SDK 1.16.0): a library name such
1691
+ * as `"pelog"` or `{ edo, ratios, cents, scl, kbm, ref, root, map }`.
1692
+ * `{ ref: 432 }` alone keeps the song's table at another pitch.
1693
+ */
1694
+ tuning?: TuningInput | null;
1461
1695
  /** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
1462
1696
  synth?: SynthInput;
1463
1697
  muted?: boolean;
@@ -1476,6 +1710,34 @@ export type TrackInput = Readonly<{
1476
1710
  /** Insert effects by name (`{ distort: { drive: 3 }, chorus: {} }`). */
1477
1711
  fx?: FxInput;
1478
1712
  automation?: AutomationInput;
1713
+ /**
1714
+ * Glide between notes (SDK 1.15.0): seconds (`glide: 0.08`, TB-303 style
1715
+ * legato) or `{ time, mode }`. Mode `legato` glides only into a note that
1716
+ * overlaps the previous one and does not retrigger it; `mono` always
1717
+ * glides and retriggers; `poly` glides every voice of a chord from the
1718
+ * matching voice of the previous one.
1719
+ */
1720
+ glide?: number | Readonly<{ time?: number; mode?: GlideMode }>;
1721
+ /** Sustain pedal changes as `[beat, "down" | "half" | "up"]` (SDK 1.15.0). */
1722
+ pedal?: readonly (readonly [number, PedalState])[];
1723
+ /**
1724
+ * Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
1725
+ * (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
1726
+ * `{ curve: "fixed", fixed: 0.6 }`. Default `linear`.
1727
+ */
1728
+ velocityCurve?:
1729
+ VelocityCurveName | Readonly<{ curve: VelocityCurveName; fixed?: number }>;
1730
+ /**
1731
+ * Seeded humanize applied when dawg renders, so the notes stay as written
1732
+ * (SDK 1.15.0): `timing` ms either side, `velocity` and `length` in
1733
+ * percent, `seed` (default 1) picks another take.
1734
+ */
1735
+ humanize?: Readonly<{
1736
+ timing?: number;
1737
+ velocity?: number;
1738
+ length?: number;
1739
+ seed?: number;
1740
+ }>;
1479
1741
  /** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
1480
1742
  notes?: readonly (NoteSpec | HitSpec)[];
1481
1743
  /**
@@ -1533,6 +1795,22 @@ export type ReverbInput = Readonly<{
1533
1795
  }>;
1534
1796
  }>;
1535
1797
 
1798
+ /** `track({ time })`: every field optional; absent follows the song. */
1799
+ export type TrackTimeInput = Readonly<{
1800
+ /** Tempo ratio against the song, 0.125..8 (1 = in step). */
1801
+ rate?: number;
1802
+ /** Beats the track's pattern starts late (negative: early); it wraps. */
1803
+ phase?: number;
1804
+ /** Beats of the track that repeat, default the whole song loop. */
1805
+ cycle?: number;
1806
+ /**
1807
+ * Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: hold `hold`
1808
+ * cycles in step, then move `shift` beats ahead over `drift` cycles, and
1809
+ * repeat. Needs `cycle`; replaces `rate`. `stepPhasing()` builds it.
1810
+ */
1811
+ steps?: Readonly<{ shift: number; hold: number; drift: number }>;
1812
+ }>;
1813
+
1536
1814
  /** Frozen track built by `track()`; `song()` consumes it. Beats, not ticks. */
1537
1815
  export type TrackSpec = Readonly<{
1538
1816
  kind: "track";
@@ -1561,8 +1839,133 @@ export type TrackSpec = Readonly<{
1561
1839
  /** Rhythm rows in order (voice names as written). */
1562
1840
  rhythm: readonly RhythmSpec[];
1563
1841
  kit: string | null;
1842
+ /** Present only when `track({ time })` set something. */
1843
+ time?: TrackTimeInput;
1844
+ /** Performance (SDK 1.15.0); present only when set. */
1845
+ glide?: Readonly<{ time: number; mode: GlideMode }>;
1846
+ pedal?: readonly (readonly [number, PedalState])[];
1847
+ velocityCurve?: Readonly<{
1848
+ curve: Exclude<VelocityCurveName, "linear">;
1849
+ fixed?: number;
1850
+ }>;
1851
+ humanize?: Readonly<{
1852
+ timing?: number;
1853
+ velocity?: number;
1854
+ length?: number;
1855
+ seed: number;
1856
+ }>;
1857
+ tuning: ScoreTuning | null;
1564
1858
  }>;
1565
1859
 
1860
+ export type GlideMode = "legato" | "mono" | "poly";
1861
+ export type PedalState = "down" | "half" | "up";
1862
+ export type VelocityCurveName = "linear" | "soft" | "hard" | "fixed";
1863
+
1864
+ const DEFAULT_GLIDE_SECONDS = 0.06;
1865
+ const DEFAULT_FIXED_VELOCITY = 0.8;
1866
+
1867
+ /** Normalizes `track()` performance options; dawg validates the ranges. */
1868
+ function trackPerformance(
1869
+ input: TrackInput,
1870
+ name: string,
1871
+ ): Partial<Pick<TrackSpec, "glide" | "pedal" | "velocityCurve" | "humanize">> {
1872
+ const out: {
1873
+ glide?: TrackSpec["glide"];
1874
+ pedal?: TrackSpec["pedal"];
1875
+ velocityCurve?: TrackSpec["velocityCurve"];
1876
+ humanize?: TrackSpec["humanize"];
1877
+ } = {};
1878
+ if (input.glide !== undefined) {
1879
+ const raw =
1880
+ typeof input.glide === "number" ? { time: input.glide } : input.glide;
1881
+ if (!isRecord(raw))
1882
+ throw new DawgSdkError(
1883
+ `track ${name}: glide must be seconds or { time, mode }`,
1884
+ );
1885
+ const mode = raw.mode ?? "legato";
1886
+ if (!["legato", "mono", "poly"].includes(mode))
1887
+ throw new DawgSdkError(
1888
+ `track ${name}: glide mode must be legato, mono or poly`,
1889
+ );
1890
+ out.glide = Object.freeze({
1891
+ time: finite(raw.time ?? DEFAULT_GLIDE_SECONDS, `${name} glide time`),
1892
+ mode,
1893
+ });
1894
+ }
1895
+ if (input.pedal !== undefined) {
1896
+ if (!Array.isArray(input.pedal) || input.pedal.length > 1024)
1897
+ throw new DawgSdkError(
1898
+ `track ${name}: pedal must be at most 1024 [beat, "down" | "half" | "up"] events`,
1899
+ );
1900
+ if (input.pedal.length > 0)
1901
+ out.pedal = Object.freeze(
1902
+ input.pedal.map((event: unknown, index: number) => {
1903
+ if (
1904
+ !Array.isArray(event) ||
1905
+ event.length !== 2 ||
1906
+ !["down", "half", "up"].includes(event[1] as string)
1907
+ )
1908
+ throw new DawgSdkError(
1909
+ `track ${name}: pedal[${index}] must be [beat, "down" | "half" | "up"]`,
1910
+ );
1911
+ return Object.freeze([
1912
+ beat(event[0], `${name} pedal[${index}] beat`),
1913
+ event[1] as PedalState,
1914
+ ] as const);
1915
+ }),
1916
+ );
1917
+ }
1918
+ if (input.velocityCurve !== undefined) {
1919
+ const raw =
1920
+ typeof input.velocityCurve === "string"
1921
+ ? { curve: input.velocityCurve }
1922
+ : input.velocityCurve;
1923
+ if (
1924
+ !isRecord(raw) ||
1925
+ !["linear", "soft", "hard", "fixed"].includes(raw.curve as string)
1926
+ )
1927
+ throw new DawgSdkError(
1928
+ `track ${name}: velocityCurve must be linear, soft, hard or fixed`,
1929
+ );
1930
+ if (raw.curve === "fixed")
1931
+ out.velocityCurve = Object.freeze({
1932
+ curve: "fixed",
1933
+ fixed: unit(
1934
+ raw.fixed ?? DEFAULT_FIXED_VELOCITY,
1935
+ `${name} velocityCurve fixed`,
1936
+ ),
1937
+ });
1938
+ else if (raw.curve !== "linear")
1939
+ out.velocityCurve = Object.freeze({ curve: raw.curve });
1940
+ }
1941
+ if (input.humanize !== undefined) {
1942
+ if (!isRecord(input.humanize))
1943
+ throw new DawgSdkError(
1944
+ `track ${name}: humanize must be { timing, velocity, length, seed }`,
1945
+ );
1946
+ const amount = (key: "timing" | "velocity" | "length") => {
1947
+ const value = input.humanize![key];
1948
+ return value === undefined ? 0 : finite(value, `${name} humanize ${key}`);
1949
+ };
1950
+ const timing = amount("timing");
1951
+ const velocity = amount("velocity");
1952
+ const length = amount("length");
1953
+ const seed = input.humanize.seed ?? 1;
1954
+ if (!Number.isInteger(seed) || seed < 0)
1955
+ throw new DawgSdkError(
1956
+ `track ${name}: humanize seed must be an integer ≥ 0`,
1957
+ );
1958
+ if (timing !== 0 || velocity !== 0 || length !== 0)
1959
+ out.humanize = Object.freeze({
1960
+ ...(timing !== 0 ? { timing } : {}),
1961
+ ...(velocity !== 0 ? { velocity } : {}),
1962
+ ...(length !== 0 ? { length } : {}),
1963
+ seed,
1964
+ });
1965
+ }
1966
+ return out;
1967
+ }
1968
+
1566
1969
  /**
1567
1970
  * A raw ZzFX parameter array (Strudel `zzfx([...])`, ZzFX's own layout:
1568
1971
  * volume, randomness, frequency, attack, sustain, release, shape,
@@ -1703,13 +2106,8 @@ export function track(input: TrackInput): TrackSpec {
1703
2106
  ? `track ${name}: unknown sampler voice "${spec.voice}" (${[...slots.keys()].join(" ")})`
1704
2107
  : `track ${name}: hit("${spec.voice}") needs instrument "kit" or sampler(...)`,
1705
2108
  );
1706
- return Object.freeze({
1707
- kind: "note" as const,
1708
- pitch,
1709
- start: spec.start,
1710
- length: spec.length,
1711
- velocity: spec.velocity,
1712
- });
2109
+ const { kind: _kind, voice: _voice, ...rest } = spec;
2110
+ return Object.freeze({ ...rest, kind: "note" as const, pitch });
1713
2111
  });
1714
2112
  if (notes.length > 4096)
1715
2113
  throw new DawgSdkError(`track ${name}: at most 4096 notes`);
@@ -1834,9 +2232,74 @@ export function track(input: TrackInput): TrackSpec {
1834
2232
  notes: Object.freeze(notes),
1835
2233
  rhythm: Object.freeze([...rhythm]),
1836
2234
  kit: drumKit === null ? null : drumKit.trim(),
2235
+ ...trackTime(input.time, name),
2236
+ ...trackPerformance(input, name),
2237
+ tuning: tuningSpec(input.tuning, `track ${name}`),
1837
2238
  });
1838
2239
  }
1839
2240
 
2241
+ function trackTime(input: unknown, name: string): { time?: TrackTimeInput } {
2242
+ if (input === undefined || input === null) return {};
2243
+ if (!isRecord(input))
2244
+ throw new DawgSdkError(`track ${name}: time must be an object`);
2245
+ for (const key of Object.keys(input))
2246
+ if (key !== "rate" && key !== "phase" && key !== "cycle" && key !== "steps")
2247
+ throw new DawgSdkError(
2248
+ `track ${name}: time takes rate, phase, cycle and steps, not ${key}`,
2249
+ );
2250
+ const out: {
2251
+ rate?: number;
2252
+ phase?: number;
2253
+ cycle?: number;
2254
+ steps?: { shift: number; hold: number; drift: number };
2255
+ } = {};
2256
+ if (input.rate !== undefined) {
2257
+ const rate = finite(input.rate, `track ${name} time.rate`);
2258
+ if (rate < 0.125 || rate > 8)
2259
+ throw new DawgSdkError(`track ${name}: time.rate must be 0.125..8`);
2260
+ if (rate !== 1) out.rate = rate;
2261
+ }
2262
+ if (input.phase !== undefined) {
2263
+ const phase = finite(input.phase, `track ${name} time.phase`);
2264
+ if (phase !== 0) out.phase = phase;
2265
+ }
2266
+ if (input.cycle !== undefined) {
2267
+ const cycle = finite(input.cycle, `track ${name} time.cycle`);
2268
+ if (cycle <= 0)
2269
+ throw new DawgSdkError(`track ${name}: time.cycle must be > 0 beats`);
2270
+ out.cycle = cycle;
2271
+ }
2272
+ if (input.steps !== undefined) {
2273
+ if (!isRecord(input.steps))
2274
+ throw new DawgSdkError(`track ${name}: time.steps must be an object`);
2275
+ if (out.cycle === undefined)
2276
+ throw new DawgSdkError(`track ${name}: time.steps needs a cycle`);
2277
+ if (out.rate !== undefined)
2278
+ throw new DawgSdkError(
2279
+ `track ${name}: time takes rate or steps, not both`,
2280
+ );
2281
+ out.steps = phaseSteps(input.steps, out.cycle, `track ${name} time.steps`);
2282
+ }
2283
+ return Object.keys(out).length > 0 ? { time: Object.freeze(out) } : {};
2284
+ }
2285
+
2286
+ function phaseSteps(
2287
+ input: Record<string, unknown>,
2288
+ cycle: number,
2289
+ label: string,
2290
+ ): Readonly<{ shift: number; hold: number; drift: number }> {
2291
+ const shift = positive(input.shift, `${label}.shift`);
2292
+ if (shift > cycle)
2293
+ throw new DawgSdkError(`${label}.shift must be at most the cycle`);
2294
+ const hold = finite(input.hold, `${label}.hold`);
2295
+ if (!Number.isInteger(hold) || hold < 0 || hold > 64)
2296
+ throw new DawgSdkError(`${label}.hold must be a whole 0..64 cycles`);
2297
+ const drift = finite(input.drift, `${label}.drift`);
2298
+ if (!Number.isInteger(drift) || drift < 1 || drift > 64)
2299
+ throw new DawgSdkError(`${label}.drift must be a whole 1..64 cycles`);
2300
+ return Object.freeze({ shift, hold, drift });
2301
+ }
2302
+
1840
2303
  const AUTOMATION_KEYS: readonly (keyof AutomationInput)[] = Object.freeze([
1841
2304
  "volume",
1842
2305
  "pan",
@@ -2018,19 +2481,103 @@ function localizeSampler(spec: SamplerSpec, slug: string): SamplerSpec {
2018
2481
  export type SongInput = Readonly<{
2019
2482
  /** BPM 20..300, default 120. */
2020
2483
  tempo?: number;
2021
- /** `[beatsPerBar, noteValue]` or just `beatsPerBar`; default `[4, 4]`. Only the numerator is stored. */
2484
+ /**
2485
+ * `[beatsPerBar, noteValue]` or just `beatsPerBar`; default `[4, 4]`.
2486
+ * A note value other than 4 is stored as a meter change at bar 1, so
2487
+ * `[6, 8]` is six eighths (three quarter-note beats) per bar.
2488
+ */
2022
2489
  meter?: readonly [number, number] | number;
2023
2490
  /** Loop length in bars, 1..256, default 4. */
2024
2491
  bars?: number;
2025
- /** Free text such as `"A minor"`, or null. */
2492
+ /**
2493
+ * Free text such as `"A minor"`, or null. A scale name after the tonic
2494
+ * picks a scale: `"D dorian"`, `"E hijaz"`, `"C yaman"`, `"C messiaen-3"`.
2495
+ */
2026
2496
  key?: string | null;
2497
+ /**
2498
+ * Song tuning (SDK 1.16.0), default 12-TET at A4 = 440 Hz: a library name
2499
+ * (`"19-edo"`, `"just"`, `"pelog"`, `"yaman"`) or `{ edo, ratios, cents,
2500
+ * scl, kbm, ref, root, map }`. See `TuningInput`.
2501
+ */
2502
+ tuning?: TuningInput | null;
2027
2503
  /** Integer ticks per beat, default 480. Leave it alone unless you know why. */
2028
2504
  ticksPerBeat?: number;
2505
+ /**
2506
+ * Tempo changes, meter changes and fermatas (SDK 1.14.0), in any order:
2507
+ * `tempo()`, `ramp()`, `rit()`, `accel()`, `fermata()` and `meter()`.
2508
+ * `tempo` above stays the opening tempo and `meter` the opening meter.
2509
+ * `rit()` and `accel()` return two marks; list them as they come.
2510
+ */
2511
+ time?: readonly (TimeMark | readonly TimeMark[])[];
2029
2512
  /** Tracks in score order; each from `track()`. */
2030
2513
  tracks: readonly TrackSpec[];
2514
+ /** Master chain and loudness target after every track and orbit bus (SDK 1.17.0); omit for none. */
2515
+ master?: MasterInput;
2516
+ /**
2517
+ * Named bar ranges (SDK 1.18.0): `{ name: "chorus", startBar: 8, bars: 8 }`,
2518
+ * optionally with `mute: ["pad"]` and `vary: { lead: { transpose: 12 } }`.
2519
+ */
2520
+ sections?: readonly SongSection[];
2521
+ /**
2522
+ * The order sections play, with repeats (SDK 1.18.0): `"intro verse
2523
+ * chorus*2 outro"`, or `["intro", { section: "chorus", repeat: 2 }]`.
2524
+ * Absent plays the bars straight through.
2525
+ */
2526
+ form?: string | readonly (string | SongFormEntry)[];
2527
+ /** The section playback loops (SDK 1.18.0); export ignores it. */
2528
+ loopSection?: string;
2529
+ }>;
2530
+
2531
+ /** A song section (SDK 1.18.0); bars are 0-based like beats. */
2532
+ export type SongSection = Readonly<{
2533
+ /** `intro`, `verse`, `chorus 2`, `A`: 1..32 characters, unique ignoring case. */
2534
+ name: string;
2535
+ /** First bar, 0-based. */
2536
+ startBar: number;
2537
+ /** Length in bars, at least 1. */
2538
+ bars: number;
2539
+ /** Track ids silent in this section. */
2540
+ mute?: readonly string[];
2541
+ /** Per-track changes in this section: semitones and a velocity multiplier. */
2542
+ vary?: Readonly<
2543
+ Record<string, Readonly<{ transpose?: number; gain?: number }>>
2544
+ >;
2545
+ }>;
2546
+
2547
+ /** One step of the song form (SDK 1.18.0). */
2548
+ export type SongFormEntry = Readonly<{ section: string; repeat?: number }>;
2549
+
2550
+ /**
2551
+ * The song master (SDK 1.17.0), processed in the fixed order
2552
+ * eq → glue → tape → width → limiter after the tracks and orbit buses are
2553
+ * summed. Each unit present is on; `{}` takes every default. `target` is
2554
+ * an integrated loudness in LUFS (ITU-R BS.1770-4), -40..-3: renders drive
2555
+ * the limiter (or, without one, a clean gain) to reach it. Streaming is
2556
+ * -14, club -8, loud hyperpop or gabber -6, classical -20, broadcast -23.
2557
+ * The SDK takes LUFS numbers only: a target name such as `master target
2558
+ * club` in the prompt also sets a limiter preset, so write that unit out.
2559
+ * DAWG.md "Master and loudness" lists every parameter with its range.
2560
+ *
2561
+ * ```ts
2562
+ * master: { glue: { ratio: 2 }, limiter: { ceiling: -1 }, target: -14 }
2563
+ * ```
2564
+ */
2565
+ export type MasterInput = Readonly<{
2566
+ /** `low`/`high` shelves and `bell1`/`bell2` gains in dB, with `…freq` and `…q`. */
2567
+ eq?: EffectParams;
2568
+ /** Bus compressor: threshold, ratio, attack, release (ms), knee, makeup, mix, hpf. */
2569
+ glue?: EffectParams;
2570
+ /** Saturation: drive (dB), bias, tone (Hz), mix. */
2571
+ tape?: EffectParams;
2572
+ /** Stereo width 0..2 (1 unchanged) and `mono` bass below this many Hz. */
2573
+ width?: EffectParams;
2574
+ /** True-peak limiter: ceiling (dBTP), gain, release, lookahead (ms), truepeak. */
2575
+ limiter?: EffectParams;
2576
+ /** Integrated loudness target in LUFS (a negative number, -40..-3). */
2577
+ target?: number;
2031
2578
  }>;
2032
2579
 
2033
- /** A stored note: integer ticks. */
2580
+ /** A stored note: integer ticks; expression fields only when set. */
2034
2581
  export type ScoreNote = Readonly<{
2035
2582
  id: string;
2036
2583
  trackId: string;
@@ -2038,6 +2585,13 @@ export type ScoreNote = Readonly<{
2038
2585
  durationTicks: number;
2039
2586
  pitch: number;
2040
2587
  velocity: number;
2588
+ articulation?: Articulation;
2589
+ glide?: number;
2590
+ bend?: readonly Readonly<{ at: number; cents: number }>[];
2591
+ vibrato?: Readonly<{ rate: number; depth: number; delay?: number }>;
2592
+ humanize?: Readonly<{ timing?: number; velocity?: number; length?: number }>;
2593
+ /** Static cents offset (SDK 1.16.0); absent is 0. */
2594
+ cents?: number;
2041
2595
  }>;
2042
2596
 
2043
2597
  /** A stored automation point: integer tick. */
@@ -2094,8 +2648,21 @@ export type ScoreTrack = Readonly<{
2094
2648
  rhythm?: readonly Readonly<Record<string, unknown>>[];
2095
2649
  /** Synth kit name; dawg validates it. */
2096
2650
  kit?: string;
2651
+ /** `rate`, plus `phase` and `cycle` in ticks. */
2652
+ time?: Readonly<{
2653
+ rate?: number;
2654
+ phase?: number;
2655
+ cycle?: number;
2656
+ steps?: Readonly<{ shift: number; hold: number; drift: number }>;
2657
+ }>;
2658
+ /** Track tuning; dawg validates it (SDK 1.16.0). */
2659
+ tuning?: ScoreTuning;
2097
2660
  wavetable?: Readonly<{ table: ScoreSampleRef } & WavetableParams>;
2098
2661
  wtAutomation?: readonly ScorePoint[];
2662
+ glide?: TrackSpec["glide"];
2663
+ pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
2664
+ velocityCurve?: TrackSpec["velocityCurve"];
2665
+ humanize?: TrackSpec["humanize"];
2099
2666
  }>;
2100
2667
 
2101
2668
  /**
@@ -2111,10 +2678,167 @@ export type Song = Readonly<{
2111
2678
  bars: number;
2112
2679
  ticksPerBeat: number;
2113
2680
  key: string | null;
2681
+ /** Present only when `song({ time })` has marks. */
2682
+ time?: ScoreTime;
2683
+ /** Present only when the song sets one (SDK 1.16.0). */
2684
+ tuning?: ScoreTuning;
2114
2685
  tracks: readonly ScoreTrack[];
2115
2686
  notes: readonly ScoreNote[];
2687
+ master?: MasterInput;
2688
+ /** Present only when the song has sections (SDK 1.18.0). */
2689
+ sections?: readonly SongSection[];
2690
+ /** Present only when the song has a form (SDK 1.18.0). */
2691
+ form?: readonly SongFormEntry[];
2692
+ /** Present only when a section loops (SDK 1.18.0). */
2693
+ loopSection?: string;
2116
2694
  }>;
2117
2695
 
2696
+ /** A stored song `time`: ticks, and 0-based bar indexes. */
2697
+ export type ScoreTime = Readonly<{
2698
+ tempo?: readonly Readonly<{
2699
+ tick: number;
2700
+ bpm: number;
2701
+ ramp?: "linear" | "exp";
2702
+ }>[];
2703
+ meter?: readonly Readonly<{
2704
+ bar: number;
2705
+ beatsPerBar: number;
2706
+ beatUnit?: number;
2707
+ }>[];
2708
+ fermatas?: readonly Readonly<{ tick: number; beats: number }>[];
2709
+ }>;
2710
+
2711
+ const MASTER_KEYS = ["eq", "glue", "tape", "width", "limiter", "target"];
2712
+
2713
+ /** Shape checks only; dawg validates every value when it loads the song. */
2714
+ function masterData(input: unknown): MasterInput | undefined {
2715
+ if (input === undefined || input === null) return undefined;
2716
+ if (!isRecord(input)) throw new DawgSdkError("song master must be an object");
2717
+ const out: Record<string, unknown> = {};
2718
+ for (const [key, value] of Object.entries(input)) {
2719
+ if (value === undefined) continue;
2720
+ if (!MASTER_KEYS.includes(key))
2721
+ throw new DawgSdkError(
2722
+ `song master has no "${key}"; use ${MASTER_KEYS.join(", ")}`,
2723
+ );
2724
+ if (key === "target") {
2725
+ if (typeof value === "string")
2726
+ throw new DawgSdkError(
2727
+ `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}"`,
2728
+ );
2729
+ out.target = finite(value as number, "song master target");
2730
+ continue;
2731
+ }
2732
+ if (!isRecord(value))
2733
+ throw new DawgSdkError(`song master ${key} must be an object`);
2734
+ out[key] = Object.freeze({ ...value });
2735
+ }
2736
+ return Object.keys(out).length > 0
2737
+ ? (Object.freeze(out) as MasterInput)
2738
+ : undefined;
2739
+ }
2740
+
2741
+ /** `"intro verse chorus*2"` (comma separated when a name has a space). */
2742
+ function parseSongForm(
2743
+ form: NonNullable<SongInput["form"]>,
2744
+ ): readonly SongFormEntry[] {
2745
+ const items: (string | SongFormEntry)[] =
2746
+ typeof form === "string"
2747
+ ? (form.includes(",") ? form.split(",") : form.trim().split(/\s+/u))
2748
+ .map((item) => item.trim())
2749
+ .filter((item) => item !== "")
2750
+ : Array.isArray(form)
2751
+ ? [...form]
2752
+ : (() => {
2753
+ throw new DawgSdkError(
2754
+ "song form must be a string or an array of section names",
2755
+ );
2756
+ })();
2757
+ return Object.freeze(
2758
+ items.map((item, index) => {
2759
+ let entry: unknown = item;
2760
+ if (typeof item === "string") {
2761
+ const match = /^(.*?)\s*(?:\*|\bx|×)\s*(\d+)$/iu.exec(item);
2762
+ entry =
2763
+ match && match[1]!.length > 0
2764
+ ? { section: match[1]!, repeat: Number(match[2]) }
2765
+ : { section: item };
2766
+ }
2767
+ if (!isRecord(entry) || typeof entry.section !== "string")
2768
+ throw new DawgSdkError(
2769
+ `song form[${index}] must be a section name or { section, repeat }`,
2770
+ );
2771
+ const repeat = entry.repeat ?? 1;
2772
+ if (
2773
+ !Number.isInteger(repeat) ||
2774
+ (repeat as number) < 1 ||
2775
+ (repeat as number) > 16
2776
+ )
2777
+ throw new DawgSdkError(`song form[${index}] repeat must be 1..16`);
2778
+ return Object.freeze(
2779
+ repeat === 1
2780
+ ? { section: entry.section }
2781
+ : { section: entry.section, repeat: repeat as number },
2782
+ );
2783
+ }),
2784
+ );
2785
+ }
2786
+
2787
+ function songSections(
2788
+ sections: NonNullable<SongInput["sections"]>,
2789
+ ): readonly SongSection[] {
2790
+ if (!Array.isArray(sections))
2791
+ throw new DawgSdkError("song sections must be an array");
2792
+ if (sections.length > 64) throw new DawgSdkError("song has over 64 sections");
2793
+ return Object.freeze(
2794
+ sections.map((section, index) => {
2795
+ const where = `song sections[${index}]`;
2796
+ if (!isRecord(section) || typeof section.name !== "string")
2797
+ throw new DawgSdkError(`${where} needs a name`);
2798
+ const stored: Record<string, unknown> = {
2799
+ name: section.name,
2800
+ startBar: finite(section.startBar, `${where}.startBar`),
2801
+ bars: finite(section.bars, `${where}.bars`),
2802
+ };
2803
+ if (section.mute !== undefined) {
2804
+ if (
2805
+ !Array.isArray(section.mute) ||
2806
+ section.mute.some((id) => typeof id !== "string")
2807
+ )
2808
+ throw new DawgSdkError(`${where}.mute must be track ids`);
2809
+ if (section.mute.length > 0)
2810
+ stored.mute = Object.freeze([...section.mute]);
2811
+ }
2812
+ if (section.vary !== undefined) {
2813
+ if (!isRecord(section.vary))
2814
+ throw new DawgSdkError(`${where}.vary must be an object`);
2815
+ const vary = Object.entries(section.vary);
2816
+ if (vary.length > 0)
2817
+ stored.vary = Object.freeze(
2818
+ Object.fromEntries(
2819
+ vary.map(([id, change]) => {
2820
+ if (!isRecord(change))
2821
+ throw new DawgSdkError(
2822
+ `${where}.vary.${id} must be an object`,
2823
+ );
2824
+ const out: Record<string, number> = {};
2825
+ if (change.transpose !== undefined)
2826
+ out.transpose = finite(
2827
+ change.transpose,
2828
+ `${where}.vary.${id}.transpose`,
2829
+ );
2830
+ if (change.gain !== undefined)
2831
+ out.gain = finite(change.gain, `${where}.vary.${id}.gain`);
2832
+ return [id, Object.freeze(out)];
2833
+ }),
2834
+ ),
2835
+ );
2836
+ }
2837
+ return Object.freeze(stored) as SongSection;
2838
+ }),
2839
+ );
2840
+ }
2841
+
2118
2842
  /**
2119
2843
  * Assemble the song. Beats become ticks (`Math.round(beat * ticksPerBeat)`,
2120
2844
  * lengths at least one tick), and every note gets a deterministic id from
@@ -2127,7 +2851,11 @@ export function song(input: SongInput): Song {
2127
2851
  const beatsPerBar = Array.isArray(meter)
2128
2852
  ? finite(meter[0], "song meter[0]")
2129
2853
  : finite(meter as number, "song meter");
2130
- if (Array.isArray(meter)) finite(meter[1], "song meter[1]");
2854
+ const beatUnit = Array.isArray(meter) ? finite(meter[1], "song meter[1]") : 4;
2855
+ if (![1, 2, 4, 8, 16, 32].includes(beatUnit))
2856
+ throw new DawgSdkError(
2857
+ "song meter note value must be 1, 2, 4, 8, 16 or 32",
2858
+ );
2131
2859
  const bars = finite(input.bars ?? 4, "song bars");
2132
2860
  const ticksPerBeat = input.ticksPerBeat ?? DEFAULT_TICKS_PER_BEAT;
2133
2861
  if (
@@ -2139,6 +2867,8 @@ export function song(input: SongInput): Song {
2139
2867
  const key = input.key ?? null;
2140
2868
  if (key !== null && typeof key !== "string")
2141
2869
  throw new DawgSdkError("song key must be a string or null");
2870
+ const songTuning = tuningSpec(input.tuning, "song");
2871
+ const master = masterData(input.master);
2142
2872
  if (!Array.isArray(input.tracks))
2143
2873
  throw new DawgSdkError("song tracks must be an array of track()");
2144
2874
  if (input.tracks.length > 64)
@@ -2202,6 +2932,36 @@ export function song(input: SongInput): Song {
2202
2932
  mode: t.sampler.mode,
2203
2933
  });
2204
2934
  if (t.kit) stored.kit = t.kit;
2935
+ if (t.time) {
2936
+ const time: Record<string, unknown> = {};
2937
+ if (t.time.rate !== undefined) time.rate = t.time.rate;
2938
+ if (t.time.phase !== undefined) {
2939
+ const phase = ticks(t.time.phase);
2940
+ if (phase !== 0) time.phase = phase;
2941
+ }
2942
+ if (t.time.cycle !== undefined)
2943
+ time.cycle = Math.max(1, ticks(t.time.cycle));
2944
+ if (t.time.steps)
2945
+ time.steps = Object.freeze({
2946
+ ...t.time.steps,
2947
+ shift: Math.max(1, ticks(t.time.steps.shift)),
2948
+ });
2949
+ if (Object.keys(time).length > 0) stored.time = Object.freeze(time);
2950
+ }
2951
+ if (t.glide) stored.glide = t.glide;
2952
+ if (t.pedal && t.pedal.length > 0) {
2953
+ // One event per tick (the last wins), in tick order, as dawg stores it.
2954
+ const byTick = new Map<number, PedalState>();
2955
+ for (const [at, state] of t.pedal) byTick.set(ticks(at), state);
2956
+ stored.pedal = Object.freeze(
2957
+ [...byTick.entries()]
2958
+ .sort((a, b) => a[0] - b[0])
2959
+ .map(([tick, state]) => Object.freeze({ tick, state })),
2960
+ );
2961
+ }
2962
+ if (t.velocityCurve) stored.velocityCurve = t.velocityCurve;
2963
+ if (t.humanize) stored.humanize = t.humanize;
2964
+ if (t.tuning) stored.tuning = t.tuning;
2205
2965
  if (t.rhythm && t.rhythm.length > 0)
2206
2966
  stored.rhythm = Object.freeze(
2207
2967
  t.rhythm.map((row) => {
@@ -2228,10 +2988,42 @@ export function song(input: SongInput): Song {
2228
2988
  durationTicks,
2229
2989
  pitch: n.pitch,
2230
2990
  velocity: n.velocity,
2991
+ ...(n.articulation ? { articulation: n.articulation } : {}),
2992
+ ...(n.glide !== undefined ? { glide: n.glide } : {}),
2993
+ ...(n.bend
2994
+ ? {
2995
+ bend: Object.freeze(
2996
+ [...n.bend]
2997
+ .sort((a, b) => a[0] - b[0])
2998
+ .map(([at, cents]) => Object.freeze({ at, cents })),
2999
+ ),
3000
+ }
3001
+ : {}),
3002
+ ...(n.vibrato ? { vibrato: n.vibrato } : {}),
3003
+ ...(n.humanize ? { humanize: n.humanize } : {}),
3004
+ ...(n.cents ? { cents: n.cents } : {}),
2231
3005
  }),
2232
3006
  );
2233
3007
  }
2234
3008
  });
3009
+ const arrangement: {
3010
+ sections?: readonly SongSection[];
3011
+ form?: readonly SongFormEntry[];
3012
+ loopSection?: string;
3013
+ } = {};
3014
+ if (input.sections !== undefined) {
3015
+ const sections = songSections(input.sections);
3016
+ if (sections.length > 0) arrangement.sections = sections;
3017
+ }
3018
+ if (input.form !== undefined) {
3019
+ const form = parseSongForm(input.form);
3020
+ if (form.length > 0) arrangement.form = form;
3021
+ }
3022
+ if (input.loopSection !== undefined) {
3023
+ if (typeof input.loopSection !== "string")
3024
+ throw new DawgSdkError("song loopSection must be a section name");
3025
+ arrangement.loopSection = input.loopSection;
3026
+ }
2235
3027
  return Object.freeze({
2236
3028
  format: "track.loop/v1",
2237
3029
  version: 1,
@@ -2240,11 +3032,624 @@ export function song(input: SongInput): Song {
2240
3032
  bars,
2241
3033
  ticksPerBeat,
2242
3034
  key,
3035
+ ...songTime(
3036
+ input.time,
3037
+ tempoBpm,
3038
+ ticks,
3039
+ beatsPerBar,
3040
+ ticksPerBeat,
3041
+ beatUnit,
3042
+ bars,
3043
+ ),
3044
+ ...(songTuning ? { tuning: songTuning } : {}),
2243
3045
  tracks: Object.freeze(tracks),
2244
3046
  notes: Object.freeze(notes),
3047
+ ...(master ? { master } : {}),
3048
+ ...arrangement,
2245
3049
  });
2246
3050
  }
2247
3051
 
3052
+ // ---------------------------------------------------------------------------
3053
+ // Time (SDK 1.14.0)
3054
+
3055
+ /** One entry of `song({ time })`; build them with the helpers below. */
3056
+ export type TimeMark =
3057
+ | Readonly<{
3058
+ kind: "tempo";
3059
+ /** Beat the change lands on. */
3060
+ at: number;
3061
+ /** Absent: keep the tempo in effect there, so a ramp can start from it. */
3062
+ bpm?: number;
3063
+ /** Glide into `bpm` from the previous mark instead of stepping. */
3064
+ ramp?: "linear" | "exp";
3065
+ /** Set by `rit()`/`accel()`: the direction `song()` checks. */
3066
+ gradual?: "rit" | "accel";
3067
+ /**
3068
+ * Set by `aTempo()` (the tempo before the last rit/accel) and
3069
+ * `tempoPrimo()` (the song's opening tempo) instead of `bpm`.
3070
+ */
3071
+ back?: "a-tempo" | "primo";
3072
+ }>
3073
+ | Readonly<{
3074
+ kind: "meter";
3075
+ /** Beat of the bar line where the meter starts. */
3076
+ at: number;
3077
+ beatsPerBar: number;
3078
+ beatUnit: number;
3079
+ }>
3080
+ | Readonly<{
3081
+ kind: "fermata";
3082
+ at: number;
3083
+ /** Extra beats the held beat lasts. */
3084
+ beats: number;
3085
+ }>;
3086
+
3087
+ /** `ramp()` curves: `linear` adds the same BPM each beat, `exp` the same ratio. */
3088
+ export type TempoCurve = "linear" | "exp";
3089
+
3090
+ /**
3091
+ * Tempo change at beat `at`: `tempo(32, 140)`. Omit `bpm` to pin the tempo
3092
+ * in effect there, the start of a ramp.
3093
+ */
3094
+ export function tempo(at: number, bpm?: number): TimeMark {
3095
+ const start = beat(at, "tempo() at");
3096
+ if (start <= 0)
3097
+ throw new DawgSdkError("tempo() at must be > 0; song({ tempo }) is beat 0");
3098
+ if (bpm === undefined) return Object.freeze({ kind: "tempo", at: start });
3099
+ return Object.freeze({ kind: "tempo", at: start, bpm: songBpm(bpm) });
3100
+ }
3101
+
3102
+ /**
3103
+ * `a tempo` at beat `at` (SDK 1.19.0): step back to the tempo in effect
3104
+ * before the last `rit()`/`accel()` (or ramp) ending before `at`.
3105
+ */
3106
+ export function aTempo(at: number): TimeMark {
3107
+ const start = beat(at, "aTempo() at");
3108
+ if (start <= 0) throw new DawgSdkError("aTempo() at must be > 0");
3109
+ return Object.freeze({ kind: "tempo", at: start, back: "a-tempo" });
3110
+ }
3111
+
3112
+ /** `tempo primo` at beat `at` (SDK 1.19.0): step back to `song({ tempo })`. */
3113
+ export function tempoPrimo(at: number): TimeMark {
3114
+ const start = beat(at, "tempoPrimo() at");
3115
+ if (start <= 0) throw new DawgSdkError("tempoPrimo() at must be > 0");
3116
+ return Object.freeze({ kind: "tempo", at: start, back: "primo" });
3117
+ }
3118
+
3119
+ /**
3120
+ * Glide from the previous tempo mark (or the song's opening tempo) to `bpm`
3121
+ * at beat `at`: `ramp(64, 90)`. `exp` changes by the same ratio each beat.
3122
+ */
3123
+ export function ramp(
3124
+ at: number,
3125
+ bpm: number,
3126
+ curve: TempoCurve = "linear",
3127
+ ): TimeMark {
3128
+ const end = beat(at, "ramp() at");
3129
+ if (end <= 0) throw new DawgSdkError("ramp() at must be > 0");
3130
+ return Object.freeze({
3131
+ kind: "tempo",
3132
+ at: end,
3133
+ bpm: songBpm(bpm),
3134
+ ramp: tempoCurve(curve, "ramp()"),
3135
+ });
3136
+ }
3137
+
3138
+ /**
3139
+ * Ritardando: slow from the tempo at beat `at` to `bpm` over `beats` beats,
3140
+ * `rit(48, 16, 80)`. Same as `[tempo(at), ramp(at + beats, bpm, curve)]`.
3141
+ */
3142
+ export function rit(
3143
+ at: number,
3144
+ beats: number,
3145
+ bpm: number,
3146
+ curve: TempoCurve = "linear",
3147
+ ): readonly TimeMark[] {
3148
+ return gradual("rit", at, beats, bpm, curve);
3149
+ }
3150
+
3151
+ /** Accelerando: like `rit()`, toward a faster `bpm`. */
3152
+ export function accel(
3153
+ at: number,
3154
+ beats: number,
3155
+ bpm: number,
3156
+ curve: TempoCurve = "linear",
3157
+ ): readonly TimeMark[] {
3158
+ return gradual("accel", at, beats, bpm, curve);
3159
+ }
3160
+
3161
+ function gradual(
3162
+ direction: "rit" | "accel",
3163
+ at: number,
3164
+ beats: number,
3165
+ bpm: number,
3166
+ curve: TempoCurve,
3167
+ ): readonly TimeMark[] {
3168
+ const label = `${direction}()`;
3169
+ const start = beat(at, `${label} at`);
3170
+ const length = positive(beats, `${label} beats`);
3171
+ const target = songBpm(bpm);
3172
+ const shape = tempoCurve(curve, label);
3173
+ const marks: TimeMark[] = [];
3174
+ if (start > 0) marks.push(Object.freeze({ kind: "tempo", at: start }));
3175
+ marks.push(
3176
+ Object.freeze({
3177
+ kind: "tempo",
3178
+ at: start + length,
3179
+ bpm: target,
3180
+ ramp: shape,
3181
+ gradual: direction,
3182
+ }),
3183
+ );
3184
+ return Object.freeze(marks);
3185
+ }
3186
+
3187
+ /** Fermata: the beat at `at` lasts `beats` extra beats (default 2). */
3188
+ export function fermata(at: number, beats = 2): TimeMark {
3189
+ const hold = positive(beats, "fermata() beats");
3190
+ if (hold > 64) throw new DawgSdkError("fermata() beats must be at most 64");
3191
+ return Object.freeze({
3192
+ kind: "fermata",
3193
+ at: beat(at, "fermata() at"),
3194
+ beats: hold,
3195
+ });
3196
+ }
3197
+
3198
+ /**
3199
+ * Meter change on the bar line at beat `at`: `meter(16, [7, 8])` or
3200
+ * `meter(16, 3)` (quarter-note beats). It lasts until the next one.
3201
+ */
3202
+ export function meter(
3203
+ at: number,
3204
+ value: readonly [number, number] | number,
3205
+ ): TimeMark {
3206
+ const start = beat(at, "meter() at");
3207
+ const [beatsPerBar, beatUnit] = Array.isArray(value)
3208
+ ? [value[0], value[1]]
3209
+ : [value as number, 4];
3210
+ if (
3211
+ typeof beatsPerBar !== "number" ||
3212
+ !Number.isInteger(beatsPerBar) ||
3213
+ beatsPerBar < 1 ||
3214
+ beatsPerBar > 16
3215
+ )
3216
+ throw new DawgSdkError("meter() beats per bar must be an integer 1..16");
3217
+ if (![1, 2, 4, 8, 16, 32].includes(beatUnit as number))
3218
+ throw new DawgSdkError("meter() note value must be 1, 2, 4, 8, 16 or 32");
3219
+ return Object.freeze({
3220
+ kind: "meter",
3221
+ at: start,
3222
+ beatsPerBar,
3223
+ beatUnit: beatUnit as number,
3224
+ });
3225
+ }
3226
+
3227
+ /**
3228
+ * Track time for continuous phasing, the tape drift of Reich's It's Gonna
3229
+ * Rain and Come Out: the track's first `cycle` beats repeat a little fast,
3230
+ * gaining `cycles` whole cycles every `over` beats, so it drifts away from
3231
+ * an identical track and lines up again. `over` should divide the song
3232
+ * loop. `track({ ..., time: phasing(3, 48) })`. For Piano Phase's
3233
+ * shift-and-hold, use `stepPhasing()`.
3234
+ */
3235
+ export function phasing(
3236
+ cycle: number,
3237
+ over: number,
3238
+ cycles = 1,
3239
+ ): TrackTimeInput {
3240
+ const length = positive(cycle, "phasing() cycle");
3241
+ const span = positive(over, "phasing() over");
3242
+ const gain = finite(cycles, "phasing() cycles");
3243
+ const repeats = span / length;
3244
+ const rate = (repeats + gain) / repeats;
3245
+ if (!(rate >= 0.125 && rate <= 8))
3246
+ throw new DawgSdkError("phasing() needs a rate between 0.125 and 8");
3247
+ return Object.freeze({ cycle: length, rate });
3248
+ }
3249
+
3250
+ /**
3251
+ * Stepped phasing (SDK 1.19.0), as in Reich's Piano Phase: the track's first `cycle`
3252
+ * beats hold in step with a twin for `hold` cycles, then move `shift`
3253
+ * beats ahead over `drift` cycles, and repeat until a whole cycle ahead.
3254
+ * `track({ ..., time: stepPhasing(3, { hold: 8 }) })`.
3255
+ */
3256
+ export function stepPhasing(
3257
+ cycle: number,
3258
+ options: Readonly<{ shift?: number; hold?: number; drift?: number }> = {},
3259
+ ): TrackTimeInput {
3260
+ const length = positive(cycle, "stepPhasing() cycle");
3261
+ const steps = phaseSteps(
3262
+ { shift: 0.25, hold: 8, drift: 2, ...options },
3263
+ length,
3264
+ "stepPhasing()",
3265
+ );
3266
+ return Object.freeze({ cycle: length, steps });
3267
+ }
3268
+
3269
+ function songBpm(value: unknown): number {
3270
+ const bpm = finite(value, "tempo bpm");
3271
+ if (bpm < 20 || bpm > 300)
3272
+ throw new DawgSdkError("tempo bpm must be 20..300");
3273
+ return bpm;
3274
+ }
3275
+
3276
+ function tempoCurve(value: unknown, label: string): TempoCurve {
3277
+ if (value !== "linear" && value !== "exp")
3278
+ throw new DawgSdkError(`${label} curve must be "linear" or "exp"`);
3279
+ return value;
3280
+ }
3281
+
3282
+ /** Same as `TIME_LIMITS.maxFermataSeconds` in core/tempo.ts. */
3283
+ const MAX_FERMATA_SECONDS = 16.777;
3284
+
3285
+ /** Tempo at `tick` through resolved tempo events (ramps glide into theirs). */
3286
+ function bpmAtTick(
3287
+ tempo: readonly { tick: number; bpm: number; ramp?: TempoCurve }[],
3288
+ start: number,
3289
+ tick: number,
3290
+ ): number {
3291
+ let from = { tick: 0, bpm: start };
3292
+ for (const event of tempo) {
3293
+ if (event.tick <= tick) {
3294
+ from = event;
3295
+ continue;
3296
+ }
3297
+ if (!event.ramp) break;
3298
+ const t = (tick - from.tick) / (event.tick - from.tick);
3299
+ return event.ramp === "exp"
3300
+ ? from.bpm * Math.pow(event.bpm / from.bpm, t)
3301
+ : from.bpm + (event.bpm - from.bpm) * t;
3302
+ }
3303
+ return from.bpm;
3304
+ }
3305
+
3306
+ /** Resolves `song({ time })` marks into the stored ticks and bar indexes. */
3307
+ function songTime(
3308
+ input: unknown,
3309
+ tempoBpm: number,
3310
+ ticks: (beats: number) => number,
3311
+ beatsPerBar: number,
3312
+ ticksPerBeat: number,
3313
+ beatUnit = 4,
3314
+ bars = Infinity,
3315
+ ): { time?: ScoreTime } {
3316
+ if ((input === undefined || input === null) && beatUnit === 4) return {};
3317
+ input ??= [];
3318
+ if (!Array.isArray(input))
3319
+ throw new DawgSdkError("song time must be an array of time marks");
3320
+ const marks: TimeMark[] = [];
3321
+ for (const [index, entry] of (input as unknown[]).entries()) {
3322
+ for (const mark of Array.isArray(entry) ? entry : [entry]) {
3323
+ if (
3324
+ !isRecord(mark) ||
3325
+ (mark.kind !== "tempo" &&
3326
+ mark.kind !== "meter" &&
3327
+ mark.kind !== "fermata")
3328
+ )
3329
+ throw new DawgSdkError(
3330
+ `song time[${index}] must come from tempo(), ramp(), rit(), accel(), fermata() or meter()`,
3331
+ );
3332
+ marks.push(mark as TimeMark);
3333
+ }
3334
+ }
3335
+ // Tempo: sort by tick, resolve pins against their neighbours.
3336
+ type Event = {
3337
+ tick: number;
3338
+ bpm?: number;
3339
+ ramp?: TempoCurve;
3340
+ back?: "a-tempo" | "primo";
3341
+ };
3342
+ const byTick = new Map<number, Event>();
3343
+ for (const mark of marks) {
3344
+ if (mark.kind !== "tempo") continue;
3345
+ const tick = ticks(mark.at);
3346
+ const previous = byTick.get(tick);
3347
+ const sets = (e: { bpm?: number; back?: unknown }) =>
3348
+ e.bpm !== undefined || e.back !== undefined;
3349
+ if (previous && sets(previous) && sets(mark)) {
3350
+ // A rit or ramp ending where a tempo change starts (`rit(18, 6, 52)`
3351
+ // with `aTempo(24)`): the ramp lands a tick early, then the step.
3352
+ const ramped = previous.ramp ? previous : mark.ramp ? mark : undefined;
3353
+ const other = ramped === previous ? mark : previous;
3354
+ if (ramped && !other.ramp && tick > 1 && !byTick.has(tick - 1)) {
3355
+ byTick.set(tick - 1, {
3356
+ tick: tick - 1,
3357
+ ...(ramped.bpm !== undefined ? { bpm: ramped.bpm } : {}),
3358
+ ramp: ramped.ramp!,
3359
+ });
3360
+ byTick.set(tick, {
3361
+ tick,
3362
+ ...(other.bpm !== undefined ? { bpm: other.bpm } : {}),
3363
+ ...(other.back ? { back: other.back } : {}),
3364
+ });
3365
+ continue;
3366
+ }
3367
+ throw new DawgSdkError(
3368
+ `song time has two tempo changes at beat ${mark.at}; move one, or end a rit() where the next tempo starts`,
3369
+ );
3370
+ }
3371
+ // A pin and a change on the same beat: the change wins.
3372
+ if (previous && !sets(mark)) continue;
3373
+ byTick.set(tick, {
3374
+ tick,
3375
+ ...(mark.bpm !== undefined ? { bpm: mark.bpm } : {}),
3376
+ ...(mark.ramp ? { ramp: mark.ramp } : {}),
3377
+ ...(mark.back ? { back: mark.back } : {}),
3378
+ });
3379
+ }
3380
+ const events = [...byTick.values()].sort((a, b) => a.tick - b.tick);
3381
+ const tempo: { tick: number; bpm: number; ramp?: TempoCurve }[] = [];
3382
+ events.forEach((event, index) => {
3383
+ if (event.back) {
3384
+ let bpm = tempoBpm;
3385
+ if (event.back === "a-tempo") {
3386
+ let last = -1;
3387
+ tempo.forEach((e, i) => {
3388
+ if (e.ramp !== undefined) last = i;
3389
+ });
3390
+ if (last < 0)
3391
+ throw new DawgSdkError(
3392
+ `aTempo() at beat ${event.tick / ticksPerBeat} has no rit() or accel() before it`,
3393
+ );
3394
+ bpm = last > 0 ? tempo[last - 1]!.bpm : tempoBpm;
3395
+ }
3396
+ tempo.push(Object.freeze({ tick: event.tick, bpm }));
3397
+ return;
3398
+ }
3399
+ if (event.bpm !== undefined) {
3400
+ // rit() and accel() check their direction against the tempo they
3401
+ // start from, as the prompt's `rit` and `accel` do.
3402
+ const mark = marks.find(
3403
+ (m) => m.kind === "tempo" && ticks(m.at) === event.tick && m.gradual,
3404
+ ) as Extract<TimeMark, { kind: "tempo" }> | undefined;
3405
+ if (mark?.gradual) {
3406
+ const from = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
3407
+ if (mark.gradual === "rit" && event.bpm > from)
3408
+ throw new DawgSdkError(
3409
+ `rit() target ${event.bpm} BPM is faster than ${from}; use accel()`,
3410
+ );
3411
+ if (mark.gradual === "accel" && event.bpm < from)
3412
+ throw new DawgSdkError(
3413
+ `accel() target ${event.bpm} BPM is slower than ${from}; use rit()`,
3414
+ );
3415
+ }
3416
+ tempo.push(
3417
+ Object.freeze({
3418
+ tick: event.tick,
3419
+ bpm: event.bpm,
3420
+ ...(event.ramp ? { ramp: event.ramp } : {}),
3421
+ }),
3422
+ );
3423
+ return;
3424
+ }
3425
+ // A pin holds the tempo of the marks before it, and the next ramp
3426
+ // starts from it. Without a ramp after it, it changes nothing.
3427
+ const after = events
3428
+ .slice(index + 1)
3429
+ .find((e) => e.bpm !== undefined || e.back !== undefined);
3430
+ if (!after?.ramp) return;
3431
+ const before = tempo[tempo.length - 1]?.bpm ?? tempoBpm;
3432
+ tempo.push(Object.freeze({ tick: event.tick, bpm: before }));
3433
+ });
3434
+ // Meter: beats to bar indexes, checking each lands on a bar line.
3435
+ const meters = marks
3436
+ .filter((mark) => mark.kind === "meter")
3437
+ .sort((a, b) => a.at - b.at);
3438
+ // `song({ meter: [6, 8] })`: the song meter's note value as a bar-1 change.
3439
+ if (beatUnit !== 4 && !meters.some((mark) => ticks(mark.at) === 0))
3440
+ meters.unshift({ kind: "meter", at: 0, beatsPerBar, beatUnit });
3441
+ const meterOut: { bar: number; beatsPerBar: number; beatUnit?: number }[] =
3442
+ [];
3443
+ let barTick = 0;
3444
+ let barIndex = 0;
3445
+ let barLength = beatsPerBar * ticksPerBeat;
3446
+ for (const mark of meters) {
3447
+ const tick = ticks(mark.at);
3448
+ const bars = (tick - barTick) / barLength;
3449
+ if (!Number.isInteger(bars) || bars < 0)
3450
+ throw new DawgSdkError(`meter() at beat ${mark.at} is not on a bar line`);
3451
+ if (meterOut.length > 0 && bars === 0)
3452
+ throw new DawgSdkError(`song time has two meters at beat ${mark.at}`);
3453
+ barIndex += bars;
3454
+ barTick = tick;
3455
+ barLength = (mark.beatsPerBar * ticksPerBeat * 4) / mark.beatUnit;
3456
+ meterOut.push(
3457
+ Object.freeze({
3458
+ bar: barIndex,
3459
+ beatsPerBar: mark.beatsPerBar,
3460
+ ...(mark.beatUnit !== 4 ? { beatUnit: mark.beatUnit } : {}),
3461
+ }),
3462
+ );
3463
+ }
3464
+ const fermatas = marks
3465
+ .filter((mark) => mark.kind === "fermata")
3466
+ .map((mark) => Object.freeze({ tick: ticks(mark.at), beats: mark.beats }))
3467
+ .sort((a, b) => a.tick - b.tick);
3468
+ for (let i = 1; i < fermatas.length; i += 1)
3469
+ if (fermatas[i]!.tick === fermatas[i - 1]!.tick)
3470
+ throw new DawgSdkError("song time has two fermatas on one beat");
3471
+ // Marks past the song end are never heard; the prompt refuses them too.
3472
+ if (Number.isFinite(bars)) {
3473
+ let end = 0;
3474
+ let fromBar = 0;
3475
+ let length = beatsPerBar * ticksPerBeat;
3476
+ for (const change of meterOut) {
3477
+ if (change.bar >= bars) break;
3478
+ end += (change.bar - fromBar) * length;
3479
+ fromBar = change.bar;
3480
+ length = (change.beatsPerBar * ticksPerBeat * 4) / (change.beatUnit ?? 4);
3481
+ }
3482
+ end += (bars - fromBar) * length;
3483
+ const beatOf = (tick: number) => tick / ticksPerBeat;
3484
+ // A ramp to the final barline (`rit()` over the last bars) lands on
3485
+ // the last tick, the tempo the song ends at.
3486
+ tempo.forEach((event, index) => {
3487
+ if (
3488
+ event.tick === end &&
3489
+ event.ramp &&
3490
+ end - 1 > (tempo[index - 1]?.tick ?? 0)
3491
+ )
3492
+ tempo[index] = Object.freeze({ ...event, tick: end - 1 });
3493
+ });
3494
+ for (const event of tempo)
3495
+ if (event.tick >= end)
3496
+ throw new DawgSdkError(
3497
+ `tempo at beat ${beatOf(event.tick)} is past the song end (${beatOf(end)} beats); add bars`,
3498
+ );
3499
+ for (const change of meterOut)
3500
+ if (change.bar >= bars)
3501
+ throw new DawgSdkError(
3502
+ `meter() at bar ${change.bar + 1} is past the song end (${bars} bars); add bars`,
3503
+ );
3504
+ for (const hold of fermatas)
3505
+ if (hold.tick >= end)
3506
+ throw new DawgSdkError(
3507
+ `fermata() at beat ${beatOf(hold.tick)} is past the song end (${beatOf(end)} beats); add bars`,
3508
+ );
3509
+ }
3510
+ // A fermata may hold its beat at most as long as a MIDI file can write.
3511
+ // The held beat is the meter's felt beat (a dotted quarter in 6/8), as
3512
+ // core/tempo.ts fermataSpan has it.
3513
+ const feltBeats = (tick: number): number => {
3514
+ let at = 0;
3515
+ let fromBar = 0;
3516
+ let meter = { beatsPerBar, beatUnit: 4 };
3517
+ let length = beatsPerBar * ticksPerBeat;
3518
+ for (const change of meterOut) {
3519
+ const start = at + (change.bar - fromBar) * length;
3520
+ if (start > tick) break;
3521
+ at = start;
3522
+ fromBar = change.bar;
3523
+ meter = {
3524
+ beatsPerBar: change.beatsPerBar,
3525
+ beatUnit: change.beatUnit ?? 4,
3526
+ };
3527
+ length = (meter.beatsPerBar * ticksPerBeat * 4) / meter.beatUnit;
3528
+ }
3529
+ if (meterOut.length === 0) return 1;
3530
+ const unit = 4 / meter.beatUnit;
3531
+ const compound =
3532
+ meter.beatUnit >= 8 &&
3533
+ meter.beatsPerBar > 3 &&
3534
+ meter.beatsPerBar % 3 === 0;
3535
+ return Math.max(1, compound ? unit * 3 : unit);
3536
+ };
3537
+ for (const hold of fermatas) {
3538
+ const bpm = bpmAtTick(tempo, tempoBpm, hold.tick);
3539
+ const held = ((1 + hold.beats) * feltBeats(hold.tick) * 60) / bpm;
3540
+ if (held > MAX_FERMATA_SECONDS + 1e-9)
3541
+ throw new DawgSdkError(
3542
+ `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`,
3543
+ );
3544
+ }
3545
+ const time: Record<string, unknown> = {};
3546
+ if (tempo.length > 0) time.tempo = Object.freeze(tempo);
3547
+ if (meterOut.length > 0) time.meter = Object.freeze(meterOut);
3548
+ if (fermatas.length > 0) time.fermatas = Object.freeze(fermatas);
3549
+ return Object.keys(time).length > 0
3550
+ ? { time: Object.freeze(time) as ScoreTime }
3551
+ : {};
3552
+ }
3553
+
3554
+ // Tuning (SDK 1.16.0)
3555
+
3556
+ /**
3557
+ * A tuning for `song({ tuning })` or `track({ tuning })`: a library name or
3558
+ * an object with at most one table source (`edo`, `ratios`, `cents` or
3559
+ * `scl`). Library names: `12-tet`, `19-edo`, `24-edo`, `31-edo`,
3560
+ * `pythagorean`, `just` (5-limit), `7-limit`, `well-tuned-piano`, `pelog`,
3561
+ * `slendro`, `nyamaropa`, `shruti`, maqam and dastgah sets (`bayati`,
3562
+ * `rast`, `saba`, `shur`, `homayoun`, `chahargah`) and raga intonations
3563
+ * (`yaman`, `bhairav`, `kafi`, `todi`, …); `dawg` lists them with
3564
+ * `/tuning list`. dawg checks every value when the song loads.
3565
+ */
3566
+ export type TuningInput =
3567
+ | string
3568
+ | Readonly<{
3569
+ /** A library tuning, or a label for the table given here. */
3570
+ name?: string;
3571
+ /** Equal divisions of the octave, 1..128. */
3572
+ edo?: number;
3573
+ /** Ratios for degrees 1..n, the last the period: `["9/8", "5/4", "2/1"]`. */
3574
+ ratios?: readonly (string | number)[];
3575
+ /** Cents for degrees 1..n, the last the period: `[240, 480, 720, 960, 1200]`. */
3576
+ cents?: readonly number[];
3577
+ /** A Scala `.scl` file in the project, e.g. `"tunings/slendro.scl"`. */
3578
+ scl?: string;
3579
+ /** A Scala `.kbm` keyboard mapping in the project; it sets its own root and A4. */
3580
+ kbm?: string;
3581
+ /** A4 in Hz, 220..880, default 440. */
3582
+ ref?: number;
3583
+ /** Key of degree 0, `"D4"` or 62; default the song key's tonic in octave 4. */
3584
+ root?: Pitch;
3585
+ /** `linear` (default): one key per step. `nearest`: every key plays the step nearest its 12-TET pitch. */
3586
+ map?: "linear" | "nearest";
3587
+ }>;
3588
+
3589
+ /** A stored tuning: `root` is a MIDI number, `ratios` are strings. */
3590
+ export type ScoreTuning = Readonly<{
3591
+ name?: string;
3592
+ edo?: number;
3593
+ ratios?: readonly string[];
3594
+ cents?: readonly number[];
3595
+ scl?: string;
3596
+ kbm?: string;
3597
+ ref?: number;
3598
+ root?: number;
3599
+ map?: "linear" | "nearest";
3600
+ }>;
3601
+
3602
+ const TUNING_FIELDS: readonly string[] = Object.freeze([
3603
+ "name",
3604
+ "edo",
3605
+ "ratios",
3606
+ "cents",
3607
+ "scl",
3608
+ "kbm",
3609
+ "ref",
3610
+ "root",
3611
+ "map",
3612
+ ]);
3613
+
3614
+ /** Checks a tuning's shape; dawg validates the values when the song loads. */
3615
+ function tuningSpec(
3616
+ input: TuningInput | null | undefined,
3617
+ where: string,
3618
+ ): ScoreTuning | null {
3619
+ if (input === undefined || input === null) return null;
3620
+ if (typeof input === "string") {
3621
+ if (input.trim() === "")
3622
+ throw new DawgSdkError(`${where} tuning must be a name or an object`);
3623
+ return Object.freeze({ name: input.trim() });
3624
+ }
3625
+ if (!isRecord(input))
3626
+ throw new DawgSdkError(`${where} tuning must be a name or an object`);
3627
+ const out: Record<string, unknown> = {};
3628
+ for (const [field, value] of Object.entries(input)) {
3629
+ if (!TUNING_FIELDS.includes(field))
3630
+ throw new DawgSdkError(
3631
+ `${where} tuning has an unknown field "${field}" (use ${TUNING_FIELDS.join(", ")})`,
3632
+ );
3633
+ if (value === undefined || value === null) continue;
3634
+ if (field === "root") out.root = midi(value as Pitch);
3635
+ else if (field === "ratios" || field === "cents") {
3636
+ if (!Array.isArray(value))
3637
+ throw new DawgSdkError(`${where} tuning ${field} must be a list`);
3638
+ out[field] = Object.freeze(
3639
+ field === "ratios" ? value.map((ratio) => String(ratio)) : [...value],
3640
+ );
3641
+ } else out[field] = value;
3642
+ }
3643
+ const sources = ["edo", "ratios", "cents", "scl"].filter(
3644
+ (field) => out[field] !== undefined,
3645
+ );
3646
+ if (sources.length > 1)
3647
+ throw new DawgSdkError(
3648
+ `${where} tuning has ${sources.join(" and ")}; give one table`,
3649
+ );
3650
+ return Object.freeze(out as ScoreTuning);
3651
+ }
3652
+
2248
3653
  // ---------------------------------------------------------------------------
2249
3654
  // Chords
2250
3655
 
@@ -2815,6 +4220,8 @@ const MODES = Object.freeze({
2815
4220
  mixolydian: [0, 2, 4, 5, 7, 9, 10],
2816
4221
  locrian: [0, 1, 3, 5, 6, 8, 10],
2817
4222
  "harmonic-minor": [0, 2, 3, 5, 7, 8, 11],
4223
+ "melodic-minor": [0, 2, 3, 5, 7, 9, 11],
4224
+ "phrygian-dominant": [0, 1, 4, 5, 7, 8, 10],
2818
4225
  } as const);
2819
4226
  type ModeName = keyof typeof MODES;
2820
4227
  const MODE_NAMES = Object.keys(MODES) as ModeName[];
@@ -2837,25 +4244,302 @@ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
2837
4244
  "harmonic-minor": "harmonic-minor",
2838
4245
  "harmonic minor": "harmonic-minor",
2839
4246
  harmonic: "harmonic-minor",
4247
+ "melodic-minor": "melodic-minor",
4248
+ "melodic minor": "melodic-minor",
4249
+ melodic: "melodic-minor",
4250
+ "jazz minor": "melodic-minor",
4251
+ "phrygian-dominant": "phrygian-dominant",
4252
+ "phrygian dominant": "phrygian-dominant",
4253
+ freygish: "phrygian-dominant",
4254
+ spanish: "phrygian-dominant",
4255
+ ajam: "major",
4256
+ mahur: "major",
4257
+ bilawal: "major",
2840
4258
  });
2841
4259
 
2842
- type Key = Readonly<{ tonic: number; mode: ModeName }>;
4260
+ type ScaleFamily =
4261
+ "pentatonic" | "blues" | "maqam" | "dastgah" | "raga" | "messiaen";
4262
+
4263
+ /**
4264
+ * Scales beyond the chord modes, for keys such as `D bayati`, `C yaman` or
4265
+ * `C messiaen-3`. `steps` are semitones above the tonic and may be
4266
+ * fractional (a quarter tone is .5); `mode` is the seven-note mode the
4267
+ * chord engine harmonizes with (the closest one; see DAWG.md). A raga's
4268
+ * `intonation` is each step's traditional just pitch in cents (shruti
4269
+ * offsets), which its named tuning applies (`tuning yaman`): Pythagorean
4270
+ * ati-komal re and dha for Bhairavi, Bhairav, Purvi and Todi, the high
4271
+ * tivra ma (729/512) for Yaman, 9/5 komal ni for Kafi, after Daniélou and
4272
+ * Jairazbhoy. Maqam and dastgah quarter tones follow the 24-tone convention;
4273
+ * Segah and Sikah start on a half-flat note, so their tonic is the key.
4274
+ */
4275
+ type ScaleInfo = Readonly<{
4276
+ steps: readonly number[];
4277
+ mode: ModeName;
4278
+ family: ScaleFamily;
4279
+ intonation?: readonly number[];
4280
+ aliases?: readonly string[];
4281
+ }>;
4282
+
4283
+ const SCALES = Object.freeze({
4284
+ "major-pentatonic": {
4285
+ steps: [0, 2, 4, 7, 9],
4286
+ mode: "major",
4287
+ family: "pentatonic",
4288
+ aliases: ["pentatonic", "major pentatonic", "pent"],
4289
+ },
4290
+ "minor-pentatonic": {
4291
+ steps: [0, 3, 5, 7, 10],
4292
+ mode: "minor",
4293
+ family: "pentatonic",
4294
+ aliases: ["minor pentatonic", "m pentatonic", "min pentatonic"],
4295
+ },
4296
+ blues: {
4297
+ steps: [0, 3, 5, 6, 7, 10],
4298
+ mode: "minor",
4299
+ family: "blues",
4300
+ aliases: ["minor blues"],
4301
+ },
4302
+ "major-blues": {
4303
+ steps: [0, 2, 3, 4, 7, 9],
4304
+ mode: "major",
4305
+ family: "blues",
4306
+ aliases: ["major blues"],
4307
+ },
4308
+ hijaz: {
4309
+ steps: [0, 1, 4, 5, 7, 8, 10],
4310
+ mode: "phrygian-dominant",
4311
+ family: "maqam",
4312
+ },
4313
+ bayati: {
4314
+ steps: [0, 1.5, 3, 5, 7, 8, 10],
4315
+ mode: "phrygian",
4316
+ family: "maqam",
4317
+ },
4318
+ rast: { steps: [0, 2, 3.5, 5, 7, 9, 10.5], mode: "major", family: "maqam" },
4319
+ saba: { steps: [0, 1.5, 3, 4, 7, 8, 10], mode: "phrygian", family: "maqam" },
4320
+ kurd: { steps: [0, 1, 3, 5, 7, 8, 10], mode: "phrygian", family: "maqam" },
4321
+ nahawand: {
4322
+ steps: [0, 2, 3, 5, 7, 8, 11],
4323
+ mode: "harmonic-minor",
4324
+ family: "maqam",
4325
+ },
4326
+ sikah: {
4327
+ steps: [0, 1.5, 3.5, 5.5, 7, 8.5, 10.5],
4328
+ mode: "phrygian",
4329
+ family: "maqam",
4330
+ aliases: ["sika"],
4331
+ },
4332
+ huzam: {
4333
+ steps: [0, 1.5, 3.5, 4.5, 7.5, 8.5, 10.5],
4334
+ mode: "phrygian",
4335
+ family: "maqam",
4336
+ aliases: ["houzam"],
4337
+ },
4338
+ nikriz: { steps: [0, 2, 3, 6, 7, 9, 10], mode: "dorian", family: "maqam" },
4339
+ shur: {
4340
+ steps: [0, 1.5, 3, 5, 7, 8, 10],
4341
+ mode: "phrygian",
4342
+ family: "dastgah",
4343
+ },
4344
+ homayoun: {
4345
+ steps: [0, 1.5, 4, 5, 7, 8, 10],
4346
+ mode: "phrygian-dominant",
4347
+ family: "dastgah",
4348
+ aliases: ["homayun"],
4349
+ },
4350
+ chahargah: {
4351
+ steps: [0, 1.5, 4, 5, 7, 8.5, 11],
4352
+ mode: "phrygian-dominant",
4353
+ family: "dastgah",
4354
+ aliases: ["chahar-gah"],
4355
+ },
4356
+ segah: {
4357
+ steps: [0, 1.5, 3.5, 5, 6.5, 8.5, 10.5],
4358
+ mode: "phrygian",
4359
+ family: "dastgah",
4360
+ aliases: ["sehgah", "se-gah"],
4361
+ },
4362
+ nava: {
4363
+ steps: [0, 2, 3.5, 5, 7, 8, 10],
4364
+ mode: "minor",
4365
+ family: "dastgah",
4366
+ },
4367
+ yaman: {
4368
+ steps: [0, 2, 4, 6, 7, 9, 11],
4369
+ mode: "lydian",
4370
+ family: "raga",
4371
+ intonation: [0, 203.91, 386.31, 611.73, 701.96, 884.36, 1088.27],
4372
+ aliases: ["kalyan", "yaman kalyan"],
4373
+ },
4374
+ bhairav: {
4375
+ steps: [0, 1, 4, 5, 7, 8, 11],
4376
+ mode: "phrygian-dominant",
4377
+ family: "raga",
4378
+ intonation: [0, 90.22, 386.31, 498.04, 701.96, 792.18, 1088.27],
4379
+ },
4380
+ kafi: {
4381
+ steps: [0, 2, 3, 5, 7, 9, 10],
4382
+ mode: "dorian",
4383
+ family: "raga",
4384
+ intonation: [0, 203.91, 315.64, 498.04, 701.96, 884.36, 1017.6],
4385
+ },
4386
+ bhairavi: {
4387
+ steps: [0, 1, 3, 5, 7, 8, 10],
4388
+ mode: "phrygian",
4389
+ family: "raga",
4390
+ intonation: [0, 90.22, 294.13, 498.04, 701.96, 792.18, 996.09],
4391
+ },
4392
+ asavari: {
4393
+ steps: [0, 2, 3, 5, 7, 8, 10],
4394
+ mode: "minor",
4395
+ family: "raga",
4396
+ intonation: [0, 203.91, 315.64, 498.04, 701.96, 813.69, 996.09],
4397
+ },
4398
+ khamaj: {
4399
+ steps: [0, 2, 4, 5, 7, 9, 10],
4400
+ mode: "mixolydian",
4401
+ family: "raga",
4402
+ intonation: [0, 203.91, 386.31, 498.04, 701.96, 884.36, 996.09],
4403
+ },
4404
+ todi: {
4405
+ steps: [0, 1, 3, 6, 7, 8, 11],
4406
+ mode: "phrygian",
4407
+ family: "raga",
4408
+ intonation: [0, 95, 294, 606, 702, 792, 1107],
4409
+ },
4410
+ purvi: {
4411
+ steps: [0, 1, 4, 6, 7, 8, 11],
4412
+ mode: "phrygian-dominant",
4413
+ family: "raga",
4414
+ intonation: [0, 90.22, 386.31, 590.22, 701.96, 792.18, 1088.27],
4415
+ },
4416
+ marwa: {
4417
+ steps: [0, 1, 4, 6, 9, 11],
4418
+ mode: "lydian",
4419
+ family: "raga",
4420
+ intonation: [0, 111.73, 386.31, 590.22, 884.36, 1088.27],
4421
+ },
4422
+ darbari: {
4423
+ steps: [0, 2, 3, 5, 7, 8, 10],
4424
+ mode: "minor",
4425
+ family: "raga",
4426
+ intonation: [0, 203.91, 294.13, 498.04, 701.96, 792.18, 996.09],
4427
+ aliases: ["darbari kanada"],
4428
+ },
4429
+ malkauns: {
4430
+ steps: [0, 3, 5, 8, 10],
4431
+ mode: "minor",
4432
+ family: "raga",
4433
+ intonation: [0, 315.64, 498.04, 813.69, 996.09],
4434
+ },
4435
+ bhupali: {
4436
+ steps: [0, 2, 4, 7, 9],
4437
+ mode: "major",
4438
+ family: "raga",
4439
+ intonation: [0, 203.91, 386.31, 701.96, 884.36],
4440
+ },
4441
+ durga: {
4442
+ steps: [0, 2, 5, 7, 9],
4443
+ mode: "major",
4444
+ family: "raga",
4445
+ intonation: [0, 203.91, 498.04, 701.96, 884.36],
4446
+ },
4447
+ "messiaen-1": {
4448
+ steps: [0, 2, 4, 6, 8, 10],
4449
+ mode: "lydian",
4450
+ family: "messiaen",
4451
+ aliases: ["whole-tone", "whole tone", "wholetone"],
4452
+ },
4453
+ "messiaen-2": {
4454
+ steps: [0, 1, 3, 4, 6, 7, 9, 10],
4455
+ mode: "mixolydian",
4456
+ family: "messiaen",
4457
+ aliases: ["octatonic", "diminished", "half-whole"],
4458
+ },
4459
+ "messiaen-3": {
4460
+ steps: [0, 2, 3, 4, 6, 7, 8, 10, 11],
4461
+ mode: "minor",
4462
+ family: "messiaen",
4463
+ },
4464
+ "messiaen-4": {
4465
+ steps: [0, 1, 2, 5, 6, 7, 8, 11],
4466
+ mode: "harmonic-minor",
4467
+ family: "messiaen",
4468
+ },
4469
+ "messiaen-5": {
4470
+ steps: [0, 1, 5, 6, 7, 11],
4471
+ mode: "lydian",
4472
+ family: "messiaen",
4473
+ },
4474
+ "messiaen-6": {
4475
+ steps: [0, 2, 4, 5, 6, 8, 10, 11],
4476
+ mode: "major",
4477
+ family: "messiaen",
4478
+ },
4479
+ "messiaen-7": {
4480
+ steps: [0, 1, 2, 3, 5, 6, 7, 8, 9, 11],
4481
+ mode: "harmonic-minor",
4482
+ family: "messiaen",
4483
+ },
4484
+ } as const satisfies Record<string, ScaleInfo>);
4485
+ type ScaleName = keyof typeof SCALES;
4486
+ const SCALE_NAMES = Object.keys(SCALES) as ScaleName[];
4487
+
4488
+ const SCALE_ALIASES: Readonly<Record<string, ScaleName>> = (() => {
4489
+ const aliases: Record<string, ScaleName> = {};
4490
+ for (const name of SCALE_NAMES) {
4491
+ const info: ScaleInfo = SCALES[name];
4492
+ aliases[name] = name;
4493
+ aliases[name.replace(/-/g, " ")] = name;
4494
+ for (const alias of info.aliases ?? []) aliases[alias] = name;
4495
+ }
4496
+ return Object.freeze(aliases);
4497
+ })();
4498
+
4499
+ /** The library scale named `text` (case and `-`/space insensitive). */
4500
+ function scaleNamed(text: string): ScaleName | undefined {
4501
+ const word = text.trim().toLowerCase().replace(/\s+/g, " ");
4502
+ return SCALE_ALIASES[word] ?? SCALE_ALIASES[word.replace(/ /g, "-")];
4503
+ }
4504
+
4505
+ /**
4506
+ * A key: a tonic and the seven-note `mode` the chord engine uses, plus the
4507
+ * library `scale` when the key names one (`D bayati`, `C yaman`).
4508
+ */
4509
+ type Key = Readonly<{
4510
+ tonic: number;
4511
+ mode: ModeName;
4512
+ scale?: ScaleName;
4513
+ }>;
2843
4514
 
2844
4515
  /**
2845
- * Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`.
2846
- * Accepts the `<note> <mode>` form `core/key.ts` writes.
4516
+ * Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`,
4517
+ * `D bayati`, `C messiaen-3`. Accepts the `<note> <mode>` form
4518
+ * `core/key.ts` writes.
2847
4519
  */
2848
4520
  function parseKey(text: string | null | undefined): Key | undefined {
2849
4521
  if (typeof text !== "string" || text.length > 40) return undefined;
2850
4522
  const match = text
2851
4523
  .trim()
2852
- .match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z -]*)?$/);
4524
+ .match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z0-9 -]*)?$/);
2853
4525
  if (!match) return undefined;
2854
4526
  const tonic = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
4527
+ if (tonic === undefined) return undefined;
2855
4528
  const word = (match[3] ?? "").trim();
2856
4529
  const mode = MODE_ALIASES[word === "m" ? "m" : word.toLowerCase()];
2857
- if (tonic === undefined || mode === undefined) return undefined;
2858
- return Object.freeze({ tonic, mode });
4530
+ if (mode !== undefined) return Object.freeze({ tonic, mode });
4531
+ const scale = scaleNamed(word);
4532
+ if (scale === undefined) return undefined;
4533
+ return Object.freeze({ tonic, mode: SCALES[scale].mode, scale });
4534
+ }
4535
+
4536
+ /**
4537
+ * Steps of the key's whole scale in semitones above the tonic (fractional
4538
+ * for quarter tones): the library scale when the key names one, else the
4539
+ * mode.
4540
+ */
4541
+ function scaleSteps(key: Key): number[] {
4542
+ return [...(key.scale ? SCALES[key.scale].steps : MODES[key.mode])];
2859
4543
  }
2860
4544
 
2861
4545
  /** True when names in the key read better with flats (F, Bb, Eb, d minor…). */
@@ -2870,6 +4554,8 @@ function keyUsesFlats(key: Key): boolean {
2870
4554
  minor: 9,
2871
4555
  locrian: 11,
2872
4556
  "harmonic-minor": 9,
4557
+ "melodic-minor": 9,
4558
+ "phrygian-dominant": 4,
2873
4559
  };
2874
4560
  const parent = mod12(key.tonic - parentOffset[key.mode]);
2875
4561
  return [5, 10, 3, 8, 1].includes(parent);
@@ -2877,7 +4563,7 @@ function keyUsesFlats(key: Key): boolean {
2877
4563
 
2878
4564
  /** `C major`, `F# dorian`, `Bb minor`. */
2879
4565
  function keyName(key: Key): string {
2880
- return `${noteName(key.tonic, keyUsesFlats(key))} ${key.mode}`;
4566
+ return `${noteName(key.tonic, keyUsesFlats(key))} ${key.scale ?? key.mode}`;
2881
4567
  }
2882
4568
 
2883
4569
  /** Pitch classes of the key's scale, tonic first. */