@hraness/dawg 0.3.0 → 0.4.1

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 (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
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.0.0";
30
+ export const SDK_VERSION = "1.13.0";
31
31
  /** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
32
32
  export const SDK_MAJOR = 1;
33
33
 
@@ -305,13 +305,723 @@ export function every(step: number, options: EveryOptions = {}): number[] {
305
305
  return beats;
306
306
  }
307
307
 
308
+ // ---------------------------------------------------------------------------
309
+ // Rhythm rows (Euclidean generators, Torso T-1 style)
310
+
311
+ /** Per-pass variation of a rhythm row (T-1 Cycles). */
312
+ export type RhythmCycleSpec = Readonly<{
313
+ pulses?: number;
314
+ rotate?: number;
315
+ repeats?: number;
316
+ probability?: number;
317
+ velocity?: number;
318
+ }>;
319
+
320
+ /**
321
+ * Parameters of one generated voice. Every field is optional; dawg checks
322
+ * ranges when the song loads. Note values are strings: `"1/16"`, `"1/8t"`.
323
+ */
324
+ export type RhythmOptions = Readonly<{
325
+ /** Steps 1..64, default 16. */
326
+ steps?: number;
327
+ /** Hits 0..steps spread as evenly as possible, default 4. */
328
+ pulses?: number;
329
+ /** Shift the pattern later by this many steps (negative: earlier), like Strudel's `euclidRot`. */
330
+ rotate?: number;
331
+ /** Length of a step, default `"1/16"`. */
332
+ division?: string;
333
+ /** Explicit steps instead of a Euclidean pattern: `x` hit, `X` accent, `.` rest. */
334
+ grid?: string;
335
+ /** Extra triggers after each pulse, 0..16 (T-1 Repeats). Cut off by the next pulse. */
336
+ repeats?: number;
337
+ /** Spacing of the repeats as a note value, default the step (T-1 Time). */
338
+ time?: string;
339
+ /** -1..1: repeats accelerate (<0) or decelerate (>0) (T-1 Pace). */
340
+ pace?: number;
341
+ /** -1..1: repeats fade out (<0) or build up (>0). */
342
+ ramp?: number;
343
+ /** Base velocity 0..1, default 0.8. */
344
+ velocity?: number;
345
+ /** 0..1: how far accented pulses rise toward full velocity. */
346
+ accent?: number;
347
+ /** Accented pulses as E(accents, pulses); default 1 (the first). */
348
+ accents?: number;
349
+ /** Note length in steps, 0.05..4 (T-1 Sustain), default 1. */
350
+ gate?: number;
351
+ /** Every pulse lasts until the next one, like Strudel's `euclidLegato`. */
352
+ legato?: boolean;
353
+ /** Chance 0..1 that a pulse plays; deterministic for a given `seed`. */
354
+ probability?: number;
355
+ /** Integer 0..1000000 choosing which pulses `probability` drops. */
356
+ seed?: number;
357
+ /** -0.5..0.5 of a step: every second step later (>0) or earlier. */
358
+ swing?: number;
359
+ /** -0.5..0.5 of a step: the whole row later or earlier. */
360
+ nudge?: number;
361
+ /** Variations applied on successive passes of the row (T-1 Cycles). */
362
+ cycles?: readonly RhythmCycleSpec[];
363
+ }>;
364
+
365
+ /** One generated voice on a track's `rhythm` list. Build with `euclid()` or `grid()`. */
366
+ export type RhythmSpec = Readonly<
367
+ RhythmOptions & {
368
+ kind: "rhythm";
369
+ voice: string;
370
+ }
371
+ >;
372
+
373
+ /**
374
+ * A Euclidean rhythm row: `pulses` hits spread over `steps`, rotated later
375
+ * by `rotate` steps. Same patterns and rotation direction as Strudel's
376
+ * `euclid`/`euclidRot` (`euclid("kick", 3, 8)` is `x..x..x.`). dawg expands
377
+ * the row into hits when the song loads, so you edit the parameters, not
378
+ * the notes; the row repeats every `steps` steps to the end of the loop.
379
+ *
380
+ * ```ts
381
+ * rhythm: [
382
+ * euclid("kick", 4, 16),
383
+ * euclid("hat", 7, 16, 2, { velocity: 0.5, accent: 0.6, accents: 3 }),
384
+ * euclid({ voice: "snare", pulses: 2, steps: 16, rotate: 4 }),
385
+ * ]
386
+ * ```
387
+ */
388
+ export function euclid(
389
+ voice: string | (RhythmOptions & { voice: string }),
390
+ pulses?: number,
391
+ steps?: number,
392
+ rotate?: number | RhythmOptions,
393
+ options: RhythmOptions = {},
394
+ ): RhythmSpec {
395
+ if (isRecord(voice)) {
396
+ const input = voice as RhythmOptions & { voice: string };
397
+ return rhythmSpec(input.voice, input);
398
+ }
399
+ // `euclid("hat", 7, 16, { velocity: 0.5 })`: options without a rotate.
400
+ if (isRecord(rotate)) {
401
+ options = { ...(rotate as RhythmOptions), ...options };
402
+ rotate = undefined;
403
+ }
404
+ const fields: Record<string, unknown> = { ...options };
405
+ if (pulses !== undefined) fields.pulses = pulses;
406
+ if (steps !== undefined) fields.steps = steps;
407
+ if (rotate !== undefined) fields.rotate = rotate;
408
+ return rhythmSpec(voice, fields as RhythmOptions);
409
+ }
410
+
411
+ /** `euclid(voice, pulses, steps, rotate)` under Strudel's name. */
412
+ export function euclidRot(
413
+ voice: string,
414
+ pulses: number,
415
+ steps: number,
416
+ rotate: number,
417
+ options: RhythmOptions = {},
418
+ ): RhythmSpec {
419
+ return euclid(voice, pulses, steps, rotate, options);
420
+ }
421
+
422
+ /** A Euclidean row whose hits last until the next one (Strudel `euclidLegato`). */
423
+ export function euclidLegato(
424
+ voice: string,
425
+ pulses: number,
426
+ steps: number,
427
+ rotate = 0,
428
+ options: RhythmOptions = {},
429
+ ): RhythmSpec {
430
+ return euclid(voice, pulses, steps, rotate, { ...options, legato: true });
431
+ }
432
+
433
+ /**
434
+ * An explicit step row: `grid("snare", "....x.......x...")`. `X` is an
435
+ * accented hit; the string's length is the step count.
436
+ */
437
+ export function grid(
438
+ voice: string,
439
+ steps: string,
440
+ options: RhythmOptions = {},
441
+ ): RhythmSpec {
442
+ return rhythmSpec(voice, { ...options, grid: steps });
443
+ }
444
+
445
+ function rhythmSpec(voice: unknown, options: RhythmOptions): RhythmSpec {
446
+ if (
447
+ typeof voice !== "string" ||
448
+ voice.trim().length === 0 ||
449
+ voice.length > 32
450
+ )
451
+ throw new DawgSdkError("rhythm voice must be a short name");
452
+ if (!isRecord(options))
453
+ throw new DawgSdkError("rhythm options must be an object");
454
+ const out: Record<string, unknown> = { kind: "rhythm", voice: voice.trim() };
455
+ for (const [key, value] of Object.entries(options)) {
456
+ if (key === "voice" || key === "kind" || value === undefined) continue;
457
+ if (!RHYTHM_KEYS.includes(key))
458
+ throw new DawgSdkError(
459
+ `rhythm ${voice}: unknown option "${key}" (${RHYTHM_KEYS.join(" ")})`,
460
+ );
461
+ out[key] =
462
+ key === "cycles" && Array.isArray(value)
463
+ ? Object.freeze(value.map((cycle) => Object.freeze({ ...cycle })))
464
+ : value;
465
+ }
466
+ return Object.freeze(out) as RhythmSpec;
467
+ }
468
+
469
+ /** Row fields in the order dawg stores and prints them. */
470
+ export const RHYTHM_KEYS: readonly string[] = Object.freeze([
471
+ "steps",
472
+ "pulses",
473
+ "rotate",
474
+ "division",
475
+ "grid",
476
+ "repeats",
477
+ "time",
478
+ "pace",
479
+ "ramp",
480
+ "velocity",
481
+ "accent",
482
+ "accents",
483
+ "gate",
484
+ "legato",
485
+ "probability",
486
+ "seed",
487
+ "swing",
488
+ "nudge",
489
+ "cycles",
490
+ ]);
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Drum pattern library
494
+
495
+ /**
496
+ * A named starting groove: one rhythm row per voice, ready for a
497
+ * `instrument: "kit"` track. Rows are Euclidean where the part is
498
+ * Euclidean and explicit grids otherwise. All patterns are 4/4; `swing`
499
+ * is already applied to the rows.
500
+ */
501
+ export type DrumPattern = Readonly<{
502
+ name: string;
503
+ label: string;
504
+ tags: readonly string[];
505
+ /** Usual tempo range and a suggested tempo, BPM. */
506
+ tempo: Readonly<{ min: number; max: number; bpm: number }>;
507
+ beatsPerBar: number;
508
+ /** Swing of the 16th rows, -0.5..0.5 of a step. */
509
+ swing: number;
510
+ /** A synthesized kit that suits it (see `kit` on `track()`). */
511
+ kit: string;
512
+ rows: readonly RhythmSpec[];
513
+ }>;
514
+
515
+ function drumPattern(
516
+ name: string,
517
+ label: string,
518
+ tags: readonly string[],
519
+ tempo: readonly [number, number, number],
520
+ kit: string,
521
+ swing: number,
522
+ rows: readonly RhythmSpec[],
523
+ ): DrumPattern {
524
+ return Object.freeze({
525
+ name,
526
+ label,
527
+ tags: Object.freeze([...tags]),
528
+ tempo: Object.freeze({ min: tempo[0], max: tempo[1], bpm: tempo[2] }),
529
+ beatsPerBar: 4,
530
+ swing,
531
+ kit,
532
+ rows: Object.freeze(
533
+ rows.map((row) =>
534
+ swing !== 0 && row.swing === undefined && row.division === undefined
535
+ ? Object.freeze({ ...row, swing })
536
+ : row,
537
+ ),
538
+ ),
539
+ });
540
+ }
541
+
542
+ /**
543
+ * The library. Written for dawg from common knowledge of each style (no
544
+ * transcriptions): the defining placements of kick, snare and hats, kept
545
+ * short so they are easy to vary.
546
+ */
547
+ export const DRUM_PATTERNS: readonly DrumPattern[] = Object.freeze([
548
+ drumPattern(
549
+ "house",
550
+ "House four-on-the-floor",
551
+ ["house", "dance", "four-on-the-floor"],
552
+ [118, 128, 124],
553
+ "syn909",
554
+ 0,
555
+ [
556
+ euclid("kick", 4, 16),
557
+ grid("clap", "....x.......x..."),
558
+ euclid("openhat", 4, 16, 2, { velocity: 0.6 }),
559
+ euclid("hat", 16, 16, 0, { velocity: 0.35, accent: 0.4, accents: 4 }),
560
+ ],
561
+ ),
562
+ drumPattern(
563
+ "disco",
564
+ "Disco",
565
+ ["disco", "dance", "four-on-the-floor"],
566
+ [110, 125, 118],
567
+ "acoustic",
568
+ 0,
569
+ [
570
+ euclid("kick", 4, 16),
571
+ grid("snare", "....x.......x..."),
572
+ euclid("openhat", 4, 16, 2, { velocity: 0.65 }),
573
+ euclid("hat", 8, 16, 0, { velocity: 0.45 }),
574
+ ],
575
+ ),
576
+ drumPattern(
577
+ "techno",
578
+ "Techno",
579
+ ["techno", "dance", "four-on-the-floor"],
580
+ [125, 140, 132],
581
+ "syn909",
582
+ 0,
583
+ [
584
+ euclid("kick", 4, 16),
585
+ euclid("openhat", 4, 16, 2, { velocity: 0.55 }),
586
+ euclid("hat", 16, 16, 0, {
587
+ velocity: 0.4,
588
+ accent: 0.5,
589
+ accents: 4,
590
+ probability: 0.9,
591
+ seed: 7,
592
+ }),
593
+ euclid("rim", 3, 8, 3, { velocity: 0.55 }),
594
+ grid("clap", "............x...", { velocity: 0.7 }),
595
+ ],
596
+ ),
597
+ drumPattern(
598
+ "minimal",
599
+ "Minimal Euclidean",
600
+ ["minimal", "techno", "euclidean"],
601
+ [120, 130, 124],
602
+ "electro",
603
+ 0,
604
+ [
605
+ euclid("kick", 4, 16),
606
+ euclid("rim", 5, 16, 3, { velocity: 0.6 }),
607
+ euclid("hat", 7, 16, 2, { velocity: 0.45, accent: 0.5, accents: 3 }),
608
+ euclid("tom", 3, 16, 6, { velocity: 0.5 }),
609
+ ],
610
+ ),
611
+ drumPattern(
612
+ "electro",
613
+ "Electro",
614
+ ["electro", "breaks"],
615
+ [120, 135, 128],
616
+ "electro",
617
+ 0,
618
+ [
619
+ grid("kick", "x.....x..x......"),
620
+ grid("snare", "....x.......x..."),
621
+ euclid("hat", 16, 16, 0, { velocity: 0.4, accent: 0.5, accents: 4 }),
622
+ grid("clap", "....x.......x..x", { velocity: 0.6 }),
623
+ ],
624
+ ),
625
+ drumPattern(
626
+ "breakbeat",
627
+ "Breakbeat",
628
+ ["breaks", "big beat"],
629
+ [120, 140, 130],
630
+ "acoustic",
631
+ 0,
632
+ [
633
+ grid("kick", "x.........x.x...x.x.......x....."),
634
+ grid("snare", "....x.......x.......x..x....x..."),
635
+ euclid("hat", 8, 16, 0, { velocity: 0.5 }),
636
+ ],
637
+ ),
638
+ drumPattern(
639
+ "amen-style",
640
+ "Amen-style break",
641
+ ["breaks", "jungle", "drum and bass"],
642
+ [160, 176, 170],
643
+ "acoustic",
644
+ 0,
645
+ [
646
+ grid("kick", "x.x.......xx....x.x.......x....."),
647
+ grid("snare", "....X..x.x..X..x....X..x.x....X.", {
648
+ velocity: 0.55,
649
+ accent: 0.4,
650
+ }),
651
+ euclid("hat", 8, 16, 0, { velocity: 0.45 }),
652
+ ],
653
+ ),
654
+ drumPattern(
655
+ "dnb",
656
+ "Drum & bass two-step",
657
+ ["drum and bass", "jungle"],
658
+ [168, 178, 174],
659
+ "syn909",
660
+ 0,
661
+ [
662
+ grid("kick", "x.........x....."),
663
+ grid("snare", "....x.......x..."),
664
+ euclid("hat", 8, 16, 1, { velocity: 0.45 }),
665
+ grid("openhat", "..............x.", { velocity: 0.4 }),
666
+ ],
667
+ ),
668
+ drumPattern(
669
+ "halftime",
670
+ "Halftime",
671
+ ["halftime", "drum and bass", "dubstep"],
672
+ [140, 175, 170],
673
+ "syn909",
674
+ 0,
675
+ [
676
+ grid("kick", "x.........x.....x......x.x......"),
677
+ grid("snare", "........x.......", { velocity: 0.95 }),
678
+ euclid("hat", 8, 16, 0, { velocity: 0.4, probability: 0.85, seed: 3 }),
679
+ ],
680
+ ),
681
+ drumPattern(
682
+ "boom-bap",
683
+ "Boom bap",
684
+ ["hip hop", "boom bap"],
685
+ [84, 96, 90],
686
+ "lofi",
687
+ 0.12,
688
+ [
689
+ grid("kick", "x......x..x.....x.x....x..x....."),
690
+ grid("snare", "....x.......x..."),
691
+ euclid("hat", 8, 16, 0, { velocity: 0.5, accent: 0.4, accents: 4 }),
692
+ ],
693
+ ),
694
+ drumPattern(
695
+ "lofi",
696
+ "Lo-fi hip hop",
697
+ ["hip hop", "lo-fi", "chill"],
698
+ [70, 90, 80],
699
+ "lofi",
700
+ 0.18,
701
+ [
702
+ grid("kick", "x.........x.....x......x..x....."),
703
+ grid("snare", "....x.......x..."),
704
+ euclid("hat", 8, 16, 0, { velocity: 0.4, probability: 0.9, seed: 11 }),
705
+ grid("rim", "...............x", { velocity: 0.4 }),
706
+ ],
707
+ ),
708
+ drumPattern(
709
+ "trap",
710
+ "Trap with hat rolls",
711
+ ["trap", "hip hop"],
712
+ [130, 160, 140],
713
+ "trap",
714
+ 0,
715
+ [
716
+ grid("kick", "x......x..x.....x.x....x......x."),
717
+ grid("snare", "........x......."),
718
+ grid("hat", "x.x.x.x.x.x.x.x.x.x.x.x.x.xxxxxx", {
719
+ division: "1/32",
720
+ velocity: 0.45,
721
+ }),
722
+ grid("openhat", "..............x.", { velocity: 0.35 }),
723
+ ],
724
+ ),
725
+ drumPattern("drill", "Drill", ["drill", "trap"], [138, 146, 142], "trap", 0, [
726
+ grid("kick", "x.....x.........x..x......x....."),
727
+ grid("snare", "........x..........x....x......."),
728
+ grid("hat", "x..x..x.x..x..x.", { velocity: 0.45 }),
729
+ ]),
730
+ drumPattern(
731
+ "reggaeton",
732
+ "Reggaeton / dembow",
733
+ ["reggaeton", "dembow", "latin"],
734
+ [88, 100, 95],
735
+ "syn808",
736
+ 0,
737
+ [
738
+ euclid("kick", 4, 16),
739
+ grid("snare", "...x..x....x..x."),
740
+ euclid("hat", 8, 16, 0, { velocity: 0.45 }),
741
+ ],
742
+ ),
743
+ drumPattern(
744
+ "dancehall",
745
+ "Dancehall",
746
+ ["dancehall", "caribbean"],
747
+ [90, 110, 100],
748
+ "syn808",
749
+ 0,
750
+ [
751
+ euclid("kick", 3, 8),
752
+ grid("snare", "....x.......x..."),
753
+ euclid("rim", 5, 16, 2, { velocity: 0.5 }),
754
+ euclid("hat", 8, 16, 0, { velocity: 0.4 }),
755
+ ],
756
+ ),
757
+ drumPattern(
758
+ "one-drop",
759
+ "Reggae one drop",
760
+ ["reggae", "dub"],
761
+ [66, 80, 74],
762
+ "acoustic",
763
+ 0.1,
764
+ [
765
+ grid("kick", "........x......."),
766
+ grid("rim", "........x......."),
767
+ euclid("hat", 8, 16, 0, { velocity: 0.45, accent: 0.4, accents: 2 }),
768
+ ],
769
+ ),
770
+ drumPattern(
771
+ "afrobeat",
772
+ "Afrobeat",
773
+ ["afrobeat", "african", "funk"],
774
+ [100, 120, 110],
775
+ "acoustic",
776
+ 0.05,
777
+ [
778
+ grid("kick", "x.....x...x.....x.....x...x..x.."),
779
+ grid("snare", "....x..x....x..x", { velocity: 0.6 }),
780
+ euclid("openhat", 4, 16, 2, { velocity: 0.45 }),
781
+ euclid("hat", 12, 16, 0, { velocity: 0.4 }),
782
+ grid("rim", "x.x.xx.x.x.x....", { velocity: 0.5 }),
783
+ ],
784
+ ),
785
+ drumPattern(
786
+ "afrobeats",
787
+ "Afrobeats / afro-pop",
788
+ ["afrobeats", "afro-pop", "african"],
789
+ [100, 115, 106],
790
+ "syn808",
791
+ 0.06,
792
+ [
793
+ euclid("kick", 4, 16),
794
+ grid("rim", "...x..x...x..x..", { velocity: 0.6 }),
795
+ euclid("hat", 8, 16, 0, { velocity: 0.4 }),
796
+ grid("clap", "............x...", { velocity: 0.6 }),
797
+ ],
798
+ ),
799
+ drumPattern(
800
+ "bembe",
801
+ "Bembé 12/8 bell",
802
+ ["afro-cuban", "african", "euclidean"],
803
+ [100, 130, 112],
804
+ "acoustic",
805
+ 0,
806
+ [
807
+ euclid("kick", 4, 12, 0, { division: "1/8t" }),
808
+ euclid("rim", 7, 12, 9, { division: "1/8t", velocity: 0.6 }),
809
+ euclid("hat", 12, 12, 0, {
810
+ division: "1/8t",
811
+ velocity: 0.35,
812
+ accent: 0.4,
813
+ accents: 4,
814
+ }),
815
+ ],
816
+ ),
817
+ drumPattern(
818
+ "tresillo",
819
+ "Tresillo",
820
+ ["latin", "euclidean", "habanera"],
821
+ [90, 120, 100],
822
+ "syn808",
823
+ 0,
824
+ [
825
+ euclid("kick", 3, 8),
826
+ grid("snare", "....x.......x..."),
827
+ euclid("hat", 8, 16, 0, { velocity: 0.4 }),
828
+ ],
829
+ ),
830
+ drumPattern(
831
+ "son-clave",
832
+ "Son clave groove",
833
+ ["afro-cuban", "salsa", "latin"],
834
+ [90, 120, 100],
835
+ "acoustic",
836
+ 0,
837
+ [
838
+ grid("rim", "x..x..x...x.x...", { velocity: 0.65 }),
839
+ grid("kick", "...x.......x....", { velocity: 0.7 }),
840
+ euclid("hat", 8, 16, 0, { velocity: 0.35 }),
841
+ ],
842
+ ),
843
+ drumPattern(
844
+ "bossa-nova",
845
+ "Bossa nova",
846
+ ["bossa nova", "brazilian", "latin"],
847
+ [120, 145, 132],
848
+ "acoustic",
849
+ 0,
850
+ [
851
+ grid("kick", "x..xx..xx..xx..x", { velocity: 0.6 }),
852
+ grid("rim", "x..x..x...x..x..", { velocity: 0.55 }),
853
+ euclid("hat", 16, 16, 0, { velocity: 0.3, accent: 0.4, accents: 4 }),
854
+ ],
855
+ ),
856
+ drumPattern(
857
+ "samba",
858
+ "Samba",
859
+ ["samba", "brazilian", "latin"],
860
+ [92, 110, 100],
861
+ "acoustic",
862
+ 0.04,
863
+ [
864
+ grid("kick", "x..xX..xx..xX..x", { velocity: 0.6, accent: 0.5 }),
865
+ grid("rim", "x.x..x.x.x.x..x.", { velocity: 0.5 }),
866
+ euclid("hat", 16, 16, 0, { velocity: 0.35, accent: 0.5, accents: 4 }),
867
+ ],
868
+ ),
869
+ drumPattern(
870
+ "cumbia",
871
+ "Cumbia",
872
+ ["cumbia", "latin"],
873
+ [85, 105, 95],
874
+ "acoustic",
875
+ 0,
876
+ [
877
+ grid("kick", "x.......x......."),
878
+ grid("rim", "....x.......x...", { velocity: 0.6 }),
879
+ euclid("hat", 12, 16, 0, { velocity: 0.35, accent: 0.5, accents: 4 }),
880
+ euclid("openhat", 4, 16, 2, { velocity: 0.4 }),
881
+ ],
882
+ ),
883
+ drumPattern(
884
+ "garage",
885
+ "UK garage 2-step",
886
+ ["uk garage", "2-step", "dance"],
887
+ [128, 136, 132],
888
+ "syn909",
889
+ 0.15,
890
+ [
891
+ grid("kick", "x.........x..x..x.......x.x....."),
892
+ grid("snare", "....x.......x..."),
893
+ euclid("hat", 12, 16, 0, { velocity: 0.4 }),
894
+ euclid("openhat", 4, 16, 2, { velocity: 0.35 }),
895
+ ],
896
+ ),
897
+ drumPattern(
898
+ "jersey-club",
899
+ "Jersey club",
900
+ ["jersey club", "club"],
901
+ [135, 145, 140],
902
+ "syn808",
903
+ 0,
904
+ [
905
+ grid("kick", "x...x...x..x.x..x...x...x.x.x.x."),
906
+ grid("clap", "....x.......x..."),
907
+ euclid("hat", 8, 16, 0, { velocity: 0.4 }),
908
+ ],
909
+ ),
910
+ drumPattern(
911
+ "footwork",
912
+ "Footwork / juke",
913
+ ["footwork", "juke", "chicago"],
914
+ [155, 165, 160],
915
+ "syn808",
916
+ 0,
917
+ [
918
+ grid("kick", "x..x..x...x..x..x..x..x...x.x.x."),
919
+ grid("clap", "............x..."),
920
+ euclid("hat", 6, 16, 2, { velocity: 0.45 }),
921
+ euclid("tom", 3, 16, 8, { velocity: 0.5 }),
922
+ ],
923
+ ),
924
+ drumPattern(
925
+ "rock",
926
+ "Rock basic",
927
+ ["rock", "pop"],
928
+ [100, 140, 120],
929
+ "acoustic",
930
+ 0,
931
+ [
932
+ grid("kick", "x.......x.x....."),
933
+ grid("snare", "....x.......x..."),
934
+ euclid("hat", 8, 16, 0, { velocity: 0.55, accent: 0.3, accents: 4 }),
935
+ ],
936
+ ),
937
+ drumPattern(
938
+ "funk",
939
+ "Funk with ghost notes",
940
+ ["funk", "soul"],
941
+ [95, 110, 102],
942
+ "acoustic",
943
+ 0.08,
944
+ [
945
+ grid("kick", "x.x.......x..x.."),
946
+ grid("snare", ".x..X..x.x..X..x", { velocity: 0.4, accent: 0.9 }),
947
+ euclid("hat", 16, 16, 0, { velocity: 0.35, accent: 0.4, accents: 4 }),
948
+ ],
949
+ ),
950
+ drumPattern(
951
+ "shuffle",
952
+ "Shuffle",
953
+ ["blues", "shuffle", "rock"],
954
+ [90, 130, 110],
955
+ "acoustic",
956
+ 0.33,
957
+ [
958
+ grid("kick", "x.......x......."),
959
+ grid("snare", "....x.......x..."),
960
+ euclid("hat", 16, 16, 0, { velocity: 0.35, accent: 0.5, accents: 8 }),
961
+ ],
962
+ ),
963
+ drumPattern(
964
+ "euclid-poly",
965
+ "Euclidean polymeter",
966
+ ["euclidean", "experimental", "polymeter"],
967
+ [110, 130, 120],
968
+ "electro",
969
+ 0,
970
+ [
971
+ euclid("kick", 5, 16),
972
+ euclid("snare", 3, 8, 2, { velocity: 0.7 }),
973
+ euclid("hat", 7, 12, 0, { velocity: 0.45, accent: 0.5, accents: 3 }),
974
+ euclid("rim", 4, 10, 1, { velocity: 0.5, probability: 0.8, seed: 21 }),
975
+ ],
976
+ ),
977
+ ]);
978
+
979
+ /** The pattern named `name` (case-insensitive), if any. */
980
+ export function findPattern(name: string): DrumPattern | undefined {
981
+ const key = String(name)
982
+ .trim()
983
+ .toLowerCase()
984
+ .replace(/[\s_]+/g, "-");
985
+ return DRUM_PATTERNS.find((entry) => entry.name === key);
986
+ }
987
+
988
+ /**
989
+ * A library pattern's rows, for a kit track's `rhythm`:
990
+ *
991
+ * ```ts
992
+ * track({ name: "drums", instrument: "kit", kit: "lofi", rhythm: pattern("boom-bap") })
993
+ * ```
994
+ *
995
+ * Spread it to change or add rows: `[...pattern("house"), euclid("rim", 5, 16)]`.
996
+ * Rows with the same voice must not repeat, so drop the original first.
997
+ */
998
+ export function pattern(name: string): readonly RhythmSpec[] {
999
+ const found = findPattern(name);
1000
+ if (!found)
1001
+ throw new DawgSdkError(
1002
+ `unknown drum pattern "${String(name).slice(0, 40)}" (${DRUM_PATTERNS.map((entry) => entry.name).join(" ")})`,
1003
+ );
1004
+ return found.rows;
1005
+ }
1006
+
308
1007
  // ---------------------------------------------------------------------------
309
1008
  // Sampler
310
1009
 
311
1010
  /** One sample voice. A bare string is `{ src }`. */
312
1011
  export type SampleSpec = Readonly<{
313
- /** Audio file: track-relative (`samples/kick.wav`) or project-relative (`tracks/x/samples/kick.wav`). */
1012
+ /**
1013
+ * Audio file: track-relative (`samples/kick.wav`) or project-relative
1014
+ * (`tracks/x/samples/kick.wav`), or a pack sound
1015
+ * `pack:<pack>/<sound>[:<n>]` such as `pack:tidal-drum-machines/RolandTR909_bd:0`
1016
+ * (fetched once into the cache; see `/pack`).
1017
+ */
314
1018
  src: string;
1019
+ /** Pack sounds: content hash dawg pinned when the sound was first used. */
1020
+ sha256?: string;
1021
+ /** Pack sounds: the pinned HTTPS file. */
1022
+ url?: string;
1023
+ /** Pack sounds: the pack's license, recorded for credits. */
1024
+ license?: string;
315
1025
  /** Pitch the file plays at, keyed mode only; default C4. */
316
1026
  root?: Pitch;
317
1027
  /** Start fraction 0..1 of the file, like Strudel `begin`. */
@@ -326,6 +1036,28 @@ export type SampleSpec = Readonly<{
326
1036
  loop?: boolean;
327
1037
  /** Choke group, like Strudel `cut`: a new hit stops the previous one in the group. */
328
1038
  choke?: string;
1039
+ /** Looped part, like Strudel `loopBegin`/`loopb` (fraction, ≥ begin). */
1040
+ loopBegin?: number;
1041
+ /** Alias of `loopBegin` (Strudel `loopb`). */
1042
+ loopb?: number;
1043
+ /** Looped part end, like Strudel `loopEnd`/`loope` (fraction, ≤ end). */
1044
+ loopEnd?: number;
1045
+ /** Alias of `loopEnd` (Strudel `loope`). */
1046
+ loope?: number;
1047
+ /** Like Strudel `clip`: the voice lasts note length × clip (0 < clip ≤ 16), cutting the sample. */
1048
+ clip?: number;
1049
+ /** Alias of `clip` (Strudel `legato`). */
1050
+ legato?: number;
1051
+ /** Like Tidal `unit`: `"r"` rate (default), `"c"` speed in cycles (bars), `"s"` speed in seconds. */
1052
+ unit?: "r" | "c" | "s";
1053
+ /** Like Strudel `fit`: the window lasts exactly the note's length. */
1054
+ fit?: boolean;
1055
+ /** Like Strudel `loopAt(n)`: the window lasts n bars (stored as `speed: 1/n, unit: "c"`). */
1056
+ loopAt?: number;
1057
+ /** Like Tidal `accelerate`: rate ramps by this × the start rate over the voice (−8..8). */
1058
+ accelerate?: number;
1059
+ /** Like Tidal `squiz`: pitch-raise ratio per zero-crossing cycle (1..32). */
1060
+ squiz?: number;
329
1061
  }>;
330
1062
 
331
1063
  /** Result of `sampler()`; pass it as a track's `instrument`. */
@@ -371,6 +1103,117 @@ export function sampler(
371
1103
  return Object.freeze({ kind: "sampler", voices: Object.freeze(out), mode });
372
1104
  }
373
1105
 
1106
+ /** Instrument name that selects a track's wavetable oscillator. */
1107
+ export const WAVETABLE_INSTRUMENT = "wavetable";
1108
+
1109
+ /** Strudel's `warpmode` names. */
1110
+ export type WarpMode =
1111
+ "none" | "asym" | "bendp" | "bendm" | "bendmp" | "sync" | "quant";
1112
+
1113
+ /** Wavetable parameters, with Strudel's names. Omitted means default. */
1114
+ export type WavetableParams = Readonly<{
1115
+ /** Position 0..1 (default 0). */
1116
+ wt?: number;
1117
+ /** Position envelope amount -1..1 and its ADSR (seconds, sustain 0..1). */
1118
+ wtenv?: number;
1119
+ wtattack?: number;
1120
+ wtdecay?: number;
1121
+ wtsustain?: number;
1122
+ wtrelease?: number;
1123
+ /** Position LFO rate (Hz) and depth 0..1. */
1124
+ wtrate?: number;
1125
+ wtdepth?: number;
1126
+ /** Phase warp amount 0..1 and mode. */
1127
+ warp?: number;
1128
+ warpmode?: WarpMode;
1129
+ /** Start phase randomness 0..1 (seeded per note). */
1130
+ wtphaserand?: number;
1131
+ }>;
1132
+
1133
+ /** Result of `wavetable()`; pass it as a track's `instrument`. */
1134
+ export type WavetableSpec = Readonly<
1135
+ { kind: "wavetable"; table: SampleSpec } & WavetableParams
1136
+ >;
1137
+
1138
+ const WAVETABLE_KEYS = Object.freeze([
1139
+ "wt",
1140
+ "wtenv",
1141
+ "wtattack",
1142
+ "wtdecay",
1143
+ "wtsustain",
1144
+ "wtrelease",
1145
+ "wtrate",
1146
+ "wtdepth",
1147
+ "warp",
1148
+ "wtphaserand",
1149
+ ] as const);
1150
+
1151
+ /**
1152
+ * A wavetable instrument. `table` is a built-in (`basic`, `pwm`,
1153
+ * `formant`, `harmonics`), a Strudel `wt_` sound (`wt_digital:2` plays
1154
+ * `pack:uzu-wavetables/wt_digital:2`), any `pack:` ref, a project WAV
1155
+ * (`./wavetables/vox.wav`, relative to the track's directory, as the agent's
1156
+ * make_wavetable writes it), or a pinned `{ src, sha256, url }` that dawg
1157
+ * writes back after resolving it.
1158
+ *
1159
+ * ```ts
1160
+ * instrument: wavetable("basic", { wt: 0.4 })
1161
+ * instrument: wavetable("./wavetables/vox.wav", { wtenv: 0.5 })
1162
+ * instrument: wavetable("wt_vgame:3", { wtenv: 0.6, wtdecay: 0.4, warp: 0.3, warpmode: "bendp" })
1163
+ * ```
1164
+ */
1165
+ export function wavetable(
1166
+ table: string | SampleSpec,
1167
+ params: WavetableParams = {},
1168
+ ): WavetableSpec {
1169
+ if (!isRecord(params))
1170
+ throw new DawgSdkError("wavetable params must be an object");
1171
+ const spec = typeof table === "string" ? { src: table } : table;
1172
+ if (!isRecord(spec) || typeof spec.src !== "string" || spec.src.length === 0)
1173
+ throw new DawgSdkError("wavetable needs a table name");
1174
+ let src = spec.src.trim();
1175
+ if (/\.wav$/i.test(src) && !src.startsWith("pack:")) {
1176
+ // A project table (make_wavetable writes tracks/<slug>/wavetables/x.wav);
1177
+ // `./wavetables/x.wav` is relative to the track's directory.
1178
+ if (
1179
+ src.includes("..") ||
1180
+ src.includes(":") ||
1181
+ src.startsWith("/") ||
1182
+ src.includes("\\")
1183
+ )
1184
+ throw new DawgSdkError(
1185
+ `wavetable file "${src.slice(0, 60)}" must be a project-relative path without ".."`,
1186
+ );
1187
+ } else if (!src.includes(":") || /^wt_[A-Za-z0-9_]+:[0-9]+$/.test(src))
1188
+ src = src.startsWith("wt_")
1189
+ ? `pack:uzu-wavetables/${src}`
1190
+ : `builtin:${src.toLowerCase()}`;
1191
+ else if (!/^(?:pack|builtin):/.test(src))
1192
+ throw new DawgSdkError(
1193
+ `wavetable table "${src.slice(0, 40)}" must be a built-in, wt_<set>:<n>, pack:<pack>/<sound> or a .wav file`,
1194
+ );
1195
+ const out: Record<string, unknown> = {
1196
+ kind: "wavetable",
1197
+ table: Object.freeze(
1198
+ src.startsWith("builtin:") ? { src } : { ...spec, src },
1199
+ ),
1200
+ };
1201
+ for (const key of Object.keys(params)) {
1202
+ const value = (params as Record<string, unknown>)[key];
1203
+ if (key === "warpmode") {
1204
+ if (typeof value !== "string")
1205
+ throw new DawgSdkError("wavetable warpmode must be a string");
1206
+ out.warpmode = value;
1207
+ } else if ((WAVETABLE_KEYS as readonly string[]).includes(key))
1208
+ out[key] = finite(value, `wavetable ${key}`);
1209
+ else
1210
+ throw new DawgSdkError(
1211
+ `wavetable has no parameter "${key}" (${WAVETABLE_KEYS.join(" ")} warpmode)`,
1212
+ );
1213
+ }
1214
+ return Object.freeze(out) as WavetableSpec;
1215
+ }
1216
+
374
1217
  /**
375
1218
  * `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
376
1219
  * chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
@@ -404,6 +1247,9 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
404
1247
  throw new DawgSdkError(`sampler voice ${name} needs a src path`);
405
1248
  const out: {
406
1249
  src: string;
1250
+ sha256?: string;
1251
+ url?: string;
1252
+ license?: string;
407
1253
  root?: number;
408
1254
  begin?: number;
409
1255
  end?: number;
@@ -411,7 +1257,21 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
411
1257
  speed?: number;
412
1258
  loop?: boolean;
413
1259
  choke?: string;
1260
+ loopBegin?: number;
1261
+ loopEnd?: number;
1262
+ clip?: number;
1263
+ unit?: "r" | "c" | "s";
1264
+ fit?: boolean;
1265
+ accelerate?: number;
1266
+ squiz?: number;
414
1267
  } = { src: spec.src };
1268
+ if (spec.src.startsWith("pack:")) {
1269
+ if (spec.sha256 !== undefined)
1270
+ out.sha256 = text(spec.sha256, `${name} sha256`);
1271
+ if (spec.url !== undefined) out.url = text(spec.url, `${name} url`);
1272
+ if (spec.license !== undefined)
1273
+ out.license = text(spec.license, `${name} license`);
1274
+ }
415
1275
  if (spec.root !== undefined) out.root = midi(spec.root);
416
1276
  if (spec.begin !== undefined) out.begin = unit(spec.begin, `${name} begin`);
417
1277
  if (spec.end !== undefined) out.end = unit(spec.end, `${name} end`);
@@ -427,6 +1287,32 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
427
1287
  throw new DawgSdkError(`${name} choke must be a group name`);
428
1288
  out.choke = spec.choke;
429
1289
  }
1290
+ const loopBegin = spec.loopBegin ?? spec.loopb;
1291
+ if (loopBegin !== undefined)
1292
+ out.loopBegin = unit(loopBegin, `${name} loopBegin`);
1293
+ const loopEnd = spec.loopEnd ?? spec.loope;
1294
+ if (loopEnd !== undefined) out.loopEnd = unit(loopEnd, `${name} loopEnd`);
1295
+ const clip = spec.clip ?? spec.legato;
1296
+ if (clip !== undefined) out.clip = finite(clip, `${name} clip`);
1297
+ if (spec.unit !== undefined) {
1298
+ if (spec.unit !== "r" && spec.unit !== "c" && spec.unit !== "s")
1299
+ throw new DawgSdkError(`${name} unit must be "r", "c" or "s"`);
1300
+ out.unit = spec.unit;
1301
+ }
1302
+ if (spec.loopAt !== undefined) {
1303
+ const bars = finite(spec.loopAt, `${name} loopAt`);
1304
+ if (bars <= 0) throw new DawgSdkError(`${name} loopAt must be positive`);
1305
+ out.speed = (out.speed ?? 1) / bars;
1306
+ out.unit = "c";
1307
+ }
1308
+ if (spec.fit !== undefined) {
1309
+ if (typeof spec.fit !== "boolean")
1310
+ throw new DawgSdkError(`${name} fit must be boolean`);
1311
+ out.fit = spec.fit;
1312
+ }
1313
+ if (spec.accelerate !== undefined)
1314
+ out.accelerate = finite(spec.accelerate, `${name} accelerate`);
1315
+ if (spec.squiz !== undefined) out.squiz = finite(spec.squiz, `${name} squiz`);
430
1316
  return Object.freeze(out);
431
1317
  }
432
1318
 
@@ -450,6 +1336,106 @@ export type AutomationInput = Readonly<{
450
1336
  delayFeedback?: readonly Point[];
451
1337
  /** 0..1 (needs `delay`). */
452
1338
  delayMix?: readonly Point[];
1339
+ /**
1340
+ * Effect parameter lanes keyed `<effect>-<param>`, e.g.
1341
+ * `"autofilter-cutoff"`, `"distort-drive"`, `"reverb-mix"` (needs the effect).
1342
+ */
1343
+ fx?: Readonly<Record<string, readonly Point[]>>;
1344
+ /** Wavetable position 0..1 (needs `wavetable()`). */
1345
+ wt?: readonly Point[];
1346
+ }>;
1347
+
1348
+ /** One effect's parameters; omitted ones take dawg's defaults. */
1349
+ export type EffectParams = Readonly<Record<string, number | string | boolean>>;
1350
+
1351
+ /**
1352
+ * 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,
1356
+ * phaser, chorus, leslie, postgain, plus the mix-bus keys `orbit`
1357
+ * (`{ orbit: 2 }`, SDK 1.9.0) and `duck` (`{ orbit: 2, depth: 0.85 }`:
1358
+ * this track's onsets duck every other track on that orbit). See
1359
+ * docs/project-format.md for every parameter, its range and its Strudel name.
1360
+ */
1361
+ export type FxInput = Readonly<Record<string, EffectParams>>;
1362
+
1363
+ /**
1364
+ * Synth voice parameters by Strudel name; only the ones given are stored
1365
+ * and the rest take dawg's defaults. Lanes go under `automation.fx` as
1366
+ * `"synth-<param>"` (e.g. `"synth-lpf"`), read at each note's onset.
1367
+ * Every parameter, range and default: **Synth** in DAWG.md.
1368
+ */
1369
+ export type SynthInput = Readonly<{
1370
+ attack?: number;
1371
+ decay?: number;
1372
+ sustain?: number;
1373
+ release?: number;
1374
+ gain?: number;
1375
+ /** Pink noise mixed into the oscillator, 0..1. */
1376
+ noise?: number;
1377
+ /** Crackle impulse density. */
1378
+ density?: number;
1379
+ /** Unison voices (supersaw defaults to 5). */
1380
+ unison?: number;
1381
+ /** Unison detune spread in semitones. */
1382
+ detune?: number;
1383
+ /** Stereo spread of the unison voices, 0..1. */
1384
+ spread?: number;
1385
+ /** Pulse width 0..1 (`pulse`). */
1386
+ pw?: number;
1387
+ pwrate?: number;
1388
+ pwsweep?: number;
1389
+ /** Vibrato rate (Hz) and depth (semitones). */
1390
+ vib?: number;
1391
+ vibmod?: number;
1392
+ /** Pitch envelope depth (semitones) and shape. */
1393
+ penv?: number;
1394
+ pattack?: number;
1395
+ pdecay?: number;
1396
+ psustain?: number;
1397
+ prelease?: number;
1398
+ pcurve?: number;
1399
+ panchor?: number;
1400
+ lpf?: number;
1401
+ lpq?: number;
1402
+ lpenv?: number;
1403
+ hpf?: number;
1404
+ hpq?: number;
1405
+ hpenv?: number;
1406
+ bpf?: number;
1407
+ bpq?: number;
1408
+ bpenv?: number;
1409
+ /** `12db`, `24db` or `ladder`. */
1410
+ ftype?: string;
1411
+ /** FM index and harmonicity ratio; `fm2`…`fm8` add operators. */
1412
+ fm?: number;
1413
+ fmh?: number;
1414
+ fmattack?: number;
1415
+ fmdecay?: number;
1416
+ fmsustain?: number;
1417
+ fmrelease?: number;
1418
+ /** `lin` or `exp`. */
1419
+ fmenv?: string;
1420
+ /** `sine`, `sawtooth`, `square` or `triangle`. */
1421
+ fmwave?: string;
1422
+ /** Harmonic amplitudes for `user` (or any basic waveform). */
1423
+ partials?: readonly number[];
1424
+ phases?: readonly number[];
1425
+ /** ZzFX controls for `z_*` sounds (units: DAWG.md "Synth"). */
1426
+ zrand?: number;
1427
+ curve?: number;
1428
+ slide?: number;
1429
+ deltaSlide?: number;
1430
+ pitchJump?: number;
1431
+ pitchJumpTime?: number;
1432
+ lfo?: number;
1433
+ zmod?: number;
1434
+ zcrush?: number;
1435
+ zdelay?: number;
1436
+ tremolo?: number;
1437
+ /** Any other Strudel synth parameter, e.g. `lpattack`, `fmh3`. */
1438
+ [param: string]: number | string | boolean | readonly number[] | undefined;
453
1439
  }>;
454
1440
 
455
1441
  /** Input to `track()`. Omitted fields keep dawg's defaults. */
@@ -460,9 +1446,20 @@ export type TrackInput = Readonly<{
460
1446
  name: string;
461
1447
  /**
462
1448
  * Synth voice (`sine`, `piano`, `pluck`, `bass`, `saw`, `square`,
463
- * `triangle`), `kit` for drums, or `sampler(...)`. Default `sine`.
1449
+ * `triangle`, and Strudel's `sawtooth`, `supersaw`, `pulse`, `user`,
1450
+ * `white`, `pink`, `brown`, `crackle`, and the ZzFX sounds `z_sine`,
1451
+ * `z_triangle`, `z_sawtooth`, `z_square`, `z_tan`, `z_noise`), `kit` for drums,
1452
+ * `sampler(...)` or `wavetable(...)`. Default `sine`.
1453
+ */
1454
+ instrument?: string | SamplerSpec | WavetableSpec;
1455
+ /**
1456
+ * Synthesized drum kit for an `instrument: "kit"` track: `syn808`,
1457
+ * `syn909`, `acoustic`, `lofi`, `electro` or `trap`. Omit for the default
1458
+ * voices.
464
1459
  */
465
- instrument?: string | SamplerSpec;
1460
+ kit?: string;
1461
+ /** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
1462
+ synth?: SynthInput;
466
1463
  muted?: boolean;
467
1464
  /** When any track is soloed only soloed tracks play. */
468
1465
  solo?: boolean;
@@ -470,15 +1467,70 @@ export type TrackInput = Readonly<{
470
1467
  volume?: number;
471
1468
  /** -1 (left) .. 1 (right), default 0. */
472
1469
  pan?: number;
473
- /** Low-pass filter; `null` or omitted means none. */
474
- filter?: Readonly<{ cutoff: number; resonance?: number }> | null;
475
- /** Tempo-synced ping-pong delay send in beats. */
476
- delay?: Readonly<{ beats: number; feedback?: number; mix?: number }> | null;
1470
+ /** Filter (low-pass unless `type`); `null` or omitted means none. */
1471
+ filter?: FilterInput | null;
1472
+ /** Tempo-synced delay send in beats. */
1473
+ delay?: DelayInput | null;
477
1474
  /** Stereo reverb send. */
478
- reverb?: Readonly<{ mix: number; size?: number }> | null;
1475
+ reverb?: ReverbInput | null;
1476
+ /** Insert effects by name (`{ distort: { drive: 3 }, chorus: {} }`). */
1477
+ fx?: FxInput;
479
1478
  automation?: AutomationInput;
480
1479
  /** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
481
1480
  notes?: readonly (NoteSpec | HitSpec)[];
1481
+ /**
1482
+ * Generated voices, one row per voice: `euclid()`/`grid()`. dawg expands
1483
+ * them into hits when the song loads; a row owns its voice, so `notes`
1484
+ * on the same voice are replaced.
1485
+ */
1486
+ rhythm?: readonly RhythmSpec[];
1487
+ }>;
1488
+
1489
+ export type FilterInput = Readonly<{
1490
+ cutoff: number;
1491
+ resonance?: number;
1492
+ /** `lpf` (default), `hpf` or `bpf`. */
1493
+ type?: "lpf" | "hpf" | "bpf";
1494
+ /** Slope: `12db` (default), `24db` or `ladder`. */
1495
+ ftype?: "12db" | "24db" | "ladder";
1496
+ }>;
1497
+
1498
+ export type DelayInput = Readonly<{
1499
+ beats: number;
1500
+ feedback?: number;
1501
+ mix?: number;
1502
+ /** Seconds; overrides `beats` when set (Strudel `delaytime`). */
1503
+ time?: number;
1504
+ /** Repeats alternate left/right. */
1505
+ pingpong?: boolean;
1506
+ /** Low-pass on the repeats, Hz. */
1507
+ highcut?: number;
1508
+ }>;
1509
+
1510
+ export type ReverbInput = Readonly<{
1511
+ mix: number;
1512
+ size?: number;
1513
+ /** Decay to -60 dB in seconds (Strudel `roomfade`). */
1514
+ fade?: number;
1515
+ /** Low-pass on the input, Hz (Strudel `roomlp`). */
1516
+ lowpass?: number;
1517
+ /** Damping toward this Hz as the tail decays (Strudel `roomdim`). */
1518
+ dim?: number;
1519
+ /** Seconds before the tail. */
1520
+ predelay?: number;
1521
+ /**
1522
+ * Convolution reverb (Strudel `iresponse`/`ir`): `"room"`, `"hall"`,
1523
+ * `"plate"` (generated), a pack sound `pack:<pack>/<sound>[:<n>]` or a
1524
+ * track-relative audio file. `size`, `fade` and `dim` then do nothing.
1525
+ */
1526
+ ir?:
1527
+ | string
1528
+ | Readonly<{
1529
+ src: string;
1530
+ sha256?: string;
1531
+ url?: string;
1532
+ license?: string;
1533
+ }>;
482
1534
  }>;
483
1535
 
484
1536
  /** Frozen track built by `track()`; `song()` consumes it. Beats, not ticks. */
@@ -492,15 +1544,97 @@ export type TrackSpec = Readonly<{
492
1544
  solo: boolean;
493
1545
  volume: number;
494
1546
  pan: number;
495
- filter: Readonly<{ cutoff: number; resonance: number }> | null;
496
- delay: Readonly<{ beats: number; feedback: number; mix: number }> | null;
497
- reverb: Readonly<{ mix: number; size: number }> | null;
1547
+ filter: Readonly<
1548
+ { cutoff: number; resonance: number } & Partial<FilterInput>
1549
+ > | null;
1550
+ delay: Readonly<
1551
+ { beats: number; feedback: number; mix: number } & Partial<DelayInput>
1552
+ > | null;
1553
+ reverb: Readonly<{ mix: number; size: number } & Partial<ReverbInput>> | null;
1554
+ fx: FxInput | null;
1555
+ synth: SynthInput | null;
498
1556
  sampler: SamplerSpec | null;
1557
+ wavetable: WavetableSpec | null;
499
1558
  automation: Readonly<Required<AutomationInput>>;
500
1559
  /** Every hit resolved to its pitch slot. */
501
1560
  notes: readonly NoteSpec[];
1561
+ /** Rhythm rows in order (voice names as written). */
1562
+ rhythm: readonly RhythmSpec[];
1563
+ kit: string | null;
502
1564
  }>;
503
1565
 
1566
+ /**
1567
+ * A raw ZzFX parameter array (Strudel `zzfx([...])`, ZzFX's own layout:
1568
+ * volume, randomness, frequency, attack, sustain, release, shape,
1569
+ * shapeCurve, slide, deltaSlide, pitchJump, pitchJumpTime, repeatTime,
1570
+ * noise, modulation, bitCrush, delay, sustainVolume, decay, tremolo,
1571
+ * filter) as a `z_*` instrument and synth parameters to spread into a
1572
+ * track. Empty slots take ZzFX's defaults; frequency and sustain time come
1573
+ * from each note. `filter` > 0 is a high-pass in Hz, < 0 a low-pass.
1574
+ *
1575
+ * ```ts
1576
+ * track({ name: "blip", ...zzfx([, , , 0.01, , 0.15, 2, , 5]), notes })
1577
+ * ```
1578
+ */
1579
+ export function zzfx(
1580
+ values: readonly (number | null | undefined)[],
1581
+ ): Readonly<{ instrument: string; synth: SynthInput }> {
1582
+ if (!Array.isArray(values) || values.length > ZZFX_LAYOUT.length)
1583
+ throw new DawgSdkError(
1584
+ `zzfx takes an array of at most ${ZZFX_LAYOUT.length} numbers`,
1585
+ );
1586
+ const synth: Record<string, number> = {
1587
+ zrand: 0.05,
1588
+ attack: 0,
1589
+ release: 0.1,
1590
+ };
1591
+ let instrument = "z_sine";
1592
+ ZZFX_LAYOUT.forEach((name, index) => {
1593
+ const value: unknown = values[index];
1594
+ if (name === null || value === undefined || value === null) return;
1595
+ const n = finite(value, `zzfx[${index}]`);
1596
+ if (name === "shape")
1597
+ instrument = ZZFX_SHAPES[Math.max(0, Math.min(5, Math.round(n)))]!;
1598
+ else if (name === "filter") {
1599
+ if (n !== 0) synth[n > 0 ? "hpf" : "lpf"] = Math.abs(n);
1600
+ } else synth[name] = n;
1601
+ });
1602
+ return Object.freeze({ instrument, synth: Object.freeze(synth) });
1603
+ }
1604
+
1605
+ const ZZFX_LAYOUT = Object.freeze([
1606
+ "gain",
1607
+ "zrand",
1608
+ null,
1609
+ "attack",
1610
+ null,
1611
+ "release",
1612
+ "shape",
1613
+ "curve",
1614
+ "slide",
1615
+ "deltaSlide",
1616
+ "pitchJump",
1617
+ "pitchJumpTime",
1618
+ "lfo",
1619
+ "noise",
1620
+ "zmod",
1621
+ "zcrush",
1622
+ "zdelay",
1623
+ "sustain",
1624
+ "decay",
1625
+ "tremolo",
1626
+ "filter",
1627
+ ] as const);
1628
+
1629
+ const ZZFX_SHAPES = Object.freeze([
1630
+ "z_sine",
1631
+ "z_triangle",
1632
+ "z_sawtooth",
1633
+ "z_tan",
1634
+ "z_noise",
1635
+ "z_square",
1636
+ ] as const);
1637
+
504
1638
  /**
505
1639
  * Build a track. Hits are resolved to pitches here: GM numbers on a kit,
506
1640
  * voice slots (36, 37, … in voice-name order) on a one-shot sampler.
@@ -523,18 +1657,24 @@ export function track(input: TrackInput): TrackSpec {
523
1657
  isRecord(rawInstrument) && rawInstrument.kind === "sampler"
524
1658
  ? localizeSampler(rawInstrument as SamplerSpec, slug)
525
1659
  : null;
1660
+ const wavetableSpec =
1661
+ isRecord(rawInstrument) && rawInstrument.kind === "wavetable"
1662
+ ? localizeWavetable(rawInstrument as WavetableSpec, slug)
1663
+ : null;
526
1664
  const instrument = samplerSpec
527
1665
  ? SAMPLER_INSTRUMENT
528
- : typeof rawInstrument === "string"
529
- ? rawInstrument
530
- : undefined;
1666
+ : wavetableSpec
1667
+ ? WAVETABLE_INSTRUMENT
1668
+ : typeof rawInstrument === "string"
1669
+ ? rawInstrument
1670
+ : undefined;
531
1671
  if (
532
1672
  instrument === undefined ||
533
1673
  instrument.length === 0 ||
534
1674
  instrument.length > 64
535
1675
  )
536
1676
  throw new DawgSdkError(
537
- `track ${name}: instrument must be a voice name, "kit" or sampler(...)`,
1677
+ `track ${name}: instrument must be a voice name, "kit", sampler(...) or wavetable(...)`,
538
1678
  );
539
1679
  if (instrument === SAMPLER_INSTRUMENT && !samplerSpec)
540
1680
  throw new DawgSdkError(
@@ -573,10 +1713,29 @@ export function track(input: TrackInput): TrackSpec {
573
1713
  });
574
1714
  if (notes.length > 4096)
575
1715
  throw new DawgSdkError(`track ${name}: at most 4096 notes`);
1716
+ const rhythm = input.rhythm ?? [];
1717
+ if (!Array.isArray(rhythm) || rhythm.length > 16)
1718
+ throw new DawgSdkError(`track ${name}: rhythm must be at most 16 rows`);
1719
+ rhythm.forEach((row, index) => {
1720
+ if (!isRecord(row) || row.kind !== "rhythm")
1721
+ throw new DawgSdkError(
1722
+ `track ${name}: rhythm[${index}] must come from euclid() or grid()`,
1723
+ );
1724
+ });
1725
+ const drumKit = input.kit ?? null;
1726
+ if (
1727
+ drumKit !== null &&
1728
+ (typeof drumKit !== "string" || drumKit.trim().length === 0 || !kit)
1729
+ )
1730
+ throw new DawgSdkError(
1731
+ `track ${name}: kit needs instrument "kit" and a kit name`,
1732
+ );
576
1733
  const automation = input.automation ?? {};
577
1734
  if (!isRecord(automation))
578
1735
  throw new DawgSdkError(`track ${name}: automation must be an object`);
579
- const lane = (key: keyof AutomationInput): readonly Point[] => {
1736
+ const lane = (
1737
+ key: Exclude<keyof AutomationInput, "fx">,
1738
+ ): readonly Point[] => {
580
1739
  const points = (automation as Record<string, unknown>)[key];
581
1740
  if (points === undefined) return Object.freeze([]);
582
1741
  if (!Array.isArray(points) || points.length > 256)
@@ -610,6 +1769,7 @@ export function track(input: TrackInput): TrackSpec {
610
1769
  input.filter.resonance ?? 0,
611
1770
  `${name} filter.resonance`,
612
1771
  ),
1772
+ ...extras(input.filter, ["type", "ftype"], `${name} filter`),
613
1773
  });
614
1774
  const delay =
615
1775
  input.delay === undefined || input.delay === null
@@ -621,6 +1781,11 @@ export function track(input: TrackInput): TrackSpec {
621
1781
  `${name} delay.feedback`,
622
1782
  ),
623
1783
  mix: finite(input.delay.mix ?? 0.35, `${name} delay.mix`),
1784
+ ...extras(
1785
+ input.delay,
1786
+ ["time", "pingpong", "highcut"],
1787
+ `${name} delay`,
1788
+ ),
624
1789
  });
625
1790
  const reverb =
626
1791
  input.reverb === undefined || input.reverb === null
@@ -628,7 +1793,17 @@ export function track(input: TrackInput): TrackSpec {
628
1793
  : Object.freeze({
629
1794
  mix: finite(input.reverb.mix, `${name} reverb.mix`),
630
1795
  size: finite(input.reverb.size ?? 0.5, `${name} reverb.size`),
1796
+ ...extras(
1797
+ input.reverb,
1798
+ ["fade", "lowpass", "dim", "predelay"],
1799
+ `${name} reverb`,
1800
+ ),
1801
+ ...(input.reverb.ir === undefined
1802
+ ? {}
1803
+ : { ir: reverbIr(input.reverb.ir, name, slug) }),
631
1804
  });
1805
+ const fx = fxInput(input.fx, name);
1806
+ const synth = synthInput(input.synth, name);
632
1807
  return Object.freeze({
633
1808
  kind: "track",
634
1809
  id,
@@ -642,7 +1817,10 @@ export function track(input: TrackInput): TrackSpec {
642
1817
  filter,
643
1818
  delay,
644
1819
  reverb,
1820
+ fx,
1821
+ synth,
645
1822
  sampler: samplerSpec,
1823
+ wavetable: wavetableSpec,
646
1824
  automation: Object.freeze({
647
1825
  volume: lane("volume"),
648
1826
  pan: lane("pan"),
@@ -650,8 +1828,12 @@ export function track(input: TrackInput): TrackSpec {
650
1828
  resonance: lane("resonance"),
651
1829
  delayFeedback: lane("delayFeedback"),
652
1830
  delayMix: lane("delayMix"),
1831
+ fx: fxLanes(automation.fx, name),
1832
+ wt: lane("wt"),
653
1833
  }),
654
1834
  notes: Object.freeze(notes),
1835
+ rhythm: Object.freeze([...rhythm]),
1836
+ kit: drumKit === null ? null : drumKit.trim(),
655
1837
  });
656
1838
  }
657
1839
 
@@ -662,8 +1844,92 @@ const AUTOMATION_KEYS: readonly (keyof AutomationInput)[] = Object.freeze([
662
1844
  "resonance",
663
1845
  "delayFeedback",
664
1846
  "delayMix",
1847
+ "fx",
1848
+ "wt",
665
1849
  ]);
666
1850
 
1851
+ type EffectValue = number | string | boolean;
1852
+
1853
+ function effectValue(value: unknown, label: string): EffectValue {
1854
+ if (typeof value === "string" || typeof value === "boolean") return value;
1855
+ return finite(value, label);
1856
+ }
1857
+
1858
+ /** The optional effect fields that are set; dawg validates their ranges. */
1859
+ function extras(
1860
+ input: object,
1861
+ keys: readonly string[],
1862
+ label: string,
1863
+ ): Record<string, EffectValue> {
1864
+ const out: Record<string, EffectValue> = {};
1865
+ for (const key of keys) {
1866
+ const value = (input as Record<string, unknown>)[key];
1867
+ if (value !== undefined) out[key] = effectValue(value, `${label}.${key}`);
1868
+ }
1869
+ return out;
1870
+ }
1871
+
1872
+ function fxInput(input: unknown, name: string): FxInput | null {
1873
+ if (input === undefined || input === null) return null;
1874
+ if (!isRecord(input))
1875
+ throw new DawgSdkError(`track ${name}: fx must be an object of effects`);
1876
+ const out: Record<string, EffectParams> = {};
1877
+ for (const [effect, params] of Object.entries(input)) {
1878
+ if (!isRecord(params))
1879
+ throw new DawgSdkError(`track ${name}: fx.${effect} must be an object`);
1880
+ const values: Record<string, EffectValue> = {};
1881
+ for (const [key, value] of Object.entries(params))
1882
+ values[key] = effectValue(value, `${name} fx.${effect}.${key}`);
1883
+ out[effect] = Object.freeze(values);
1884
+ }
1885
+ return Object.keys(out).length > 0 ? Object.freeze(out) : null;
1886
+ }
1887
+
1888
+ function synthInput(input: unknown, name: string): SynthInput | null {
1889
+ if (input === undefined || input === null) return null;
1890
+ if (!isRecord(input))
1891
+ throw new DawgSdkError(`track ${name}: synth must be an object`);
1892
+ const out: Record<string, EffectValue | readonly number[]> = {};
1893
+ for (const [key, value] of Object.entries(input)) {
1894
+ if (value === undefined) continue;
1895
+ out[key] = Array.isArray(value)
1896
+ ? Object.freeze(
1897
+ value.map((n, index) => finite(n, `${name} synth.${key}[${index}]`)),
1898
+ )
1899
+ : effectValue(value, `${name} synth.${key}`);
1900
+ }
1901
+ return Object.keys(out).length > 0 ? Object.freeze(out) : null;
1902
+ }
1903
+
1904
+ function fxLanes(
1905
+ input: unknown,
1906
+ name: string,
1907
+ ): Readonly<Record<string, readonly Point[]>> {
1908
+ if (input === undefined) return Object.freeze({});
1909
+ if (!isRecord(input))
1910
+ throw new DawgSdkError(`track ${name}: automation.fx must be an object`);
1911
+ const out: Record<string, readonly Point[]> = {};
1912
+ for (const [key, points] of Object.entries(input)) {
1913
+ if (!Array.isArray(points) || points.length > 256)
1914
+ throw new DawgSdkError(
1915
+ `track ${name}: automation.fx["${key}"] must be an array of at most 256 [beat, value] points`,
1916
+ );
1917
+ out[key] = Object.freeze(
1918
+ points.map((point: unknown, index: number): Point => {
1919
+ if (!Array.isArray(point) || point.length !== 2)
1920
+ throw new DawgSdkError(
1921
+ `track ${name}: automation.fx["${key}"][${index}] must be [beat, value]`,
1922
+ );
1923
+ return Object.freeze([
1924
+ beat(point[0], `automation.fx["${key}"][${index}] beat`),
1925
+ finite(point[1], `automation.fx["${key}"][${index}] value`),
1926
+ ] as const);
1927
+ }),
1928
+ );
1929
+ }
1930
+ return Object.freeze(out);
1931
+ }
1932
+
667
1933
  /**
668
1934
  * Directory name for a track: lowercase, spaces and runs of punctuation
669
1935
  * become one `-` (`"Keys 2"` → `keys-2`); empty input becomes `track`.
@@ -691,13 +1957,55 @@ export function voiceSlots(spec: SamplerSpec): ReadonlyMap<string, number> {
691
1957
  return slots;
692
1958
  }
693
1959
 
1960
+ /** `reverb.ir`: built-in names stay bare; files become project-relative. */
1961
+ function reverbIr(
1962
+ value: unknown,
1963
+ name: string,
1964
+ slug: string,
1965
+ ):
1966
+ | string
1967
+ | Readonly<{ src: string; sha256?: string; url?: string; license?: string }> {
1968
+ const spec = typeof value === "string" ? { src: value } : value;
1969
+ if (!isRecord(spec) || typeof spec.src !== "string" || spec.src.length === 0)
1970
+ throw new DawgSdkError(`track ${name}: reverb.ir needs a src`);
1971
+ const src = spec.src.trim().replace(/^\.\//, "");
1972
+ if (src.startsWith("pack:")) {
1973
+ const ref = sample(spec as SampleSpec, `${name} reverb.ir`);
1974
+ return Object.freeze({
1975
+ src: ref.src,
1976
+ ...(ref.sha256 ? { sha256: ref.sha256 } : {}),
1977
+ ...(ref.url ? { url: ref.url } : {}),
1978
+ ...(ref.license ? { license: ref.license } : {}),
1979
+ });
1980
+ }
1981
+ if (src.startsWith("builtin:") || !/[./]/.test(src)) return src;
1982
+ return src.startsWith("tracks/") ? src : `tracks/${slug}/${src}`;
1983
+ }
1984
+
1985
+ /** `./wavetables/x.wav` → `tracks/<slug>/wavetables/x.wav`, like sampler files. */
1986
+ function localizeWavetable(spec: WavetableSpec, slug: string): WavetableSpec {
1987
+ const src = spec.table.src;
1988
+ if (!/\.wav$/i.test(src) || src.startsWith("pack:")) return spec;
1989
+ const bare = src.replace(/^\.\//, "");
1990
+ return Object.freeze({
1991
+ ...spec,
1992
+ table: Object.freeze({
1993
+ ...spec.table,
1994
+ src: bare.startsWith("tracks/") ? bare : `tracks/${slug}/${bare}`,
1995
+ }),
1996
+ });
1997
+ }
1998
+
694
1999
  function localizeSampler(spec: SamplerSpec, slug: string): SamplerSpec {
695
2000
  const voices: Record<string, SampleSpec> = {};
696
2001
  for (const [name, voice] of Object.entries(spec.voices)) {
697
2002
  const src = voice.src.replace(/^\.\//, "");
698
2003
  voices[name] = Object.freeze({
699
2004
  ...voice,
700
- src: src.startsWith("tracks/") ? src : `tracks/${slug}/${src}`,
2005
+ src:
2006
+ src.startsWith("tracks/") || src.startsWith("pack:")
2007
+ ? src
2008
+ : `tracks/${slug}/${src}`,
701
2009
  });
702
2010
  }
703
2011
  return Object.freeze({ ...spec, voices: Object.freeze(voices) });
@@ -739,6 +2047,8 @@ export type ScorePoint = Readonly<{ tick: number; value: number }>;
739
2047
  export type ScoreSampleRef = Readonly<{
740
2048
  src: string;
741
2049
  sha256?: string;
2050
+ url?: string;
2051
+ license?: string;
742
2052
  root?: number;
743
2053
  begin?: number;
744
2054
  end?: number;
@@ -746,6 +2056,13 @@ export type ScoreSampleRef = Readonly<{
746
2056
  speed?: number;
747
2057
  loop?: boolean;
748
2058
  choke?: string;
2059
+ loopBegin?: number;
2060
+ loopEnd?: number;
2061
+ clip?: number;
2062
+ unit?: "r" | "c" | "s";
2063
+ fit?: boolean;
2064
+ accelerate?: number;
2065
+ squiz?: number;
749
2066
  }>;
750
2067
 
751
2068
  /** A stored track; optional fields are present only when set. */
@@ -759,17 +2076,26 @@ export type ScoreTrack = Readonly<{
759
2076
  volumeAutomation: readonly ScorePoint[];
760
2077
  panAutomation: readonly ScorePoint[];
761
2078
  solo?: boolean;
762
- filter?: Readonly<{ cutoff: number; resonance: number }>;
763
- delay?: Readonly<{ beats: number; feedback: number; mix: number }>;
2079
+ filter?: TrackSpec["filter"] & object;
2080
+ delay?: TrackSpec["delay"] & object;
764
2081
  filterAutomation?: readonly ScorePoint[];
765
2082
  resonanceAutomation?: readonly ScorePoint[];
766
2083
  delayFeedbackAutomation?: readonly ScorePoint[];
767
2084
  delayMixAutomation?: readonly ScorePoint[];
768
- reverb?: Readonly<{ mix: number; size: number }>;
2085
+ reverb?: TrackSpec["reverb"] & object;
2086
+ fx?: FxInput;
2087
+ fxAutomation?: Readonly<Record<string, readonly ScorePoint[]>>;
2088
+ synth?: SynthInput;
769
2089
  sampler?: Readonly<{
770
2090
  voices: Readonly<Record<string, ScoreSampleRef>>;
771
2091
  mode: "oneshot" | "keyed";
772
2092
  }>;
2093
+ /** Rhythm rows without `kind`; dawg validates and expands them. */
2094
+ rhythm?: readonly Readonly<Record<string, unknown>>[];
2095
+ /** Synth kit name; dawg validates it. */
2096
+ kit?: string;
2097
+ wavetable?: Readonly<{ table: ScoreSampleRef } & WavetableParams>;
2098
+ wtAutomation?: readonly ScorePoint[];
773
2099
  }>;
774
2100
 
775
2101
  /**
@@ -836,6 +2162,7 @@ export function song(input: SongInput): Song {
836
2162
  const resonanceAutomation = points(t.automation.resonance);
837
2163
  const delayFeedbackAutomation = points(t.automation.delayFeedback);
838
2164
  const delayMixAutomation = points(t.automation.delayMix);
2165
+ const wtAutomation = points(t.automation.wt ?? []);
839
2166
  const stored: Record<string, unknown> = {
840
2167
  id: t.id,
841
2168
  name: t.name,
@@ -857,11 +2184,31 @@ export function song(input: SongInput): Song {
857
2184
  if (delayMixAutomation.length > 0)
858
2185
  stored.delayMixAutomation = delayMixAutomation;
859
2186
  if (t.reverb) stored.reverb = t.reverb;
2187
+ if (t.fx) stored.fx = t.fx;
2188
+ if (t.synth) stored.synth = t.synth;
2189
+ const fxLaneEntries = Object.entries(t.automation.fx ?? {})
2190
+ .map(([key, lane]) => [key, points(lane)] as const)
2191
+ .filter(([, lane]) => lane.length > 0);
2192
+ if (fxLaneEntries.length > 0)
2193
+ stored.fxAutomation = Object.freeze(Object.fromEntries(fxLaneEntries));
2194
+ if (t.wavetable) {
2195
+ const { kind: _kind, ...fields } = t.wavetable;
2196
+ stored.wavetable = Object.freeze(fields);
2197
+ }
2198
+ if (wtAutomation.length > 0) stored.wtAutomation = wtAutomation;
860
2199
  if (t.sampler)
861
2200
  stored.sampler = Object.freeze({
862
2201
  voices: t.sampler.voices,
863
2202
  mode: t.sampler.mode,
864
2203
  });
2204
+ if (t.kit) stored.kit = t.kit;
2205
+ if (t.rhythm && t.rhythm.length > 0)
2206
+ stored.rhythm = Object.freeze(
2207
+ t.rhythm.map((row) => {
2208
+ const { kind: _kind, ...fields } = row;
2209
+ return Object.freeze(fields);
2210
+ }),
2211
+ );
865
2212
  tracks.push(Object.freeze(stored) as ScoreTrack);
866
2213
  const ids = new Set<string>();
867
2214
  for (const n of t.notes) {
@@ -898,6 +2245,1858 @@ export function song(input: SongInput): Song {
898
2245
  });
899
2246
  }
900
2247
 
2248
+ // ---------------------------------------------------------------------------
2249
+ // Chords
2250
+
2251
+ /** Options shared by `chord()` and `progression()`. */
2252
+ export type ChordOptions = Readonly<{
2253
+ /** Voicing dial: each step moves the lowest note up an octave (negative: the highest down), -12..12. */
2254
+ voicing?: number;
2255
+ /** `close` (default), `open` (drop 2) or `wide` (drop 2 and 4). */
2256
+ spread?: "close" | "open" | "wide";
2257
+ /** `block` (default), `strum-up`, `strum-down`, `arp-up`, `arp-down`, `arp-updown`, `arp-random`, `harp`, `slop`, `pattern` (SDK 1.7.0). */
2258
+ perform?:
2259
+ | "block"
2260
+ | "strum-up"
2261
+ | "strum-down"
2262
+ | "arp-up"
2263
+ | "arp-down"
2264
+ | "arp-updown"
2265
+ | "arp-random"
2266
+ | "harp"
2267
+ | "slop"
2268
+ | "pattern";
2269
+ /**
2270
+ * With `perform: "pattern"`: a rhythm pattern by name or 1-based number
2271
+ * (SDK 1.7.0): `eighths`, `sixteenths`, `offbeat`, `pop`, `charleston`,
2272
+ * `bossa`, `skank`, `gallop`, `half-time`, `tresillo`, `oom-pah`, `roll`,
2273
+ * `pick`. Default 1.
2274
+ */
2275
+ pattern?: string | number;
2276
+ /** Arpeggio step in beats, default 0.25. */
2277
+ rate?: number;
2278
+ /** Arpeggio/harp octaves 1..4, default 1. */
2279
+ octaves?: number;
2280
+ /** Strum gap between voices in beats, default 1/32. */
2281
+ strum?: number;
2282
+ /** Seed for `arp-random`, default 0. */
2283
+ seed?: number;
2284
+ /** Velocity, default 0.8. */
2285
+ vel?: number;
2286
+ /** Lowest root position: the root lands at or above this pitch, default C4. */
2287
+ anchor?: Pitch;
2288
+ /** `chords` (default), `bass` (root or slash bass in octave 2, one per chord) or `both`. */
2289
+ part?: "chords" | "bass" | "both";
2290
+ /**
2291
+ * Orchid bass mode (SDK 1.7.0; overrides `part`): `off` (chords only),
2292
+ * `chords` and `single` (chords plus root or slash bass), `unison`
2293
+ * (chords plus the chord's root, ignoring a slash) or `solo` (bass only).
2294
+ */
2295
+ bass?: "off" | "chords" | "unison" | "single" | "solo";
2296
+ }>;
2297
+
2298
+ /** Options for `progression()`. */
2299
+ export type ProgressionOptions = ChordOptions &
2300
+ Readonly<{
2301
+ /** Key the numerals read in: `"C major"`, `"a minor"`, `"F# dorian"`; default C major. */
2302
+ key?: string;
2303
+ /** Beat of the first chord, default 0. */
2304
+ from?: number;
2305
+ /** Beats per chord, default 4. */
2306
+ each?: number;
2307
+ /** Voice-lead each chord to the inversion nearest the previous, default true. */
2308
+ lead?: boolean;
2309
+ }>;
2310
+
2311
+ /**
2312
+ * One chord from a symbol (`"Cm7"`, `"F#dim"`, `"Bbmaj9"`, `"G7sus4"`,
2313
+ * `"C/E"`) as notes from `start` for `length` beats.
2314
+ *
2315
+ * ```ts
2316
+ * notes: [...chord("Am7", 0, 4), ...chord("D9", 4, 4, { perform: "strum-up" })]
2317
+ * ```
2318
+ */
2319
+ export function chord(
2320
+ symbol: string,
2321
+ start = 0,
2322
+ length = 4,
2323
+ options: ChordOptions = {},
2324
+ ): readonly NoteSpec[] {
2325
+ if (typeof symbol !== "string" || parseChord(symbol) === undefined)
2326
+ throw new DawgSdkError(`unknown chord symbol ${JSON.stringify(symbol)}`);
2327
+ return progression([symbol], {
2328
+ ...options,
2329
+ from: start,
2330
+ each: length,
2331
+ lead: false,
2332
+ });
2333
+ }
2334
+
2335
+ /**
2336
+ * A voice-led progression: roman numerals in `key` (`"ii7"`, `"V"`,
2337
+ * `"bVII"`, `"V/V"`) or chord symbols, as an array or a space-separated
2338
+ * string, one chord per `each` beats from `from`. Each chord takes the
2339
+ * inversion nearest the previous one, so common tones hold. Same input,
2340
+ * same notes; dawg's play mode and agent tools use the same engine.
2341
+ *
2342
+ * ```ts
2343
+ * notes: progression("ii7 V7 Imaj7 Imaj7", { key: "C major", perform: "arp-up", rate: 0.5 })
2344
+ * notes: progression(["i", "VI", "III", "VII"], { key: "a minor", part: "bass" })
2345
+ * ```
2346
+ */
2347
+ export function progression(
2348
+ chords: string | readonly string[],
2349
+ options: ProgressionOptions = {},
2350
+ ): readonly NoteSpec[] {
2351
+ const symbols =
2352
+ typeof chords === "string" ? chords.trim().split(/\s+/) : [...chords];
2353
+ if (symbols.length === 0 || symbols.length > 256 || symbols[0] === "")
2354
+ throw new DawgSdkError("progression needs 1..256 chords");
2355
+ const key = parseKey(options.key ?? "C major");
2356
+ if (!key)
2357
+ throw new DawgSdkError(`unknown key ${JSON.stringify(options.key)}`);
2358
+ const parsed = symbols.map((symbol) => {
2359
+ const value =
2360
+ typeof symbol === "string" ? resolveChord(key, symbol) : undefined;
2361
+ if (!value)
2362
+ throw new DawgSdkError(
2363
+ `unknown chord ${JSON.stringify(symbol)} (roman numeral or symbol)`,
2364
+ );
2365
+ return value;
2366
+ });
2367
+ const from = beat(options.from ?? 0, "progression from");
2368
+ const each = positive(options.each ?? 4, "progression each");
2369
+ const vel = unit(options.vel ?? DEFAULT_VELOCITY, "chord vel");
2370
+ const voicing = finite(options.voicing ?? 0, "chord voicing");
2371
+ const spread = options.spread ?? "close";
2372
+ if (!(SPREADS as readonly string[]).includes(spread))
2373
+ throw new DawgSdkError('chord spread must be "close", "open" or "wide"');
2374
+ const mode = options.perform ?? "block";
2375
+ if (!(PERFORM_MODES as readonly string[]).includes(mode))
2376
+ throw new DawgSdkError(`unknown chord perform ${JSON.stringify(mode)}`);
2377
+ if (
2378
+ options.bass !== undefined &&
2379
+ !(BASS_MODES as readonly string[]).includes(options.bass)
2380
+ )
2381
+ throw new DawgSdkError(
2382
+ `chord bass must be one of ${BASS_MODES.map((m) => JSON.stringify(m)).join(", ")}`,
2383
+ );
2384
+ if (options.pattern !== undefined && !findChordPattern(options.pattern))
2385
+ throw new DawgSdkError(
2386
+ `unknown chord pattern ${JSON.stringify(options.pattern)} (1..${CHORD_PATTERNS.length} or ${CHORD_PATTERNS.map((p) => p.name).join(", ")})`,
2387
+ );
2388
+ const part =
2389
+ options.bass === undefined
2390
+ ? (options.part ?? "chords")
2391
+ : options.bass === "off"
2392
+ ? "chords"
2393
+ : options.bass === "solo"
2394
+ ? "bass"
2395
+ : "both";
2396
+ if (part !== "chords" && part !== "bass" && part !== "both")
2397
+ throw new DawgSdkError('chord part must be "chords", "bass" or "both"');
2398
+ const rendered = renderProgression({
2399
+ key,
2400
+ chords: parsed,
2401
+ beatsPerChord: each,
2402
+ start: from,
2403
+ inversion: voicing,
2404
+ spread,
2405
+ bass: part !== "chords",
2406
+ ...(options.bass === "unison" ? { bassMode: "unison" as const } : {}),
2407
+ lead: options.lead ?? true,
2408
+ anchor: midi(options.anchor ?? 60),
2409
+ perform: {
2410
+ mode,
2411
+ rate: positive(options.rate ?? DEFAULT_ARP_RATE, "chord rate"),
2412
+ octaves: finite(options.octaves ?? 1, "chord octaves"),
2413
+ ...(options.strum !== undefined
2414
+ ? { strum: beat(options.strum, "chord strum") }
2415
+ : {}),
2416
+ seed: finite(options.seed ?? 0, "chord seed"),
2417
+ ...(options.pattern !== undefined ? { pattern: options.pattern } : {}),
2418
+ velocity: vel,
2419
+ },
2420
+ });
2421
+ const out = [
2422
+ ...(part === "bass" ? [] : rendered.notes),
2423
+ ...(part === "chords" ? [] : rendered.bass),
2424
+ ];
2425
+ if (out.length > 4096)
2426
+ throw new DawgSdkError("progression would produce over 4096 notes");
2427
+ return Object.freeze(
2428
+ out.map((n) => note(n.pitch, round(n.start), n.length, n.velocity)),
2429
+ );
2430
+ }
2431
+
2432
+ // BEGIN chord engine: generated from core/chords.ts by core/sdk/sync-chords.ts
2433
+ // ---------------------------------------------------------------------------
2434
+ // Vocabulary
2435
+
2436
+ /** The four Orchid chord-type buttons. */
2437
+ const CHORD_TYPES = ["dim", "min", "maj", "sus"] as const;
2438
+ type ChordType = (typeof CHORD_TYPES)[number];
2439
+
2440
+ /** The four Orchid extension buttons. */
2441
+ const EXTENSIONS = ["6", "m7", "M7", "9"] as const;
2442
+ type Extension = (typeof EXTENSIONS)[number];
2443
+
2444
+ /** Triad qualities: the four buttons plus dawg's two-button combinations. */
2445
+ const QUALITIES = [
2446
+ "maj",
2447
+ "min",
2448
+ "dim",
2449
+ "sus4",
2450
+ "aug",
2451
+ "sus2",
2452
+ "5",
2453
+ "madd4",
2454
+ "mb6",
2455
+ "b6",
2456
+ "7#9",
2457
+ ] as const;
2458
+ type Quality = (typeof QUALITIES)[number];
2459
+
2460
+ const QUALITY_INTERVALS: Readonly<Record<Quality, readonly number[]>> =
2461
+ Object.freeze({
2462
+ maj: [0, 4, 7],
2463
+ min: [0, 3, 7],
2464
+ dim: [0, 3, 6],
2465
+ sus4: [0, 5, 7],
2466
+ aug: [0, 4, 8],
2467
+ sus2: [0, 2, 7],
2468
+ "5": [0, 7],
2469
+ madd4: [0, 3, 5, 7],
2470
+ mb6: [0, 3, 7, 8],
2471
+ b6: [0, 4, 7, 8],
2472
+ "7#9": [0, 4, 7, 10, 15],
2473
+ });
2474
+
2475
+ /**
2476
+ * The extension button a secret chord is built with: it is part of the
2477
+ * chord, so `makeChord` drops it rather than stacking it again.
2478
+ */
2479
+ const SECRET_EXTENSION: Readonly<Partial<Record<Quality, Extension>>> =
2480
+ Object.freeze({ mb6: "6", b6: "6", "7#9": "m7" });
2481
+
2482
+ const EXTENSION_INTERVAL: Readonly<Record<Extension, number>> = Object.freeze({
2483
+ "6": 9,
2484
+ m7: 10,
2485
+ M7: 11,
2486
+ "9": 14,
2487
+ });
2488
+
2489
+ /**
2490
+ * Two chord-type buttons held together: Orchid's "secret chords" (manual
2491
+ * section 14.8). min+dim and maj+dim are listed with the 6 button and
2492
+ * maj+min with m7; dawg plays them without it too.
2493
+ */
2494
+ const COMBINED_TYPES: Readonly<Record<string, Quality>> = Object.freeze({
2495
+ "dim+sus": "5",
2496
+ "maj+sus": "aug",
2497
+ "min+sus": "madd4",
2498
+ "dim+min": "mb6",
2499
+ "dim+maj": "b6",
2500
+ "maj+min": "7#9",
2501
+ });
2502
+
2503
+ /** Quality for a set of held chord-type buttons, or undefined for none. */
2504
+ function qualityOf(types: Iterable<ChordType>): Quality | undefined {
2505
+ const held = [...new Set(types)].sort();
2506
+ if (held.length === 0) return undefined;
2507
+ if (held.length === 1) return held[0] === "sus" ? "sus4" : held[0]!;
2508
+ return COMBINED_TYPES[held.slice(0, 2).join("+")] ?? "maj";
2509
+ }
2510
+
2511
+ /** A chord: root pitch class, triad quality, extensions, optional bass. */
2512
+ type Chord = Readonly<{
2513
+ /** 0..11, C = 0. */
2514
+ root: number;
2515
+ quality: Quality;
2516
+ extensions: readonly Extension[];
2517
+ /** Slash bass pitch class, when not the root. */
2518
+ bass?: number | undefined;
2519
+ }>;
2520
+
2521
+ function makeChord(
2522
+ root: number,
2523
+ quality: Quality,
2524
+ extensions: Iterable<Extension> = [],
2525
+ bass?: number,
2526
+ ): Chord {
2527
+ const held = new Set(extensions);
2528
+ const own = SECRET_EXTENSION[quality];
2529
+ const ext = EXTENSIONS.filter((value) => held.has(value) && value !== own);
2530
+ const pc = mod12(root);
2531
+ const slash = bass === undefined ? undefined : mod12(bass);
2532
+ return Object.freeze({
2533
+ root: pc,
2534
+ quality,
2535
+ extensions: Object.freeze(ext),
2536
+ ...(slash !== undefined && slash !== pc ? { bass: slash } : {}),
2537
+ });
2538
+ }
2539
+
2540
+ /** Semitones above the root, ascending and unique (9 sits at 14). */
2541
+ function chordIntervals(chord: Chord): number[] {
2542
+ const set = new Set(QUALITY_INTERVALS[chord.quality]);
2543
+ for (const ext of chord.extensions) set.add(EXTENSION_INTERVAL[ext]);
2544
+ // m7 and M7 together keep both; 6 with m7 on a dim triad is the dim7's bb7.
2545
+ return [...set].sort((a, b) => a - b);
2546
+ }
2547
+
2548
+ /** Pitch classes of the chord (bass excluded), root first. */
2549
+ function chordPitchClasses(chord: Chord): number[] {
2550
+ return chordIntervals(chord).map((step) => mod12(chord.root + step));
2551
+ }
2552
+
2553
+ // ---------------------------------------------------------------------------
2554
+ // Names
2555
+
2556
+ const SHARP_NAMES = [
2557
+ "C",
2558
+ "C#",
2559
+ "D",
2560
+ "D#",
2561
+ "E",
2562
+ "F",
2563
+ "F#",
2564
+ "G",
2565
+ "G#",
2566
+ "A",
2567
+ "A#",
2568
+ "B",
2569
+ ];
2570
+ const FLAT_NAMES = [
2571
+ "C",
2572
+ "Db",
2573
+ "D",
2574
+ "Eb",
2575
+ "E",
2576
+ "F",
2577
+ "Gb",
2578
+ "G",
2579
+ "Ab",
2580
+ "A",
2581
+ "Bb",
2582
+ "B",
2583
+ ];
2584
+
2585
+ /** Note name for a pitch class; flats when `flats`. */
2586
+ function noteName(pc: number, flats = false): string {
2587
+ return (flats ? FLAT_NAMES : SHARP_NAMES)[mod12(pc)]!;
2588
+ }
2589
+
2590
+ const SECRET_SUFFIX: Readonly<Partial<Record<Quality, string>>> = Object.freeze(
2591
+ { madd4: "m(add4)", mb6: "m(b6)", b6: "(b6)", "7#9": "7#9" },
2592
+ );
2593
+
2594
+ /** Chord symbol suffix: `m7`, `maj9`, `7sus4`, `dim7`, `m7b5`, `6/9`. */
2595
+ function chordSuffix(chord: Chord): string {
2596
+ const ext = new Set(chord.extensions);
2597
+ const b7 = ext.has("m7");
2598
+ const M7 = ext.has("M7");
2599
+ const six = ext.has("6");
2600
+ const nine = ext.has("9");
2601
+ const q = chord.quality;
2602
+ const add = (base: string, parts: string[]) =>
2603
+ parts.length === 0 ? base : `${base}(${parts.join(",")})`;
2604
+ const extras: string[] = [];
2605
+ let base: string;
2606
+ const secret = SECRET_SUFFIX[q];
2607
+ if (secret !== undefined) {
2608
+ const names: Readonly<Record<Extension, string>> = {
2609
+ "6": "6",
2610
+ m7: "7",
2611
+ M7: "maj7",
2612
+ "9": "9",
2613
+ };
2614
+ const parts = chord.extensions.map((e) => names[e]);
2615
+ if (parts.length === 0 || !secret.endsWith(")")) return add(secret, parts);
2616
+ return `${secret.slice(0, -1)},${parts.join(",")})`;
2617
+ }
2618
+ if (q === "dim" && six && !b7 && !M7) {
2619
+ base = "dim7";
2620
+ if (nine) extras.push("add9");
2621
+ return add(base, extras);
2622
+ }
2623
+ if (b7 && M7) {
2624
+ // Both sevenths: name the dominant and list the major seventh.
2625
+ extras.push("maj7");
2626
+ }
2627
+ const seventh = b7 ? "7" : M7 ? "maj7" : "";
2628
+ if (seventh) {
2629
+ const ninth = nine ? (seventh === "7" ? "9" : "maj9") : seventh;
2630
+ switch (q) {
2631
+ case "maj":
2632
+ base = ninth;
2633
+ break;
2634
+ case "min":
2635
+ base = b7 ? (nine ? "m9" : "m7") : nine ? "m(maj9)" : "m(maj7)";
2636
+ break;
2637
+ case "dim":
2638
+ base = b7 ? (nine ? "m9b5" : "m7b5") : "dim(maj7)";
2639
+ if (!b7 && nine) extras.push("9");
2640
+ break;
2641
+ case "aug":
2642
+ base = b7 ? (nine ? "aug9" : "aug7") : "aug(maj7)";
2643
+ if (!b7 && nine) extras.push("9");
2644
+ break;
2645
+ case "sus4":
2646
+ base = `${ninth}sus4`;
2647
+ break;
2648
+ case "sus2":
2649
+ base = `${seventh}sus2`;
2650
+ if (nine) extras.push("9");
2651
+ break;
2652
+ case "5":
2653
+ base = `${seventh}(no3)`;
2654
+ if (nine) extras.push("9");
2655
+ break;
2656
+ default:
2657
+ base = seventh; // secret qualities returned above
2658
+ }
2659
+ if (six) extras.push("13");
2660
+ return add(base, b7 && M7 ? extras : extras.filter((e) => e !== "maj7"));
2661
+ }
2662
+ const triad: Record<Quality, string> = {
2663
+ maj: "",
2664
+ min: "m",
2665
+ dim: "dim",
2666
+ sus4: "sus4",
2667
+ aug: "aug",
2668
+ sus2: "sus2",
2669
+ "5": "5",
2670
+ madd4: "m(add4)",
2671
+ mb6: "m(b6)",
2672
+ b6: "(b6)",
2673
+ "7#9": "7#9",
2674
+ };
2675
+ base = triad[q];
2676
+ if (six && nine && (q === "maj" || q === "min")) return `${base}6/9`;
2677
+ if (six) {
2678
+ if (q === "maj" || q === "min") base = `${base}6`;
2679
+ else extras.push("6");
2680
+ }
2681
+ if (nine) {
2682
+ if (extras.length === 0 && (q === "maj" || q === "min"))
2683
+ return `${base}${q === "min" && !six ? "(add9)" : "add9"}`;
2684
+ extras.push("9");
2685
+ }
2686
+ return add(base, extras);
2687
+ }
2688
+
2689
+ /** Chord symbol: `Cm7`, `F#dim`, `Bbmaj9`, `G7sus4`, `C/E`. */
2690
+ function chordName(chord: Chord, flats = false): string {
2691
+ const slash =
2692
+ chord.bass === undefined ? "" : `/${noteName(chord.bass, flats)}`;
2693
+ return `${noteName(chord.root, flats)}${chordSuffix(chord)}${slash}`;
2694
+ }
2695
+
2696
+ /** Suffix → quality and extensions, longest first when parsing. */
2697
+ const SUFFIXES: readonly (readonly [string, Quality, readonly Extension[]])[] =
2698
+ [
2699
+ ["", "maj", []],
2700
+ ["maj", "maj", []],
2701
+ ["M", "maj", []],
2702
+ ["m", "min", []],
2703
+ ["min", "min", []],
2704
+ ["-", "min", []],
2705
+ ["dim", "dim", []],
2706
+ ["°", "dim", []],
2707
+ ["o", "dim", []],
2708
+ ["aug", "aug", []],
2709
+ ["+", "aug", []],
2710
+ ["sus", "sus4", []],
2711
+ ["sus4", "sus4", []],
2712
+ ["sus2", "sus2", []],
2713
+ ["5", "5", []],
2714
+ ["6", "maj", ["6"]],
2715
+ ["m6", "min", ["6"]],
2716
+ ["6/9", "maj", ["6", "9"]],
2717
+ ["69", "maj", ["6", "9"]],
2718
+ ["m6/9", "min", ["6", "9"]],
2719
+ ["m69", "min", ["6", "9"]],
2720
+ ["7", "maj", ["m7"]],
2721
+ ["dom7", "maj", ["m7"]],
2722
+ ["maj7", "maj", ["M7"]],
2723
+ ["M7", "maj", ["M7"]],
2724
+ ["Δ", "maj", ["M7"]],
2725
+ ["Δ7", "maj", ["M7"]],
2726
+ ["m7", "min", ["m7"]],
2727
+ ["min7", "min", ["m7"]],
2728
+ ["-7", "min", ["m7"]],
2729
+ ["m(maj7)", "min", ["M7"]],
2730
+ ["mM7", "min", ["M7"]],
2731
+ ["m7b5", "dim", ["m7"]],
2732
+ ["ø", "dim", ["m7"]],
2733
+ ["ø7", "dim", ["m7"]],
2734
+ ["dim7", "dim", ["6"]],
2735
+ ["°7", "dim", ["6"]],
2736
+ ["o7", "dim", ["6"]],
2737
+ ["aug7", "aug", ["m7"]],
2738
+ ["+7", "aug", ["m7"]],
2739
+ ["9", "maj", ["m7", "9"]],
2740
+ ["maj9", "maj", ["M7", "9"]],
2741
+ ["M9", "maj", ["M7", "9"]],
2742
+ ["m9", "min", ["m7", "9"]],
2743
+ ["add9", "maj", ["9"]],
2744
+ ["madd9", "min", ["9"]],
2745
+ ["m(add9)", "min", ["9"]],
2746
+ ["7sus4", "sus4", ["m7"]],
2747
+ ["7sus", "sus4", ["m7"]],
2748
+ ["9sus4", "sus4", ["m7", "9"]],
2749
+ ["7sus2", "sus2", ["m7"]],
2750
+ ["maj7sus4", "sus4", ["M7"]],
2751
+ ["m(add4)", "madd4", []],
2752
+ ["madd4", "madd4", []],
2753
+ ["m(b6)", "mb6", []],
2754
+ ["mb6", "mb6", []],
2755
+ ["(b6)", "b6", []],
2756
+ ["addb6", "b6", []],
2757
+ ["7#9", "7#9", []],
2758
+ ];
2759
+ const SUFFIX_TABLE = new Map(
2760
+ SUFFIXES.map(([suffix, quality, ext]) => [suffix, { quality, ext }]),
2761
+ );
2762
+
2763
+ const LETTER: Readonly<Record<string, number>> = Object.freeze({
2764
+ c: 0,
2765
+ d: 2,
2766
+ e: 4,
2767
+ f: 5,
2768
+ g: 7,
2769
+ a: 9,
2770
+ b: 11,
2771
+ });
2772
+
2773
+ /** Pitch class of a note name (`C`, `f#`, `Bb`), or undefined. */
2774
+ function parsePitchClass(text: string): number | undefined {
2775
+ const match = text.trim().match(/^([a-gA-G])(#|b|♯|♭)?$/);
2776
+ if (!match) return undefined;
2777
+ const accidental =
2778
+ match[2] === "#" || match[2] === "♯"
2779
+ ? 1
2780
+ : match[2] === "b" || match[2] === "♭"
2781
+ ? -1
2782
+ : 0;
2783
+ return mod12(LETTER[match[1]!.toLowerCase()]! + accidental);
2784
+ }
2785
+
2786
+ /** Parse a chord symbol (`Cm7`, `F#dim`, `Bbmaj9`, `G7sus4`, `C/E`). */
2787
+ function parseChord(symbol: string): Chord | undefined {
2788
+ if (typeof symbol !== "string" || symbol.length > 24) return undefined;
2789
+ const trimmed = symbol
2790
+ .trim()
2791
+ .replace(/6\/9$/, "69")
2792
+ .replace(/6\/9\//, "69/");
2793
+ const match = trimmed.match(/^([A-Ga-g])(#|b|♯|♭)?([^/]*)(?:\/(.+))?$/);
2794
+ if (!match) return undefined;
2795
+ const root = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
2796
+ const entry = SUFFIX_TABLE.get(match[3] ?? "");
2797
+ if (root === undefined || !entry) return undefined;
2798
+ let bass: number | undefined;
2799
+ if (match[4] !== undefined) {
2800
+ bass = parsePitchClass(match[4]);
2801
+ if (bass === undefined) return undefined;
2802
+ }
2803
+ return makeChord(root, entry.quality, entry.ext, bass);
2804
+ }
2805
+
2806
+ // ---------------------------------------------------------------------------
2807
+ // Keys and modes
2808
+
2809
+ const MODES = Object.freeze({
2810
+ major: [0, 2, 4, 5, 7, 9, 11],
2811
+ minor: [0, 2, 3, 5, 7, 8, 10],
2812
+ dorian: [0, 2, 3, 5, 7, 9, 10],
2813
+ phrygian: [0, 1, 3, 5, 7, 8, 10],
2814
+ lydian: [0, 2, 4, 6, 7, 9, 11],
2815
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
2816
+ locrian: [0, 1, 3, 5, 6, 8, 10],
2817
+ "harmonic-minor": [0, 2, 3, 5, 7, 8, 11],
2818
+ } as const);
2819
+ type ModeName = keyof typeof MODES;
2820
+ const MODE_NAMES = Object.keys(MODES) as ModeName[];
2821
+
2822
+ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
2823
+ "": "major",
2824
+ maj: "major",
2825
+ major: "major",
2826
+ ionian: "major",
2827
+ m: "minor",
2828
+ min: "minor",
2829
+ minor: "minor",
2830
+ aeolian: "minor",
2831
+ dorian: "dorian",
2832
+ phrygian: "phrygian",
2833
+ lydian: "lydian",
2834
+ mixolydian: "mixolydian",
2835
+ mixo: "mixolydian",
2836
+ locrian: "locrian",
2837
+ "harmonic-minor": "harmonic-minor",
2838
+ "harmonic minor": "harmonic-minor",
2839
+ harmonic: "harmonic-minor",
2840
+ });
2841
+
2842
+ type Key = Readonly<{ tonic: number; mode: ModeName }>;
2843
+
2844
+ /**
2845
+ * Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`.
2846
+ * Accepts the `<note> <mode>` form `core/key.ts` writes.
2847
+ */
2848
+ function parseKey(text: string | null | undefined): Key | undefined {
2849
+ if (typeof text !== "string" || text.length > 40) return undefined;
2850
+ const match = text
2851
+ .trim()
2852
+ .match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z -]*)?$/);
2853
+ if (!match) return undefined;
2854
+ const tonic = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
2855
+ const word = (match[3] ?? "").trim();
2856
+ const mode = MODE_ALIASES[word === "m" ? "m" : word.toLowerCase()];
2857
+ if (tonic === undefined || mode === undefined) return undefined;
2858
+ return Object.freeze({ tonic, mode });
2859
+ }
2860
+
2861
+ /** True when names in the key read better with flats (F, Bb, Eb, d minor…). */
2862
+ function keyUsesFlats(key: Key): boolean {
2863
+ // The parent major scale's tonic decides: F, Bb, Eb, Ab, Db read in flats.
2864
+ const parentOffset: Record<ModeName, number> = {
2865
+ major: 0,
2866
+ dorian: 2,
2867
+ phrygian: 4,
2868
+ lydian: 5,
2869
+ mixolydian: 7,
2870
+ minor: 9,
2871
+ locrian: 11,
2872
+ "harmonic-minor": 9,
2873
+ };
2874
+ const parent = mod12(key.tonic - parentOffset[key.mode]);
2875
+ return [5, 10, 3, 8, 1].includes(parent);
2876
+ }
2877
+
2878
+ /** `C major`, `F# dorian`, `Bb minor`. */
2879
+ function keyName(key: Key): string {
2880
+ return `${noteName(key.tonic, keyUsesFlats(key))} ${key.mode}`;
2881
+ }
2882
+
2883
+ /** Pitch classes of the key's scale, tonic first. */
2884
+ function scaleOf(key: Key): number[] {
2885
+ return MODES[key.mode].map((step) => mod12(key.tonic + step));
2886
+ }
2887
+
2888
+ function qualityFromThirds(third: number, fifth: number): Quality {
2889
+ if (third === 4 && fifth === 7) return "maj";
2890
+ if (third === 3 && fifth === 7) return "min";
2891
+ if (third === 3 && fifth === 6) return "dim";
2892
+ if (third === 4 && fifth === 8) return "aug";
2893
+ return "maj";
2894
+ }
2895
+
2896
+ /**
2897
+ * The diatonic chord on scale degree `degree` (0-based): stacked thirds
2898
+ * from the scale. `sevenths` adds the scale's seventh above the root.
2899
+ */
2900
+ function diatonicChord(key: Key, degree: number, sevenths = false): Chord {
2901
+ const scale = MODES[key.mode];
2902
+ const at = (index: number) => {
2903
+ const octave = Math.floor(index / 7);
2904
+ return scale[((index % 7) + 7) % 7]! + 12 * octave;
2905
+ };
2906
+ const d = ((degree % 7) + 7) % 7;
2907
+ const root = at(d);
2908
+ const third = at(d + 2) - root;
2909
+ const fifth = at(d + 4) - root;
2910
+ const quality = qualityFromThirds(third, fifth);
2911
+ const ext: Extension[] = [];
2912
+ if (sevenths) {
2913
+ const seventh = at(d + 6) - root;
2914
+ if (quality === "dim" && seventh === 9) ext.push("6");
2915
+ else ext.push(seventh === 11 ? "M7" : "m7");
2916
+ }
2917
+ return makeChord(key.tonic + root, quality, ext);
2918
+ }
2919
+
2920
+ /** The seven diatonic chords of the key. */
2921
+ function diatonicChords(key: Key, sevenths = false): Chord[] {
2922
+ return Array.from({ length: 7 }, (_, degree) =>
2923
+ diatonicChord(key, degree, sevenths),
2924
+ );
2925
+ }
2926
+
2927
+ /** Scale degree (0-based) of a pitch class, or undefined when not in key. */
2928
+ function degreeOf(key: Key, pc: number): number | undefined {
2929
+ const index = scaleOf(key).indexOf(mod12(pc));
2930
+ return index < 0 ? undefined : index;
2931
+ }
2932
+
2933
+ /**
2934
+ * Orchid Key mode: the chord a key plays. In-scale pitches play their
2935
+ * diatonic chord (C major: D → Dm). Out-of-scale pitches are dawg's
2936
+ * choice: the chord borrowed from the parallel major/minor when that
2937
+ * scale contains the pitch (C major: Eb → Eb, Ab → Ab, Bb → Bb), otherwise
2938
+ * a passing diminished seventh (C major: C# → C#dim7, F# → F#dim7).
2939
+ * `types` and `extensions` are the held Orchid buttons: a type overrides
2940
+ * the quality ("unorthodox" choices), extensions add on top.
2941
+ */
2942
+ function keyModeChord(
2943
+ key: Key,
2944
+ pitch: number,
2945
+ options: Readonly<{
2946
+ types?: Iterable<ChordType>;
2947
+ extensions?: Iterable<Extension>;
2948
+ sevenths?: boolean;
2949
+ }> = {},
2950
+ ): Chord {
2951
+ const pc = mod12(pitch);
2952
+ const extensions = [...(options.extensions ?? [])];
2953
+ const forced = qualityOf(options.types ?? []);
2954
+ const degree = degreeOf(key, pc);
2955
+ let base: Chord;
2956
+ if (degree !== undefined) base = diatonicChord(key, degree, options.sevenths);
2957
+ else {
2958
+ const parallel: Key = {
2959
+ tonic: key.tonic,
2960
+ mode: MODES[key.mode][2] === 4 ? "minor" : "major",
2961
+ };
2962
+ const borrowed = degreeOf(parallel, pc);
2963
+ base =
2964
+ borrowed !== undefined
2965
+ ? diatonicChord(parallel, borrowed, options.sevenths)
2966
+ : makeChord(pc, "dim", ["6"]);
2967
+ }
2968
+ if (forced === undefined && extensions.length === 0) return base;
2969
+ const quality = forced ?? base.quality;
2970
+ const ext =
2971
+ forced === undefined ? [...base.extensions, ...extensions] : extensions;
2972
+ return makeChord(pc, quality, ext);
2973
+ }
2974
+
2975
+ /** Manual (non-key) mode: the held buttons on the pressed root. */
2976
+ function manualChord(
2977
+ pitch: number,
2978
+ types: Iterable<ChordType>,
2979
+ extensions: Iterable<Extension> = [],
2980
+ ): Chord | undefined {
2981
+ const quality = qualityOf(types);
2982
+ const ext = [...extensions];
2983
+ if (quality === undefined && ext.length === 0) return undefined;
2984
+ return makeChord(pitch, quality ?? "maj", ext);
2985
+ }
2986
+
2987
+ // ---------------------------------------------------------------------------
2988
+ // Roman numerals
2989
+
2990
+ const NUMERALS = ["i", "ii", "iii", "iv", "v", "vi", "vii"];
2991
+
2992
+ /** Roman numeral for a chord in a key (`ii`, `V7`, `bVII`, `vii°`). */
2993
+ function romanOf(key: Key, chord: Chord): string {
2994
+ const scale = scaleOf(key);
2995
+ let degree = scale.indexOf(chord.root);
2996
+ let accidental = "";
2997
+ if (degree < 0) {
2998
+ // Name chromatic roots against the major scale: bIII, #iv°.
2999
+ const major = MODES.major.map((step) => mod12(key.tonic + step));
3000
+ const flat = major.indexOf(mod12(chord.root + 1));
3001
+ const sharp = major.indexOf(mod12(chord.root - 1));
3002
+ if (flat >= 0) {
3003
+ degree = flat;
3004
+ accidental = "b";
3005
+ } else {
3006
+ degree = Math.max(0, sharp);
3007
+ accidental = "#";
3008
+ }
3009
+ }
3010
+ const lower =
3011
+ chord.quality === "min" ||
3012
+ chord.quality === "dim" ||
3013
+ chord.quality === "5" ||
3014
+ chord.quality === "madd4" ||
3015
+ chord.quality === "mb6";
3016
+ const numeral = NUMERALS[degree]!;
3017
+ const body = lower ? numeral : numeral.toUpperCase();
3018
+ const ext = new Set(chord.extensions);
3019
+ let mark = "";
3020
+ if (chord.quality === "dim") mark = ext.has("m7") ? "ø" : "°";
3021
+ else if (chord.quality === "aug") mark = "+";
3022
+ const seventh =
3023
+ ext.has("6") && chord.quality === "dim"
3024
+ ? "7"
3025
+ : ext.has("m7")
3026
+ ? "7"
3027
+ : ext.has("M7")
3028
+ ? "maj7"
3029
+ : "";
3030
+ return `${accidental}${body}${mark}${seventh}`;
3031
+ }
3032
+
3033
+ /**
3034
+ * Parse a roman numeral in a key: `I`, `ii`, `V7`, `vii°`, `bVII`, `iv`,
3035
+ * `IVmaj7`, `ii7`, `V/V` (secondary dominant), `Vsus4`.
3036
+ *
3037
+ * A numeral whose case matches the diatonic chord (lowercase for minor or
3038
+ * diminished) takes the diatonic quality, so `vii` is diminished in major;
3039
+ * a mismatched case is explicit (`iv` in major is minor, `IV` in minor is
3040
+ * major). `7` adds the diatonic seventh; `maj7`/`M7` and `dom7` are exact.
3041
+ */
3042
+ function parseRoman(key: Key, text: string): Chord | undefined {
3043
+ if (typeof text !== "string" || text.length > 16) return undefined;
3044
+ const trimmed = text.trim();
3045
+ const slash = trimmed.match(/^(.+)\/(.+)$/);
3046
+ if (slash) {
3047
+ // V/x: the chord built on the degree of x in the key (secondary function).
3048
+ const target = parseRoman(key, slash[2]!);
3049
+ if (!target) return undefined;
3050
+ const sub = parseRoman({ tonic: target.root, mode: "major" }, slash[1]!);
3051
+ return sub;
3052
+ }
3053
+ const match = trimmed.match(
3054
+ /^(b|#|♭|♯)?(vii|vi|v|iv|iii|ii|i|VII|VI|V|IV|III|II|I)(°|o|ø|\+)?(maj7|M7|dom7|7|9|maj9|6|sus4|sus2|sus|add9)?$/,
3055
+ );
3056
+ if (!match) return undefined;
3057
+ const accidental =
3058
+ match[1] === "b" || match[1] === "♭" ? -1 : match[1] ? 1 : 0;
3059
+ const numeral = match[2]!;
3060
+ const lower = numeral === numeral.toLowerCase();
3061
+ const degree = NUMERALS.indexOf(numeral.toLowerCase());
3062
+ const mark = match[3];
3063
+ const suffix = match[4] ?? "";
3064
+ const root =
3065
+ accidental === 0
3066
+ ? scaleOf(key)[degree]!
3067
+ : mod12(key.tonic + MODES.major[degree]! + accidental);
3068
+ const inKey = degreeOf(key, root);
3069
+ const triad = inKey === undefined ? undefined : diatonicChord(key, inKey);
3070
+ const seventh =
3071
+ inKey === undefined ? undefined : diatonicChord(key, inKey, true);
3072
+ const diatonicLower =
3073
+ triad !== undefined && (triad.quality === "min" || triad.quality === "dim");
3074
+ const matches = triad !== undefined && diatonicLower === lower;
3075
+ let quality: Quality;
3076
+ if (mark === "°" || mark === "o" || mark === "ø") quality = "dim";
3077
+ else if (mark === "+") quality = "aug";
3078
+ else if (matches) quality = triad.quality;
3079
+ else quality = lower ? "min" : "maj";
3080
+ // The diatonic seventh when the triad is the diatonic one, else b7.
3081
+ const diatonicSeventh = (): Extension[] =>
3082
+ mark === "ø"
3083
+ ? ["m7"]
3084
+ : seventh && seventh.quality === quality
3085
+ ? [...seventh.extensions]
3086
+ : quality === "dim" && mark !== undefined
3087
+ ? ["6"]
3088
+ : ["m7"];
3089
+ let ext: Extension[] = mark === "ø" ? ["m7"] : [];
3090
+ switch (suffix) {
3091
+ case "7":
3092
+ ext = diatonicSeventh();
3093
+ break;
3094
+ case "dom7":
3095
+ ext = ["m7"];
3096
+ break;
3097
+ case "maj7":
3098
+ case "M7":
3099
+ ext = ["M7"];
3100
+ break;
3101
+ case "9":
3102
+ ext = [...diatonicSeventh(), "9"];
3103
+ break;
3104
+ case "maj9":
3105
+ ext = ["M7", "9"];
3106
+ break;
3107
+ case "6":
3108
+ ext = ["6"];
3109
+ break;
3110
+ case "add9":
3111
+ ext = ["9"];
3112
+ break;
3113
+ case "sus4":
3114
+ case "sus":
3115
+ quality = "sus4";
3116
+ break;
3117
+ case "sus2":
3118
+ quality = "sus2";
3119
+ break;
3120
+ }
3121
+ return makeChord(root, quality, ext);
3122
+ }
3123
+
3124
+ // ---------------------------------------------------------------------------
3125
+ // Voicing
3126
+
3127
+ /** Default chord register: a voicing is kept inside [low, high]. */
3128
+ const VOICING_RANGE = Object.freeze({ low: 48, high: 79 });
3129
+ /** Voicing dial: Orchid-style rotation steps, -12..12. */
3130
+ const MAX_VOICING_STEP = 12;
3131
+ const SPREADS = ["close", "open", "wide"] as const;
3132
+ type Spread = (typeof SPREADS)[number];
3133
+
3134
+ type VoicingOptions = Readonly<{
3135
+ /** Rotation steps from root position (Orchid's voicing dial). */
3136
+ inversion?: number;
3137
+ spread?: Spread;
3138
+ /** Voice-lead from this voicing (minimal movement). */
3139
+ previous?: readonly number[] | undefined;
3140
+ /** Root-position anchor: the root lands at or above this pitch. */
3141
+ anchor?: number;
3142
+ low?: number;
3143
+ high?: number;
3144
+ }>;
3145
+
3146
+ /**
3147
+ * Root position of a chord with its root at or above `anchor`, then the
3148
+ * Orchid voicing dial: each positive step moves the lowest note up an
3149
+ * octave, each negative step the highest note down.
3150
+ */
3151
+ function rotate(pitches: readonly number[], steps: number): number[] {
3152
+ const notes = [...pitches].sort((a, b) => a - b);
3153
+ if (notes.length === 0) return notes;
3154
+ for (let i = 0; i < Math.abs(Math.trunc(steps)); i += 1) {
3155
+ if (steps > 0) notes.push(notes.shift()! + 12);
3156
+ else notes.unshift(notes.pop()! - 12);
3157
+ }
3158
+ return notes;
3159
+ }
3160
+
3161
+ function rootPosition(chord: Chord, anchor = 60): number[] {
3162
+ const rootPitch = anchor + mod12(chord.root - anchor);
3163
+ return chordIntervals(chord).map((step) => rootPitch + step);
3164
+ }
3165
+
3166
+ /** Open voicings: `open` drops the second voice from the top an octave
3167
+ * (drop 2); `wide` also drops the fourth from the top (drop 2+4). */
3168
+ function applySpread(pitches: readonly number[], spread: Spread): number[] {
3169
+ const notes = [...pitches].sort((a, b) => a - b);
3170
+ if (spread === "close" || notes.length < 3) return notes;
3171
+ const n = notes.length;
3172
+ notes[n - 2] = notes[n - 2]! - 12;
3173
+ if (spread === "wide" && n >= 4) notes[n - 4] = notes[n - 4]! - 12;
3174
+ return notes.sort((a, b) => a - b);
3175
+ }
3176
+
3177
+ /**
3178
+ * Movement between two voicings: each voice of the new chord pays its
3179
+ * distance to the nearest previous voice and vice versa, so voicings of
3180
+ * different sizes compare and common tones are free.
3181
+ */
3182
+ function movement(a: readonly number[], b: readonly number[]): number {
3183
+ if (a.length === 0 || b.length === 0) return 0;
3184
+ const nearest = (pitch: number, set: readonly number[]) =>
3185
+ Math.min(...set.map((other) => Math.abs(other - pitch)));
3186
+ let total = 0;
3187
+ for (const pitch of b) total += nearest(pitch, a);
3188
+ for (const pitch of a) total += nearest(pitch, b);
3189
+ return total;
3190
+ }
3191
+
3192
+ /**
3193
+ * Voice a chord. Without `previous`, root position at `anchor` rotated by
3194
+ * `inversion` (the Orchid dial). With `previous` (voice leading), every
3195
+ * rotation within an octave either side of the dial is tried and the one
3196
+ * with the least movement wins; ties go to the voicing nearest the dial,
3197
+ * then the lower one. Results stay within [low, high] when they fit.
3198
+ */
3199
+ function voiceChord(chord: Chord, options: VoicingOptions = {}): number[] {
3200
+ const anchor = options.anchor ?? 60;
3201
+ const spread = options.spread ?? "close";
3202
+ const low = options.low ?? VOICING_RANGE.low;
3203
+ const high = options.high ?? VOICING_RANGE.high;
3204
+ const dial = clampInt(
3205
+ options.inversion ?? 0,
3206
+ -MAX_VOICING_STEP,
3207
+ MAX_VOICING_STEP,
3208
+ );
3209
+ const base = rootPosition(chord, anchor);
3210
+ const size = base.length;
3211
+ const fit = (notes: number[]) =>
3212
+ notes.every((pitch) => pitch >= low && pitch <= high);
3213
+ const clampMidi = (notes: number[]) =>
3214
+ notes.map((pitch) => Math.max(0, Math.min(127, pitch)));
3215
+ const at = (steps: number) => applySpread(rotate(base, steps), spread);
3216
+ if (!options.previous || options.previous.length === 0)
3217
+ return clampMidi(at(dial));
3218
+ let best: { notes: number[]; cost: number; distance: number } | undefined;
3219
+ for (let offset = -size; offset <= size; offset += 1) {
3220
+ const notes = at(dial + offset);
3221
+ if (!fit(notes) && offset !== 0) continue;
3222
+ const cost = movement(options.previous, notes);
3223
+ const distance = Math.abs(offset);
3224
+ if (
3225
+ !best ||
3226
+ cost < best.cost ||
3227
+ (cost === best.cost && distance < best.distance)
3228
+ )
3229
+ best = { notes, cost, distance };
3230
+ }
3231
+ return clampMidi(best!.notes);
3232
+ }
3233
+
3234
+ /** Bass under a chord: its slash bass or root, in C2..B2 by default. */
3235
+ function bassNote(chord: Chord, low = 36): number {
3236
+ return low + mod12((chord.bass ?? chord.root) - low);
3237
+ }
3238
+
3239
+ /**
3240
+ * Orchid's bass behaviours (manual 10.2 and the "How to use Bass" article),
3241
+ * plus `off`. Labels in BASS_MODE_TEXT; `chords` is Orchid's default
3242
+ * "Chords Only".
3243
+ */
3244
+ const BASS_MODES = ["off", "chords", "unison", "single", "solo"] as const;
3245
+ type BassMode = (typeof BASS_MODES)[number];
3246
+
3247
+ const BASS_MODE_TEXT: Readonly<Record<BassMode, string>> = {
3248
+ off: "no bass",
3249
+ chords: "bass root under chords only",
3250
+ unison: "bass doubles single notes; root under chords",
3251
+ single: "single notes play bass only; chords play treble and root",
3252
+ solo: "bass only: the treble is muted, even for chords",
3253
+ };
3254
+
3255
+ /** What one key press sounds once the bass mode has routed it. */
3256
+ type BassRoute = Readonly<{
3257
+ /** Whether the treble (chord or single note) sounds. */
3258
+ treble: boolean;
3259
+ /** Bass pitch, or undefined for none. */
3260
+ bass: number | undefined;
3261
+ }>;
3262
+
3263
+ /**
3264
+ * Route a key press through a bass mode. `chord` is the chord the key
3265
+ * played (undefined for a single note); `pitch` the pressed key; `low` the
3266
+ * bottom of the bass octave. Sourced semantics (Orchid manual 10.2, support
3267
+ * article "How to use Bass on Orchid"): `chords` adds the chord's root only
3268
+ * when a chord plays; `unison` plays bass and treble together on single
3269
+ * notes; `single` plays only bass on single notes and the treble only on
3270
+ * chords; `solo` mutes the treble entirely, even for chords. dawg's
3271
+ * reading where the sources are silent: every mode that sounds bass under
3272
+ * a chord uses the chord's root (or slash bass), and a single note's bass
3273
+ * is the pressed pitch class in the bass octave.
3274
+ */
3275
+ function routeBass(
3276
+ mode: BassMode,
3277
+ chord: Chord | undefined,
3278
+ pitch: number,
3279
+ low = 36,
3280
+ ): BassRoute {
3281
+ const under = chord ? bassNote(chord, low) : low + mod12(pitch - low);
3282
+ switch (mode) {
3283
+ case "off":
3284
+ return { treble: true, bass: undefined };
3285
+ case "chords":
3286
+ return { treble: true, bass: chord ? under : undefined };
3287
+ case "unison":
3288
+ return { treble: true, bass: under };
3289
+ case "single":
3290
+ return { treble: chord !== undefined, bass: under };
3291
+ case "solo":
3292
+ return { treble: false, bass: under };
3293
+ }
3294
+ }
3295
+
3296
+ function parseBassMode(value: string | undefined): BassMode | undefined {
3297
+ const text = (value ?? "").trim().toLowerCase();
3298
+ if (text === "on" || text === "true") return "chords";
3299
+ if (text === "false" || text === "none") return "off";
3300
+ if (text === "single-notes" || text === "singles") return "single";
3301
+ return (BASS_MODES as readonly string[]).includes(text)
3302
+ ? (text as BassMode)
3303
+ : undefined;
3304
+ }
3305
+
3306
+ // ---------------------------------------------------------------------------
3307
+ // Performance
3308
+
3309
+ const PERFORM_MODES = [
3310
+ "block",
3311
+ "strum-up",
3312
+ "strum-down",
3313
+ "arp-up",
3314
+ "arp-down",
3315
+ "arp-updown",
3316
+ "arp-random",
3317
+ "harp",
3318
+ "slop",
3319
+ "pattern",
3320
+ ] as const;
3321
+ type PerformMode = (typeof PERFORM_MODES)[number];
3322
+
3323
+ type PerformOptions = Readonly<{
3324
+ mode?: PerformMode;
3325
+ /** Arp step in beats (the grid), default 1/8 beat... 0.25. */
3326
+ rate?: number;
3327
+ /** Arp/harp octaves, 1..4. */
3328
+ octaves?: number;
3329
+ /** Strum gap between voices in beats, default 1/32 beat. */
3330
+ strum?: number;
3331
+ /** Seed for arp-random and slop. */
3332
+ seed?: number;
3333
+ /** Slop amount 0..1: each voice lands up to `slop` × 1/8 beat late. */
3334
+ slop?: number;
3335
+ /** Pattern mode: a CHORD_PATTERNS name or 1-based number, default 1. */
3336
+ pattern?: string | number;
3337
+ /** 0..1. */
3338
+ velocity?: number;
3339
+ }>;
3340
+
3341
+ type PerformedNote = Readonly<{
3342
+ pitch: number;
3343
+ /** Beats. */
3344
+ start: number;
3345
+ length: number;
3346
+ velocity: number;
3347
+ }>;
3348
+
3349
+ const DEFAULT_ARP_RATE = 0.25;
3350
+ const DEFAULT_STRUM = 1 / 32;
3351
+ const DEFAULT_SLOP = 0.5;
3352
+ /** Latest a slopped voice can land, in beats, at slop 1. */
3353
+ const MAX_SLOP = 1 / 8;
3354
+
3355
+ // ---------------------------------------------------------------------------
3356
+ // Patterns
3357
+
3358
+ /**
3359
+ * One hit of a chord pattern. `voices` picks chord tones by index, low to
3360
+ * high: `all`, `upper` (all but the lowest), or a list where an index past
3361
+ * the top wraps an octave up (index 3 of a triad is the root +12) and a
3362
+ * negative index counts down from the top (-1 is the highest voice).
3363
+ */
3364
+ type PatternHit = Readonly<{
3365
+ /** Beats from the cycle start. */
3366
+ at: number;
3367
+ /** Beats. */
3368
+ length: number;
3369
+ voices: "all" | "upper" | readonly number[];
3370
+ /** 0..1, scaled by the press velocity. */
3371
+ velocity: number;
3372
+ /** Octave shift for these voices (the bass half of oom-pah is -1). */
3373
+ octave?: number;
3374
+ }>;
3375
+
3376
+ type ChordPattern = Readonly<{
3377
+ name: string;
3378
+ description: string;
3379
+ /** Cycle length in beats; the pattern repeats from the press. */
3380
+ beats: number;
3381
+ hits: readonly PatternHit[];
3382
+ }>;
3383
+
3384
+ const everyStep = (
3385
+ step: number,
3386
+ beats: number,
3387
+ hit: (index: number) => Omit<PatternHit, "at">,
3388
+ ): PatternHit[] =>
3389
+ Array.from({ length: Math.round(beats / step) }, (_, index) => ({
3390
+ at: index * step,
3391
+ ...hit(index),
3392
+ }));
3393
+
3394
+ /**
3395
+ * Pattern mode. Orchid's own patterns are not published (manual 7.2: "Plays
3396
+ * chord notes in pre-determined rhythmic patterns", tempo-synced, the
3397
+ * rhythm independent of the chord's note count, with per-note velocities
3398
+ * scaled by the press; 11 at launch and two more in firmware 3.84). These
3399
+ * 13 are dawg's own design in that spirit: each hit names voices by index
3400
+ * so the rhythm holds for triads and 9th chords alike.
3401
+ */
3402
+ const CHORD_PATTERNS: readonly ChordPattern[] = Object.freeze([
3403
+ {
3404
+ name: "eighths",
3405
+ description: "straight 8ths, beats accented",
3406
+ beats: 4,
3407
+ hits: everyStep(0.5, 4, (i) => ({
3408
+ length: 0.45,
3409
+ voices: "all",
3410
+ velocity: i % 2 === 0 ? 1 : 0.7,
3411
+ })),
3412
+ },
3413
+ {
3414
+ name: "sixteenths",
3415
+ description: "straight 16ths, 1-e-&-a accents",
3416
+ beats: 4,
3417
+ hits: everyStep(0.25, 4, (i) => ({
3418
+ length: 0.2,
3419
+ voices: "all",
3420
+ velocity: [1, 0.55, 0.8, 0.55][i % 4]!,
3421
+ })),
3422
+ },
3423
+ {
3424
+ name: "offbeat",
3425
+ description: "short stabs on every &",
3426
+ beats: 4,
3427
+ hits: everyStep(1, 4, () => ({
3428
+ length: 0.25,
3429
+ voices: "all",
3430
+ velocity: 0.9,
3431
+ })).map((hit) => ({ ...hit, at: hit.at + 0.5 })),
3432
+ },
3433
+ {
3434
+ name: "pop",
3435
+ description: "syncopated pop comp with 16th pushes",
3436
+ beats: 4,
3437
+ hits: [
3438
+ { at: 0, length: 0.5, voices: "all", velocity: 1 },
3439
+ { at: 0.75, length: 0.5, voices: "upper", velocity: 0.7 },
3440
+ { at: 1.5, length: 0.75, voices: "all", velocity: 0.85 },
3441
+ { at: 2.5, length: 0.5, voices: "upper", velocity: 0.7 },
3442
+ { at: 3, length: 0.25, voices: "all", velocity: 0.6 },
3443
+ { at: 3.5, length: 0.5, voices: "all", velocity: 0.85 },
3444
+ ],
3445
+ },
3446
+ {
3447
+ name: "charleston",
3448
+ description: "dotted quarter, then the & of 2",
3449
+ beats: 4,
3450
+ hits: [
3451
+ { at: 0, length: 0.75, voices: "all", velocity: 1 },
3452
+ { at: 1.5, length: 0.5, voices: "all", velocity: 0.85 },
3453
+ ],
3454
+ },
3455
+ {
3456
+ name: "bossa",
3457
+ description: "two-bar bossa comp over a root-fifth pulse",
3458
+ beats: 8,
3459
+ hits: [
3460
+ ...everyStep(2, 8, () => ({
3461
+ length: 1.5,
3462
+ voices: [0],
3463
+ velocity: 0.85,
3464
+ octave: -1,
3465
+ })),
3466
+ ...[0, 1.5, 3, 4.5, 6].map((at) => ({
3467
+ at,
3468
+ length: 0.5,
3469
+ voices: "upper" as const,
3470
+ velocity: at === 0 ? 0.9 : 0.75,
3471
+ })),
3472
+ ],
3473
+ },
3474
+ {
3475
+ name: "skank",
3476
+ description: "reggae skank: short upper stabs on 2 and 4",
3477
+ beats: 4,
3478
+ hits: [1, 3].map((at) => ({
3479
+ at,
3480
+ length: 0.2,
3481
+ voices: "upper" as const,
3482
+ velocity: 0.95,
3483
+ })),
3484
+ },
3485
+ {
3486
+ name: "gallop",
3487
+ description: "gallop: an 8th and two 16ths per beat",
3488
+ beats: 4,
3489
+ hits: everyStep(1, 4, () => ({
3490
+ length: 0.4,
3491
+ voices: "all",
3492
+ velocity: 1,
3493
+ })).flatMap((hit) => [
3494
+ hit,
3495
+ { ...hit, at: hit.at + 0.5, length: 0.2, velocity: 0.7 },
3496
+ { ...hit, at: hit.at + 0.75, length: 0.2, velocity: 0.75 },
3497
+ ]),
3498
+ },
3499
+ {
3500
+ name: "half-time",
3501
+ description: "half-time: a long hit and a pickup per two bars",
3502
+ beats: 8,
3503
+ hits: [
3504
+ { at: 0, length: 3.5, voices: "all", velocity: 1 },
3505
+ { at: 4, length: 1.5, voices: "all", velocity: 0.8 },
3506
+ { at: 7.5, length: 0.5, voices: "upper", velocity: 0.65 },
3507
+ ],
3508
+ },
3509
+ {
3510
+ name: "tresillo",
3511
+ description: "tresillo 3+3+2",
3512
+ beats: 4,
3513
+ hits: [
3514
+ { at: 0, length: 1.25, voices: "all", velocity: 1 },
3515
+ { at: 1.5, length: 1.25, voices: "all", velocity: 0.8 },
3516
+ { at: 3, length: 0.75, voices: "all", velocity: 0.9 },
3517
+ ],
3518
+ },
3519
+ {
3520
+ name: "oom-pah",
3521
+ description: "alternating bass and chord: low root, upper chord",
3522
+ beats: 4,
3523
+ hits: everyStep(1, 4, (i) =>
3524
+ i % 2 === 0
3525
+ ? { length: 0.9, voices: [0], velocity: 1, octave: -1 }
3526
+ : { length: 0.8, voices: "upper", velocity: 0.75 },
3527
+ ),
3528
+ },
3529
+ {
3530
+ name: "roll",
3531
+ description: "broken-chord roll up in 16ths, ringing to the half bar",
3532
+ beats: 4,
3533
+ hits: [0, 2].flatMap((bar) =>
3534
+ [0, 1, 2, 3].map((step) => ({
3535
+ at: bar + step * 0.25,
3536
+ length: 2 - step * 0.25,
3537
+ voices: [step],
3538
+ velocity: 0.7 + step * 0.08,
3539
+ })),
3540
+ ),
3541
+ },
3542
+ {
3543
+ name: "pick",
3544
+ description: "broken-chord picking: low, high, middle, high in 8ths",
3545
+ beats: 4,
3546
+ hits: everyStep(0.5, 4, (i) => ({
3547
+ length: 0.5,
3548
+ voices: [[0, -1, 1, -1][i % 4]!],
3549
+ velocity: i % 4 === 0 ? 0.95 : 0.7,
3550
+ })),
3551
+ },
3552
+ ]);
3553
+
3554
+ /** A pattern by name or 1-based number, or undefined. */
3555
+ function findChordPattern(
3556
+ value: string | number | undefined,
3557
+ ): ChordPattern | undefined {
3558
+ if (value === undefined) return undefined;
3559
+ const text = String(value).trim().toLowerCase();
3560
+ const number = Number(text);
3561
+ if (/^\d+$/.test(text)) return CHORD_PATTERNS[number - 1];
3562
+ return CHORD_PATTERNS.find((pattern) => pattern.name === text);
3563
+ }
3564
+
3565
+ function patternVoices(notes: readonly number[], hit: PatternHit): number[] {
3566
+ const n = notes.length;
3567
+ const indices =
3568
+ hit.voices === "all"
3569
+ ? notes.map((_, i) => i)
3570
+ : hit.voices === "upper"
3571
+ ? n > 1
3572
+ ? notes.slice(1).map((_, i) => i + 1)
3573
+ : [0]
3574
+ : hit.voices;
3575
+ const shift = 12 * (hit.octave ?? 0);
3576
+ const out = new Set<number>();
3577
+ for (const index of indices) {
3578
+ const i = index < 0 ? ((index % n) + n) % n : index;
3579
+ const wrapped = ((i % n) + n) % n;
3580
+ const pitch = notes[wrapped]! + 12 * Math.floor(i / n) + shift;
3581
+ if (pitch >= 0 && pitch <= 127) out.add(pitch);
3582
+ }
3583
+ return [...out].sort((a, b) => a - b);
3584
+ }
3585
+
3586
+ /**
3587
+ * Lay a voiced chord out in time over [start, start + length). Block holds
3588
+ * every voice; strums offset voices by `strum` beats and hold to the end;
3589
+ * arpeggios step one voice per `rate` beats across `octaves`, aligned to
3590
+ * multiples of `rate` from `start`; harp is an upward strum across the
3591
+ * octaves that rings to the end; slop (Orchid's humanised timing) holds
3592
+ * every voice like block but delays each by a seeded random fraction of
3593
+ * `slop` × MAX_SLOP, so each seed lands differently; pattern repeats a
3594
+ * CHORD_PATTERNS rhythm from `start`, each hit's velocity scaled by
3595
+ * `velocity`.
3596
+ */
3597
+ function perform(
3598
+ pitches: readonly number[],
3599
+ start: number,
3600
+ length: number,
3601
+ options: PerformOptions = {},
3602
+ ): PerformedNote[] {
3603
+ const mode = options.mode ?? "block";
3604
+ const velocity = options.velocity ?? 0.8;
3605
+ const notes = [...pitches].sort((a, b) => a - b);
3606
+ if (notes.length === 0 || !(length > 0)) return [];
3607
+ const end = start + length;
3608
+ const octaves = clampInt(options.octaves ?? 1, 1, 4);
3609
+ const spanned: number[] = [];
3610
+ for (let o = 0; o < octaves; o += 1)
3611
+ for (const pitch of notes)
3612
+ if (pitch + 12 * o <= 127) spanned.push(pitch + 12 * o);
3613
+ const at = (pitch: number, from: number, to: number): PerformedNote => ({
3614
+ pitch,
3615
+ start: round6(from),
3616
+ length: round6(Math.max(1e-6, to - from)),
3617
+ velocity,
3618
+ });
3619
+ switch (mode) {
3620
+ case "block":
3621
+ return notes.map((pitch) => at(pitch, start, end));
3622
+ case "strum-up":
3623
+ case "strum-down": {
3624
+ const gap = Math.max(0, options.strum ?? DEFAULT_STRUM);
3625
+ const order = mode === "strum-up" ? notes : [...notes].reverse();
3626
+ return order
3627
+ .map((pitch, index) =>
3628
+ at(pitch, Math.min(end - gap, start + index * gap), end),
3629
+ )
3630
+ .filter((note) => note.length > 0);
3631
+ }
3632
+ case "slop": {
3633
+ const amount = Math.min(1, Math.max(0, options.slop ?? DEFAULT_SLOP));
3634
+ const random = mulberry32(options.seed ?? 0);
3635
+ const late = Math.min(amount * MAX_SLOP, length / 2);
3636
+ return notes.map((pitch) => at(pitch, start + random() * late, end));
3637
+ }
3638
+ case "pattern": {
3639
+ const pattern =
3640
+ findChordPattern(options.pattern ?? 1) ?? CHORD_PATTERNS[0]!;
3641
+ const out: PerformedNote[] = [];
3642
+ for (let cycle = start; cycle < end - 1e-9; cycle += pattern.beats)
3643
+ for (const hit of pattern.hits) {
3644
+ const from = cycle + hit.at;
3645
+ if (from >= end - 1e-9) continue;
3646
+ const to = Math.min(end, from + hit.length);
3647
+ for (const pitch of patternVoices(notes, hit))
3648
+ out.push({
3649
+ ...at(pitch, from, to),
3650
+ velocity: round6(velocity * hit.velocity),
3651
+ });
3652
+ }
3653
+ return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
3654
+ }
3655
+ case "harp": {
3656
+ const gap = Math.max(0, options.strum ?? DEFAULT_STRUM * 2);
3657
+ return spanned.map((pitch, index) =>
3658
+ at(pitch, Math.min(end - 1e-3, start + index * gap), end),
3659
+ );
3660
+ }
3661
+ default: {
3662
+ const rate =
3663
+ options.rate && options.rate > 0 ? options.rate : DEFAULT_ARP_RATE;
3664
+ const steps = Math.max(1, Math.floor(length / rate + 1e-9));
3665
+ let order: number[];
3666
+ if (mode === "arp-down") order = [...spanned].reverse();
3667
+ else if (mode === "arp-updown")
3668
+ order =
3669
+ spanned.length > 2
3670
+ ? [...spanned, ...spanned.slice(1, -1).reverse()]
3671
+ : spanned;
3672
+ else order = spanned;
3673
+ const random = mulberry32(options.seed ?? 0);
3674
+ const out: PerformedNote[] = [];
3675
+ for (let step = 0; step < steps; step += 1) {
3676
+ const from = start + step * rate;
3677
+ const to = Math.min(end, from + rate);
3678
+ const pitch =
3679
+ mode === "arp-random"
3680
+ ? spanned[Math.floor(random() * spanned.length)]!
3681
+ : order[step % order.length]!;
3682
+ out.push(at(pitch, from, to));
3683
+ }
3684
+ return out;
3685
+ }
3686
+ }
3687
+ }
3688
+
3689
+ // ---------------------------------------------------------------------------
3690
+ // Progressions
3691
+
3692
+ /** A progression preset: roman numerals and the mode they read in. */
3693
+ type ProgressionPreset = Readonly<{
3694
+ name: string;
3695
+ mode: "major" | "minor" | "dorian" | "mixolydian";
3696
+ numerals: readonly string[];
3697
+ /** Use diatonic sevenths. */
3698
+ sevenths?: boolean;
3699
+ description: string;
3700
+ }>;
3701
+
3702
+ const PROGRESSION_PRESETS: readonly ProgressionPreset[] = Object.freeze([
3703
+ {
3704
+ name: "axis",
3705
+ mode: "major",
3706
+ numerals: ["I", "V", "vi", "IV"],
3707
+ description: "I–V–vi–IV, the four-chord pop loop",
3708
+ },
3709
+ {
3710
+ name: "sad-pop",
3711
+ mode: "major",
3712
+ numerals: ["vi", "IV", "I", "V"],
3713
+ description: "vi–IV–I–V, the same loop from the relative minor",
3714
+ },
3715
+ {
3716
+ name: "fifties",
3717
+ mode: "major",
3718
+ numerals: ["I", "vi", "IV", "V"],
3719
+ description: "I–vi–IV–V doo-wop",
3720
+ },
3721
+ {
3722
+ name: "ii-v-i",
3723
+ mode: "major",
3724
+ numerals: ["ii", "V", "I", "I"],
3725
+ sevenths: true,
3726
+ description: "ii7–V7–Imaj7, the jazz cadence",
3727
+ },
3728
+ {
3729
+ name: "turnaround",
3730
+ mode: "major",
3731
+ numerals: ["I", "vi", "ii", "V"],
3732
+ sevenths: true,
3733
+ description: "Imaj7–vi7–ii7–V7 turnaround",
3734
+ },
3735
+ {
3736
+ name: "canon",
3737
+ mode: "major",
3738
+ numerals: ["I", "V", "vi", "iii", "IV", "I", "IV", "V"],
3739
+ description: "Pachelbel's canon",
3740
+ },
3741
+ {
3742
+ name: "aeolian",
3743
+ mode: "minor",
3744
+ numerals: ["i", "VI", "III", "VII"],
3745
+ description: "i–VI–III–VII minor anthem",
3746
+ },
3747
+ {
3748
+ name: "andalusian",
3749
+ mode: "minor",
3750
+ numerals: ["i", "VII", "VI", "V"],
3751
+ description: "i–VII–VI–V descending (major V)",
3752
+ },
3753
+ {
3754
+ name: "minor-ii-v",
3755
+ mode: "minor",
3756
+ numerals: ["iiø", "V7", "i", "i"],
3757
+ sevenths: true,
3758
+ description: "iiø7–V7–i minor cadence",
3759
+ },
3760
+ {
3761
+ name: "dorian-vamp",
3762
+ mode: "dorian",
3763
+ numerals: ["i", "IV"],
3764
+ sevenths: true,
3765
+ description: "i7–IV7 dorian vamp",
3766
+ },
3767
+ {
3768
+ name: "mixolydian-rock",
3769
+ mode: "mixolydian",
3770
+ numerals: ["I", "bVII", "IV", "I"],
3771
+ description: "I–bVII–IV–I mixolydian rock",
3772
+ },
3773
+ ]);
3774
+
3775
+ const PROGRESSION_STYLES = ["pop", "jazz", "modal", "classical"] as const;
3776
+ type ProgressionStyle = (typeof PROGRESSION_STYLES)[number];
3777
+
3778
+ /**
3779
+ * Functional-harmony transition weights between scale degrees (0 = I),
3780
+ * per style. Tonic (I, vi, iii) moves to predominant (IV, ii), which moves
3781
+ * to dominant (V, vii°), which resolves to tonic; pop adds the plagal and
3782
+ * vi–IV moves, jazz favours the cycle of fifths, modal keeps to the tonic
3783
+ * and its neighbours.
3784
+ */
3785
+ const TRANSITIONS: Readonly<
3786
+ Record<ProgressionStyle, readonly (readonly number[])[]>
3787
+ > = Object.freeze({
3788
+ // I ii iii IV V vi vii
3789
+ pop: [
3790
+ [0, 1, 1, 4, 4, 4, 0], // I
3791
+ [1, 0, 0, 2, 5, 1, 0], // ii
3792
+ [0, 0, 0, 3, 1, 4, 0], // iii
3793
+ [4, 1, 0, 0, 4, 2, 0], // IV
3794
+ [4, 0, 0, 2, 0, 4, 0], // V
3795
+ [1, 2, 1, 5, 3, 0, 0], // vi
3796
+ [5, 0, 1, 0, 0, 1, 0], // vii°
3797
+ ],
3798
+ jazz: [
3799
+ [0, 4, 1, 2, 1, 4, 0],
3800
+ [0, 0, 0, 0, 8, 0, 1],
3801
+ [0, 0, 0, 1, 0, 6, 0],
3802
+ [2, 2, 0, 0, 2, 0, 3],
3803
+ [6, 0, 0, 0, 0, 2, 0],
3804
+ [0, 7, 0, 1, 1, 0, 0],
3805
+ [2, 0, 5, 0, 0, 0, 0],
3806
+ ],
3807
+ modal: [
3808
+ [0, 3, 1, 4, 1, 2, 2],
3809
+ [5, 0, 1, 1, 0, 0, 1],
3810
+ [3, 1, 0, 1, 0, 0, 1],
3811
+ [5, 1, 0, 0, 1, 0, 2],
3812
+ [3, 0, 0, 2, 0, 1, 1],
3813
+ [3, 1, 0, 1, 0, 0, 1],
3814
+ [5, 0, 0, 2, 0, 0, 0],
3815
+ ],
3816
+ classical: [
3817
+ [0, 2, 1, 4, 5, 3, 1],
3818
+ [0, 0, 0, 0, 6, 0, 2],
3819
+ [0, 0, 0, 2, 0, 5, 0],
3820
+ [2, 3, 0, 0, 5, 0, 1],
3821
+ [6, 0, 0, 0, 0, 2, 0],
3822
+ [0, 4, 0, 4, 1, 0, 0],
3823
+ [6, 0, 1, 0, 0, 0, 0],
3824
+ ],
3825
+ });
3826
+
3827
+ /** Seeded PRNG (mulberry32): same seed, same sequence. */
3828
+ function mulberry32(seed: number): () => number {
3829
+ let state = Math.trunc(seed) >>> 0;
3830
+ return () => {
3831
+ state = (state + 0x6d2b79f5) >>> 0;
3832
+ let t = state;
3833
+ t = Math.imul(t ^ (t >>> 15), t | 1);
3834
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
3835
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
3836
+ };
3837
+ }
3838
+
3839
+ function pick(weights: readonly number[], random: () => number): number {
3840
+ const total = weights.reduce((sum, w) => sum + w, 0);
3841
+ if (!(total > 0)) return 0;
3842
+ let roll = random() * total;
3843
+ for (let i = 0; i < weights.length; i += 1) {
3844
+ roll -= weights[i]!;
3845
+ if (roll < 0) return i;
3846
+ }
3847
+ return weights.length - 1;
3848
+ }
3849
+
3850
+ function findPreset(name: string): ProgressionPreset | undefined {
3851
+ const wanted = name.trim().toLowerCase();
3852
+ return PROGRESSION_PRESETS.find((preset) => preset.name === wanted);
3853
+ }
3854
+
3855
+ /** The next degree the graph favours most after `degree` (no randomness). */
3856
+ function likelyNext(degree: number, style: ProgressionStyle = "pop"): number {
3857
+ const row = TRANSITIONS[style][((degree % 7) + 7) % 7]!;
3858
+ let best = 0;
3859
+ for (let i = 1; i < row.length; i += 1) if (row[i]! > row[best]!) best = i;
3860
+ return best;
3861
+ }
3862
+
3863
+ /**
3864
+ * The chord play mode shows as "next": with a preset, the preset chord
3865
+ * after the last one played (matched by root); otherwise the strongest
3866
+ * graph transition from the last chord's degree, or I when there is none.
3867
+ */
3868
+ function suggestNext(
3869
+ key: Key,
3870
+ last: Chord | undefined,
3871
+ options: Readonly<{
3872
+ preset?: string;
3873
+ style?: ProgressionStyle;
3874
+ sevenths?: boolean;
3875
+ }> = {},
3876
+ ): Chord {
3877
+ const preset = options.preset ? findPreset(options.preset) : undefined;
3878
+ if (preset) {
3879
+ const chords = presetChords(key, preset);
3880
+ const index = last
3881
+ ? chords.findIndex((chord) => chord.root === last.root)
3882
+ : -1;
3883
+ return chords[(index + 1) % chords.length]!;
3884
+ }
3885
+ const degree = last ? degreeOf(key, last.root) : undefined;
3886
+ const next = degree === undefined ? 0 : likelyNext(degree, options.style);
3887
+ return diatonicChord(key, next, options.sevenths);
3888
+ }
3889
+
3890
+ /** A preset's chords in `key` (numerals read in the key's own mode). */
3891
+ function presetChords(key: Key, preset: ProgressionPreset): Chord[] {
3892
+ return preset.numerals.map((numeral) => {
3893
+ const chord = parseRoman(key, numeral);
3894
+ if (!chord) throw new Error(`bad preset numeral ${numeral}`);
3895
+ if (!preset.sevenths || chord.extensions.length > 0) return chord;
3896
+ const withSeventh = parseRoman(key, `${numeral}7`);
3897
+ return withSeventh ?? chord;
3898
+ });
3899
+ }
3900
+
3901
+ type ProgressionRequest = Readonly<{
3902
+ key: Key;
3903
+ /** Number of chords, 1..64. */
3904
+ length: number;
3905
+ /** A preset name or a style for the random walk. */
3906
+ style?: ProgressionStyle | string;
3907
+ seed?: number;
3908
+ sevenths?: boolean;
3909
+ }>;
3910
+
3911
+ /**
3912
+ * A progression of `length` chords. A preset name cycles the preset. A
3913
+ * style walks the transition graph from I with a seeded PRNG; when the
3914
+ * progression is 4+ chords long, the last chord is drawn from the
3915
+ * dominant-function chords (V, vii°, or IV in modal) so the loop leads
3916
+ * back to I. Same request, same chords.
3917
+ */
3918
+ function generateProgression(request: ProgressionRequest): Chord[] {
3919
+ const length = clampInt(request.length, 1, 64);
3920
+ const preset = request.style ? findPreset(request.style) : undefined;
3921
+ if (preset) {
3922
+ const chords = presetChords(request.key, preset);
3923
+ return Array.from({ length }, (_, i) => chords[i % chords.length]!);
3924
+ }
3925
+ const style = (PROGRESSION_STYLES as readonly string[]).includes(
3926
+ request.style ?? "",
3927
+ )
3928
+ ? (request.style as ProgressionStyle)
3929
+ : "pop";
3930
+ const sevenths = request.sevenths ?? style === "jazz";
3931
+ const random = mulberry32(request.seed ?? 1);
3932
+ const degrees: number[] = [0];
3933
+ for (let i = 1; i < length; i += 1) {
3934
+ const row: number[] = [...TRANSITIONS[style][degrees[i - 1]!]!];
3935
+ if (i === length - 1 && length >= 4) {
3936
+ const cadence = style === "modal" ? [3, 6] : [4, 6];
3937
+ for (let d = 0; d < 7; d += 1) if (!cadence.includes(d)) row[d] = 0;
3938
+ if (!row.some((w) => w > 0)) row[cadence[0]!] = 1;
3939
+ }
3940
+ degrees.push(pick(row, random));
3941
+ }
3942
+ return degrees.map((degree) => diatonicChord(request.key, degree, sevenths));
3943
+ }
3944
+
3945
+ /** A chord with its voicing, as tools and the SDK report it. */
3946
+ type VoicedChord = Readonly<{
3947
+ name: string;
3948
+ roman: string;
3949
+ pitches: readonly number[];
3950
+ bass?: number | undefined;
3951
+ }>;
3952
+
3953
+ /** Voice a progression with minimal movement chord to chord. */
3954
+ function voiceProgression(
3955
+ key: Key,
3956
+ chords: readonly Chord[],
3957
+ options: Readonly<{
3958
+ inversion?: number;
3959
+ spread?: Spread;
3960
+ bass?: boolean;
3961
+ anchor?: number;
3962
+ lead?: boolean;
3963
+ }> = {},
3964
+ ): VoicedChord[] {
3965
+ const flats = keyUsesFlats(key);
3966
+ let previous: number[] | undefined;
3967
+ return chords.map((chord) => {
3968
+ const pitches = voiceChord(chord, {
3969
+ ...(options.inversion !== undefined
3970
+ ? { inversion: options.inversion }
3971
+ : {}),
3972
+ ...(options.spread ? { spread: options.spread } : {}),
3973
+ ...(options.anchor !== undefined ? { anchor: options.anchor } : {}),
3974
+ previous: options.lead === false ? undefined : previous,
3975
+ });
3976
+ previous = pitches;
3977
+ return Object.freeze({
3978
+ name: chordName(chord, flats),
3979
+ roman: romanOf(key, chord),
3980
+ pitches: Object.freeze(pitches),
3981
+ ...(options.bass ? { bass: bassNote(chord) } : {}),
3982
+ });
3983
+ });
3984
+ }
3985
+
3986
+ // ---------------------------------------------------------------------------
3987
+ // Helpers
3988
+
3989
+ function mod12(value: number): number {
3990
+ return ((Math.trunc(value) % 12) + 12) % 12;
3991
+ }
3992
+
3993
+ function clampInt(value: number, min: number, max: number): number {
3994
+ if (!Number.isFinite(value)) return min;
3995
+ return Math.max(min, Math.min(max, Math.round(value)));
3996
+ }
3997
+
3998
+ function round6(value: number): number {
3999
+ return Math.round(value * 1e6) / 1e6;
4000
+ }
4001
+
4002
+ // ---------------------------------------------------------------------------
4003
+ // Rendering a progression to notes (tools, SDK, recording)
4004
+
4005
+ /** A roman numeral (`ii7`, `bVII`) or chord symbol (`Cm7`, `F/A`) in `key`. */
4006
+ function resolveChord(key: Key, text: string): Chord | undefined {
4007
+ return parseRoman(key, text) ?? parseChord(text);
4008
+ }
4009
+
4010
+ type RenderOptions = Readonly<{
4011
+ key: Key;
4012
+ chords: readonly Chord[];
4013
+ /** Beats each chord lasts (one bar of 4/4 by default). */
4014
+ beatsPerChord?: number;
4015
+ /** First chord's start in beats. */
4016
+ start?: number;
4017
+ perform?: PerformOptions;
4018
+ inversion?: number;
4019
+ spread?: Spread;
4020
+ /** Add a bass note under each chord. */
4021
+ bass?: boolean;
4022
+ /**
4023
+ * Bass behaviour (overrides `bass`). Every progression step is a chord,
4024
+ * so `chords` and `single` add the root (or slash bass), `unison` the
4025
+ * chord's root (the key a player would press), and `solo` drops the
4026
+ * treble and keeps only that bass.
4027
+ */
4028
+ bassMode?: BassMode;
4029
+ /** Voice-lead chord to chord (default true). */
4030
+ lead?: boolean;
4031
+ anchor?: number;
4032
+ }>;
4033
+
4034
+ type RenderedProgression = Readonly<{
4035
+ voiced: readonly VoicedChord[];
4036
+ notes: readonly PerformedNote[];
4037
+ bass: readonly PerformedNote[];
4038
+ }>;
4039
+
4040
+ /**
4041
+ * A progression as notes: voice-led voicings performed over consecutive
4042
+ * spans of `beatsPerChord`, plus one sustained bass note per chord. The
4043
+ * arp-random seed advances per chord so repeated chords vary but the
4044
+ * whole render stays deterministic.
4045
+ */
4046
+ function renderProgression(options: RenderOptions): RenderedProgression {
4047
+ const span =
4048
+ options.beatsPerChord && options.beatsPerChord > 0
4049
+ ? options.beatsPerChord
4050
+ : 4;
4051
+ const start = options.start ?? 0;
4052
+ const voiced = voiceProgression(options.key, options.chords, {
4053
+ ...(options.inversion !== undefined
4054
+ ? { inversion: options.inversion }
4055
+ : {}),
4056
+ ...(options.spread ? { spread: options.spread } : {}),
4057
+ ...(options.anchor !== undefined ? { anchor: options.anchor } : {}),
4058
+ ...(options.lead !== undefined ? { lead: options.lead } : {}),
4059
+ bass: true,
4060
+ });
4061
+ const notes: PerformedNote[] = [];
4062
+ const bass: PerformedNote[] = [];
4063
+ const velocity = options.perform?.velocity ?? 0.8;
4064
+ const mode: BassMode = options.bassMode ?? (options.bass ? "chords" : "off");
4065
+ voiced.forEach((chord, index) => {
4066
+ const at = start + index * span;
4067
+ if (mode !== "solo")
4068
+ notes.push(
4069
+ ...perform(chord.pitches, at, span, {
4070
+ ...options.perform,
4071
+ seed: (options.perform?.seed ?? 0) + index,
4072
+ }),
4073
+ );
4074
+ const source = options.chords[index]!;
4075
+ const under =
4076
+ mode === "off"
4077
+ ? undefined
4078
+ : mode === "unison"
4079
+ ? bassNote({ ...source, bass: undefined })
4080
+ : chord.bass;
4081
+ if (under !== undefined)
4082
+ bass.push({
4083
+ pitch: under,
4084
+ start: round6(at),
4085
+ length: round6(span),
4086
+ velocity,
4087
+ });
4088
+ });
4089
+ return {
4090
+ voiced:
4091
+ mode !== "off"
4092
+ ? voiced
4093
+ : voiced.map(({ bass: _bass, ...rest }) => Object.freeze(rest)),
4094
+ notes,
4095
+ bass,
4096
+ };
4097
+ }
4098
+ // END chord engine
4099
+
901
4100
  // ---------------------------------------------------------------------------
902
4101
  // Internals
903
4102
 
@@ -911,6 +4110,12 @@ function finite(value: unknown, label: string): number {
911
4110
  return value;
912
4111
  }
913
4112
 
4113
+ function text(value: unknown, label: string): string {
4114
+ if (typeof value !== "string" || value.length === 0 || value.length > 1024)
4115
+ throw new DawgSdkError(`${label} must be a short string`);
4116
+ return value;
4117
+ }
4118
+
914
4119
  function beat(value: unknown, label: string): number {
915
4120
  const number = finite(value, label);
916
4121
  if (number < 0) throw new DawgSdkError(`${label} must be ≥ 0 beats`);