@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/README.md CHANGED
@@ -15,17 +15,17 @@ curl -fsSL https://dawg.sh/install | sh
15
15
  or install the release tarball from GitHub directly:
16
16
 
17
17
  ```sh
18
- bun add -g https://github.com/hraness/dawg/releases/download/v0.6.0/hraness-dawg-0.6.0.tgz
18
+ bun add -g https://github.com/hraness/dawg/releases/download/v0.6.1/hraness-dawg-0.6.1.tgz
19
19
  dawg --help
20
20
  ```
21
21
 
22
22
  Each [release](https://github.com/hraness/dawg/releases) is immutable and ships the tarball, a `SHA256SUMS` file and a build provenance attestation. To check a download before installing it:
23
23
 
24
24
  ```sh
25
- gh release download v0.6.0 --repo hraness/dawg
25
+ gh release download v0.6.1 --repo hraness/dawg
26
26
  shasum -a 256 -c SHA256SUMS
27
- gh attestation verify hraness-dawg-0.6.0.tgz --repo hraness/dawg
28
- bun add -g "$PWD/hraness-dawg-0.6.0.tgz"
27
+ gh attestation verify hraness-dawg-0.6.1.tgz --repo hraness/dawg
28
+ bun add -g "$PWD/hraness-dawg-0.6.1.tgz"
29
29
  ```
30
30
 
31
31
  dawg is also on npm as [`@hraness/dawg`](https://www.npmjs.com/package/@hraness/dawg):
package/core/chords.ts CHANGED
@@ -111,6 +111,12 @@ export type Chord = Readonly<{
111
111
  extensions: readonly Extension[];
112
112
  /** Slash bass pitch class, when not the root. */
113
113
  bass?: number | undefined;
114
+ /**
115
+ * Upper tensions beyond the four extension buttons, as semitones above
116
+ * the root (13 b9, 15 #9, 17 11, 18 #11, 21 13): only typed symbols
117
+ * such as `C11`, `G13` or `A7b9` carry them (0.6.1).
118
+ */
119
+ tensions?: readonly number[] | undefined;
114
120
  }>;
115
121
 
116
122
  export function makeChord(
@@ -118,6 +124,7 @@ export function makeChord(
118
124
  quality: Quality,
119
125
  extensions: Iterable<Extension> = [],
120
126
  bass?: number,
127
+ tensions: readonly number[] = [],
121
128
  ): Chord {
122
129
  const held = new Set(extensions);
123
130
  const own = SECRET_EXTENSION[quality];
@@ -129,6 +136,11 @@ export function makeChord(
129
136
  quality,
130
137
  extensions: Object.freeze(ext),
131
138
  ...(slash !== undefined && slash !== pc ? { bass: slash } : {}),
139
+ ...(tensions.length > 0
140
+ ? {
141
+ tensions: Object.freeze([...new Set(tensions)].sort((a, b) => a - b)),
142
+ }
143
+ : {}),
132
144
  });
133
145
  }
134
146
 
@@ -136,6 +148,7 @@ export function makeChord(
136
148
  export function chordIntervals(chord: Chord): number[] {
137
149
  const set = new Set(QUALITY_INTERVALS[chord.quality]);
138
150
  for (const ext of chord.extensions) set.add(EXTENSION_INTERVAL[ext]);
151
+ for (const step of chord.tensions ?? []) set.add(step);
139
152
  // m7 and M7 together keep both; 6 with m7 on a dim triad is the dim7's bb7.
140
153
  return [...set].sort((a, b) => a - b);
141
154
  }
@@ -188,6 +201,18 @@ const SECRET_SUFFIX: Readonly<Partial<Record<Quality, string>>> = Object.freeze(
188
201
 
189
202
  /** Chord symbol suffix: `m7`, `maj9`, `7sus4`, `dim7`, `m7b5`, `6/9`. */
190
203
  export function chordSuffix(chord: Chord): string {
204
+ if (chord.tensions?.length) {
205
+ // A typed extended chord keeps the symbol it was typed as.
206
+ const typed = TENSION_SUFFIXES.find(
207
+ ([, quality, ext, tensions]) =>
208
+ quality === chord.quality &&
209
+ ext.join() === chord.extensions.join() &&
210
+ tensions.join() === chord.tensions!.join(),
211
+ );
212
+ if (typed) return typed[0];
213
+ const names = chord.tensions.map((step) => TENSION_NAMES[step] ?? step);
214
+ return `${chordSuffix({ ...chord, tensions: undefined })}(${names.join(",")})`;
215
+ }
191
216
  const ext = new Set(chord.extensions);
192
217
  const b7 = ext.has("m7");
193
218
  const M7 = ext.has("M7");
@@ -351,9 +376,51 @@ const SUFFIXES: readonly (readonly [string, Quality, readonly Extension[]])[] =
351
376
  ["addb6", "b6", []],
352
377
  ["7#9", "7#9", []],
353
378
  ];
354
- const SUFFIX_TABLE = new Map(
355
- SUFFIXES.map(([suffix, quality, ext]) => [suffix, { quality, ext }]),
356
- );
379
+
380
+ /** Typed upper-tension chords (0.6.1): suffix, quality, buttons, tensions. */
381
+ const TENSION_SUFFIXES: readonly (readonly [
382
+ string,
383
+ Quality,
384
+ readonly Extension[],
385
+ readonly number[],
386
+ ])[] = [
387
+ ["11", "maj", ["m7", "9"], [17]],
388
+ ["m11", "min", ["m7", "9"], [17]],
389
+ ["maj11", "maj", ["M7", "9"], [17]],
390
+ ["add11", "maj", [], [17]],
391
+ ["madd11", "min", [], [17]],
392
+ ["13", "maj", ["m7", "9"], [21]],
393
+ ["m13", "min", ["m7", "9"], [21]],
394
+ ["maj13", "maj", ["M7", "9"], [21]],
395
+ ["7b9", "maj", ["m7"], [13]],
396
+ ["7#11", "maj", ["m7"], [18]],
397
+ ["maj7#11", "maj", ["M7"], [18]],
398
+ ["M7#11", "maj", ["M7"], [18]],
399
+ ["7b13", "maj", ["m7"], [20]],
400
+ ["13b9", "maj", ["m7"], [13, 21]],
401
+ ];
402
+
403
+ const TENSION_NAMES: Readonly<Record<number, string>> = Object.freeze({
404
+ 13: "b9",
405
+ 15: "#9",
406
+ 17: "11",
407
+ 18: "#11",
408
+ 20: "b13",
409
+ 21: "13",
410
+ });
411
+
412
+ const SUFFIX_TABLE = new Map<
413
+ string,
414
+ { quality: Quality; ext: readonly Extension[]; tensions?: readonly number[] }
415
+ >([
416
+ ...SUFFIXES.map(
417
+ ([suffix, quality, ext]) => [suffix, { quality, ext }] as const,
418
+ ),
419
+ ...TENSION_SUFFIXES.map(
420
+ ([suffix, quality, ext, tensions]) =>
421
+ [suffix, { quality, ext, tensions }] as const,
422
+ ),
423
+ ]);
357
424
 
358
425
  const LETTER: Readonly<Record<string, number>> = Object.freeze({
359
426
  c: 0,
@@ -395,7 +462,7 @@ export function parseChord(symbol: string): Chord | undefined {
395
462
  bass = parsePitchClass(match[4]);
396
463
  if (bass === undefined) return undefined;
397
464
  }
398
- return makeChord(root, entry.quality, entry.ext, bass);
465
+ return makeChord(root, entry.quality, entry.ext, bass, entry.tensions);
399
466
  }
400
467
 
401
468
  // ---------------------------------------------------------------------------
@@ -1209,6 +1276,8 @@ export const PERFORM_MODES = [
1209
1276
  "harp",
1210
1277
  "slop",
1211
1278
  "pattern",
1279
+ // 0.6.1: fretboard-voiced strokes (see `voiceGuitar`, `strokeVoicing`).
1280
+ "guitar",
1212
1281
  ] as const;
1213
1282
  export type PerformMode = (typeof PERFORM_MODES)[number];
1214
1283
 
@@ -1228,6 +1297,23 @@ export type PerformOptions = Readonly<{
1228
1297
  pattern?: string | number;
1229
1298
  /** 0..1. */
1230
1299
  velocity?: number;
1300
+ /** Guitar mode: the stroke grid (a STROKE_PATTERNS name or D U d u x - .). */
1301
+ strokes?: string;
1302
+ /** Guitar mode: grid step in beats, default 1/2. */
1303
+ step?: number;
1304
+ /** Guitar mode: seconds a full six-string down stroke takes, default 0.022. */
1305
+ speed?: number;
1306
+ /** Guitar mode: tempo in BPM that `speed` converts with, default 120. */
1307
+ tempo?: number;
1308
+ /** Guitar mode: the guitar's tuning, capo, hand, ring and position. */
1309
+ guitar?: GuitarSetup;
1310
+ /** Guitar mode: the chord's root pitch class (default: the lowest note). */
1311
+ root?: number;
1312
+ /**
1313
+ * Guitar mode: a slash bass pitch class that must sound lowest, even when
1314
+ * it is not a chord tone (B/E keeps its E pedal). Default: the lowest note.
1315
+ */
1316
+ slash?: number;
1231
1317
  }>;
1232
1318
 
1233
1319
  export type PerformedNote = Readonly<{
@@ -1544,6 +1630,24 @@ export function perform(
1544
1630
  }
1545
1631
  return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
1546
1632
  }
1633
+ case "guitar": {
1634
+ const bass = options.slash ?? notes[0]!;
1635
+ const voicing = voiceGuitar(
1636
+ notes.map(mod12),
1637
+ mod12(bass),
1638
+ options.root ?? mod12(bass),
1639
+ options.guitar,
1640
+ );
1641
+ if (!voicing) return notes.map((pitch) => at(pitch, start, end));
1642
+ return strokeVoicing(voicing, start, length, {
1643
+ ...(options.strokes !== undefined ? { strokes: options.strokes } : {}),
1644
+ ...(options.step !== undefined ? { step: options.step } : {}),
1645
+ ...(options.speed !== undefined ? { speed: options.speed } : {}),
1646
+ ...(options.tempo !== undefined ? { tempo: options.tempo } : {}),
1647
+ velocity,
1648
+ strings: guitarStrings(options.guitar?.tune).length,
1649
+ });
1650
+ }
1547
1651
  case "harp": {
1548
1652
  const gap = Math.max(0, options.strum ?? DEFAULT_STRUM * 2);
1549
1653
  return spanned.map((pitch, index) =>
@@ -1578,6 +1682,382 @@ export function perform(
1578
1682
  }
1579
1683
  }
1580
1684
 
1685
+ // ---------------------------------------------------------------------------
1686
+ // Fretboard (0.6.1): guitar voicings and strokes
1687
+
1688
+ /**
1689
+ * Open-string MIDI pitches, low string to high, for `guitar tune <name>`.
1690
+ * Ported from the guitar design lane (proto/guitar/strum.ts TUNINGS).
1691
+ */
1692
+ export const GUITAR_TUNINGS = Object.freeze({
1693
+ standard: [40, 45, 50, 55, 59, 64],
1694
+ dropd: [38, 45, 50, 55, 59, 64],
1695
+ doubledropd: [38, 45, 50, 55, 59, 62],
1696
+ dadgad: [38, 45, 50, 55, 57, 62],
1697
+ openg: [38, 43, 50, 55, 59, 62],
1698
+ opend: [38, 45, 50, 54, 57, 62],
1699
+ opene: [40, 47, 52, 56, 59, 64],
1700
+ halfdown: [39, 44, 49, 54, 58, 63],
1701
+ nashville: [52, 57, 62, 67, 59, 64],
1702
+ bass: [28, 33, 38, 43],
1703
+ ukulele: [67, 60, 64, 69],
1704
+ requinto: [45, 50, 55, 60, 64, 69],
1705
+ } as const satisfies Record<string, readonly number[]>);
1706
+ export type GuitarTuningName = keyof typeof GUITAR_TUNINGS;
1707
+ export const GUITAR_TUNING_NAMES = Object.keys(
1708
+ GUITAR_TUNINGS,
1709
+ ) as GuitarTuningName[];
1710
+
1711
+ /** How a guitar is set up for voicing chords (`Track.guitar`). */
1712
+ export type GuitarSetup = Readonly<{
1713
+ /** A GUITAR_TUNINGS name or open-string pitches low to high. */
1714
+ tune?: string | readonly number[];
1715
+ /** Capo fret 0..12. */
1716
+ capo?: number;
1717
+ /** Hand stretch in frets 3..6 (default 4: one fret per finger). */
1718
+ hand?: number;
1719
+ /** 0 closed shapes (no open strings) .. 1 prefer ringing open strings. */
1720
+ ring?: number;
1721
+ /** Preferred fret position 0..12 (0 open position). */
1722
+ position?: number;
1723
+ }>;
1724
+
1725
+ /** One fretted chord: frets per string low to high (-1 muted). */
1726
+ export type GuitarVoicing = Readonly<{
1727
+ frets: readonly number[];
1728
+ /** Sounding pitches, low string to high. */
1729
+ pitches: readonly number[];
1730
+ /** The string each pitch sounds on. */
1731
+ strings: readonly number[];
1732
+ }>;
1733
+
1734
+ /** Open-string pitches for a tuning name or list (standard when unknown). */
1735
+ export function guitarStrings(tune: GuitarSetup["tune"]): readonly number[] {
1736
+ if (Array.isArray(tune)) return tune as readonly number[];
1737
+ const name = String(tune ?? "standard")
1738
+ .toLowerCase()
1739
+ .replace(/[\s_-]/g, "");
1740
+ return (
1741
+ (GUITAR_TUNINGS as Record<string, readonly number[]>)[name] ??
1742
+ GUITAR_TUNINGS.standard
1743
+ );
1744
+ }
1745
+
1746
+ /**
1747
+ * Fingers a fretting uses. Strings at the lowest fret count as one barre
1748
+ * only when no open string lies between them (a barre cannot skip an open
1749
+ * string); otherwise one finger each.
1750
+ */
1751
+ export function guitarFingers(frets: readonly number[]): number {
1752
+ const fretted = frets.filter((f) => f > 0);
1753
+ if (fretted.length === 0) return 0;
1754
+ const min = Math.min(...fretted);
1755
+ const at = frets.flatMap((f, i) => (f === min ? [i] : []));
1756
+ const between = frets.slice(at[0]!, at[at.length - 1]! + 1);
1757
+ const barre = at.length > 1 && !between.includes(0);
1758
+ return (barre ? 1 : at.length) + fretted.filter((f) => f > min).length;
1759
+ }
1760
+
1761
+ /**
1762
+ * The chord tones a voicing must keep, most required first, after dropping
1763
+ * `drops` tones in the guitarist's order: the 5th, then the 9th; the root,
1764
+ * the 3rd and the 7th always stay (after the design lane's review).
1765
+ */
1766
+ function requiredTones(tones: readonly number[], root: number, drops: number) {
1767
+ // The 5th, then the 11th (only beside a 3rd: a sus4 keeps its 4th), then
1768
+ // the 9th. dawg's symbols reach an 11th through m(add4)/madd4 (+9).
1769
+ const third = tones.some((pc) => [3, 4].includes(mod12(pc - root)));
1770
+ const order = (third ? [7, 5, 2] : [7, 2]).map((step) => mod12(root + step));
1771
+ const dropped = order.filter((pc) => tones.includes(pc)).slice(0, drops);
1772
+ return tones.filter((pc) => !dropped.includes(pc));
1773
+ }
1774
+
1775
+ /**
1776
+ * The most playable fretting of pitch classes `tones` over `bass` on a
1777
+ * guitar `setup`: the bass is the lowest sounding note, every required
1778
+ * tone sounds, at most four fingers within a `hand`-fret stretch, mutes
1779
+ * only under the bass or one inside, and no barre over an open string.
1780
+ * Cost prefers more strings, low positions, open strings (by `ring`), a
1781
+ * fifth present, no doubled third, and small moves from `previous`.
1782
+ * Undefined when nothing fits. Ported from proto/guitar/strum.ts.
1783
+ */
1784
+ export function voiceGuitar(
1785
+ tones: readonly number[],
1786
+ bass: number,
1787
+ root: number,
1788
+ setup: GuitarSetup = {},
1789
+ previous?: readonly number[],
1790
+ ): GuitarVoicing | undefined {
1791
+ // Play mode voices every pad press; dense chords at a wide hand take
1792
+ // ~15 ms to search, so repeats come from a small LRU (results are frozen).
1793
+ const key = JSON.stringify([
1794
+ [...new Set([...tones, bass].map(mod12))],
1795
+ mod12(bass),
1796
+ mod12(root),
1797
+ setup.tune ?? null,
1798
+ setup.capo ?? null,
1799
+ setup.hand ?? null,
1800
+ setup.ring ?? null,
1801
+ setup.position ?? null,
1802
+ previous ?? null,
1803
+ ]);
1804
+ if (voicingCache.has(key)) {
1805
+ const hit = voicingCache.get(key);
1806
+ voicingCache.delete(key);
1807
+ voicingCache.set(key, hit);
1808
+ return hit;
1809
+ }
1810
+ const voiced = searchGuitar(tones, bass, root, setup, previous);
1811
+ voicingCache.set(key, voiced);
1812
+ if (voicingCache.size > VOICING_CACHE_SIZE)
1813
+ voicingCache.delete(voicingCache.keys().next().value!);
1814
+ return voiced;
1815
+ }
1816
+
1817
+ const VOICING_CACHE_SIZE = 256;
1818
+ const voicingCache = new Map<string, GuitarVoicing | undefined>();
1819
+
1820
+ function searchGuitar(
1821
+ tones: readonly number[],
1822
+ bass: number,
1823
+ root: number,
1824
+ setup: GuitarSetup,
1825
+ previous?: readonly number[],
1826
+ ): GuitarVoicing | undefined {
1827
+ const pcs = [...new Set([...tones, bass].map(mod12))];
1828
+ const capo = clampInt(setup.capo ?? 0, 0, 12);
1829
+ const open = guitarStrings(setup.tune).map((pitch) => pitch + capo);
1830
+ const n = open.length;
1831
+ const ring = Math.min(1, Math.max(0, setup.ring ?? 0.5));
1832
+ const stretch = clampInt(setup.hand ?? 4, 3, 6);
1833
+ const fifth = mod12(root + 7);
1834
+ const third = pcs.find((pc) => [3, 4].includes(mod12(pc - root)));
1835
+ const prev = previous ? [...previous].sort((a, b) => a - b) : undefined;
1836
+ for (let drops = 0; drops <= 3; drops += 1) {
1837
+ const required = requiredTones(pcs, root, drops);
1838
+ let best: { frets: number[]; cost: number } | undefined;
1839
+ const choose = (frets: readonly number[]) => {
1840
+ const idx = frets.flatMap((f, i) => (f < 0 ? [] : [i]));
1841
+ if (idx.length < Math.min(n, n >= 6 ? 4 : 3)) return;
1842
+ const interior = frets.slice(idx[0]).filter((f) => f < 0).length;
1843
+ if (interior > (n >= 6 ? 1 : 0)) return;
1844
+ const pitches = idx.map((i) => open[i]! + frets[i]!);
1845
+ if (mod12(Math.min(...pitches)) !== mod12(bass)) return;
1846
+ const sounding = new Set(pitches.map(mod12));
1847
+ if (required.some((pc) => !sounding.has(pc))) return;
1848
+ const fingers = guitarFingers(frets);
1849
+ if (fingers > 4) return;
1850
+ const fretted = frets.filter((f) => f > 0);
1851
+ const span = fretted.length
1852
+ ? Math.max(...fretted) - Math.min(...fretted)
1853
+ : 0;
1854
+ if (span > stretch - 1) return;
1855
+ const opens = frets.filter((f) => f === 0).length;
1856
+ if (ring === 0 && opens > 0) return;
1857
+ const pos = fretted.length ? Math.min(...fretted) : 0;
1858
+ let cost =
1859
+ span * 0.6 +
1860
+ pos * 0.25 +
1861
+ fingers * 0.4 -
1862
+ idx.length -
1863
+ opens * 0.7 * ring;
1864
+ cost += interior * 1.5;
1865
+ if (!sounding.has(fifth) && pcs.includes(fifth)) cost += 0.6;
1866
+ if (
1867
+ third !== undefined &&
1868
+ pitches.filter((p) => mod12(p) === third).length > 1
1869
+ )
1870
+ cost += 0.5;
1871
+ if (setup.position !== undefined)
1872
+ cost += Math.abs(pos - setup.position) * 0.5;
1873
+ if (prev && prev.length) {
1874
+ const cur = [...pitches].sort((a, b) => a - b);
1875
+ let move = 0;
1876
+ for (let i = 0; i < cur.length; i += 1)
1877
+ move += Math.abs(cur[i]! - prev[Math.min(i, prev.length - 1)]!);
1878
+ cost += move * 0.05;
1879
+ }
1880
+ if (
1881
+ !best ||
1882
+ cost < best.cost - 1e-9 ||
1883
+ (Math.abs(cost - best.cost) <= 1e-9 && frets.join() < best.frets.join())
1884
+ )
1885
+ best = { frets: [...frets], cost };
1886
+ };
1887
+ for (let p = 1; p <= 12; p += 1) {
1888
+ const options = open.map((o) => {
1889
+ const list = [-1];
1890
+ if (pcs.includes(mod12(o))) list.push(0);
1891
+ for (let f = p; f < p + stretch; f += 1)
1892
+ if (pcs.includes(mod12(o + f))) list.push(f);
1893
+ return list;
1894
+ });
1895
+ const frets = new Array<number>(n).fill(-1);
1896
+ const walk = (s: number): void => {
1897
+ if (s === n) return choose(frets);
1898
+ for (const f of options[s]!) {
1899
+ frets[s] = f;
1900
+ walk(s + 1);
1901
+ }
1902
+ };
1903
+ walk(0);
1904
+ }
1905
+ if (best) {
1906
+ const { frets } = best as { frets: number[] };
1907
+ const strings = frets.flatMap((f, i) => (f < 0 ? [] : [i]));
1908
+ return Object.freeze({
1909
+ frets: Object.freeze(frets),
1910
+ pitches: Object.freeze(strings.map((i) => open[i]! + frets[i]!)),
1911
+ strings: Object.freeze(strings),
1912
+ });
1913
+ }
1914
+ }
1915
+ return undefined;
1916
+ }
1917
+
1918
+ /** A voicing as tab, low string first: `x 3 2 0 1 0`. */
1919
+ export function guitarTab(frets: readonly number[]): string {
1920
+ return frets.map((f) => (f < 0 ? "x" : String(f))).join(" ");
1921
+ }
1922
+
1923
+ /**
1924
+ * Stroke grids for perform mode `guitar`, one character per step: D down
1925
+ * (all strings), U up (top four), d light down (top four), u light up (top
1926
+ * three), x muted chuck, `-` or `.` rest (strings ring on). After the
1927
+ * design lane's STRUM_PATTERNS.
1928
+ */
1929
+ export const STROKE_PATTERNS = Object.freeze({
1930
+ down: "D",
1931
+ folk: "D-DU-UDU",
1932
+ pop: "D-DU-UD-",
1933
+ punk: "DDDDDDDD",
1934
+ funk: "xUxUDUxUxUDUxUxU",
1935
+ reggae: "-D-D",
1936
+ waltz: "Ddd",
1937
+ jangle: "D-DUDUDU",
1938
+ island: "D-DU-UDU",
1939
+ } as const satisfies Record<string, string>);
1940
+ export const STROKE_PATTERN_NAMES = Object.keys(STROKE_PATTERNS);
1941
+ const STROKE_CHARS = /^[DUdux.\-]+$/;
1942
+
1943
+ /** Default seconds a full six-string down stroke takes (22 ms). */
1944
+ export const DEFAULT_STROKE_SPEED = 0.022;
1945
+ /** Default stroke grid step in beats (8ths). */
1946
+ export const DEFAULT_STROKE_STEP = 0.5;
1947
+
1948
+ /** A stroke grid by name, or a literal grid of D U d u x - .; undefined if bad. */
1949
+ export function strokeGrid(text: string | undefined): string | undefined {
1950
+ const raw = (text ?? "down").trim();
1951
+ const named = (STROKE_PATTERNS as Record<string, string>)[raw.toLowerCase()];
1952
+ if (named) return named;
1953
+ const grid = raw.replace(/[|\s]/g, "");
1954
+ return grid.length > 0 && grid.length <= 64 && STROKE_CHARS.test(grid)
1955
+ ? grid
1956
+ : undefined;
1957
+ }
1958
+
1959
+ /**
1960
+ * Strum fretted `voicing` from `start` for `length` beats with stroke grid
1961
+ * `strokes` (repeats every grid length, one step per `step` beats). A full
1962
+ * down stroke sweeps the six strings in `speed` seconds at `tempo` BPM;
1963
+ * each struck string rings until it is struck again or the chord ends; a
1964
+ * chuck (x) is a short muted hit that stops the strings. Velocity accents
1965
+ * downbeats and lightens upstrokes.
1966
+ */
1967
+ export function strokeVoicing(
1968
+ voicing: GuitarVoicing,
1969
+ start: number,
1970
+ length: number,
1971
+ options: Readonly<{
1972
+ strokes?: string;
1973
+ step?: number;
1974
+ speed?: number;
1975
+ tempo?: number;
1976
+ velocity?: number;
1977
+ strings?: number;
1978
+ }> = {},
1979
+ ): PerformedNote[] {
1980
+ const grid = strokeGrid(options.strokes) ?? "D";
1981
+ const step =
1982
+ options.step && options.step > 0 ? options.step : DEFAULT_STROKE_STEP;
1983
+ const tempo = options.tempo && options.tempo > 0 ? options.tempo : 120;
1984
+ const sweep =
1985
+ (Math.max(0, options.speed ?? DEFAULT_STROKE_SPEED) * tempo) / 60;
1986
+ const base = options.velocity ?? 0.8;
1987
+ const total = Math.max(2, options.strings ?? 6);
1988
+ const sounding = voicing.strings;
1989
+ const end = start + length;
1990
+ type Open = { note: PerformedNote; index: number };
1991
+ const ringing = new Map<number, Open>();
1992
+ const out: PerformedNote[] = [];
1993
+ const stop = (string: number, at: number) => {
1994
+ const held = ringing.get(string);
1995
+ if (!held) return;
1996
+ const cut = round6(
1997
+ Math.max(1e-3, Math.min(held.note.length, at - held.note.start)),
1998
+ );
1999
+ out[held.index] = { ...held.note, length: cut };
2000
+ ringing.delete(string);
2001
+ };
2002
+ const steps = Math.max(1, Math.round(length / step));
2003
+ for (let s = 0; s < steps; s += 1) {
2004
+ const ch = grid[s % grid.length]!;
2005
+ if (ch === "-" || ch === ".") continue;
2006
+ const t0 = start + s * step;
2007
+ if (t0 >= end - 1e-9) break;
2008
+ const beat = s * step;
2009
+ const accent =
2010
+ Math.abs(beat - Math.round(beat)) < 1e-9
2011
+ ? Math.round(beat) % 2 === 0
2012
+ ? 1
2013
+ : 0.92
2014
+ : 0.82;
2015
+ let order: number[];
2016
+ let velocity = base * accent;
2017
+ let ring = end - t0;
2018
+ const all = sounding.map((_, k) => k);
2019
+ switch (ch) {
2020
+ case "D":
2021
+ order = all;
2022
+ break;
2023
+ case "U":
2024
+ order = all.slice(-4).reverse();
2025
+ velocity *= 0.85;
2026
+ break;
2027
+ case "d":
2028
+ order = all.slice(-4);
2029
+ velocity *= 0.62;
2030
+ break;
2031
+ case "u":
2032
+ order = all.slice(-3).reverse();
2033
+ velocity *= 0.55;
2034
+ break;
2035
+ default:
2036
+ order = all;
2037
+ velocity *= 0.5;
2038
+ ring = Math.min(ring, 0.03 * (tempo / 60));
2039
+ }
2040
+ const first = sounding[order[0]!]!;
2041
+ order.forEach((k, position) => {
2042
+ const string = sounding[k]!;
2043
+ const at = t0 + (Math.abs(string - first) / (total - 1)) * sweep;
2044
+ if (at >= end - 1e-9) return;
2045
+ stop(string, at);
2046
+ const note: PerformedNote = {
2047
+ pitch: voicing.pitches[k]!,
2048
+ start: round6(at),
2049
+ length: round6(Math.max(1e-3, Math.min(ring, end - at))),
2050
+ velocity: round6(
2051
+ Math.max(0.05, Math.min(1, velocity * (1 - 0.04 * position))),
2052
+ ),
2053
+ };
2054
+ out.push(note);
2055
+ if (ch !== "x") ringing.set(string, { note, index: out.length - 1 });
2056
+ });
2057
+ }
2058
+ return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
2059
+ }
2060
+
1581
2061
  // ---------------------------------------------------------------------------
1582
2062
  // Progressions
1583
2063
 
@@ -1969,6 +2449,14 @@ export function renderProgression(options: RenderOptions): RenderedProgression {
1969
2449
  ...perform(chord.pitches, at, span, {
1970
2450
  ...options.perform,
1971
2451
  seed: (options.perform?.seed ?? 0) + index,
2452
+ ...(options.perform?.mode === "guitar"
2453
+ ? {
2454
+ root: options.chords[index]!.root,
2455
+ ...(options.chords[index]!.bass !== undefined
2456
+ ? { slash: options.chords[index]!.bass }
2457
+ : {}),
2458
+ }
2459
+ : {}),
1972
2460
  }),
1973
2461
  );
1974
2462
  const source = options.chords[index]!;
package/core/diff.ts CHANGED
@@ -44,6 +44,11 @@ const EFFECTS = [
44
44
  "granular",
45
45
  "keys",
46
46
  "modal",
47
+ "softPedal",
48
+ "sostenuto",
49
+ // f061-guitar: fretting setup.
50
+ "guitar",
51
+ "wind",
47
52
  ] as const;
48
53
  const LANES = [
49
54
  "volumeAutomation",