@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
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Electric keys (0.6.1 keys-electric lane): the Rhodes-style tine piano
3
+ * (`epiano`), the Wurlitzer-style reed piano (`wurli`) and the clavinet
4
+ * (`clav`), ported from the reviewed prototype
5
+ * (`.plans/dawg-06/proto/keys/keys.ts`, `TineVoice` and `ClavVoice`) with
6
+ * the review's round-2 fixes: the pickups run at twice the sample rate
7
+ * (band-limited both ways, so a hard-driven tine does not alias), the
8
+ * epiano carries its own suitcase stereo vibrato (`vibe`), the wurli its
9
+ * tremolo (`trem`), and clav pickup positions vary per track (seeded).
10
+ *
11
+ * Tine: a clamped-free cantilever, three modes at 1, 6.267 and 17.547 times
12
+ * the fundamental plus the tone bar, read through an electromagnetic pickup
13
+ * whose flux falls off with the tine's distance from the pole piece
14
+ * (1 / (1 + d^2)), differentiated (Faraday). After Shear and Wright, "The
15
+ * Electromagnetically Sustained Rhodes Piano" (NIME 2011) and Gabrielli et
16
+ * al., "Physical modeling of the Rhodes piano" (DAFx-10). Reed: the
17
+ * electrostatic pickup of the Wurlitzer 200 reads capacitance, 1 / (1 - k x),
18
+ * not a derivative; a peak at 1.1 kHz gives the nasal case. Clav: modal
19
+ * string struck near its end by a tangent, two pickup combs sin(n pi p) at
20
+ * neck and bridge, the mute slider damping upper partials and a small
21
+ * negative re-pluck when the string leaves the anvil (Gabrielli, Välimäki,
22
+ * Penttinen, Squartini and Bilbao, EURASIP JASP 2013:103).
23
+ */
24
+ import { DcBlock, OnePole } from "../dsp/filters.ts";
25
+ import { Oversample2x } from "../dsp/oversample.ts";
26
+ import { unit } from "../dsp/rng.ts";
27
+ import {
28
+ Biquad2,
29
+ Burst,
30
+ clamp,
31
+ hammerPulse,
32
+ lerp,
33
+ LN1000,
34
+ ModeBank,
35
+ panGains,
36
+ pulseMag,
37
+ TAU,
38
+ } from "./dsp.ts";
39
+
40
+ /** Resolved electric voice parameters (core/keys.ts `KEYS_PARAMS`). */
41
+ export type ElectricParams = Readonly<{
42
+ kind: "epiano" | "wurli" | "clav";
43
+ hardness: number;
44
+ touch: number;
45
+ decay: number;
46
+ release: number;
47
+ width: number;
48
+ bark: number;
49
+ bell: number;
50
+ /** Output low-pass in Hz; 0 is off. */
51
+ tone: number;
52
+ pickup: "neck" | "bridge" | "both" | "out";
53
+ mute: number;
54
+ }>;
55
+
56
+ export type ElectricNoteOn = Readonly<{
57
+ pitch: number;
58
+ hz: number;
59
+ velocity: number;
60
+ /** Track seed hash (clav pickup positions are per track). */
61
+ trackSeed: number;
62
+ noteSeed: string;
63
+ }>;
64
+
65
+ /** Clamped-free cantilever mode ratios. */
66
+ const BEAM = [1, 6.267, 17.547] as const;
67
+ /** Rest position of the tine relative to the pickup pole. */
68
+ const X0 = 0.4;
69
+
70
+ /** The voice interface the keys engine drives (shared with `PianoVoice`). */
71
+ export type KeysVoice = {
72
+ readonly gl: number;
73
+ readonly gr: number;
74
+ readonly released: boolean;
75
+ noteOff(): void;
76
+ setCents(cents: number): void;
77
+ damp(tau: number): void;
78
+ process(out: Float64Array, offset: number, count: number): boolean;
79
+ };
80
+
81
+ /** Seconds a released tine or reed takes to fall 60 dB. */
82
+ export function tineReleaseT60(pitch: number, release: number): number {
83
+ return release * (0.09 + 0.25 * clamp((60 - pitch) / 32, 0, 1));
84
+ }
85
+
86
+ /** Seconds a released clav string takes to fall 60 dB (the yarn damper). */
87
+ export function clavReleaseT60(release: number): number {
88
+ return release * 0.06;
89
+ }
90
+
91
+ /** Tine (epiano) and reed (wurli) voice. */
92
+ export class TineVoice implements KeysVoice {
93
+ private readonly bank: ModeBank;
94
+ private readonly pulse: Float64Array;
95
+ private pos = 0;
96
+ private readonly over = new Oversample2x();
97
+ private readonly dc: DcBlock;
98
+ private readonly lp: OnePole | undefined;
99
+ private readonly peak: Biquad2 | undefined;
100
+ private readonly norm: number;
101
+ readonly gl: number;
102
+ readonly gr: number;
103
+ private tmp = new Float64Array(0);
104
+ private readonly bursts: Burst[] = [];
105
+ private readonly rhodes: boolean;
106
+ /** Pickup state at the doubled rate. */
107
+ private prevX = 0;
108
+ private prevPhi = 0;
109
+ private releasedFlag = false;
110
+ private age = 0;
111
+ private readonly shape: (x: number) => number;
112
+
113
+ constructor(
114
+ private readonly note: ElectricNoteOn,
115
+ private readonly p: ElectricParams,
116
+ private readonly sr: number,
117
+ ) {
118
+ const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
119
+ const key = note.pitch;
120
+ const f = note.hz;
121
+ const rhodes = p.kind === "epiano";
122
+ this.rhodes = rhodes;
123
+ const T = rhodes
124
+ ? p.decay * 9 * 2 ** (-(key - 36) / 26)
125
+ : p.decay * 4.5 * 2 ** (-(key - 40) / 26);
126
+ const bell =
127
+ p.bell *
128
+ (rhodes ? 0.04 + 0.22 * v * v : 0.02 + 0.08 * v * v) *
129
+ 2 ** (-(key - 60) / 30);
130
+ const drive = lerp(0.35, rhodes ? 1.6 : 1.5, p.bark);
131
+ const A = (0.15 + 0.85 * v) * drive;
132
+ // Hardness scales the hammer contact (0.5 is the prototype's).
133
+ const contact = lerp(2.2, 0.9, v) * (1.4 - 0.8 * p.hardness);
134
+ this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.5);
135
+ this.bank = new ModeBank(4);
136
+ this.bank.add(f, T, A * 0.85, 1, sr);
137
+ // Tone bar: a slightly sharp, longer partner (the Rhodes tuning fork).
138
+ if (rhodes) this.bank.add(f * 2 ** (0.4 / 1200), T * 1.3, A * 0.15, 1, sr);
139
+ this.bank.add(f * BEAM[1], 0.35 * 2 ** (-(key - 60) / 24), A * bell, 1, sr);
140
+ if (rhodes) this.bank.add(f * BEAM[2], 0.06, A * bell * 0.3 * v, 1, sr);
141
+ // Normalise by the pickup's small-signal slope at the doubled rate. The
142
+ // tine's 0.36 puts a velocity-0.8 C-major triad near -6.5 dBFS, the
143
+ // house level (the reed's 0.32 already sits there).
144
+ const w = (TAU * f) / (2 * sr);
145
+ this.norm =
146
+ (rhodes ? 0.36 : 0.32) /
147
+ ((rhodes ? (2 * X0) / (1 + X0 * X0) ** 2 : 0.5) * w);
148
+ this.prevPhi = this.pickup(0);
149
+ this.dc = new DcBlock(sr, 8);
150
+ this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
151
+ this.peak = rhodes
152
+ ? undefined
153
+ : new Biquad2().set("peak", 1100, 0.9, 4, sr);
154
+ [this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
155
+ this.bursts.push(
156
+ new Burst(
157
+ `${note.noteSeed}:tine`,
158
+ rhodes ? 1800 : 1200,
159
+ 1.2,
160
+ 0.002,
161
+ 0.01 * v * v,
162
+ sr,
163
+ ),
164
+ );
165
+ // The pickup at 2x: linear interpolation of the modal output between
166
+ // base-rate samples is done by the half-band interpolator; this runs on
167
+ // each doubled-rate sample in order, so it may keep state.
168
+ this.shape = (x: number) => {
169
+ this.prevX = x;
170
+ const phi = this.pickup(x);
171
+ const dy = rhodes ? -(phi - this.prevPhi) : phi - this.prevPhi;
172
+ this.prevPhi = phi;
173
+ const y = dy * this.norm;
174
+ return rhodes ? y : Math.tanh(1.3 * y) / 1.3;
175
+ };
176
+ }
177
+
178
+ private pickup(x: number): number {
179
+ if (this.rhodes) {
180
+ const d = x - X0;
181
+ return 1 / (1 + d * d);
182
+ }
183
+ return 1 / (1 - 0.5 * Math.min(x, 1.6));
184
+ }
185
+
186
+ get released(): boolean {
187
+ return this.releasedFlag;
188
+ }
189
+
190
+ noteOff(): void {
191
+ if (this.releasedFlag) return;
192
+ this.releasedFlag = true;
193
+ const t = tineReleaseT60(this.note.pitch, this.p.release);
194
+ this.bank.damp(() => t, this.sr);
195
+ this.bursts.push(
196
+ new Burst(
197
+ `${this.note.noteSeed}:damper`,
198
+ 180,
199
+ 1,
200
+ 0.02,
201
+ 0.006 * this.note.velocity,
202
+ this.sr,
203
+ ),
204
+ );
205
+ }
206
+
207
+ setCents(cents: number): void {
208
+ this.bank.retune(2 ** (cents / 1200));
209
+ }
210
+
211
+ damp(tau: number): void {
212
+ this.bank.damp(() => LN1000 * tau, this.sr);
213
+ }
214
+
215
+ process(out: Float64Array, offset: number, count: number): boolean {
216
+ if (this.tmp.length < count) this.tmp = new Float64Array(count);
217
+ const tmp = this.tmp;
218
+ tmp.fill(0, 0, count);
219
+ let done = 0;
220
+ if (this.pos < this.pulse.length) {
221
+ done = Math.min(count, this.pulse.length - this.pos);
222
+ this.bank.run(tmp, 0, done, this.pulse, this.pos);
223
+ this.pos += done;
224
+ }
225
+ if (done < count) this.bank.run(tmp, done, count - done, null, 0);
226
+ for (let i = 0; i < count; i += 1) {
227
+ let y = this.over.process(tmp[i]!, this.shape);
228
+ y = this.dc.process(y);
229
+ if (this.peak) y = this.peak.process(y);
230
+ if (this.lp) y = this.lp.process(y);
231
+ for (const b of this.bursts) if (!b.done) y += b.next();
232
+ out[offset + i]! += y;
233
+ }
234
+ this.age += count;
235
+ if (this.age % 256 < count) this.bank.cull(1e-6);
236
+ // The oversampler and DC blocker hold a short tail after the modes go.
237
+ return (
238
+ this.bank.n > 0 ||
239
+ this.bursts.some((b) => !b.done) ||
240
+ Math.abs(this.prevX) > 1e-9
241
+ );
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Clav pickup positions (fractions of the string from the bridge end), with
247
+ * a seeded per-track spread of +/-6%: two clavinets never sound identical.
248
+ */
249
+ export function clavPositions(trackSeed: number): {
250
+ neck: number;
251
+ bridge: number;
252
+ tangent: number;
253
+ } {
254
+ const spread = (slot: number) => 1 + 0.12 * (unit(trackSeed, 0, slot) - 0.5);
255
+ return {
256
+ neck: 0.19 * spread(0),
257
+ bridge: 0.065 * spread(1),
258
+ tangent: 0.04 * spread(2),
259
+ };
260
+ }
261
+
262
+ export class ClavVoice implements KeysVoice {
263
+ private readonly bank: ModeBank;
264
+ private pulse: Float64Array;
265
+ private pos = 0;
266
+ private readonly lp: OnePole | undefined;
267
+ private readonly hp: Biquad2;
268
+ readonly gl: number;
269
+ readonly gr: number;
270
+ private tmp = new Float64Array(0);
271
+ private releasedFlag = false;
272
+ private age = 0;
273
+
274
+ constructor(
275
+ note: ElectricNoteOn,
276
+ private readonly p: ElectricParams,
277
+ private readonly sr: number,
278
+ ) {
279
+ const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
280
+ const key = note.pitch;
281
+ const B = 1.5e-4 * 2 ** ((key - 48) / 16);
282
+ // Partial 1 sits exactly at the tuned pitch.
283
+ const f0 = note.hz / Math.sqrt(1 + B);
284
+ const T = p.decay * 5 * 2 ** (-(key - 40) / 24);
285
+ const { neck, bridge, tangent } = clavPositions(note.trackSeed);
286
+ const contact = lerp(0.6, 0.25, v) * (1.4 - 0.8 * p.hardness);
287
+ this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.2);
288
+ const bank = new ModeBank(80);
289
+ const modes: [number, number, number][] = [];
290
+ let energy = 0;
291
+ for (let n = 1; n <= 80; n += 1) {
292
+ const fn = n * f0 * Math.sqrt(1 + B * n * n);
293
+ if (fn >= 0.45 * sr) break;
294
+ const a = Math.sin(n * Math.PI * neck);
295
+ const b = Math.sin(n * Math.PI * bridge);
296
+ const w =
297
+ p.pickup === "neck"
298
+ ? a
299
+ : p.pickup === "bridge"
300
+ ? b * 2
301
+ : p.pickup === "out"
302
+ ? a - b
303
+ : (a + b) * 0.7;
304
+ const u = Math.sin(n * Math.PI * tangent) * w;
305
+ const t60 =
306
+ (T / (1 + (fn / 3000) ** 2)) *
307
+ (1 - 0.85 * p.mute * Math.min(1, (n - 1) / 4));
308
+ modes.push([fn, t60, u]);
309
+ // Level is set on what the pickup hears through the tangent's
310
+ // contact spectrum, so every pickup switch sits within about 1.5 dB
311
+ // of the others (a C-major triad at velocity 0.8 near -6 dBFS).
312
+ energy += (u * pulseMag(this.pulse, (2 * Math.PI * fn) / sr)) ** 2;
313
+ }
314
+ // Out of phase leaves a peakier, thinner sum: +1.6 dB brings its peak
315
+ // level to the other pickups' (measured at C3, velocity 0.8).
316
+ const trim = p.pickup === "out" ? 1.2 : 1;
317
+ const G = (0.146 * trim * v ** 1.2) / Math.sqrt(Math.max(1e-12, energy));
318
+ for (const [fn, t60, u] of modes)
319
+ bank.add(fn, Math.max(0.02, t60), u * G, 1, sr);
320
+ this.bank = bank;
321
+ this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
322
+ this.hp = new Biquad2().set("hpf", 70, 0.7, 0, sr);
323
+ [this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
324
+ }
325
+
326
+ get released(): boolean {
327
+ return this.releasedFlag;
328
+ }
329
+
330
+ noteOff(): void {
331
+ if (this.releasedFlag) return;
332
+ this.releasedFlag = true;
333
+ // The string leaves the anvil: a small negative pluck, then the yarn damps it.
334
+ const plunk = hammerPulse(Math.max(2, Math.round(0.3e-3 * this.sr)), 1);
335
+ for (let i = 0; i < plunk.length; i += 1) plunk[i]! *= -0.35;
336
+ this.pulse = plunk;
337
+ this.pos = 0;
338
+ const t = clavReleaseT60(this.p.release);
339
+ this.bank.damp(() => t, this.sr);
340
+ }
341
+
342
+ setCents(cents: number): void {
343
+ this.bank.retune(2 ** (cents / 1200));
344
+ }
345
+
346
+ damp(tau: number): void {
347
+ this.bank.damp(() => LN1000 * tau, this.sr);
348
+ }
349
+
350
+ process(out: Float64Array, offset: number, count: number): boolean {
351
+ if (this.tmp.length < count) this.tmp = new Float64Array(count);
352
+ const tmp = this.tmp;
353
+ tmp.fill(0, 0, count);
354
+ let done = 0;
355
+ if (this.pos < this.pulse.length) {
356
+ done = Math.min(count, this.pulse.length - this.pos);
357
+ this.bank.run(tmp, 0, done, this.pulse, this.pos);
358
+ this.pos += done;
359
+ }
360
+ if (done < count) this.bank.run(tmp, done, count - done, null, 0);
361
+ for (let i = 0; i < count; i += 1) {
362
+ let y = this.hp.process(tmp[i]!);
363
+ if (this.lp) y = this.lp.process(y);
364
+ out[offset + i]! += y;
365
+ }
366
+ this.age += count;
367
+ if (this.age % 256 < count) this.bank.cull(1e-6);
368
+ return this.bank.n > 0;
369
+ }
370
+ }
371
+
372
+ /** A voice for an electric family. */
373
+ export function electricVoice(
374
+ note: ElectricNoteOn,
375
+ p: ElectricParams,
376
+ sr: number,
377
+ ): KeysVoice {
378
+ return p.kind === "clav"
379
+ ? new ClavVoice(note, p, sr)
380
+ : new TineVoice(note, p, sr);
381
+ }
382
+
383
+ /**
384
+ * The electric post stage, run on the summed track: the epiano's suitcase
385
+ * stereo vibrato (antiphase left/right gain, `vibe` depth at `vibehz`) and
386
+ * the wurli's tremolo (`trem` depth at 5.6 Hz, both channels). The phase is
387
+ * song time (`startSample` of the buffer at the song's sample 0), so a loop
388
+ * and a full render agree. `depthAt(sample)` reads the lane.
389
+ */
390
+ export function electricPost(
391
+ left: Float64Array,
392
+ right: Float64Array,
393
+ kind: ElectricParams["kind"],
394
+ rateHz: number,
395
+ depthAt: (sample: number) => number,
396
+ sr: number,
397
+ startSample = 0,
398
+ ): void {
399
+ if (kind === "clav") return;
400
+ const block = 32;
401
+ for (let i = 0; i < left.length; i += block) {
402
+ const depth = clamp(depthAt(i), 0, 1);
403
+ const end = Math.min(left.length, i + block);
404
+ if (depth <= 0) continue;
405
+ for (let s = i; s < end; s += 1) {
406
+ const lfo = Math.sin((TAU * rateHz * (s + startSample)) / sr);
407
+ if (kind === "epiano") {
408
+ // Constant-power autopan between the two speakers of the suitcase.
409
+ const pan = depth * lfo;
410
+ const a = ((pan + 1) * Math.PI) / 4;
411
+ left[s]! *= Math.SQRT2 * Math.cos(a);
412
+ right[s]! *= Math.SQRT2 * Math.sin(a);
413
+ } else {
414
+ const gain = 1 - 0.5 * depth * (1 - lfo);
415
+ left[s]! *= gain;
416
+ right[s]! *= gain;
417
+ }
418
+ }
419
+ }
420
+ }
@@ -16,14 +16,20 @@
16
16
  * - Post: the family's body EQ per channel (the minimal per-track post
17
17
  * hook), then the track volume, before the existing effects chain.
18
18
  */
19
- import type { PerformedNote } from "../../../core/expression.ts";
20
- import { isPianoFamily, resolvedKeys } from "../../../core/keys.ts";
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";
21
26
  import type { Track } from "../../../core/score.ts";
22
27
  import { noteHz } from "../../../core/tuning.ts";
23
28
  import type { FxLane } from "../../../core/fx.ts";
24
29
  import { interpolateAutomation } from "../effects/common.ts";
25
30
  import type { EngineContext, InstrumentEngine } from "../instruments.ts";
26
31
  import { Biquad2, clamp, CONTROL, LN1000 } from "./dsp.ts";
32
+ import { ORGAN_ENGINES } from "./organ.ts";
27
33
  import {
28
34
  damperT60,
29
35
  physicalKey,
@@ -34,6 +40,15 @@ import {
34
40
  trackSeedOf,
35
41
  type PianoParams,
36
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";
37
52
 
38
53
  export const KEYS_LIMITS = Object.freeze({
39
54
  /** Per-track polyphony cap (voice stealing beyond it). */
@@ -98,6 +113,39 @@ export function pianoParamsAt(
98
113
  };
99
114
  }
100
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
+
101
149
  /** Body voicing name for a track (the stored `body`, else the family's). */
102
150
  export function bodyOf(values: Values): string {
103
151
  const body = values.body;
@@ -105,7 +153,7 @@ export function bodyOf(values: Values): string {
105
153
  }
106
154
 
107
155
  type Live = {
108
- voice: PianoVoice;
156
+ voice: KeysVoice;
109
157
  /** Sample index the voice started at. */
110
158
  start: number;
111
159
  /** Sample index of key-up. */
@@ -161,6 +209,9 @@ export function renderKeysTrack(
161
209
  const sr = context.sampleRate;
162
210
  const total = left.length;
163
211
  const values = resolvedKeys(track.instrument ?? "grand", track.keys);
212
+ const electric = isElectricFamily(track.instrument)
213
+ ? track.instrument
214
+ : undefined;
164
215
  const trackSeed = trackSeedOf(track.id);
165
216
  const seedTick = context.seedTick ?? 0;
166
217
  const window = Math.round(KEYS_LIMITS.tailSeconds * sr);
@@ -204,18 +255,30 @@ export function renderKeysTrack(
204
255
  victim.stolen = true;
205
256
  }
206
257
  const note = onset.note;
207
- const params = pianoParamsAt(track, values, note.startTick);
208
- const voice = new PianoVoice(
209
- {
210
- pitch: note.pitch,
211
- hz: onset.hz,
212
- velocity: clamp(note.velocity, 0, 1),
213
- trackSeed,
214
- noteSeed: `${track.id}:${note.id}:${note.startTick + seedTick}`,
215
- },
216
- params,
217
- sr,
218
- );
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
+ );
219
282
  const performance = note.performance;
220
283
  // The `vib`/`vibmod` wow (lofi) unless the note brings its own vibrato.
221
284
  const vib = num(values, "vib");
@@ -277,6 +340,50 @@ export function renderKeysTrack(
277
340
  if (!alive) live.splice(k, 1);
278
341
  }
279
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
+ }
280
387
  // The per-track post hook: body EQ, then the track volume.
281
388
  const body = bodyOf(values);
282
389
  const eqL = pianoBody(body, sr);
@@ -329,6 +436,14 @@ function applyVolume(
329
436
  export function keysReleaseSeconds(track: Track, hz: number): number {
330
437
  if (!(hz > 0) || !Number.isFinite(hz)) return 0.01;
331
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
+ }
332
447
  const p = pianoParamsAt(track, values, 0);
333
448
  const key = physicalKey(hz);
334
449
  if (undamped(key)) return KEYS_LIMITS.tailSeconds;
@@ -353,9 +468,18 @@ function engineFor(id: string): InstrumentEngine {
353
468
  };
354
469
  }
355
470
 
356
- /** One engine per piano family (the registry dispatches by instrument id). */
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
+ */
357
475
  export const KEYS_ENGINES: readonly InstrumentEngine[] = Object.freeze(
358
- ["grand", "upright", "felt", "honkytonk", "prepared"]
359
- .filter((id) => isPianoFamily(id))
360
- .map(engineFor),
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),
361
485
  );