@hraness/dawg 0.6.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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. 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.25.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
 
@@ -1283,6 +1283,30 @@ export type SampleSpec = Readonly<{
1283
1283
  fitmode?: "repitch" | "beats" | "tones";
1284
1284
  /** Window length in beats (SDK 1.20.0); `fit` wins over `bpm`, `bpm` over `len`. */
1285
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;
1286
1310
  }>;
1287
1311
 
1288
1312
  /** Result of `sampler()`; pass it as a track's `instrument`. */
@@ -1446,9 +1470,13 @@ export const STRING_INSTRUMENT = "string";
1446
1470
  * String engine settings (SDK 1.21.0): a `preset` (`nylon`, `steel`,
1447
1471
  * `electric`, `jangle`, `ebass`, `slap`, `upright`, `sitar`, `tanpura`,
1448
1472
  * `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.
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.
1452
1480
  */
1453
1481
  export type StringInput = Readonly<
1454
1482
  { preset?: string } & Record<string, number | string | undefined>
@@ -1458,15 +1486,24 @@ export type StringInput = Readonly<
1458
1486
  export type StringSpec = Readonly<{ kind: "string" } & StringInput>;
1459
1487
 
1460
1488
  /**
1461
- * A plucked string instrument (SDK 1.21.0): a preset and overrides.
1489
+ * A plucked or bowed string instrument (SDK 1.21.0; bowed and the object
1490
+ * form 1.31.0): a preset and overrides.
1462
1491
  *
1463
1492
  * instrument: stringed("nylon")
1464
1493
  * instrument: stringed("sitar", { buzz: 0.8, sym: 0.5 })
1494
+ * instrument: stringed({ preset: "cello", vib: 6 })
1465
1495
  */
1466
1496
  export function stringed(
1467
- preset = "nylon",
1497
+ preset: string | StringInput = "nylon",
1468
1498
  params: Readonly<Record<string, number | string>> = {},
1469
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
+ }
1470
1507
  if (typeof preset !== "string" || preset.length === 0)
1471
1508
  throw new DawgSdkError("stringed needs a preset name");
1472
1509
  if (!isRecord(params))
@@ -1567,7 +1604,23 @@ export type ModalPresetName =
1567
1604
  | "steelpan"
1568
1605
  | "bowl"
1569
1606
  | "gong"
1570
- | "timpani";
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";
1571
1624
 
1572
1625
  /** Modal overrides; omitted means the preset's value. dawg validates ranges. */
1573
1626
  export type ModalParams = Readonly<{
@@ -1600,6 +1653,12 @@ export type ModalParams = Readonly<{
1600
1653
  gain?: number;
1601
1654
  /** Mode table override (`marimba`, `bell`, `gong`, …). */
1602
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;
1603
1662
  }>;
1604
1663
 
1605
1664
  const MODAL_KEYS = Object.freeze([
@@ -1675,6 +1734,9 @@ const MODAL_RANGES: Readonly<Record<string, readonly [number, number]>> =
1675
1734
  gain: [0, 2],
1676
1735
  });
1677
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
+
1678
1740
  const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
1679
1741
  "marimba",
1680
1742
  "vibes",
@@ -1688,6 +1750,21 @@ const MODAL_PRESET_WORDS: readonly string[] = Object.freeze([
1688
1750
  "bowl",
1689
1751
  "gong",
1690
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",
1691
1768
  ]);
1692
1769
 
1693
1770
  /** Result of `modal()`; pass it as a track's `instrument`. */
@@ -1733,6 +1810,10 @@ export function modal(
1733
1810
  `modal ${key} "${String(value).slice(0, 32)}" is not one of ${words.join(" ")}`,
1734
1811
  );
1735
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;
1736
1817
  } else if ((MODAL_KEYS as readonly string[]).includes(key)) {
1737
1818
  const number = finite(value, `modal ${key}`);
1738
1819
  const [min, max] = MODAL_RANGES[key]!;
@@ -1741,12 +1822,192 @@ export function modal(
1741
1822
  out[key] = number;
1742
1823
  } else
1743
1824
  throw new DawgSdkError(
1744
- `modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")})`,
1825
+ `modal has no parameter "${key.slice(0, 32)}" (${MODAL_KEYS.join(" ")} pair)`,
1745
1826
  );
