@hraness/dawg 0.5.0 → 0.6.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 (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
package/core/sdk/v1.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  /** SDK release; dawg refreshes the vendored copy when its own is newer. */
30
- export const SDK_VERSION = "1.19.0";
30
+ export const SDK_VERSION = "1.31.0";
31
31
  /** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
32
32
  export const SDK_MAJOR = 1;
33
33
 
@@ -1277,6 +1277,36 @@ export type SampleSpec = Readonly<{
1277
1277
  accelerate?: number;
1278
1278
  /** Like Tidal `squiz`: pitch-raise ratio per zero-crossing cycle (1..32). */
1279
1279
  squiz?: number;
1280
+ /** The file's own tempo (20..400, SDK 1.20.0): the window follows the song's tempo map. */
1281
+ bpm?: number;
1282
+ /** How a fitted window changes time (SDK 1.20.0): `"repitch"` (tape), `"beats"` (onset slices), `"tones"` (keeps pitch). */
1283
+ fitmode?: "repitch" | "beats" | "tones";
1284
+ /** Window length in beats (SDK 1.20.0); `fit` wins over `bpm`, `bpm` over `len`. */
1285
+ len?: number;
1286
+ /** Pitch shift in semitones at the same length (−24..24, SDK 1.29.0). */
1287
+ shift?: number;
1288
+ /** Formant shift in semitones with `shift` (SDK 1.29.0): absent follows the pitch, 0 keeps the formants. */
1289
+ formant?: number;
1290
+ /** Like Strudel `fadeTime`: release fade in seconds (0..2, SDK 1.29.0). */
1291
+ fadeTime?: number;
1292
+ /** Like Strudel `fadeInTime`: attack fade in seconds (0..2, SDK 1.29.0). */
1293
+ fadeInTime?: number;
1294
+ /** Where `resample` rendered the file from (informational, SDK 1.29.0). */
1295
+ from?: SampleProvenanceSpec;
1296
+ /** Velocity layer (SDK 1.31.0, SFZ lovel/hivel): MIDI velocities `[lo, hi]` this voice plays. */
1297
+ vel?: readonly [number, number];
1298
+ /** Round-robin group (SDK 1.31.0, SFZ seq_length): voices in a group take turns, A B A B. */
1299
+ rr?: string;
1300
+ }>;
1301
+
1302
+ /** `SampleSpec.from` (SDK 1.29.0): the source of a resampled file. */
1303
+ export type SampleProvenanceSpec = Readonly<{
1304
+ /** `track:<id>`, `orbit:<n>` or `master`. */
1305
+ source: string;
1306
+ section?: string;
1307
+ bars?: readonly [number, number];
1308
+ /** sha256 of the score the file was rendered from. */
1309
+ score: string;
1280
1310
  }>;
1281
1311
 
1282
1312
  /** Result of `sampler()`; pass it as a track's `instrument`. */
@@ -1317,7 +1347,7 @@ export function sampler(
1317
1347
  throw new DawgSdkError(
1318
1348
  `sampler voice "${name}" must be a short identifier`,
1319
1349
  );
1320
- out[name] = sample(voices[name]!, name);
1350
+ out[name] = sampleSpec(voices[name]!, name);
1321
1351
  }
1322
1352
  return Object.freeze({ kind: "sampler", voices: Object.freeze(out), mode });
1323
1353
  }
@@ -1433,6 +1463,551 @@ export function wavetable(
1433
1463
  return Object.freeze(out) as WavetableSpec;
1434
1464
  }
1435
1465
 
