@hraness/dawg 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
package/core/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.25.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,12 @@ 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;
1280
1286
  }>;
1281
1287
 
1282
1288
  /** Result of `sampler()`; pass it as a track's `instrument`. */
@@ -1317,7 +1323,7 @@ export function sampler(
1317
1323
  throw new DawgSdkError(
1318
1324
  `sampler voice "${name}" must be a short identifier`,
1319
1325
  );
1320
- out[name] = sample(voices[name]!, name);
1326
+ out[name] = sampleSpec(voices[name]!, name);
1321
1327
  }
1322
1328
  return Object.freeze({ kind: "sampler", voices: Object.freeze(out), mode });
1323
1329
  }
@@ -1433,6 +1439,314 @@ export function wavetable(
1433
1439
  return Object.freeze(out) as WavetableSpec;
1434
1440
  }
1435
1441
 
1442
+ /** Instrument name of the 0.6 string engine (`Track.string`). */
1443
+ export const STRING_INSTRUMENT = "string";
1444
+
1445
+ /**
1446
+ * String engine settings (SDK 1.21.0): a `preset` (`nylon`, `steel`,
1447
+ * `electric`, `jangle`, `ebass`, `slap`, `upright`, `sitar`, `tanpura`,
1448
+ * `harpsichord`, `lute`, `oud`, `setar`, `tar`, `santur`, `dulcimer`, `koto`,
1449
+ * `harp`, `banjo`, `tres`, `requinto`) plus any parameter to override
1450
+ * (`ring`, `bright`, `damp`, `pos`, `mute`, `buzz`, `body`, `sym`, ...).
1451
+ * dawg validates names and ranges; see **Strings** in DAWG.md.
1452
+ */
1453
+ export type StringInput = Readonly<
1454
+ { preset?: string } & Record<string, number | string | undefined>
1455
+ >;
1456
+
1457
+ /** Result of `stringed()`; pass it as a track's `instrument`. */
1458
+ export type StringSpec = Readonly<{ kind: "string" } & StringInput>;
1459
+
1460
+ /**
1461
+ * A plucked string instrument (SDK 1.21.0): a preset and overrides.
1462
+ *
1463
+ * instrument: stringed("nylon")
1464
+ * instrument: stringed("sitar", { buzz: 0.8, sym: 0.5 })
1465
+ */
1466
+ export function stringed(
1467
+ preset = "nylon",
1468
+ params: Readonly<Record<string, number | string>> = {},
1469
+ ): StringSpec {
1470
+ if (typeof preset !== "string" || preset.length === 0)
1471
+ throw new DawgSdkError("stringed needs a preset name");
1472
+ if (!isRecord(params))
1473
+ throw new DawgSdkError("stringed params must be an object");
1474
+ const out: Record<string, number | string> = { kind: "string", preset };
1475
+ for (const [key, value] of Object.entries(params)) {
1476
+ if (value === undefined || key === "kind" || key === "preset") continue;
1477
+ out[key] =
1478
+ typeof value === "string" ? value : finite(value, `string ${key}`);
1479
+ }
1480
+ return Object.freeze(out) as StringSpec;
1481
+ }
1482
+
1483
+ /** Instrument name of the 0.6 granular engine (`Track.granular`). */
1484
+ export const GRANULAR_INSTRUMENT = "granular";
1485
+
1486
+ /**
1487
+ * Granular settings (SDK 1.23.0). `src` is a sample (`sample(...)` shape,
1488
+ * pinned like a sampler voice) or a built-in synth render
1489
+ * `"synth:<preset>[@note]"` (default `synth:pad`, nothing to download);
1490
+ * `preset` is `cloud`, `hold`, `sparkle`, `swarm`, `stutter`, `microloop`,
1491
+ * `backwards` or `dust`; every other key overrides one parameter (`grain`
1492
+ * seconds, `overlap`, `scan`, `pos`, `begin`, `end`, `spray`, `jitter`,
1493
+ * `pitch`, `detune`, `shimmer`, `shimint`, `spread`, `window`, `reverse`,
1494
+ * `freeze`, `repeat`, `hold`, `drift`, `drate`, `attack`, `release`,
1495
+ * `veltone`, `gain`, `seed`, `root`). dawg validates names and ranges; see
1496
+ * **Granular** in DAWG.md.
1497
+ */
1498
+ export type GranularInput = Readonly<
1499
+ {
1500
+ src?: string | SampleSpec;
1501
+ preset?: string;
1502
+ } & Record<string, number | string | boolean | SampleSpec | undefined>
1503
+ >;
1504
+
1505
+ /** Result of `granular()`; pass it as a track's `instrument`. */
1506
+ export type GranularSpec = Readonly<{ kind: "granular" } & GranularInput>;
1507
+
1508
+ /**
1509
+ * A granular instrument (SDK 1.23.0): an optional preset, then overrides.
1510
+ *
1511
+ * ```ts
1512
+ * instrument: granular("cloud")
1513
+ * instrument: granular("hold", { src: "samples/choir.wav", scan: 0 })
1514
+ * instrument: granular({ src: "synth:bell@72", grain: 0.08, overlap: 6 })
1515
+ * ```
1516
+ */
1517
+ export function granular(
1518
+ preset?: string | GranularInput,
1519
+ params: GranularInput = {},
1520
+ ): GranularSpec {
1521
+ const fields =
1522
+ typeof preset === "object" && preset !== null ? preset : params;
1523
+ if (!isRecord(fields))
1524
+ throw new DawgSdkError("granular params must be an object");
1525
+ const out: Record<string, unknown> = { kind: "granular" };
1526
+ if (typeof preset === "string") {
1527
+ if (preset.length === 0)
1528
+ throw new DawgSdkError("granular needs a preset name");
1529
+ out.preset = preset;
1530
+ } else if (preset !== undefined && (typeof preset !== "object" || !preset))
1531
+ throw new DawgSdkError("granular takes a preset name or params");
1532
+ for (const [key, value] of Object.entries(fields)) {
1533
+ if (value === undefined || key === "kind") continue;
1534
+ if (key === "src")
1535
+ out.src =
1536
+ typeof value === "string" && value.startsWith("synth:")
1537
+ ? value
1538
+ : sampleSpec(value as string | SampleSpec, "granular src");
1539
+ else if (key === "preset" && typeof preset === "string") continue;
1540
+ else if (key === "root" && typeof value === "string")
1541
+ out.root = midi(value as Pitch);
1542
+ else if (
1543
+ typeof value === "number" ||
1544
+ typeof value === "string" ||
1545
+ typeof value === "boolean"
1546
+ )
1547
+ out[key] =
1548
+ typeof value === "number" ? finite(value, `granular ${key}`) : value;
1549
+ else throw new DawgSdkError(`granular ${key} must be a value`);
1550
+ }
1551
+ return Object.freeze(out) as GranularSpec;
1552
+ }
1553
+
1554
+ /** Instrument name that selects the modal mallet-and-bell engine (SDK 1.25.0). */
1555
+ export const MODAL_INSTRUMENT = "modal";
1556
+
1557
+ /** Modal presets (dawg's core/resonators.ts). */
1558
+ export type ModalPresetName =
1559
+ | "marimba"
1560
+ | "vibes"
1561
+ | "xylophone"
1562
+ | "glock"
1563
+ | "celesta"
1564
+ | "chimes"
1565
+ | "kalimba"
1566
+ | "mbira"
1567
+ | "steelpan"
1568
+ | "bowl"
1569
+ | "gong"
1570
+ | "timpani";
1571
+
1572
+ /** Modal overrides; omitted means the preset's value. dawg validates ranges. */
1573
+ export type ModalParams = Readonly<{
1574
+ /** `yarn` `cord` `rubber` `plastic` `brass`: sets hardness. */
1575
+ mallet?: "yarn" | "cord" | "rubber" | "plastic" | "brass";
1576
+ /** Mallet hardness 0..1: brighter, shorter contact. */
1577
+ hardness?: number;
1578
+ /** Strike position 0..1 (0.5 is the bar's centre). */
1579
+ position?: number;
1580
+ /** Fundamental ring time (T60 seconds). */
1581
+ ring?: number;
1582
+ /** How much faster upper modes die (octaves of decay per octave). */
1583
+ tilt?: number;
1584
+ /** Damping on note-off 0..1 (0 lets the bar ring). */
1585
+ damp?: number;
1586
+ /** Choke time after note-off, seconds. */
1587
+ release?: number;
1588
+ /** Vibraphone motor rate Hz and depth 0..1. */
1589
+ motor?: number;
1590
+ motordepth?: number;
1591
+ /** Beat between paired gamelan modes, Hz. */
1592
+ ombak?: number;
1593
+ /** Mbira buzz 0..1 and mallet click 0..1. */
1594
+ buzz?: number;
1595
+ click?: number;
1596
+ /** Strike pitch bend in semitones and its decay seconds (Strudel penv/pdecay). */
1597
+ strikebend?: number;
1598
+ strikedecay?: number;
1599
+ /** Output level 0..2 (1 is the preset level). */
1600
+ gain?: number;
1601
+ /** Mode table override (`marimba`, `bell`, `gong`, …). */
1602
+ body?: string;
1603
+ }>;
1604
+
1605
+ const MODAL_KEYS = Object.freeze([
1606
+ "mallet",
1607
+ "hardness",
1608
+ "position",
1609
+ "ring",
1610
+ "tilt",
1611
+ "damp",
1612
+ "release",
1613
+ "motor",
1614
+ "motordepth",
1615
+ "ombak",
1616
+ "buzz",
1617
+ "click",
1618
+ "strikebend",
1619
+ "strikedecay",
1620
+ "gain",
1621
+ "body",
1622
+ ] as const);
1623
+
1624
+ /** Mallet words (core/resonators.ts MODAL_MALLETS). */
1625
+ const MODAL_MALLET_WORDS: readonly string[] = Object.freeze([
1626
+ "yarn",
1627
+ "cord",
1628
+ "rubber",
1629
+ "plastic",
1630
+ "brass",
1631
+ ]);
1632
+
1633
+ /** Mode tables a `body` override may name (core/resonators.ts MODAL_BODIES). */
1634
+ const MODAL_BODY_WORDS: readonly string[] = Object.freeze([
1635
+ "marimba",
1636
+ "vibraphone",
1637
+ "xylophone",
1638
+ "glockenspiel",
1639
+ "celesta",
1640
+ "chimes",
1641
+ "crotale",
1642
+ "mbira",
1643
+ "kalimba",
1644
+ "musicbox",
1645
+ "toypiano",
1646
+ "saron",
1647
+ "bonang",
1648
+ "gender",
1649
+ "kempul",
1650
+ "gong",
1651
+ "bell",
1652
+ "steelpan",
1653
+ "bowl",
1654
+ "timpani",
1655
+ "tabla",
1656
+ "frame",
1657
+ ]);
1658
+
1659
+ /** Numeric ranges (core/resonators.ts MODAL_PARAMS min..max). */
1660
+ const MODAL_RANGES: Readonly<Record<string, readonly [number, number]>> =
1661
+ Object.freeze({
1662
+ hardness: [0, 1],
1663
+ position: [0, 1],
1664
+ ring: [0.05, 30],
1665
+ tilt: [0, 2],
1666
+ damp: [0, 1],
1667
+ release: [0.005, 2],
1668
+ motor: [0, 12],
1669
+ motordepth: [0, 1],
1670
+ ombak: [0, 12],
1671
+ buzz: [0, 1],
1672
+ click: [0, 1],
1673
+ strikebend: [-24, 24],
1674
+ strikedecay: [0.001, 2],
1675
+ gain: [0, 2],
1676
+ });
1677
+
1678
+ const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
1679
+ "marimba",
1680
+ "vibes",
1681
+ "xylophone",
1682
+ "glock",
1683
+ "celesta",
1684
+ "chimes",
1685
+ "kalimba",
1686
+ "mbira",
1687
+ "steelpan",
1688
+ "bowl",
1689
+ "gong",
1690
+ "timpani",
1691
+ ]);
1692
+
1693
+ /** Result of `modal()`; pass it as a track's `instrument`. */
1694
+ export type ModalSpec = Readonly<
1695
+ { kind: "modal"; preset?: ModalPresetName } & ModalParams
1696
+ >;
1697
+
1698
+ /**
1699
+ * Mallets and bells on the modal engine (SDK 1.25.0): a preset and
1700
+ * optional overrides. A preset word alone (`instrument: "vibes"`) is the
1701
+ * same as `modal("vibes")`, except `"marimba"`, which stays the legacy
1702
+ * marimba voice; `modal("marimba")` is the modal one.
1703
+ *
1704
+ * ```ts
1705
+ * instrument: modal("vibes", { motor: 4, hardness: 0.6 })
1706
+ * instrument: modal("marimba", { mallet: "rubber" })
1707
+ * instrument: modal({ ring: 2 }) // default preset (marimba)
1708
+ * ```
1709
+ */
1710
+ export function modal(
1711
+ preset?: ModalPresetName | ModalParams,
1712
+ params: ModalParams = {},
1713
+ ): ModalSpec {
1714
+ const overrides = isRecord(preset) ? preset : params;
1715
+ const name = isRecord(preset) ? undefined : preset;
1716
+ if (!isRecord(overrides))
1717
+ throw new DawgSdkError("modal params must be an object");
1718
+ const out: Record<string, unknown> = { kind: "modal" };
1719
+ if (name !== undefined) {
1720
+ if (typeof name !== "string" || !MODAL_PRESET_WORDS.includes(name))
1721
+ throw new DawgSdkError(
1722
+ `modal preset "${String(name).slice(0, 32)}" is not one of ${MODAL_PRESET_WORDS.join(" ")}`,
1723
+ );
1724
+ out.preset = name;
1725
+ }
1726
+ for (const key of Object.keys(overrides)) {
1727
+ const value = (overrides as Record<string, unknown>)[key];
1728
+ if (value === undefined) continue;
1729
+ if (key === "mallet" || key === "body") {
1730
+ const words = key === "mallet" ? MODAL_MALLET_WORDS : MODAL_BODY_WORDS;
1731
+ if (typeof value !== "string" || !words.includes(value))
1732
+ throw new DawgSdkError(
1733
+ `modal ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
1734
+ );
1735
+ out[key] = value;
1736
+ } else if ((MODAL_KEYS as readonly string[]).includes(key)) {
1737
+ const number = finite(value, `modal ${key}`);
1738
+ const [min, max] = MODAL_RANGES[key]!;
1739
+ if (number < min || number > max)
1740
+ throw new DawgSdkError(`modal ${key} must be ${min}..${max}`);
1741
+ out[key] = number;
1742
+ } else
1743
+ throw new DawgSdkError(
1744
+ `modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")})`,
1745
+ );
1746
+ }
1747
+ return Object.freeze(out) as ModalSpec;
1748
+ }
1749
+
1436
1750
  /**
1437
1751
  * `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
1438
1752
  * chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
@@ -1445,7 +1759,7 @@ export function slices(
1445
1759
  ): Record<string, SampleSpec> {
1446
1760
  if (!Number.isInteger(count) || count < 1 || count > 64)
1447
1761
  throw new DawgSdkError("slices count must be an integer 1..64");
1448
- const base = sample(src, prefix);
1762
+ const base = sampleSpec(src, prefix);
1449
1763
  const begin = base.begin ?? 0;
1450
1764
  const end = base.end ?? 1;
1451
1765
  const span = (end - begin) / count;
@@ -1460,7 +1774,7 @@ export function slices(
1460
1774
  return voices;
1461
1775
  }
1462
1776
 
1463
- function sample(value: string | SampleSpec, name: string): SampleSpec {
1777
+ function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
1464
1778
  const spec = typeof value === "string" ? { src: value } : value;
1465
1779
  if (!isRecord(spec) || typeof spec.src !== "string" || spec.src.length === 0)
1466
1780
  throw new DawgSdkError(`sampler voice ${name} needs a src path`);
@@ -1483,6 +1797,9 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
1483
1797
  fit?: boolean;
1484
1798
  accelerate?: number;
1485
1799
  squiz?: number;
1800
+ bpm?: number;
1801
+ fitmode?: "repitch" | "beats" | "tones";
1802
+ len?: number;
1486
1803
  } = { src: spec.src };
1487
1804
  if (spec.src.startsWith("pack:")) {
1488
1805
  if (spec.sha256 !== undefined)
@@ -1532,9 +1849,36 @@ function sample(value: string | SampleSpec, name: string): SampleSpec {
1532
1849
  if (spec.accelerate !== undefined)
1533
1850
  out.accelerate = finite(spec.accelerate, `${name} accelerate`);
1534
1851
  if (spec.squiz !== undefined) out.squiz = finite(spec.squiz, `${name} squiz`);
1852
+ if (spec.bpm !== undefined) out.bpm = finite(spec.bpm, `${name} bpm`);
1853
+ if (spec.fitmode !== undefined) {
1854
+ if (
1855
+ spec.fitmode !== "repitch" &&
1856
+ spec.fitmode !== "beats" &&
1857
+ spec.fitmode !== "tones"
1858
+ )
1859
+ throw new DawgSdkError(
1860
+ `${name} fitmode must be "repitch", "beats" or "tones"`,
1861
+ );
1862
+ out.fitmode = spec.fitmode;
1863
+ }
1864
+ if (spec.len !== undefined) out.len = finite(spec.len, `${name} len`);
1535
1865
  return Object.freeze(out);
1536
1866
  }
1537
1867
 
1868
+ /**
1869
+ * One sample file with options (SDK 1.20.0), for `sampler({ brk: ... })`:
1870
+ * `sample("samples/break.wav", { bpm: 174, fitmode: "beats" })` plays a
1871
+ * 174 BPM break in time with the song, cut at its hits.
1872
+ */
1873
+ export function sample(
1874
+ src: string,
1875
+ options: Omit<SampleSpec, "src"> = {},
1876
+ ): SampleSpec {
1877
+ if (typeof src !== "string" || src.length === 0)
1878
+ throw new DawgSdkError("sample() needs a src path");
1879
+ return Object.freeze({ ...options, src });
1880
+ }
1881
+
1538
1882
  // ---------------------------------------------------------------------------
1539
1883
  // Tracks
1540
1884
 
@@ -1564,14 +1908,177 @@ export type AutomationInput = Readonly<{
1564
1908
  wt?: readonly Point[];
1565
1909
  }>;
1566
1910
 
1911
+ /**
1912
+ * Every rig preset (SDK 1.22.0), kept equal to `RIG_PRESETS` in
1913
+ * core/fx.ts by `print-rig.test.ts`: the stomp, head and cab stages plus
1914
+ * the companion effects a few rigs need (funk's and wah's autofilter,
1915
+ * bachata's chorus, jangle's compressor). `spring`'s short room is the
1916
+ * track's `reverb`, not `fx`: give it as
1917
+ * `reverb: { mix: 0.3, size: 0.35, fade: 1.5, predelay: 0, dim: 3500 }`.
1918
+ */
1919
+ export const RIG_PRESETS: Readonly<
1920
+ Record<
1921
+ string,
1922
+ Readonly<
1923
+ Record<"stomp" | "head" | "cab", EffectParams | undefined> &
1924
+ Readonly<Record<string, EffectParams | undefined>>
1925
+ >
1926
+ >
1927
+ > = Object.freeze({
1928
+ clean: {
1929
+ stomp: undefined,
1930
+ head: { type: "clean", gain: 3, treble: 6 },
1931
+ cab: { type: "1x12" },
1932
+ },
1933
+ crunch: {
1934
+ stomp: undefined,
1935
+ head: { type: "crunch", gain: 5 },
1936
+ cab: { type: "4x12" },
1937
+ },
1938
+ punk: {
1939
+ stomp: undefined,
1940
+ head: { type: "crunch", gain: 7, mid: 6, master: 6 },
1941
+ cab: { type: "4x12", mic: 0.2 },
1942
+ },
1943
+ ragged: {
1944
+ stomp: { type: "face", gain: 6, tone: 0.6 },
1945
+ head: { type: "chime", gain: 4 },
1946
+ cab: { type: "2x12" },
1947
+ },
1948
+ lead: {
1949
+ stomp: { type: "od", gain: 3, tone: 0.5, level: 3 },
1950
+ head: { type: "lead", gain: 6, mid: 6 },
1951
+ cab: { type: "4x12" },
1952
+ },
1953
+ metal: {
1954
+ stomp: { type: "od", gain: 0, tone: 0.6, level: 6 },
1955
+ head: { type: "high", gain: 7, bass: 6, mid: 3, treble: 7, gate: -55 },
1956
+ cab: { type: "4x12", mic: 0.2 },
1957
+ },
1958
+ fuzz: {
1959
+ stomp: { type: "fuzz", gain: 7, tone: 0.5 },
1960
+ head: { type: "clean", gain: 4 },
1961
+ cab: { type: "2x12" },
1962
+ },
1963
+ octave: {
1964
+ stomp: { type: "octave", gain: 6, tone: 0.6, octave: 0.8 },
1965
+ head: { type: "clean", gain: 3 },
1966
+ cab: { type: "1x12" },
1967
+ },
1968
+ funk: {
1969
+ stomp: undefined,
1970
+ head: { type: "clean", gain: 2, treble: 7, presence: 6 },
1971
+ cab: { type: "2x12" },
1972
+ autofilter: {
1973
+ type: "bpf",
1974
+ sync: 0,
1975
+ rate: 0.01,
1976
+ depth: 0,
1977
+ follow: 3,
1978
+ cutoff: 500,
1979
+ resonance: 0.6,
1980
+ },
1981
+ },
1982
+ wah: {
1983
+ stomp: undefined,
1984
+ head: { type: "crunch", gain: 4 },
1985
+ cab: { type: "2x12" },
1986
+ autofilter: {
1987
+ type: "bpf",
1988
+ sync: 0.5,
1989
+ depth: 2,
1990
+ shape: "sine",
1991
+ cutoff: 700,
1992
+ resonance: 0.6,
1993
+ },
1994
+ },
1995
+ bachata: {
1996
+ stomp: undefined,
1997
+ head: { type: "clean", gain: 2, mid: 6, treble: 7 },
1998
+ cab: { type: "1x12", mic: 0.2 },
1999
+ chorus: { rate: 0.8, depth: 0.25, mix: 0.3 },
2000
+ },
2001
+ spring: {
2002
+ stomp: undefined,
2003
+ head: { type: "clean", gain: 3, treble: 6 },
2004
+ cab: { type: "open" },
2005
+ },
2006
+ bassdrive: {
2007
+ stomp: { type: "od", gain: 4, tone: 0.5, mix: 0.6 },
2008
+ head: { type: "bass", gain: 4 },
2009
+ cab: { type: "8x10" },
2010
+ },
2011
+ reese: {
2012
+ stomp: { type: "rat", gain: 3, tone: 0.3, mix: 0.5 },
2013
+ head: { type: "bass", gain: 6, master: 6 },
2014
+ cab: { type: "1x15" },
2015
+ },
2016
+ jangle: {
2017
+ stomp: undefined,
2018
+ head: { type: "chime", gain: 3, treble: 7 },
2019
+ cab: { type: "2x12", mic: 0.2 },
2020
+ compressor: { threshold: -20, ratio: 4, attack: 0.01, release: 0.15 },
2021
+ },
2022
+ alt: {
2023
+ stomp: { type: "rat", gain: 6, tone: 0.4 },
2024
+ head: { type: "crunch", gain: 4 },
2025
+ cab: { type: "4x12" },
2026
+ },
2027
+ });
2028
+
2029
+ /**
2030
+ * A guitar rig for a track's `fx` (SDK 1.22.0): the stomp → head → cab
2031
+ * stages of rig preset `name` and its companion effects (as the `rig`
2032
+ * command sets them), with optional per-stage overrides. Spread it
2033
+ * into `fx` next to other effects:
2034
+ *
2035
+ * ```ts
2036
+ * fx: { ...rig("crunch"), chorus: {} }
2037
+ * fx: { ...rig("metal", { head: { gain: 9 } }) }
2038
+ * ```
2039
+ */
2040
+ export function rig(
2041
+ name: string,
2042
+ overrides: Readonly<
2043
+ Partial<Record<"stomp" | "head" | "cab", EffectParams>>
2044
+ > = {},
2045
+ ): FxInput {
2046
+ if (
2047
+ typeof name !== "string" ||
2048
+ !Object.prototype.hasOwnProperty.call(RIG_PRESETS, name)
2049
+ )
2050
+ throw new DawgSdkError(
2051
+ `unknown rig "${String(name)}" (rigs: ${Object.keys(RIG_PRESETS).join(", ")})`,
2052
+ );
2053
+ if (!isRecord(overrides))
2054
+ throw new DawgSdkError("rig overrides must be an object");
2055
+ const out: Record<string, EffectParams> = {};
2056
+ // Companion effects first, then the stages in chain order.
2057
+ for (const [effect, values] of Object.entries(RIG_PRESETS[name]!))
2058
+ if (values && effect !== "stomp" && effect !== "head" && effect !== "cab")
2059
+ out[effect] = Object.freeze({ ...values });
2060
+ for (const stage of ["stomp", "head", "cab"] as const) {
2061
+ const base = RIG_PRESETS[name]![stage];
2062
+ const extra = overrides[stage];
2063
+ if (extra !== undefined && !isRecord(extra))
2064
+ throw new DawgSdkError(`rig ${stage} overrides must be an object`);
2065
+ if (base || extra)
2066
+ out[stage] = Object.freeze({ ...(base ?? {}), ...(extra ?? {}) });
2067
+ }
2068
+ return Object.freeze(out);
2069
+ }
2070
+
1567
2071
  /** One effect's parameters; omitted ones take dawg's defaults. */
1568
2072
  export type EffectParams = Readonly<Record<string, number | string | boolean>>;
1569
2073
 
1570
2074
  /**
1571
2075
  * 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,
2076
+ * filter → djf → autofilter → vowel → crush → distort → stomp → head →
2077
+ * cab → tremolo → compressor → pan → phaser → chorus → leslie → postgain →
2078
+ * delay → reverb. The guitar rig (stomp, head, cab; SDK 1.22.0) is easiest
2079
+ * as `...rig("crunch")`.
2080
+ * Keys here: djf, autofilter, vowel, crush, distort, stomp, head, cab,
2081
+ * tremolo, compressor,
1575
2082
  * phaser, chorus, leslie, postgain, plus the mix-bus keys `orbit`
1576
2083
  * (`{ orbit: 2 }`, SDK 1.9.0) and `duck` (`{ orbit: 2, depth: 0.85 }`:
1577
2084
  * this track's onsets duck every other track on that orbit). See
@@ -1585,6 +2092,53 @@ export type FxInput = Readonly<Record<string, EffectParams>>;
1585
2092
  * `"synth-<param>"` (e.g. `"synth-lpf"`), read at each note's onset.
1586
2093
  * Every parameter, range and default: **Synth** in DAWG.md.
1587
2094
  */
2095
+ /**
2096
+ * Modelled piano settings (SDK 1.24.0), for a track whose instrument is a
2097
+ * piano family (`"grand"`, `"upright"`, `"felt"`, `"honkytonk"`,
2098
+ * `"prepared"`; the words `"ballad"` and `"lofi"` pick presets). Every field
2099
+ * is optional; `{}` is the family's own sound. Automate a parameter with
2100
+ * `automation.fx["keys-<param>"]` (hardness, touch, decay, release, knock,
2101
+ * noise, felt), read at each note's onset. Ranges: **Keys** in DAWG.md.
2102
+ */
2103
+ export type KeysInput = Readonly<{
2104
+ /** A named preset: grand ballad upright felt lofi honkytonk prepared. */
2105
+ preset?: string;
2106
+ /** Hammer hardness 0..1: brightness at a given velocity (0.5). */
2107
+ hardness?: number;
2108
+ /** Velocity sensitivity 0..1 (1). */
2109
+ touch?: number;
2110
+ /** Inharmonicity multiplier 0..4 (1 grand, 2.5 upright, 0 harmonic). */
2111
+ inharm?: number;
2112
+ /** Unison detune in cents 0..30 (0.7; honkytonk 16). */
2113
+ unison?: number;
2114
+ /** Sustain time multiplier 0.1..4 (1). */
2115
+ decay?: number;
2116
+ /** Damper time multiplier 0.1..4 (1). */
2117
+ release?: number;
2118
+ /** Hammer position along the string 0.04..0.3 (0.12). */
2119
+ strike?: number;
2120
+ /** Aftersound share 0..1 (0.3). */
2121
+ after?: number;
2122
+ /** Soundboard knock 0..1 (0.5). */
2123
+ knock?: number;
2124
+ /** Key and damper mechanics 0..1 (0.25). */
2125
+ noise?: number;
2126
+ /** Felt strip 0..1 (0; felt family 1). */
2127
+ felt?: number;
2128
+ /** Share of prepared keys 0..1 (0; prepared family 0.6). */
2129
+ prep?: number;
2130
+ /** Keyboard stereo width 0..1 (0.6). */
2131
+ width?: number;
2132
+ /** Octave stretch 0..1 (1); 0 keeps every key exactly on its tuning. */
2133
+ stretch?: number;
2134
+ /** Body EQ: grand upright felt honkytonk prepared (the family's own). */
2135
+ body?: string;
2136
+ /** Pitch wobble rate in Hz (tape wow), 0 off. */
2137
+ vib?: number;
2138
+ /** Pitch wobble depth in semitones (0.5). */
2139
+ vibmod?: number;
2140
+ }>;
2141
+
1588
2142
  export type SynthInput = Readonly<{
1589
2143
  attack?: number;
1590
2144
  decay?: number;
@@ -1668,9 +2222,26 @@ export type TrackInput = Readonly<{
1668
2222
  * `triangle`, and Strudel's `sawtooth`, `supersaw`, `pulse`, `user`,
1669
2223
  * `white`, `pink`, `brown`, `crackle`, and the ZzFX sounds `z_sine`,
1670
2224
  * `z_triangle`, `z_sawtooth`, `z_square`, `z_tan`, `z_noise`), `kit` for drums,
1671
- * `sampler(...)` or `wavetable(...)`. Default `sine`.
2225
+ * `sampler(...)` or `wavetable(...)`, or a mallet or bell (`vibes`,
2226
+ * `glock`, `gong`, … or `modal(...)`, SDK 1.25.0). Default `sine`.
1672
2227
  */
1673
- instrument?: string | SamplerSpec | WavetableSpec;
2228
+ instrument?:
2229
+ | string
2230
+ | SamplerSpec
2231
+ | WavetableSpec
2232
+ | StringSpec
2233
+ | GranularSpec
2234
+ | ModalSpec;
2235
+ /**
2236
+ * The sampler a `granular(...)` track keeps while it grains one of its
2237
+ * voices (SDK 1.23.0); `grain off` plays it again.
2238
+ */
2239
+ sampler?: SamplerSpec | null;
2240
+ /**
2241
+ * Granular engine (SDK 1.23.0) for an `instrument: "granular"` track, or
2242
+ * use `instrument: granular("cloud", {...})` or a word (`"cloud"`).
2243
+ */
2244
+ granular?: GranularInput | null;
1674
2245
  /**
1675
2246
  * Synthesized drum kit for an `instrument: "kit"` track: `syn808`,
1676
2247
  * `syn909`, `acoustic`, `lofi`, `electro` or `trap`. Omit for the default
@@ -1694,6 +2265,17 @@ export type TrackInput = Readonly<{
1694
2265
  tuning?: TuningInput | null;
1695
2266
  /** Synth voice parameters, Strudel names (`{ attack: 0.01, lpf: 800 }`). */
1696
2267
  synth?: SynthInput;
2268
+ /**
2269
+ * String engine (SDK 1.21.0) for an `instrument: "string"` track, or use
2270
+ * `instrument: stringed("sitar", {...})` or a preset word (`"nylon"`).
2271
+ */
2272
+ string?: StringInput | null;
2273
+ /**
2274
+ * Modelled piano settings (SDK 1.24.0) for `instrument: "grand"` and the
2275
+ * other piano families; `{}` is the family's sound. The word `"piano"`
2276
+ * keeps the classic 0.4 tone; use `"grand"` for the modelled piano.
2277
+ */
2278
+ keys?: KeysInput;
1697
2279
  muted?: boolean;
1698
2280
  /** When any track is soloed only soloed tracks play. */
1699
2281
  solo?: boolean;
@@ -1833,6 +2415,10 @@ export type TrackSpec = Readonly<{
1833
2415
  synth: SynthInput | null;
1834
2416
  sampler: SamplerSpec | null;
1835
2417
  wavetable: WavetableSpec | null;
2418
+ /** String engine settings (SDK 1.21.0); present only when set. */
2419
+ string?: StringInput;
2420
+ /** Granular engine settings (SDK 1.23.0); null when not granular. */
2421
+ granular?: GranularInput | null;
1836
2422
  automation: Readonly<Required<AutomationInput>>;
1837
2423
  /** Every hit resolved to its pitch slot. */
1838
2424
  notes: readonly NoteSpec[];
@@ -1855,6 +2441,10 @@ export type TrackSpec = Readonly<{
1855
2441
  seed: number;
1856
2442
  }>;
1857
2443
  tuning: ScoreTuning | null;
2444
+ /** Modelled piano settings (SDK 1.24.0); present only when set. */
2445
+ keys?: KeysInput;
2446
+ /** Modal settings (SDK 1.25.0); present only on a modal track. */
2447
+ modal?: Readonly<{ preset?: ModalPresetName } & ModalParams>;
1858
2448
  }>;
1859
2449
 
1860
2450
  export type GlideMode = "legato" | "mono" | "poly";
@@ -2056,34 +2646,77 @@ export function track(input: TrackInput): TrackSpec {
2056
2646
  if (typeof id !== "string" || id.length === 0 || id.length > 64)
2057
2647
  throw new DawgSdkError(`track ${name}: id must be 1..64 characters`);
2058
2648
  const rawInstrument = input.instrument ?? "sine";
2649
+ // A granular track may keep the sampler it grains (`grain off` goes back).
2650
+ const keptSampler =
2651
+ isRecord(input.sampler) &&
2652
+ input.sampler.kind === "sampler" &&
2653
+ isRecord(rawInstrument) &&
2654
+ rawInstrument.kind === "granular"
2655
+ ? localizeSampler(input.sampler as SamplerSpec, slug)
2656
+ : null;
2657
+ if (input.sampler !== undefined && input.sampler !== null && !keptSampler)
2658
+ throw new DawgSdkError(
2659
+ `track ${name}: sampler: is only for a granular(...) track; use instrument: sampler({...})`,
2660
+ );
2059
2661
  const samplerSpec =
2060
2662
  isRecord(rawInstrument) && rawInstrument.kind === "sampler"
2061
2663
  ? localizeSampler(rawInstrument as SamplerSpec, slug)
2062
- : null;
2664
+ : keptSampler;
2063
2665
  const wavetableSpec =
2064
2666
  isRecord(rawInstrument) && rawInstrument.kind === "wavetable"
2065
2667
  ? localizeWavetable(rawInstrument as WavetableSpec, slug)
2066
2668
  : null;
2067
- const instrument = samplerSpec
2068
- ? SAMPLER_INSTRUMENT
2069
- : wavetableSpec
2070
- ? WAVETABLE_INSTRUMENT
2071
- : typeof rawInstrument === "string"
2072
- ? rawInstrument
2073
- : undefined;
2669
+ const stringFromInstrument =
2670
+ isRecord(rawInstrument) && rawInstrument.kind === "string"
2671
+ ? stringInput(rawInstrument, name)
2672
+ : null;
2673
+ const granularFromInstrument =
2674
+ isRecord(rawInstrument) && rawInstrument.kind === "granular"
2675
+ ? granularInput(rawInstrument, name, slug)
2676
+ : null;
2677
+ const word =
2678
+ typeof rawInstrument === "string"
2679
+ ? resolveInstrumentWord(rawInstrument)
2680
+ : undefined;
2681
+ const modalSpec = trackModal(rawInstrument);
2682
+ const instrument = granularFromInstrument
2683
+ ? GRANULAR_INSTRUMENT
2684
+ : samplerSpec
2685
+ ? SAMPLER_INSTRUMENT
2686
+ : wavetableSpec
2687
+ ? WAVETABLE_INSTRUMENT
2688
+ : stringFromInstrument
2689
+ ? STRING_INSTRUMENT
2690
+ : modalSpec
2691
+ ? MODAL_INSTRUMENT
2692
+ : typeof rawInstrument === "string"
2693
+ ? (word?.instrument ?? rawInstrument)
2694
+ : undefined;
2695
+ // A granular word (`"cloud"`) turns the engine on with its preset.
2696
+ const granularSpec =
2697
+ granularInput(input.granular, name, slug) ??
2698
+ granularFromInstrument ??
2699
+ (word?.field === "granular" && word.preset
2700
+ ? Object.freeze({ preset: word.preset })
2701
+ : instrument === GRANULAR_INSTRUMENT
2702
+ ? Object.freeze({})
2703
+ : null);
2074
2704
  if (
2075
2705
  instrument === undefined ||
2076
2706
  instrument.length === 0 ||
2077
2707
  instrument.length > 64
2078
2708
  )
2079
2709
  throw new DawgSdkError(
2080
- `track ${name}: instrument must be a voice name, "kit", sampler(...) or wavetable(...)`,
2710
+ `track ${name}: instrument must be a voice name, "kit", sampler(...), wavetable(...), stringed(...), granular(...) or modal(...)`,
2081
2711
  );
2082
2712
  if (instrument === SAMPLER_INSTRUMENT && !samplerSpec)
2083
2713
  throw new DawgSdkError(
2084
2714
  `track ${name}: use instrument: sampler({...}) for a sampler track`,
2085
2715
  );
2086
- const slots = samplerSpec ? voiceSlots(samplerSpec) : undefined;
2716
+ const slots =
2717
+ samplerSpec && instrument === SAMPLER_INSTRUMENT
2718
+ ? voiceSlots(samplerSpec)
2719
+ : undefined;
2087
2720
  const kit = KIT_INSTRUMENTS.includes(instrument.trim().toLowerCase());
2088
2721
  const notes = (input.notes ?? []).map((item, index) => {
2089
2722
  if (!isRecord(item) || (item.kind !== "note" && item.kind !== "hit"))
@@ -2158,6 +2791,23 @@ export function track(input: TrackInput): TrackSpec {
2158
2791
  throw new DawgSdkError(
2159
2792
  `track ${name}: unknown automation lane "${key}" (${AUTOMATION_KEYS.join(" ")})`,
2160
2793
  );
2794
+ // A keys preset word (`"lofi"`, `"ballad"`) brings its preset's effects,
2795
+ // as the prompt does; explicit filter, fx and reverb win.
2796
+ const presetFx = keysPresetFx(input.keys, rawInstrument);
2797
+ if (presetFx) {
2798
+ input = {
2799
+ ...input,
2800
+ ...(input.filter === undefined && presetFx.filter
2801
+ ? { filter: presetFx.filter }
2802
+ : {}),
2803
+ ...(input.reverb === undefined && presetFx.reverb
2804
+ ? { reverb: presetFx.reverb }
2805
+ : {}),
2806
+ ...(presetFx.fx && input.fx !== null
2807
+ ? { fx: { ...presetFx.fx, ...(isRecord(input.fx) ? input.fx : {}) } }
2808
+ : {}),
2809
+ };
2810
+ }
2161
2811
  const filter =
2162
2812
  input.filter === undefined || input.filter === null
2163
2813
  ? null
@@ -2200,8 +2850,26 @@ export function track(input: TrackInput): TrackSpec {
2200
2850
  ? {}
2201
2851
  : { ir: reverbIr(input.reverb.ir, name, slug) }),
2202
2852
  });
2203
- const fx = fxInput(input.fx, name);
2853
+ // A guitar alias (`instrument: "jangle"`) also loads its rig; stages and
2854
+ // effects the track's own `fx` names win.
2855
+ const aliasRig =
2856
+ typeof rawInstrument === "string"
2857
+ ? resolveInstrumentWord(rawInstrument)?.fx
2858
+ : undefined;
2859
+ const fx = fxInput(
2860
+ aliasRig === undefined
2861
+ ? input.fx
2862
+ : { ...rig(aliasRig), ...(isRecord(input.fx) ? input.fx : {}) },
2863
+ name,
2864
+ );
2204
2865
  const synth = synthInput(input.synth, name);
2866
+ // A string preset word (`"nylon"`) turns the engine on with its preset.
2867
+ const string =
2868
+ stringInput(input.string, name) ??
2869
+ stringFromInstrument ??
2870
+ (word?.field === "string" && word.preset
2871
+ ? Object.freeze({ preset: word.preset })
2872
+ : null);
2205
2873
  return Object.freeze({
2206
2874
  kind: "track",
2207
2875
  id,
@@ -2219,6 +2887,8 @@ export function track(input: TrackInput): TrackSpec {
2219
2887
  synth,
2220
2888
  sampler: samplerSpec,
2221
2889
  wavetable: wavetableSpec,
2890
+ ...(string ? { string } : {}),
2891
+ ...(granularSpec ? { granular: granularSpec } : {}),
2222
2892
  automation: Object.freeze({
2223
2893
  volume: lane("volume"),
2224
2894
  pan: lane("pan"),
@@ -2235,9 +2905,100 @@ export function track(input: TrackInput): TrackSpec {
2235
2905
  ...trackTime(input.time, name),
2236
2906
  ...trackPerformance(input, name),
2237
2907
  tuning: tuningSpec(input.tuning, `track ${name}`),
2908
+ ...keysSpec(input.keys, rawInstrument, name),
2909
+ ...(modalSpec ? { modal: modalSpec } : {}),
2238
2910
  });
2239
2911
  }
2240
2912
 
2913
+ /**
2914
+ * Effects the keys presets set with the voice: a copy of `KEYS_PRESETS`
2915
+ * filter, fx and reverb in core/keys.ts (core/keys.test.ts checks they
2916
+ * match the prompt's `piano <preset>`).
2917
+ */
2918
+ const KEYS_PRESET_FX: Readonly<
2919
+ Record<
2920
+ string,
2921
+ Readonly<{
2922
+ filter?: FilterInput;
2923
+ fx?: FxInput;
2924
+ reverb?: ReverbInput;
2925
+ }>
2926
+ >
2927
+ > = Object.freeze({
2928
+ ballad: { reverb: { mix: 0.25, size: 0.7 } },
2929
+ felt: { reverb: { mix: 0.2, size: 0.5 } },
2930
+ lofi: {
2931
+ filter: { cutoff: 3500, resonance: 0.1 },
2932
+ fx: { crush: { bits: 10 } },
2933
+ },
2934
+ });
2935
+
2936
+ /**
2937
+ * The preset effects an instrument word brings: a preset word that is not
2938
+ * also its family (`"lofi"`, `"ballad"`), or any preset word without
2939
+ * `keys` (`"felt"`). A printed track names its family and always prints
2940
+ * `keys`, so print → eval never adds them twice.
2941
+ */
2942
+ function keysPresetFx(
2943
+ keys: unknown,
2944
+ word: unknown,
2945
+ ): (typeof KEYS_PRESET_FX)[string] | undefined {
2946
+ if (typeof word !== "string") return undefined;
2947
+ const meaning = resolveInstrumentWord(word);
2948
+ if (meaning?.field !== "keys" || !meaning.preset) return undefined;
2949
+ if (keys !== undefined && keys !== null && word === meaning.instrument)
2950
+ return undefined;
2951
+ return KEYS_PRESET_FX[meaning.preset];
2952
+ }
2953
+
2954
+ /**
2955
+ * `keys` for `track()`: the input as given, or `{ preset }` when the
2956
+ * instrument word names a keys preset (`"grand"`, `"lofi"`), so the word
2957
+ * alone plays the modelled piano. dawg validates the values.
2958
+ */
2959
+ function keysSpec(
2960
+ input: unknown,
2961
+ word: unknown,
2962
+ name: string,
2963
+ ): { keys?: KeysInput } {
2964
+ const meaning =
2965
+ typeof word === "string" ? resolveInstrumentWord(word) : undefined;
2966
+ const preset = meaning?.field === "keys" ? meaning.preset : undefined;
2967
+ if (input === undefined || input === null)
2968
+ return preset ? { keys: Object.freeze({ preset }) } : {};
2969
+ if (!isRecord(input))
2970
+ throw new DawgSdkError(`track ${name}: keys must be an object`);
2971
+ const out: Record<string, EffectValue> = {};
2972
+ // A preset word (`"lofi"`) keeps its preset under given overrides; a
2973
+ // family word (`"upright"`) with `keys` is exactly the given keys.
2974
+ if (preset && input.preset === undefined && word !== meaning?.instrument)
2975
+ out.preset = preset;
2976
+ for (const [key, value] of Object.entries(input)) {
2977
+ if (value === undefined) continue;
2978
+ out[key] = effectValue(value, `${name} keys.${key}`);
2979
+ }
2980
+ return { keys: Object.freeze(out) };
2981
+ }
2982
+
2983
+ /**
2984
+ * The modal field an instrument makes: `modal(...)`, or a modal preset
2985
+ * word (`"vibes"`, `"glockenspiel"`). The bare words `"modal"` and
2986
+ * `"marimba"` keep their pre-0.6 meaning and make none.
2987
+ */
2988
+ function trackModal(
2989
+ raw: unknown,
2990
+ ): Readonly<{ preset?: ModalPresetName } & ModalParams> | undefined {
2991
+ if (isRecord(raw) && raw.kind === "modal") {
2992
+ const { kind: _kind, ...fields } = raw as ModalSpec;
2993
+ return Object.freeze(fields);
2994
+ }
2995
+ if (typeof raw !== "string" || raw === MODAL_INSTRUMENT) return undefined;
2996
+ const meaning = resolveInstrumentWord(raw);
2997
+ if (meaning?.instrument !== MODAL_INSTRUMENT || !meaning.preset)
2998
+ return undefined;
2999
+ return Object.freeze({ preset: meaning.preset as ModalPresetName });
3000
+ }
3001
+
2241
3002
  function trackTime(input: unknown, name: string): { time?: TrackTimeInput } {
2242
3003
  if (input === undefined || input === null) return {};
2243
3004
  if (!isRecord(input))
@@ -2348,6 +3109,21 @@ function fxInput(input: unknown, name: string): FxInput | null {
2348
3109
  return Object.keys(out).length > 0 ? Object.freeze(out) : null;
2349
3110
  }
2350
3111
 
3112
+ function stringInput(input: unknown, name: string): StringInput | null {
3113
+ if (input === undefined || input === null) return null;
3114
+ if (!isRecord(input))
3115
+ throw new DawgSdkError(`track ${name}: string must be an object`);
3116
+ const out: Record<string, number | string> = {};
3117
+ for (const [key, value] of Object.entries(input)) {
3118
+ if (value === undefined || key === "kind") continue;
3119
+ out[key] =
3120
+ typeof value === "string"
3121
+ ? value
3122
+ : finite(value, `${name} string.${key}`);
3123
+ }
3124
+ return Object.freeze(out);
3125
+ }
3126
+
2351
3127
  function synthInput(input: unknown, name: string): SynthInput | null {
2352
3128
  if (input === undefined || input === null) return null;
2353
3129
  if (!isRecord(input))
@@ -2433,7 +3209,7 @@ function reverbIr(
2433
3209
  throw new DawgSdkError(`track ${name}: reverb.ir needs a src`);
2434
3210
  const src = spec.src.trim().replace(/^\.\//, "");
2435
3211
  if (src.startsWith("pack:")) {
2436
- const ref = sample(spec as SampleSpec, `${name} reverb.ir`);
3212
+ const ref = sampleSpec(spec as SampleSpec, `${name} reverb.ir`);
2437
3213
  return Object.freeze({
2438
3214
  src: ref.src,
2439
3215
  ...(ref.sha256 ? { sha256: ref.sha256 } : {}),
@@ -2445,6 +3221,41 @@ function reverbIr(
2445
3221
  return src.startsWith("tracks/") ? src : `tracks/${slug}/${src}`;
2446
3222
  }
2447
3223
 
3224
+ /** Validates `granular` input and localizes a sample source like a voice. */
3225
+ function granularInput(
3226
+ input: unknown,
3227
+ name: string,
3228
+ slug: string,
3229
+ ): GranularInput | null {
3230
+ if (input === undefined || input === null) return null;
3231
+ if (!isRecord(input))
3232
+ throw new DawgSdkError(`track ${name}: granular must be an object`);
3233
+ const out: Record<string, unknown> = {};
3234
+ for (const [key, value] of Object.entries(input)) {
3235
+ if (value === undefined || key === "kind") continue;
3236
+ if (key === "src" && isRecord(value)) {
3237
+ const ref = sampleSpec(value as SampleSpec, `${name} granular src`);
3238
+ const src = ref.src.replace(/^\.\//, "");
3239
+ out.src = Object.freeze({
3240
+ ...ref,
3241
+ src:
3242
+ src.startsWith("tracks/") || src.startsWith("pack:")
3243
+ ? src
3244
+ : `tracks/${slug}/${src}`,
3245
+ });
3246
+ } else if (key === "src" && typeof value === "string")
3247
+ out.src = value.startsWith("synth:")
3248
+ ? value
3249
+ : granularInput({ src: { src: value } }, name, slug)!.src;
3250
+ else if (typeof value === "number")
3251
+ out[key] = finite(value, `${name} granular.${key}`);
3252
+ else if (typeof value === "string" || typeof value === "boolean")
3253
+ out[key] = value;
3254
+ else throw new DawgSdkError(`${name} granular.${key} must be a value`);
3255
+ }
3256
+ return Object.freeze(out) as GranularInput;
3257
+ }
3258
+
2448
3259
  /** `./wavetables/x.wav` → `tracks/<slug>/wavetables/x.wav`, like sampler files. */
2449
3260
  function localizeWavetable(spec: WavetableSpec, slug: string): WavetableSpec {
2450
3261
  const src = spec.table.src;
@@ -2617,6 +3428,9 @@ export type ScoreSampleRef = Readonly<{
2617
3428
  fit?: boolean;
2618
3429
  accelerate?: number;
2619
3430
  squiz?: number;
3431
+ bpm?: number;
3432
+ fitmode?: "repitch" | "beats" | "tones";
3433
+ len?: number;
2620
3434
  }>;
2621
3435
 
2622
3436
  /** A stored track; optional fields are present only when set. */
@@ -2640,6 +3454,7 @@ export type ScoreTrack = Readonly<{
2640
3454
  fx?: FxInput;
2641
3455
  fxAutomation?: Readonly<Record<string, readonly ScorePoint[]>>;
2642
3456
  synth?: SynthInput;
3457
+ keys?: KeysInput;
2643
3458
  sampler?: Readonly<{
2644
3459
  voices: Readonly<Record<string, ScoreSampleRef>>;
2645
3460
  mode: "oneshot" | "keyed";
@@ -2659,6 +3474,12 @@ export type ScoreTrack = Readonly<{
2659
3474
  tuning?: ScoreTuning;
2660
3475
  wavetable?: Readonly<{ table: ScoreSampleRef } & WavetableParams>;
2661
3476
  wtAutomation?: readonly ScorePoint[];
3477
+ /** String engine settings; dawg validates them (SDK 1.21.0). */
3478
+ string?: StringInput;
3479
+ /** Granular settings (SDK 1.23.0); present only when set. */
3480
+ granular?: GranularInput;
3481
+ /** Modal settings (SDK 1.25.0). */
3482
+ modal?: TrackSpec["modal"];
2662
3483
  glide?: TrackSpec["glide"];
2663
3484
  pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
2664
3485
  velocityCurve?: TrackSpec["velocityCurve"];
@@ -2926,6 +3747,7 @@ export function song(input: SongInput): Song {
2926
3747
  stored.wavetable = Object.freeze(fields);
2927
3748
  }
2928
3749
  if (wtAutomation.length > 0) stored.wtAutomation = wtAutomation;
3750
+ if (t.granular) stored.granular = t.granular;
2929
3751
  if (t.sampler)
2930
3752
  stored.sampler = Object.freeze({
2931
3753
  voices: t.sampler.voices,
@@ -2962,6 +3784,9 @@ export function song(input: SongInput): Song {
2962
3784
  if (t.velocityCurve) stored.velocityCurve = t.velocityCurve;
2963
3785
  if (t.humanize) stored.humanize = t.humanize;
2964
3786
  if (t.tuning) stored.tuning = t.tuning;
3787
+ if (t.string) stored.string = t.string;
3788
+ if (t.keys) stored.keys = t.keys;
3789
+ if (t.modal) stored.modal = t.modal;
2965
3790
  if (t.rhythm && t.rhythm.length > 0)
2966
3791
  stored.rhythm = Object.freeze(
2967
3792
  t.rhythm.map((row) => {
@@ -3834,6 +4659,274 @@ export function progression(
3834
4659
  );
3835
4660
  }
3836
4661
 
4662
+ // BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts
4663
+ /** What an instrument word stores on a track. */
4664
+ type InstrumentWord = Readonly<{
4665
+ /** The `Track.instrument` value. */
4666
+ instrument: string;
4667
+ /** The optional Track field the engine reads (created with defaults). */
4668
+ field?: string;
4669
+ /** A preset of that engine to apply. */
4670
+ preset?: string;
4671
+ /** An insert-effect preset to apply with it (rig aliases). */
4672
+ fx?: string;
4673
+ }>;
4674
+
4675
+ /** A word and what it means. */
4676
+ type InstrumentWordRow = Readonly<{
4677
+ word: string;
4678
+ /**
4679
+ * Borrow the voice (instrument, field, preset) of this other word's row
4680
+ * when it exists; `instrument` is the fallback when it does not.
4681
+ */
4682
+ voice?: string;
4683
+ }> &
4684
+ InstrumentWord;
4685
+
4686
+ /**
4687
+ * Words that keep their pre-0.6 meaning forever: they resolve to
4688
+ * themselves, whatever rows the lanes add.
4689
+ */
4690
+ const LEGACY_WORDS: readonly string[] = Object.freeze([
4691
+ "piano",
4692
+ "pluck",
4693
+ "bass",
4694
+ "saw",
4695
+ "square",
4696
+ "triangle",
4697
+ "marimba",
4698
+ "wind",
4699
+ "cello",
4700
+ "contrabass",
4701
+ "ebass",
4702
+ "sitar",
4703
+ "organ",
4704
+ "strings",
4705
+ "bell",
4706
+ "keys",
4707
+ "lead",
4708
+ ]);
4709
+
4710
+ /** 0.6 instrument words; each lane appends its own block. */
4711
+ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
4712
+ // strings (f06-strings): plucked presets of the string engine. Legacy
4713
+ // sitar/ebass keep today's voice (`string preset sitar` reaches the
4714
+ // engine), jangle is the rig alias (the guitar lane maps its 12string to the
4715
+ // preset; `string jangle` reaches it) and
4716
+ // upright is the keys lane's piano (doublebass reaches the preset).
4717
+ { word: "nylon", instrument: "string", field: "string", preset: "nylon" },
4718
+ { word: "steel", instrument: "string", field: "string", preset: "steel" },
4719
+ {
4720
+ word: "electric",
4721
+ instrument: "string",
4722
+ field: "string",
4723
+ preset: "electric",
4724
+ },
4725
+ { word: "slap", instrument: "string", field: "string", preset: "slap" },
4726
+ { word: "motown", instrument: "string", field: "string", preset: "motown" },
4727
+ { word: "tanpura", instrument: "string", field: "string", preset: "tanpura" },
4728
+ {
4729
+ word: "harpsichord",
4730
+ instrument: "string",
4731
+ field: "string",
4732
+ preset: "harpsichord",
4733
+ },
4734
+ { word: "lute", instrument: "string", field: "string", preset: "lute" },
4735
+ { word: "oud", instrument: "string", field: "string", preset: "oud" },
4736
+ { word: "setar", instrument: "string", field: "string", preset: "setar" },
4737
+ { word: "tar", instrument: "string", field: "string", preset: "tar" },
4738
+ { word: "santur", instrument: "string", field: "string", preset: "santur" },
4739
+ {
4740
+ word: "dulcimer",
4741
+ instrument: "string",
4742
+ field: "string",
4743
+ preset: "dulcimer",
4744
+ },
4745
+ { word: "koto", instrument: "string", field: "string", preset: "koto" },
4746
+ { word: "harp", instrument: "string", field: "string", preset: "harp" },
4747
+ { word: "banjo", instrument: "string", field: "string", preset: "banjo" },
4748
+ { word: "tres", instrument: "string", field: "string", preset: "tres" },
4749
+ {
4750
+ word: "requinto",
4751
+ instrument: "string",
4752
+ field: "string",
4753
+ preset: "requinto",
4754
+ },
4755
+ { word: "acoustic", instrument: "string", field: "string", preset: "steel" },
4756
+ { word: "classical", instrument: "string", field: "string", preset: "nylon" },
4757
+ {
4758
+ word: "bassguitar",
4759
+ instrument: "string",
4760
+ field: "string",
4761
+ preset: "ebass",
4762
+ },
4763
+ { word: "fender", instrument: "string", field: "string", preset: "ebass" },
4764
+ {
4765
+ word: "doublebass",
4766
+ instrument: "string",
4767
+ field: "string",
4768
+ preset: "upright",
4769
+ },
4770
+ {
4771
+ word: "cembalo",
4772
+ instrument: "string",
4773
+ field: "string",
4774
+ preset: "harpsichord",
4775
+ },
4776
+ {
4777
+ word: "hammered",
4778
+ instrument: "string",
4779
+ field: "string",
4780
+ preset: "dulcimer",
4781
+ },
4782
+ { word: "sehtar", instrument: "string", field: "string", preset: "setar" },
4783
+ // f06-rig: guitar track aliases, a guitar voice plus a whole rig. The
4784
+ // voice is the strings lane's `electric` row (jangle: its 12-string
4785
+ // `jangle` preset); the pluck only while that row is absent. Never `lead`
4786
+ // or `bass`.
4787
+ {
4788
+ word: "jangle",
4789
+ instrument: "string",
4790
+ field: "string",
4791
+ preset: "jangle",
4792
+ fx: "jangle",
4793
+ },
4794
+ { word: "punk", instrument: "pluck", voice: "electric", fx: "punk" },
4795
+ { word: "funk", instrument: "pluck", voice: "electric", fx: "funk" },
4796
+ { word: "ragged", instrument: "pluck", voice: "electric", fx: "ragged" },
4797
+ { word: "gtr-lead", instrument: "pluck", voice: "electric", fx: "lead" },
4798
+ { word: "gtr-metal", instrument: "pluck", voice: "electric", fx: "metal" },
4799
+ { word: "bachata", instrument: "pluck", voice: "electric", fx: "bachata" },
4800
+ // granular (f06-granular): the instrument and its texture presets. Each
4801
+ // starts from a built-in synth source, so nothing downloads.
4802
+ {
4803
+ word: "granular",
4804
+ instrument: "granular",
4805
+ field: "granular",
4806
+ preset: "cloud",
4807
+ },
4808
+ {
4809
+ word: "grains",
4810
+ instrument: "granular",
4811
+ field: "granular",
4812
+ preset: "cloud",
4813
+ },
4814
+ { word: "cloud", instrument: "granular", field: "granular", preset: "cloud" },
4815
+ {
4816
+ word: "sparkle",
4817
+ instrument: "granular",
4818
+ field: "granular",
4819
+ preset: "sparkle",
4820
+ },
4821
+ { word: "swarm", instrument: "granular", field: "granular", preset: "swarm" },
4822
+ {
4823
+ word: "microloop",
4824
+ instrument: "granular",
4825
+ field: "granular",
4826
+ preset: "microloop",
4827
+ },
4828
+ // keys (f06-piano): modelled pianos. `piano` stays legacy here; the typed
4829
+ // surfaces store a new `piano` as `grand` (core/keys.ts `pianoWrite`).
4830
+ { word: "grand", instrument: "grand", field: "keys", preset: "grand" },
4831
+ { word: "ballad", instrument: "grand", field: "keys", preset: "ballad" },
4832
+ { word: "upright", instrument: "upright", field: "keys", preset: "upright" },
4833
+ { word: "felt", instrument: "felt", field: "keys", preset: "felt" },
4834
+ { word: "lofi", instrument: "felt", field: "keys", preset: "lofi" },
4835
+ {
4836
+ word: "honkytonk",
4837
+ instrument: "honkytonk",
4838
+ field: "keys",
4839
+ preset: "honkytonk",
4840
+ },
4841
+ {
4842
+ word: "prepared",
4843
+ instrument: "prepared",
4844
+ field: "keys",
4845
+ preset: "prepared",
4846
+ },
4847
+ // f06-modal: mallets and bells (core/resonators.ts). `marimba` is legacy;
4848
+ // `modal` alone gives the modal marimba.
4849
+ { word: "modal", instrument: "modal", field: "modal", preset: "marimba" },
4850
+ { word: "vibes", instrument: "modal", field: "modal", preset: "vibes" },
4851
+ { word: "vibraphone", instrument: "modal", field: "modal", preset: "vibes" },
4852
+ {
4853
+ word: "xylophone",
4854
+ instrument: "modal",
4855
+ field: "modal",
4856
+ preset: "xylophone",
4857
+ },
4858
+ { word: "glock", instrument: "modal", field: "modal", preset: "glock" },
4859
+ {
4860
+ word: "glockenspiel",
4861
+ instrument: "modal",
4862
+ field: "modal",
4863
+ preset: "glock",
4864
+ },
4865
+ { word: "celesta", instrument: "modal", field: "modal", preset: "celesta" },
4866
+ { word: "chimes", instrument: "modal", field: "modal", preset: "chimes" },
4867
+ { word: "tubular", instrument: "modal", field: "modal", preset: "chimes" },
4868
+ { word: "kalimba", instrument: "modal", field: "modal", preset: "kalimba" },
4869
+ {
4870
+ word: "thumbpiano",
4871
+ instrument: "modal",
4872
+ field: "modal",
4873
+ preset: "kalimba",
4874
+ },
4875
+ { word: "mbira", instrument: "modal", field: "modal", preset: "mbira" },
4876
+ { word: "steelpan", instrument: "modal", field: "modal", preset: "steelpan" },
4877
+ { word: "bowl", instrument: "modal", field: "modal", preset: "bowl" },
4878
+ { word: "gong", instrument: "modal", field: "modal", preset: "gong" },
4879
+ { word: "gongageng", instrument: "modal", field: "modal", preset: "gong" },
4880
+ { word: "timpani", instrument: "modal", field: "modal", preset: "timpani" },
4881
+ {
4882
+ word: "steeldrum",
4883
+ instrument: "modal",
4884
+ field: "modal",
4885
+ preset: "steelpan",
4886
+ },
4887
+ { word: "singingbowl", instrument: "modal", field: "modal", preset: "bowl" },
4888
+ {
4889
+ word: "kettledrum",
4890
+ instrument: "modal",
4891
+ field: "modal",
4892
+ preset: "timpani",
4893
+ },
4894
+ {
4895
+ word: "tubularbells",
4896
+ instrument: "modal",
4897
+ field: "modal",
4898
+ preset: "chimes",
4899
+ },
4900
+ ]);
4901
+
4902
+ /**
4903
+ * What an instrument word means: a legacy word is itself, a row word is its
4904
+ * row, anything else is undefined (callers keep the word as typed).
4905
+ */
4906
+ function resolveInstrumentWord(word: string): InstrumentWord | undefined {
4907
+ if (LEGACY_WORDS.includes(word)) return Object.freeze({ instrument: word });
4908
+ const row = INSTRUMENT_WORDS.find((entry) => entry.word === word);
4909
+ if (!row) return undefined;
4910
+ const { word: _word, voice, ...meaning } = row;
4911
+ const borrowed =
4912
+ voice === undefined
4913
+ ? undefined
4914
+ : INSTRUMENT_WORDS.find((entry) => entry.word === voice && !entry.voice);
4915
+ if (!borrowed) return Object.freeze(meaning);
4916
+ return Object.freeze({
4917
+ instrument: borrowed.instrument,
4918
+ ...(borrowed.field === undefined ? {} : { field: borrowed.field }),
4919
+ ...(borrowed.preset === undefined ? {} : { preset: borrowed.preset }),
4920
+ ...(meaning.fx === undefined ? {} : { fx: meaning.fx }),
4921
+ });
4922
+ }
4923
+
4924
+ /** The `Track.instrument` value a word stores (the word itself if unknown). */
4925
+ function instrumentForWord(word: string): string {
4926
+ return resolveInstrumentWord(word)?.instrument ?? word;
4927
+ }
4928
+ // END instrument words
4929
+
3837
4930
  // BEGIN chord engine: generated from core/chords.ts by core/sdk/sync-chords.ts
3838
4931
  // ---------------------------------------------------------------------------
3839
4932
  // Vocabulary