1746
1827
  }
1747
1828
  return Object.freeze(out) as ModalSpec;
1748
1829
  }
1749
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
+
1750
2011
  /**
1751
2012
  * `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
1752
2013
  * chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
@@ -1800,6 +2061,13 @@ function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
1800
2061
  bpm?: number;
1801
2062
  fitmode?: "repitch" | "beats" | "tones";
1802
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;
1803
2071
  } = { src: spec.src };
1804
2072
  if (spec.src.startsWith("pack:")) {
1805
2073
  if (spec.sha256 !== undefined)
@@ -1862,6 +2130,27 @@ function sampleSpec(value: string | SampleSpec, name: string): SampleSpec {
1862
2130
  out.fitmode = spec.fitmode;
1863
2131
  }
1864
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`);
1865
2154
  return Object.freeze(out);
1866
2155
  }
1867
2156
 
@@ -2018,14 +2307,85 @@ export const RIG_PRESETS: Readonly<
2018
2307
  head: { type: "chime", gain: 3, treble: 7 },
2019
2308
  cab: { type: "2x12", mic: 0.2 },
2020
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.
2021
2313
  },
2022
2314
  alt: {
2023
2315
  stomp: { type: "rat", gain: 6, tone: 0.4 },
2024
2316
  head: { type: "crunch", gain: 4 },
2025
2317
  cab: { type: "4x12" },
2026
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
+ },
2027
2359
  });
2028
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
+
2029
2389
  /**
2030
2390
  * A guitar rig for a track's `fx` (SDK 1.22.0): the stomp → head → cab
2031
2391
  * stages of rig preset `name` and its companion effects (as the `rig`
@@ -2093,15 +2453,21 @@ export type FxInput = Readonly<Record<string, EffectParams>>;
2093
2453
  * Every parameter, range and default: **Synth** in DAWG.md.
2094
2454
  */
2095
2455
  /**
2096
- * Modelled piano settings (SDK 1.24.0), for a track whose instrument is a
2456
+ * Modelled keys settings (SDK 1.24.0), for a track whose instrument is a
2097
2457
  * 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
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
2100
2462
  * `automation.fx["keys-<param>"]` (hardness, touch, decay, release, knock,
2101
- * noise, felt), read at each note's onset. Ranges: **Keys** in DAWG.md.
2463
+ * noise, felt; electric tone, vibe, trem), read at each note's onset.
2464
+ * Ranges: **Keys** in DAWG.md.
2102
2465
  */
