@hraness/dawg 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,629 @@
1
+ /**
2
+ * The modelled piano commands (0.6 keys lane, `core/keys.ts`):
3
+ *
4
+ * piano the focused track becomes the modelled grand
5
+ * piano <preset> grand ballad upright felt lofi honkytonk prepared
6
+ * grand | upright | felt … the same, by the preset word alone
7
+ * keys list this track's piano settings
8
+ * keys preset <name> load a preset (instrument, keys and its effects)
9
+ * keys <param> <value> […] set parameters (hardness 0.4 decay 1.5 …)
10
+ * keys <param> off unset one parameter (back to the preset's)
11
+ * keys reset the family's own sound (`keys: {}`),
12
+ * dropping the preset's unedited effects
13
+ * tonewheel [888800008] the tonewheel organ (hammond, b3), drawbars
14
+ * combo [08880] the combo organ (farfisa), registers
15
+ * pipe [plenum | flute8 …] the pipe organ (church), stops
16
+ * rotary slow|fast|stop the organ's rotary speaker speed
17
+ * rotary fast at <beat> a keys-rotary lane point (the Leslie switch)
18
+ * keys drawbars 888800008 organ text rows: drawbars registers stops
19
+ * epiano | wurli | clav the electric keys (0.6.1), also rhodes
20
+ * suitcase dyno wurlitzer clavinet funkclav
21
+ * epiano preset <name> an electric preset
22
+ * epiano <param> <value> […] set electric parameters (vibe 0.6 …);
23
+ * the track becomes that family first
24
+ *
25
+ * Every new write of `piano` stores `instrument: "grand"` with `keys`; a
26
+ * stored legacy `instrument: "piano"` is never rewritten unless asked. Each
27
+ * command is one `updateTrack` revision and one undo step.
28
+ */
29
+ import { FxValidationError } from "../../core/params.ts";
30
+ import {
31
+ ELECTRIC_FAMILIES,
32
+ KEYS_PARAMS,
33
+ KEYS_PRESETS,
34
+ KEYS_PARAM_FAMILIES,
35
+ ORGAN_ALIASES,
36
+ ORGAN_TEXT,
37
+ PIANO_FAMILIES,
38
+ PIPE_REGISTRATIONS,
39
+ PIPE_STOPS,
40
+ isKeysFamily,
41
+ isKeysPreset,
42
+ isOrganFamily,
43
+ isOrganText,
44
+ isPianoFamily,
45
+ keysParamName,
46
+ keysParamsFor,
47
+ keysSimpleFor,
48
+ ORGAN_ROWS,
49
+ ORGAN_FAMILIES,
50
+ normalizeKeys,
51
+ pianoWrite,
52
+ type TrackKeys,
53
+ } from "../../core/keys.ts";
54
+ import {
55
+ automationPoints,
56
+ createScore,
57
+ SCORE_LIMITS,
58
+ setTrackAutomation,
59
+ ScoreValidationError,
60
+ updateTrack,
61
+ type TrackPatch,
62
+ type TrackScore,
63
+ } from "../../core/score.ts";
64
+ import { parseParamValue } from "./fx.ts";
65
+
66
+ export type KeysCommand =
67
+ | { type: "keys-list" }
68
+ /** `family`: `epiano preset` lists only that family's presets. */
69
+ | { type: "keys-presets"; family?: string }
70
+ | { type: "keys-reset" }
71
+ | {
72
+ type: "keys-preset";
73
+ preset: string;
74
+ /** f061-organ: overrides on top of the preset (`tonewheel 888800008`). */
75
+ values?: Readonly<Record<string, string>>;
76
+ }
77
+ | {
78
+ /** f061-organ: `rotary fast at 16` writes a keys-rotary point. */
79
+ type: "keys-rotary-at";
80
+ speed: "stop" | "slow" | "fast";
81
+ beat: number;
82
+ }
83
+ | {
84
+ type: "keys-set";
85
+ /** `null` unsets a parameter. */
86
+ values: Readonly<Record<string, number | string | null>>;
87
+ /** `epiano vibe 0.6`: the electric family the track becomes first. */
88
+ family?: string;
89
+ };
90
+
91
+ /** Electric family words that also take `preset` and parameters. */
92
+ const FAMILY_HEADS: Readonly<Record<string, string>> = Object.freeze({
93
+ epiano: "epiano",
94
+ rhodes: "epiano",
95
+ wurli: "wurli",
96
+ wurlitzer: "wurli",
97
+ clav: "clav",
98
+ clavinet: "clav",
99
+ });
100
+
101
+ /** The words that start a keys command (registered with /help). */
102
+ export const KEYS_COMMAND_WORDS = Object.freeze([
103
+ "keys",
104
+ "piano",
105
+ ...Object.keys(KEYS_PRESETS).filter((name) => name !== "lofi"),
106
+ "rhodes",
107
+ "wurlitzer",
108
+ "clavinet",
109
+ // f061-organ
110
+ ...Object.keys(ORGAN_ALIASES),
111
+ "rotary",
112
+ ]);
113
+
114
+ /** A pipe stop or registration word. */
115
+ function isStopWord(word: string): boolean {
116
+ return (
117
+ (PIPE_STOPS as readonly string[]).includes(word) ||
118
+ Object.prototype.hasOwnProperty.call(PIPE_REGISTRATIONS, word)
119
+ );
120
+ }
121
+
122
+ /** `principal8,octave4` / `principal8+octave4` / words: the stop words. */
123
+ function stopWords(words: readonly string[]): string[] | undefined {
124
+ const out = words.flatMap((word) => word.split(/[,+]/).filter(Boolean));
125
+ return out.length > 0 && out.every(isStopWord) ? out : undefined;
126
+ }
127
+
128
+ /**
129
+ * f061-organ: `tonewheel 888800008`, `combo 08880`, `pipe plenum`,
130
+ * `hammond gospel`: an organ preset word with its one text row.
131
+ */
132
+ function parseOrganWord(words: readonly string[]): KeysCommand | undefined {
133
+ const head = ORGAN_ALIASES[words[0]!] ?? words[0]!;
134
+ if (!isKeysPreset(head)) return undefined;
135
+ const family = KEYS_PRESETS[head]!.instrument;
136
+ if (!isOrganFamily(family)) return undefined;
137
+ const rest = words.slice(1);
138
+ if (rest.length === 0) return { type: "keys-preset", preset: head };
139
+ if (
140
+ rest.length === 1 &&
141
+ isKeysPreset(rest[0]!) &&
142
+ KEYS_PRESETS[rest[0]!]!.instrument === family
143
+ )
144
+ return { type: "keys-preset", preset: rest[0]! };
145
+ if (family === "pipe") {
146
+ const stops = stopWords(rest);
147
+ return stops
148
+ ? {
149
+ type: "keys-preset",
150
+ preset: head,
151
+ values: { stops: stops.join(" ") },
152
+ }
153
+ : undefined;
154
+ }
155
+ const name = family === "tonewheel" ? "drawbars" : "registers";
156
+ const digits = ORGAN_TEXT[name].digits;
157
+ const values: Record<string, string> = {};
158
+ let at = 0;
159
+ if (new RegExp(`^[0-8]{${digits}}$`).test(rest[0]!)) {
160
+ values[name] = rest[0]!;
161
+ at = 1;
162
+ }
163
+ // `combo flute`: the combo voice by its word.
164
+ const spec = KEYS_PARAMS.voice;
165
+ const voices: readonly string[] = spec?.kind === "enum" ? spec.values : [];
166
+ if (family === "combo" && voices.includes(rest[at]!)) {
167
+ values.voice = rest[at]!;
168
+ at += 1;
169
+ }
170
+ // Then any rows as `keys` pairs: `tonewheel 888800008 perc 3rd`.
171
+ const pairs = rest.slice(at);
172
+ if (pairs.length > 0) {
173
+ const set = parseKeysCommand(`keys ${pairs.join(" ")}`);
174
+ if (set?.type !== "keys-set") return undefined;
175
+ for (const [key, value] of Object.entries(set.values)) {
176
+ if (value === null) return undefined;
177
+ values[key] = String(value);
178
+ }
179
+ }
180
+ return Object.keys(values).length > 0
181
+ ? { type: "keys-preset", preset: head, values }
182
+ : undefined;
183
+ }
184
+
185
+ /** The families that read a keys row (for the "wrong family" message). */
186
+ function familiesReading(param: string): string[] {
187
+ const organs = ORGAN_FAMILIES.filter((family) =>
188
+ ORGAN_ROWS[family].includes(param),
189
+ );
190
+ const others = (KEYS_PARAM_FAMILIES[param] ?? []).filter(
191
+ (family) => !isOrganFamily(family),
192
+ );
193
+ const families = [...new Set([...others, ...organs])];
194
+ return families.length > 0 ? families : ["the pianos"];
195
+ }
196
+
197
+ /**
198
+ * A row the track's family never reads, as a message (`rotary` on a pipe
199
+ * organ): stored projects still load, but a typed write is refused.
200
+ */
201
+ export function keysRowMismatch(
202
+ instrument: string,
203
+ params: readonly string[],
204
+ ): string | undefined {
205
+ const rows = keysParamsFor(instrument);
206
+ const wrong = params.filter(
207
+ (param) => param !== "preset" && !rows.includes(param),
208
+ );
209
+ if (wrong.length === 0) return undefined;
210
+ return `${instrument} has no ${wrong.join(" ")} (${familiesReading(wrong[0]!).join("/")} row); its parameters: ${rows.join(" ")}`;
211
+ }
212
+
213
+ export function parseKeysCommand(prompt: string): KeysCommand | undefined {
214
+ if (prompt.length > 1_024) return undefined;
215
+ let words = prompt.trim().toLowerCase().split(/\s+/);
216
+ // `instrument piano` / `sound grand`: the generic instrument command with
217
+ // a keys word is a new write, so it stores the modelled piano.
218
+ if (
219
+ words.length === 2 &&
220
+ ["instrument", "sound", "voice"].includes(words[0]!) &&
221
+ pianoWrite(words[1]!)
222
+ )
223
+ words = [words[1]!];
224
+ const head = words[0] ?? "";
225
+ // f061-organ: `rotary slow|fast|stop` and the organ words with a row.
226
+ if (head === "rotary") {
227
+ const speed = words[1] as "stop" | "slow" | "fast";
228
+ if (!["slow", "fast", "stop"].includes(speed)) return undefined;
229
+ if (words.length === 2)
230
+ return { type: "keys-set", values: { rotary: speed } };
231
+ const beat = Number(words[3]);
232
+ return words.length === 4 &&
233
+ words[2] === "at" &&
234
+ /^\d+(\.\d+)?$/.test(words[3]!) &&
235
+ Number.isFinite(beat)
236
+ ? { type: "keys-rotary-at", speed, beat }
237
+ : undefined;
238
+ }
239
+ if (head !== "keys" && head !== "piano") {
240
+ const organ = parseOrganWord(words);
241
+ if (organ) return organ;
242
+ }
243
+ // `piano`, `piano <preset>`, and a bare preset word (`grand`, `felt`).
244
+ if (head === "piano" || head === "uprightpiano" || head === "feltpiano") {
245
+ if (words.length === 1)
246
+ return { type: "keys-preset", preset: pianoWrite(head)!.preset! };
247
+ return words.length === 2 && isKeysPreset(words[1]!)
248
+ ? { type: "keys-preset", preset: words[1]! }
249
+ : undefined;
250
+ }
251
+ const family = FAMILY_HEADS[head];
252
+ if (family) {
253
+ if (words.length === 1)
254
+ return { type: "keys-preset", preset: pianoWrite(head)!.preset! };
255
+ const parsed = parseKeysCommand(["keys", ...words.slice(1)].join(" "));
256
+ if (!parsed || parsed.type === "keys-list") return undefined;
257
+ if (parsed.type === "keys-preset")
258
+ return KEYS_PRESETS[parsed.preset]!.instrument === family
259
+ ? parsed
260
+ : undefined;
261
+ if (parsed.type === "keys-set") return { ...parsed, family };
262
+ if (parsed.type === "keys-presets") return { ...parsed, family };
263
+ return parsed;
264
+ }
265
+ if (head !== "keys") {
266
+ if (words.length === 1 && isKeysPreset(head) && head !== "lofi")
267
+ return { type: "keys-preset", preset: head };
268
+ if (words.length === 2 && isKeysPreset(head) && words[1] === "piano")
269
+ return { type: "keys-preset", preset: head };
270
+ return undefined;
271
+ }
272
+ if (words.length === 1) return { type: "keys-list" };
273
+ const rest = words.slice(1);
274
+ if (rest.length === 1 && (rest[0] === "reset" || rest[0] === "off"))
275
+ return { type: "keys-reset" };
276
+ if (rest.length === 1 && rest[0] === "presets")
277
+ return { type: "keys-presets" };
278
+ if (rest[0] === "preset")
279
+ return rest.length === 2 && isKeysPreset(rest[1]!)
280
+ ? { type: "keys-preset", preset: rest[1]! }
281
+ : rest.length === 1
282
+ ? { type: "keys-presets" }
283
+ : undefined;
284
+ if (rest.length === 1 && isKeysPreset(rest[0]!))
285
+ return { type: "keys-preset", preset: rest[0]! };
286
+ const values: Record<string, number | string | null> = {};
287
+ for (let index = 0; index < rest.length; index += 2) {
288
+ const name = keysParamName(rest[index]!);
289
+ if (!name) return undefined;
290
+ const word = rest[index + 1];
291
+ if (word === undefined) return undefined;
292
+ if (word === "off" || word === "unset") {
293
+ values[name] = null;
294
+ continue;
295
+ }
296
+ // f061-organ text rows: digits, or stop words up to the next row name.
297
+ if (isOrganText(name)) {
298
+ if (name !== "stops") {
299
+ values[name] = word;
300
+ continue;
301
+ }
302
+ let end = index + 1;
303
+ while (end < rest.length && !keysParamName(rest[end]!)) end += 1;
304
+ const stops = stopWords(rest.slice(index + 1, end));
305
+ if (!stops) return undefined;
306
+ values[name] = stops.join(" ");
307
+ index = end - 2;
308
+ continue;
309
+ }
310
+ const value = parseParamValue(KEYS_PARAMS[name]!, word);
311
+ if (value === undefined || typeof value === "boolean") return undefined;
312
+ values[name] = value as number | string;
313
+ }
314
+ return { type: "keys-set", values };
315
+ }
316
+
317
+ export function describeKeys(keys: TrackKeys | undefined): string {
318
+ if (!keys) return "off";
319
+ const entries = Object.entries(keys).filter(([key]) => key !== "preset");
320
+ const parts = [
321
+ ...(keys.preset ? [`preset ${keys.preset}`] : []),
322
+ ...entries.map(([key, value]) => `${key} ${String(value)}`),
323
+ ];
324
+ return parts.length > 0 ? parts.join(" · ") : "defaults";
325
+ }
326
+
327
+ export type KeysResult = Readonly<{
328
+ ok: boolean;
329
+ message: string;
330
+ next?: TrackScore;
331
+ kind?: string;
332
+ payload?: Record<string, unknown>;
333
+ }>;
334
+
335
+ /** One line per preset for `keys presets`, /help and the agent prompt. */
336
+ export function keysPresetLines(): string[] {
337
+ return Object.entries(KEYS_PRESETS).map(
338
+ ([name, preset]) => `${name} · ${preset.doc} · ${preset.styles}`,
339
+ );
340
+ }
341
+
342
+ type PresetTrack = Pick<
343
+ TrackScore["tracks"][number],
344
+ "fx" | "keys" | "filter" | "reverb"
345
+ >;
346
+
347
+ /** A preset's effects as the score stores them (defaults filled in). */
348
+ const storedPresetFx = new Map<string, Track>();
349
+ type Track = TrackScore["tracks"][number];
350
+ function presetEffects(preset: string): Track | undefined {
351
+ if (storedPresetFx.has(preset)) return storedPresetFx.get(preset);
352
+ const write = pianoWrite(preset);
353
+ const track = write
354
+ ? createScore({
355
+ tracks: [
356
+ {
357
+ id: "p",
358
+ name: "p",
359
+ instrument: write.instrument,
360
+ ...(write.filter ? { filter: write.filter } : {}),
361
+ ...(write.fx ? { fx: write.fx } : {}),
362
+ ...(write.reverb ? { reverb: write.reverb } : {}),
363
+ },
364
+ ],
365
+ }).tracks[0]
366
+ : undefined;
367
+ if (track) storedPresetFx.set(preset, track);
368
+ return track;
369
+ }
370
+
371
+ /** True when `stored` still equals the preset's stored value. */
372
+ function unchanged(stored: unknown, preset: unknown): boolean {
373
+ return (
374
+ stored !== undefined &&
375
+ preset !== undefined &&
376
+ JSON.stringify(stored) === JSON.stringify(preset)
377
+ );
378
+ }
379
+
380
+ /**
381
+ * Clears the effects the track's current preset brought (its filter, fx
382
+ * entries and reverb) while they still hold the preset's values, so moving
383
+ * between presets or resetting never piles them up. Effects the user has
384
+ * since edited stay.
385
+ */
386
+ export function keysPresetClear(track: PresetTrack): TrackPatch {
387
+ const name = track.keys?.preset;
388
+ const old = typeof name === "string" ? presetEffects(name) : undefined;
389
+ if (!old) return {};
390
+ const patch: {
391
+ filter?: null;
392
+ reverb?: null;
393
+ fx?: TrackPatch["fx"];
394
+ } = {};
395
+ if (old.filter && unchanged(track.filter, old.filter)) patch.filter = null;
396
+ if (old.reverb && unchanged(track.reverb, old.reverb)) patch.reverb = null;
397
+ if (old.fx && track.fx) {
398
+ const fx: Record<string, unknown> = { ...track.fx };
399
+ let removed = false;
400
+ for (const [effect, params] of Object.entries(old.fx))
401
+ if (unchanged(fx[effect], params)) {
402
+ delete fx[effect];
403
+ removed = true;
404
+ }
405
+ if (removed)
406
+ patch.fx = Object.keys(fx).length > 0 ? (fx as TrackPatch["fx"]) : null;
407
+ }
408
+ return patch;
409
+ }
410
+
411
+ /**
412
+ * The track patch for a preset write (instrument, keys, preset effects),
413
+ * first clearing the effects the previous preset brought.
414
+ */
415
+ export function keysPresetPatch(
416
+ track: PresetTrack,
417
+ preset: string,
418
+ ): TrackPatch {
419
+ const write = pianoWrite(preset)!;
420
+ const clear = keysPresetClear(track);
421
+ const fx = "fx" in clear ? (clear.fx ?? undefined) : track.fx;
422
+ return {
423
+ ...clear,
424
+ instrument: write.instrument,
425
+ keys: write.keys,
426
+ ...(write.filter ? { filter: write.filter } : {}),
427
+ ...(write.fx
428
+ ? { fx: { ...(fx ?? {}), ...write.fx } as TrackPatch["fx"] }
429
+ : {}),
430
+ ...(write.reverb ? { reverb: write.reverb } : {}),
431
+ };
432
+ }
433
+
434
+ export function applyKeysCommand(
435
+ score: TrackScore,
436
+ trackId: string,
437
+ command: KeysCommand,
438
+ ): KeysResult {
439
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
440
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
441
+ if (command.type === "keys-presets")
442
+ return {
443
+ ok: true,
444
+ message: `${command.family ?? "keys"} presets · ${Object.keys(
445
+ KEYS_PRESETS,
446
+ )
447
+ .filter(
448
+ (name) =>
449
+ !command.family ||
450
+ KEYS_PRESETS[name]!.instrument === command.family,
451
+ )
452
+ .join(" ")}`,
453
+ };
454
+ // f061-organ: organs take `keys` commands too.
455
+ const piano = isKeysFamily(track.instrument);
456
+ const elsewhere =
457
+ track.instrument === "organ"
458
+ ? `keys · ${trackId} is the legacy organ; type tonewheel (or combo, pipe) for drawbars and rotary, or piano for the modelled piano`
459
+ : `keys · ${trackId} is ${track.instrument}; type piano first (${PIANO_FAMILIES.join(" ")}), ${ELECTRIC_FAMILIES.join(" ")} or ${ORGAN_FAMILIES.join(" ")} for modelled keys`;
460
+ if (command.type === "keys-list")
461
+ return {
462
+ ok: true,
463
+ message: piano
464
+ ? `keys · ${track.instrument} · ${describeKeys(track.keys)} · basics ${keysSimpleFor(track.instrument).join(" ")}`
465
+ : elsewhere,
466
+ };
467
+ if (
468
+ command.type === "keys-set" &&
469
+ command.family &&
470
+ track.instrument !== command.family
471
+ ) {
472
+ // `epiano vibe 0.6` on another sound: become the family, then set.
473
+ const base = applyKeysCommand(score, trackId, {
474
+ type: "keys-preset",
475
+ preset: command.family,
476
+ });
477
+ if (!base.ok || !base.next) return base;
478
+ return applyKeysCommand(base.next, trackId, {
479
+ type: "keys-set",
480
+ values: command.values,
481
+ });
482
+ }
483
+ if (track.sampler && command.type !== "keys-preset")
484
+ return {
485
+ ok: false,
486
+ message: `keys · ${trackId} is a sampler track; type piano to make it a modelled piano`,
487
+ };
488
+ let patch: TrackPatch;
489
+ if (command.type === "keys-preset") {
490
+ patch = {
491
+ ...keysPresetPatch(track, command.preset),
492
+ ...(track.sampler ? { sampler: null } : {}),
493
+ ...(track.synth ? { synth: null } : {}),
494
+ };
495
+ if (command.values) {
496
+ try {
497
+ patch = {
498
+ ...patch,
499
+ keys: normalizeKeys({ ...patch.keys, ...command.values }) ?? {},
500
+ };
501
+ } catch (error) {
502
+ if (error instanceof FxValidationError)
503
+ return { ok: false, message: `keys · ${error.message}` };
504
+ throw error;
505
+ }
506
+ }
507
+ } else if (!piano && command.type === "keys-rotary-at") {
508
+ return { ok: false, message: elsewhere };
509
+ } else if (!piano) {
510
+ // `keys` is also the legacy synth sound (`instrument keys`): name both
511
+ // ways out.
512
+ const keysSynth = track.instrument === "keys";
513
+ const [first] = Object.keys(
514
+ command.type === "keys-set" ? command.values : {},
515
+ );
516
+ return {
517
+ ok: false,
518
+ message: keysSynth
519
+ ? `keys · keys shapes the modelled piano; ${trackId} is the keys synth: use synth ${first ?? "decay"} …, or type piano`
520
+ : elsewhere,
521
+ };
522
+ } else if (command.type === "keys-rotary-at") {
523
+ const wrong = keysRowMismatch(track.instrument, ["rotary"]);
524
+ if (wrong) return { ok: false, message: `keys · ${wrong}` };
525
+ const tick = Math.round(command.beat * score.ticksPerBeat);
526
+ const value = { stop: 0, slow: 1, fast: 2 }[command.speed];
527
+ const merged = new Map(
528
+ automationPoints(track, "keys-rotary").map((point) => [
529
+ point.tick,
530
+ point,
531
+ ]),
532
+ );
533
+ merged.set(tick, { tick, value });
534
+ const points = [...merged.values()].sort((a, b) => a.tick - b.tick);
535
+ if (points.length > SCORE_LIMITS.maxAutomationPoints)
536
+ return { ok: false, message: "keys-rotary automation is full" };
537
+ return {
538
+ ok: true,
539
+ message: `keys · rotary ${command.speed} at beat ${command.beat} · keys-rotary ${points.length} point${points.length === 1 ? "" : "s"}`,
540
+ next: setTrackAutomation(score, trackId, "keys-rotary", points),
541
+ kind: "score.automation",
542
+ payload: { trackId, parameter: "keys-rotary", points },
543
+ };
544
+ } else if (command.type === "keys-reset") {
545
+ // The family's own sound: the preset and the effects it brought go.
546
+ patch = { ...keysPresetClear(track), keys: {} };
547
+ } else {
548
+ const wrong = keysRowMismatch(
549
+ track.instrument,
550
+ Object.entries(command.values)
551
+ .filter(([, value]) => value !== null)
552
+ .map(([key]) => key),
553
+ );
554
+ if (wrong) return { ok: false, message: `keys · ${wrong}` };
555
+ const keys: Record<string, unknown> = { ...(track.keys ?? {}) };
556
+ for (const [key, value] of Object.entries(command.values)) {
557
+ if (value === null) delete keys[key];
558
+ else keys[key] = value;
559
+ }
560
+ try {
561
+ patch = { keys: normalizeKeys(keys) ?? {} };
562
+ } catch (error) {
563
+ if (error instanceof FxValidationError)
564
+ return { ok: false, message: `keys · ${error.message}` };
565
+ throw error;
566
+ }
567
+ }
568
+ let next: TrackScore;
569
+ try {
570
+ next = updateTrack(score, trackId, patch);
571
+ } catch (error) {
572
+ if (
573
+ error instanceof ScoreValidationError ||
574
+ error instanceof FxValidationError
575
+ )
576
+ return { ok: false, message: `keys · ${error.message}` };
577
+ throw error;
578
+ }
579
+ const stored = next.tracks.find((candidate) => candidate.id === trackId)!;
580
+ const extras =
581
+ command.type === "keys-preset"
582
+ ? [
583
+ ...(patch.reverb ? ["reverb"] : []),
584
+ ...(patch.filter ? ["filter"] : []),
585
+ ...Object.keys(pianoWrite(command.preset)?.fx ?? {}),
586
+ ]
587
+ : [];
588
+ return {
589
+ ok: true,
590
+ message:
591
+ command.type === "keys-preset"
592
+ ? command.values
593
+ ? `keys · ${stored.instrument} · ${describeKeys(stored.keys)}${extras.length > 0 ? ` · with ${extras.join(" ")}` : ""}`
594
+ : `keys · ${stored.instrument} · preset ${command.preset} · ${KEYS_PRESETS[command.preset]!.doc}${extras.length > 0 ? ` · with ${extras.join(" ")}` : ""}`
595
+ : `keys · ${stored.instrument} · ${describeKeys(stored.keys)}`,
596
+ next,
597
+ kind: "score.keys",
598
+ payload: {
599
+ trackId,
600
+ instrument: stored.instrument,
601
+ keys: stored.keys ?? null,
602
+ },
603
+ };
604
+ }
605
+
606
+ /**
607
+ * The fields a new track named after a piano word starts with
608
+ * (`/track piano` → the modelled grand); empty for any other name. `lofi`
609
+ * is left out: it also names a drum kit and pattern.
610
+ */
611
+ export function newPianoTrack(name: string): {
612
+ instrument?: string;
613
+ keys?: TrackKeys;
614
+ filter?: TrackPatch["filter"];
615
+ fx?: TrackPatch["fx"];
616
+ reverb?: TrackPatch["reverb"];
617
+ } {
618
+ const word = name.toLowerCase();
619
+ if (word === "lofi") return {};
620
+ const write = pianoWrite(word);
621
+ if (!write) return {};
622
+ return {
623
+ instrument: write.instrument,
624
+ keys: write.keys,
625
+ ...(write.filter ? { filter: write.filter } : {}),
626
+ ...(write.fx ? { fx: write.fx } : {}),
627
+ ...(write.reverb ? { reverb: write.reverb } : {}),
628
+ };
629
+ }