1466
+ /** Instrument name of the 0.6 string engine (`Track.string`). */
1467
+ export const STRING_INSTRUMENT = "string";
1468
+
1469
+ /**
1470
+ * String engine settings (SDK 1.21.0): a `preset` (`nylon`, `steel`,
1471
+ * `electric`, `jangle`, `ebass`, `slap`, `upright`, `sitar`, `tanpura`,
1472
+ * `harpsichord`, `lute`, `oud`, `setar`, `tar`, `santur`, `dulcimer`, `koto`,
1473
+ * `harp`, `banjo`, `tres`, `requinto`; bowed since SDK 1.31.0: `violin`,
1474
+ * `viola`, `cello`, `contrabass`, `fiddle`, `erhu`, `kamancheh`, `violins`,
1475
+ * `violas`, `cellos`, `contrabasses`, `pizz`, `trem`) plus any parameter to
1476
+ * override (`ring`, `bright`, `damp`, `pos`, `mute`, `buzz`, `body`, `sym`,
1477
+ * bow: `exciter: "bow"`, `pressure`, `speed`, `attack`, `vib`, `vibmod`,
1478
+ * `vibdelay`, `tremhz`, `sord`, `dyn`). dawg validates names and ranges;
1479
+ * see **Strings** in DAWG.md.
1480
+ */
1481
+ export type StringInput = Readonly<
1482
+ { preset?: string } & Record<string, number | string | undefined>
1483
+ >;
1484
+
1485
+ /** Result of `stringed()`; pass it as a track's `instrument`. */
1486
+ export type StringSpec = Readonly<{ kind: "string" } & StringInput>;
1487
+
1488
+ /**
1489
+ * A plucked or bowed string instrument (SDK 1.21.0; bowed and the object
1490
+ * form 1.31.0): a preset and overrides.
1491
+ *
1492
+ * instrument: stringed("nylon")
1493
+ * instrument: stringed("sitar", { buzz: 0.8, sym: 0.5 })
1494
+ * instrument: stringed({ preset: "cello", vib: 6 })
1495
+ */
1496
+ export function stringed(
1497
+ preset: string | StringInput = "nylon",
1498
+ params: Readonly<Record<string, number | string>> = {},
1499
+ ): StringSpec {
1500
+ if (isRecord(preset)) {
1501
+ const { preset: name, ...rest } = preset as Record<string, unknown>;
1502
+ return stringed(name === undefined ? "nylon" : (name as string), {
1503
+ ...(rest as Record<string, number | string>),
1504
+ ...params,
1505
+ });
1506
+ }
1507
+ if (typeof preset !== "string" || preset.length === 0)
1508
+ throw new DawgSdkError("stringed needs a preset name");
1509
+ if (!isRecord(params))
1510
+ throw new DawgSdkError("stringed params must be an object");
1511
+ const out: Record<string, number | string> = { kind: "string", preset };
1512
+ for (const [key, value] of Object.entries(params)) {
1513
+ if (value === undefined || key === "kind" || key === "preset") continue;
1514
+ out[key] =
1515
+ typeof value === "string" ? value : finite(value, `string ${key}`);
1516
+ }
1517
+ return Object.freeze(out) as StringSpec;
1518
+ }
1519
+
1520
+ /** Instrument name of the 0.6 granular engine (`Track.granular`). */
1521
+ export const GRANULAR_INSTRUMENT = "granular";
1522
+
1523
+ /**
1524
+ * Granular settings (SDK 1.23.0). `src` is a sample (`sample(...)` shape,
1525
+ * pinned like a sampler voice) or a built-in synth render
1526
+ * `"synth:<preset>[@note]"` (default `synth:pad`, nothing to download);
1527
+ * `preset` is `cloud`, `hold`, `sparkle`, `swarm`, `stutter`, `microloop`,
1528
+ * `backwards` or `dust`; every other key overrides one parameter (`grain`
1529
+ * seconds, `overlap`, `scan`, `pos`, `begin`, `end`, `spray`, `jitter`,
1530
+ * `pitch`, `detune`, `shimmer`, `shimint`, `spread`, `window`, `reverse`,
1531
+ * `freeze`, `repeat`, `hold`, `drift`, `drate`, `attack`, `release`,
1532
+ * `veltone`, `gain`, `seed`, `root`). dawg validates names and ranges; see
1533
+ * **Granular** in DAWG.md.
1534
+ */
1535
+ export type GranularInput = Readonly<
1536
+ {
1537
+ src?: string | SampleSpec;
1538
+ preset?: string;
1539
+ } & Record<string, number | string | boolean | SampleSpec | undefined>
1540
+ >;
1541
+
1542
+ /** Result of `granular()`; pass it as a track's `instrument`. */
1543
+ export type GranularSpec = Readonly<{ kind: "granular" } & GranularInput>;
1544
+
1545
+ /**
1546
+ * A granular instrument (SDK 1.23.0): an optional preset, then overrides.
1547
+ *
1548
+ * ```ts
1549
+ * instrument: granular("cloud")
1550
+ * instrument: granular("hold", { src: "samples/choir.wav", scan: 0 })
1551
+ * instrument: granular({ src: "synth:bell@72", grain: 0.08, overlap: 6 })
1552
+ * ```
1553
+ */
1554
+ export function granular(
1555
+ preset?: string | GranularInput,
1556
+ params: GranularInput = {},
1557
+ ): GranularSpec {
1558
+ const fields =
1559
+ typeof preset === "object" && preset !== null ? preset : params;
1560
+ if (!isRecord(fields))
1561
+ throw new DawgSdkError("granular params must be an object");
1562
+ const out: Record<string, unknown> = { kind: "granular" };
1563
+ if (typeof preset === "string") {
1564
+ if (preset.length === 0)
1565
+ throw new DawgSdkError("granular needs a preset name");
1566
+ out.preset = preset;
1567
+ } else if (preset !== undefined && (typeof preset !== "object" || !preset))
1568
+ throw new DawgSdkError("granular takes a preset name or params");
1569
+ for (const [key, value] of Object.entries(fields)) {
1570
+ if (value === undefined || key === "kind") continue;
1571
+ if (key === "src")
1572
+ out.src =
1573
+ typeof value === "string" && value.startsWith("synth:")
1574
+ ? value
1575
+ : sampleSpec(value as string | SampleSpec, "granular src");
1576
+ else if (key === "preset" && typeof preset === "string") continue;
1577
+ else if (key === "root" && typeof value === "string")
1578
+ out.root = midi(value as Pitch);
1579
+ else if (
1580
+ typeof value === "number" ||
1581
+ typeof value === "string" ||
1582
+ typeof value === "boolean"
1583
+ )
1584
+ out[key] =
1585
+ typeof value === "number" ? finite(value, `granular ${key}`) : value;
1586
+ else throw new DawgSdkError(`granular ${key} must be a value`);
1587
+ }
1588
+ return Object.freeze(out) as GranularSpec;
1589
+ }
1590
+
1591
+ /** Instrument name that selects the modal mallet-and-bell engine (SDK 1.25.0). */
1592
+ export const MODAL_INSTRUMENT = "modal";
1593
+
1594
+ /** Modal presets (dawg's core/resonators.ts). */
1595
+ export type ModalPresetName =
1596
+ | "marimba"
1597
+ | "vibes"
1598
+ | "xylophone"
1599
+ | "glock"
1600
+ | "celesta"
1601
+ | "chimes"
1602
+ | "kalimba"
1603
+ | "mbira"
1604
+ | "steelpan"
1605
+ | "bowl"
1606
+ | "gong"
1607
+ | "timpani"
1608
+ // SDK 1.30.0: gamelan, small bells and frame drums.
1609
+ | "crotales"
1610
+ | "musicbox"
1611
+ | "toypiano"
1612
+ | "saron"
1613
+ | "demung"
1614
+ | "slenthem"
1615
+ | "gangsa"
1616
+ | "gender"
1617
+ | "bonang"
1618
+ | "kenong"
1619
+ | "kethuk"
1620
+ | "kempul"
1621
+ | "daf"
1622
+ | "bodhran"
1623
+ | "tabla";
1624
+
1625
+ /** Modal overrides; omitted means the preset's value. dawg validates ranges. */
1626
+ export type ModalParams = Readonly<{
1627
+ /** `yarn` `cord` `rubber` `plastic` `brass`: sets hardness. */
1628
+ mallet?: "yarn" | "cord" | "rubber" | "plastic" | "brass";
1629
+ /** Mallet hardness 0..1: brighter, shorter contact. */
1630
+ hardness?: number;
1631
+ /** Strike position 0..1 (0.5 is the bar's centre). */
1632
+ position?: number;
1633
+ /** Fundamental ring time (T60 seconds). */
1634
+ ring?: number;
1635
+ /** How much faster upper modes die (octaves of decay per octave). */
1636
+ tilt?: number;
1637
+ /** Damping on note-off 0..1 (0 lets the bar ring). */
1638
+ damp?: number;
1639
+ /** Choke time after note-off, seconds. */
1640
+ release?: number;
1641
+ /** Vibraphone motor rate Hz and depth 0..1. */
1642
+ motor?: number;
1643
+ motordepth?: number;
1644
+ /** Beat between paired gamelan modes, Hz. */
1645
+ ombak?: number;
1646
+ /** Mbira buzz 0..1 and mallet click 0..1. */
1647
+ buzz?: number;
1648
+ click?: number;
1649
+ /** Strike pitch bend in semitones and its decay seconds (Strudel penv/pdecay). */
1650
+ strikebend?: number;
1651
+ strikedecay?: number;
1652
+ /** Output level 0..2 (1 is the preset level). */
1653
+ gain?: number;
1654
+ /** Mode table override (`marimba`, `bell`, `gong`, …). */
1655
+ body?: string;
1656
+ /**
1657
+ * The partner track id of an ombak pair (SDK 1.30.0): this track (the
1658
+ * pengisep) sounds `ombak` Hz above it. song() sets the partner's ombak
1659
+ * to 0 (one straight voice) unless the partner gives its own.
1660
+ */
1661
+ pair?: string;
1662
+ }>;
1663
+
1664
+ const MODAL_KEYS = Object.freeze([
1665
+ "mallet",
1666
+ "hardness",
1667
+ "position",
1668
+ "ring",
1669
+ "tilt",
1670
+ "damp",
1671
+ "release",
1672
+ "motor",
1673
+ "motordepth",
1674
+ "ombak",
1675
+ "buzz",
1676
+ "click",
1677
+ "strikebend",
1678
+ "strikedecay",
1679
+ "gain",
1680
+ "body",
1681
+ ] as const);
1682
+
1683
+ /** Mallet words (core/resonators.ts MODAL_MALLETS). */
1684
+ const MODAL_MALLET_WORDS: readonly string[] = Object.freeze([
1685
+ "yarn",
1686
+ "cord",
1687
+ "rubber",
1688
+ "plastic",
1689
+ "brass",
1690
+ ]);
1691
+
1692
+ /** Mode tables a `body` override may name (core/resonators.ts MODAL_BODIES). */
1693
+ const MODAL_BODY_WORDS: readonly string[] = Object.freeze([
1694
+ "marimba",
1695
+ "vibraphone",
1696
+ "xylophone",
1697
+ "glockenspiel",
1698
+ "celesta",
1699
+ "chimes",
1700
+ "crotale",
1701
+ "mbira",
1702
+ "kalimba",
1703
+ "musicbox",
1704
+ "toypiano",
1705
+ "saron",
1706
+ "bonang",
1707
+ "gender",
1708
+ "kempul",
1709
+ "gong",
1710
+ "bell",
1711
+ "steelpan",
1712
+ "bowl",
1713
+ "timpani",
1714
+ "tabla",
1715
+ "frame",
1716
+ ]);
1717
+
1718
+ /** Numeric ranges (core/resonators.ts MODAL_PARAMS min..max). */
1719
+ const MODAL_RANGES: Readonly<Record<string, readonly [number, number]>> =
1720
+ Object.freeze({
1721
+ hardness: [0, 1],
1722
+ position: [0, 1],
1723
+ ring: [0.05, 30],
1724
+ tilt: [0, 2],
1725
+ damp: [0, 1],
1726
+ release: [0.005, 2],
1727
+ motor: [0, 12],
1728
+ motordepth: [0, 1],
1729
+ ombak: [0, 12],
1730
+ buzz: [0, 1],
1731
+ click: [0, 1],
1732
+ strikebend: [-24, 24],
1733
+ strikedecay: [0.001, 2],
1734
+ gain: [0, 2],
1735
+ });
1736
+
1737
+ /** Modal presets whose default carries an ombak twin bank (core/resonators.ts). */
1738
+ const MODAL_TWIN_PRESETS: readonly string[] = Object.freeze(["gangsa"]);
1739
+
1740
+ const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
1741
+ "marimba",
1742
+ "vibes",
1743
+ "xylophone",
1744
+ "glock",
1745
+ "celesta",
1746
+ "chimes",
1747
+ "kalimba",
1748
+ "mbira",
1749
+ "steelpan",
1750
+ "bowl",
1751
+ "gong",
1752
+ "timpani",
1753
+ "crotales",
1754
+ "musicbox",
1755
+ "toypiano",
1756
+ "saron",
1757
+ "demung",
1758
+ "slenthem",
1759
+ "gangsa",
1760
+ "gender",
1761
+ "bonang",
1762
+ "kenong",
1763
+ "kethuk",
1764
+ "kempul",
1765
+ "daf",
1766
+ "bodhran",
1767
+ "tabla",
1768
+ ]);
1769
+
1770
+ /** Result of `modal()`; pass it as a track's `instrument`. */
1771
+ export type ModalSpec = Readonly<
1772
+ { kind: "modal"; preset?: ModalPresetName } & ModalParams
1773
+ >;
1774
+
1775
+ /**
1776
+ * Mallets and bells on the modal engine (SDK 1.25.0): a preset and
1777
+ * optional overrides. A preset word alone (`instrument: "vibes"`) is the
1778
+ * same as `modal("vibes")`, except `"marimba"`, which stays the legacy
1779
+ * marimba voice; `modal("marimba")` is the modal one.
1780
+ *
1781
+ * ```ts
1782
+ * instrument: modal("vibes", { motor: 4, hardness: 0.6 })
1783
+ * instrument: modal("marimba", { mallet: "rubber" })
1784
+ * instrument: modal({ ring: 2 }) // default preset (marimba)
1785
+ * ```
1786
+ */
1787
+ export function modal(
1788
+ preset?: ModalPresetName | ModalParams,
1789
+ params: ModalParams = {},
1790
+ ): ModalSpec {
1791
+ const overrides = isRecord(preset) ? preset : params;
1792
+ const name = isRecord(preset) ? undefined : preset;
1793
+ if (!isRecord(overrides))
1794
+ throw new DawgSdkError("modal params must be an object");
1795
+ const out: Record<string, unknown> = { kind: "modal" };
1796
+ if (name !== undefined) {
1797
+ if (typeof name !== "string" || !MODAL_PRESET_WORDS.includes(name))
1798
+ throw new DawgSdkError(
1799
+ `modal preset "${String(name).slice(0, 32)}" is not one of ${MODAL_PRESET_WORDS.join(" ")}`,
1800
+ );
1801
+ out.preset = name;
1802
+ }
1803
+ for (const key of Object.keys(overrides)) {
1804
+ const value = (overrides as Record<string, unknown>)[key];
1805
+ if (value === undefined) continue;
1806
+ if (key === "mallet" || key === "body") {
1807
+ const words = key === "mallet" ? MODAL_MALLET_WORDS : MODAL_BODY_WORDS;
1808
+ if (typeof value !== "string" || !words.includes(value))
1809
+ throw new DawgSdkError(
1810
+ `modal ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
1811
+ );
1812
+ out[key] = value;
1813
+ } else if (key === "pair") {
1814
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(value))
1815
+ throw new DawgSdkError("modal pair must be a track id");
1816
+ out.pair = value;
1817
+ } else if ((MODAL_KEYS as readonly string[]).includes(key)) {
1818
+ const number = finite(value, `modal ${key}`);
1819
+ const [min, max] = MODAL_RANGES[key]!;
1820
+ if (number < min || number > max)
1821
+ throw new DawgSdkError(`modal ${key} must be ${min}..${max}`);
1822
+ out[key] = number;
1823
+ } else
1824
+ throw new DawgSdkError(
1825
+ `modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")} pair)`,
1826
+ );
1827
+ }
1828
+ return Object.freeze(out) as ModalSpec;
1829
+ }
1830
+
1831
+ // ---- winds (f061-gamelan-winds, SDK 1.30.0) ----
1832
+
1833
+ /** The instrument value of the wind engine (core/winds.ts). */
1834
+ export const WIND_INSTRUMENT = "wind";
1835
+
1836
+ /** Wind presets (core/winds.ts WIND_PRESET_NAMES). */
1837
+ export type WindPresetName =
1838
+ | "flute"
1839
+ | "recorder"
1840
+ | "whistle"
1841
+ | "ney"
1842
+ | "shakuhachi"
1843
+ | "panpipe"
1844
+ | "suling"
1845
+ | "bansuri"
1846
+ | "clarinet"
1847
+ | "bassclarinet"
1848
+ | "oboe"
1849
+ | "bassoon"
1850
+ | "sax"
1851
+ | "altosax"
1852
+ | "barisax"
1853
+ | "trumpet"
1854
+ | "harmon"
1855
+ | "plunger"
1856
+ | "trombone"
1857
+ | "tuba"
1858
+ | "horn";
1859
+
1860
+ const WIND_PRESET_WORDS: readonly string[] = Object.freeze([
1861
+ "flute",
1862
+ "recorder",
1863
+ "whistle",
1864
+ "ney",
1865
+ "shakuhachi",
1866
+ "panpipe",
1867
+ "suling",
1868
+ "bansuri",
1869
+ "clarinet",
1870
+ "bassclarinet",
1871
+ "oboe",
1872
+ "bassoon",
1873
+ "sax",
1874
+ "altosax",
1875
+ "barisax",
1876
+ "trumpet",
1877
+ "harmon",
1878
+ "plunger",
1879
+ "trombone",
1880
+ "tuba",
1881
+ "horn",
1882
+ ]);
1883
+
1884
+ /** Wind overrides (core/winds.ts WIND_PARAMS). */
1885
+ export type WindParams = Readonly<{
1886
+ model?: "jet" | "reed" | "sax" | "lips";
1887
+ breath?: number;
1888
+ noise?: number;
1889
+ attack?: number;
1890
+ release?: number;
1891
+ vib?: number;
1892
+ vibmod?: number;
1893
+ reed?: number;
1894
+ bright?: number;
1895
+ stopped?: boolean;
1896
+ mute?: "open" | "straight" | "cup" | "harmon" | "plunger";
1897
+ wah?: number;
1898
+ wahenv?: number;
1899
+ growl?: number;
1900
+ flutter?: number;
1901
+ players?: number;
1902
+ gain?: number;
1903
+ }>;
1904
+
1905
+ /** Enum words per wind parameter (core/winds.ts WIND_PARAMS). */
1906
+ const WIND_ENUMS: Readonly<Record<string, readonly string[]>> = Object.freeze({
1907
+ model: Object.freeze(["jet", "reed", "sax", "lips"]),
1908
+ mute: Object.freeze(["open", "straight", "cup", "harmon", "plunger"]),
1909
+ });
1910
+
1911
+ /** Numeric ranges (core/winds.ts WIND_PARAMS min..max). */
1912
+ const WIND_RANGES: Readonly<Record<string, readonly [number, number]>> =
1913
+ Object.freeze({
1914
+ breath: [0, 1],
1915
+ noise: [0, 1],
1916
+ attack: [0.001, 2],
1917
+ release: [0.005, 2],
1918
+ vib: [0, 12],
1919
+ vibmod: [0, 1],
1920
+ reed: [0, 1],
1921
+ bright: [0, 1],
1922
+ wah: [0, 1],
1923
+ wahenv: [0, 1],
1924
+ growl: [0, 1],
1925
+ flutter: [0, 1],
1926
+ players: [1, 8],
1927
+ gain: [0, 2],
1928
+ });
1929
+
1930
+ /** Result of `wind()`; pass it as a track's `instrument`. */
1931
+ export type WindSpec = Readonly<
1932
+ { kind: "wind"; preset?: WindPresetName } & WindParams
1933
+ >;
1934
+
1935
+ /**
1936
+ * Winds and brass on the wind engine (SDK 1.30.0): a preset and optional
1937
+ * overrides. A preset word alone (`instrument: "flute"`) is the same as
1938
+ * `wind("flute")`; the bare word `"wind"` stays the legacy wind tone.
1939
+ *
1940
+ * ```ts
1941
+ * instrument: wind("sax", { breath: 0.8, growl: 0.2 })
1942
+ * instrument: wind("trumpet", { mute: "harmon", players: 3 })
1943
+ * instrument: wind({ bright: 0.3 }) // default preset (flute)
1944
+ * ```
1945
+ */
1946
+ export function wind(
1947
+ preset?: WindPresetName | WindParams,
1948
+ params: WindParams = {},
1949
+ ): WindSpec {
1950
+ const overrides = isRecord(preset) ? preset : params;
1951
+ const name = isRecord(preset) ? undefined : preset;
1952
+ if (!isRecord(overrides))
1953
+ throw new DawgSdkError("wind params must be an object");
1954
+ const out: Record<string, unknown> = { kind: "wind" };
1955
+ if (name !== undefined) {
1956
+ if (typeof name !== "string" || !WIND_PRESET_WORDS.includes(name))
1957
+ throw new DawgSdkError(
1958
+ `wind preset "${String(name).slice(0, 32)}" is not one of ${WIND_PRESET_WORDS.join(" ")}`,
1959
+ );
1960
+ out.preset = name;
1961
+ }
1962
+ for (const key of Object.keys(overrides)) {
1963
+ const value = (overrides as Record<string, unknown>)[key];
1964
+ if (value === undefined) continue;
1965
+ const words = WIND_ENUMS[key];
1966
+ if (words) {
1967
+ if (typeof value !== "string" || !words.includes(value))
1968
+ throw new DawgSdkError(
1969
+ `wind ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
1970
+ );
1971
+ out[key] = value;
1972
+ } else if (key === "stopped") {
1973
+ if (typeof value !== "boolean")
1974
+ throw new DawgSdkError("wind stopped must be true or false");
1975
+ out.stopped = value;
1976
+ } else if (WIND_RANGES[key]) {
1977
+ const number = finite(value, `wind ${key}`);
1978
+ const [min, max] = WIND_RANGES[key]!;
1979
+ if (number < min || number > max)
1980
+ throw new DawgSdkError(`wind ${key} must be ${min}..${max}`);
1981
+ if (key === "players" && !Number.isInteger(number))
1982
+ throw new DawgSdkError("wind players must be a whole number");
1983
+ out[key] = number;
1984
+ } else
1985
+ throw new DawgSdkError(
1986
+ `wind has no parameter "${key.slice(0, 32)}" (${[...Object.keys(WIND_ENUMS), "stopped", ...Object.keys(WIND_RANGES)].join(" ")})`,
1987
+ );
1988
+ }
1989
+ return Object.freeze(out) as WindSpec;
1990
+ }
1991
+
1992
+ /**
1993
+ * The wind field an instrument makes: `wind(...)`, or a wind preset word
1994
+ * (`"flute"`, `"saxophone"`). The bare word `"wind"` keeps its pre-0.6.1
1995
+ * meaning (the legacy tone) and makes none.
1996
+ */
1997
+ function trackWind(
1998
+ raw: unknown,
1999
+ ): Readonly<{ preset?: WindPresetName } & WindParams> | undefined {
2000
+ if (isRecord(raw) && raw.kind === "wind") {
2001
+ const { kind: _kind, ...fields } = raw as WindSpec;
2002
+ return Object.freeze(fields);
2003
+ }
2004
+ if (typeof raw !== "string" || raw === WIND_INSTRUMENT) return undefined;
2005
+ const meaning = resolveInstrumentWord(raw);
2006
+ if (meaning?.instrument !== WIND_INSTRUMENT || !meaning.preset)
2007
+ return undefined;
2008
+ return Object.freeze({ preset: meaning.preset as WindPresetName });
2009
+ }
2010
+
1436
2011
  /**
1437
2012
  * `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
1438
2013
  * chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
@@ -1445,7 +2020,7 @@ export function slices(
1445
2020
  ): Record<string, SampleSpec> {
1446
2021
  if (!Number.isInteger(count) || count < 1 || count > 64)
1447
2022
  throw new DawgSdkError("slices count must be an integer 1..64");
1448
- const base = sample(src, prefix);
2023
+ const base = sampleSpec(src, prefix);
1449
2024
  const begin = base.begin ?? 0;
1450
2025
  const end = base.end ?? 1;
1451
2026
  const span = (end - begin) / count;
@@ -1460,7 +2035,7 @@ export function slices(
1460
2035
  return voices;
1461
2036
  }
1462
2037
 
1463
- function sample(value: string | SampleSpec, name: string): SampleSpec {
2038
+ function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
1464
2039
  const spec = typeof value === "string" ? { src: value } : value;
1465
2040
  if (!isRecord(spec) || typeof spec.src !== "string" || spec.src.length === 0)
1466
2041
  throw new DawgSdkError(`sampler voice ${name} needs a src path`);
@@ -1483,6 +2058,16 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
1483
2058
  fit?: boolean;
1484
2059
  accelerate?: number;
1485
2060
  squiz?: number;
2061
+ bpm?: number;
2062
+ fitmode?: "repitch" | "beats" | "tones";
2063
+ len?: number;
2064
+ shift?: number;
2065
+ formant?: number;
2066
+ fadeTime?: number;
2067
+ fadeInTime?: number;
2068
+ from?: SampleProvenanceSpec;
2069
+ vel?: readonly [number, number];
2070
+ rr?: string;
1486
2071
  } = { src: spec.src };
1487
2072
  if (spec.src.startsWith("pack:")) {
1488
2073
  if (spec.sha256 !== undefined)
@@ -1532,9 +2117,57 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
1532
2117
  if (spec.accelerate !== undefined)
1533
2118
  out.accelerate = finite(spec.accelerate, `${name} accelerate`);
1534
2119
  if (spec.squiz !== undefined) out.squiz = finite(spec.squiz, `${name} squiz`);
2120
+ if (spec.bpm !== undefined) out.bpm = finite(spec.bpm, `${name} bpm`);
2121
+ if (spec.fitmode !== undefined) {
2122
+ if (
2123
+ spec.fitmode !== "repitch" &&
2124
+ spec.fitmode !== "beats" &&
2125
+ spec.fitmode !== "tones"
2126
+ )
2127
+ throw new DawgSdkError(
2128
+ `${name} fitmode must be "repitch", "beats" or "tones"`,
2129
+ );
2130
+ out.fitmode = spec.fitmode;
2131
+ }
2132
+ if (spec.len !== undefined) out.len = finite(spec.len, `${name} len`);
2133
+ if (spec.shift !== undefined) out.shift = finite(spec.shift, `${name} shift`);
2134
+ if (spec.formant !== undefined)
2135
+ out.formant = finite(spec.formant, `${name} formant`);
2136
+ if (spec.fadeTime !== undefined)
2137
+ out.fadeTime = finite(spec.fadeTime, `${name} fadeTime`);
2138
+ if (spec.fadeInTime !== undefined)
2139
+ out.fadeInTime = finite(spec.fadeInTime, `${name} fadeInTime`);
2140
+ if (spec.from !== undefined) {
2141
+ if (typeof spec.from !== "object" || spec.from === null)
2142
+ throw new DawgSdkError(`${name} from must be an object`);
2143
+ out.from = spec.from;
2144
+ }
2145
+ if (spec.vel !== undefined) {
2146
+ if (!Array.isArray(spec.vel) || spec.vel.length !== 2)
2147
+ throw new DawgSdkError(`${name} vel must be [lo, hi]`);
2148
+ out.vel = Object.freeze([
2149
+ finite(spec.vel[0], `${name} vel`),
2150
+ finite(spec.vel[1], `${name} vel`),
2151
+ ] as const);
2152
+ }
2153
+ if (spec.rr !== undefined) out.rr = text(spec.rr, `${name} rr`);
1535
2154
  return Object.freeze(out);
1536
2155
  }
1537
2156
 
2157
+ /**
2158
+ * One sample file with options (SDK 1.20.0), for `sampler({ brk: ... })`:
2159
+ * `sample("samples/break.wav", { bpm: 174, fitmode: "beats" })` plays a
2160
+ * 174 BPM break in time with the song, cut at its hits.
2161
+ */
2162
+ export function sample(
2163
+ src: string,
2164
+ options: Omit<SampleSpec, "src"> = {},
2165
+ ): SampleSpec {
2166
+ if (typeof src !== "string" || src.length === 0)
2167
+ throw new DawgSdkError("sample() needs a src path");
2168
+ return Object.freeze({ ...options, src });
2169
+ }
2170
+
1538
2171
  // ---------------------------------------------------------------------------
1539
2172
  // Tracks
1540
2173
 
@@ -1564,14 +2197,248 @@ export type AutomationInput = Readonly<{
1564
2197
  wt?: readonly Point[];
1565
2198
  }>;
1566
2199
 
2200
+ /**
2201
+ * Every rig preset (SDK 1.22.0), kept equal to `RIG_PRESETS` in
2202
+ * core/fx.ts by `print-rig.test.ts`: the stomp, head and cab stages plus
2203
+ * the companion effects a few rigs need (funk's and wah's autofilter,
2204
+ * bachata's chorus, jangle's compressor). `spring`'s short room is the
2205
+ * track's `reverb`, not `fx`: give it as
2206
+ * `reverb: { mix: 0.3, size: 0.35, fade: 1.5, predelay: 0, dim: 3500 }`.
2207
+ */
2208
+ export const RIG_PRESETS: Readonly<
2209
+ Record<
2210
+ string,
2211
+ Readonly<
2212
+ Record<"stomp" | "head" | "cab", EffectParams | undefined> &
2213
+ Readonly<Record<string, EffectParams | undefined>>
2214
+ >
2215
+ >
2216
+ > = Object.freeze({
2217
+ clean: {
2218
+ stomp: undefined,
2219
+ head: { type: "clean", gain: 3, treble: 6 },
2220
+ cab: { type: "1x12" },
2221
+ },
2222
+ crunch: {
2223
+ stomp: undefined,
2224
+ head: { type: "crunch", gain: 5 },
2225
+ cab: { type: "4x12" },
2226
+ },
2227
+ punk: {
2228
+ stomp: undefined,
2229
+ head: { type: "crunch", gain: 7, mid: 6, master: 6 },
2230
+ cab: { type: "4x12", mic: 0.2 },
2231
+ },
2232
+ ragged: {
2233
+ stomp: { type: "face", gain: 6, tone: 0.6 },
2234
+ head: { type: "chime", gain: 4 },
2235
+ cab: { type: "2x12" },
2236
+ },
2237
+ lead: {
2238
+ stomp: { type: "od", gain: 3, tone: 0.5, level: 3 },
2239
+ head: { type: "lead", gain: 6, mid: 6 },
2240
+ cab: { type: "4x12" },
2241
+ },
2242
+ metal: {
2243
+ stomp: { type: "od", gain: 0, tone: 0.6, level: 6 },
2244
+ head: { type: "high", gain: 7, bass: 6, mid: 3, treble: 7, gate: -55 },
2245
+ cab: { type: "4x12", mic: 0.2 },
2246
+ },
2247
+ fuzz: {
2248
+ stomp: { type: "fuzz", gain: 7, tone: 0.5 },
2249
+ head: { type: "clean", gain: 4 },
2250
+ cab: { type: "2x12" },
2251
+ },
2252
+ octave: {
2253
+ stomp: { type: "octave", gain: 6, tone: 0.6, octave: 0.8 },
2254
+ head: { type: "clean", gain: 3 },
2255
+ cab: { type: "1x12" },
2256
+ },
2257
+ funk: {
2258
+ stomp: undefined,
2259
+ head: { type: "clean", gain: 2, treble: 7, presence: 6 },
2260
+ cab: { type: "2x12" },
2261
+ autofilter: {
2262
+ type: "bpf",
2263
+ sync: 0,
2264
+ rate: 0.01,
2265
+ depth: 0,
2266
+ follow: 3,
2267
+ cutoff: 500,
2268
+ resonance: 0.6,
2269
+ },
2270
+ },
2271
+ wah: {
2272
+ stomp: undefined,
2273
+ head: { type: "crunch", gain: 4 },
2274
+ cab: { type: "2x12" },
2275
+ autofilter: {
2276
+ type: "bpf",
2277
+ sync: 0.5,
2278
+ depth: 2,
2279
+ shape: "sine",
2280
+ cutoff: 700,
2281
+ resonance: 0.6,
2282
+ },
2283
+ },
2284
+ bachata: {
2285
+ stomp: undefined,
2286
+ head: { type: "clean", gain: 2, mid: 6, treble: 7 },
2287
+ cab: { type: "1x12", mic: 0.2 },
2288
+ chorus: { rate: 0.8, depth: 0.25, mix: 0.3 },
2289
+ },
2290
+ spring: {
2291
+ stomp: undefined,
2292
+ head: { type: "clean", gain: 3, treble: 6 },
2293
+ cab: { type: "open" },
2294
+ },
2295
+ bassdrive: {
2296
+ stomp: { type: "od", gain: 4, tone: 0.5, mix: 0.6 },
2297
+ head: { type: "bass", gain: 4 },
2298
+ cab: { type: "8x10" },
2299
+ },
2300
+ reese: {
2301
+ stomp: { type: "rat", gain: 3, tone: 0.3, mix: 0.5 },
2302
+ head: { type: "bass", gain: 6, master: 6 },
2303
+ cab: { type: "1x15" },
2304
+ },
2305
+ jangle: {
2306
+ stomp: undefined,
2307
+ head: { type: "chime", gain: 3, treble: 7 },
2308
+ cab: { type: "2x12", mic: 0.2 },
2309
+ compressor: { threshold: -20, ratio: 4, attack: 0.01, release: 0.15 },
2310
+ // The `rig jangle` command adds `double` since 0.6.1; rig("jangle")
2311
+ // keeps its 0.6.0 stages so older song.ts files sound the same. Add
2312
+ // `double: { time: 12, drift: 1.5, width: 0.5 }` for the new one.
2313
+ },
2314
+ alt: {
2315
+ stomp: { type: "rat", gain: 6, tone: 0.4 },
2316
+ head: { type: "crunch", gain: 4 },
2317
+ cab: { type: "4x12" },
2318
+ },
2319
+ // SDK 1.27.0 shoegaze rigs. Their long wash is the track's `reverb`
2320
+ // (see `rigReverb` in the `rig` command), not part of `rig()`.
2321
+ shoegaze: {
2322
+ stomp: { type: "fuzz", gain: 7, tone: 0.45 },
2323
+ head: { type: "chime", gain: 4 },
2324
+ cab: { type: "2x12" },
2325
+ wobble: { depth: 25, rate: 0.4, drift: 0.4 },
2326
+ bloom: { amount: 0.4, harm: 2, delay: 0.6, time: 1 },
2327
+ double: { time: 18, drift: 2.5, width: 0.4 },
2328
+ postgain: { gain: 0.86 },
2329
+ },
2330
+ glide: {
2331
+ stomp: { type: "face", gain: 5, tone: 0.5 },
2332
+ head: { type: "clean", gain: 4, treble: 5 },
2333
+ cab: { type: "2x12" },
2334
+ wobble: { depth: 35, rate: 0.4, drift: 0.4 },
2335
+ postgain: { gain: 0.8 },
2336
+ },
2337
+ dreampop: {
2338
+ stomp: undefined,
2339
+ head: { type: "clean", gain: 2, treble: 6 },
2340
+ cab: { type: "1x12" },
2341
+ chorus: { rate: 0.6, depth: 0.3, mix: 0.35 },
2342
+ double: { time: 14, drift: 2, width: 0.4 },
2343
+ postgain: { gain: 1.45 },
2344
+ },
2345
+ swell: {
2346
+ stomp: undefined,
2347
+ head: { type: "clean", gain: 3, treble: 5 },
2348
+ cab: { type: "1x12" },
2349
+ swell: { time: 0.5 },
2350
+ postgain: { gain: 1.25 },
2351
+ },
2352
+ ebow: {
2353
+ stomp: { type: "od", gain: 3, tone: 0.5, level: 3 },
2354
+ head: { type: "lead", gain: 5, mid: 6 },
2355
+ cab: { type: "4x12" },
2356
+ swell: { time: 0.25 },
2357
+ bloom: { amount: 0.8, harm: 1, delay: 0.1, time: 0.6 },
2358
+ },
2359
+ });
2360
+
2361
+ /**
2362
+ * The long wash a rig preset sets as the track's `reverb` (SDK 1.27.0),
2363
+ * as the `rig` command stores it. `rig()` returns `fx` only, so give it
2364
+ * as `reverb: rigReverb("shoegaze")`; `instrument: "shoegaze"` adds it
2365
+ * when the track has no `reverb` of its own.
2366
+ */
2367
+ const RIG_REVERBS: Readonly<Record<string, ReverbInput & { mix: number }>> =
2368
+ Object.freeze({
2369
+ spring: { mix: 0.3, size: 0.35, fade: 1.5, predelay: 0, dim: 3500 },
2370
+ shoegaze: { mix: 0.35, size: 0.9, fade: 4, predelay: 0.02, dim: 6000 },
2371
+ glide: { mix: 0.35, size: 0.7, fade: 2.5, predelay: 0.01, dim: 6000 },
2372
+ dreampop: { mix: 0.4, size: 0.85, fade: 5, predelay: 0.03, dim: 7000 },
2373
+ swell: { mix: 0.45, size: 0.8, fade: 4, predelay: 0.02, dim: 6500 },
2374
+ });
2375
+
2376
+ /** A rig preset's track reverb (`spring`, the shoegaze rigs), if any. */
2377
+ export function rigReverb(name: string): ReverbInput | undefined {
2378
+ if (
2379
+ typeof name !== "string" ||
2380
+ !Object.prototype.hasOwnProperty.call(RIG_PRESETS, name)
2381
+ )
2382
+ throw new DawgSdkError(
2383
+ `unknown rig "${String(name)}" (rigs: ${Object.keys(RIG_PRESETS).join(", ")})`,
2384
+ );
2385
+ const values = RIG_REVERBS[name];
2386
+ return values === undefined ? undefined : Object.freeze({ ...values });
2387
+ }
2388
+
2389
+ /**
2390
+ * A guitar rig for a track's `fx` (SDK 1.22.0): the stomp → head → cab
2391
+ * stages of rig preset `name` and its companion effects (as the `rig`
2392
+ * command sets them), with optional per-stage overrides. Spread it
2393
+ * into `fx` next to other effects:
2394
+ *
2395
+ * ```ts
2396
+ * fx: { ...rig("crunch"), chorus: {} }
2397
+ * fx: { ...rig("metal", { head: { gain: 9 } }) }
2398
+ * ```
2399
+ */
2400
+ export function rig(
2401
+ name: string,
2402
+ overrides: Readonly<
2403
+ Partial<Record<"stomp" | "head" | "cab", EffectParams>>
2404
+ > = {},
2405
+ ): FxInput {
2406
+ if (
2407
+ typeof name !== "string" ||
2408
+ !Object.prototype.hasOwnProperty.call(RIG_PRESETS, name)
2409
+ )
2410
+ throw new DawgSdkError(
2411
+ `unknown rig "${String(name)}" (rigs: ${Object.keys(RIG_PRESETS).join(", ")})`,
2412
+ );
2413
+ if (!isRecord(overrides))
2414
+ throw new DawgSdkError("rig overrides must be an object");
2415
+ const out: Record<string, EffectParams> = {};
2416
+ // Companion effects first, then the stages in chain order.
2417
+ for (const [effect, values] of Object.entries(RIG_PRESETS[name]!))
2418
+ if (values && effect !== "stomp" && effect !== "head" && effect !== "cab")
2419
+ out[effect] = Object.freeze({ ...values });
2420
+ for (const stage of ["stomp", "head", "cab"] as const) {
2421
+ const base = RIG_PRESETS[name]![stage];
2422
+ const extra = overrides[stage];
2423
+ if (extra !== undefined && !isRecord(extra))
2424
+ throw new DawgSdkError(`rig ${stage} overrides must be an object`);
2425
+ if (base || extra)
2426
+ out[stage] = Object.freeze({ ...(base ?? {}), ...(extra ?? {}) });
2427
+ }
2428
+ return Object.freeze(out);
2429
+ }
2430
+
1567
2431
  /** One effect's parameters; omitted ones take dawg's defaults. */
1568
2432
  export type EffectParams = Readonly<Record<string, number | string | boolean>>;
1569
2433
 
1570
2434
  /**
1571
2435
  * Insert effects by name, rendered in the fixed chain order
1572
- * filter → djf → autofilter → vowel → crush → distort → tremolo →
1573
- * compressor → pan → phaser → chorus → leslie → postgain → delay → reverb.
1574
- * Keys here: djf, autofilter, vowel, crush, distort, tremolo, compressor,
2436
+ * filter → djf → autofilter → vowel → crush → distort → stomp → head →
2437
+ * cab → tremolo → compressor → pan → phaser → chorus → leslie → postgain →
2438
+ * delay → reverb. The guitar rig (stomp, head, cab; SDK 1.22.0) is easiest
2439
+ * as `...rig("crunch")`.
2440
+ * Keys here: djf, autofilter, vowel, crush, distort, stomp, head, cab,
2441
+ * tremolo, compressor,
1575
2442
  * phaser, chorus, leslie, postgain, plus the mix-bus keys `orbit`
1576
2443
  * (`{ orbit: 2 }`, SDK 1.9.0) and `duck` (`{ orbit: 2, depth: 0.85 }`:
1577
2444
  * this track's onsets duck every other track on that orbit). See
@@ -1585,6 +2452,106 @@ export type FxInput = Readonly<Record<string, EffectParams>>;
1585
2452
  * `"synth-<param>"` (e.g. `"synth-lpf"`), read at each note's onset.
1586
2453
  * Every parameter, range and default: **Synth** in DAWG.md.
1587
2454
  */
2455
+ /**
2456
+ * Modelled keys settings (SDK 1.24.0), for a track whose instrument is a
2457
+ * piano family (`"grand"`, `"upright"`, `"felt"`, `"honkytonk"`,
2458
+ * `"prepared"`; the words `"ballad"` and `"lofi"` pick presets) or, from
2459
+ * SDK 1.26.0, an electric family (`"epiano"`, `"wurli"`, `"clav"`; presets
2460
+ * epiano suitcase dyno wurli clav funkclav). Every field is optional; `{}`
2461
+ * is the family's own sound. Automate a parameter with
2462
+ * `automation.fx["keys-<param>"]` (hardness, touch, decay, release, knock,
2463
+ * noise, felt; electric tone, vibe, trem), read at each note's onset.
2464
+ * Ranges: **Keys** in DAWG.md.
2465
+ */
2466
+ export type KeysInput = Readonly<{
2467
+ /**
2468
+ * A named preset: grand ballad upright felt lofi honkytonk prepared, or
2469
+ * electric epiano suitcase dyno wurli clav funkclav.
2470
+ */
2471
+ preset?: string;
2472
+ /** Hammer hardness 0..1: brightness at a given velocity (0.5). */
2473
+ hardness?: number;
2474
+ /** Velocity sensitivity 0..1 (1). */
2475
+ touch?: number;
2476
+ /** Inharmonicity multiplier 0..4 (1 grand, 2.5 upright, 0 harmonic). */
2477
+ inharm?: number;
2478
+ /** Unison detune in cents 0..30 (0.7; honkytonk 16). */
2479
+ unison?: number;
2480
+ /** Sustain time multiplier 0.1..4 (1). */
2481
+ decay?: number;
2482
+ /** Damper time multiplier 0.1..4 (1). */
2483
+ release?: number;
2484
+ /** Hammer position along the string 0.04..0.3 (0.12). */
2485
+ strike?: number;
2486
+ /** Aftersound share 0..1 (0.3). */
2487
+ after?: number;
2488
+ /** Soundboard knock 0..1 (0.5). */
2489
+ knock?: number;
2490
+ /** Key and damper mechanics 0..1 (0.25). */
2491
+ noise?: number;
2492
+ /** Felt strip 0..1 (0; felt family 1). */
2493
+ felt?: number;
2494
+ /** Share of prepared keys 0..1 (0; prepared family 0.6). */
2495
+ prep?: number;
2496
+ /** Keyboard stereo width 0..1 (0.6). */
2497
+ width?: number;
2498
+ /** Octave stretch 0..1 (1); 0 keeps every key exactly on its tuning. */
2499
+ stretch?: number;
2500
+ /** Body EQ: grand upright felt honkytonk prepared (the family's own). */
2501
+ body?: string;
2502
+ /** Pitch wobble rate in Hz (tape wow), 0 off. */
2503
+ vib?: number;
2504
+ /** Pitch wobble depth in semitones (0.5). */
2505
+ vibmod?: number;
2506
+ // keys-electric (SDK 1.26.0)
2507
+ /** Sympathetic string resonance 0..1 under the sustain pedal (0; pianos). */
2508
+ sym?: number;
2509
+ /** Pickup drive 0..1: growl when played hard (0.35; epiano, wurli). */
2510
+ bark?: number;
2511
+ /** Tine or reed bell ping 0..1 (0.5; epiano, wurli). */
2512
+ bell?: number;
2513
+ /** Output low-pass in Hz, 0 off (electric keys). */
2514
+ tone?: number;
2515
+ /** Clav pickup switch: neck bridge both out ("both"). */
2516
+ pickup?: "neck" | "bridge" | "both" | "out";
2517
+ /** Clav mute slider 0..1 (0). */
2518
+ mute?: number;
2519
+ /** Suitcase stereo vibrato depth 0..1 (0; epiano). */
2520
+ vibe?: number;
2521
+ /** Suitcase vibrato rate in Hz 0.5..12 (4; epiano). */
2522
+ vibehz?: number;
2523
+ // Organs (SDK 1.28.0), for instrument `"tonewheel"`, `"combo"` or
2524
+ // `"pipe"` (presets tonewheel gospel jazzorgan combo vox pipe flutes
2525
+ // cornet reeds celeste). `"organ"` stays the legacy sine voice.
2526
+ /** Tonewheel drawbars, nine digits 0-8, 16' to 1' (`"888000000"`). */
2527
+ drawbars?: string;
2528
+ /** Tonewheel percussion: off 2nd 3rd. */
2529
+ perc?: string;
2530
+ /** Percussion decay: fast slow. */
2531
+ percdecay?: string;
2532
+ /** Key click 0..1. */
2533
+ click?: number;
2534
+ /** Scanner vibrato/chorus: off v1 v2 v3 c1 c2 c3. */
2535
+ scanner?: string;
2536
+ /** Preamp drive 0..1 (lane `keys-drive`). */
2537
+ drive?: number;
2538
+ /** Rotary speaker: slow fast stop (lane `keys-rotary`). */
2539
+ rotary?: string;
2540
+ /** Combo registers, five digits 0-8, 16' 8' 4' 2⅔' 2' (`"08800"`). */
2541
+ registers?: string;
2542
+ /**
2543
+ * Pipe stops: names or a registration (plenum flutes cornet reeds strings
2544
+ * full), as one string or a list (`["principal8", "octave4"]`).
2545
+ */
2546
+ stops?: string | readonly string[];
2547
+ /** Pipe chiff 0..1. */
2548
+ chiff?: number;
2549
+ /** Pipe wind unsteadiness 0..1. */
2550
+ wind?: number;
2551
+ /** Tremolo depth 0..1: reed piano (0; wurli), pipe tremulant (0; pipe). */
2552
+ trem?: number;
2553
+ }>;
2554
+
1588
2555
  export type SynthInput = Readonly<{
1589
2556
  attack?: number;
1590
2557
  decay?: number;
@@ -1668,9 +2635,27 @@ export type TrackInput = Readonly<{
1668
2635
  * `triangle`, and Strudel's `sawtooth`, `supersaw`, `pulse`, `user`,
1669
2636
  * `white`, `pink`, `brown`, `crackle`, and the ZzFX sounds `z_sine`,
1670
2637
  * `z_triangle`, `z_sawtooth`, `z_square`, `z_tan`, `z_noise`), `kit` for drums,
1671
- * `sampler(...)` or `wavetable(...)`. Default `sine`.
2638
+ * `sampler(...)` or `wavetable(...)`, or a mallet or bell (`vibes`,
2639
+ * `glock`, `gong`, … or `modal(...)`, SDK 1.25.0). Default `sine`.
1672
2640
  */
1673
- instrument?: string | SamplerSpec | WavetableSpec;
2641
+ instrument?:
2642
+ | string
2643
+ | SamplerSpec
2644
+ | WavetableSpec
2645
+ | StringSpec
2646
+ | GranularSpec
2647
+ | ModalSpec
2648
+ | WindSpec;
2649
+ /**
2650
+ * The sampler a `granular(...)` track keeps while it grains one of its
2651
+ * voices (SDK 1.23.0); `grain off` plays it again.
2652
+ */
2653
+ sampler?: SamplerSpec | null;
2654
+ /**
2655
+ * Granular engine (SDK 1.23.0) for an `instrument: "granular"` track, or
2656
+ * use `instrument: granular("cloud", {...})` or a word (`"cloud"`).
2657
+ */
2658
+ granular?: GranularInput | null;
1674
2659
  /**
1675
2660
  * Synthesized drum kit for an `instrument: "kit"` track: `syn808`,
1676
2661
  * `syn909`, `acoustic`, `lofi`, `electro` or `trap`. Omit for the default
@@ -1694,6 +2679,17 @@ export type TrackInput = Readonly<{
1694
2679
  tuning?: TuningInput | null;
1695
2680
  /** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
1696
2681
  synth?: SynthInput;
2682
+ /**
2683
+ * String engine (SDK 1.21.0) for an `instrument: "string"` track, or use
2684
+ * `instrument: stringed("sitar", {...})` or a preset word (`"nylon"`).
2685
+ */
2686
+ string?: StringInput | null;
2687
+ /**
2688
+ * Modelled piano settings (SDK 1.24.0) for `instrument: "grand"` and the
2689
+ * other piano families; `{}` is the family's sound. The word `"piano"`
2690
+ * keeps the classic 0.4 tone; use `"grand"` for the modelled piano.
2691
+ */
2692
+ keys?: KeysInput;
1697
2693
  muted?: boolean;
1698
2694
  /** When any track is soloed only soloed tracks play. */
1699
2695
  solo?: boolean;
@@ -1720,6 +2716,16 @@ export type TrackInput = Readonly<{
1720
2716
  glide?: number | Readonly<{ time?: number; mode?: GlideMode }>;
1721
2717
  /** Sustain pedal changes as `[beat, "down" | "half" | "up"]` (SDK 1.15.0). */
1722
2718
  pedal?: readonly (readonly [number, PedalState])[];
2719
+ /**
2720
+ * Una corda on a modelled piano (SDK 1.26.0): `[beat, "down" | "half" |
2721
+ * "up"]`; while down each note strikes fewer strings, softer and darker.
2722
+ */
2723
+ softPedal?: readonly (readonly [number, PedalState])[];
2724
+ /**
2725
+ * Sostenuto on a modelled piano (SDK 1.26.0): `[beat, "down" | "up"]`;
2726
+ * holds only the keys already down when it presses.
2727
+ */
2728
+ sostenuto?: readonly (readonly [number, "down" | "up"])[];
1723
2729
  /**
1724
2730
  * Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
1725
2731
  * (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
@@ -1738,6 +2744,12 @@ export type TrackInput = Readonly<{
1738
2744
  length?: number;
1739
2745
  seed?: number;
1740
2746
  }>;
2747
+ /**
2748
+ * How chords are fretted on this track (SDK 1.27.0): `{ tune: "dadgad",
2749
+ * capo: 2 }`. dawg's `strum` command and perform mode `guitar` use it;
2750
+ * it changes no sound by itself.
2751
+ */
2752
+ guitar?: GuitarInput;
1741
2753
  /** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
1742
2754
  notes?: readonly (NoteSpec | HitSpec)[];
1743
2755
  /**
@@ -1833,6 +2845,10 @@ export type TrackSpec = Readonly<{
1833
2845
  synth: SynthInput | null;
1834
2846
  sampler: SamplerSpec | null;
1835
2847
  wavetable: WavetableSpec | null;
2848
+ /** String engine settings (SDK 1.21.0); present only when set. */
2849
+ string?: StringInput;
2850
+ /** Granular engine settings (SDK 1.23.0); null when not granular. */
2851
+ granular?: GranularInput | null;
1836
2852
  automation: Readonly<Required<AutomationInput>>;
1837
2853
  /** Every hit resolved to its pitch slot. */
1838
2854
  notes: readonly NoteSpec[];
@@ -1844,6 +2860,9 @@ export type TrackSpec = Readonly<{
1844
2860
  /** Performance (SDK 1.15.0); present only when set. */
1845
2861
  glide?: Readonly<{ time: number; mode: GlideMode }>;
1846
2862
  pedal?: readonly (readonly [number, PedalState])[];
2863
+ /** Soft and sostenuto pedals (SDK 1.26.0); present only when set. */
2864
+ softPedal?: readonly (readonly [number, PedalState])[];
2865
+ sostenuto?: readonly (readonly [number, PedalState])[];
1847
2866
  velocityCurve?: Readonly<{
1848
2867
  curve: Exclude<VelocityCurveName, "linear">;
1849
2868
  fixed?: number;
@@ -1855,6 +2874,14 @@ export type TrackSpec = Readonly<{
1855
2874
  seed: number;
1856
2875
  }>;
1857
2876
  tuning: ScoreTuning | null;
2877
+ /** Modelled piano settings (SDK 1.24.0); present only when set. */
2878
+ keys?: KeysInput;
2879
+ /** Modal settings (SDK 1.25.0); present only on a modal track. */
2880
+ modal?: Readonly<{ preset?: ModalPresetName } & ModalParams>;
2881
+ /** Guitar fretting (SDK 1.27.0); present only when set. */
2882
+ guitar?: GuitarSetup;
2883
+ /** Wind settings (SDK 1.30.0); present only on a wind-engine track. */
2884
+ wind?: Readonly<{ preset?: WindPresetName } & WindParams>;
1858
2885
  }>;
1859
2886
 
1860
2887
  export type GlideMode = "legato" | "mono" | "poly";
@@ -1868,10 +2895,17 @@ const DEFAULT_FIXED_VELOCITY = 0.8;
1868
2895
  function trackPerformance(
1869
2896
  input: TrackInput,
1870
2897
  name: string,
1871
- ): Partial<Pick<TrackSpec, "glide" | "pedal" | "velocityCurve" | "humanize">> {
2898
+ ): Partial<
2899
+ Pick<
2900
+ TrackSpec,
2901
+ "glide" | "pedal" | "softPedal" | "sostenuto" | "velocityCurve" | "humanize"
2902
+ >
2903
+ > {
1872
2904
  const out: {
1873
2905
  glide?: TrackSpec["glide"];
1874
2906
  pedal?: TrackSpec["pedal"];
2907
+ softPedal?: TrackSpec["softPedal"];
2908
+ sostenuto?: TrackSpec["sostenuto"];
1875
2909
  velocityCurve?: TrackSpec["velocityCurve"];
1876
2910
  humanize?: TrackSpec["humanize"];
1877
2911
  } = {};
@@ -1892,29 +2926,38 @@ function trackPerformance(
1892
2926
  mode,
1893
2927
  });
1894
2928
  }
1895
- if (input.pedal !== undefined) {
1896
- if (!Array.isArray(input.pedal) || input.pedal.length > 1024)
2929
+ const pedalLane = (
2930
+ key: "pedal" | "softPedal" | "sostenuto",
2931
+ states: readonly PedalState[],
2932
+ ) => {
2933
+ const value: unknown = input[key];
2934
+ if (value === undefined) return;
2935
+ const shape = `[beat, ${states.map((state) => `"${state}"`).join(" | ")}]`;
2936
+ if (!Array.isArray(value) || value.length > 1024)
1897
2937
  throw new DawgSdkError(
1898
- `track ${name}: pedal must be at most 1024 [beat, "down" | "half" | "up"] events`,
2938
+ `track ${name}: ${key} must be at most 1024 ${shape} events`,
1899
2939
  );
1900
- if (input.pedal.length > 0)
1901
- out.pedal = Object.freeze(
1902
- input.pedal.map((event: unknown, index: number) => {
2940
+ if (value.length > 0)
2941
+ out[key] = Object.freeze(
2942
+ value.map((event: unknown, index: number) => {
1903
2943
  if (
1904
2944
  !Array.isArray(event) ||
1905
2945
  event.length !== 2 ||
1906
- !["down", "half", "up"].includes(event[1] as string)
2946
+ !states.includes(event[1] as PedalState)
1907
2947
  )
1908
2948
  throw new DawgSdkError(
1909
- `track ${name}: pedal[${index}] must be [beat, "down" | "half" | "up"]`,
2949
+ `track ${name}: ${key}[${index}] must be ${shape}`,
1910
2950
  );
1911
2951
  return Object.freeze([
1912
- beat(event[0], `${name} pedal[${index}] beat`),
2952
+ beat(event[0], `${name} ${key}[${index}] beat`),
1913
2953
  event[1] as PedalState,
1914
2954
  ] as const);
1915
2955
  }),
1916
2956
  );
1917
- }
2957
+ };
2958
+ pedalLane("pedal", ["down", "half", "up"]);
2959
+ pedalLane("softPedal", ["down", "half", "up"]);
2960
+ pedalLane("sostenuto", ["down", "up"]);
1918
2961
  if (input.velocityCurve !== undefined) {
1919
2962
  const raw =
1920
2963
  typeof input.velocityCurve === "string"
@@ -2056,34 +3099,81 @@ export function track(input: TrackInput): TrackSpec {
2056
3099
  if (typeof id !== "string" || id.length === 0 || id.length > 64)
2057
3100
  throw new DawgSdkError(`track ${name}: id must be 1..64 characters`);
2058
3101
  const rawInstrument = input.instrument ?? "sine";
3102
+ // A granular track may keep the sampler it grains (`grain off` goes back).
3103
+ const keptSampler =
3104
+ isRecord(input.sampler) &&
3105
+ input.sampler.kind === "sampler" &&
3106
+ isRecord(rawInstrument) &&
3107
+ rawInstrument.kind === "granular"
3108
+ ? localizeSampler(input.sampler as SamplerSpec, slug)
3109
+ : null;
3110
+ if (input.sampler !== undefined && input.sampler !== null && !keptSampler)
3111
+ throw new DawgSdkError(
3112
+ `track ${name}: sampler: is only for a granular(...) track; use instrument: sampler({...})`,
3113
+ );
2059
3114
  const samplerSpec =
2060
3115
  isRecord(rawInstrument) && rawInstrument.kind === "sampler"
2061
3116
  ? localizeSampler(rawInstrument as SamplerSpec, slug)
2062
- : null;
3117
+ : keptSampler;
2063
3118
  const wavetableSpec =
2064
3119
  isRecord(rawInstrument) && rawInstrument.kind === "wavetable"
2065
3120
  ? localizeWavetable(rawInstrument as WavetableSpec, slug)
2066
3121
  : null;
2067
- const instrument = samplerSpec
2068
- ? SAMPLER_INSTRUMENT
2069
- : wavetableSpec
2070
- ? WAVETABLE_INSTRUMENT
2071
- : typeof rawInstrument === "string"
2072
- ? rawInstrument
2073
- : undefined;
3122
+ const stringFromInstrument =
3123
+ isRecord(rawInstrument) && rawInstrument.kind === "string"
3124
+ ? stringInput(rawInstrument, name)
3125
+ : null;
3126
+ const granularFromInstrument =
3127
+ isRecord(rawInstrument) && rawInstrument.kind === "granular"
3128
+ ? granularInput(rawInstrument, name, slug)
3129
+ : null;
3130
+ const word =
3131
+ typeof rawInstrument === "string"
3132
+ ? resolveInstrumentWord(rawInstrument)
3133
+ : undefined;
3134
+ const modalSpec = trackModal(rawInstrument);
3135
+ const guitarSpec = guitarInput(input.guitar, `track ${name}`);
3136
+ const windSpec = trackWind(rawInstrument);
3137
+ const instrument = granularFromInstrument
3138
+ ? GRANULAR_INSTRUMENT
3139
+ : samplerSpec
3140
+ ? SAMPLER_INSTRUMENT
3141
+ : wavetableSpec
3142
+ ? WAVETABLE_INSTRUMENT
3143
+ : stringFromInstrument
3144
+ ? STRING_INSTRUMENT
3145
+ : modalSpec
3146
+ ? MODAL_INSTRUMENT
3147
+ : windSpec
3148
+ ? WIND_INSTRUMENT
3149
+ : typeof rawInstrument === "string"
3150
+ ? (word?.instrument ?? rawInstrument)
3151
+ : undefined;
3152
+ // A granular word (`"cloud"`) turns the engine on with its preset.
3153
+ const granularSpec =
3154
+ granularInput(input.granular, name, slug) ??
3155
+ granularFromInstrument ??
3156
+ (word?.field === "granular" && word.preset
3157
+ ? Object.freeze({ preset: word.preset })
3158
+ : instrument === GRANULAR_INSTRUMENT
3159
+ ? Object.freeze({})
3160
+ : null);
2074
3161
  if (
2075
3162
  instrument === undefined ||
2076
3163
  instrument.length === 0 ||
2077
3164
  instrument.length > 64
2078
3165
  )
2079
3166
  throw new DawgSdkError(
2080
- `track ${name}: instrument must be a voice name, "kit", sampler(...) or wavetable(...)`,
3167
+ `track ${name}: instrument must be a voice name, "kit", sampler(...), wavetable(...), stringed(...), granular(...) or modal(...)`,
2081
3168
  );
2082
3169
  if (instrument === SAMPLER_INSTRUMENT && !samplerSpec)
2083
3170
  throw new DawgSdkError(
2084
3171
  `track ${name}: use instrument: sampler({...}) for a sampler track`,
2085
3172
  );
2086
- const slots = samplerSpec ? voiceSlots(samplerSpec) : undefined;
3173
+ const slots =
3174
+ samplerSpec && instrument === SAMPLER_INSTRUMENT
3175
+ ? voiceSlots(samplerSpec)
3176
+ : undefined;
2087
3177
  const kit = KIT_INSTRUMENTS.includes(instrument.trim().toLowerCase());
2088
3178
  const notes = (input.notes ?? []).map((item, index) => {
2089
3179
  if (!isRecord(item) || (item.kind !== "note" && item.kind !== "hit"))
@@ -2158,6 +3248,23 @@ export function track(input: TrackInput): TrackSpec {
2158
3248
  throw new DawgSdkError(
2159
3249
  `track ${name}: unknown automation lane "${key}" (${AUTOMATION_KEYS.join(" ")})`,
2160
3250
  );
3251
+ // A keys preset word (`"lofi"`, `"ballad"`) brings its preset's effects,
3252
+ // as the prompt does; explicit filter, fx and reverb win.
3253
+ const presetFx = keysPresetFx(input.keys, rawInstrument);
3254
+ if (presetFx) {
3255
+ input = {
3256
+ ...input,
3257
+ ...(input.filter === undefined && presetFx.filter
3258
+ ? { filter: presetFx.filter }
3259
+ : {}),
3260
+ ...(input.reverb === undefined && presetFx.reverb
3261
+ ? { reverb: presetFx.reverb }
3262
+ : {}),
3263
+ ...(presetFx.fx && input.fx !== null
3264
+ ? { fx: { ...presetFx.fx, ...(isRecord(input.fx) ? input.fx : {}) } }
3265
+ : {}),
3266
+ };
3267
+ }
2161
3268
  const filter =
2162
3269
  input.filter === undefined || input.filter === null
2163
3270
  ? null
@@ -2185,6 +3292,13 @@ export function track(input: TrackInput): TrackSpec {
2185
3292
  `${name} delay`,
2186
3293
  ),
2187
3294
  });
3295
+ // A rig alias with a wash (`instrument: "shoegaze"`) sets the track
3296
+ // reverb the `track shoegaze` prompt sets, unless `reverb` is given.
3297
+ if (input.reverb === undefined && typeof rawInstrument === "string") {
3298
+ const rigName = resolveInstrumentWord(rawInstrument)?.fx;
3299
+ const wash = rigName === undefined ? undefined : RIG_REVERBS[rigName];
3300
+ if (wash) input = { ...input, reverb: wash };
3301
+ }
2188
3302
  const reverb =
2189
3303
  input.reverb === undefined || input.reverb === null
2190
3304
  ? null
@@ -2200,8 +3314,26 @@ export function track(input: TrackInput): TrackSpec {
2200
3314
  ? {}
2201
3315
  : { ir: reverbIr(input.reverb.ir, name, slug) }),
2202
3316
  });
2203
- const fx = fxInput(input.fx, name);
3317
+ // A guitar alias (`instrument: "jangle"`) also loads its rig; stages and
3318
+ // effects the track's own `fx` names win.
3319
+ const aliasRig =
3320
+ typeof rawInstrument === "string"
3321
+ ? resolveInstrumentWord(rawInstrument)?.fx
3322
+ : undefined;
3323
+ const fx = fxInput(
3324
+ aliasRig === undefined
3325
+ ? input.fx
3326
+ : { ...rig(aliasRig), ...(isRecord(input.fx) ? input.fx : {}) },
3327
+ name,
3328
+ );
2204
3329
  const synth = synthInput(input.synth, name);
3330
+ // A string preset word (`"nylon"`) turns the engine on with its preset.
3331
+ const string =
3332
+ stringInput(input.string, name) ??
3333
+ stringFromInstrument ??
3334
+ (word?.field === "string" && word.preset
3335
+ ? Object.freeze({ preset: word.preset })
3336
+ : null);
2205
3337
  return Object.freeze({
2206
3338
  kind: "track",
2207
3339
  id,
@@ -2219,6 +3351,8 @@ export function track(input: TrackInput): TrackSpec {
2219
3351
  synth,
2220
3352
  sampler: samplerSpec,
2221
3353
  wavetable: wavetableSpec,
3354
+ ...(string ? { string } : {}),
3355
+ ...(granularSpec ? { granular: granularSpec } : {}),
2222
3356
  automation: Object.freeze({
2223
3357
  volume: lane("volume"),
2224
3358
  pan: lane("pan"),
@@ -2235,9 +3369,115 @@ export function track(input: TrackInput): TrackSpec {
2235
3369
  ...trackTime(input.time, name),
2236
3370
  ...trackPerformance(input, name),
2237
3371
  tuning: tuningSpec(input.tuning, `track ${name}`),
3372
+ ...keysSpec(input.keys, rawInstrument, name),
3373
+ ...(modalSpec ? { modal: modalSpec } : {}),
3374
+ ...(guitarSpec ? { guitar: guitarSpec } : {}),
3375
+ ...(windSpec ? { wind: windSpec } : {}),
2238
3376
  });
2239
3377
  }
2240
3378
 
3379
+ /**
3380
+ * Effects the keys presets set with the voice: a copy of `KEYS_PRESETS`
3381
+ * filter, fx and reverb in core/keys.ts (core/keys.test.ts checks they
3382
+ * match the prompt's `piano <preset>`).
3383
+ */
3384
+ const KEYS_PRESET_FX: Readonly<
3385
+ Record<
3386
+ string,
3387
+ Readonly<{
3388
+ filter?: FilterInput;
3389
+ fx?: FxInput;
3390
+ reverb?: ReverbInput;
3391
+ }>
3392
+ >
3393
+ > = Object.freeze({
3394
+ ballad: { reverb: { mix: 0.25, size: 0.7 } },
3395
+ felt: { reverb: { mix: 0.2, size: 0.5 } },
3396
+ lofi: {
3397
+ filter: { cutoff: 3500, resonance: 0.1 },
3398
+ fx: { crush: { bits: 10 } },
3399
+ },
3400
+ // f061-organ: the pipe presets sound in a church.
3401
+ pipe: { reverb: { mix: 0.35, size: 0.9 } },
3402
+ flutes: { reverb: { mix: 0.3, size: 0.8 } },
3403
+ cornet: { reverb: { mix: 0.3, size: 0.8 } },
3404
+ reeds: { reverb: { mix: 0.3, size: 0.85 } },
3405
+ celeste: { reverb: { mix: 0.35, size: 0.9 } },
3406
+ });
3407
+
3408
+ /**
3409
+ * The preset effects an instrument word brings: a preset word that is not
3410
+ * also its family (`"lofi"`, `"ballad"`), or any preset word without
3411
+ * `keys` (`"felt"`). A printed track names its family and always prints
3412
+ * `keys`, so print → eval never adds them twice.
3413
+ */
3414
+ function keysPresetFx(
3415
+ keys: unknown,
3416
+ word: unknown,
3417
+ ): (typeof KEYS_PRESET_FX)[string] | undefined {
3418
+ if (typeof word !== "string") return undefined;
3419
+ const meaning = resolveInstrumentWord(word);
3420
+ if (meaning?.field !== "keys" || !meaning.preset) return undefined;
3421
+ if (keys !== undefined && keys !== null && word === meaning.instrument)
3422
+ return undefined;
3423
+ return KEYS_PRESET_FX[meaning.preset];
3424
+ }
3425
+
3426
+ /**
3427
+ * `keys` for `track()`: the input as given, or `{ preset }` when the
3428
+ * instrument word names a keys preset (`"grand"`, `"lofi"`), so the word
3429
+ * alone plays the modelled piano. dawg validates the values.
3430
+ */
3431
+ function keysSpec(
3432
+ input: unknown,
3433
+ word: unknown,
3434
+ name: string,
3435
+ ): { keys?: KeysInput } {
3436
+ const meaning =
3437
+ typeof word === "string" ? resolveInstrumentWord(word) : undefined;
3438
+ const preset = meaning?.field === "keys" ? meaning.preset : undefined;
3439
+ if (input === undefined || input === null)
3440
+ return preset ? { keys: Object.freeze({ preset }) } : {};
3441
+ if (!isRecord(input))
3442
+ throw new DawgSdkError(`track ${name}: keys must be an object`);
3443
+ const out: Record<string, EffectValue> = {};
3444
+ // A preset word (`"lofi"`) keeps its preset under given overrides; a
3445
+ // family word (`"upright"`) with `keys` is exactly the given keys.
3446
+ if (preset && input.preset === undefined && word !== meaning?.instrument)
3447
+ out.preset = preset;
3448
+ for (const [key, value] of Object.entries(input)) {
3449
+ if (value === undefined) continue;
3450
+ // Pipe stops may be a list: stored as one space-separated string.
3451
+ if (key === "stops" && Array.isArray(value)) {
3452
+ if (!value.every((stop) => typeof stop === "string"))
3453
+ throw new DawgSdkError(`track ${name}: keys.stops must be stop names`);
3454
+ out[key] = value.join(" ");
3455
+ continue;
3456
+ }
3457
+ out[key] = effectValue(value, `${name} keys.${key}`);
3458
+ }
3459
+ return { keys: Object.freeze(out) };
3460
+ }
3461
+
3462
+ /**
3463
+ * The modal field an instrument makes: `modal(...)`, or a modal preset
3464
+ * word (`"vibes"`, `"glockenspiel"`). The bare words `"modal"` and
3465
+ * `"marimba"` keep their pre-0.6 meaning and make none.
3466
+ */
3467
+ function trackModal(
3468
+ raw: unknown,
3469
+ ): Readonly<{ preset?: ModalPresetName } & ModalParams> | undefined {
3470
+ if (isRecord(raw) && raw.kind === "modal") {
3471
+ const { kind: _kind, ...fields } = raw as ModalSpec;
3472
+ return Object.freeze(fields);
3473
+ }
3474
+ if (typeof raw !== "string" || raw === MODAL_INSTRUMENT) return undefined;
3475
+ const meaning = resolveInstrumentWord(raw);
3476
+ if (meaning?.instrument !== MODAL_INSTRUMENT || !meaning.preset)
3477
+ return undefined;
3478
+ return Object.freeze({ preset: meaning.preset as ModalPresetName });
3479
+ }
3480
+
2241
3481
  function trackTime(input: unknown, name: string): { time?: TrackTimeInput } {
2242
3482
  if (input === undefined || input === null) return {};
2243
3483
  if (!isRecord(input))
@@ -2348,6 +3588,21 @@ function fxInput(input: unknown, name: string): FxInput | null {
2348
3588
  return Object.keys(out).length > 0 ? Object.freeze(out) : null;
2349
3589
  }
2350
3590
 
3591
+ function stringInput(input: unknown, name: string): StringInput | null {
3592
+ if (input === undefined || input === null) return null;
3593
+ if (!isRecord(input))
3594
+ throw new DawgSdkError(`track ${name}: string must be an object`);
3595
+ const out: Record<string, number | string> = {};
3596
+ for (const [key, value] of Object.entries(input)) {
3597
+ if (value === undefined || key === "kind") continue;
3598
+ out[key] =
3599
+ typeof value === "string"
3600
+ ? value
3601
+ : finite(value, `${name} string.${key}`);
3602
+ }
3603
+ return Object.freeze(out);
3604
+ }
3605
+
2351
3606
  function synthInput(input: unknown, name: string): SynthInput | null {
2352
3607
  if (input === undefined || input === null) return null;
2353
3608
  if (!isRecord(input))
@@ -2433,7 +3688,7 @@ function reverbIr(
2433
3688
  throw new DawgSdkError(`track ${name}: reverb.ir needs a src`);
2434
3689
  const src = spec.src.trim().replace(/^\.\//, "");
2435
3690
  if (src.startsWith("pack:")) {
2436
- const ref = sample(spec as SampleSpec, `${name} reverb.ir`);
3691
+ const ref = sampleSpec(spec as SampleSpec, `${name} reverb.ir`);
2437
3692
  return Object.freeze({
2438
3693
  src: ref.src,
2439
3694
  ...(ref.sha256 ? { sha256: ref.sha256 } : {}),
@@ -2445,6 +3700,41 @@ function reverbIr(
2445
3700
  return src.startsWith("tracks/") ? src : `tracks/${slug}/${src}`;
2446
3701
  }
2447
3702
 
3703
+ /** Validates `granular` input and localizes a sample source like a voice. */
3704
+ function granularInput(
3705
+ input: unknown,
3706
+ name: string,
3707
+ slug: string,
3708
+ ): GranularInput | null {
3709
+ if (input === undefined || input === null) return null;
3710
+ if (!isRecord(input))
3711
+ throw new DawgSdkError(`track ${name}: granular must be an object`);
3712
+ const out: Record<string, unknown> = {};
3713
+ for (const [key, value] of Object.entries(input)) {
3714
+ if (value === undefined || key === "kind") continue;
3715
+ if (key === "src" && isRecord(value)) {
3716
+ const ref = sampleSpec(value as SampleSpec, `${name} granular src`);
3717
+ const src = ref.src.replace(/^\.\//, "");
3718
+ out.src = Object.freeze({
3719
+ ...ref,
3720
+ src:
3721
+ src.startsWith("tracks/") || src.startsWith("pack:")
3722
+ ? src
3723
+ : `tracks/${slug}/${src}`,
3724
+ });
3725
+ } else if (key === "src" && typeof value === "string")
3726
+ out.src = value.startsWith("synth:")
3727
+ ? value
3728
+ : granularInput({ src: { src: value } }, name, slug)!.src;
3729
+ else if (typeof value === "number")
3730
+ out[key] = finite(value, `${name} granular.${key}`);
3731
+ else if (typeof value === "string" || typeof value === "boolean")
3732
+ out[key] = value;
3733
+ else throw new DawgSdkError(`${name} granular.${key} must be a value`);
3734
+ }
3735
+ return Object.freeze(out) as GranularInput;
3736
+ }
3737
+
2448
3738
  /** `./wavetables/x.wav` → `tracks/<slug>/wavetables/x.wav`, like sampler files. */
2449
3739
  function localizeWavetable(spec: WavetableSpec, slug: string): WavetableSpec {
2450
3740
  const src = spec.table.src;
@@ -2617,6 +3907,14 @@ export type ScoreSampleRef = Readonly<{
2617
3907
  fit?: boolean;
2618
3908
  accelerate?: number;
2619
3909
  squiz?: number;
3910
+ bpm?: number;
3911
+ fitmode?: "repitch" | "beats" | "tones";
3912
+ len?: number;
3913
+ shift?: number;
3914
+ formant?: number;
3915
+ fadeTime?: number;
3916
+ fadeInTime?: number;
3917
+ from?: SampleProvenanceSpec;
2620
3918
  }>;
2621
3919
 
2622
3920
  /** A stored track; optional fields are present only when set. */
@@ -2640,6 +3938,7 @@ export type ScoreTrack = Readonly<{
2640
3938
  fx?: FxInput;
2641
3939
  fxAutomation?: Readonly<Record<string, readonly ScorePoint[]>>;
2642
3940
  synth?: SynthInput;
3941
+ keys?: KeysInput;
2643
3942
  sampler?: Readonly<{
2644
3943
  voices: Readonly<Record<string, ScoreSampleRef>>;
2645
3944
  mode: "oneshot" | "keyed";
@@ -2659,8 +3958,20 @@ export type ScoreTrack = Readonly<{
2659
3958
  tuning?: ScoreTuning;
2660
3959
  wavetable?: Readonly<{ table: ScoreSampleRef } & WavetableParams>;
2661
3960
  wtAutomation?: readonly ScorePoint[];
3961
+ /** String engine settings; dawg validates them (SDK 1.21.0). */
3962
+ string?: StringInput;
3963
+ /** Granular settings (SDK 1.23.0); present only when set. */
3964
+ granular?: GranularInput;
3965
+ /** Modal settings (SDK 1.25.0). */
3966
+ modal?: TrackSpec["modal"];
3967
+ /** Guitar fretting (SDK 1.27.0). */
3968
+ guitar?: GuitarSetup;
3969
+ /** Wind settings (SDK 1.30.0). */
3970
+ wind?: TrackSpec["wind"];
2662
3971
  glide?: TrackSpec["glide"];
2663
3972
  pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
3973
+ softPedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
3974
+ sostenuto?: readonly Readonly<{ tick: number; state: PedalState }>[];
2664
3975
  velocityCurve?: TrackSpec["velocityCurve"];
2665
3976
  humanize?: TrackSpec["humanize"];
2666
3977
  }>;
@@ -2926,6 +4237,7 @@ export function song(input: SongInput): Song {
2926
4237
  stored.wavetable = Object.freeze(fields);
2927
4238
  }
2928
4239
  if (wtAutomation.length > 0) stored.wtAutomation = wtAutomation;
4240
+ if (t.granular) stored.granular = t.granular;
2929
4241
  if (t.sampler)
2930
4242
  stored.sampler = Object.freeze({
2931
4243
  voices: t.sampler.voices,
@@ -2949,11 +4261,13 @@ export function song(input: SongInput): Song {
2949
4261
  if (Object.keys(time).length > 0) stored.time = Object.freeze(time);
2950
4262
  }
2951
4263
  if (t.glide) stored.glide = t.glide;
2952
- if (t.pedal && t.pedal.length > 0) {
4264
+ for (const key of ["pedal", "softPedal", "sostenuto"] as const) {
4265
+ const lane = t[key];
4266
+ if (!lane || lane.length === 0) continue;
2953
4267
  // One event per tick (the last wins), in tick order, as dawg stores it.
2954
4268
  const byTick = new Map<number, PedalState>();
2955
- for (const [at, state] of t.pedal) byTick.set(ticks(at), state);
2956
- stored.pedal = Object.freeze(
4269
+ for (const [at, state] of lane) byTick.set(ticks(at), state);
4270
+ stored[key] = Object.freeze(
2957
4271
  [...byTick.entries()]
2958
4272
  .sort((a, b) => a[0] - b[0])
2959
4273
  .map(([tick, state]) => Object.freeze({ tick, state })),
@@ -2962,6 +4276,11 @@ export function song(input: SongInput): Song {
2962
4276
  if (t.velocityCurve) stored.velocityCurve = t.velocityCurve;
2963
4277
  if (t.humanize) stored.humanize = t.humanize;
2964
4278
  if (t.tuning) stored.tuning = t.tuning;
4279
+ if (t.string) stored.string = t.string;
4280
+ if (t.keys) stored.keys = t.keys;
4281
+ if (t.modal) stored.modal = t.modal;
4282
+ if (t.guitar) stored.guitar = t.guitar;
4283
+ if (t.wind) stored.wind = t.wind;
2965
4284
  if (t.rhythm && t.rhythm.length > 0)
2966
4285
  stored.rhythm = Object.freeze(
2967
4286
  t.rhythm.map((row) => {
@@ -3006,6 +4325,25 @@ export function song(input: SongInput): Song {
3006
4325
  );
3007
4326
  }
3008
4327
  });
4328
+ // An ombak pair (SDK 1.30.0): a partner (pengumbang) whose preset carries
4329
+ // its own ombak twin bank plays one straight voice instead, as `modal
4330
+ // pair` and set_modal leave it, unless it sets ombak itself.
4331
+ for (const track of [...tracks]) {
4332
+ const pair = (track as { modal?: { pair?: string } }).modal?.pair;
4333
+ if (pair === undefined) continue;
4334
+ const at = tracks.findIndex((candidate) => candidate.id === pair);
4335
+ const partner = tracks[at] as
4336
+ (ScoreTrack & { modal?: Readonly<Record<string, unknown>> }) | undefined;
4337
+ if (
4338
+ partner?.modal &&
4339
+ partner.modal.ombak === undefined &&
4340
+ MODAL_TWIN_PRESETS.includes(String(partner.modal.preset))
4341
+ )
4342
+ tracks[at] = Object.freeze({
4343
+ ...partner,
4344
+ modal: Object.freeze({ ...partner.modal, ombak: 0 }),
4345
+ }) as ScoreTrack;
4346
+ }
3009
4347
  const arrangement: {
3010
4348
  sections?: readonly SongSection[];
3011
4349
  form?: readonly SongFormEntry[];
@@ -3670,7 +5008,22 @@ export type ChordOptions = Readonly<{
3670
5008
  | "arp-random"
3671
5009
  | "harp"
3672
5010
  | "slop"
3673
- | "pattern";
5011
+ | "pattern"
5012
+ | "guitar";
5013
+ /**
5014
+ * With `perform: "guitar"` (SDK 1.27.0): the stroke grid, a name (`down`
5015
+ * `folk` `pop` `punk` `funk` `reggae` `waltz` `jangle` `island`) or
5016
+ * characters D (down) U (up) d u (light) x (muted chuck) - . (rest).
5017
+ */
5018
+ strokes?: string;
5019
+ /** Guitar: grid step in beats, default 0.5 (8ths). */
5020
+ step?: number;
5021
+ /** Guitar: milliseconds a full six-string down stroke takes, default 22. */
5022
+ speed?: number;
5023
+ /** Guitar: the song tempo `speed` converts with, default 120. */
5024
+ tempo?: number;
5025
+ /** Guitar: tuning, capo, hand, ring and position (as a track's `guitar`). */
5026
+ guitar?: GuitarInput;
3674
5027
  /**
3675
5028
  * With `perform: "pattern"`: a rhythm pattern by name or 1-based number
3676
5029
  * (SDK 1.7.0): `eighths`, `sixteenths`, `offbeat`, `pop`, `charleston`,
@@ -3821,6 +5174,7 @@ export function progression(
3821
5174
  seed: finite(options.seed ?? 0, "chord seed"),
3822
5175
  ...(options.pattern !== undefined ? { pattern: options.pattern } : {}),
3823
5176
  velocity: vel,
5177
+ ...(mode === "guitar" ? guitarPerform(options) : {}),
3824
5178
  },
3825
5179
  });
3826
5180
  const out = [
@@ -3834,6 +5188,543 @@ export function progression(
3834
5188
  );
3835
5189
  }
3836
5190
 
5191
+ /** A track's guitar setup (SDK 1.27.0): see `TrackInput.guitar`. */
5192
+ export type GuitarInput = Readonly<{
5193
+ /** `standard` `dropd` `doubledropd` `dadgad` `openg` `opend` `opene` `halfdown` `nashville` `bass` `ukulele` `requinto`, or open-string pitches low to high. */
5194
+ tune?: string | readonly Pitch[];
5195
+ /** Capo fret 0..12. */
5196
+ capo?: number;
5197
+ /** Hand stretch in frets 3..6, default 4. */
5198
+ hand?: number;
5199
+ /** 0 closed shapes .. 1 ringing open strings, default 0.5. */
5200
+ ring?: number;
5201
+ /** Preferred fret position 0..12. */
5202
+ position?: number;
5203
+ }>;
5204
+
5205
+ function guitarInput(
5206
+ input: GuitarInput | undefined,
5207
+ where: string,
5208
+ ): GuitarSetup | undefined {
5209
+ if (input === undefined) return undefined;
5210
+ if (!isRecord(input))
5211
+ throw new DawgSdkError(`${where} guitar must be an object`);
5212
+ const out: Record<string, unknown> = {};
5213
+ for (const key of Object.keys(input))
5214
+ if (!["tune", "capo", "hand", "ring", "position"].includes(key))
5215
+ throw new DawgSdkError(
5216
+ `${where} guitar has no field "${key.slice(0, 32)}" (tune capo hand ring position)`,
5217
+ );
5218
+ if (input.tune !== undefined) {
5219
+ if (typeof input.tune === "string") {
5220
+ const name = input.tune.toLowerCase().replace(/[\s_-]/g, "");
5221
+ if (!(GUITAR_TUNING_NAMES as readonly string[]).includes(name))
5222
+ throw new DawgSdkError(
5223
+ `${where} guitar tune must be one of ${GUITAR_TUNING_NAMES.join(" ")} or a list of pitches`,
5224
+ );
5225
+ out.tune = name;
5226
+ } else if (
5227
+ Array.isArray(input.tune) &&
5228
+ input.tune.length >= 3 &&
5229
+ input.tune.length <= 12
5230
+ )
5231
+ out.tune = Object.freeze(input.tune.map((p) => midi(p)));
5232
+ else
5233
+ throw new DawgSdkError(
5234
+ `${where} guitar tune must be a name or 3..12 pitches`,
5235
+ );
5236
+ }
5237
+ const int = (key: "capo" | "hand" | "position", min: number, max: number) => {
5238
+ const value = input[key];
5239
+ if (value === undefined) return;
5240
+ if (!Number.isInteger(value) || value < min || value > max)
5241
+ throw new DawgSdkError(
5242
+ `${where} guitar ${key} must be an integer ${min}..${max}`,
5243
+ );
5244
+ out[key] = value;
5245
+ };
5246
+ int("capo", 0, 12);
5247
+ int("hand", 3, 6);
5248
+ if (input.ring !== undefined)
5249
+ out.ring =
5250
+ Math.round(unit(input.ring, `${where} guitar ring`) * 1000) / 1000;
5251
+ int("position", 0, 12);
5252
+ return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
5253
+ }
5254
+
5255
+ function guitarPerform(options: ChordOptions): Partial<PerformOptions> {
5256
+ if (options.strokes !== undefined && !strokeGrid(options.strokes))
5257
+ throw new DawgSdkError(
5258
+ `chord strokes must be one of ${STROKE_PATTERN_NAMES.join(" ")} or a grid of D U d u x - .`,
5259
+ );
5260
+ const setup = guitarInput(options.guitar, "chord");
5261
+ return {
5262
+ ...(options.strokes !== undefined ? { strokes: options.strokes } : {}),
5263
+ ...(options.step !== undefined
5264
+ ? { step: positive(options.step, "chord step") }
5265
+ : {}),
5266
+ ...(options.speed !== undefined
5267
+ ? {
5268
+ speed:
5269
+ Math.min(200, Math.max(0, finite(options.speed, "chord speed"))) /
5270
+ 1000,
5271
+ }
5272
+ : {}),
5273
+ ...(options.tempo !== undefined
5274
+ ? { tempo: positive(options.tempo, "chord tempo") }
5275
+ : {}),
5276
+ ...(setup ? { guitar: setup } : {}),
5277
+ };
5278
+ }
5279
+
5280
+ /**
5281
+ * Strummed guitar chords (SDK 1.27.0): `progression()` with
5282
+ * `perform: "guitar"`. Each chord is fretted on the guitar (`tune`, `capo`,
5283
+ * no barre over an open string) and strummed with a stroke grid; a down
5284
+ * stroke sweeps the strings in `speed` ms. Pair with `guitar` on the track
5285
+ * so dawg's prompt strums the same shapes.
5286
+ *
5287
+ * ```ts
5288
+ * notes: strum("G D Em C", { strokes: "folk", speed: 25, tempo: 96 })
5289
+ * notes: strum("i VI III VII", { key: "e minor", strokes: "D-DU-UDU", guitar: { capo: 2 } })
5290
+ * ```
5291
+ */
5292
+ export function strum(
5293
+ chords: string | readonly string[],
5294
+ options: Omit<ProgressionOptions, "perform"> = {},
5295
+ ): readonly NoteSpec[] {
5296
+ return progression(chords, { ...options, perform: "guitar" });
5297
+ }
5298
+
5299
+ // BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts
5300
+ /** What an instrument word stores on a track. */
5301
+ type InstrumentWord = Readonly<{
5302
+ /** The `Track.instrument` value. */
5303
+ instrument: string;
5304
+ /** The optional Track field the engine reads (created with defaults). */
5305
+ field?: string;
5306
+ /** A preset of that engine to apply. */
5307
+ preset?: string;
5308
+ /** An insert-effect preset to apply with it (rig aliases). */
5309
+ fx?: string;
5310
+ }>;
5311
+
5312
+ /** A word and what it means. */
5313
+ type InstrumentWordRow = Readonly<{
5314
+ word: string;
5315
+ /**
5316
+ * Borrow the voice (instrument, field, preset) of this other word's row
5317
+ * when it exists; `instrument` is the fallback when it does not.
5318
+ */
5319
+ voice?: string;
5320
+ }> &
5321
+ InstrumentWord;
5322
+
5323
+ /**
5324
+ * Words that keep their pre-0.6 meaning forever: they resolve to
5325
+ * themselves, whatever rows the lanes add.
5326
+ */
5327
+ const LEGACY_WORDS: readonly string[] = Object.freeze([
5328
+ "piano",
5329
+ "pluck",
5330
+ "bass",
5331
+ "saw",
5332
+ "square",
5333
+ "triangle",
5334
+ "marimba",
5335
+ "wind",
5336
+ "cello",
5337
+ "contrabass",
5338
+ "ebass",
5339
+ "sitar",
5340
+ "organ",
5341
+ "strings",
5342
+ "bell",
5343
+ "keys",
5344
+ "lead",
5345
+ ]);
5346
+
5347
+ /** 0.6 instrument words; each lane appends its own block. */
5348
+ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
5349
+ // strings (f06-strings): plucked presets of the string engine. Legacy
5350
+ // sitar/ebass keep today's voice (`string preset sitar` reaches the
5351
+ // engine), jangle is the rig alias (the guitar lane maps its 12string to the
5352
+ // preset; `string jangle` reaches it) and
5353
+ // upright is the keys lane's piano (doublebass reaches the preset).
5354
+ { word: "nylon", instrument: "string", field: "string", preset: "nylon" },
5355
+ { word: "steel", instrument: "string", field: "string", preset: "steel" },
5356
+ {
5357
+ word: "electric",
5358
+ instrument: "string",
5359
+ field: "string",
5360
+ preset: "electric",
5361
+ },
5362
+ { word: "slap", instrument: "string", field: "string", preset: "slap" },
5363
+ { word: "motown", instrument: "string", field: "string", preset: "motown" },
5364
+ { word: "tanpura", instrument: "string", field: "string", preset: "tanpura" },
5365
+ {
5366
+ word: "harpsichord",
5367
+ instrument: "string",
5368
+ field: "string",
5369
+ preset: "harpsichord",
5370
+ },
5371
+ { word: "lute", instrument: "string", field: "string", preset: "lute" },
5372
+ { word: "oud", instrument: "string", field: "string", preset: "oud" },
5373
+ { word: "setar", instrument: "string", field: "string", preset: "setar" },
5374
+ { word: "tar", instrument: "string", field: "string", preset: "tar" },
5375
+ { word: "santur", instrument: "string", field: "string", preset: "santur" },
5376
+ {
5377
+ word: "dulcimer",
5378
+ instrument: "string",
5379
+ field: "string",
5380
+ preset: "dulcimer",
5381
+ },
5382
+ { word: "koto", instrument: "string", field: "string", preset: "koto" },
5383
+ { word: "harp", instrument: "string", field: "string", preset: "harp" },
5384
+ { word: "banjo", instrument: "string", field: "string", preset: "banjo" },
5385
+ { word: "tres", instrument: "string", field: "string", preset: "tres" },
5386
+ {
5387
+ word: "requinto",
5388
+ instrument: "string",
5389
+ field: "string",
5390
+ preset: "requinto",
5391
+ },
5392
+ { word: "acoustic", instrument: "string", field: "string", preset: "steel" },
5393
+ { word: "classical", instrument: "string", field: "string", preset: "nylon" },
5394
+ {
5395
+ word: "bassguitar",
5396
+ instrument: "string",
5397
+ field: "string",
5398
+ preset: "ebass",
5399
+ },
5400
+ { word: "fender", instrument: "string", field: "string", preset: "ebass" },
5401
+ {
5402
+ word: "doublebass",
5403
+ instrument: "string",
5404
+ field: "string",
5405
+ preset: "upright",
5406
+ },
5407
+ {
5408
+ word: "cembalo",
5409
+ instrument: "string",
5410
+ field: "string",
5411
+ preset: "harpsichord",
5412
+ },
5413
+ {
5414
+ word: "hammered",
5415
+ instrument: "string",
5416
+ field: "string",
5417
+ preset: "dulcimer",
5418
+ },
5419
+ { word: "sehtar", instrument: "string", field: "string", preset: "setar" },
5420
+ // bowed (f061-bowed): bowed presets of the string engine. `cello`,
5421
+ // `contrabass` and `strings` stay legacy words (today's voice);
5422
+ // `bowed-cello`, `string cello` or `bowed cello` reach the engine.
5423
+ { word: "violin", instrument: "string", field: "string", preset: "violin" },
5424
+ { word: "viola", instrument: "string", field: "string", preset: "viola" },
5425
+ { word: "fiddle", instrument: "string", field: "string", preset: "fiddle" },
5426
+ { word: "erhu", instrument: "string", field: "string", preset: "erhu" },
5427
+ {
5428
+ word: "kamancheh",
5429
+ instrument: "string",
5430
+ field: "string",
5431
+ preset: "kamancheh",
5432
+ },
5433
+ {
5434
+ word: "kemence",
5435
+ instrument: "string",
5436
+ field: "string",
5437
+ preset: "kamancheh",
5438
+ },
5439
+ { word: "violins", instrument: "string", field: "string", preset: "violins" },
5440
+ { word: "violas", instrument: "string", field: "string", preset: "violas" },
5441
+ { word: "cellos", instrument: "string", field: "string", preset: "cellos" },
5442
+ {
5443
+ word: "contrabasses",
5444
+ instrument: "string",
5445
+ field: "string",
5446
+ preset: "contrabasses",
5447
+ },
5448
+ { word: "pizzicato", instrument: "string", field: "string", preset: "pizz" },
5449
+ { word: "tremolo", instrument: "string", field: "string", preset: "trem" },
5450
+ {
5451
+ word: "bowed-cello",
5452
+ instrument: "string",
5453
+ field: "string",
5454
+ preset: "cello",
5455
+ },
5456
+ // f06-rig: guitar track aliases, a guitar voice plus a whole rig. The
5457
+ // voice is the strings lane's `electric` row (jangle: its 12-string
5458
+ // `jangle` preset); the pluck only while that row is absent. Never `lead`
5459
+ // or `bass`.
5460
+ {
5461
+ word: "jangle",
5462
+ instrument: "string",
5463
+ field: "string",
5464
+ preset: "jangle",
5465
+ fx: "jangle",
5466
+ },
5467
+ { word: "punk", instrument: "pluck", voice: "electric", fx: "punk" },
5468
+ { word: "funk", instrument: "pluck", voice: "electric", fx: "funk" },
5469
+ { word: "ragged", instrument: "pluck", voice: "electric", fx: "ragged" },
5470
+ { word: "gtr-lead", instrument: "pluck", voice: "electric", fx: "lead" },
5471
+ { word: "gtr-metal", instrument: "pluck", voice: "electric", fx: "metal" },
5472
+ { word: "bachata", instrument: "pluck", voice: "electric", fx: "bachata" },
5473
+ // granular (f06-granular): the instrument and its texture presets. Each
5474
+ // starts from a built-in synth source, so nothing downloads.
5475
+ {
5476
+ word: "granular",
5477
+ instrument: "granular",
5478
+ field: "granular",
5479
+ preset: "cloud",
5480
+ },
5481
+ {
5482
+ word: "grains",
5483
+ instrument: "granular",
5484
+ field: "granular",
5485
+ preset: "cloud",
5486
+ },
5487
+ { word: "cloud", instrument: "granular", field: "granular", preset: "cloud" },
5488
+ {
5489
+ word: "sparkle",
5490
+ instrument: "granular",
5491
+ field: "granular",
5492
+ preset: "sparkle",
5493
+ },
5494
+ { word: "swarm", instrument: "granular", field: "granular", preset: "swarm" },
5495
+ {
5496
+ word: "microloop",
5497
+ instrument: "granular",
5498
+ field: "granular",
5499
+ preset: "microloop",
5500
+ },
5501
+ // keys (f06-piano): modelled pianos. `piano` stays legacy here; the typed
5502
+ // surfaces store a new `piano` as `grand` (core/keys.ts `pianoWrite`).
5503
+ { word: "grand", instrument: "grand", field: "keys", preset: "grand" },
5504
+ { word: "ballad", instrument: "grand", field: "keys", preset: "ballad" },
5505
+ { word: "upright", instrument: "upright", field: "keys", preset: "upright" },
5506
+ { word: "felt", instrument: "felt", field: "keys", preset: "felt" },
5507
+ { word: "lofi", instrument: "felt", field: "keys", preset: "lofi" },
5508
+ {
5509
+ word: "honkytonk",
5510
+ instrument: "honkytonk",
5511
+ field: "keys",
5512
+ preset: "honkytonk",
5513
+ },
5514
+ {
5515
+ word: "prepared",
5516
+ instrument: "prepared",
5517
+ field: "keys",
5518
+ preset: "prepared",
5519
+ },
5520
+ // keys (f061-organ): tonewheel, combo and pipe organs on the keys
5521
+ // engine. `organ` stays legacy (the sine voice).
5522
+ {
5523
+ word: "tonewheel",
5524
+ instrument: "tonewheel",
5525
+ field: "keys",
5526
+ preset: "tonewheel",
5527
+ },
5528
+ {
5529
+ word: "hammond",
5530
+ instrument: "tonewheel",
5531
+ field: "keys",
5532
+ preset: "tonewheel",
5533
+ },
5534
+ { word: "b3", instrument: "tonewheel", field: "keys", preset: "tonewheel" },
5535
+ { word: "gospel", instrument: "tonewheel", field: "keys", preset: "gospel" },
5536
+ {
5537
+ word: "jazzorgan",
5538
+ instrument: "tonewheel",
5539
+ field: "keys",
5540
+ preset: "jazzorgan",
5541
+ },
5542
+ { word: "combo", instrument: "combo", field: "keys", preset: "combo" },
5543
+ { word: "farfisa", instrument: "combo", field: "keys", preset: "combo" },
5544
+ { word: "vox", instrument: "combo", field: "keys", preset: "vox" },
5545
+ { word: "pipe", instrument: "pipe", field: "keys", preset: "pipe" },
5546
+ { word: "church", instrument: "pipe", field: "keys", preset: "pipe" },
5547
+ { word: "pipeorgan", instrument: "pipe", field: "keys", preset: "pipe" },
5548
+ { word: "churchorgan", instrument: "pipe", field: "keys", preset: "pipe" },
5549
+ { word: "flutes", instrument: "pipe", field: "keys", preset: "flutes" },
5550
+ { word: "cornet", instrument: "pipe", field: "keys", preset: "cornet" },
5551
+ { word: "reeds", instrument: "pipe", field: "keys", preset: "reeds" },
5552
+ { word: "celeste", instrument: "pipe", field: "keys", preset: "celeste" },
5553
+ // f06-modal: mallets and bells (core/resonators.ts). `marimba` is legacy;
5554
+ // `modal` alone gives the modal marimba.
5555
+ { word: "modal", instrument: "modal", field: "modal", preset: "marimba" },
5556
+ { word: "vibes", instrument: "modal", field: "modal", preset: "vibes" },
5557
+ { word: "vibraphone", instrument: "modal", field: "modal", preset: "vibes" },
5558
+ {
5559
+ word: "xylophone",
5560
+ instrument: "modal",
5561
+ field: "modal",
5562
+ preset: "xylophone",
5563
+ },
5564
+ { word: "glock", instrument: "modal", field: "modal", preset: "glock" },
5565
+ {
5566
+ word: "glockenspiel",
5567
+ instrument: "modal",
5568
+ field: "modal",
5569
+ preset: "glock",
5570
+ },
5571
+ { word: "celesta", instrument: "modal", field: "modal", preset: "celesta" },
5572
+ { word: "chimes", instrument: "modal", field: "modal", preset: "chimes" },
5573
+ { word: "tubular", instrument: "modal", field: "modal", preset: "chimes" },
5574
+ { word: "kalimba", instrument: "modal", field: "modal", preset: "kalimba" },
5575
+ {
5576
+ word: "thumbpiano",
5577
+ instrument: "modal",
5578
+ field: "modal",
5579
+ preset: "kalimba",
5580
+ },
5581
+ { word: "mbira", instrument: "modal", field: "modal", preset: "mbira" },
5582
+ { word: "steelpan", instrument: "modal", field: "modal", preset: "steelpan" },
5583
+ { word: "bowl", instrument: "modal", field: "modal", preset: "bowl" },
5584
+ { word: "gong", instrument: "modal", field: "modal", preset: "gong" },
5585
+ { word: "gongageng", instrument: "modal", field: "modal", preset: "gong" },
5586
+ { word: "timpani", instrument: "modal", field: "modal", preset: "timpani" },
5587
+ {
5588
+ word: "steeldrum",
5589
+ instrument: "modal",
5590
+ field: "modal",
5591
+ preset: "steelpan",
5592
+ },
5593
+ { word: "singingbowl", instrument: "modal", field: "modal", preset: "bowl" },
5594
+ {
5595
+ word: "kettledrum",
5596
+ instrument: "modal",
5597
+ field: "modal",
5598
+ preset: "timpani",
5599
+ },
5600
+ {
5601
+ word: "tubularbells",
5602
+ instrument: "modal",
5603
+ field: "modal",
5604
+ preset: "chimes",
5605
+ },
5606
+ // keys-electric (0.6.1): electric pianos and clavinet. `keys` stays legacy.
5607
+ { word: "epiano", instrument: "epiano", field: "keys", preset: "epiano" },
5608
+ { word: "rhodes", instrument: "epiano", field: "keys", preset: "epiano" },
5609
+ {
5610
+ word: "suitcase",
5611
+ instrument: "epiano",
5612
+ field: "keys",
5613
+ preset: "suitcase",
5614
+ },
5615
+ { word: "dyno", instrument: "epiano", field: "keys", preset: "dyno" },
5616
+ { word: "wurli", instrument: "wurli", field: "keys", preset: "wurli" },
5617
+ { word: "wurlitzer", instrument: "wurli", field: "keys", preset: "wurli" },
5618
+ { word: "clav", instrument: "clav", field: "keys", preset: "clav" },
5619
+ { word: "clavinet", instrument: "clav", field: "keys", preset: "clav" },
5620
+ { word: "funkclav", instrument: "clav", field: "keys", preset: "funkclav" },
5621
+ // f061-guitar: the shoegaze alias, an electric guitar voice plus the
5622
+ // shoegaze rig and its long wash.
5623
+ { word: "shoegaze", instrument: "pluck", voice: "electric", fx: "shoegaze" },
5624
+ // f061 integration: the two rig names that are free as instrument words
5625
+ // (glide and swell are taken by commands; reach them with `rig glide`).
5626
+ { word: "dreampop", instrument: "pluck", voice: "electric", fx: "dreampop" },
5627
+ { word: "ebow", instrument: "pluck", voice: "electric", fx: "ebow" },
5628
+ // f061-gamelan-winds: gamelan, small bells and frame drums.
5629
+ { word: "crotales", instrument: "modal", field: "modal", preset: "crotales" },
5630
+ { word: "crotale", instrument: "modal", field: "modal", preset: "crotales" },
5631
+ { word: "musicbox", instrument: "modal", field: "modal", preset: "musicbox" },
5632
+ { word: "toypiano", instrument: "modal", field: "modal", preset: "toypiano" },
5633
+ { word: "saron", instrument: "modal", field: "modal", preset: "saron" },
5634
+ { word: "demung", instrument: "modal", field: "modal", preset: "demung" },
5635
+ { word: "slenthem", instrument: "modal", field: "modal", preset: "slenthem" },
5636
+ { word: "gangsa", instrument: "modal", field: "modal", preset: "gangsa" },
5637
+ { word: "gender", instrument: "modal", field: "modal", preset: "gender" },
5638
+ { word: "bonang", instrument: "modal", field: "modal", preset: "bonang" },
5639
+ { word: "kenong", instrument: "modal", field: "modal", preset: "kenong" },
5640
+ { word: "kethuk", instrument: "modal", field: "modal", preset: "kethuk" },
5641
+ { word: "kempul", instrument: "modal", field: "modal", preset: "kempul" },
5642
+ { word: "daf", instrument: "modal", field: "modal", preset: "daf" },
5643
+ { word: "bodhran", instrument: "modal", field: "modal", preset: "bodhran" },
5644
+ { word: "framedrum", instrument: "modal", field: "modal", preset: "bodhran" },
5645
+ { word: "tabla", instrument: "modal", field: "modal", preset: "tabla" },
5646
+ // f061-gamelan-winds: blown waveguides (core/winds.ts). The legacy word
5647
+ // `wind` keeps its tone; these words and their aliases pick a wind preset.
5648
+ { word: "flute", instrument: "wind", field: "wind", preset: "flute" },
5649
+ { word: "recorder", instrument: "wind", field: "wind", preset: "recorder" },
5650
+ { word: "whistle", instrument: "wind", field: "wind", preset: "whistle" },
5651
+ { word: "ney", instrument: "wind", field: "wind", preset: "ney" },
5652
+ {
5653
+ word: "shakuhachi",
5654
+ instrument: "wind",
5655
+ field: "wind",
5656
+ preset: "shakuhachi",
5657
+ },
5658
+ { word: "panpipe", instrument: "wind", field: "wind", preset: "panpipe" },
5659
+ { word: "suling", instrument: "wind", field: "wind", preset: "suling" },
5660
+ { word: "bansuri", instrument: "wind", field: "wind", preset: "bansuri" },
5661
+ { word: "clarinet", instrument: "wind", field: "wind", preset: "clarinet" },
5662
+ {
5663
+ word: "bassclarinet",
5664
+ instrument: "wind",
5665
+ field: "wind",
5666
+ preset: "bassclarinet",
5667
+ },
5668
+ { word: "oboe", instrument: "wind", field: "wind", preset: "oboe" },
5669
+ { word: "bassoon", instrument: "wind", field: "wind", preset: "bassoon" },
5670
+ { word: "sax", instrument: "wind", field: "wind", preset: "sax" },
5671
+ { word: "altosax", instrument: "wind", field: "wind", preset: "altosax" },
5672
+ { word: "barisax", instrument: "wind", field: "wind", preset: "barisax" },
5673
+ { word: "trumpet", instrument: "wind", field: "wind", preset: "trumpet" },
5674
+ { word: "harmon", instrument: "wind", field: "wind", preset: "harmon" },
5675
+ { word: "plunger", instrument: "wind", field: "wind", preset: "plunger" },
5676
+ { word: "trombone", instrument: "wind", field: "wind", preset: "trombone" },
5677
+ { word: "tuba", instrument: "wind", field: "wind", preset: "tuba" },
5678
+ { word: "horn", instrument: "wind", field: "wind", preset: "horn" },
5679
+ { word: "tinwhistle", instrument: "wind", field: "wind", preset: "whistle" },
5680
+ {
5681
+ word: "pennywhistle",
5682
+ instrument: "wind",
5683
+ field: "wind",
5684
+ preset: "whistle",
5685
+ },
5686
+ { word: "nay", instrument: "wind", field: "wind", preset: "ney" },
5687
+ { word: "panflute", instrument: "wind", field: "wind", preset: "panpipe" },
5688
+ { word: "panpipes", instrument: "wind", field: "wind", preset: "panpipe" },
5689
+ { word: "saxophone", instrument: "wind", field: "wind", preset: "sax" },
5690
+ { word: "tenorsax", instrument: "wind", field: "wind", preset: "sax" },
5691
+ { word: "tenor", instrument: "wind", field: "wind", preset: "sax" },
5692
+ { word: "alto", instrument: "wind", field: "wind", preset: "altosax" },
5693
+ { word: "bari", instrument: "wind", field: "wind", preset: "barisax" },
5694
+ { word: "baritonesax", instrument: "wind", field: "wind", preset: "barisax" },
5695
+ { word: "frenchhorn", instrument: "wind", field: "wind", preset: "horn" },
5696
+ { word: "mutedtrumpet", instrument: "wind", field: "wind", preset: "harmon" },
5697
+ { word: "wahtrumpet", instrument: "wind", field: "wind", preset: "plunger" },
5698
+ ]);
5699
+
5700
+ /**
5701
+ * What an instrument word means: a legacy word is itself, a row word is its
5702
+ * row, anything else is undefined (callers keep the word as typed).
5703
+ */
5704
+ function resolveInstrumentWord(word: string): InstrumentWord | undefined {
5705
+ if (LEGACY_WORDS.includes(word)) return Object.freeze({ instrument: word });
5706
+ const row = INSTRUMENT_WORDS.find((entry) => entry.word === word);
5707
+ if (!row) return undefined;
5708
+ const { word: _word, voice, ...meaning } = row;
5709
+ const borrowed =
5710
+ voice === undefined
5711
+ ? undefined
5712
+ : INSTRUMENT_WORDS.find((entry) => entry.word === voice && !entry.voice);
5713
+ if (!borrowed) return Object.freeze(meaning);
5714
+ return Object.freeze({
5715
+ instrument: borrowed.instrument,
5716
+ ...(borrowed.field === undefined ? {} : { field: borrowed.field }),
5717
+ ...(borrowed.preset === undefined ? {} : { preset: borrowed.preset }),
5718
+ ...(meaning.fx === undefined ? {} : { fx: meaning.fx }),
5719
+ });
5720
+ }
5721
+
5722
+ /** The `Track.instrument` value a word stores (the word itself if unknown). */
5723
+ function instrumentForWord(word: string): string {
5724
+ return resolveInstrumentWord(word)?.instrument ?? word;
5725
+ }
5726
+ // END instrument words
5727
+
3837
5728
  // BEGIN chord engine: generated from core/chords.ts by core/sdk/sync-chords.ts
3838
5729
  // ---------------------------------------------------------------------------
3839
5730
  // Vocabulary
@@ -3921,6 +5812,12 @@ type Chord = Readonly<{
3921
5812
  extensions: readonly Extension[];
3922
5813
  /** Slash bass pitch class, when not the root. */
3923
5814
  bass?: number | undefined;
5815
+ /**
5816
+ * Upper tensions beyond the four extension buttons, as semitones above
5817
+ * the root (13 b9, 15 #9, 17 11, 18 #11, 21 13): only typed symbols
5818
+ * such as `C11`, `G13` or `A7b9` carry them (0.6.1).
5819
+ */
5820
+ tensions?: readonly number[] | undefined;
3924
5821
  }>;
3925
5822
 
3926
5823
  function makeChord(
@@ -3928,6 +5825,7 @@ function makeChord(
3928
5825
  quality: Quality,
3929
5826
  extensions: Iterable<Extension> = [],
3930
5827
  bass?: number,
5828
+ tensions: readonly number[] = [],
3931
5829
  ): Chord {
3932
5830
  const held = new Set(extensions);
3933
5831
  const own = SECRET_EXTENSION[quality];
@@ -3939,6 +5837,11 @@ function makeChord(
3939
5837
  quality,
3940
5838
  extensions: Object.freeze(ext),
3941
5839
  ...(slash !== undefined && slash !== pc ? { bass: slash } : {}),
5840
+ ...(tensions.length > 0
5841
+ ? {
5842
+ tensions: Object.freeze([...new Set(tensions)].sort((a, b) => a - b)),
5843
+ }
5844
+ : {}),
3942
5845
  });
3943
5846
  }
3944
5847
 
@@ -3946,6 +5849,7 @@ function makeChord(
3946
5849
  function chordIntervals(chord: Chord): number[] {
3947
5850
  const set = new Set(QUALITY_INTERVALS[chord.quality]);
3948
5851
  for (const ext of chord.extensions) set.add(EXTENSION_INTERVAL[ext]);
5852
+ for (const step of chord.tensions ?? []) set.add(step);
3949
5853
  // m7 and M7 together keep both; 6 with m7 on a dim triad is the dim7's bb7.
3950
5854
  return [...set].sort((a, b) => a - b);
3951
5855
  }
@@ -3998,6 +5902,18 @@ const SECRET_SUFFIX: Readonly<Partial<Record<Quality, string>>> = Object.freeze(
3998
5902
 
3999
5903
  /** Chord symbol suffix: `m7`, `maj9`, `7sus4`, `dim7`, `m7b5`, `6/9`. */
4000
5904
  function chordSuffix(chord: Chord): string {
5905
+ if (chord.tensions?.length) {
5906
+ // A typed extended chord keeps the symbol it was typed as.
5907
+ const typed = TENSION_SUFFIXES.find(
5908
+ ([, quality, ext, tensions]) =>
5909
+ quality === chord.quality &&
5910
+ ext.join() === chord.extensions.join() &&
5911
+ tensions.join() === chord.tensions!.join(),
5912
+ );
5913
+ if (typed) return typed[0];
5914
+ const names = chord.tensions.map((step) => TENSION_NAMES[step] ?? step);
5915
+ return `${chordSuffix({ ...chord, tensions: undefined })}(${names.join(",")})`;
5916
+ }
4001
5917
  const ext = new Set(chord.extensions);
4002
5918
  const b7 = ext.has("m7");
4003
5919
  const M7 = ext.has("M7");
@@ -4161,9 +6077,51 @@ const SUFFIXES: readonly (readonly [string, Quality, readonly Extension[]])[] =
4161
6077
  ["addb6", "b6", []],
4162
6078
  ["7#9", "7#9", []],
4163
6079
  ];
4164
- const SUFFIX_TABLE = new Map(
4165
- SUFFIXES.map(([suffix, quality, ext]) => [suffix, { quality, ext }]),
4166
- );
6080
+
6081
+ /** Typed upper-tension chords (0.6.1): suffix, quality, buttons, tensions. */
6082
+ const TENSION_SUFFIXES: readonly (readonly [
6083
+ string,
6084
+ Quality,
6085
+ readonly Extension[],
6086
+ readonly number[],
6087
+ ])[] = [
6088
+ ["11", "maj", ["m7", "9"], [17]],
6089
+ ["m11", "min", ["m7", "9"], [17]],
6090
+ ["maj11", "maj", ["M7", "9"], [17]],
6091
+ ["add11", "maj", [], [17]],
6092
+ ["madd11", "min", [], [17]],
6093
+ ["13", "maj", ["m7", "9"], [21]],
6094
+ ["m13", "min", ["m7", "9"], [21]],
6095
+ ["maj13", "maj", ["M7", "9"], [21]],
6096
+ ["7b9", "maj", ["m7"], [13]],
6097
+ ["7#11", "maj", ["m7"], [18]],
6098
+ ["maj7#11", "maj", ["M7"], [18]],
6099
+ ["M7#11", "maj", ["M7"], [18]],
6100
+ ["7b13", "maj", ["m7"], [20]],
6101
+ ["13b9", "maj", ["m7"], [13, 21]],
6102
+ ];
6103
+
6104
+ const TENSION_NAMES: Readonly<Record<number, string>> = Object.freeze({
6105
+ 13: "b9",
6106
+ 15: "#9",
6107
+ 17: "11",
6108
+ 18: "#11",
6109
+ 20: "b13",
6110
+ 21: "13",
6111
+ });
6112
+
6113
+ const SUFFIX_TABLE = new Map<
6114
+ string,
6115
+ { quality: Quality; ext: readonly Extension[]; tensions?: readonly number[] }
6116
+ >([
6117
+ ...SUFFIXES.map(
6118
+ ([suffix, quality, ext]) => [suffix, { quality, ext }] as const,
6119
+ ),
6120
+ ...TENSION_SUFFIXES.map(
6121
+ ([suffix, quality, ext, tensions]) =>
6122
+ [suffix, { quality, ext, tensions }] as const,
6123
+ ),
6124
+ ]);
4167
6125
 
4168
6126
  const LETTER: Readonly<Record<string, number>> = Object.freeze({
4169
6127
  c: 0,
@@ -4205,7 +6163,7 @@ function parseChord(symbol: string): Chord | undefined {
4205
6163
  bass = parsePitchClass(match[4]);
4206
6164
  if (bass === undefined) return undefined;
4207
6165
  }
4208
- return makeChord(root, entry.quality, entry.ext, bass);
6166
+ return makeChord(root, entry.quality, entry.ext, bass, entry.tensions);
4209
6167
  }
4210
6168
 
4211
6169
  // ---------------------------------------------------------------------------
@@ -5003,6 +6961,8 @@ const PERFORM_MODES = [
5003
6961
  "harp",
5004
6962
  "slop",
5005
6963
  "pattern",
6964
+ // 0.6.1: fretboard-voiced strokes (see `voiceGuitar`, `strokeVoicing`).
6965
+ "guitar",
5006
6966
  ] as const;
5007
6967
  type PerformMode = (typeof PERFORM_MODES)[number];
5008
6968
 
@@ -5022,6 +6982,23 @@ type PerformOptions = Readonly<{
5022
6982
  pattern?: string | number;
5023
6983
  /** 0..1. */
5024
6984
  velocity?: number;
6985
+ /** Guitar mode: the stroke grid (a STROKE_PATTERNS name or D U d u x - .). */
6986
+ strokes?: string;
6987
+ /** Guitar mode: grid step in beats, default 1/2. */
6988
+ step?: number;
6989
+ /** Guitar mode: seconds a full six-string down stroke takes, default 0.022. */
6990
+ speed?: number;
6991
+ /** Guitar mode: tempo in BPM that `speed` converts with, default 120. */
6992
+ tempo?: number;
6993
+ /** Guitar mode: the guitar's tuning, capo, hand, ring and position. */
6994
+ guitar?: GuitarSetup;
6995
+ /** Guitar mode: the chord's root pitch class (default: the lowest note). */
6996
+ root?: number;
6997
+ /**
6998
+ * Guitar mode: a slash bass pitch class that must sound lowest, even when
6999
+ * it is not a chord tone (B/E keeps its E pedal). Default: the lowest note.
7000
+ */
7001
+ slash?: number;
5025
7002
  }>;
5026
7003
 
5027
7004
  type PerformedNote = Readonly<{
@@ -5338,6 +7315,24 @@ function perform(
5338
7315
  }
5339
7316
  return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
5340
7317
  }
7318
+ case "guitar": {
7319
+ const bass = options.slash ?? notes[0]!;
7320
+ const voicing = voiceGuitar(
7321
+ notes.map(mod12),
7322
+ mod12(bass),
7323
+ options.root ?? mod12(bass),
7324
+ options.guitar,
7325
+ );
7326
+ if (!voicing) return notes.map((pitch) => at(pitch, start, end));
7327
+ return strokeVoicing(voicing, start, length, {
7328
+ ...(options.strokes !== undefined ? { strokes: options.strokes } : {}),
7329
+ ...(options.step !== undefined ? { step: options.step } : {}),
7330
+ ...(options.speed !== undefined ? { speed: options.speed } : {}),
7331
+ ...(options.tempo !== undefined ? { tempo: options.tempo } : {}),
7332
+ velocity,
7333
+ strings: guitarStrings(options.guitar?.tune).length,
7334
+ });
7335
+ }
5341
7336
  case "harp": {
5342
7337
  const gap = Math.max(0, options.strum ?? DEFAULT_STRUM * 2);
5343
7338
  return spanned.map((pitch, index) =>
@@ -5372,6 +7367,380 @@ function perform(
5372
7367
  }
5373
7368
  }
5374
7369
 
7370
+ // ---------------------------------------------------------------------------
7371
+ // Fretboard (0.6.1): guitar voicings and strokes
7372
+
7373
+ /**
7374
+ * Open-string MIDI pitches, low string to high, for `guitar tune <name>`.
7375
+ * Ported from the guitar design lane (proto/guitar/strum.ts TUNINGS).
7376
+ */
7377
+ const GUITAR_TUNINGS = Object.freeze({
7378
+ standard: [40, 45, 50, 55, 59, 64],
7379
+ dropd: [38, 45, 50, 55, 59, 64],
7380
+ doubledropd: [38, 45, 50, 55, 59, 62],
7381
+ dadgad: [38, 45, 50, 55, 57, 62],
7382
+ openg: [38, 43, 50, 55, 59, 62],
7383
+ opend: [38, 45, 50, 54, 57, 62],
7384
+ opene: [40, 47, 52, 56, 59, 64],
7385
+ halfdown: [39, 44, 49, 54, 58, 63],
7386
+ nashville: [52, 57, 62, 67, 59, 64],
7387
+ bass: [28, 33, 38, 43],
7388
+ ukulele: [67, 60, 64, 69],
7389
+ requinto: [45, 50, 55, 60, 64, 69],
7390
+ } as const satisfies Record<string, readonly number[]>);
7391
+ type GuitarTuningName = keyof typeof GUITAR_TUNINGS;
7392
+ const GUITAR_TUNING_NAMES = Object.keys(GUITAR_TUNINGS) as GuitarTuningName[];
7393
+
7394
+ /** How a guitar is set up for voicing chords (`Track.guitar`). */
7395
+ type GuitarSetup = Readonly<{
7396
+ /** A GUITAR_TUNINGS name or open-string pitches low to high. */
7397
+ tune?: string | readonly number[];
7398
+ /** Capo fret 0..12. */
7399
+ capo?: number;
7400
+ /** Hand stretch in frets 3..6 (default 4: one fret per finger). */
7401
+ hand?: number;
7402
+ /** 0 closed shapes (no open strings) .. 1 prefer ringing open strings. */
7403
+ ring?: number;
7404
+ /** Preferred fret position 0..12 (0 open position). */
7405
+ position?: number;
7406
+ }>;
7407
+
7408
+ /** One fretted chord: frets per string low to high (-1 muted). */
7409
+ type GuitarVoicing = Readonly<{
7410
+ frets: readonly number[];
7411
+ /** Sounding pitches, low string to high. */
7412
+ pitches: readonly number[];
7413
+ /** The string each pitch sounds on. */
7414
+ strings: readonly number[];
7415
+ }>;
7416
+
7417
+ /** Open-string pitches for a tuning name or list (standard when unknown). */
7418
+ function guitarStrings(tune: GuitarSetup["tune"]): readonly number[] {
7419
+ if (Array.isArray(tune)) return tune as readonly number[];
7420
+ const name = String(tune ?? "standard")
7421
+ .toLowerCase()
7422
+ .replace(/[\s_-]/g, "");
7423
+ return (
7424
+ (GUITAR_TUNINGS as Record<string, readonly number[]>)[name] ??
7425
+ GUITAR_TUNINGS.standard
7426
+ );
7427
+ }
7428
+
7429
+ /**
7430
+ * Fingers a fretting uses. Strings at the lowest fret count as one barre
7431
+ * only when no open string lies between them (a barre cannot skip an open
7432
+ * string); otherwise one finger each.
7433
+ */
7434
+ function guitarFingers(frets: readonly number[]): number {
7435
+ const fretted = frets.filter((f) => f > 0);
7436
+ if (fretted.length === 0) return 0;
7437
+ const min = Math.min(...fretted);
7438
+ const at = frets.flatMap((f, i) => (f === min ? [i] : []));
7439
+ const between = frets.slice(at[0]!, at[at.length - 1]! + 1);
7440
+ const barre = at.length > 1 && !between.includes(0);
7441
+ return (barre ? 1 : at.length) + fretted.filter((f) => f > min).length;
7442
+ }
7443
+
7444
+ /**
7445
+ * The chord tones a voicing must keep, most required first, after dropping
7446
+ * `drops` tones in the guitarist's order: the 5th, then the 9th; the root,
7447
+ * the 3rd and the 7th always stay (after the design lane's review).
7448
+ */
7449
+ function requiredTones(tones: readonly number[], root: number, drops: number) {
7450
+ // The 5th, then the 11th (only beside a 3rd: a sus4 keeps its 4th), then
7451
+ // the 9th. dawg's symbols reach an 11th through m(add4)/madd4 (+9).
7452
+ const third = tones.some((pc) => [3, 4].includes(mod12(pc - root)));
7453
+ const order = (third ? [7, 5, 2] : [7, 2]).map((step) => mod12(root + step));
7454
+ const dropped = order.filter((pc) => tones.includes(pc)).slice(0, drops);
7455
+ return tones.filter((pc) => !dropped.includes(pc));
7456
+ }
7457
+
7458
+ /**
7459
+ * The most playable fretting of pitch classes `tones` over `bass` on a
7460
+ * guitar `setup`: the bass is the lowest sounding note, every required
7461
+ * tone sounds, at most four fingers within a `hand`-fret stretch, mutes
7462
+ * only under the bass or one inside, and no barre over an open string.
7463
+ * Cost prefers more strings, low positions, open strings (by `ring`), a
7464
+ * fifth present, no doubled third, and small moves from `previous`.
7465
+ * Undefined when nothing fits. Ported from proto/guitar/strum.ts.
7466
+ */
7467
+ function voiceGuitar(
7468
+ tones: readonly number[],
7469
+ bass: number,
7470
+ root: number,
7471
+ setup: GuitarSetup = {},
7472
+ previous?: readonly number[],
7473
+ ): GuitarVoicing | undefined {
7474
+ // Play mode voices every pad press; dense chords at a wide hand take
7475
+ // ~15 ms to search, so repeats come from a small LRU (results are frozen).
7476
+ const key = JSON.stringify([
7477
+ [...new Set([...tones, bass].map(mod12))],
7478
+ mod12(bass),
7479
+ mod12(root),
7480
+ setup.tune ?? null,
7481
+ setup.capo ?? null,
7482
+ setup.hand ?? null,
7483
+ setup.ring ?? null,
7484
+ setup.position ?? null,
7485
+ previous ?? null,
7486
+ ]);
7487
+ if (voicingCache.has(key)) {
7488
+ const hit = voicingCache.get(key);
7489
+ voicingCache.delete(key);
7490
+ voicingCache.set(key, hit);
7491
+ return hit;
7492
+ }
7493
+ const voiced = searchGuitar(tones, bass, root, setup, previous);
7494
+ voicingCache.set(key, voiced);
7495
+ if (voicingCache.size > VOICING_CACHE_SIZE)
7496
+ voicingCache.delete(voicingCache.keys().next().value!);
7497
+ return voiced;
7498
+ }
7499
+
7500
+ const VOICING_CACHE_SIZE = 256;
7501
+ const voicingCache = new Map<string, GuitarVoicing | undefined>();
7502
+
7503
+ function searchGuitar(
7504
+ tones: readonly number[],
7505
+ bass: number,
7506
+ root: number,
7507
+ setup: GuitarSetup,
7508
+ previous?: readonly number[],
7509
+ ): GuitarVoicing | undefined {
7510
+ const pcs = [...new Set([...tones, bass].map(mod12))];
7511
+ const capo = clampInt(setup.capo ?? 0, 0, 12);
7512
+ const open = guitarStrings(setup.tune).map((pitch) => pitch + capo);
7513
+ const n = open.length;
7514
+ const ring = Math.min(1, Math.max(0, setup.ring ?? 0.5));
7515
+ const stretch = clampInt(setup.hand ?? 4, 3, 6);
7516
+ const fifth = mod12(root + 7);
7517
+ const third = pcs.find((pc) => [3, 4].includes(mod12(pc - root)));
7518
+ const prev = previous ? [...previous].sort((a, b) => a - b) : undefined;
7519
+ for (let drops = 0; drops <= 3; drops += 1) {
7520
+ const required = requiredTones(pcs, root, drops);
7521
+ let best: { frets: number[]; cost: number } | undefined;
7522
+ const choose = (frets: readonly number[]) => {
7523
+ const idx = frets.flatMap((f, i) => (f < 0 ? [] : [i]));
7524
+ if (idx.length < Math.min(n, n >= 6 ? 4 : 3)) return;
7525
+ const interior = frets.slice(idx[0]).filter((f) => f < 0).length;
7526
+ if (interior > (n >= 6 ? 1 : 0)) return;
7527
+ const pitches = idx.map((i) => open[i]! + frets[i]!);
7528
+ if (mod12(Math.min(...pitches)) !== mod12(bass)) return;
7529
+ const sounding = new Set(pitches.map(mod12));
7530
+ if (required.some((pc) => !sounding.has(pc))) return;
7531
+ const fingers = guitarFingers(frets);
7532
+ if (fingers > 4) return;
7533
+ const fretted = frets.filter((f) => f > 0);
7534
+ const span = fretted.length
7535
+ ? Math.max(...fretted) - Math.min(...fretted)
7536
+ : 0;
7537
+ if (span > stretch - 1) return;
7538
+ const opens = frets.filter((f) => f === 0).length;
7539
+ if (ring === 0 && opens > 0) return;
7540
+ const pos = fretted.length ? Math.min(...fretted) : 0;
7541
+ let cost =
7542
+ span * 0.6 +
7543
+ pos * 0.25 +
7544
+ fingers * 0.4 -
7545
+ idx.length -
7546
+ opens * 0.7 * ring;
7547
+ cost += interior * 1.5;
7548
+ if (!sounding.has(fifth) && pcs.includes(fifth)) cost += 0.6;
7549
+ if (
7550
+ third !== undefined &&
7551
+ pitches.filter((p) => mod12(p) === third).length > 1
7552
+ )
7553
+ cost += 0.5;
7554
+ if (setup.position !== undefined)
7555
+ cost += Math.abs(pos - setup.position) * 0.5;
7556
+ if (prev && prev.length) {
7557
+ const cur = [...pitches].sort((a, b) => a - b);
7558
+ let move = 0;
7559
+ for (let i = 0; i < cur.length; i += 1)
7560
+ move += Math.abs(cur[i]! - prev[Math.min(i, prev.length - 1)]!);
7561
+ cost += move * 0.05;
7562
+ }
7563
+ if (
7564
+ !best ||
7565
+ cost < best.cost - 1e-9 ||
7566
+ (Math.abs(cost - best.cost) <= 1e-9 && frets.join() < best.frets.join())
7567
+ )
7568
+ best = { frets: [...frets], cost };
7569
+ };
7570
+ for (let p = 1; p <= 12; p += 1) {
7571
+ const options = open.map((o) => {
7572
+ const list = [-1];
7573
+ if (pcs.includes(mod12(o))) list.push(0);
7574
+ for (let f = p; f < p + stretch; f += 1)
7575
+ if (pcs.includes(mod12(o + f))) list.push(f);
7576
+ return list;
7577
+ });
7578
+ const frets = new Array<number>(n).fill(-1);
7579
+ const walk = (s: number): void => {
7580
+ if (s === n) return choose(frets);
7581
+ for (const f of options[s]!) {
7582
+ frets[s] = f;
7583
+ walk(s + 1);
7584
+ }
7585
+ };
7586
+ walk(0);
7587
+ }
7588
+ if (best) {
7589
+ const { frets } = best as { frets: number[] };
7590
+ const strings = frets.flatMap((f, i) => (f < 0 ? [] : [i]));
7591
+ return Object.freeze({
7592
+ frets: Object.freeze(frets),
7593
+ pitches: Object.freeze(strings.map((i) => open[i]! + frets[i]!)),
7594
+ strings: Object.freeze(strings),
7595
+ });
7596
+ }
7597
+ }
7598
+ return undefined;
7599
+ }
7600
+
7601
+ /** A voicing as tab, low string first: `x 3 2 0 1 0`. */
7602
+ function guitarTab(frets: readonly number[]): string {
7603
+ return frets.map((f) => (f < 0 ? "x" : String(f))).join(" ");
7604
+ }
7605
+
7606
+ /**
7607
+ * Stroke grids for perform mode `guitar`, one character per step: D down
7608
+ * (all strings), U up (top four), d light down (top four), u light up (top
7609
+ * three), x muted chuck, `-` or `.` rest (strings ring on). After the
7610
+ * design lane's STRUM_PATTERNS.
7611
+ */
7612
+ const STROKE_PATTERNS = Object.freeze({
7613
+ down: "D",
7614
+ folk: "D-DU-UDU",
7615
+ pop: "D-DU-UD-",
7616
+ punk: "DDDDDDDD",
7617
+ funk: "xUxUDUxUxUDUxUxU",
7618
+ reggae: "-D-D",
7619
+ waltz: "Ddd",
7620
+ jangle: "D-DUDUDU",
7621
+ island: "D-DU-UDU",
7622
+ } as const satisfies Record<string, string>);
7623
+ const STROKE_PATTERN_NAMES = Object.keys(STROKE_PATTERNS);
7624
+ const STROKE_CHARS = /^[DUdux.\-]+$/;
7625
+
7626
+ /** Default seconds a full six-string down stroke takes (22 ms). */
7627
+ const DEFAULT_STROKE_SPEED = 0.022;
7628
+ /** Default stroke grid step in beats (8ths). */
7629
+ const DEFAULT_STROKE_STEP = 0.5;
7630
+
7631
+ /** A stroke grid by name, or a literal grid of D U d u x - .; undefined if bad. */
7632
+ function strokeGrid(text: string | undefined): string | undefined {
7633
+ const raw = (text ?? "down").trim();
7634
+ const named = (STROKE_PATTERNS as Record<string, string>)[raw.toLowerCase()];
7635
+ if (named) return named;
7636
+ const grid = raw.replace(/[|\s]/g, "");
7637
+ return grid.length > 0 && grid.length <= 64 && STROKE_CHARS.test(grid)
7638
+ ? grid
7639
+ : undefined;
7640
+ }
7641
+
7642
+ /**
7643
+ * Strum fretted `voicing` from `start` for `length` beats with stroke grid
7644
+ * `strokes` (repeats every grid length, one step per `step` beats). A full
7645
+ * down stroke sweeps the six strings in `speed` seconds at `tempo` BPM;
7646
+ * each struck string rings until it is struck again or the chord ends; a
7647
+ * chuck (x) is a short muted hit that stops the strings. Velocity accents
7648
+ * downbeats and lightens upstrokes.
7649
+ */
7650
+ function strokeVoicing(
7651
+ voicing: GuitarVoicing,
7652
+ start: number,
7653
+ length: number,
7654
+ options: Readonly<{
7655
+ strokes?: string;
7656
+ step?: number;
7657
+ speed?: number;
7658
+ tempo?: number;
7659
+ velocity?: number;
7660
+ strings?: number;
7661
+ }> = {},
7662
+ ): PerformedNote[] {
7663
+ const grid = strokeGrid(options.strokes) ?? "D";
7664
+ const step =
7665
+ options.step && options.step > 0 ? options.step : DEFAULT_STROKE_STEP;
7666
+ const tempo = options.tempo && options.tempo > 0 ? options.tempo : 120;
7667
+ const sweep =
7668
+ (Math.max(0, options.speed ?? DEFAULT_STROKE_SPEED) * tempo) / 60;
7669
+ const base = options.velocity ?? 0.8;
7670
+ const total = Math.max(2, options.strings ?? 6);
7671
+ const sounding = voicing.strings;
7672
+ const end = start + length;
7673
+ type Open = { note: PerformedNote; index: number };
7674
+ const ringing = new Map<number, Open>();
7675
+ const out: PerformedNote[] = [];
7676
+ const stop = (string: number, at: number) => {
7677
+ const held = ringing.get(string);
7678
+ if (!held) return;
7679
+ const cut = round6(
7680
+ Math.max(1e-3, Math.min(held.note.length, at - held.note.start)),
7681
+ );
7682
+ out[held.index] = { ...held.note, length: cut };
7683
+ ringing.delete(string);
7684
+ };
7685
+ const steps = Math.max(1, Math.round(length / step));
7686
+ for (let s = 0; s < steps; s += 1) {
7687
+ const ch = grid[s % grid.length]!;
7688
+ if (ch === "-" || ch === ".") continue;
7689
+ const t0 = start + s * step;
7690
+ if (t0 >= end - 1e-9) break;
7691
+ const beat = s * step;
7692
+ const accent =
7693
+ Math.abs(beat - Math.round(beat)) < 1e-9
7694
+ ? Math.round(beat) % 2 === 0
7695
+ ? 1
7696
+ : 0.92
7697
+ : 0.82;
7698
+ let order: number[];
7699
+ let velocity = base * accent;
7700
+ let ring = end - t0;
7701
+ const all = sounding.map((_, k) => k);
7702
+ switch (ch) {
7703
+ case "D":
7704
+ order = all;
7705
+ break;
7706
+ case "U":
7707
+ order = all.slice(-4).reverse();
7708
+ velocity *= 0.85;
7709
+ break;
7710
+ case "d":
7711
+ order = all.slice(-4);
7712
+ velocity *= 0.62;
7713
+ break;
7714
+ case "u":
7715
+ order = all.slice(-3).reverse();
7716
+ velocity *= 0.55;
7717
+ break;
7718
+ default:
7719
+ order = all;
7720
+ velocity *= 0.5;
7721
+ ring = Math.min(ring, 0.03 * (tempo / 60));
7722
+ }
7723
+ const first = sounding[order[0]!]!;
7724
+ order.forEach((k, position) => {
7725
+ const string = sounding[k]!;
7726
+ const at = t0 + (Math.abs(string - first) / (total - 1)) * sweep;
7727
+ if (at >= end - 1e-9) return;
7728
+ stop(string, at);
7729
+ const note: PerformedNote = {
7730
+ pitch: voicing.pitches[k]!,
7731
+ start: round6(at),
7732
+ length: round6(Math.max(1e-3, Math.min(ring, end - at))),
7733
+ velocity: round6(
7734
+ Math.max(0.05, Math.min(1, velocity * (1 - 0.04 * position))),
7735
+ ),
7736
+ };
7737
+ out.push(note);
7738
+ if (ch !== "x") ringing.set(string, { note, index: out.length - 1 });
7739
+ });
7740
+ }
7741
+ return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
7742
+ }
7743
+
5375
7744
  // ---------------------------------------------------------------------------
5376
7745
  // Progressions
5377
7746
 
@@ -5755,6 +8124,14 @@ function renderProgression(options: RenderOptions): RenderedProgression {
5755
8124
  ...perform(chord.pitches, at, span, {
5756
8125
  ...options.perform,
5757
8126
  seed: (options.perform?.seed ?? 0) + index,
8127
+ ...(options.perform?.mode === "guitar"
8128
+ ? {
8129
+ root: options.chords[index]!.root,
8130
+ ...(options.chords[index]!.bass !== undefined
8131
+ ? { slash: options.chords[index]!.bass }
8132
+ : {}),
8133
+ }
8134
+ : {}),
5758
8135
  }),
5759
8136
  );
5760
8137
  const source = options.chords[index]!;