2103
2466
  export type KeysInput = Readonly<{
2104
- /** A named preset: grand ballad upright felt lofi honkytonk prepared. */
2467
+ /**
2468
+ * A named preset: grand ballad upright felt lofi honkytonk prepared, or
2469
+ * electric epiano suitcase dyno wurli clav funkclav.
2470
+ */
2105
2471
  preset?: string;
2106
2472
  /** Hammer hardness 0..1: brightness at a given velocity (0.5). */
2107
2473
  hardness?: number;
@@ -2137,6 +2503,53 @@ export type KeysInput = Readonly<{
2137
2503
  vib?: number;
2138
2504
  /** Pitch wobble depth in semitones (0.5). */
2139
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;
2140
2553
  }>;
2141
2554
 
2142
2555
  export type SynthInput = Readonly<{
@@ -2231,7 +2644,8 @@ export type TrackInput = Readonly<{
2231
2644
  | WavetableSpec
2232
2645
  | StringSpec
2233
2646
  | GranularSpec
2234
- | ModalSpec;
2647
+ | ModalSpec
2648
+ | WindSpec;
2235
2649
  /**
2236
2650
  * The sampler a `granular(...)` track keeps while it grains one of its
2237
2651
  * voices (SDK 1.23.0); `grain off` plays it again.
@@ -2302,6 +2716,16 @@ export type TrackInput = Readonly<{
2302
2716
  glide?: number | Readonly<{ time?: number; mode?: GlideMode }>;
2303
2717
  /** Sustain pedal changes as `[beat, "down" | "half" | "up"]` (SDK 1.15.0). */
2304
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"])[];
2305
2729
  /**
2306
2730
  * Velocity response (SDK 1.15.0): `soft` (quiet notes louder), `hard`
2307
2731
  * (needs a firm touch), `fixed` (every note at 0.8, like an organ) or
@@ -2320,6 +2744,12 @@ export type TrackInput = Readonly<{
2320
2744
  length?: number;
2321
2745
  seed?: number;
2322
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;
2323
2753
  /** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
2324
2754
  notes?: readonly (NoteSpec | HitSpec)[];
2325
2755
  /**
@@ -2430,6 +2860,9 @@ export type TrackSpec = Readonly<{
2430
2860
  /** Performance (SDK 1.15.0); present only when set. */
2431
2861
  glide?: Readonly<{ time: number; mode: GlideMode }>;
2432
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])[];
2433
2866
  velocityCurve?: Readonly<{
2434
2867
  curve: Exclude<VelocityCurveName, "linear">;
2435
2868
  fixed?: number;
@@ -2445,6 +2878,10 @@ export type TrackSpec = Readonly<{
2445
2878
  keys?: KeysInput;
2446
2879
  /** Modal settings (SDK 1.25.0); present only on a modal track. */
2447
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>;
2448
2885
  }>;
2449
2886
 
2450
2887
  export type GlideMode = "legato" | "mono" | "poly";
@@ -2458,10 +2895,17 @@ const DEFAULT_FIXED_VELOCITY = 0.8;
2458
2895
  function trackPerformance(
2459
2896
  input: TrackInput,
2460
2897
  name: string,
2461
- ): Partial<Pick<TrackSpec, "glide" | "pedal" | "velocityCurve" | "humanize">> {
2898
+ ): Partial<
2899
+ Pick<
2900
+ TrackSpec,
2901
+ "glide" | "pedal" | "softPedal" | "sostenuto" | "velocityCurve" | "humanize"
2902
+ >
2903
+ > {
2462
2904
  const out: {
2463
2905
  glide?: TrackSpec["glide"];
2464
2906
  pedal?: TrackSpec["pedal"];
2907
+ softPedal?: TrackSpec["softPedal"];
2908
+ sostenuto?: TrackSpec["sostenuto"];
2465
2909
  velocityCurve?: TrackSpec["velocityCurve"];
2466
2910
  humanize?: TrackSpec["humanize"];
2467
2911
  } = {};
@@ -2482,29 +2926,38 @@ function trackPerformance(
2482
2926
  mode,
2483
2927
  });
2484
2928
  }
2485
- if (input.pedal !== undefined) {
2486
- 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)
2487
2937
  throw new DawgSdkError(
2488
- `track ${name}: pedal must be at most 1024 [beat, "down" | "half" | "up"] events`,
2938
+ `track ${name}: ${key} must be at most 1024 ${shape} events`,
2489
2939
  );
2490
- if (input.pedal.length > 0)
2491
- out.pedal = Object.freeze(
2492
- input.pedal.map((event: unknown, index: number) => {
2940
+ if (value.length > 0)
2941
+ out[key] = Object.freeze(
2942
+ value.map((event: unknown, index: number) => {
2493
2943
  if (
2494
2944
  !Array.isArray(event) ||
2495
2945
  event.length !== 2 ||
2496
- !["down", "half", "up"].includes(event[1] as string)
2946
+ !states.includes(event[1] as PedalState)
2497
2947
  )
2498
2948
  throw new DawgSdkError(
2499
- `track ${name}: pedal[${index}] must be [beat, "down" | "half" | "up"]`,
2949
+ `track ${name}: ${key}[${index}] must be ${shape}`,
2500
2950
  );
2501
2951
  return Object.freeze([
2502
- beat(event[0], `${name} pedal[${index}] beat`),
2952
+ beat(event[0], `${name} ${key}[${index}] beat`),
2503
2953
  event[1] as PedalState,
2504
2954
  ] as const);
2505
2955
  }),
2506
2956
  );
2507
- }
2957
+ };
2958
+ pedalLane("pedal", ["down", "half", "up"]);
2959
+ pedalLane("softPedal", ["down", "half", "up"]);
2960
+ pedalLane("sostenuto", ["down", "up"]);
2508
2961
  if (input.velocityCurve !== undefined) {
2509
2962
  const raw =
2510
2963
  typeof input.velocityCurve === "string"
@@ -2679,6 +3132,8 @@ export function track(input: TrackInput): TrackSpec {
2679
3132
  ? resolveInstrumentWord(rawInstrument)
2680
3133
  : undefined;
2681
3134
  const modalSpec = trackModal(rawInstrument);
3135
+ const guitarSpec = guitarInput(input.guitar, `track ${name}`);
3136
+ const windSpec = trackWind(rawInstrument);
2682
3137
  const instrument = granularFromInstrument
2683
3138
  ? GRANULAR_INSTRUMENT
2684
3139
  : samplerSpec
@@ -2689,9 +3144,11 @@ export function track(input: TrackInput): TrackSpec {
2689
3144
  ? STRING_INSTRUMENT
2690
3145
  : modalSpec
2691
3146
  ? MODAL_INSTRUMENT
2692
- : typeof rawInstrument === "string"
2693
- ? (word?.instrument ?? rawInstrument)
2694
- : undefined;
3147
+ : windSpec
3148
+ ? WIND_INSTRUMENT
3149
+ : typeof rawInstrument === "string"
3150
+ ? (word?.instrument ?? rawInstrument)
3151
+ : undefined;
2695
3152
  // A granular word (`"cloud"`) turns the engine on with its preset.
2696
3153
  const granularSpec =
2697
3154
  granularInput(input.granular, name, slug) ??
@@ -2835,6 +3292,13 @@ export function track(input: TrackInput): TrackSpec {
2835
3292
  `${name} delay`,
2836
3293
  ),
2837
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
+ }
2838
3302
  const reverb =
2839
3303
  input.reverb === undefined || input.reverb === null
2840
3304
  ? null
@@ -2907,6 +3371,8 @@ export function track(input: TrackInput): TrackSpec {
2907
3371
  tuning: tuningSpec(input.tuning, `track ${name}`),
2908
3372
  ...keysSpec(input.keys, rawInstrument, name),
2909
3373
  ...(modalSpec ? { modal: modalSpec } : {}),
3374
+ ...(guitarSpec ? { guitar: guitarSpec } : {}),
3375
+ ...(windSpec ? { wind: windSpec } : {}),
2910
3376
  });
2911
3377
  }
2912
3378
 
@@ -2931,6 +3397,12 @@ const KEYS_PRESET_FX: Readonly<
2931
3397
  filter: { cutoff: 3500, resonance: 0.1 },
2932
3398
  fx: { crush: { bits: 10 } },
2933
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 } },
2934
3406
  });
