@hraness/dawg 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
package/core/chords.ts ADDED
@@ -0,0 +1,1724 @@
1
+ /**
2
+ * Chords: vocabulary, key-mode harmony, voice leading, bass, performance
3
+ * (block, strum, arpeggio, harp) and a seeded progression engine. Pure and
4
+ * deterministic; shared by play mode, the agent tools and the SDK docs.
5
+ *
6
+ * Two layers, kept apart on purpose:
7
+ *
8
+ * - Orchid-style input (Telepathic Instruments ORC-1). Four chord-type
9
+ * buttons (dim, min, maj, sus) pick the triad and four extension buttons
10
+ * (6, m7, M7, 9) add notes; any number of extensions combine with one
11
+ * type. "Key mode" makes every key play a chord that fits the song key
12
+ * (in C major, D plays D minor). The voicing control rotates the chord:
13
+ * one step moves the lowest note up an octave, or the highest down.
14
+ * Bass plays one note under each chord. Those behaviours come from the
15
+ * Orchid support articles and reviews (see DAWG.md, Chords).
16
+ * The "secret chords" (`COMBINED_TYPES`) follow the Orchid manual's
17
+ * table (section 14.8, firmware 3.84+): dim+sus power chord, maj+sus
18
+ * augmented, min+sus m(add4), min+dim with 6 m(b6), maj+dim with 6
19
+ * (b6), maj+min with m7 7#9. Slop (humanised timing) is an Orchid
20
+ * performance mode too.
21
+ * - dawg's own design. A secret chord plays even when its extension button
22
+ * is not latched (terminals have no chords of held keys), three held
23
+ * types use the first two, non-scale keys in key mode, the voice leader
24
+ * (minimal movement from the previous chord), spread, the perform
25
+ * timings, slop amounts and the progression graph.
26
+ */
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Vocabulary
30
+
31
+ /** The four Orchid chord-type buttons. */
32
+ export const CHORD_TYPES = ["dim", "min", "maj", "sus"] as const;
33
+ export type ChordType = (typeof CHORD_TYPES)[number];
34
+
35
+ /** The four Orchid extension buttons. */
36
+ export const EXTENSIONS = ["6", "m7", "M7", "9"] as const;
37
+ export type Extension = (typeof EXTENSIONS)[number];
38
+
39
+ /** Triad qualities: the four buttons plus dawg's two-button combinations. */
40
+ export const QUALITIES = [
41
+ "maj",
42
+ "min",
43
+ "dim",
44
+ "sus4",
45
+ "aug",
46
+ "sus2",
47
+ "5",
48
+ "madd4",
49
+ "mb6",
50
+ "b6",
51
+ "7#9",
52
+ ] as const;
53
+ export type Quality = (typeof QUALITIES)[number];
54
+
55
+ const QUALITY_INTERVALS: Readonly<Record<Quality, readonly number[]>> =
56
+ Object.freeze({
57
+ maj: [0, 4, 7],
58
+ min: [0, 3, 7],
59
+ dim: [0, 3, 6],
60
+ sus4: [0, 5, 7],
61
+ aug: [0, 4, 8],
62
+ sus2: [0, 2, 7],
63
+ "5": [0, 7],
64
+ madd4: [0, 3, 5, 7],
65
+ mb6: [0, 3, 7, 8],
66
+ b6: [0, 4, 7, 8],
67
+ "7#9": [0, 4, 7, 10, 15],
68
+ });
69
+
70
+ /**
71
+ * The extension button a secret chord is built with: it is part of the
72
+ * chord, so `makeChord` drops it rather than stacking it again.
73
+ */
74
+ const SECRET_EXTENSION: Readonly<Partial<Record<Quality, Extension>>> =
75
+ Object.freeze({ mb6: "6", b6: "6", "7#9": "m7" });
76
+
77
+ const EXTENSION_INTERVAL: Readonly<Record<Extension, number>> = Object.freeze({
78
+ "6": 9,
79
+ m7: 10,
80
+ M7: 11,
81
+ "9": 14,
82
+ });
83
+
84
+ /**
85
+ * Two chord-type buttons held together: Orchid's "secret chords" (manual
86
+ * section 14.8). min+dim and maj+dim are listed with the 6 button and
87
+ * maj+min with m7; dawg plays them without it too.
88
+ */
89
+ export const COMBINED_TYPES: Readonly<Record<string, Quality>> = Object.freeze({
90
+ "dim+sus": "5",
91
+ "maj+sus": "aug",
92
+ "min+sus": "madd4",
93
+ "dim+min": "mb6",
94
+ "dim+maj": "b6",
95
+ "maj+min": "7#9",
96
+ });
97
+
98
+ /** Quality for a set of held chord-type buttons, or undefined for none. */
99
+ export function qualityOf(types: Iterable<ChordType>): Quality | undefined {
100
+ const held = [...new Set(types)].sort();
101
+ if (held.length === 0) return undefined;
102
+ if (held.length === 1) return held[0] === "sus" ? "sus4" : held[0]!;
103
+ return COMBINED_TYPES[held.slice(0, 2).join("+")] ?? "maj";
104
+ }
105
+
106
+ /** A chord: root pitch class, triad quality, extensions, optional bass. */
107
+ export type Chord = Readonly<{
108
+ /** 0..11, C = 0. */
109
+ root: number;
110
+ quality: Quality;
111
+ extensions: readonly Extension[];
112
+ /** Slash bass pitch class, when not the root. */
113
+ bass?: number | undefined;
114
+ }>;
115
+
116
+ export function makeChord(
117
+ root: number,
118
+ quality: Quality,
119
+ extensions: Iterable<Extension> = [],
120
+ bass?: number,
121
+ ): Chord {
122
+ const held = new Set(extensions);
123
+ const own = SECRET_EXTENSION[quality];
124
+ const ext = EXTENSIONS.filter((value) => held.has(value) && value !== own);
125
+ const pc = mod12(root);
126
+ const slash = bass === undefined ? undefined : mod12(bass);
127
+ return Object.freeze({
128
+ root: pc,
129
+ quality,
130
+ extensions: Object.freeze(ext),
131
+ ...(slash !== undefined && slash !== pc ? { bass: slash } : {}),
132
+ });
133
+ }
134
+
135
+ /** Semitones above the root, ascending and unique (9 sits at 14). */
136
+ export function chordIntervals(chord: Chord): number[] {
137
+ const set = new Set(QUALITY_INTERVALS[chord.quality]);
138
+ for (const ext of chord.extensions) set.add(EXTENSION_INTERVAL[ext]);
139
+ // m7 and M7 together keep both; 6 with m7 on a dim triad is the dim7's bb7.
140
+ return [...set].sort((a, b) => a - b);
141
+ }
142
+
143
+ /** Pitch classes of the chord (bass excluded), root first. */
144
+ export function chordPitchClasses(chord: Chord): number[] {
145
+ return chordIntervals(chord).map((step) => mod12(chord.root + step));
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Names
150
+
151
+ const SHARP_NAMES = [
152
+ "C",
153
+ "C#",
154
+ "D",
155
+ "D#",
156
+ "E",
157
+ "F",
158
+ "F#",
159
+ "G",
160
+ "G#",
161
+ "A",
162
+ "A#",
163
+ "B",
164
+ ];
165
+ const FLAT_NAMES = [
166
+ "C",
167
+ "Db",
168
+ "D",
169
+ "Eb",
170
+ "E",
171
+ "F",
172
+ "Gb",
173
+ "G",
174
+ "Ab",
175
+ "A",
176
+ "Bb",
177
+ "B",
178
+ ];
179
+
180
+ /** Note name for a pitch class; flats when `flats`. */
181
+ export function noteName(pc: number, flats = false): string {
182
+ return (flats ? FLAT_NAMES : SHARP_NAMES)[mod12(pc)]!;
183
+ }
184
+
185
+ const SECRET_SUFFIX: Readonly<Partial<Record<Quality, string>>> = Object.freeze(
186
+ { madd4: "m(add4)", mb6: "m(b6)", b6: "(b6)", "7#9": "7#9" },
187
+ );
188
+
189
+ /** Chord symbol suffix: `m7`, `maj9`, `7sus4`, `dim7`, `m7b5`, `6/9`. */
190
+ export function chordSuffix(chord: Chord): string {
191
+ const ext = new Set(chord.extensions);
192
+ const b7 = ext.has("m7");
193
+ const M7 = ext.has("M7");
194
+ const six = ext.has("6");
195
+ const nine = ext.has("9");
196
+ const q = chord.quality;
197
+ const add = (base: string, parts: string[]) =>
198
+ parts.length === 0 ? base : `${base}(${parts.join(",")})`;
199
+ const extras: string[] = [];
200
+ let base: string;
201
+ const secret = SECRET_SUFFIX[q];
202
+ if (secret !== undefined) {
203
+ const names: Readonly<Record<Extension, string>> = {
204
+ "6": "6",
205
+ m7: "7",
206
+ M7: "maj7",
207
+ "9": "9",
208
+ };
209
+ const parts = chord.extensions.map((e) => names[e]);
210
+ if (parts.length === 0 || !secret.endsWith(")")) return add(secret, parts);
211
+ return `${secret.slice(0, -1)},${parts.join(",")})`;
212
+ }
213
+ if (q === "dim" && six && !b7 && !M7) {
214
+ base = "dim7";
215
+ if (nine) extras.push("add9");
216
+ return add(base, extras);
217
+ }
218
+ if (b7 && M7) {
219
+ // Both sevenths: name the dominant and list the major seventh.
220
+ extras.push("maj7");
221
+ }
222
+ const seventh = b7 ? "7" : M7 ? "maj7" : "";
223
+ if (seventh) {
224
+ const ninth = nine ? (seventh === "7" ? "9" : "maj9") : seventh;
225
+ switch (q) {
226
+ case "maj":
227
+ base = ninth;
228
+ break;
229
+ case "min":
230
+ base = b7 ? (nine ? "m9" : "m7") : nine ? "m(maj9)" : "m(maj7)";
231
+ break;
232
+ case "dim":
233
+ base = b7 ? (nine ? "m9b5" : "m7b5") : "dim(maj7)";
234
+ if (!b7 && nine) extras.push("9");
235
+ break;
236
+ case "aug":
237
+ base = b7 ? (nine ? "aug9" : "aug7") : "aug(maj7)";
238
+ if (!b7 && nine) extras.push("9");
239
+ break;
240
+ case "sus4":
241
+ base = `${ninth}sus4`;
242
+ break;
243
+ case "sus2":
244
+ base = `${seventh}sus2`;
245
+ if (nine) extras.push("9");
246
+ break;
247
+ case "5":
248
+ base = `${seventh}(no3)`;
249
+ if (nine) extras.push("9");
250
+ break;
251
+ default:
252
+ base = seventh; // secret qualities returned above
253
+ }
254
+ if (six) extras.push("13");
255
+ return add(base, b7 && M7 ? extras : extras.filter((e) => e !== "maj7"));
256
+ }
257
+ const triad: Record<Quality, string> = {
258
+ maj: "",
259
+ min: "m",
260
+ dim: "dim",
261
+ sus4: "sus4",
262
+ aug: "aug",
263
+ sus2: "sus2",
264
+ "5": "5",
265
+ madd4: "m(add4)",
266
+ mb6: "m(b6)",
267
+ b6: "(b6)",
268
+ "7#9": "7#9",
269
+ };
270
+ base = triad[q];
271
+ if (six && nine && (q === "maj" || q === "min")) return `${base}6/9`;
272
+ if (six) {
273
+ if (q === "maj" || q === "min") base = `${base}6`;
274
+ else extras.push("6");
275
+ }
276
+ if (nine) {
277
+ if (extras.length === 0 && (q === "maj" || q === "min"))
278
+ return `${base}${q === "min" && !six ? "(add9)" : "add9"}`;
279
+ extras.push("9");
280
+ }
281
+ return add(base, extras);
282
+ }
283
+
284
+ /** Chord symbol: `Cm7`, `F#dim`, `Bbmaj9`, `G7sus4`, `C/E`. */
285
+ export function chordName(chord: Chord, flats = false): string {
286
+ const slash =
287
+ chord.bass === undefined ? "" : `/${noteName(chord.bass, flats)}`;
288
+ return `${noteName(chord.root, flats)}${chordSuffix(chord)}${slash}`;
289
+ }
290
+
291
+ /** Suffix → quality and extensions, longest first when parsing. */
292
+ const SUFFIXES: readonly (readonly [string, Quality, readonly Extension[]])[] =
293
+ [
294
+ ["", "maj", []],
295
+ ["maj", "maj", []],
296
+ ["M", "maj", []],
297
+ ["m", "min", []],
298
+ ["min", "min", []],
299
+ ["-", "min", []],
300
+ ["dim", "dim", []],
301
+ ["°", "dim", []],
302
+ ["o", "dim", []],
303
+ ["aug", "aug", []],
304
+ ["+", "aug", []],
305
+ ["sus", "sus4", []],
306
+ ["sus4", "sus4", []],
307
+ ["sus2", "sus2", []],
308
+ ["5", "5", []],
309
+ ["6", "maj", ["6"]],
310
+ ["m6", "min", ["6"]],
311
+ ["6/9", "maj", ["6", "9"]],
312
+ ["69", "maj", ["6", "9"]],
313
+ ["m6/9", "min", ["6", "9"]],
314
+ ["m69", "min", ["6", "9"]],
315
+ ["7", "maj", ["m7"]],
316
+ ["dom7", "maj", ["m7"]],
317
+ ["maj7", "maj", ["M7"]],
318
+ ["M7", "maj", ["M7"]],
319
+ ["Δ", "maj", ["M7"]],
320
+ ["Δ7", "maj", ["M7"]],
321
+ ["m7", "min", ["m7"]],
322
+ ["min7", "min", ["m7"]],
323
+ ["-7", "min", ["m7"]],
324
+ ["m(maj7)", "min", ["M7"]],
325
+ ["mM7", "min", ["M7"]],
326
+ ["m7b5", "dim", ["m7"]],
327
+ ["ø", "dim", ["m7"]],
328
+ ["ø7", "dim", ["m7"]],
329
+ ["dim7", "dim", ["6"]],
330
+ ["°7", "dim", ["6"]],
331
+ ["o7", "dim", ["6"]],
332
+ ["aug7", "aug", ["m7"]],
333
+ ["+7", "aug", ["m7"]],
334
+ ["9", "maj", ["m7", "9"]],
335
+ ["maj9", "maj", ["M7", "9"]],
336
+ ["M9", "maj", ["M7", "9"]],
337
+ ["m9", "min", ["m7", "9"]],
338
+ ["add9", "maj", ["9"]],
339
+ ["madd9", "min", ["9"]],
340
+ ["m(add9)", "min", ["9"]],
341
+ ["7sus4", "sus4", ["m7"]],
342
+ ["7sus", "sus4", ["m7"]],
343
+ ["9sus4", "sus4", ["m7", "9"]],
344
+ ["7sus2", "sus2", ["m7"]],
345
+ ["maj7sus4", "sus4", ["M7"]],
346
+ ["m(add4)", "madd4", []],
347
+ ["madd4", "madd4", []],
348
+ ["m(b6)", "mb6", []],
349
+ ["mb6", "mb6", []],
350
+ ["(b6)", "b6", []],
351
+ ["addb6", "b6", []],
352
+ ["7#9", "7#9", []],
353
+ ];
354
+ const SUFFIX_TABLE = new Map(
355
+ SUFFIXES.map(([suffix, quality, ext]) => [suffix, { quality, ext }]),
356
+ );
357
+
358
+ const LETTER: Readonly<Record<string, number>> = Object.freeze({
359
+ c: 0,
360
+ d: 2,
361
+ e: 4,
362
+ f: 5,
363
+ g: 7,
364
+ a: 9,
365
+ b: 11,
366
+ });
367
+
368
+ /** Pitch class of a note name (`C`, `f#`, `Bb`), or undefined. */
369
+ export function parsePitchClass(text: string): number | undefined {
370
+ const match = text.trim().match(/^([a-gA-G])(#|b|♯|♭)?$/);
371
+ if (!match) return undefined;
372
+ const accidental =
373
+ match[2] === "#" || match[2] === "♯"
374
+ ? 1
375
+ : match[2] === "b" || match[2] === "♭"
376
+ ? -1
377
+ : 0;
378
+ return mod12(LETTER[match[1]!.toLowerCase()]! + accidental);
379
+ }
380
+
381
+ /** Parse a chord symbol (`Cm7`, `F#dim`, `Bbmaj9`, `G7sus4`, `C/E`). */
382
+ export function parseChord(symbol: string): Chord | undefined {
383
+ if (typeof symbol !== "string" || symbol.length > 24) return undefined;
384
+ const trimmed = symbol
385
+ .trim()
386
+ .replace(/6\/9$/, "69")
387
+ .replace(/6\/9\//, "69/");
388
+ const match = trimmed.match(/^([A-Ga-g])(#|b|♯|♭)?([^/]*)(?:\/(.+))?$/);
389
+ if (!match) return undefined;
390
+ const root = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
391
+ const entry = SUFFIX_TABLE.get(match[3] ?? "");
392
+ if (root === undefined || !entry) return undefined;
393
+ let bass: number | undefined;
394
+ if (match[4] !== undefined) {
395
+ bass = parsePitchClass(match[4]);
396
+ if (bass === undefined) return undefined;
397
+ }
398
+ return makeChord(root, entry.quality, entry.ext, bass);
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Keys and modes
403
+
404
+ export const MODES = Object.freeze({
405
+ major: [0, 2, 4, 5, 7, 9, 11],
406
+ minor: [0, 2, 3, 5, 7, 8, 10],
407
+ dorian: [0, 2, 3, 5, 7, 9, 10],
408
+ phrygian: [0, 1, 3, 5, 7, 8, 10],
409
+ lydian: [0, 2, 4, 6, 7, 9, 11],
410
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
411
+ locrian: [0, 1, 3, 5, 6, 8, 10],
412
+ "harmonic-minor": [0, 2, 3, 5, 7, 8, 11],
413
+ } as const);
414
+ export type ModeName = keyof typeof MODES;
415
+ export const MODE_NAMES = Object.keys(MODES) as ModeName[];
416
+
417
+ const MODE_ALIASES: Readonly<Record<string, ModeName>> = Object.freeze({
418
+ "": "major",
419
+ maj: "major",
420
+ major: "major",
421
+ ionian: "major",
422
+ m: "minor",
423
+ min: "minor",
424
+ minor: "minor",
425
+ aeolian: "minor",
426
+ dorian: "dorian",
427
+ phrygian: "phrygian",
428
+ lydian: "lydian",
429
+ mixolydian: "mixolydian",
430
+ mixo: "mixolydian",
431
+ locrian: "locrian",
432
+ "harmonic-minor": "harmonic-minor",
433
+ "harmonic minor": "harmonic-minor",
434
+ harmonic: "harmonic-minor",
435
+ });
436
+
437
+ export type Key = Readonly<{ tonic: number; mode: ModeName }>;
438
+
439
+ /**
440
+ * Parse a key: `C`, `c major`, `Am`, `a minor`, `F# dorian`, `Eb mixo`.
441
+ * Accepts the `<note> <mode>` form `core/key.ts` writes.
442
+ */
443
+ export function parseKey(text: string | null | undefined): Key | undefined {
444
+ if (typeof text !== "string" || text.length > 40) return undefined;
445
+ const match = text
446
+ .trim()
447
+ .match(/^([a-gA-G])(#|b|♯|♭)?\s*(m(?![a-z])|[a-zA-Z][a-zA-Z -]*)?$/);
448
+ if (!match) return undefined;
449
+ const tonic = parsePitchClass(`${match[1]}${match[2] ?? ""}`);
450
+ const word = (match[3] ?? "").trim();
451
+ const mode = MODE_ALIASES[word === "m" ? "m" : word.toLowerCase()];
452
+ if (tonic === undefined || mode === undefined) return undefined;
453
+ return Object.freeze({ tonic, mode });
454
+ }
455
+
456
+ /** True when names in the key read better with flats (F, Bb, Eb, d minor…). */
457
+ export function keyUsesFlats(key: Key): boolean {
458
+ // The parent major scale's tonic decides: F, Bb, Eb, Ab, Db read in flats.
459
+ const parentOffset: Record<ModeName, number> = {
460
+ major: 0,
461
+ dorian: 2,
462
+ phrygian: 4,
463
+ lydian: 5,
464
+ mixolydian: 7,
465
+ minor: 9,
466
+ locrian: 11,
467
+ "harmonic-minor": 9,
468
+ };
469
+ const parent = mod12(key.tonic - parentOffset[key.mode]);
470
+ return [5, 10, 3, 8, 1].includes(parent);
471
+ }
472
+
473
+ /** `C major`, `F# dorian`, `Bb minor`. */
474
+ export function keyName(key: Key): string {
475
+ return `${noteName(key.tonic, keyUsesFlats(key))} ${key.mode}`;
476
+ }
477
+
478
+ /** Pitch classes of the key's scale, tonic first. */
479
+ export function scaleOf(key: Key): number[] {
480
+ return MODES[key.mode].map((step) => mod12(key.tonic + step));
481
+ }
482
+
483
+ function qualityFromThirds(third: number, fifth: number): Quality {
484
+ if (third === 4 && fifth === 7) return "maj";
485
+ if (third === 3 && fifth === 7) return "min";
486
+ if (third === 3 && fifth === 6) return "dim";
487
+ if (third === 4 && fifth === 8) return "aug";
488
+ return "maj";
489
+ }
490
+
491
+ /**
492
+ * The diatonic chord on scale degree `degree` (0-based): stacked thirds
493
+ * from the scale. `sevenths` adds the scale's seventh above the root.
494
+ */
495
+ export function diatonicChord(
496
+ key: Key,
497
+ degree: number,
498
+ sevenths = false,
499
+ ): Chord {
500
+ const scale = MODES[key.mode];
501
+ const at = (index: number) => {
502
+ const octave = Math.floor(index / 7);
503
+ return scale[((index % 7) + 7) % 7]! + 12 * octave;
504
+ };
505
+ const d = ((degree % 7) + 7) % 7;
506
+ const root = at(d);
507
+ const third = at(d + 2) - root;
508
+ const fifth = at(d + 4) - root;
509
+ const quality = qualityFromThirds(third, fifth);
510
+ const ext: Extension[] = [];
511
+ if (sevenths) {
512
+ const seventh = at(d + 6) - root;
513
+ if (quality === "dim" && seventh === 9) ext.push("6");
514
+ else ext.push(seventh === 11 ? "M7" : "m7");
515
+ }
516
+ return makeChord(key.tonic + root, quality, ext);
517
+ }
518
+
519
+ /** The seven diatonic chords of the key. */
520
+ export function diatonicChords(key: Key, sevenths = false): Chord[] {
521
+ return Array.from({ length: 7 }, (_, degree) =>
522
+ diatonicChord(key, degree, sevenths),
523
+ );
524
+ }
525
+
526
+ /** Scale degree (0-based) of a pitch class, or undefined when not in key. */
527
+ export function degreeOf(key: Key, pc: number): number | undefined {
528
+ const index = scaleOf(key).indexOf(mod12(pc));
529
+ return index < 0 ? undefined : index;
530
+ }
531
+
532
+ /**
533
+ * Orchid Key mode: the chord a key plays. In-scale pitches play their
534
+ * diatonic chord (C major: D → Dm). Out-of-scale pitches are dawg's
535
+ * choice: the chord borrowed from the parallel major/minor when that
536
+ * scale contains the pitch (C major: Eb → Eb, Ab → Ab, Bb → Bb), otherwise
537
+ * a passing diminished seventh (C major: C# → C#dim7, F# → F#dim7).
538
+ * `types` and `extensions` are the held Orchid buttons: a type overrides
539
+ * the quality ("unorthodox" choices), extensions add on top.
540
+ */
541
+ export function keyModeChord(
542
+ key: Key,
543
+ pitch: number,
544
+ options: Readonly<{
545
+ types?: Iterable<ChordType>;
546
+ extensions?: Iterable<Extension>;
547
+ sevenths?: boolean;
548
+ }> = {},
549
+ ): Chord {
550
+ const pc = mod12(pitch);
551
+ const extensions = [...(options.extensions ?? [])];
552
+ const forced = qualityOf(options.types ?? []);
553
+ const degree = degreeOf(key, pc);
554
+ let base: Chord;
555
+ if (degree !== undefined) base = diatonicChord(key, degree, options.sevenths);
556
+ else {
557
+ const parallel: Key = {
558
+ tonic: key.tonic,
559
+ mode: MODES[key.mode][2] === 4 ? "minor" : "major",
560
+ };
561
+ const borrowed = degreeOf(parallel, pc);
562
+ base =
563
+ borrowed !== undefined
564
+ ? diatonicChord(parallel, borrowed, options.sevenths)
565
+ : makeChord(pc, "dim", ["6"]);
566
+ }
567
+ if (forced === undefined && extensions.length === 0) return base;
568
+ const quality = forced ?? base.quality;
569
+ const ext =
570
+ forced === undefined ? [...base.extensions, ...extensions] : extensions;
571
+ return makeChord(pc, quality, ext);
572
+ }
573
+
574
+ /** Manual (non-key) mode: the held buttons on the pressed root. */
575
+ export function manualChord(
576
+ pitch: number,
577
+ types: Iterable<ChordType>,
578
+ extensions: Iterable<Extension> = [],
579
+ ): Chord | undefined {
580
+ const quality = qualityOf(types);
581
+ const ext = [...extensions];
582
+ if (quality === undefined && ext.length === 0) return undefined;
583
+ return makeChord(pitch, quality ?? "maj", ext);
584
+ }
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // Roman numerals
588
+
589
+ const NUMERALS = ["i", "ii", "iii", "iv", "v", "vi", "vii"];
590
+
591
+ /** Roman numeral for a chord in a key (`ii`, `V7`, `bVII`, `vii°`). */
592
+ export function romanOf(key: Key, chord: Chord): string {
593
+ const scale = scaleOf(key);
594
+ let degree = scale.indexOf(chord.root);
595
+ let accidental = "";
596
+ if (degree < 0) {
597
+ // Name chromatic roots against the major scale: bIII, #iv°.
598
+ const major = MODES.major.map((step) => mod12(key.tonic + step));
599
+ const flat = major.indexOf(mod12(chord.root + 1));
600
+ const sharp = major.indexOf(mod12(chord.root - 1));
601
+ if (flat >= 0) {
602
+ degree = flat;
603
+ accidental = "b";
604
+ } else {
605
+ degree = Math.max(0, sharp);
606
+ accidental = "#";
607
+ }
608
+ }
609
+ const lower =
610
+ chord.quality === "min" ||
611
+ chord.quality === "dim" ||
612
+ chord.quality === "5" ||
613
+ chord.quality === "madd4" ||
614
+ chord.quality === "mb6";
615
+ const numeral = NUMERALS[degree]!;
616
+ const body = lower ? numeral : numeral.toUpperCase();
617
+ const ext = new Set(chord.extensions);
618
+ let mark = "";
619
+ if (chord.quality === "dim") mark = ext.has("m7") ? "ø" : "°";
620
+ else if (chord.quality === "aug") mark = "+";
621
+ const seventh =
622
+ ext.has("6") && chord.quality === "dim"
623
+ ? "7"
624
+ : ext.has("m7")
625
+ ? "7"
626
+ : ext.has("M7")
627
+ ? "maj7"
628
+ : "";
629
+ return `${accidental}${body}${mark}${seventh}`;
630
+ }
631
+
632
+ /**
633
+ * Parse a roman numeral in a key: `I`, `ii`, `V7`, `vii°`, `bVII`, `iv`,
634
+ * `IVmaj7`, `ii7`, `V/V` (secondary dominant), `Vsus4`.
635
+ *
636
+ * A numeral whose case matches the diatonic chord (lowercase for minor or
637
+ * diminished) takes the diatonic quality, so `vii` is diminished in major;
638
+ * a mismatched case is explicit (`iv` in major is minor, `IV` in minor is
639
+ * major). `7` adds the diatonic seventh; `maj7`/`M7` and `dom7` are exact.
640
+ */
641
+ export function parseRoman(key: Key, text: string): Chord | undefined {
642
+ if (typeof text !== "string" || text.length > 16) return undefined;
643
+ const trimmed = text.trim();
644
+ const slash = trimmed.match(/^(.+)\/(.+)$/);
645
+ if (slash) {
646
+ // V/x: the chord built on the degree of x in the key (secondary function).
647
+ const target = parseRoman(key, slash[2]!);
648
+ if (!target) return undefined;
649
+ const sub = parseRoman({ tonic: target.root, mode: "major" }, slash[1]!);
650
+ return sub;
651
+ }
652
+ const match = trimmed.match(
653
+ /^(b|#|♭|♯)?(vii|vi|v|iv|iii|ii|i|VII|VI|V|IV|III|II|I)(°|o|ø|\+)?(maj7|M7|dom7|7|9|maj9|6|sus4|sus2|sus|add9)?$/,
654
+ );
655
+ if (!match) return undefined;
656
+ const accidental =
657
+ match[1] === "b" || match[1] === "♭" ? -1 : match[1] ? 1 : 0;
658
+ const numeral = match[2]!;
659
+ const lower = numeral === numeral.toLowerCase();
660
+ const degree = NUMERALS.indexOf(numeral.toLowerCase());
661
+ const mark = match[3];
662
+ const suffix = match[4] ?? "";
663
+ const root =
664
+ accidental === 0
665
+ ? scaleOf(key)[degree]!
666
+ : mod12(key.tonic + MODES.major[degree]! + accidental);
667
+ const inKey = degreeOf(key, root);
668
+ const triad = inKey === undefined ? undefined : diatonicChord(key, inKey);
669
+ const seventh =
670
+ inKey === undefined ? undefined : diatonicChord(key, inKey, true);
671
+ const diatonicLower =
672
+ triad !== undefined && (triad.quality === "min" || triad.quality === "dim");
673
+ const matches = triad !== undefined && diatonicLower === lower;
674
+ let quality: Quality;
675
+ if (mark === "°" || mark === "o" || mark === "ø") quality = "dim";
676
+ else if (mark === "+") quality = "aug";
677
+ else if (matches) quality = triad.quality;
678
+ else quality = lower ? "min" : "maj";
679
+ // The diatonic seventh when the triad is the diatonic one, else b7.
680
+ const diatonicSeventh = (): Extension[] =>
681
+ mark === "ø"
682
+ ? ["m7"]
683
+ : seventh && seventh.quality === quality
684
+ ? [...seventh.extensions]
685
+ : quality === "dim" && mark !== undefined
686
+ ? ["6"]
687
+ : ["m7"];
688
+ let ext: Extension[] = mark === "ø" ? ["m7"] : [];
689
+ switch (suffix) {
690
+ case "7":
691
+ ext = diatonicSeventh();
692
+ break;
693
+ case "dom7":
694
+ ext = ["m7"];
695
+ break;
696
+ case "maj7":
697
+ case "M7":
698
+ ext = ["M7"];
699
+ break;
700
+ case "9":
701
+ ext = [...diatonicSeventh(), "9"];
702
+ break;
703
+ case "maj9":
704
+ ext = ["M7", "9"];
705
+ break;
706
+ case "6":
707
+ ext = ["6"];
708
+ break;
709
+ case "add9":
710
+ ext = ["9"];
711
+ break;
712
+ case "sus4":
713
+ case "sus":
714
+ quality = "sus4";
715
+ break;
716
+ case "sus2":
717
+ quality = "sus2";
718
+ break;
719
+ }
720
+ return makeChord(root, quality, ext);
721
+ }
722
+
723
+ // ---------------------------------------------------------------------------
724
+ // Voicing
725
+
726
+ /** Default chord register: a voicing is kept inside [low, high]. */
727
+ export const VOICING_RANGE = Object.freeze({ low: 48, high: 79 });
728
+ /** Voicing dial: Orchid-style rotation steps, -12..12. */
729
+ export const MAX_VOICING_STEP = 12;
730
+ export const SPREADS = ["close", "open", "wide"] as const;
731
+ export type Spread = (typeof SPREADS)[number];
732
+
733
+ export type VoicingOptions = Readonly<{
734
+ /** Rotation steps from root position (Orchid's voicing dial). */
735
+ inversion?: number;
736
+ spread?: Spread;
737
+ /** Voice-lead from this voicing (minimal movement). */
738
+ previous?: readonly number[] | undefined;
739
+ /** Root-position anchor: the root lands at or above this pitch. */
740
+ anchor?: number;
741
+ low?: number;
742
+ high?: number;
743
+ }>;
744
+
745
+ /**
746
+ * Root position of a chord with its root at or above `anchor`, then the
747
+ * Orchid voicing dial: each positive step moves the lowest note up an
748
+ * octave, each negative step the highest note down.
749
+ */
750
+ export function rotate(pitches: readonly number[], steps: number): number[] {
751
+ const notes = [...pitches].sort((a, b) => a - b);
752
+ if (notes.length === 0) return notes;
753
+ for (let i = 0; i < Math.abs(Math.trunc(steps)); i += 1) {
754
+ if (steps > 0) notes.push(notes.shift()! + 12);
755
+ else notes.unshift(notes.pop()! - 12);
756
+ }
757
+ return notes;
758
+ }
759
+
760
+ export function rootPosition(chord: Chord, anchor = 60): number[] {
761
+ const rootPitch = anchor + mod12(chord.root - anchor);
762
+ return chordIntervals(chord).map((step) => rootPitch + step);
763
+ }
764
+
765
+ /** Open voicings: `open` drops the second voice from the top an octave
766
+ * (drop 2); `wide` also drops the fourth from the top (drop 2+4). */
767
+ export function applySpread(
768
+ pitches: readonly number[],
769
+ spread: Spread,
770
+ ): number[] {
771
+ const notes = [...pitches].sort((a, b) => a - b);
772
+ if (spread === "close" || notes.length < 3) return notes;
773
+ const n = notes.length;
774
+ notes[n - 2] = notes[n - 2]! - 12;
775
+ if (spread === "wide" && n >= 4) notes[n - 4] = notes[n - 4]! - 12;
776
+ return notes.sort((a, b) => a - b);
777
+ }
778
+
779
+ /**
780
+ * Movement between two voicings: each voice of the new chord pays its
781
+ * distance to the nearest previous voice and vice versa, so voicings of
782
+ * different sizes compare and common tones are free.
783
+ */
784
+ export function movement(a: readonly number[], b: readonly number[]): number {
785
+ if (a.length === 0 || b.length === 0) return 0;
786
+ const nearest = (pitch: number, set: readonly number[]) =>
787
+ Math.min(...set.map((other) => Math.abs(other - pitch)));
788
+ let total = 0;
789
+ for (const pitch of b) total += nearest(pitch, a);
790
+ for (const pitch of a) total += nearest(pitch, b);
791
+ return total;
792
+ }
793
+
794
+ /**
795
+ * Voice a chord. Without `previous`, root position at `anchor` rotated by
796
+ * `inversion` (the Orchid dial). With `previous` (voice leading), every
797
+ * rotation within an octave either side of the dial is tried and the one
798
+ * with the least movement wins; ties go to the voicing nearest the dial,
799
+ * then the lower one. Results stay within [low, high] when they fit.
800
+ */
801
+ export function voiceChord(
802
+ chord: Chord,
803
+ options: VoicingOptions = {},
804
+ ): number[] {
805
+ const anchor = options.anchor ?? 60;
806
+ const spread = options.spread ?? "close";
807
+ const low = options.low ?? VOICING_RANGE.low;
808
+ const high = options.high ?? VOICING_RANGE.high;
809
+ const dial = clampInt(
810
+ options.inversion ?? 0,
811
+ -MAX_VOICING_STEP,
812
+ MAX_VOICING_STEP,
813
+ );
814
+ const base = rootPosition(chord, anchor);
815
+ const size = base.length;
816
+ const fit = (notes: number[]) =>
817
+ notes.every((pitch) => pitch >= low && pitch <= high);
818
+ const clampMidi = (notes: number[]) =>
819
+ notes.map((pitch) => Math.max(0, Math.min(127, pitch)));
820
+ const at = (steps: number) => applySpread(rotate(base, steps), spread);
821
+ if (!options.previous || options.previous.length === 0)
822
+ return clampMidi(at(dial));
823
+ let best: { notes: number[]; cost: number; distance: number } | undefined;
824
+ for (let offset = -size; offset <= size; offset += 1) {
825
+ const notes = at(dial + offset);
826
+ if (!fit(notes) && offset !== 0) continue;
827
+ const cost = movement(options.previous, notes);
828
+ const distance = Math.abs(offset);
829
+ if (
830
+ !best ||
831
+ cost < best.cost ||
832
+ (cost === best.cost && distance < best.distance)
833
+ )
834
+ best = { notes, cost, distance };
835
+ }
836
+ return clampMidi(best!.notes);
837
+ }
838
+
839
+ /** Bass under a chord: its slash bass or root, in C2..B2 by default. */
840
+ export function bassNote(chord: Chord, low = 36): number {
841
+ return low + mod12((chord.bass ?? chord.root) - low);
842
+ }
843
+
844
+ /**
845
+ * Orchid's bass behaviours (manual 10.2 and the "How to use Bass" article),
846
+ * plus `off`. Labels in BASS_MODE_TEXT; `chords` is Orchid's default
847
+ * "Chords Only".
848
+ */
849
+ export const BASS_MODES = [
850
+ "off",
851
+ "chords",
852
+ "unison",
853
+ "single",
854
+ "solo",
855
+ ] as const;
856
+ export type BassMode = (typeof BASS_MODES)[number];
857
+
858
+ export const BASS_MODE_TEXT: Readonly<Record<BassMode, string>> = {
859
+ off: "no bass",
860
+ chords: "bass root under chords only",
861
+ unison: "bass doubles single notes; root under chords",
862
+ single: "single notes play bass only; chords play treble and root",
863
+ solo: "bass only: the treble is muted, even for chords",
864
+ };
865
+
866
+ /** What one key press sounds once the bass mode has routed it. */
867
+ export type BassRoute = Readonly<{
868
+ /** Whether the treble (chord or single note) sounds. */
869
+ treble: boolean;
870
+ /** Bass pitch, or undefined for none. */
871
+ bass: number | undefined;
872
+ }>;
873
+
874
+ /**
875
+ * Route a key press through a bass mode. `chord` is the chord the key
876
+ * played (undefined for a single note); `pitch` the pressed key; `low` the
877
+ * bottom of the bass octave. Sourced semantics (Orchid manual 10.2, support
878
+ * article "How to use Bass on Orchid"): `chords` adds the chord's root only
879
+ * when a chord plays; `unison` plays bass and treble together on single
880
+ * notes; `single` plays only bass on single notes and the treble only on
881
+ * chords; `solo` mutes the treble entirely, even for chords. dawg's
882
+ * reading where the sources are silent: every mode that sounds bass under
883
+ * a chord uses the chord's root (or slash bass), and a single note's bass
884
+ * is the pressed pitch class in the bass octave.
885
+ */
886
+ export function routeBass(
887
+ mode: BassMode,
888
+ chord: Chord | undefined,
889
+ pitch: number,
890
+ low = 36,
891
+ ): BassRoute {
892
+ const under = chord ? bassNote(chord, low) : low + mod12(pitch - low);
893
+ switch (mode) {
894
+ case "off":
895
+ return { treble: true, bass: undefined };
896
+ case "chords":
897
+ return { treble: true, bass: chord ? under : undefined };
898
+ case "unison":
899
+ return { treble: true, bass: under };
900
+ case "single":
901
+ return { treble: chord !== undefined, bass: under };
902
+ case "solo":
903
+ return { treble: false, bass: under };
904
+ }
905
+ }
906
+
907
+ export function parseBassMode(value: string | undefined): BassMode | undefined {
908
+ const text = (value ?? "").trim().toLowerCase();
909
+ if (text === "on" || text === "true") return "chords";
910
+ if (text === "false" || text === "none") return "off";
911
+ if (text === "single-notes" || text === "singles") return "single";
912
+ return (BASS_MODES as readonly string[]).includes(text)
913
+ ? (text as BassMode)
914
+ : undefined;
915
+ }
916
+
917
+ // ---------------------------------------------------------------------------
918
+ // Performance
919
+
920
+ export const PERFORM_MODES = [
921
+ "block",
922
+ "strum-up",
923
+ "strum-down",
924
+ "arp-up",
925
+ "arp-down",
926
+ "arp-updown",
927
+ "arp-random",
928
+ "harp",
929
+ "slop",
930
+ "pattern",
931
+ ] as const;
932
+ export type PerformMode = (typeof PERFORM_MODES)[number];
933
+
934
+ export type PerformOptions = Readonly<{
935
+ mode?: PerformMode;
936
+ /** Arp step in beats (the grid), default 1/8 beat... 0.25. */
937
+ rate?: number;
938
+ /** Arp/harp octaves, 1..4. */
939
+ octaves?: number;
940
+ /** Strum gap between voices in beats, default 1/32 beat. */
941
+ strum?: number;
942
+ /** Seed for arp-random and slop. */
943
+ seed?: number;
944
+ /** Slop amount 0..1: each voice lands up to `slop` × 1/8 beat late. */
945
+ slop?: number;
946
+ /** Pattern mode: a CHORD_PATTERNS name or 1-based number, default 1. */
947
+ pattern?: string | number;
948
+ /** 0..1. */
949
+ velocity?: number;
950
+ }>;
951
+
952
+ export type PerformedNote = Readonly<{
953
+ pitch: number;
954
+ /** Beats. */
955
+ start: number;
956
+ length: number;
957
+ velocity: number;
958
+ }>;
959
+
960
+ export const DEFAULT_ARP_RATE = 0.25;
961
+ export const DEFAULT_STRUM = 1 / 32;
962
+ export const DEFAULT_SLOP = 0.5;
963
+ /** Latest a slopped voice can land, in beats, at slop 1. */
964
+ export const MAX_SLOP = 1 / 8;
965
+
966
+ // ---------------------------------------------------------------------------
967
+ // Patterns
968
+
969
+ /**
970
+ * One hit of a chord pattern. `voices` picks chord tones by index, low to
971
+ * high: `all`, `upper` (all but the lowest), or a list where an index past
972
+ * the top wraps an octave up (index 3 of a triad is the root +12) and a
973
+ * negative index counts down from the top (-1 is the highest voice).
974
+ */
975
+ export type PatternHit = Readonly<{
976
+ /** Beats from the cycle start. */
977
+ at: number;
978
+ /** Beats. */
979
+ length: number;
980
+ voices: "all" | "upper" | readonly number[];
981
+ /** 0..1, scaled by the press velocity. */
982
+ velocity: number;
983
+ /** Octave shift for these voices (the bass half of oom-pah is -1). */
984
+ octave?: number;
985
+ }>;
986
+
987
+ export type ChordPattern = Readonly<{
988
+ name: string;
989
+ description: string;
990
+ /** Cycle length in beats; the pattern repeats from the press. */
991
+ beats: number;
992
+ hits: readonly PatternHit[];
993
+ }>;
994
+
995
+ const everyStep = (
996
+ step: number,
997
+ beats: number,
998
+ hit: (index: number) => Omit<PatternHit, "at">,
999
+ ): PatternHit[] =>
1000
+ Array.from({ length: Math.round(beats / step) }, (_, index) => ({
1001
+ at: index * step,
1002
+ ...hit(index),
1003
+ }));
1004
+
1005
+ /**
1006
+ * Pattern mode. Orchid's own patterns are not published (manual 7.2: "Plays
1007
+ * chord notes in pre-determined rhythmic patterns", tempo-synced, the
1008
+ * rhythm independent of the chord's note count, with per-note velocities
1009
+ * scaled by the press; 11 at launch and two more in firmware 3.84). These
1010
+ * 13 are dawg's own design in that spirit: each hit names voices by index
1011
+ * so the rhythm holds for triads and 9th chords alike.
1012
+ */
1013
+ export const CHORD_PATTERNS: readonly ChordPattern[] = Object.freeze([
1014
+ {
1015
+ name: "eighths",
1016
+ description: "straight 8ths, beats accented",
1017
+ beats: 4,
1018
+ hits: everyStep(0.5, 4, (i) => ({
1019
+ length: 0.45,
1020
+ voices: "all",
1021
+ velocity: i % 2 === 0 ? 1 : 0.7,
1022
+ })),
1023
+ },
1024
+ {
1025
+ name: "sixteenths",
1026
+ description: "straight 16ths, 1-e-&-a accents",
1027
+ beats: 4,
1028
+ hits: everyStep(0.25, 4, (i) => ({
1029
+ length: 0.2,
1030
+ voices: "all",
1031
+ velocity: [1, 0.55, 0.8, 0.55][i % 4]!,
1032
+ })),
1033
+ },
1034
+ {
1035
+ name: "offbeat",
1036
+ description: "short stabs on every &",
1037
+ beats: 4,
1038
+ hits: everyStep(1, 4, () => ({
1039
+ length: 0.25,
1040
+ voices: "all",
1041
+ velocity: 0.9,
1042
+ })).map((hit) => ({ ...hit, at: hit.at + 0.5 })),
1043
+ },
1044
+ {
1045
+ name: "pop",
1046
+ description: "syncopated pop comp with 16th pushes",
1047
+ beats: 4,
1048
+ hits: [
1049
+ { at: 0, length: 0.5, voices: "all", velocity: 1 },
1050
+ { at: 0.75, length: 0.5, voices: "upper", velocity: 0.7 },
1051
+ { at: 1.5, length: 0.75, voices: "all", velocity: 0.85 },
1052
+ { at: 2.5, length: 0.5, voices: "upper", velocity: 0.7 },
1053
+ { at: 3, length: 0.25, voices: "all", velocity: 0.6 },
1054
+ { at: 3.5, length: 0.5, voices: "all", velocity: 0.85 },
1055
+ ],
1056
+ },
1057
+ {
1058
+ name: "charleston",
1059
+ description: "dotted quarter, then the & of 2",
1060
+ beats: 4,
1061
+ hits: [
1062
+ { at: 0, length: 0.75, voices: "all", velocity: 1 },
1063
+ { at: 1.5, length: 0.5, voices: "all", velocity: 0.85 },
1064
+ ],
1065
+ },
1066
+ {
1067
+ name: "bossa",
1068
+ description: "two-bar bossa comp over a root-fifth pulse",
1069
+ beats: 8,
1070
+ hits: [
1071
+ ...everyStep(2, 8, () => ({
1072
+ length: 1.5,
1073
+ voices: [0],
1074
+ velocity: 0.85,
1075
+ octave: -1,
1076
+ })),
1077
+ ...[0, 1.5, 3, 4.5, 6].map((at) => ({
1078
+ at,
1079
+ length: 0.5,
1080
+ voices: "upper" as const,
1081
+ velocity: at === 0 ? 0.9 : 0.75,
1082
+ })),
1083
+ ],
1084
+ },
1085
+ {
1086
+ name: "skank",
1087
+ description: "reggae skank: short upper stabs on 2 and 4",
1088
+ beats: 4,
1089
+ hits: [1, 3].map((at) => ({
1090
+ at,
1091
+ length: 0.2,
1092
+ voices: "upper" as const,
1093
+ velocity: 0.95,
1094
+ })),
1095
+ },
1096
+ {
1097
+ name: "gallop",
1098
+ description: "gallop: an 8th and two 16ths per beat",
1099
+ beats: 4,
1100
+ hits: everyStep(1, 4, () => ({
1101
+ length: 0.4,
1102
+ voices: "all",
1103
+ velocity: 1,
1104
+ })).flatMap((hit) => [
1105
+ hit,
1106
+ { ...hit, at: hit.at + 0.5, length: 0.2, velocity: 0.7 },
1107
+ { ...hit, at: hit.at + 0.75, length: 0.2, velocity: 0.75 },
1108
+ ]),
1109
+ },
1110
+ {
1111
+ name: "half-time",
1112
+ description: "half-time: a long hit and a pickup per two bars",
1113
+ beats: 8,
1114
+ hits: [
1115
+ { at: 0, length: 3.5, voices: "all", velocity: 1 },
1116
+ { at: 4, length: 1.5, voices: "all", velocity: 0.8 },
1117
+ { at: 7.5, length: 0.5, voices: "upper", velocity: 0.65 },
1118
+ ],
1119
+ },
1120
+ {
1121
+ name: "tresillo",
1122
+ description: "tresillo 3+3+2",
1123
+ beats: 4,
1124
+ hits: [
1125
+ { at: 0, length: 1.25, voices: "all", velocity: 1 },
1126
+ { at: 1.5, length: 1.25, voices: "all", velocity: 0.8 },
1127
+ { at: 3, length: 0.75, voices: "all", velocity: 0.9 },
1128
+ ],
1129
+ },
1130
+ {
1131
+ name: "oom-pah",
1132
+ description: "alternating bass and chord: low root, upper chord",
1133
+ beats: 4,
1134
+ hits: everyStep(1, 4, (i) =>
1135
+ i % 2 === 0
1136
+ ? { length: 0.9, voices: [0], velocity: 1, octave: -1 }
1137
+ : { length: 0.8, voices: "upper", velocity: 0.75 },
1138
+ ),
1139
+ },
1140
+ {
1141
+ name: "roll",
1142
+ description: "broken-chord roll up in 16ths, ringing to the half bar",
1143
+ beats: 4,
1144
+ hits: [0, 2].flatMap((bar) =>
1145
+ [0, 1, 2, 3].map((step) => ({
1146
+ at: bar + step * 0.25,
1147
+ length: 2 - step * 0.25,
1148
+ voices: [step],
1149
+ velocity: 0.7 + step * 0.08,
1150
+ })),
1151
+ ),
1152
+ },
1153
+ {
1154
+ name: "pick",
1155
+ description: "broken-chord picking: low, high, middle, high in 8ths",
1156
+ beats: 4,
1157
+ hits: everyStep(0.5, 4, (i) => ({
1158
+ length: 0.5,
1159
+ voices: [[0, -1, 1, -1][i % 4]!],
1160
+ velocity: i % 4 === 0 ? 0.95 : 0.7,
1161
+ })),
1162
+ },
1163
+ ]);
1164
+
1165
+ /** A pattern by name or 1-based number, or undefined. */
1166
+ export function findChordPattern(
1167
+ value: string | number | undefined,
1168
+ ): ChordPattern | undefined {
1169
+ if (value === undefined) return undefined;
1170
+ const text = String(value).trim().toLowerCase();
1171
+ const number = Number(text);
1172
+ if (/^\d+$/.test(text)) return CHORD_PATTERNS[number - 1];
1173
+ return CHORD_PATTERNS.find((pattern) => pattern.name === text);
1174
+ }
1175
+
1176
+ function patternVoices(notes: readonly number[], hit: PatternHit): number[] {
1177
+ const n = notes.length;
1178
+ const indices =
1179
+ hit.voices === "all"
1180
+ ? notes.map((_, i) => i)
1181
+ : hit.voices === "upper"
1182
+ ? n > 1
1183
+ ? notes.slice(1).map((_, i) => i + 1)
1184
+ : [0]
1185
+ : hit.voices;
1186
+ const shift = 12 * (hit.octave ?? 0);
1187
+ const out = new Set<number>();
1188
+ for (const index of indices) {
1189
+ const i = index < 0 ? ((index % n) + n) % n : index;
1190
+ const wrapped = ((i % n) + n) % n;
1191
+ const pitch = notes[wrapped]! + 12 * Math.floor(i / n) + shift;
1192
+ if (pitch >= 0 && pitch <= 127) out.add(pitch);
1193
+ }
1194
+ return [...out].sort((a, b) => a - b);
1195
+ }
1196
+
1197
+ /**
1198
+ * Lay a voiced chord out in time over [start, start + length). Block holds
1199
+ * every voice; strums offset voices by `strum` beats and hold to the end;
1200
+ * arpeggios step one voice per `rate` beats across `octaves`, aligned to
1201
+ * multiples of `rate` from `start`; harp is an upward strum across the
1202
+ * octaves that rings to the end; slop (Orchid's humanised timing) holds
1203
+ * every voice like block but delays each by a seeded random fraction of
1204
+ * `slop` × MAX_SLOP, so each seed lands differently; pattern repeats a
1205
+ * CHORD_PATTERNS rhythm from `start`, each hit's velocity scaled by
1206
+ * `velocity`.
1207
+ */
1208
+ export function perform(
1209
+ pitches: readonly number[],
1210
+ start: number,
1211
+ length: number,
1212
+ options: PerformOptions = {},
1213
+ ): PerformedNote[] {
1214
+ const mode = options.mode ?? "block";
1215
+ const velocity = options.velocity ?? 0.8;
1216
+ const notes = [...pitches].sort((a, b) => a - b);
1217
+ if (notes.length === 0 || !(length > 0)) return [];
1218
+ const end = start + length;
1219
+ const octaves = clampInt(options.octaves ?? 1, 1, 4);
1220
+ const spanned: number[] = [];
1221
+ for (let o = 0; o < octaves; o += 1)
1222
+ for (const pitch of notes)
1223
+ if (pitch + 12 * o <= 127) spanned.push(pitch + 12 * o);
1224
+ const at = (pitch: number, from: number, to: number): PerformedNote => ({
1225
+ pitch,
1226
+ start: round6(from),
1227
+ length: round6(Math.max(1e-6, to - from)),
1228
+ velocity,
1229
+ });
1230
+ switch (mode) {
1231
+ case "block":
1232
+ return notes.map((pitch) => at(pitch, start, end));
1233
+ case "strum-up":
1234
+ case "strum-down": {
1235
+ const gap = Math.max(0, options.strum ?? DEFAULT_STRUM);
1236
+ const order = mode === "strum-up" ? notes : [...notes].reverse();
1237
+ return order
1238
+ .map((pitch, index) =>
1239
+ at(pitch, Math.min(end - gap, start + index * gap), end),
1240
+ )
1241
+ .filter((note) => note.length > 0);
1242
+ }
1243
+ case "slop": {
1244
+ const amount = Math.min(1, Math.max(0, options.slop ?? DEFAULT_SLOP));
1245
+ const random = mulberry32(options.seed ?? 0);
1246
+ const late = Math.min(amount * MAX_SLOP, length / 2);
1247
+ return notes.map((pitch) => at(pitch, start + random() * late, end));
1248
+ }
1249
+ case "pattern": {
1250
+ const pattern =
1251
+ findChordPattern(options.pattern ?? 1) ?? CHORD_PATTERNS[0]!;
1252
+ const out: PerformedNote[] = [];
1253
+ for (let cycle = start; cycle < end - 1e-9; cycle += pattern.beats)
1254
+ for (const hit of pattern.hits) {
1255
+ const from = cycle + hit.at;
1256
+ if (from >= end - 1e-9) continue;
1257
+ const to = Math.min(end, from + hit.length);
1258
+ for (const pitch of patternVoices(notes, hit))
1259
+ out.push({
1260
+ ...at(pitch, from, to),
1261
+ velocity: round6(velocity * hit.velocity),
1262
+ });
1263
+ }
1264
+ return out.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
1265
+ }
1266
+ case "harp": {
1267
+ const gap = Math.max(0, options.strum ?? DEFAULT_STRUM * 2);
1268
+ return spanned.map((pitch, index) =>
1269
+ at(pitch, Math.min(end - 1e-3, start + index * gap), end),
1270
+ );
1271
+ }
1272
+ default: {
1273
+ const rate =
1274
+ options.rate && options.rate > 0 ? options.rate : DEFAULT_ARP_RATE;
1275
+ const steps = Math.max(1, Math.floor(length / rate + 1e-9));
1276
+ let order: number[];
1277
+ if (mode === "arp-down") order = [...spanned].reverse();
1278
+ else if (mode === "arp-updown")
1279
+ order =
1280
+ spanned.length > 2
1281
+ ? [...spanned, ...spanned.slice(1, -1).reverse()]
1282
+ : spanned;
1283
+ else order = spanned;
1284
+ const random = mulberry32(options.seed ?? 0);
1285
+ const out: PerformedNote[] = [];
1286
+ for (let step = 0; step < steps; step += 1) {
1287
+ const from = start + step * rate;
1288
+ const to = Math.min(end, from + rate);
1289
+ const pitch =
1290
+ mode === "arp-random"
1291
+ ? spanned[Math.floor(random() * spanned.length)]!
1292
+ : order[step % order.length]!;
1293
+ out.push(at(pitch, from, to));
1294
+ }
1295
+ return out;
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ // ---------------------------------------------------------------------------
1301
+ // Progressions
1302
+
1303
+ /** A progression preset: roman numerals and the mode they read in. */
1304
+ export type ProgressionPreset = Readonly<{
1305
+ name: string;
1306
+ mode: "major" | "minor" | "dorian" | "mixolydian";
1307
+ numerals: readonly string[];
1308
+ /** Use diatonic sevenths. */
1309
+ sevenths?: boolean;
1310
+ description: string;
1311
+ }>;
1312
+
1313
+ export const PROGRESSION_PRESETS: readonly ProgressionPreset[] = Object.freeze([
1314
+ {
1315
+ name: "axis",
1316
+ mode: "major",
1317
+ numerals: ["I", "V", "vi", "IV"],
1318
+ description: "I–V–vi–IV, the four-chord pop loop",
1319
+ },
1320
+ {
1321
+ name: "sad-pop",
1322
+ mode: "major",
1323
+ numerals: ["vi", "IV", "I", "V"],
1324
+ description: "vi–IV–I–V, the same loop from the relative minor",
1325
+ },
1326
+ {
1327
+ name: "fifties",
1328
+ mode: "major",
1329
+ numerals: ["I", "vi", "IV", "V"],
1330
+ description: "I–vi–IV–V doo-wop",
1331
+ },
1332
+ {
1333
+ name: "ii-v-i",
1334
+ mode: "major",
1335
+ numerals: ["ii", "V", "I", "I"],
1336
+ sevenths: true,
1337
+ description: "ii7–V7–Imaj7, the jazz cadence",
1338
+ },
1339
+ {
1340
+ name: "turnaround",
1341
+ mode: "major",
1342
+ numerals: ["I", "vi", "ii", "V"],
1343
+ sevenths: true,
1344
+ description: "Imaj7–vi7–ii7–V7 turnaround",
1345
+ },
1346
+ {
1347
+ name: "canon",
1348
+ mode: "major",
1349
+ numerals: ["I", "V", "vi", "iii", "IV", "I", "IV", "V"],
1350
+ description: "Pachelbel's canon",
1351
+ },
1352
+ {
1353
+ name: "aeolian",
1354
+ mode: "minor",
1355
+ numerals: ["i", "VI", "III", "VII"],
1356
+ description: "i–VI–III–VII minor anthem",
1357
+ },
1358
+ {
1359
+ name: "andalusian",
1360
+ mode: "minor",
1361
+ numerals: ["i", "VII", "VI", "V"],
1362
+ description: "i–VII–VI–V descending (major V)",
1363
+ },
1364
+ {
1365
+ name: "minor-ii-v",
1366
+ mode: "minor",
1367
+ numerals: ["iiø", "V7", "i", "i"],
1368
+ sevenths: true,
1369
+ description: "iiø7–V7–i minor cadence",
1370
+ },
1371
+ {
1372
+ name: "dorian-vamp",
1373
+ mode: "dorian",
1374
+ numerals: ["i", "IV"],
1375
+ sevenths: true,
1376
+ description: "i7–IV7 dorian vamp",
1377
+ },
1378
+ {
1379
+ name: "mixolydian-rock",
1380
+ mode: "mixolydian",
1381
+ numerals: ["I", "bVII", "IV", "I"],
1382
+ description: "I–bVII–IV–I mixolydian rock",
1383
+ },
1384
+ ]);
1385
+
1386
+ export const PROGRESSION_STYLES = [
1387
+ "pop",
1388
+ "jazz",
1389
+ "modal",
1390
+ "classical",
1391
+ ] as const;
1392
+ export type ProgressionStyle = (typeof PROGRESSION_STYLES)[number];
1393
+
1394
+ /**
1395
+ * Functional-harmony transition weights between scale degrees (0 = I),
1396
+ * per style. Tonic (I, vi, iii) moves to predominant (IV, ii), which moves
1397
+ * to dominant (V, vii°), which resolves to tonic; pop adds the plagal and
1398
+ * vi–IV moves, jazz favours the cycle of fifths, modal keeps to the tonic
1399
+ * and its neighbours.
1400
+ */
1401
+ export const TRANSITIONS: Readonly<
1402
+ Record<ProgressionStyle, readonly (readonly number[])[]>
1403
+ > = Object.freeze({
1404
+ // I ii iii IV V vi vii
1405
+ pop: [
1406
+ [0, 1, 1, 4, 4, 4, 0], // I
1407
+ [1, 0, 0, 2, 5, 1, 0], // ii
1408
+ [0, 0, 0, 3, 1, 4, 0], // iii
1409
+ [4, 1, 0, 0, 4, 2, 0], // IV
1410
+ [4, 0, 0, 2, 0, 4, 0], // V
1411
+ [1, 2, 1, 5, 3, 0, 0], // vi
1412
+ [5, 0, 1, 0, 0, 1, 0], // vii°
1413
+ ],
1414
+ jazz: [
1415
+ [0, 4, 1, 2, 1, 4, 0],
1416
+ [0, 0, 0, 0, 8, 0, 1],
1417
+ [0, 0, 0, 1, 0, 6, 0],
1418
+ [2, 2, 0, 0, 2, 0, 3],
1419
+ [6, 0, 0, 0, 0, 2, 0],
1420
+ [0, 7, 0, 1, 1, 0, 0],
1421
+ [2, 0, 5, 0, 0, 0, 0],
1422
+ ],
1423
+ modal: [
1424
+ [0, 3, 1, 4, 1, 2, 2],
1425
+ [5, 0, 1, 1, 0, 0, 1],
1426
+ [3, 1, 0, 1, 0, 0, 1],
1427
+ [5, 1, 0, 0, 1, 0, 2],
1428
+ [3, 0, 0, 2, 0, 1, 1],
1429
+ [3, 1, 0, 1, 0, 0, 1],
1430
+ [5, 0, 0, 2, 0, 0, 0],
1431
+ ],
1432
+ classical: [
1433
+ [0, 2, 1, 4, 5, 3, 1],
1434
+ [0, 0, 0, 0, 6, 0, 2],
1435
+ [0, 0, 0, 2, 0, 5, 0],
1436
+ [2, 3, 0, 0, 5, 0, 1],
1437
+ [6, 0, 0, 0, 0, 2, 0],
1438
+ [0, 4, 0, 4, 1, 0, 0],
1439
+ [6, 0, 1, 0, 0, 0, 0],
1440
+ ],
1441
+ });
1442
+
1443
+ /** Seeded PRNG (mulberry32): same seed, same sequence. */
1444
+ export function mulberry32(seed: number): () => number {
1445
+ let state = Math.trunc(seed) >>> 0;
1446
+ return () => {
1447
+ state = (state + 0x6d2b79f5) >>> 0;
1448
+ let t = state;
1449
+ t = Math.imul(t ^ (t >>> 15), t | 1);
1450
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
1451
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1452
+ };
1453
+ }
1454
+
1455
+ function pick(weights: readonly number[], random: () => number): number {
1456
+ const total = weights.reduce((sum, w) => sum + w, 0);
1457
+ if (!(total > 0)) return 0;
1458
+ let roll = random() * total;
1459
+ for (let i = 0; i < weights.length; i += 1) {
1460
+ roll -= weights[i]!;
1461
+ if (roll < 0) return i;
1462
+ }
1463
+ return weights.length - 1;
1464
+ }
1465
+
1466
+ export function findPreset(name: string): ProgressionPreset | undefined {
1467
+ const wanted = name.trim().toLowerCase();
1468
+ return PROGRESSION_PRESETS.find((preset) => preset.name === wanted);
1469
+ }
1470
+
1471
+ /** The next degree the graph favours most after `degree` (no randomness). */
1472
+ export function likelyNext(
1473
+ degree: number,
1474
+ style: ProgressionStyle = "pop",
1475
+ ): number {
1476
+ const row = TRANSITIONS[style][((degree % 7) + 7) % 7]!;
1477
+ let best = 0;
1478
+ for (let i = 1; i < row.length; i += 1) if (row[i]! > row[best]!) best = i;
1479
+ return best;
1480
+ }
1481
+
1482
+ /**
1483
+ * The chord play mode shows as "next": with a preset, the preset chord
1484
+ * after the last one played (matched by root); otherwise the strongest
1485
+ * graph transition from the last chord's degree, or I when there is none.
1486
+ */
1487
+ export function suggestNext(
1488
+ key: Key,
1489
+ last: Chord | undefined,
1490
+ options: Readonly<{
1491
+ preset?: string;
1492
+ style?: ProgressionStyle;
1493
+ sevenths?: boolean;
1494
+ }> = {},
1495
+ ): Chord {
1496
+ const preset = options.preset ? findPreset(options.preset) : undefined;
1497
+ if (preset) {
1498
+ const chords = presetChords(key, preset);
1499
+ const index = last
1500
+ ? chords.findIndex((chord) => chord.root === last.root)
1501
+ : -1;
1502
+ return chords[(index + 1) % chords.length]!;
1503
+ }
1504
+ const degree = last ? degreeOf(key, last.root) : undefined;
1505
+ const next = degree === undefined ? 0 : likelyNext(degree, options.style);
1506
+ return diatonicChord(key, next, options.sevenths);
1507
+ }
1508
+
1509
+ /** A preset's chords in `key` (numerals read in the key's own mode). */
1510
+ export function presetChords(key: Key, preset: ProgressionPreset): Chord[] {
1511
+ return preset.numerals.map((numeral) => {
1512
+ const chord = parseRoman(key, numeral);
1513
+ if (!chord) throw new Error(`bad preset numeral ${numeral}`);
1514
+ if (!preset.sevenths || chord.extensions.length > 0) return chord;
1515
+ const withSeventh = parseRoman(key, `${numeral}7`);
1516
+ return withSeventh ?? chord;
1517
+ });
1518
+ }
1519
+
1520
+ export type ProgressionRequest = Readonly<{
1521
+ key: Key;
1522
+ /** Number of chords, 1..64. */
1523
+ length: number;
1524
+ /** A preset name or a style for the random walk. */
1525
+ style?: ProgressionStyle | string;
1526
+ seed?: number;
1527
+ sevenths?: boolean;
1528
+ }>;
1529
+
1530
+ /**
1531
+ * A progression of `length` chords. A preset name cycles the preset. A
1532
+ * style walks the transition graph from I with a seeded PRNG; when the
1533
+ * progression is 4+ chords long, the last chord is drawn from the
1534
+ * dominant-function chords (V, vii°, or IV in modal) so the loop leads
1535
+ * back to I. Same request, same chords.
1536
+ */
1537
+ export function generateProgression(request: ProgressionRequest): Chord[] {
1538
+ const length = clampInt(request.length, 1, 64);
1539
+ const preset = request.style ? findPreset(request.style) : undefined;
1540
+ if (preset) {
1541
+ const chords = presetChords(request.key, preset);
1542
+ return Array.from({ length }, (_, i) => chords[i % chords.length]!);
1543
+ }
1544
+ const style = (PROGRESSION_STYLES as readonly string[]).includes(
1545
+ request.style ?? "",
1546
+ )
1547
+ ? (request.style as ProgressionStyle)
1548
+ : "pop";
1549
+ const sevenths = request.sevenths ?? style === "jazz";
1550
+ const random = mulberry32(request.seed ?? 1);
1551
+ const degrees: number[] = [0];
1552
+ for (let i = 1; i < length; i += 1) {
1553
+ const row: number[] = [...TRANSITIONS[style][degrees[i - 1]!]!];
1554
+ if (i === length - 1 && length >= 4) {
1555
+ const cadence = style === "modal" ? [3, 6] : [4, 6];
1556
+ for (let d = 0; d < 7; d += 1) if (!cadence.includes(d)) row[d] = 0;
1557
+ if (!row.some((w) => w > 0)) row[cadence[0]!] = 1;
1558
+ }
1559
+ degrees.push(pick(row, random));
1560
+ }
1561
+ return degrees.map((degree) => diatonicChord(request.key, degree, sevenths));
1562
+ }
1563
+
1564
+ /** A chord with its voicing, as tools and the SDK report it. */
1565
+ export type VoicedChord = Readonly<{
1566
+ name: string;
1567
+ roman: string;
1568
+ pitches: readonly number[];
1569
+ bass?: number | undefined;
1570
+ }>;
1571
+
1572
+ /** Voice a progression with minimal movement chord to chord. */
1573
+ export function voiceProgression(
1574
+ key: Key,
1575
+ chords: readonly Chord[],
1576
+ options: Readonly<{
1577
+ inversion?: number;
1578
+ spread?: Spread;
1579
+ bass?: boolean;
1580
+ anchor?: number;
1581
+ lead?: boolean;
1582
+ }> = {},
1583
+ ): VoicedChord[] {
1584
+ const flats = keyUsesFlats(key);
1585
+ let previous: number[] | undefined;
1586
+ return chords.map((chord) => {
1587
+ const pitches = voiceChord(chord, {
1588
+ ...(options.inversion !== undefined
1589
+ ? { inversion: options.inversion }
1590
+ : {}),
1591
+ ...(options.spread ? { spread: options.spread } : {}),
1592
+ ...(options.anchor !== undefined ? { anchor: options.anchor } : {}),
1593
+ previous: options.lead === false ? undefined : previous,
1594
+ });
1595
+ previous = pitches;
1596
+ return Object.freeze({
1597
+ name: chordName(chord, flats),
1598
+ roman: romanOf(key, chord),
1599
+ pitches: Object.freeze(pitches),
1600
+ ...(options.bass ? { bass: bassNote(chord) } : {}),
1601
+ });
1602
+ });
1603
+ }
1604
+
1605
+ // ---------------------------------------------------------------------------
1606
+ // Helpers
1607
+
1608
+ function mod12(value: number): number {
1609
+ return ((Math.trunc(value) % 12) + 12) % 12;
1610
+ }
1611
+
1612
+ function clampInt(value: number, min: number, max: number): number {
1613
+ if (!Number.isFinite(value)) return min;
1614
+ return Math.max(min, Math.min(max, Math.round(value)));
1615
+ }
1616
+
1617
+ function round6(value: number): number {
1618
+ return Math.round(value * 1e6) / 1e6;
1619
+ }
1620
+
1621
+ // ---------------------------------------------------------------------------
1622
+ // Rendering a progression to notes (tools, SDK, recording)
1623
+
1624
+ /** A roman numeral (`ii7`, `bVII`) or chord symbol (`Cm7`, `F/A`) in `key`. */
1625
+ export function resolveChord(key: Key, text: string): Chord | undefined {
1626
+ return parseRoman(key, text) ?? parseChord(text);
1627
+ }
1628
+
1629
+ export type RenderOptions = Readonly<{
1630
+ key: Key;
1631
+ chords: readonly Chord[];
1632
+ /** Beats each chord lasts (one bar of 4/4 by default). */
1633
+ beatsPerChord?: number;
1634
+ /** First chord's start in beats. */
1635
+ start?: number;
1636
+ perform?: PerformOptions;
1637
+ inversion?: number;
1638
+ spread?: Spread;
1639
+ /** Add a bass note under each chord. */
1640
+ bass?: boolean;
1641
+ /**
1642
+ * Bass behaviour (overrides `bass`). Every progression step is a chord,
1643
+ * so `chords` and `single` add the root (or slash bass), `unison` the
1644
+ * chord's root (the key a player would press), and `solo` drops the
1645
+ * treble and keeps only that bass.
1646
+ */
1647
+ bassMode?: BassMode;
1648
+ /** Voice-lead chord to chord (default true). */
1649
+ lead?: boolean;
1650
+ anchor?: number;
1651
+ }>;
1652
+
1653
+ export type RenderedProgression = Readonly<{
1654
+ voiced: readonly VoicedChord[];
1655
+ notes: readonly PerformedNote[];
1656
+ bass: readonly PerformedNote[];
1657
+ }>;
1658
+
1659
+ /**
1660
+ * A progression as notes: voice-led voicings performed over consecutive
1661
+ * spans of `beatsPerChord`, plus one sustained bass note per chord. The
1662
+ * arp-random seed advances per chord so repeated chords vary but the
1663
+ * whole render stays deterministic.
1664
+ */
1665
+ export function renderProgression(options: RenderOptions): RenderedProgression {
1666
+ const span =
1667
+ options.beatsPerChord && options.beatsPerChord > 0
1668
+ ? options.beatsPerChord
1669
+ : 4;
1670
+ const start = options.start ?? 0;
1671
+ const voiced = voiceProgression(options.key, options.chords, {
1672
+ ...(options.inversion !== undefined
1673
+ ? { inversion: options.inversion }
1674
+ : {}),
1675
+ ...(options.spread ? { spread: options.spread } : {}),
1676
+ ...(options.anchor !== undefined ? { anchor: options.anchor } : {}),
1677
+ ...(options.lead !== undefined ? { lead: options.lead } : {}),
1678
+ bass: true,
1679
+ });
1680
+ const notes: PerformedNote[] = [];
1681
+ const bass: PerformedNote[] = [];
1682
+ const velocity = options.perform?.velocity ?? 0.8;
1683
+ const mode: BassMode = options.bassMode ?? (options.bass ? "chords" : "off");
1684
+ voiced.forEach((chord, index) => {
1685
+ const at = start + index * span;
1686
+ if (mode !== "solo")
1687
+ notes.push(
1688
+ ...perform(chord.pitches, at, span, {
1689
+ ...options.perform,
1690
+ seed: (options.perform?.seed ?? 0) + index,
1691
+ }),
1692
+ );
1693
+ const source = options.chords[index]!;
1694
+ const under =
1695
+ mode === "off"
1696
+ ? undefined
1697
+ : mode === "unison"
1698
+ ? bassNote({ ...source, bass: undefined })
1699
+ : chord.bass;
1700
+ if (under !== undefined)
1701
+ bass.push({
1702
+ pitch: under,
1703
+ start: round6(at),
1704
+ length: round6(span),
1705
+ velocity,
1706
+ });
1707
+ });
1708
+ return {
1709
+ voiced:
1710
+ mode !== "off"
1711
+ ? voiced
1712
+ : voiced.map(({ bass: _bass, ...rest }) => Object.freeze(rest)),
1713
+ notes,
1714
+ bass,
1715
+ };
1716
+ }
1717
+
1718
+ /** How dawg builds chords, for the agent prompt and tool descriptions. */
1719
+ export const CHORD_PROCESS = [
1720
+ "Chords (Orchid-style): pick chords from the song key's diatonic set (in C major: C Dm Em F G Am Bdim; minor keys i ii° III iv v VI VII, V major for cadences).",
1721
+ "Move tonic (I vi iii) → predominant (IV ii) → dominant (V vii°) → tonic; loops end on V or IV to lead home.",
1722
+ "Add sevenths/9ths as colour (6, m7, M7, 9 extensions); voice-lead each chord to the inversion nearest the previous one in C3–G5 so common tones hold; put the root an octave or two below as bass.",
1723
+ "Use suggest_progression to get voice-led chords and write_chords to write them (block, strum, arpeggio, harp, slop or a rhythm pattern; bassMode chords|unison|single|solo), instead of hand-placing chord notes with add_notes.",
1724
+ ].join(" ");