@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,476 @@
1
+ /**
2
+ * The modelled acoustic piano (0.6 keys lane, families grand, upright, felt,
3
+ * honkytonk, prepared), ported from the reviewed prototype
4
+ * (`.plans/dawg-06/proto/keys/keys.ts` round 2, `PianoVoice`): modal strings
5
+ * after Bank (f_n = n f0 sqrt(1 + B n^2), partial 1 exactly at the tuned
6
+ * pitch), a spectral felt-hammer pulse (Chaigne and Askenfelt 1994), 1-3
7
+ * unison strings with prompt sound and aftersound (Weinreich 1977), dampers
8
+ * that let F6 and up ring, knock and key-off mechanics, and seeded per-key
9
+ * preparations. The numbers are the prototype's; only the octave stretch is
10
+ * new (the review's fix): derived from each key's own B rather than a fixed
11
+ * Railsback curve.
12
+ */
13
+ import { seedHash, unit } from "../dsp/rng.ts";
14
+ import { seededRandom } from "../dsp/rng.ts";
15
+ import {
16
+ Biquad2,
17
+ Burst,
18
+ clamp,
19
+ hammerPulse,
20
+ lerp,
21
+ LN1000,
22
+ MODE_CEILING,
23
+ ModeBank,
24
+ panGains,
25
+ pulseMag,
26
+ TAU,
27
+ } from "./dsp.ts";
28
+
29
+ /** Resolved voice parameters (core/keys.ts `KEYS_PARAMS`). */
30
+ export type PianoParams = Readonly<{
31
+ hardness: number;
32
+ touch: number;
33
+ inharm: number;
34
+ unison: number;
35
+ decay: number;
36
+ release: number;
37
+ strike: number;
38
+ after: number;
39
+ knock: number;
40
+ noise: number;
41
+ felt: number;
42
+ prep: number;
43
+ width: number;
44
+ stretch: number;
45
+ }>;
46
+
47
+ export const PIANO_LOW = 21;
48
+ export const PIANO_HIGH = 108;
49
+ /** F6 and up have no dampers and ring on after key-up, as on a grand. */
50
+ export const NO_DAMPER_FROM = 89;
51
+
52
+ /** Inharmonicity coefficient B for a key (grand at mult 1): 1e-4 at A1, 0.02 at C8. */
53
+ export function pianoB(pitch: number, mult: number): number {
54
+ const p = clamp(pitch, PIANO_LOW, PIANO_HIGH);
55
+ const lg = p >= 33 ? -4 + ((p - 33) / 75) * 2.3 : -4 + ((33 - p) / 12) * 0.45;
56
+ return mult * 10 ** lg;
57
+ }
58
+
59
+ /**
60
+ * The physical key a frequency sounds at (12-TET place, fractional, on the
61
+ * 88-key range). Everything physical (B, strings, decay, dampers) is read
62
+ * here, so a 19-EDO degree near 750 Hz is voiced as the F#5 it sounds as,
63
+ * not as the MIDI number it is stored under. Exact in 12-TET.
64
+ */
65
+ export function physicalKey(hz: number): number {
66
+ const key = 69 + 12 * Math.log2(hz / 440);
67
+ const near = Math.round(key);
68
+ return clamp(Math.abs(key - near) < 1e-6 ? near : key, PIANO_LOW, PIANO_HIGH);
69
+ }
70
+
71
+ /** True when a key has no damper (F6 and up). */
72
+ export const undamped = (key: number): boolean =>
73
+ Math.round(key) >= NO_DAMPER_FROM;
74
+
75
+ /** Damper T60 of the fundamental at `f1` Hz when the key falls (seconds). */
76
+ export function damperT60(key: number, f1: number, release: number): number {
77
+ const base = release * (0.12 + 0.55 * clamp((64 - key) / 43, 0, 1));
78
+ return base / (1 + f1 / 2000);
79
+ }
80
+
81
+ export const stringsFor = (pitch: number): number =>
82
+ pitch < 31 ? 1 : pitch < 42 ? 2 : 3;
83
+
84
+ /** Aftersound T60 of the fundamental in seconds (A0 28 s, A4 6.2 s, C8 1.8 s). */
85
+ export const pianoT60 = (pitch: number): number =>
86
+ 28 * 2 ** (-(clamp(pitch, PIANO_LOW, PIANO_HIGH) - 21) / 22);
87
+
88
+ /** Partial `n` of a string whose partial 1 sounds at `f1`. */
89
+ export function pianoPartial(f1: number, b: number, n: number): number {
90
+ return ((n * f1) / Math.sqrt(1 + b)) * Math.sqrt(1 + b * n * n);
91
+ }
92
+
93
+ /**
94
+ * Cents an octave from `low` to `low + 12` is widened so the upper key's
95
+ * partial 1 meets the lower key's partials: pure 2:1 in the treble (from the
96
+ * C5 octave up), blending in half of the 4:2 match below C4, as an aural
97
+ * tuner does.
98
+ */
99
+ function octaveWidening(low: number, inharm: number): number {
100
+ const bl = pianoB(low, inharm);
101
+ const bu = pianoB(low + 12, inharm);
102
+ const c21 = 600 * Math.log2((1 + 4 * bl) / (1 + bl));
103
+ const c42 =
104
+ 600 * Math.log2(((1 + 16 * bl) * (1 + bu)) / ((1 + bl) * (1 + 4 * bu)));
105
+ const w42 = 0.5 * clamp((72 - (low + 12)) / 12, 0, 1);
106
+ return (1 - w42) * c21 + w42 * c42;
107
+ }
108
+
109
+ const stretchTables = new Map<number, Float64Array>();
110
+
111
+ /**
112
+ * Stretch in cents for each key 0..127 at an inharmonicity multiplier: the
113
+ * temperament octave D#4..D5 stays put (A4 exact), every other key is tuned
114
+ * by octaves from its own strings' B.
115
+ */
116
+ export function stretchCents(pitch: number, inharm: number): number {
117
+ let table = stretchTables.get(inharm);
118
+ if (!table) {
119
+ table = new Float64Array(128);
120
+ for (let p = 75; p < 128; p += 1)
121
+ table[p] = table[p - 12]! + octaveWidening(p - 12, inharm);
122
+ for (let p = 62; p >= 0; p -= 1)
123
+ table[p] = table[p + 12]! - octaveWidening(p, inharm);
124
+ if (stretchTables.size > 32) stretchTables.clear();
125
+ stretchTables.set(inharm, table);
126
+ }
127
+ return table[clamp(Math.round(pitch), 0, 127)]!;
128
+ }
129
+
130
+ /**
131
+ * The pitch of partial 1 for a note at a resolved `hz` with stretch. The
132
+ * stretch is read at the note's frequency (its 12-TET key position, linearly
133
+ * interpolated), so it follows octaves in any tuning table: a 19-EDO key an
134
+ * octave up gets the same widening as a 12-TET one.
135
+ */
136
+ export function pianoF1(hz: number, p: PianoParams): number {
137
+ if (!(p.stretch > 0)) return hz;
138
+ const key = clamp(69 + 12 * Math.log2(hz / 440), 0, 127);
139
+ const low = Math.floor(key);
140
+ const high = Math.min(127, low + 1);
141
+ const cents = lerp(
142
+ stretchCents(low, p.inharm),
143
+ stretchCents(high, p.inharm),
144
+ key - low,
145
+ );
146
+ return hz * 2 ** ((p.stretch * cents) / 1200);
147
+ }
148
+
149
+ export type PianoNoteOn = Readonly<{
150
+ pitch: number;
151
+ /** Resolved frequency: noteHz(pitch, cents, table); > 0. */
152
+ hz: number;
153
+ /** 0..1 after the 0.5 velocity curve. */
154
+ velocity: number;
155
+ /** Track seed hash (preparations are per track and key). */
156
+ trackSeed: number;
157
+ /** Track seed plus note id (this strike's own randomness). */
158
+ noteSeed: string;
159
+ /**
160
+ * Una corda (0.6.1) at the onset, 0..1 (half pedal 0.5); absent is 0.
161
+ * The hammer shifts: fewer strings struck, a softer felt spot, more of
162
+ * the unstruck string's aftersound.
163
+ */
164
+ soft?: number;
165
+ }>;
166
+
167
+ type Rattle = {
168
+ hz: number;
169
+ gain: number;
170
+ k: number;
171
+ phase: number;
172
+ random: () => number;
173
+ };
174
+
175
+ export class PianoVoice {
176
+ private readonly bank: ModeBank;
177
+ private readonly pulse: Float64Array;
178
+ private pos = 0;
179
+ private readonly bursts: Burst[] = [];
180
+ readonly gl: number;
181
+ readonly gr: number;
182
+ private tmp = new Float64Array(0);
183
+ private readonly key: number;
184
+ private readonly rattle: Rattle | undefined;
185
+ private age = 0;
186
+ private releasedFlag = false;
187
+
188
+ constructor(
189
+ private readonly note: PianoNoteOn,
190
+ private readonly p: PianoParams,
191
+ private readonly sr: number,
192
+ ) {
193
+ const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
194
+ // Physical place from the sounding frequency; the MIDI key only seeds
195
+ // the preparation slot.
196
+ const key = physicalKey(note.hz);
197
+ this.key = key;
198
+ const prepKey = clamp(note.pitch, PIANO_LOW, PIANO_HIGH);
199
+ const keyPos = (key - 21) / 87;
200
+ const random = seededRandom(`${note.noteSeed}:piano`);
201
+ // Preparation: drawn per track and key, so a prepared key always
202
+ // sounds the same in that track and differs between tracks.
203
+ let slot = 0;
204
+ const prep = () => unit(note.trackSeed, prepKey, slot++);
205
+ const prepared = p.prep > 0 && prep() < p.prep;
206
+ // 0 bolt, 1 rubber, 2 screw
207
+ const prepKind = prepared ? Math.floor(prep() * 3) : -1;
208
+ const B = pianoB(key, p.inharm);
209
+ const f1 = pianoF1(note.hz, p);
210
+ const f0 = f1 / Math.sqrt(1 + B);
211
+ const T1 =
212
+ pianoT60(key) *
213
+ p.decay *
214
+ (1 - 0.15 * p.felt) *
215
+ (prepKind === 1 ? 0.12 : prepKind >= 0 ? 0.5 : 1);
216
+ const soft = clamp(note.soft ?? 0, 0, 1);
217
+ let after = clamp(p.after, 0, 0.95);
218
+ const thMs =
219
+ lerp(3.5, 0.8, keyPos) *
220
+ lerp(1.6, 0.7, v) *
221
+ (1 + 1.5 * p.felt) *
222
+ (1.3 - 0.6 * p.hardness);
223
+ // Una corda (keys.md 2.1): the shifted hammer meets the strings with
224
+ // its softer felt. Modelled spectrally, so the pulse length stays and
225
+ // its nulls never land on a low partial: mode input gains get a smooth
226
+ // two-pole low-pass relative to partial 1 (cutoff at a tenth of the
227
+ // spec's hammer cutoff, lowered 25% at full pedal), floored at -12 dB
228
+ // so no low harmonic is notched out; more of the slow aftersound is
229
+ // excited. Measured at keys 48-84, velocity 0.5-0.9: centroid -12 to
230
+ // -20% through key 72 (-5% at 84), peak -2.3 to -4.8 dB, no partial of
231
+ // the first eight more than 9 dB down (src/audio/keys/pedals.test.ts).
232
+ const softFc =
233
+ 35 *
234
+ 2 ** (5 * v * (0.4 + 0.6 * p.hardness) + 1.5 * keyPos) *
235
+ 0.75 ** soft;
236
+ const softFloor = 1 - 0.75 * soft;
237
+ const softW = (hz: number) =>
238
+ soft > 0
239
+ ? Math.max(
240
+ softFloor,
241
+ ((1 + (f1 / softFc) ** 2) / (1 + (hz / softFc) ** 2)) ** soft,
242
+ )
243
+ : 1;
244
+ if (soft > 0) after = clamp(after + (1 - after) * 0.4 * soft, 0, 0.95);
245
+ this.pulse = hammerPulse(
246
+ Math.max(2, Math.round(thMs * 1e-3 * sr)),
247
+ 1 + 2 * v * p.hardness,
248
+ );
249
+ const strings = stringsFor(key);
250
+ // Una corda strikes one string fewer: the unison spread narrows.
251
+ const spread =
252
+ strings === 1
253
+ ? 0.08
254
+ : soft > 0
255
+ ? p.unison * (1 - (soft * 1) / strings)
256
+ : p.unison;
257
+ const maxHz = MODE_CEILING * sr;
258
+ let partials = 0;
259
+ while (partials < 160) {
260
+ const n = partials + 1;
261
+ if (n * f0 * Math.sqrt(1 + B * n * n) >= maxHz) break;
262
+ partials = n;
263
+ }
264
+ const extra = prepKind === 0 || prepKind === 2 ? 4 : 0;
265
+ const bank = new ModeBank(
266
+ Math.min(12, partials) * 3 + Math.max(0, partials - 12) + extra,
267
+ );
268
+ const xb = 0.1 + 0.3 * prep();
269
+ const shift = prepKind === 0 || prepKind === 2 ? 0.04 + 0.1 * prep() : 0;
270
+ const candidates: { hz: number; t60: number; u: number }[] = [];
271
+ let energy = 0;
272
+ let sum0 = 0;
273
+ let sumW = 0;
274
+ for (let n = 1; n <= partials; n += 1) {
275
+ let fn = n * f0 * Math.sqrt(1 + B * n * n);
276
+ if (shift && n > 1) fn *= 1 - shift * Math.sin(n * Math.PI * xb) ** 2;
277
+ const shape = Math.sin(n * Math.PI * p.strike);
278
+ if (Math.abs(shape) < 1e-3) continue;
279
+ const tn = 1 / (1 / T1 + (fn / 3000) ** 2 / (1.2 * p.decay));
280
+ const damped = prepKind === 1 && n > 3 ? 0.3 : 1;
281
+ const u0 = shape * damped;
282
+ energy += u0 * u0;
283
+ const u = u0 * softW(fn);
284
+ sum0 += Math.abs(u0);
285
+ sumW += Math.abs(u);
286
+ if (n <= 12) {
287
+ // Prompt sound at the centre, aftersound split into a symmetric pair.
288
+ const d = (spread / 2) * (1 + 0.3 * (random() - 0.5));
289
+ candidates.push({ hz: fn, t60: tn * 0.3, u: u * (1 - after) });
290
+ candidates.push({
291
+ hz: fn * 2 ** (-d / 1200),
292
+ t60: tn,
293
+ u: (u * after) / 2,
294
+ });
295
+ candidates.push({
296
+ hz: fn * 2 ** (d / 1200),
297
+ t60: tn,
298
+ u: (u * after) / 2,
299
+ });
300
+ } else candidates.push({ hz: fn, t60: tn * 0.6, u });
301
+ }
302
+ if (extra)
303
+ for (const r of [2.76, 5.4, 8.93, 13.34])
304
+ candidates.push({
305
+ hz: f1 * r * (1 + 0.05 * (prep() - 0.5)),
306
+ t60: 0.6 + 1.2 * prep(),
307
+ u: 0.35,
308
+ });
309
+ let loud = v ** 1.4 * (1 - 0.4 * p.felt);
310
+ // Una corda's level is 30% down: half the low-pass loss (in summed mode
311
+ // amplitude) is made up so the extra loss stays about 1 dB.
312
+ if (soft > 0)
313
+ loud *= (1 - 0.3 * soft) * Math.sqrt(sum0 / Math.max(sumW, 1e-9));
314
+ const G = (0.35 * loud) / Math.sqrt(Math.max(energy, 1e-9));
315
+ const mags = candidates.map(
316
+ (c) => pulseMag(this.pulse, (TAU * c.hz) / sr) * Math.abs(c.u),
317
+ );
318
+ const peak = Math.max(0, ...mags);
319
+ candidates.forEach((c, i) => {
320
+ // Below -80 dB after the hammer filter: not worth a mode.
321
+ if (mags[i]! >= peak * 1e-4) bank.add(c.hz, c.t60, c.u * G, 1, sr);
322
+ });
323
+ this.bank = bank;
324
+ const pan = p.width * clamp((key - 64) / 44, -1, 1) * 0.8;
325
+ [this.gl, this.gr] = panGains(pan);
326
+ const s = note.noteSeed;
327
+ if (p.knock > 0) {
328
+ this.bursts.push(
329
+ new Burst(
330
+ `${s}:knock`,
331
+ lerp(90, 250, keyPos),
332
+ 1.0,
333
+ 0.02 + 0.03 * (1 - keyPos),
334
+ p.knock * 0.05 * v ** 1.5 * (prepKind === 1 ? 2 : 1),
335
+ sr,
336
+ ),
337
+ new Burst(
338
+ `${s}:click`,
339
+ 2800,
340
+ 1.5,
341
+ 0.002,
342
+ 0.02 * v * v * p.hardness * (1 - p.felt) * (1 - 0.3 * soft),
343
+ sr,
344
+ ),
345
+ );
346
+ }
347
+ if (p.felt > 0 && p.noise > 0)
348
+ this.bursts.push(
349
+ new Burst(`${s}:thud`, 300, 0.9, 0.03, 0.04 * p.felt * p.noise * v, sr),
350
+ );
351
+ this.rattle =
352
+ prepKind === 2
353
+ ? {
354
+ hz: f1,
355
+ gain: 0.05 * v,
356
+ k: Math.exp(-1 / (0.35 * sr)),
357
+ phase: 0,
358
+ random: seededRandom(`${s}:rattle`),
359
+ }
360
+ : undefined;
361
+ }
362
+
363
+ get released(): boolean {
364
+ return this.releasedFlag;
365
+ }
366
+
367
+ /** Key up: the damper falls (none from F6 up), with key-off noise. */
368
+ noteOff(): void {
369
+ if (this.releasedFlag) return;
370
+ this.releasedFlag = true;
371
+ if (undamped(this.key)) return;
372
+ const base =
373
+ this.p.release * (0.12 + 0.55 * clamp((64 - this.key) / 43, 0, 1));
374
+ this.bank.damp((hz) => base / (1 + hz / 2000), this.sr);
375
+ if (this.p.noise > 0)
376
+ this.bursts.push(
377
+ new Burst(
378
+ `${this.note.noteSeed}:keyoff`,
379
+ 500,
380
+ 1.2,
381
+ 0.015,
382
+ 0.012 * this.p.noise,
383
+ this.sr,
384
+ ),
385
+ );
386
+ }
387
+
388
+ /** Pitch offset in cents (bend, vibrato); modes above the ceiling mute. */
389
+ setCents(cents: number): void {
390
+ this.bank.retune(2 ** (cents / 1200));
391
+ }
392
+
393
+ /** Half pedal (0.5 `damp`): every mode decays with time constant `tau`. */
394
+ damp(tau: number): void {
395
+ this.bank.damp(() => LN1000 * tau, this.sr);
396
+ }
397
+
398
+ /** Adds `count` mono samples at `offset`; false once the voice is silent. */
399
+ process(out: Float64Array, offset: number, count: number): boolean {
400
+ if (this.tmp.length < count) this.tmp = new Float64Array(count);
401
+ const tmp = this.tmp;
402
+ tmp.fill(0, 0, count);
403
+ let done = 0;
404
+ if (this.pos < this.pulse.length) {
405
+ done = Math.min(count, this.pulse.length - this.pos);
406
+ this.bank.run(tmp, 0, done, this.pulse, this.pos);
407
+ this.pos += done;
408
+ }
409
+ if (done < count) this.bank.run(tmp, done, count - done, null, 0);
410
+ for (const b of this.bursts)
411
+ if (!b.done) for (let i = 0; i < count; i += 1) tmp[i]! += b.next();
412
+ const r = this.rattle;
413
+ if (r)
414
+ for (let i = 0; i < count; i += 1) {
415
+ r.phase += r.hz / this.sr;
416
+ const gate = Math.abs(Math.sin(Math.PI * r.phase)) ** 16;
417
+ tmp[i]! += (r.random() * 2 - 1) * gate * r.gain;
418
+ r.gain *= r.k;
419
+ }
420
+ for (let i = 0; i < count; i += 1) out[offset + i]! += tmp[i]!;
421
+ this.age += count;
422
+ if (this.age % 256 < count) this.bank.cull(2e-6);
423
+ return (
424
+ this.bank.n > 0 ||
425
+ this.bursts.some((b) => !b.done) ||
426
+ (r !== undefined && r.gain > 1e-6)
427
+ );
428
+ }
429
+ }
430
+
431
+ export type PianoBodyKind =
432
+ "grand" | "upright" | "felt" | "honkytonk" | "prepared";
433
+
434
+ /** Piano body: a fixed EQ per body type (soundboard and case colour). */
435
+ export function pianoBody(kind: string, sr: number): Biquad2[] {
436
+ const q = (
437
+ t: Parameters<Biquad2["set"]>[0],
438
+ hz: number,
439
+ qq: number,
440
+ db: number,
441
+ ) => new Biquad2().set(t, hz, qq, db, sr);
442
+ switch (kind) {
443
+ case "upright":
444
+ return [
445
+ q("lowshelf", 120, 0.7, -4),
446
+ q("peak", 380, 1.2, 3),
447
+ q("peak", 1500, 1, 2),
448
+ q("highshelf", 5000, 0.7, -3),
449
+ ];
450
+ case "honkytonk":
451
+ return [
452
+ q("lowshelf", 120, 0.7, -4),
453
+ q("peak", 1200, 1, 4),
454
+ q("highshelf", 5000, 0.7, 1),
455
+ ];
456
+ case "felt":
457
+ return [
458
+ q("lowshelf", 150, 0.7, 2),
459
+ q("peak", 400, 0.9, 1.5),
460
+ q("highshelf", 2500, 0.7, -7),
461
+ ];
462
+ case "prepared":
463
+ return [q("lowshelf", 120, 0.7, 1), q("peak", 250, 0.8, 2)];
464
+ default:
465
+ return [
466
+ q("lowshelf", 120, 0.7, 1.5),
467
+ q("peak", 250, 0.8, 2),
468
+ q("peak", 2500, 1, -2),
469
+ q("highshelf", 6000, 0.7, -2),
470
+ ];
471
+ }
472
+ }
473
+
474
+ /** Track seed hash for preparations. */
475
+ export const trackSeedOf = (trackId: string): number =>
476
+ seedHash(`keys:${trackId}`);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Sympathetic resonance for the modelled pianos (0.6.1, keys-electric
3
+ * lane): a per-track bank of 36 tuned feedback combs (C2..B4), fed by the
4
+ * dry sum, ringing with T60 4 s while the sustain pedal is down (all
5
+ * dampers up) and 0.06 s when it is up. Ported from the design prototype
6
+ * (proto/keys/keys.ts `Sympathetic`).
7
+ *
8
+ * Gating (zero cost otherwise): it runs only on a piano track with `sym`
9
+ * above 0 and at least one sustain pedal `down` or `half` event; between
10
+ * pedal-down spans it stops processing once its energy is below -90 dB.
11
+ * At 44.1 kHz and above it runs at half rate (pairs averaged in, output
12
+ * linearly interpolated), which keeps 48 kHz near the 22.05 kHz cost; the
13
+ * combs top out at B4 (494 Hz), far below the halved Nyquist.
14
+ */
15
+
16
+ import { hermite4 } from "../dsp/interp.ts";
17
+
18
+ const TAU = 2 * Math.PI;
19
+ const LOW = 36;
20
+ const HIGH = 71;
21
+ /** Energy below which the bank goes quiet while the pedal is up. */
22
+ const QUIET = 1e-9;
23
+
24
+ export class Sympathetic {
25
+ private readonly lines: Float64Array[] = [];
26
+ private readonly delay: number[] = [];
27
+ private readonly lp: number[] = [];
28
+ private readonly ws: number[] = [];
29
+ private readonly gUp: number[] = [];
30
+ private readonly gDown: number[] = [];
31
+ private readonly a: number;
32
+ /** Internal decimation: 1 at 22.05 kHz, 2 at 44.1/48 kHz. */
33
+ readonly factor: number;
34
+
35
+ constructor(
36
+ hzOf: (pitch: number) => number,
37
+ readonly amount: number,
38
+ sr: number,
39
+ ) {
40
+ this.factor = sr >= 40_000 ? 2 : 1;
41
+ const rate = sr / this.factor;
42
+ this.a = Math.exp((-TAU * 4000) / rate);
43
+ const lpDelay = this.a / (1 - this.a);
44
+ for (let q = LOW; q <= HIGH; q += 1) {
45
+ const hz = hzOf(q);
46
+ // An unmapped tuning degree has no string to ring.
47
+ if (!(hz > 0) || !Number.isFinite(hz)) continue;
48
+ const d = Math.max(2, rate / hz - lpDelay);
49
+ this.lines.push(new Float64Array(Math.ceil(d) + 8));
50
+ this.delay.push(d);
51
+ this.lp.push(0);
52
+ this.ws.push(0);
53
+ this.gDown.push(10 ** ((-3 * d) / (rate * 4)));
54
+ this.gUp.push(10 ** ((-3 * d) / (rate * 0.06)));
55
+ }
56
+ }
57
+
58
+ /** One internal-rate step: input `x`, returns [left, right] resonance. */
59
+ private step(x: number, pedal: boolean, out: [number, number]): void {
60
+ let yl = 0;
61
+ let yr = 0;
62
+ const a = this.a;
63
+ for (let k = 0; k < this.lines.length; k += 1) {
64
+ const line = this.lines[k]!;
65
+ const size = line.length;
66
+ const w = this.ws[k]!;
67
+ const pos = w - this.delay[k]!;
68
+ const i = Math.floor(pos);
69
+ const at = (j: number) => line[((j % size) + size) % size]!;
70
+ const y = hermite4(at(i - 1), at(i), at(i + 1), at(i + 2), pos - i);
71
+ const f = (this.lp[k] = y + a * (this.lp[k]! - y));
72
+ line[w % size] = x + (pedal ? this.gDown[k]! : this.gUp[k]!) * f;
73
+ this.ws[k] = (w + 1) % size;
74
+ if (k & 1) yr += f;
75
+ else yl += f;
76
+ }
77
+ out[0] = yl * 0.5;
78
+ out[1] = yr * 0.5;
79
+ }
80
+
81
+ /**
82
+ * In place: adds resonance to `left`/`right`. `down(i)` says whether the
83
+ * sustain pedal is down at sample `i` (read per sample pair at most).
84
+ */
85
+ process(
86
+ left: Float64Array,
87
+ right: Float64Array,
88
+ down: (sample: number) => boolean,
89
+ ): void {
90
+ const c = 0.02 * this.amount;
91
+ const out: [number, number] = [0, 0];
92
+ const f = this.factor;
93
+ let prevL = 0;
94
+ let prevR = 0;
95
+ let energy = 0;
96
+ for (let i = 0; i < left.length; i += f) {
97
+ const pedal = down(i);
98
+ let x = 0;
99
+ for (let j = 0; j < f && i + j < left.length; j += 1)
100
+ x += left[i + j]! + right[i + j]!;
101
+ x = (x / (2 * f)) * c;
102
+ // Dampers down and the combs quiet: skip them entirely.
103
+ if (!pedal && energy < QUIET) {
104
+ prevL = 0;
105
+ prevR = 0;
106
+ continue;
107
+ }
108
+ this.step(x, pedal, out);
109
+ energy = 0.999 * energy + 0.001 * (out[0] * out[0] + out[1] * out[1]);
110
+ if (f === 1) {
111
+ left[i]! += out[0];
112
+ right[i]! += out[1];
113
+ } else {
114
+ // Linear interpolation back to the full rate (one sample latency
115
+ // at half rate, inaudible on a 4 s resonance).
116
+ left[i]! += 0.5 * (prevL + out[0]);
117
+ right[i]! += 0.5 * (prevR + out[1]);
118
+ if (i + 1 < left.length) {
119
+ left[i + 1]! += out[0];
120
+ right[i + 1]! += out[1];
121
+ }
122
+ }
123
+ prevL = out[0];
124
+ prevR = out[1];
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Off-thread full pass for live guitar-rig notes. The key path renders only
3
+ * the first `LIVE_RIG_WINDOW_SECONDS` of a rig note; this worker renders the
4
+ * whole note so the TUI thread never blocks on an oversampled 8 s render.
5
+ * Requests are handled in arrival order, so a strummed chord's strings swap
6
+ * in the order they were played.
7
+ */
8
+ import { scoreFromJSON } from "../../core/score.ts";
9
+ import { LiveSynth, type LiveNotePcm } from "./live.ts";
10
+ import type { SampleBank } from "./samples.ts";
11
+
12
+ export type LiveFullRequest = Readonly<{
13
+ id: number;
14
+ sampleRate: number;
15
+ score: unknown;
16
+ trackId: string;
17
+ pitch: number;
18
+ velocity: number;
19
+ seconds: number;
20
+ tick?: number;
21
+ clock?: number;
22
+ samples?: SampleBank;
23
+ }>;
24
+
25
+ export type LiveFullReply = Readonly<{
26
+ id: number;
27
+ pcm?: LiveNotePcm;
28
+ error?: string;
29
+ }>;
30
+
31
+ declare const self: Worker;
32
+
33
+ let synth: LiveSynth | undefined;
34
+
35
+ self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
36
+ const { id, sampleRate, score, samples, tick, clock, ...note } = event.data;
37
+ try {
38
+ if (synth?.rate !== sampleRate) synth = new LiveSynth(sampleRate);
39
+ const pcm = synth.render({
40
+ ...note,
41
+ score: scoreFromJSON(score),
42
+ full: true,
43
+ ...(samples ? { samples } : {}),
44
+ ...(tick === undefined ? {} : { tick }),
45
+ ...(clock === undefined ? {} : { clock }),
46
+ });
47
+ const reply: LiveFullReply = pcm ? { id, pcm } : { id };
48
+ self.postMessage(reply);
49
+ } catch (error) {
50
+ const reply: LiveFullReply = {
51
+ id,
52
+ error: error instanceof Error ? error.message : String(error),
53
+ };
54
+ self.postMessage(reply);
55
+ }
56
+ };