2935
3407
 
2936
3408
  /**
@@ -2975,6 +3447,13 @@ function keysSpec(
2975
3447
  out.preset = preset;
2976
3448
  for (const [key, value] of Object.entries(input)) {
2977
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
+ }
2978
3457
  out[key] = effectValue(value, `${name} keys.${key}`);
2979
3458
  }
2980
3459
  return { keys: Object.freeze(out) };
@@ -3431,6 +3910,11 @@ export type ScoreSampleRef = Readonly<{
3431
3910
  bpm?: number;
3432
3911
  fitmode?: "repitch" | "beats" | "tones";
3433
3912
  len?: number;
3913
+ shift?: number;
3914
+ formant?: number;
3915
+ fadeTime?: number;
3916
+ fadeInTime?: number;
3917
+ from?: SampleProvenanceSpec;
3434
3918
  }>;
3435
3919
 
3436
3920
  /** A stored track; optional fields are present only when set. */
@@ -3480,8 +3964,14 @@ export type ScoreTrack = Readonly<{
3480
3964
  granular?: GranularInput;
3481
3965
  /** Modal settings (SDK 1.25.0). */
3482
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"];
3483
3971
  glide?: TrackSpec["glide"];
3484
3972
  pedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
3973
+ softPedal?: readonly Readonly<{ tick: number; state: PedalState }>[];
3974
+ sostenuto?: readonly Readonly<{ tick: number; state: PedalState }>[];
3485
3975
  velocityCurve?: TrackSpec["velocityCurve"];
3486
3976
  humanize?: TrackSpec["humanize"];
3487
3977
  }>;
@@ -3771,11 +4261,13 @@ export function song(input: SongInput): Song {
3771
4261
  if (Object.keys(time).length > 0) stored.time = Object.freeze(time);
3772
4262
  }
3773
4263
  if (t.glide) stored.glide = t.glide;
