@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.
- package/CHANGELOG.md +63 -0
- package/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +70 -2
- 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
|
+
}
|