@hraness/dawg 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
@@ -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
- KEYS_SIMPLE,
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
- | { type: "keys-presets" }
68
+ /** `family`: `epiano preset` lists only that family's presets. */
69
+ | { type: "keys-presets"; family?: string }
42
70
  | { type: "keys-reset" }
43
- | { type: "keys-preset"; preset: string }
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: `keys presets · ${Object.keys(KEYS_PRESETS).join(" ")}`,
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
- const piano = isPianoFamily(track.instrument);
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 ${KEYS_SIMPLE.join(" ")}`
250
- : `keys · ${trackId} is ${track.instrument}; type piano (or grand upright felt honkytonk prepared) for the modelled piano`,
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
- : `keys · ${trackId} is ${track.instrument}; type piano first (keys shapes the modelled 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 },
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
- ? `keys · ${stored.instrument} · preset ${command.preset} · ${KEYS_PRESETS[command.preset]!.doc}${extras.length > 0 ? ` · with ${extras.join(" ")}` : ""}`
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",
@@ -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 (saron, kempul, bonang, gender ...) sets the body: the
95
- // gamelan presets come later, the bodies already ship.
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 },