3774
- 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;
3775
4267
  // One event per tick (the last wins), in tick order, as dawg stores it.
3776
4268
  const byTick = new Map<number, PedalState>();
3777
- for (const [at, state] of t.pedal) byTick.set(ticks(at), state);
3778
- stored.pedal = Object.freeze(
4269
+ for (const [at, state] of lane) byTick.set(ticks(at), state);
4270
+ stored[key] = Object.freeze(
3779
4271
  [...byTick.entries()]
3780
4272
  .sort((a, b) => a[0] - b[0])
3781
4273
  .map(([tick, state]) => Object.freeze({ tick, state })),
@@ -3787,6 +4279,8 @@ export function song(input: SongInput): Song {
3787
4279
  if (t.string) stored.string = t.string;
3788
4280
  if (t.keys) stored.keys = t.keys;
3789
4281
  if (t.modal) stored.modal = t.modal;
4282
+ if (t.guitar) stored.guitar = t.guitar;
4283
+ if (t.wind) stored.wind = t.wind;
3790
4284
  if (t.rhythm && t.rhythm.length > 0)
3791
4285
  stored.rhythm = Object.freeze(
3792
4286
  t.rhythm.map((row) => {
@@ -3831,6 +4325,25 @@ export function song(input: SongInput): Song {
3831
4325
  );
3832
4326
  }
3833
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
+ }
3834
4347
  const arrangement: {
3835
4348
  sections?: readonly SongSection[];
3836
4349
  form?: readonly SongFormEntry[];
@@ -4495,7 +5008,22 @@ export type ChordOptions = Readonly<{
4495
5008
  | "arp-random"
4496
5009
  | "harp"
4497
5010
  | "slop"
4498
- | "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;
4499
5027
  /**
4500
5028
  * With `perform: "pattern"`: a rhythm pattern by name or 1-based number
4501
5029
  * (SDK 1.7.0): `eighths`, `sixteenths`, `offbeat`, `pop`, `charleston`,
@@ -4646,6 +5174,7 @@ export function progression(
4646
5174
  seed: finite(options.seed ?? 0, "chord seed"),
4647
5175
  ...(options.pattern !== undefined ? { pattern: options.pattern } : {}),
4648
5176
  velocity: vel,
5177
+ ...(mode === "guitar" ? guitarPerform(options) : {}),
4649
5178
  },
4650
5179
  });
4651
5180
  const out = [
@@ -4659,6 +5188,114 @@ export function progression(
4659
5188
  );
4660
5189
  }
4661
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
+
4662
5299
  // BEGIN instrument words: generated from core/instruments.ts by core/sdk/sync-instruments.ts
4663
5300
  /** What an instrument word stores on a track. */
4664
5301
  type InstrumentWord = Readonly<{
@@ -4780,6 +5417,42 @@ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
4780
5417
  preset: "dulcimer",
4781
5418
  },
4782
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
+ },
4783
5456
  // f06-rig: guitar track aliases, a guitar voice plus a whole rig. The
4784
5457
  // voice is the strings lane's `electric` row (jangle: its 12-string
4785
5458
  // `jangle` preset); the pluck only while that row is absent. Never `lead`
@@ -4844,6 +5517,39 @@ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
4844
5517
  field: "keys",
4845
5518
  preset: "prepared",
4846
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" },
4847
5553
  // f06-modal: mallets and bells (core/resonators.ts). `marimba` is legacy;
4848
5554
  // `modal` alone gives the modal marimba.
4849
5555
  { word: "modal", instrument: "modal", field: "modal", preset: "marimba" },
@@ -4897,6 +5603,98 @@ const INSTRUMENT_WORDS: readonly InstrumentWordRow[] = Object.freeze([
4897
5603
  field: "modal",
4898
5604
  preset: "chimes",
4899
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" },
4900
5698
  ]);
4901
5699
 
4902
5700
  /**
@@ -5014,6 +5812,12 @@ type Chord = Readonly<{
5014
5812
  extensions: readonly Extension[];
5015
5813
  /** Slash bass pitch class, when not the root. */
5016
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;
5017
5821
  }>;
5018
5822
 
5019
5823
  function makeChord(
@@ -5021,6 +5825,7 @@ function makeChord(
5021
5825
  quality: Quality,
5022
5826
  extensions: Iterable<Extension> = [],
5023
5827
  bass?: number,
5828
+ tensions: readonly number[] = [],
5024
5829
  ): Chord {
5025
5830
  const held = new Set(extensions);
5026
5831
  const own = SECRET_EXTENSION[quality];
@@ -5032,6 +5837,11 @@ function makeChord(
5032
5837
  quality,
5033
5838
  extensions: Object.freeze(ext),
5034
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
+ : {}),
5035
5845
  });
5036
5846
  }
5037
5847
 
@@ -5039,6 +5849,7 @@ function makeChord(
5039
5849
  function chordIntervals(chord: Chord): number[] {
5040
5850
  const set = new Set(QUALITY_INTERVALS[chord.quality]);
5041
5851
  for (const ext of chord.extensions) set.add(EXTENSION_INTERVAL[ext]);
5852
+ for (const step of chord.tensions ?? []) set.add(step);
5042
5853
  // m7 and M7 together keep both; 6 with m7 on a dim triad is the dim7's bb7.
5043
5854
  return [...set].sort((a, b) => a - b);
5044
5855
  }
@@ -5091,6 +5902,18 @@ const SECRET_SUFFIX: Readonly<Partial<Record<Quality, string>>> = Object.freeze(
5091
5902
 
5092
5903
  /** Chord symbol suffix: `m7`, `maj9`, `7sus4`, `dim7`, `m7b5`, `6/9`. */
5093
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
+ }
5094
5917
  const ext = new Set(chord.extensions);
5095
5918
  const b7 = ext.has("m7");
5096
5919
  const M7 = ext.has("M7");
@@ -5254,9 +6077,51 @@ const SUFFIXES: readonly (readonly [string, Quality, readonly Extension[]])[] =
5254
6077
  ["addb6", "b6", []],
5255
6078
  ["7#9", "7#9", []],
5256
6079
  ];
5257
- const SUFFIX_TABLE = new Map(
5258
- SUFFIXES.map(([suffix, quality, ext]) => [suffix, { quality, ext }]),
5259
- );
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
+ ]);
5260
6125
 
5261
6126
  const LETTER: Readonly<Record<string, number>> = Object.freeze({
5262
6127
  c: 0,
@@ -5298,7 +6163,7 @@ function parseChord(symbol: string): Chord | undefined {
5298
6163
  bass = parsePitchClass(match[4]);
5299
6164
  if (bass === undefined) return undefined;
5300
6165
  }
5301
- return makeChord(root, entry.quality, entry.ext, bass);
6166
+ return makeChord(root, entry.quality, entry.ext, bass, entry.tensions);
5302
6167
  }
5303
6168
 
5304
6169
  // ---------------------------------------------------------------------------
@@ -6096,6 +6961,8 @@ const PERFORM_MODES = [
6096
6961
  "harp",
6097
6962
  "slop",
6098
6963
  "pattern",
6964
+ // 0.6.1: fretboard-voiced strokes (see `voiceGuitar`, `strokeVoicing`).
6965
+ "guitar",
6099
6966
  ] as const;
6100
6967
  type PerformMode = (typeof PERFORM_MODES)[number];
6101
6968
 
@@ -6115,6 +6982,23 @@ type PerformOptions = Readonly<{
6115
6982
  pattern?: string | number;
6116
6983
  /** 0..1. */
6117
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;
6118
7002
  }>;
6119
7003
 
6120
7004
  type PerformedNote = Readonly<{
@@ -6431,6 +7315,24 @@ function perform(
6431
7315
  }
6432
7316
  return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
6433
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
+ }
6434
7336
  case "harp": {
6435
7337
  const gap = Math.max(0, options.strum ?? DEFAULT_STRUM * 2);
6436
7338
  return spanned.map((pitch, index) =>
@@ -6465,6 +7367,380 @@ function perform(
6465
7367
  }
6466
7368
  }
6467
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
+
6468
7744
  // ---------------------------------------------------------------------------
6469
7745
  // Progressions
6470
7746
 
@@ -6848,6 +8124,14 @@ function renderProgression(options: RenderOptions): RenderedProgression {
6848
8124
  ...perform(chord.pitches, at, span, {
6849
8125
  ...options.perform,
6850
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
+ : {}),
6851
8135
  }),
6852
8136
  );
6853
8137
  const source = options.chords[index]!;