@hraness/dawg 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/DAWG.md +338 -51
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +5 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +258 -6
- package/core/granular.ts +91 -0
- package/core/instruments.ts +161 -0
- package/core/keys.ts +573 -4
- package/core/resonators.ts +291 -3
- package/core/score.ts +349 -3
- package/core/sdk/print.ts +78 -3
- package/core/sdk/v1.ts +1320 -36
- package/core/sections.ts +12 -4
- package/core/strings.ts +237 -2
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +3 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -6
- package/package.json +1 -1
- package/src/agent/agent.ts +8 -2
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/ops.ts +4 -1
- package/src/agent/preview-tool.ts +2 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +200 -15
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/arrange.ts +21 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +18 -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/engine.ts +46 -5
- package/src/audio/fit.ts +45 -0
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +9 -5
- package/src/audio/instruments.ts +32 -3
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +143 -19
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +50 -6
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +136 -9
- package/src/audio/preview.ts +15 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +168 -21
- package/src/audio/samples.ts +26 -1
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +266 -20
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/wav.ts +119 -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 +103 -22
- package/src/commands/fx.ts +4 -1
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +91 -8
- package/src/commands/keys.ts +287 -11
- package/src/commands/modal.ts +67 -4
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +5 -1
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +473 -0
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +129 -7
- package/src/project/check.ts +5 -1
- package/src/render.ts +16 -0
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +1 -1
- package/src/tui/menu.ts +637 -35
- package/src/tui/modal-menu.ts +34 -7
- 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 +34 -9
- package/src/tui/wind-menu.ts +144 -0
package/src/commands/keys.ts
CHANGED
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
* keys <param> off unset one parameter (back to the preset's)
|
|
11
11
|
* keys reset the family's own sound (`keys: {}`),
|
|
12
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
|
|
13
24
|
*
|
|
14
25
|
* Every new write of `piano` stores `instrument: "grand"` with `keys`; a
|
|
15
26
|
* stored legacy `instrument: "piano"` is never rewritten unless asked. Each
|
|
@@ -17,18 +28,34 @@
|
|
|
17
28
|
*/
|
|
18
29
|
import { FxValidationError } from "../../core/params.ts";
|
|
19
30
|
import {
|
|
31
|
+
ELECTRIC_FAMILIES,
|
|
20
32
|
KEYS_PARAMS,
|
|
21
33
|
KEYS_PRESETS,
|
|
22
|
-
|
|
34
|
+
KEYS_PARAM_FAMILIES,
|
|
35
|
+
ORGAN_ALIASES,
|
|
36
|
+
ORGAN_TEXT,
|
|
37
|
+
PIANO_FAMILIES,
|
|
38
|
+
PIPE_REGISTRATIONS,
|
|
39
|
+
PIPE_STOPS,
|
|
40
|
+
isKeysFamily,
|
|
23
41
|
isKeysPreset,
|
|
42
|
+
isOrganFamily,
|
|
43
|
+
isOrganText,
|
|
24
44
|
isPianoFamily,
|
|
25
45
|
keysParamName,
|
|
46
|
+
keysParamsFor,
|
|
47
|
+
keysSimpleFor,
|
|
48
|
+
ORGAN_ROWS,
|
|
49
|
+
ORGAN_FAMILIES,
|
|
26
50
|
normalizeKeys,
|
|
27
51
|
pianoWrite,
|
|
28
52
|
type TrackKeys,
|
|
29
53
|
} from "../../core/keys.ts";
|
|
30
54
|
import {
|
|
55
|
+
automationPoints,
|
|
31
56
|
createScore,
|
|
57
|
+
SCORE_LIMITS,
|
|
58
|
+
setTrackAutomation,
|
|
32
59
|
ScoreValidationError,
|
|
33
60
|
updateTrack,
|
|
34
61
|
type TrackPatch,
|
|
@@ -38,22 +65,151 @@ import { parseParamValue } from "./fx.ts";
|
|
|
38
65
|
|
|
39
66
|
export type KeysCommand =
|
|
40
67
|
| { type: "keys-list" }
|
|
41
|
-
|
|
68
|
+
/** `family`: `epiano preset` lists only that family's presets. */
|
|
69
|
+
| { type: "keys-presets"; family?: string }
|
|
42
70
|
| { type: "keys-reset" }
|
|
43
|
-
| {
|
|
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
|
+
}
|
|
44
83
|
| {
|
|
45
84
|
type: "keys-set";
|
|
46
85
|
/** `null` unsets a parameter. */
|
|
47
86
|
values: Readonly<Record<string, number | string | null>>;
|
|
87
|
+
/** `epiano vibe 0.6`: the electric family the track becomes first. */
|
|
88
|
+
family?: string;
|
|
48
89
|
};
|
|
49
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
|
+
|
|
50
101
|
/** The words that start a keys command (registered with /help). */
|
|
51
102
|
export const KEYS_COMMAND_WORDS = Object.freeze([
|
|
52
103
|
"keys",
|
|
53
104
|
"piano",
|
|
54
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",
|
|
55
112
|
]);
|
|
56
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
|
+
|
|
57
213
|
export function parseKeysCommand(prompt: string): KeysCommand | undefined {
|
|
58
214
|
if (prompt.length > 1_024) return undefined;
|
|
59
215
|
let words = prompt.trim().toLowerCase().split(/\s+/);
|
|
@@ -66,6 +222,24 @@ export function parseKeysCommand(prompt: string): KeysCommand | undefined {
|
|
|
66
222
|
)
|
|
67
223
|
words = [words[1]!];
|
|
68
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
|
+
}
|
|
69
243
|
// `piano`, `piano <preset>`, and a bare preset word (`grand`, `felt`).
|
|
70
244
|
if (head === "piano" || head === "uprightpiano" || head === "feltpiano") {
|
|
71
245
|
if (words.length === 1)
|
|
@@ -74,6 +248,20 @@ export function parseKeysCommand(prompt: string): KeysCommand | undefined {
|
|
|
74
248
|
? { type: "keys-preset", preset: words[1]! }
|
|
75
249
|
: undefined;
|
|
76
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
|
+
}
|
|
77
265
|
if (head !== "keys") {
|
|
78
266
|
if (words.length === 1 && isKeysPreset(head) && head !== "lofi")
|
|
79
267
|
return { type: "keys-preset", preset: head };
|
|
@@ -95,16 +283,30 @@ export function parseKeysCommand(prompt: string): KeysCommand | undefined {
|
|
|
95
283
|
: undefined;
|
|
96
284
|
if (rest.length === 1 && isKeysPreset(rest[0]!))
|
|
97
285
|
return { type: "keys-preset", preset: rest[0]! };
|
|
98
|
-
if (rest.length % 2 !== 0) return undefined;
|
|
99
286
|
const values: Record<string, number | string | null> = {};
|
|
100
287
|
for (let index = 0; index < rest.length; index += 2) {
|
|
101
288
|
const name = keysParamName(rest[index]!);
|
|
102
289
|
if (!name) return undefined;
|
|
103
|
-
const word = rest[index + 1]
|
|
290
|
+
const word = rest[index + 1];
|
|
291
|
+
if (word === undefined) return undefined;
|
|
104
292
|
if (word === "off" || word === "unset") {
|
|
105
293
|
values[name] = null;
|
|
106
294
|
continue;
|
|
107
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
|
+
}
|
|
108
310
|
const value = parseParamValue(KEYS_PARAMS[name]!, word);
|
|
109
311
|
if (value === undefined || typeof value === "boolean") return undefined;
|
|
110
312
|
values[name] = value as number | string;
|
|
@@ -239,16 +441,45 @@ export function applyKeysCommand(
|
|
|
239
441
|
if (command.type === "keys-presets")
|
|
240
442
|
return {
|
|
241
443
|
ok: true,
|
|
242
|
-
message:
|
|
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(" ")}`,
|
|
243
453
|
};
|
|
244
|
-
|
|
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`;
|
|
245
460
|
if (command.type === "keys-list")
|
|
246
461
|
return {
|
|
247
462
|
ok: true,
|
|
248
463
|
message: piano
|
|
249
|
-
? `keys · ${track.instrument} · ${describeKeys(track.keys)} · basics ${
|
|
250
|
-
:
|
|
464
|
+
? `keys · ${track.instrument} · ${describeKeys(track.keys)} · basics ${keysSimpleFor(track.instrument).join(" ")}`
|
|
465
|
+
: elsewhere,
|
|
251
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
|
+
}
|
|
252
483
|
if (track.sampler && command.type !== "keys-preset")
|
|
253
484
|
return {
|
|
254
485
|
ok: false,
|
|
@@ -261,6 +492,20 @@ export function applyKeysCommand(
|
|
|
261
492
|
...(track.sampler ? { sampler: null } : {}),
|
|
262
493
|
...(track.synth ? { synth: null } : {}),
|
|
263
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 };
|
|
264
509
|
} else if (!piano) {
|
|
265
510
|
// `keys` is also the legacy synth sound (`instrument keys`): name both
|
|
266
511
|
// ways out.
|
|
@@ -272,12 +517,41 @@ export function applyKeysCommand(
|
|
|
272
517
|
ok: false,
|
|
273
518
|
message: keysSynth
|
|
274
519
|
? `keys · keys shapes the modelled piano; ${trackId} is the keys synth: use synth ${first ?? "decay"} …, or type piano`
|
|
275
|
-
:
|
|
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 },
|
|
276
543
|
};
|
|
277
544
|
} else if (command.type === "keys-reset") {
|
|
278
545
|
// The family's own sound: the preset and the effects it brought go.
|
|
279
546
|
patch = { ...keysPresetClear(track), keys: {} };
|
|
280
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}` };
|
|
281
555
|
const keys: Record<string, unknown> = { ...(track.keys ?? {}) };
|
|
282
556
|
for (const [key, value] of Object.entries(command.values)) {
|
|
283
557
|
if (value === null) delete keys[key];
|
|
@@ -315,7 +589,9 @@ export function applyKeysCommand(
|
|
|
315
589
|
ok: true,
|
|
316
590
|
message:
|
|
317
591
|
command.type === "keys-preset"
|
|
318
|
-
?
|
|
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(" ")}` : ""}`
|
|
319
595
|
: `keys · ${stored.instrument} · ${describeKeys(stored.keys)}`,
|
|
320
596
|
next,
|
|
321
597
|
kind: "score.keys",
|
package/src/commands/modal.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { FxValidationError } from "../../core/params.ts";
|
|
25
25
|
import {
|
|
26
26
|
DEFAULT_MODAL_PRESET,
|
|
27
|
+
GAMELAN_PRESETS,
|
|
27
28
|
MODAL_INSTRUMENT,
|
|
28
29
|
MODAL_PARAMS,
|
|
29
30
|
MODAL_PRESETS,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
MODAL_SIMPLE,
|
|
32
33
|
modalParamName,
|
|
33
34
|
modalPresetFor,
|
|
35
|
+
modalSettings,
|
|
34
36
|
normalizeModal,
|
|
35
37
|
type TrackModal,
|
|
36
38
|
} from "../../core/resonators.ts";
|
|
@@ -48,6 +50,7 @@ export type ModalCommand =
|
|
|
48
50
|
| { type: "modal-off" }
|
|
49
51
|
| { type: "modal-preset"; preset: string }
|
|
50
52
|
| { type: "modal-usage"; message: string }
|
|
53
|
+
| { type: "modal-hint"; message: string }
|
|
51
54
|
| {
|
|
52
55
|
type: "modal-set";
|
|
53
56
|
/** `null` returns a parameter to the preset's value. */
|
|
@@ -58,7 +61,7 @@ export type ModalCommand =
|
|
|
58
61
|
export const MODAL_OFF_INSTRUMENT = "marimba";
|
|
59
62
|
|
|
60
63
|
export const MODAL_USAGE =
|
|
61
|
-
"modal <preset> | modal <body> | modal <param> <value> | modal mallet <name> | modal reset | modal off | modal presets";
|
|
64
|
+
"modal <preset> | modal <body> | modal <param> <value> | modal mallet <name> | modal pair <track> | modal gamelan | modal reset | modal off | modal presets";
|
|
62
65
|
|
|
63
66
|
function rangeOf(name: string): string {
|
|
64
67
|
const spec = MODAL_PARAMS[name]!;
|
|
@@ -79,6 +82,12 @@ export function parseModalCommand(prompt: string): ModalCommand | undefined {
|
|
|
79
82
|
if (rest.length === 1 && rest[0] === "off") return { type: "modal-off" };
|
|
80
83
|
if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
|
|
81
84
|
return { type: "modal-list" };
|
|
85
|
+
// `gamelan` is a browse group, not a preset: list the bronzes.
|
|
86
|
+
if (rest.length === 1 && rest[0] === "gamelan")
|
|
87
|
+
return {
|
|
88
|
+
type: "modal-hint",
|
|
89
|
+
message: `gamelan · ${GAMELAN_PRESETS.join(" ")} · modal gangsa to start · pair two tracks with modal pair <track> · tuning slendro or pelog · ctrl-k Sound › browse sounds › Mallets and bells › Gamelan`,
|
|
90
|
+
};
|
|
82
91
|
if (rest[0] === "preset") {
|
|
83
92
|
const preset = rest.length === 2 ? modalPresetFor(rest[1]!) : undefined;
|
|
84
93
|
return preset
|
|
@@ -91,13 +100,26 @@ export function parseModalCommand(prompt: string): ModalCommand | undefined {
|
|
|
91
100
|
if (rest.length === 1) {
|
|
92
101
|
const preset = modalPresetFor(rest[0]!);
|
|
93
102
|
if (preset) return { type: "modal-preset", preset };
|
|
94
|
-
// A body word (
|
|
95
|
-
//
|
|
103
|
+
// A body word with no preset of its own (frame, bell, crotale ...)
|
|
104
|
+
// sets the body.
|
|
96
105
|
const body = MODAL_PARAMS.body;
|
|
97
106
|
if (body?.kind === "enum" && body.values.includes(rest[0]!))
|
|
98
107
|
return { type: "modal-set", values: { body: rest[0]! } };
|
|
99
108
|
return { type: "modal-usage", message: MODAL_USAGE };
|
|
100
109
|
}
|
|
110
|
+
// f061-gamelan-winds: `modal pair <track>` makes this track the
|
|
111
|
+
// pengisep of an ombak pair (ids keep their case).
|
|
112
|
+
if (rest[0] === "pair" && rest.length === 2) {
|
|
113
|
+
const id = prompt.trim().split(/\s+/)[2]!;
|
|
114
|
+
if (id === "off" || id === "unset" || id === "none")
|
|
115
|
+
return { type: "modal-set", values: { pair: null } };
|
|
116
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id))
|
|
117
|
+
return {
|
|
118
|
+
type: "modal-usage",
|
|
119
|
+
message: "modal pair <track id> | modal pair off",
|
|
120
|
+
};
|
|
121
|
+
return { type: "modal-set", values: { pair: id } };
|
|
122
|
+
}
|
|
101
123
|
if (rest.length % 2 !== 0)
|
|
102
124
|
return { type: "modal-usage", message: MODAL_USAGE };
|
|
103
125
|
const values: Record<string, number | string | null> = {};
|
|
@@ -159,6 +181,16 @@ export function nextModal(
|
|
|
159
181
|
>,
|
|
160
182
|
): TrackModal {
|
|
161
183
|
const base: Record<string, unknown> = { ...current };
|
|
184
|
+
// `modal ombak 6` or `modal pair t2` on a track that is not modal yet
|
|
185
|
+
// means a gamelan pair: start from gangsa, not the default marimba.
|
|
186
|
+
if (
|
|
187
|
+
current === undefined &&
|
|
188
|
+
command.type === "modal-set" &&
|
|
189
|
+
command.values.preset === undefined &&
|
|
190
|
+
(typeof command.values.ombak === "number" ||
|
|
191
|
+
typeof command.values.pair === "string")
|
|
192
|
+
)
|
|
193
|
+
base.preset = "gangsa";
|
|
162
194
|
if (command.type === "modal-reset")
|
|
163
195
|
return normalizeModal(current?.preset ? { preset: current.preset } : {})!;
|
|
164
196
|
if (command.type === "modal-preset") base.preset = command.preset;
|
|
@@ -183,6 +215,8 @@ export function applyModalCommand(
|
|
|
183
215
|
): ModalResult {
|
|
184
216
|
if (command.type === "modal-usage")
|
|
185
217
|
return { ok: false, message: `modal · ${command.message}` };
|
|
218
|
+
if (command.type === "modal-hint")
|
|
219
|
+
return { ok: true, message: `modal · ${command.message}` };
|
|
186
220
|
if (command.type === "modal-list")
|
|
187
221
|
return {
|
|
188
222
|
ok: true,
|
|
@@ -223,8 +257,37 @@ export function applyModalCommand(
|
|
|
223
257
|
}
|
|
224
258
|
let modal: TrackModal;
|
|
225
259
|
let next: TrackScore;
|
|
260
|
+
let partnerNote = "";
|
|
226
261
|
try {
|
|
227
262
|
modal = nextModal(current, command);
|
|
263
|
+
if (
|
|
264
|
+
command.type === "modal-set" &&
|
|
265
|
+
typeof command.values.pair === "string"
|
|
266
|
+
) {
|
|
267
|
+
const partnerId = command.values.pair;
|
|
268
|
+
const partner = score.tracks.find((entry) => entry.id === partnerId);
|
|
269
|
+
if (!partner || partnerId === trackId)
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
message: `modal · pair needs another track id (${score.tracks
|
|
273
|
+
.filter((entry) => entry.id !== trackId)
|
|
274
|
+
.map((entry) => entry.id)
|
|
275
|
+
.join(" ")})`,
|
|
276
|
+
};
|
|
277
|
+
if (partner.instrument !== MODAL_INSTRUMENT || !partner.modal)
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
message: `modal · ${partnerId} is not a modal track · modal gangsa on it first`,
|
|
281
|
+
};
|
|
282
|
+
// The partner (pengumbang) plays straight (ombak 0) and this track
|
|
283
|
+
// (pengisep) sounds ombak Hz above it, so the pair beats at ombak Hz.
|
|
284
|
+
if (modalSettings(partner.modal).ombak !== 0) {
|
|
285
|
+
score = updateTrack(score, partnerId, {
|
|
286
|
+
modal: normalizeModal({ ...partner.modal, ombak: 0 })!,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
partnerNote = ` · ${partnerId} is the pengumbang (ombak 0)`;
|
|
290
|
+
}
|
|
228
291
|
next = updateTrack(score, trackId, {
|
|
229
292
|
instrument: MODAL_INSTRUMENT,
|
|
230
293
|
modal,
|
|
@@ -239,7 +302,7 @@ export function applyModalCommand(
|
|
|
239
302
|
}
|
|
240
303
|
return {
|
|
241
304
|
ok: true,
|
|
242
|
-
message: `modal · ${describeModal(modal)}`,
|
|
305
|
+
message: `modal · ${describeModal(modal)}${partnerNote}`,
|
|
243
306
|
next,
|
|
244
307
|
kind: "score.modal",
|
|
245
308
|
payload: { trackId, instrument: MODAL_INSTRUMENT, modal },
|