@hraness/dawg 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,485 @@
1
+ /**
2
+ * The keys engine (0.6, lane keys-piano): renders a track whose instrument
3
+ * is a piano family (grand upright felt honkytonk prepared) AND that
4
+ * carries `Track.keys`. Notes are performed by `performNotes` first, so the
5
+ * 0.5 sustain pedal (down, half, up), velocity curves, humanize, bends and
6
+ * vibrato all arrive on the notes; this file turns them into voices.
7
+ *
8
+ * - Voices run in 32-sample control blocks (bend, vibrato and the half
9
+ * pedal are read per block; `keys-<param>` lanes at each onset).
10
+ * - Polyphony: 64 voices per track; beyond that the oldest released voice
11
+ * is stolen, else the oldest, with a 5 ms fade. Notes whose frequency is
12
+ * not positive (an unmapped degree of a tuning table) are dropped.
13
+ * - Every voice ends inside the renderer's 8 s tail window with a 250 ms
14
+ * raised-cosine taper, so a long bass or pedalled note never hard-cuts
15
+ * at the window or the loop fold.
16
+ * - Post: the family's body EQ per channel (the minimal per-track post
17
+ * hook), then the track volume, before the existing effects chain.
18
+ */
19
+ import { pedalStateAt, type PerformedNote } from "../../../core/expression.ts";
20
+ import {
21
+ isElectricFamily,
22
+ isPianoFamily,
23
+ resolvedKeys,
24
+ type ElectricFamily,
25
+ } from "../../../core/keys.ts";
26
+ import type { Track } from "../../../core/score.ts";
27
+ import { noteHz } from "../../../core/tuning.ts";
28
+ import type { FxLane } from "../../../core/fx.ts";
29
+ import { interpolateAutomation } from "../effects/common.ts";
30
+ import type { EngineContext, InstrumentEngine } from "../instruments.ts";
31
+ import { Biquad2, clamp, CONTROL, LN1000 } from "./dsp.ts";
32
+ import { ORGAN_ENGINES } from "./organ.ts";
33
+ import {
34
+ damperT60,
35
+ physicalKey,
36
+ pianoF1,
37
+ undamped,
38
+ PianoVoice,
39
+ pianoBody,
40
+ trackSeedOf,
41
+ type PianoParams,
42
+ } from "./piano.ts";
43
+ import {
44
+ clavReleaseT60,
45
+ electricPost,
46
+ electricVoice,
47
+ tineReleaseT60,
48
+ type ElectricParams,
49
+ type KeysVoice,
50
+ } from "./electric.ts";
51
+ import { Sympathetic } from "./sympathetic.ts";
52
+
53
+ export const KEYS_LIMITS = Object.freeze({
54
+ /** Per-track polyphony cap (voice stealing beyond it). */
55
+ voices: 64,
56
+ /** Seconds a voice may ring after its note ends (the wav.ts window). */
57
+ tailSeconds: 8,
58
+ /** Raised-cosine taper ending each voice inside the window. */
59
+ taperSeconds: 0.25,
60
+ /** Fade of a stolen voice. */
61
+ stealSeconds: 0.005,
62
+ });
63
+
64
+ /** Lane parameters read at a note's onset. */
65
+ const LANE_PARAMS = Object.freeze([
66
+ "hardness",
67
+ "touch",
68
+ "decay",
69
+ "release",
70
+ "knock",
71
+ "noise",
72
+ "felt",
73
+ ] as const);
74
+
75
+ type Values = Readonly<Record<string, number | string>>;
76
+
77
+ function num(values: Values, key: string): number {
78
+ const value = values[key];
79
+ return typeof value === "number" ? value : 0;
80
+ }
81
+
82
+ /** Voice parameters for a note: the resolved keys plus lanes at `tick`. */
83
+ export function pianoParamsAt(
84
+ track: Track,
85
+ values: Values,
86
+ tick: number,
87
+ ): PianoParams {
88
+ const lane = (name: string) => {
89
+ const points = track.fxAutomation?.[`keys-${name}` as FxLane];
90
+ const base = num(values, name);
91
+ return points && points.length > 0
92
+ ? interpolateAutomation(points, tick, base)
93
+ : base;
94
+ };
95
+ const lanes = Object.fromEntries(
96
+ LANE_PARAMS.map((name) => [name, lane(name)]),
97
+ );
98
+ return {
99
+ hardness: clamp(lanes.hardness!, 0, 1),
100
+ touch: clamp(lanes.touch!, 0, 1),
101
+ inharm: num(values, "inharm"),
102
+ unison: num(values, "unison"),
103
+ decay: clamp(lanes.decay!, 0.1, 4),
104
+ release: clamp(lanes.release!, 0.1, 4),
105
+ strike: num(values, "strike"),
106
+ after: num(values, "after"),
107
+ knock: clamp(lanes.knock!, 0, 1),
108
+ noise: clamp(lanes.noise!, 0, 1),
109
+ felt: clamp(lanes.felt!, 0, 1),
110
+ prep: num(values, "prep"),
111
+ width: num(values, "width"),
112
+ stretch: num(values, "stretch"),
113
+ };
114
+ }
115
+
116
+ /** Electric voice parameters for a note: the resolved keys plus lanes. */
117
+ export function electricParamsAt(
118
+ track: Track,
119
+ values: Values,
120
+ tick: number,
121
+ kind: ElectricFamily,
122
+ ): ElectricParams {
123
+ const lane = (name: string) => {
124
+ const points = track.fxAutomation?.[`keys-${name}` as FxLane];
125
+ const base = num(values, name);
126
+ return points && points.length > 0
127
+ ? interpolateAutomation(points, tick, base)
128
+ : base;
129
+ };
130
+ const pickup = values.pickup;
131
+ return {
132
+ kind,
133
+ hardness: clamp(lane("hardness"), 0, 1),
134
+ touch: clamp(lane("touch"), 0, 1),
135
+ decay: clamp(lane("decay"), 0.1, 4),
136
+ release: clamp(lane("release"), 0.1, 4),
137
+ width: num(values, "width"),
138
+ bark: num(values, "bark"),
139
+ bell: num(values, "bell"),
140
+ tone: clamp(lane("tone"), 0, 12000),
141
+ pickup:
142
+ pickup === "neck" || pickup === "bridge" || pickup === "out"
143
+ ? pickup
144
+ : "both",
145
+ mute: num(values, "mute"),
146
+ };
147
+ }
148
+
149
+ /** Body voicing name for a track (the stored `body`, else the family's). */
150
+ export function bodyOf(values: Values): string {
151
+ const body = values.body;
152
+ return typeof body === "string" ? body : "grand";
153
+ }
154
+
155
+ type Live = {
156
+ voice: KeysVoice;
157
+ /** Sample index the voice started at. */
158
+ start: number;
159
+ /** Sample index of key-up. */
160
+ off: number;
161
+ /** Sample index the voice must be silent by (taper end). */
162
+ end: number;
163
+ order: number;
164
+ /** Per-block bend and vibrato, cents at seconds from the note start. */
165
+ cents?: (t: number) => number;
166
+ damp?: Readonly<{ from: number; tau: number }>;
167
+ damped: boolean;
168
+ /** Being stolen: fading out over `fadeLength`, then removed. */
169
+ stolen: boolean;
170
+ /** Steal fade samples left. */
171
+ fade: number;
172
+ fadeLength: number;
173
+ };
174
+
175
+ type Onset = Readonly<{
176
+ note: PerformedNote;
177
+ start: number;
178
+ off: number;
179
+ hz: number;
180
+ order: number;
181
+ }>;
182
+
183
+ function spanOf(
184
+ note: PerformedNote,
185
+ context: EngineContext,
186
+ ): { start: number; off: number } {
187
+ const { warp, samplesPerTick } = context;
188
+ if (warp) {
189
+ const start = Math.max(0, Math.floor(warp.sample(note.startTick)));
190
+ const end = Math.floor(warp.sample(note.startTick + note.durationTicks));
191
+ return { start, off: Math.max(start + 1, end) };
192
+ }
193
+ const start = Math.max(0, Math.floor(note.startTick * samplesPerTick));
194
+ const length = Math.max(1, Math.floor(note.durationTicks * samplesPerTick));
195
+ return { start, off: start + length };
196
+ }
197
+
198
+ /**
199
+ * Renders a keys track's performed notes into `left` and `right` (both
200
+ * already zeroed or holding earlier output; voices add).
201
+ */
202
+ export function renderKeysTrack(
203
+ left: Float64Array,
204
+ right: Float64Array,
205
+ notes: readonly PerformedNote[],
206
+ track: Track,
207
+ context: EngineContext,
208
+ ): void {
209
+ const sr = context.sampleRate;
210
+ const total = left.length;
211
+ const values = resolvedKeys(track.instrument ?? "grand", track.keys);
212
+ const electric = isElectricFamily(track.instrument)
213
+ ? track.instrument
214
+ : undefined;
215
+ const trackSeed = trackSeedOf(track.id);
216
+ const seedTick = context.seedTick ?? 0;
217
+ const window = Math.round(KEYS_LIMITS.tailSeconds * sr);
218
+ const taper = Math.round(KEYS_LIMITS.taperSeconds * sr);
219
+ const onsets: Onset[] = [];
220
+ notes.forEach((note) => {
221
+ const hz = noteHz(note.pitch, note.cents, context.tuning);
222
+ // An unmapped degree of a tuning table is silent.
223
+ if (!(hz > 0) || !Number.isFinite(hz)) return;
224
+ const { start, off } = spanOf(note, context);
225
+ if (start >= total) return;
226
+ onsets.push({ note, start, off, hz, order: 0 });
227
+ });
228
+ onsets.sort(
229
+ (a, b) =>
230
+ a.start - b.start ||
231
+ a.note.startTick - b.note.startTick ||
232
+ (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
233
+ );
234
+ const live: Live[] = [];
235
+ const block = new Float64Array(CONTROL);
236
+ let next = 0;
237
+ let order = 0;
238
+ for (let i = 0; i < total; i += CONTROL) {
239
+ const count = Math.min(CONTROL, total - i);
240
+ while (next < onsets.length && onsets[next]!.start < i + count) {
241
+ const onset = onsets[next]!;
242
+ next += 1;
243
+ const playing = live.filter((x) => !x.stolen);
244
+ if (playing.length >= KEYS_LIMITS.voices) {
245
+ // Steal the oldest released voice, else the oldest.
246
+ const released = playing.filter((x) => x.voice.released);
247
+ const pool = released.length > 0 ? released : playing;
248
+ let victim = pool[0]!;
249
+ for (const x of pool) if (x.order < victim.order) victim = x;
250
+ victim.fadeLength = Math.max(
251
+ 1,
252
+ Math.round(KEYS_LIMITS.stealSeconds * sr),
253
+ );
254
+ victim.fade = victim.fadeLength;
255
+ victim.stolen = true;
256
+ }
257
+ const note = onset.note;
258
+ // Una corda (0.6.1) at the onset: down is the full shift, half half.
259
+ const softState = track.softPedal
260
+ ? pedalStateAt(track.softPedal, note.startTick)
261
+ : "up";
262
+ const soft = softState === "down" ? 1 : softState === "half" ? 0.5 : 0;
263
+ const noteOn = {
264
+ pitch: note.pitch,
265
+ hz: onset.hz,
266
+ velocity: clamp(note.velocity, 0, 1),
267
+ trackSeed,
268
+ noteSeed: `${track.id}:${note.id}:${note.startTick + seedTick}`,
269
+ ...(soft > 0 ? { soft } : {}),
270
+ };
271
+ const voice: KeysVoice = electric
272
+ ? electricVoice(
273
+ noteOn,
274
+ electricParamsAt(track, values, note.startTick, electric),
275
+ sr,
276
+ )
277
+ : new PianoVoice(
278
+ noteOn,
279
+ pianoParamsAt(track, values, note.startTick),
280
+ sr,
281
+ );
282
+ const performance = note.performance;
283
+ // The `vib`/`vibmod` wow (lofi) unless the note brings its own vibrato.
284
+ const vib = num(values, "vib");
285
+ const depth = num(values, "vibmod") * 100;
286
+ const wow =
287
+ vib > 0 && depth > 0 && !performance?.replaceVibrato
288
+ ? (t: number) => depth * Math.sin(2 * Math.PI * vib * t)
289
+ : undefined;
290
+ const bend = performance?.cents;
291
+ const cents =
292
+ bend && wow ? (t: number) => bend(t) + wow(t) : (bend ?? wow);
293
+ live.push({
294
+ voice,
295
+ start: onset.start,
296
+ off: onset.off,
297
+ end: onset.off + window,
298
+ order: order++,
299
+ ...(cents ? { cents } : {}),
300
+ ...(performance?.damp ? { damp: performance.damp } : {}),
301
+ damped: false,
302
+ stolen: false,
303
+ fade: 0,
304
+ fadeLength: 0,
305
+ });
306
+ }
307
+ for (let k = live.length - 1; k >= 0; k -= 1) {
308
+ const x = live[k]!;
309
+ // Voices that start inside this block begin at their own sample.
310
+ const from = Math.max(i, x.start);
311
+ const n = i + count - from;
312
+ if (n <= 0) continue;
313
+ const t = (from - x.start) / sr;
314
+ if (x.cents) x.voice.setCents(x.cents(t));
315
+ if (x.damp && !x.damped && t >= x.damp.from) {
316
+ x.voice.damp(x.damp.tau);
317
+ x.damped = true;
318
+ }
319
+ if (!x.voice.released && x.off < from + n) x.voice.noteOff();
320
+ block.fill(0, 0, n);
321
+ let alive = x.voice.process(block, 0, n);
322
+ const { gl, gr } = x.voice;
323
+ for (let s = 0; s < n; s += 1) {
324
+ const index = from + s;
325
+ let gain = 1;
326
+ const left0 = x.end - index;
327
+ if (left0 <= 0) gain = 0;
328
+ else if (left0 < taper)
329
+ gain = 0.5 - 0.5 * Math.cos((Math.PI * left0) / taper);
330
+ if (x.stolen) {
331
+ gain *= Math.max(0, x.fade) / x.fadeLength;
332
+ x.fade -= 1;
333
+ }
334
+ const y = block[s]! * gain;
335
+ left[index]! += y * gl;
336
+ right[index]! += y * gr;
337
+ }
338
+ if (x.stolen && x.fade <= 0) alive = false;
339
+ if (from + n >= x.end) alive = false;
340
+ if (!alive) live.splice(k, 1);
341
+ }
342
+ }
343
+ if (electric) {
344
+ // The electric post: suitcase vibrato or reed tremolo, then volume.
345
+ const param = electric === "wurli" ? "trem" : "vibe";
346
+ const points = track.fxAutomation?.[`keys-${param}` as FxLane];
347
+ const base = num(values, param);
348
+ const tickOf = (sample: number) =>
349
+ context.warp
350
+ ? context.warp.tick(sample)
351
+ : sample / context.samplesPerTick;
352
+ const depthAt =
353
+ points && points.length > 0
354
+ ? (sample: number) =>
355
+ interpolateAutomation(points, tickOf(sample), base)
356
+ : () => base;
357
+ const rate = electric === "wurli" ? 5.6 : num(values, "vibehz") || 4;
358
+ const startSample = Math.round(seedTick * context.samplesPerTick);
359
+ electricPost(left, right, electric, rate, depthAt, sr, startSample);
360
+ applyVolume(left, right, track, context);
361
+ return;
362
+ }
363
+ // Sympathetic resonance (0.6.1): only with `sym` and sustain pedal events.
364
+ const sym = num(values, "sym");
365
+ const pedal = track.pedal;
366
+ if (sym > 0 && pedal?.some((event) => event.state !== "up")) {
367
+ const bank = new Sympathetic(
368
+ (pitch) => noteHz(pitch, undefined, context.tuning),
369
+ sym,
370
+ sr,
371
+ );
372
+ // Pedal changes as samples; the bank reads them in order.
373
+ const changes = pedal.map((event) => ({
374
+ at: context.warp
375
+ ? context.warp.sample(event.tick)
376
+ : event.tick * context.samplesPerTick,
377
+ down: event.state !== "up",
378
+ }));
379
+ let cursor = 0;
380
+ let down = false;
381
+ bank.process(left, right, (sample) => {
382
+ while (cursor < changes.length && changes[cursor]!.at <= sample)
383
+ down = changes[cursor++]!.down;
384
+ return down;
385
+ });
386
+ }
387
+ // The per-track post hook: body EQ, then the track volume.
388
+ const body = bodyOf(values);
389
+ const eqL = pianoBody(body, sr);
390
+ const eqR = pianoBody(body, sr);
391
+ applyEq(left, eqL);
392
+ applyEq(right, eqR);
393
+ applyVolume(left, right, track, context);
394
+ }
395
+
396
+ function applyEq(buffer: Float64Array, filters: readonly Biquad2[]): void {
397
+ for (const filter of filters)
398
+ for (let i = 0; i < buffer.length; i += 1)
399
+ buffer[i] = filter.process(buffer[i]!);
400
+ }
401
+
402
+ function applyVolume(
403
+ left: Float64Array,
404
+ right: Float64Array,
405
+ track: Track,
406
+ context: EngineContext,
407
+ ): void {
408
+ const volume = clamp(track.volume ?? 1, 0, 1);
409
+ const points = track.volumeAutomation ?? [];
410
+ if (points.length === 0) {
411
+ if (volume === 1) return;
412
+ for (let i = 0; i < left.length; i += 1) {
413
+ left[i]! *= volume;
414
+ right[i]! *= volume;
415
+ }
416
+ return;
417
+ }
418
+ for (let i = 0; i < left.length; i += CONTROL) {
419
+ const tick = context.warp
420
+ ? context.warp.tick(i)
421
+ : i / context.samplesPerTick;
422
+ const gain = volume * interpolateAutomation(points, tick, 1);
423
+ const end = Math.min(left.length, i + CONTROL);
424
+ for (let s = i; s < end; s += 1) {
425
+ left[s]! *= gain;
426
+ right[s]! *= gain;
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * The live note-off fade for a key sounding at `hz`: a linear fade with the
433
+ * same energy as the damper's exponential decay (0.217 x its T60, at least
434
+ * 10 ms); keys with no damper (F6 up) ring on through the 8 s window.
435
+ */
436
+ export function keysReleaseSeconds(track: Track, hz: number): number {
437
+ if (!(hz > 0) || !Number.isFinite(hz)) return 0.01;
438
+ const values = resolvedKeys(track.instrument ?? "grand", track.keys);
439
+ if (isElectricFamily(track.instrument)) {
440
+ const release = clamp(num(values, "release"), 0.1, 4);
441
+ const t60 =
442
+ track.instrument === "clav"
443
+ ? clavReleaseT60(release)
444
+ : tineReleaseT60(physicalKey(hz), release);
445
+ return Math.max(0.01, (3 / (2 * LN1000)) * t60);
446
+ }
447
+ const p = pianoParamsAt(track, values, 0);
448
+ const key = physicalKey(hz);
449
+ if (undamped(key)) return KEYS_LIMITS.tailSeconds;
450
+ const t60 = damperT60(key, pianoF1(hz, p), p.release);
451
+ return Math.max(0.01, (3 / (2 * LN1000)) * t60);
452
+ }
453
+
454
+ function engineFor(id: string): InstrumentEngine {
455
+ return {
456
+ id,
457
+ field: "keys",
458
+ render(dry, dryR, notes, track, context) {
459
+ const right = dryR ?? new Float64Array(dry.length);
460
+ renderKeysTrack(dry, right, notes, track, context);
461
+ if (!dryR)
462
+ for (let i = 0; i < dry.length; i += 1)
463
+ dry[i] = 0.5 * (dry[i]! + right[i]!);
464
+ },
465
+ tailSeconds: () => KEYS_LIMITS.tailSeconds,
466
+ releaseSeconds: (track, _pitch, hz) => keysReleaseSeconds(track, hz),
467
+ stereo: () => true,
468
+ };
469
+ }
470
+
471
+ /**
472
+ * One engine per keys family (the registry dispatches by instrument id):
473
+ * the pianos, then the electric keys and the organs (0.6.1).
474
+ */
475
+ export const KEYS_ENGINES: readonly InstrumentEngine[] = Object.freeze(
476
+ [
477
+ ...["grand", "upright", "felt", "honkytonk", "prepared"].filter((id) =>
478
+ isPianoFamily(id),
479
+ ),
480
+ ...["epiano", "wurli", "clav"].filter((id) => isElectricFamily(id)),
481
+ ]
482
+ .map(engineFor)
483
+ // f061-organ: tonewheel, combo and pipe (src/audio/keys/organ.ts).
484
+ .concat(ORGAN_ENGINES),
485
+ );