@hraness/dawg 0.5.0 → 0.6.0

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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Modal percussion settings (0.6 "Instruments", lane f06-modal): mallets,
3
+ * bars, tines, bells, pans, bowls, gongs and drums played by one engine,
4
+ * a bank of decaying resonators struck by a mallet pulse
5
+ * (`src/audio/dsp/modal.ts`). Pure and import-light: the parameter table,
6
+ * the presets and the validator that `core/score.ts`, the prompt, the menu,
7
+ * the agent and the SDK printer share.
8
+ *
9
+ * A track plays this engine only when its `instrument` is `"modal"` AND it
10
+ * carries a `modal` object (`{ preset, ...overrides }`). A bare `"modal"`
11
+ * or `"marimba"` instrument without the field keeps the legacy tone voice
12
+ * byte for byte.
13
+ *
14
+ * Strike position maps the user range onto a safe interior per shape so no
15
+ * setting is silent: bars use x = p along the bar, tines x = 0.08 + 0.92 p
16
+ * from the clamp, membranes r = 0.05 + 0.85 p from the centre, shells tilt
17
+ * brightness by ratio^(2 (p - 0.5)); every mode keeps at least 0.02 of its
18
+ * maximum weight.
19
+ */
20
+ import { resolveInstrumentWord } from "./instruments.ts";
21
+ import {
22
+ FxValidationError,
23
+ isRecord,
24
+ normalizeParams,
25
+ type EnumParam,
26
+ type NumberParam,
27
+ type ParamSpec,
28
+ } from "./params.ts";
29
+
30
+ /** Instrument value that selects the modal engine. */
31
+ export const MODAL_INSTRUMENT = "modal" as const;
32
+
33
+ /** Mode tables the engine knows (`body` overrides the preset's). */
34
+ export const MODAL_BODIES = Object.freeze([
35
+ "marimba",
36
+ "vibraphone",
37
+ "xylophone",
38
+ "glockenspiel",
39
+ "celesta",
40
+ "chimes",
41
+ "crotale",
42
+ "mbira",
43
+ "kalimba",
44
+ "musicbox",
45
+ "toypiano",
46
+ "saron",
47
+ "bonang",
48
+ "gender",
49
+ "kempul",
50
+ "gong",
51
+ "bell",
52
+ "steelpan",
53
+ "bowl",
54
+ "timpani",
55
+ "tabla",
56
+ "frame",
57
+ ] as const);
58
+ export type ModalBody = (typeof MODAL_BODIES)[number];
59
+
60
+ /** Mallet words and the hardness each one sets. */
61
+ export const MODAL_MALLETS = Object.freeze({
62
+ yarn: 0.15,
63
+ cord: 0.35,
64
+ rubber: 0.55,
65
+ plastic: 0.75,
66
+ brass: 0.95,
67
+ } as const);
68
+ export type ModalMallet = keyof typeof MODAL_MALLETS;
69
+ export const MODAL_MALLET_NAMES = Object.freeze(
70
+ Object.keys(MODAL_MALLETS) as ModalMallet[],
71
+ );
72
+
73
+ /** The 0.6 core presets (lane A1). */
74
+ export const MODAL_PRESET_NAMES = Object.freeze([
75
+ "marimba",
76
+ "vibes",
77
+ "xylophone",
78
+ "glock",
79
+ "celesta",
80
+ "chimes",
81
+ "kalimba",
82
+ "mbira",
83
+ "steelpan",
84
+ "bowl",
85
+ "gong",
86
+ "timpani",
87
+ ] as const);
88
+ export type ModalPresetName = (typeof MODAL_PRESET_NAMES)[number];
89
+
90
+ /** Extra words that pick a preset (`instrument vibraphone`). */
91
+ export const MODAL_ALIASES: Readonly<Record<string, ModalPresetName>> =
92
+ Object.freeze({
93
+ vibraphone: "vibes",
94
+ glockenspiel: "glock",
95
+ tubular: "chimes",
96
+ thumbpiano: "kalimba",
97
+ gongageng: "gong",
98
+ steeldrum: "steelpan",
99
+ singingbowl: "bowl",
100
+ kettledrum: "timpani",
101
+ tubularbells: "chimes",
102
+ });
103
+
104
+ const num = (
105
+ min: number,
106
+ max: number,
107
+ fallback: number,
108
+ step: number | "log",
109
+ doc: string,
110
+ extra: Partial<NumberParam> = {},
111
+ ): NumberParam =>
112
+ Object.freeze({
113
+ kind: "number",
114
+ min,
115
+ max,
116
+ default: fallback,
117
+ step,
118
+ optional: true,
119
+ doc,
120
+ ...extra,
121
+ });
122
+
123
+ /**
124
+ * Every modal parameter. All are optional: an absent one takes the
125
+ * preset's value, then the default here. `automate` ones have a
126
+ * `modal-<param>` lane read at each note's onset (`motordepth` is read
127
+ * every 32-sample control tick).
128
+ */
129
+ export const MODAL_PARAMS: Readonly<Record<string, ParamSpec>> = Object.freeze({
130
+ mallet: Object.freeze({
131
+ kind: "enum",
132
+ values: MODAL_MALLET_NAMES,
133
+ default: "cord",
134
+ optional: true,
135
+ doc: "mallet: yarn, cord, rubber, plastic or brass (sets hardness)",
136
+ }) as EnumParam,
137
+ hardness: num(0, 1, 0.4, 0.05, "mallet hardness: 0 yarn/felt, 1 brass", {
138
+ automate: true,
139
+ }),
140
+ position: num(0, 1, 0.42, 0.05, "strike point: 0 end or edge, 0.5 centre", {
141
+ automate: true,
142
+ }),
143
+ ring: num(0.05, 30, 1.6, "log", "ring time (T60) at middle C", {
144
+ unit: "s",
145
+ automate: true,
146
+ }),
147
+ tilt: num(0, 2, 0.9, 0.05, "how much faster high modes and notes decay", {
148
+ automate: true,
149
+ }),
150
+ damp: num(0, 1, 0, 0.05, "damping at note-off: 0 rings on, 1 chokes", {
151
+ automate: true,
152
+ }),
153
+ release: num(0.005, 2, 0.12, "log", "choke time when damp is 1", {
154
+ unit: "s",
155
+ strudel: ["release", "rel"],
156
+ }),
157
+ motor: num(0, 12, 0, 0.25, "vibraphone motor tremolo rate", {
158
+ unit: "Hz",
159
+ }),
160
+ motordepth: num(0, 1, 0, 0.05, "motor tremolo depth", { automate: true }),
161
+ ombak: num(0, 12, 0, 0.25, "paired-instrument beat rate (gamelan ombak)", {
162
+ unit: "Hz",
163
+ }),
164
+ buzz: num(0, 1, 0, 0.05, "buzzer or jingle amount", { automate: true }),
165
+ click: num(0, 1, 0, 0.05, "mallet contact click", { automate: true }),
166
+ strikebend: num(-24, 24, 0, 0.5, "strike pitch glide start", {
167
+ unit: "st",
168
+ strudel: ["penv"],
169
+ }),
170
+ strikedecay: num(0.001, 2, 0.1, "log", "strike pitch glide time", {
171
+ unit: "s",
172
+ strudel: ["pdecay"],
173
+ }),
174
+ gain: num(0, 2, 0.8, 0.05, "level", { automate: true }),
175
+ body: Object.freeze({
176
+ kind: "enum",
177
+ values: MODAL_BODIES,
178
+ default: "marimba",
179
+ optional: true,
180
+ doc: "mode table (from the preset)",
181
+ }) as EnumParam,
182
+ });
183
+
184
+ /** Menu rows: the first five are what most people reach for. */
185
+ export const MODAL_SIMPLE = Object.freeze([
186
+ "mallet",
187
+ "hardness",
188
+ "position",
189
+ "ring",
190
+ "damp",
191
+ ]);
192
+
193
+ /** Resolved settings the engine plays. */
194
+ export type ModalSettings = Readonly<{
195
+ body: ModalBody;
196
+ hardness: number;
197
+ position: number;
198
+ ring: number;
199
+ tilt: number;
200
+ damp: number;
201
+ release: number;
202
+ motor: number;
203
+ motordepth: number;
204
+ ombak: number;
205
+ buzz: number;
206
+ click: number;
207
+ strikebend: number;
208
+ strikedecay: number;
209
+ gain: number;
210
+ }>;
211
+
212
+ export const MODAL_DEFAULTS: ModalSettings = Object.freeze({
213
+ body: "marimba",
214
+ hardness: 0.4,
215
+ position: 0.42,
216
+ ring: 1.6,
217
+ tilt: 0.9,
218
+ damp: 0,
219
+ release: 0.12,
220
+ motor: 0,
221
+ motordepth: 0,
222
+ ombak: 0,
223
+ buzz: 0,
224
+ click: 0,
225
+ strikebend: 0,
226
+ strikedecay: 0.1,
227
+ gain: 0.8,
228
+ });
229
+
230
+ export type ModalPreset = Readonly<{
231
+ settings: ModalSettings;
232
+ /** MIDI range the instrument is built for (browse hint and tests). */
233
+ range: readonly [number, number];
234
+ doc: string;
235
+ styles: string;
236
+ }>;
237
+
238
+ const preset = (
239
+ settings: Partial<ModalSettings>,
240
+ range: [number, number],
241
+ doc: string,
242
+ styles: string,
243
+ ): ModalPreset =>
244
+ Object.freeze({
245
+ settings: Object.freeze({ ...MODAL_DEFAULTS, ...settings }),
246
+ range: Object.freeze(range) as readonly [number, number],
247
+ doc,
248
+ styles,
249
+ });
250
+
251
+ /**
252
+ * Presets, ported from the reviewed prototype (proto/resonators). Vibes
253
+ * carry damp 0.3 (a vibist pedals; fully damped bars sounded dead in
254
+ * review) and every preset uses the strengthened velocity coupling.
255
+ */
256
+ export const MODAL_PRESETS: Readonly<Record<ModalPresetName, ModalPreset>> =
257
+ Object.freeze({
258
+ marimba: preset(
259
+ { body: "marimba", ring: 1.6, tilt: 0.9, hardness: 0.35, position: 0.42 },
260
+ [45, 96],
261
+ "rosewood bars, yarn mallets, warm and round",
262
+ "Reich, Glass, Four Tet, Caribou, jazz",
263
+ ),
264
+ vibes: preset(
265
+ {
266
+ body: "vibraphone",
267
+ ring: 6,
268
+ tilt: 0.6,
269
+ hardness: 0.5,
270
+ position: 0.42,
271
+ damp: 0.3,
272
+ release: 0.15,
273
+ motor: 5.5,
274
+ motordepth: 0.45,
275
+ },
276
+ [53, 89],
277
+ "aluminium bars with motor tremolo, damped unless the pedal is down",
278
+ "jazz, Motown, soul, Four Tet",
279
+ ),
280
+ xylophone: preset(
281
+ {
282
+ body: "xylophone",
283
+ ring: 0.6,
284
+ tilt: 1.2,
285
+ hardness: 0.85,
286
+ position: 0.45,
287
+ },
288
+ [65, 108],
289
+ "hard, dry and bright",
290
+ "orchestral, Messiaen, Shostakovich",
291
+ ),
292
+ glock: preset(
293
+ {
294
+ body: "glockenspiel",
295
+ ring: 3.5,
296
+ tilt: 0.5,
297
+ hardness: 0.9,
298
+ position: 0.45,
299
+ },
300
+ [79, 108],
301
+ "steel bars, brass mallets, sparkling",
302
+ "orchestral, Motown, The Field, Beach House",
303
+ ),
304
+ celesta: preset(
305
+ { body: "celesta", ring: 1.2, tilt: 0.7, hardness: 0.5, position: 0.45 },
306
+ [60, 108],
307
+ "felt-hammered steel plates, soft and glassy",
308
+ "Debussy, OPN, Radiohead, ambient",
309
+ ),
310
+ chimes: preset(
311
+ { body: "chimes", ring: 7, tilt: 0.35, hardness: 0.8, position: 0.05 },
312
+ [60, 77],
313
+ "tubular bells struck at the cap",
314
+ "orchestral, Messiaen, ambient",
315
+ ),
316
+ kalimba: preset(
317
+ { body: "kalimba", ring: 1.4, tilt: 0.8, hardness: 0.6, position: 0.7 },
318
+ [60, 88],
319
+ "clean thumb piano",
320
+ "Four Tet, Caribou, ambient, hyperpop",
321
+ ),
322
+ mbira: preset(
323
+ {
324
+ body: "mbira",
325
+ ring: 1.4,
326
+ tilt: 0.8,
327
+ hardness: 0.7,
328
+ position: 0.7,
329
+ buzz: 0.35,
330
+ },
331
+ [48, 84],
332
+ "thumb-plucked tines with a bottle-cap buzz",
333
+ "mbira, Four Tet, Caribou",
334
+ ),
335
+ steelpan: preset(
336
+ { body: "steelpan", ring: 1.6, tilt: 0.6, hardness: 0.6, position: 0.5 },
337
+ [60, 88],
338
+ "tuned steel drum with near-harmonic partials",
339
+ "Caribou, ambient, calypso",
340
+ ),
341
+ bowl: preset(
342
+ { body: "bowl", ring: 18, tilt: 0.25, hardness: 0.2, position: 0.5 },
343
+ [48, 79],
344
+ "singing bowl, beating split modes",
345
+ "ambient, Eno, La Monte Young",
346
+ ),
347
+ gong: preset(
348
+ { body: "gong", ring: 12, tilt: 0.3, hardness: 0.15, position: 0.5 },
349
+ [31, 55],
350
+ "gong ageng: deep, beating, very long",
351
+ "gamelan, ambient, La Monte Young",
352
+ ),
353
+ timpani: preset(
354
+ {
355
+ body: "timpani",
356
+ ring: 2.2,
357
+ tilt: 0.6,
358
+ hardness: 0.4,
359
+ position: 0.75,
360
+ strikebend: 0.3,
361
+ strikedecay: 0.15,
362
+ },
363
+ [40, 60],
364
+ "pedal timpani, felt sticks, a small strike glide",
365
+ "Beethoven, Brahms, Shostakovich, orchestral",
366
+ ),
367
+ });
368
+
369
+ /** Default preset of a `modal` field that names none. */
370
+ export const DEFAULT_MODAL_PRESET: ModalPresetName = "marimba";
371
+
372
+ /** A track's stored modal settings: a preset plus overrides, all optional. */
373
+ export type TrackModal = Readonly<{
374
+ preset?: ModalPresetName;
375
+ mallet?: ModalMallet;
376
+ hardness?: number;
377
+ position?: number;
378
+ ring?: number;
379
+ tilt?: number;
380
+ damp?: number;
381
+ release?: number;
382
+ motor?: number;
383
+ motordepth?: number;
384
+ ombak?: number;
385
+ buzz?: number;
386
+ click?: number;
387
+ strikebend?: number;
388
+ strikedecay?: number;
389
+ gain?: number;
390
+ body?: ModalBody;
391
+ }>;
392
+
393
+ export function isModalPreset(name: string): name is ModalPresetName {
394
+ return (MODAL_PRESET_NAMES as readonly string[]).includes(name);
395
+ }
396
+
397
+ /** A preset word or alias (`vibraphone`) to its preset; undefined if neither. */
398
+ export function modalPresetFor(word: string): ModalPresetName | undefined {
399
+ const lower = word.trim().toLowerCase();
400
+ if (isModalPreset(lower)) return lower;
401
+ return MODAL_ALIASES[lower];
402
+ }
403
+
404
+ export function isModalParam(name: string): boolean {
405
+ return Object.prototype.hasOwnProperty.call(MODAL_PARAMS, name);
406
+ }
407
+
408
+ /** Strudel spelling (`penv`, `pdecay`, `rel`) to a modal parameter name. */
409
+ export function modalParamName(name: string): string | undefined {
410
+ if (isModalParam(name)) return name;
411
+ for (const [key, spec] of Object.entries(MODAL_PARAMS))
412
+ if (spec.strudel?.includes(name)) return key;
413
+ return undefined;
414
+ }
415
+
416
+ /**
417
+ * Validates a `modal` field: `{ preset?, ...overrides }`, null or absent
418
+ * meaning none. Keys come out in canonical order (preset, then
419
+ * `MODAL_PARAMS` order) so equal settings print equally. `{}` is kept: it
420
+ * means the default preset.
421
+ */
422
+ export function normalizeModal(input: unknown): TrackModal | undefined {
423
+ if (input === undefined || input === null) return undefined;
424
+ if (!isRecord(input))
425
+ throw new FxValidationError("track modal must be an object or null");
426
+ const params: Record<string, unknown> = {};
427
+ let presetName: ModalPresetName | undefined;
428
+ for (const [key, value] of Object.entries(input)) {
429
+ if (value === undefined || value === null) continue;
430
+ if (key === "preset") {
431
+ if (typeof value !== "string")
432
+ throw new FxValidationError(
433
+ `modal preset must be one of ${MODAL_PRESET_NAMES.join(", ")}`,
434
+ );
435
+ const resolved = modalPresetFor(value);
436
+ if (!resolved)
437
+ throw new FxValidationError(
438
+ `modal preset must be one of ${MODAL_PRESET_NAMES.join(", ")}`,
439
+ );
440
+ presetName = resolved;
441
+ continue;
442
+ }
443
+ const name = modalParamName(key);
444
+ if (!name)
445
+ throw new FxValidationError(
446
+ `modal has no parameter "${key.slice(0, 32)}"`,
447
+ );
448
+ params[name] = value;
449
+ }
450
+ const values = normalizeParams(MODAL_PARAMS, params, "modal", false);
451
+ return Object.freeze({
452
+ ...(presetName ? { preset: presetName } : {}),
453
+ ...values,
454
+ }) as TrackModal;
455
+ }
456
+
457
+ /**
458
+ * The settings a `modal` field plays: defaults, then the preset, then the
459
+ * mallet's hardness, then explicit overrides. `lane` reads an automation
460
+ * lane value for a parameter (at the note's onset) when present.
461
+ */
462
+ export function modalSettings(
463
+ modal: TrackModal | undefined,
464
+ lane?: (param: string) => number | undefined,
465
+ ): ModalSettings {
466
+ const base = MODAL_PRESETS[modal?.preset ?? DEFAULT_MODAL_PRESET].settings;
467
+ const out: Record<string, number | string> = { ...base };
468
+ if (modal?.mallet) out.hardness = MODAL_MALLETS[modal.mallet];
469
+ if (modal)
470
+ for (const [key, value] of Object.entries(modal))
471
+ if (key !== "preset" && key !== "mallet" && value !== undefined)
472
+ out[key] = value;
473
+ if (lane)
474
+ for (const key of MODAL_LANE_PARAMS) {
475
+ const value = lane(key.param);
476
+ if (value !== undefined) out[key.param] = value;
477
+ }
478
+ return Object.freeze(out) as ModalSettings;
479
+ }
480
+
481
+ /** Parameters with a `modal-<param>` automation lane. */
482
+ export const MODAL_LANE_PARAMS: readonly Readonly<{
483
+ param: string;
484
+ spec: NumberParam;
485
+ }>[] = Object.freeze(
486
+ Object.entries(MODAL_PARAMS)
487
+ .filter(
488
+ (entry): entry is [string, NumberParam] =>
489
+ entry[1].kind === "number" && entry[1].automate === true,
490
+ )
491
+ .map(([param, spec]) => Object.freeze({ param, spec })),
492
+ );
493
+
494
+ /** Upper bound on a modal voice's ring after note-off, in seconds. */
495
+ export const MAX_RESONATOR_TAIL = 30;
496
+ /** Voices one modal track sounds at once; the oldest onset is stolen. */
497
+ export const MAX_RESONATOR_VOICES = 32;
498
+ /** Joins the stem cache key so mode-table changes invalidate stems. */
499
+ export const RESONATOR_TABLE_VERSION = 1;
500
+
501
+ /**
502
+ * A modal track's ring-out after its last note-off, in seconds: the preset
503
+ * ring stretched to an octave below middle C by `tilt`, or the choke time
504
+ * when fully damped. Early silence detection usually stops sooner.
505
+ */
506
+ export function modalTailSeconds(
507
+ modal: TrackModal | undefined,
508
+ lowestPitch?: number,
509
+ ): number {
510
+ const s = modalSettings(modal);
511
+ if (s.damp >= 0.999) return Math.min(MAX_RESONATOR_TAIL, s.release + 0.05);
512
+ // The ring law at C3, or at the lowest note when it is lower (a low gong
513
+ // rings longer than the C3 estimate).
514
+ const octavesBelowC4 =
515
+ lowestPitch === undefined ? 1 : Math.max(1, (60 - lowestPitch) / 12);
516
+ return Math.min(MAX_RESONATOR_TAIL, s.ring * 2 ** (s.tilt * octavesBelowC4));
517
+ }
518
+
519
+ /**
520
+ * The track patch an instrument word makes: a modal word (`vibes`,
521
+ * `glockenspiel`, `modal`) sets `instrument: "modal"` and `modal: { preset }`;
522
+ * any other word (legacy `marimba` included) only sets `instrument`.
523
+ */
524
+ export function instrumentPatchForWord(word: string): {
525
+ instrument: string;
526
+ modal?: TrackModal;
527
+ } {
528
+ const meaning = resolveInstrumentWord(word);
529
+ if (meaning?.field === "modal" && meaning.instrument === MODAL_INSTRUMENT) {
530
+ const preset = meaning.preset ? modalPresetFor(meaning.preset) : undefined;
531
+ return {
532
+ instrument: MODAL_INSTRUMENT,
533
+ modal: Object.freeze(preset ? { preset } : {}) as TrackModal,
534
+ };
535
+ }
536
+ return { instrument: meaning?.instrument ?? word };
537
+ }
538
+
539
+ /** True for a word that resolves to the modal engine (`vibes`, `gong`). */
540
+ export function isModalWord(word: string): boolean {
541
+ return resolveInstrumentWord(word)?.instrument === MODAL_INSTRUMENT;
542
+ }
543
+
544
+ /**
545
+ * `dawg check` notes for tracks that store a legacy word the 0.6 physical
546
+ * engines also answer to (`marimba`, `modal`, `wind`) with no engine
547
+ * field: they keep the pre-0.6 tone, which is right for old projects but
548
+ * rarely what a new one means.
549
+ */
550
+ export function legacyResonatorWarnings(
551
+ tracks: readonly Readonly<{
552
+ id: string;
553
+ instrument: string;
554
+ modal?: unknown;
555
+ wind?: unknown;
556
+ }>[],
557
+ ): string[] {
558
+ const out: string[] = [];
559
+ for (const track of tracks) {
560
+ if (
561
+ track.instrument === "marimba" ||
562
+ track.instrument === MODAL_INSTRUMENT
563
+ ) {
564
+ if (track.modal === undefined)
565
+ out.push(
566
+ `track ${track.id}: instrument "${track.instrument}" plays the legacy marimba tone; use modal("marimba") (or \`modal marimba\`) for the modal mallet engine`,
567
+ );
568
+ } else if (track.instrument === "wind" && track.wind === undefined)
569
+ out.push(
570
+ `track ${track.id}: instrument "wind" plays the legacy wind tone; the 0.6 wind engine needs a wind preset`,
571
+ );
572
+ }
573
+ return out;
574
+ }