@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.
- package/CHANGELOG.md +63 -0
- package/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +70 -2
- package/src/tui/wind-menu.ts +144 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal synthesis primitives (0.6, lane f06-modal): a bank of decaying
|
|
3
|
+
* complex phasors (exact-frequency two-pole resonators) struck by a
|
|
4
|
+
* half-sine mallet pulse. Bars, tines, shells (bells, gongs, bowls, pans)
|
|
5
|
+
* and membranes differ only by a mode table and a strike-position law.
|
|
6
|
+
* Ported from the reviewed prototype (proto/resonators/modal.ts) with the
|
|
7
|
+
* review's fixes: an interior strike-position map with a 0.02 weight floor,
|
|
8
|
+
* a per-tick Nyquist fade for bent modes, and stronger velocity to
|
|
9
|
+
* hardness coupling.
|
|
10
|
+
*
|
|
11
|
+
* References: Adrien, "The missing link: modal synthesis" (1991); Cook,
|
|
12
|
+
* Real Sound Synthesis (2002) and STK ModalBar; Fletcher and Rossing, The
|
|
13
|
+
* Physics of Musical Instruments (1998) ch. 2, 3, 18-21; Rossing, Science
|
|
14
|
+
* of Percussion Instruments (2000); Chaigne and Doutaut, JASA 101 (1997).
|
|
15
|
+
*/
|
|
16
|
+
import type { ModalBody, ModalSettings } from "../../../core/resonators.ts";
|
|
17
|
+
import { Biquad } from "./filters.ts";
|
|
18
|
+
import { seededRandom } from "./rng.ts";
|
|
19
|
+
|
|
20
|
+
export type ModalShape = "bar" | "tine" | "membrane" | "shell";
|
|
21
|
+
|
|
22
|
+
export type ModeTable = Readonly<{
|
|
23
|
+
shape: ModalShape;
|
|
24
|
+
/** Mode frequency ratios to the sounding pitch. */
|
|
25
|
+
ratios: readonly number[];
|
|
26
|
+
/** Relative mode amplitudes before strike position and mallet. */
|
|
27
|
+
gains: readonly number[];
|
|
28
|
+
/** Per-mode decay multipliers on top of the ring/tilt law (default 1). */
|
|
29
|
+
decays?: readonly number[];
|
|
30
|
+
/** bar/tine: beam mode index per entry. */
|
|
31
|
+
beam?: readonly number[];
|
|
32
|
+
/** membrane: [m, j_mn] per entry. */
|
|
33
|
+
membrane?: readonly (readonly [number, number])[];
|
|
34
|
+
/** Twin every mode at ratio (1 ± split/2): beating split modes centred on the nominal pitch. */
|
|
35
|
+
split?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Output trim (default 1) so every core preset peaks near -7.5 dBFS at
|
|
38
|
+
* velocity 0.8 (spec test 13); measured on the bodies as ported.
|
|
39
|
+
*/
|
|
40
|
+
level?: number;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
43
|
+
/** Membrane Bessel zeros j_mn. */
|
|
44
|
+
const J = {
|
|
45
|
+
"01": 2.4048,
|
|
46
|
+
"11": 3.8317,
|
|
47
|
+
"21": 5.1356,
|
|
48
|
+
"02": 5.5201,
|
|
49
|
+
"31": 6.3802,
|
|
50
|
+
"12": 7.0156,
|
|
51
|
+
"41": 7.5883,
|
|
52
|
+
"22": 8.4172,
|
|
53
|
+
"03": 8.6537,
|
|
54
|
+
"51": 8.7715,
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/** Mode tables by body name (prototype values, unchanged). */
|
|
58
|
+
export const MODE_TABLES: Readonly<Record<ModalBody, ModeTable>> = {
|
|
59
|
+
// Tuned bars, Rossing (2000) ch. 6-7: marimba and vibraphone 1:4:10,
|
|
60
|
+
// xylophone 1:3:6.
|
|
61
|
+
marimba: {
|
|
62
|
+
shape: "bar",
|
|
63
|
+
ratios: [1, 3.99, 9.95, 17.8],
|
|
64
|
+
gains: [1, 0.32, 0.12, 0.03],
|
|
65
|
+
decays: [1, 1, 1, 1.4],
|
|
66
|
+
beam: [0, 1, 2, 3],
|
|
67
|
+
level: 2.9,
|
|
68
|
+
},
|
|
69
|
+
vibraphone: {
|
|
70
|
+
shape: "bar",
|
|
71
|
+
ratios: [1, 3.98, 9.92, 17.6],
|
|
72
|
+
gains: [1, 0.28, 0.1, 0.03],
|
|
73
|
+
beam: [0, 1, 2, 3],
|
|
74
|
+
level: 2.6,
|
|
75
|
+
},
|
|
76
|
+
xylophone: {
|
|
77
|
+
shape: "bar",
|
|
78
|
+
ratios: [1, 3.0, 6.1, 10.2],
|
|
79
|
+
gains: [1, 0.45, 0.25, 0.08],
|
|
80
|
+
beam: [0, 1, 2, 3],
|
|
81
|
+
level: 3.5,
|
|
82
|
+
},
|
|
83
|
+
// Uniform free-free bar (Euler-Bernoulli).
|
|
84
|
+
glockenspiel: {
|
|
85
|
+
shape: "bar",
|
|
86
|
+
ratios: [1, 2.756, 5.404, 8.933, 13.345, 18.638],
|
|
87
|
+
gains: [1, 0.55, 0.35, 0.2, 0.1, 0.05],
|
|
88
|
+
beam: [0, 1, 2, 3, 4, 5],
|
|
89
|
+
level: 3.6,
|
|
90
|
+
},
|
|
91
|
+
celesta: {
|
|
92
|
+
shape: "bar",
|
|
93
|
+
ratios: [1, 2.756, 5.404, 8.933],
|
|
94
|
+
gains: [1, 0.25, 0.1, 0.04],
|
|
95
|
+
beam: [0, 1, 2, 3],
|
|
96
|
+
level: 2.5,
|
|
97
|
+
},
|
|
98
|
+
// Tubular bell: the strike pitch sits an octave below mode 4.
|
|
99
|
+
chimes: {
|
|
100
|
+
shape: "bar",
|
|
101
|
+
ratios: [0.2238, 0.617, 1.2096, 2.0, 2.987, 4.172, 5.555],
|
|
102
|
+
gains: [0.05, 0.15, 0.35, 1, 0.8, 0.6, 0.3],
|
|
103
|
+
beam: [0, 1, 2, 3, 4, 5, 6],
|
|
104
|
+
level: 3.1,
|
|
105
|
+
},
|
|
106
|
+
// Clamped-free tines (cantilever 1 : 6.267 : 17.55).
|
|
107
|
+
mbira: {
|
|
108
|
+
shape: "tine",
|
|
109
|
+
ratios: [1, 5.9, 16.7],
|
|
110
|
+
gains: [1, 0.3, 0.06],
|
|
111
|
+
beam: [0, 1, 2],
|
|
112
|
+
level: 0.76,
|
|
113
|
+
},
|
|
114
|
+
kalimba: {
|
|
115
|
+
shape: "tine",
|
|
116
|
+
ratios: [1, 6.1, 17.2],
|
|
117
|
+
gains: [1, 0.22, 0.05],
|
|
118
|
+
beam: [0, 1, 2],
|
|
119
|
+
level: 2.0,
|
|
120
|
+
},
|
|
121
|
+
musicbox: {
|
|
122
|
+
shape: "tine",
|
|
123
|
+
ratios: [1, 6.267, 17.55, 34.39],
|
|
124
|
+
gains: [1, 0.4, 0.15, 0.05],
|
|
125
|
+
beam: [0, 1, 2, 3],
|
|
126
|
+
},
|
|
127
|
+
toypiano: {
|
|
128
|
+
shape: "tine",
|
|
129
|
+
ratios: [1, 6.27, 17.55, 3.1],
|
|
130
|
+
gains: [1, 0.5, 0.2, 0.12],
|
|
131
|
+
beam: [0, 1, 2, 0],
|
|
132
|
+
},
|
|
133
|
+
// Gamelan: saron and bonang (Sethares 2005), gender ~ jublag.
|
|
134
|
+
saron: {
|
|
135
|
+
shape: "shell",
|
|
136
|
+
ratios: [1, 2.34, 2.76, 4.75, 5.08, 5.91],
|
|
137
|
+
gains: [1, 0.3, 0.45, 0.18, 0.15, 0.1],
|
|
138
|
+
},
|
|
139
|
+
bonang: {
|
|
140
|
+
shape: "shell",
|
|
141
|
+
ratios: [1, 1.52, 3.46, 3.92],
|
|
142
|
+
gains: [1, 0.45, 0.3, 0.25],
|
|
143
|
+
split: 0.002,
|
|
144
|
+
},
|
|
145
|
+
gender: {
|
|
146
|
+
shape: "shell",
|
|
147
|
+
ratios: [1, 2.77, 5.18, 5.33],
|
|
148
|
+
gains: [1, 0.3, 0.12, 0.1],
|
|
149
|
+
},
|
|
150
|
+
kempul: {
|
|
151
|
+
shape: "shell",
|
|
152
|
+
ratios: [1, 2.0, 2.86, 3.67, 4.6],
|
|
153
|
+
gains: [1, 0.25, 0.15, 0.1, 0.06],
|
|
154
|
+
split: 0.012,
|
|
155
|
+
},
|
|
156
|
+
gong: {
|
|
157
|
+
shape: "shell",
|
|
158
|
+
ratios: [1, 2.0, 2.9, 3.8, 4.95, 6.2],
|
|
159
|
+
gains: [1, 0.35, 0.2, 0.12, 0.08, 0.05],
|
|
160
|
+
decays: [1, 0.8, 0.6, 0.5, 0.4, 0.3],
|
|
161
|
+
split: 0.03,
|
|
162
|
+
level: 1.25,
|
|
163
|
+
},
|
|
164
|
+
// Church bell partials (Rossing 2000 ch. 13).
|
|
165
|
+
bell: {
|
|
166
|
+
shape: "shell",
|
|
167
|
+
ratios: [0.5, 1, 1.183, 1.506, 2.0, 2.514, 2.662, 3.011, 4.166],
|
|
168
|
+
gains: [0.45, 0.5, 0.55, 0.25, 0.9, 0.35, 0.3, 0.25, 0.2],
|
|
169
|
+
split: 0.0015,
|
|
170
|
+
},
|
|
171
|
+
// Free circular plate as a crotale approximation.
|
|
172
|
+
crotale: {
|
|
173
|
+
shape: "shell",
|
|
174
|
+
ratios: [1, 1.73, 2.328, 3.91, 4.11, 6.3],
|
|
175
|
+
gains: [1, 0.3, 0.6, 0.25, 0.35, 0.15],
|
|
176
|
+
},
|
|
177
|
+
// Steel pan note: tuned 1:2:3 with a slight octave stretch.
|
|
178
|
+
steelpan: {
|
|
179
|
+
shape: "shell",
|
|
180
|
+
ratios: [1, 2.004, 3.0, 4.02, 5.1],
|
|
181
|
+
gains: [1, 0.55, 0.3, 0.1, 0.05],
|
|
182
|
+
level: 1.4,
|
|
183
|
+
},
|
|
184
|
+
// Singing bowl (Inacio, Henrique, Antunes 2004/2006), split pairs.
|
|
185
|
+
bowl: {
|
|
186
|
+
shape: "shell",
|
|
187
|
+
ratios: [1, 2.7, 4.8, 7.5, 10.7, 14.2],
|
|
188
|
+
gains: [1, 0.5, 0.3, 0.15, 0.08, 0.04],
|
|
189
|
+
split: 0.004,
|
|
190
|
+
level: 1.85,
|
|
191
|
+
},
|
|
192
|
+
// Timpani with air loading (Rossing 2000 ch. 3).
|
|
193
|
+
timpani: {
|
|
194
|
+
shape: "membrane",
|
|
195
|
+
ratios: [0.85, 1, 1.5, 1.98, 2.44, 2.9],
|
|
196
|
+
gains: [0.6, 1, 0.7, 0.5, 0.3, 0.2],
|
|
197
|
+
decays: [0.12, 1, 0.9, 0.8, 0.7, 0.6],
|
|
198
|
+
membrane: [
|
|
199
|
+
[0, J["01"]],
|
|
200
|
+
[1, J["11"]],
|
|
201
|
+
[2, J["21"]],
|
|
202
|
+
[3, J["31"]],
|
|
203
|
+
[4, J["41"]],
|
|
204
|
+
[5, J["51"]],
|
|
205
|
+
],
|
|
206
|
+
level: 2.45,
|
|
207
|
+
},
|
|
208
|
+
// Tabla dayan: harmonic overtones from the loaded syahi (Raman 1934).
|
|
209
|
+
tabla: {
|
|
210
|
+
shape: "membrane",
|
|
211
|
+
ratios: [1, 2, 3, 3.01, 4, 4.02, 5],
|
|
212
|
+
gains: [1, 0.8, 0.5, 0.4, 0.3, 0.25, 0.15],
|
|
213
|
+
decays: [0.6, 1, 1, 0.9, 0.9, 0.8, 0.8],
|
|
214
|
+
membrane: [
|
|
215
|
+
[0, J["01"]],
|
|
216
|
+
[1, J["11"]],
|
|
217
|
+
[2, J["21"]],
|
|
218
|
+
[0, J["02"]],
|
|
219
|
+
[3, J["31"]],
|
|
220
|
+
[1, J["12"]],
|
|
221
|
+
[4, J["41"]],
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
// Ideal membrane ratios for frame drums.
|
|
225
|
+
frame: {
|
|
226
|
+
shape: "membrane",
|
|
227
|
+
ratios: [1, 1.593, 2.136, 2.296, 2.653, 2.918, 3.156, 3.501, 3.6],
|
|
228
|
+
gains: [1, 0.8, 0.6, 0.5, 0.45, 0.35, 0.3, 0.25, 0.2],
|
|
229
|
+
membrane: [
|
|
230
|
+
[0, J["01"]],
|
|
231
|
+
[1, J["11"]],
|
|
232
|
+
[2, J["21"]],
|
|
233
|
+
[0, J["02"]],
|
|
234
|
+
[3, J["31"]],
|
|
235
|
+
[1, J["12"]],
|
|
236
|
+
[4, J["41"]],
|
|
237
|
+
[2, J["22"]],
|
|
238
|
+
[0, J["03"]],
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const LN1000 = Math.log(1000);
|
|
244
|
+
|
|
245
|
+
function clamp(value: number, min: number, max: number): number {
|
|
246
|
+
return value < min ? min : value > max ? max : value;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Pole radius that decays by 60 dB in `t60` seconds. */
|
|
250
|
+
export function radiusForT60(t60: number, sampleRate: number): number {
|
|
251
|
+
return Math.exp(-LN1000 / (Math.max(1e-4, t60) * sampleRate));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Bessel J_m(x) by its integral form (64-point midpoint rule). */
|
|
255
|
+
export function besselJ(m: number, x: number): number {
|
|
256
|
+
const n = 64;
|
|
257
|
+
let sum = 0;
|
|
258
|
+
for (let i = 0; i < n; i += 1) {
|
|
259
|
+
const t = ((i + 0.5) / n) * Math.PI;
|
|
260
|
+
sum += Math.cos(m * t - x * Math.sin(t));
|
|
261
|
+
}
|
|
262
|
+
return sum / n;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Euler-Bernoulli beam mode shape at x in [0, 1]: free-free ("free") or
|
|
267
|
+
* clamped-free ("clamped", tip at x = 1). Both peak near 1 at a free end.
|
|
268
|
+
*/
|
|
269
|
+
export function beamShape(
|
|
270
|
+
kind: "free" | "clamped",
|
|
271
|
+
mode: number,
|
|
272
|
+
x: number,
|
|
273
|
+
): number {
|
|
274
|
+
const FREE = [4.7300408, 7.8532046, 10.9956078, 14.1371655, 17.2787597];
|
|
275
|
+
const CLAMPED = [1.8751041, 4.6940911, 7.8547574, 10.9955407, 14.1371684];
|
|
276
|
+
const table = kind === "free" ? FREE : CLAMPED;
|
|
277
|
+
const beta =
|
|
278
|
+
mode < table.length
|
|
279
|
+
? table[mode]!
|
|
280
|
+
: ((2 * mode + (kind === "free" ? 3 : 1)) * Math.PI) / 2;
|
|
281
|
+
const bx = beta * x;
|
|
282
|
+
if (kind === "free") {
|
|
283
|
+
const sigma =
|
|
284
|
+
(Math.cosh(beta) - Math.cos(beta)) / (Math.sinh(beta) - Math.sin(beta));
|
|
285
|
+
if (beta > 15)
|
|
286
|
+
return (
|
|
287
|
+
Math.cos(bx) -
|
|
288
|
+
Math.sin(bx) +
|
|
289
|
+
Math.exp(-bx) +
|
|
290
|
+
(mode % 2 ? -1 : 1) * Math.exp(bx - beta)
|
|
291
|
+
);
|
|
292
|
+
return (
|
|
293
|
+
(Math.cosh(bx) + Math.cos(bx) - sigma * (Math.sinh(bx) + Math.sin(bx))) /
|
|
294
|
+
2
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const sigma =
|
|
298
|
+
(Math.cosh(beta) + Math.cos(beta)) / (Math.sinh(beta) + Math.sin(beta));
|
|
299
|
+
if (beta > 15) return Math.cos(bx) - Math.sin(bx) - Math.exp(-bx);
|
|
300
|
+
return (
|
|
301
|
+
(Math.cosh(bx) - Math.cos(bx) - sigma * (Math.sinh(bx) - Math.sin(bx))) / 2
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** No mode drops below this fraction of its maximum weight. */
|
|
306
|
+
export const POSITION_FLOOR = 0.02;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* How strongly striking at user position `p` (0..1) excites a mode. The
|
|
310
|
+
* range maps onto a safe interior per shape so no setting is silent.
|
|
311
|
+
*/
|
|
312
|
+
export function positionWeight(
|
|
313
|
+
table: ModeTable,
|
|
314
|
+
index: number,
|
|
315
|
+
p: number,
|
|
316
|
+
): number {
|
|
317
|
+
const u = clamp(p, 0, 1);
|
|
318
|
+
let weight: number;
|
|
319
|
+
if (table.shape === "bar")
|
|
320
|
+
weight = Math.abs(beamShape("free", table.beam?.[index] ?? index, u));
|
|
321
|
+
else if (table.shape === "tine")
|
|
322
|
+
weight = Math.abs(
|
|
323
|
+
beamShape("clamped", table.beam?.[index] ?? index, 0.08 + 0.92 * u),
|
|
324
|
+
);
|
|
325
|
+
else if (table.shape === "membrane") {
|
|
326
|
+
const [m, j] = table.membrane![index]!;
|
|
327
|
+
weight = Math.abs(besselJ(m, j * (0.05 + 0.85 * u)));
|
|
328
|
+
} else return table.ratios[index]! ** (2 * (u - 0.5));
|
|
329
|
+
return Math.max(POSITION_FLOOR, Math.min(1, weight));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Hertz-law mallet: a unit-area half-sine force pulse. Harder mallets and
|
|
334
|
+
* harder hits contact for less time (tau ~ v^-1/5), never longer than half
|
|
335
|
+
* the fundamental period.
|
|
336
|
+
*/
|
|
337
|
+
export class MalletPulse {
|
|
338
|
+
readonly samples: Float64Array;
|
|
339
|
+
|
|
340
|
+
constructor(
|
|
341
|
+
hardness: number,
|
|
342
|
+
velocity: number,
|
|
343
|
+
fundamentalHz: number,
|
|
344
|
+
sampleRate: number,
|
|
345
|
+
) {
|
|
346
|
+
const tauSoft = 0.004;
|
|
347
|
+
const tauHard = 0.0002;
|
|
348
|
+
let tau = Math.exp(
|
|
349
|
+
Math.log(tauSoft) + (Math.log(tauHard) - Math.log(tauSoft)) * hardness,
|
|
350
|
+
);
|
|
351
|
+
tau *= Math.max(0.05, velocity) ** -0.2 * 0.9;
|
|
352
|
+
// Capped at half a period at velocity 0.6, shorter for harder blows.
|
|
353
|
+
tau = Math.min(tau, (0.5 * (1.6 - velocity)) / Math.max(20, fundamentalHz));
|
|
354
|
+
const length = Math.max(1, Math.round(tau * sampleRate));
|
|
355
|
+
const samples = new Float64Array(length);
|
|
356
|
+
let area = 0;
|
|
357
|
+
for (let i = 0; i < length; i += 1) {
|
|
358
|
+
const value = Math.sin((Math.PI * (i + 0.5)) / length);
|
|
359
|
+
samples[i] = value;
|
|
360
|
+
area += value;
|
|
361
|
+
}
|
|
362
|
+
for (let i = 0; i < length; i += 1) samples[i]! /= area;
|
|
363
|
+
this.samples = samples;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Samples between control updates (poles, motor, Nyquist fade, lanes). */
|
|
368
|
+
export const MODAL_BLOCK = 32;
|
|
369
|
+
/** Middle C, where `ring` is the fundamental's T60. */
|
|
370
|
+
export const RING_REF_HZ = 261.63;
|
|
371
|
+
const SILENT = 1e-5;
|
|
372
|
+
/** Velocity to hardness coupling per unit velocity (review: 0.25 to 0.5). */
|
|
373
|
+
export const VELOCITY_HARDNESS = 0.5;
|
|
374
|
+
/**
|
|
375
|
+
* Upper modes gain ratio^(VELOCITY_TILT (v - 0.6)): a harder blow excites
|
|
376
|
+
* the upper modes more than the mallet's contact time alone shows once the
|
|
377
|
+
* pulse is capped (spec test 12: onset centroid at velocity 1 at least
|
|
378
|
+
* 1.15 times the centroid at 0.3). Velocity 0.6 is unchanged.
|
|
379
|
+
*/
|
|
380
|
+
export const VELOCITY_TILT = 1.5;
|
|
381
|
+
/** Fraction of the sample rate above which a mode fades out. */
|
|
382
|
+
export const NYQUIST_GUARD = 0.45;
|
|
383
|
+
|
|
384
|
+
export type ModalNote = Readonly<{
|
|
385
|
+
hz: number;
|
|
386
|
+
velocity: number;
|
|
387
|
+
/** Song seconds of the onset (phase-locks the motor). */
|
|
388
|
+
start: number;
|
|
389
|
+
/** Seconds to note-off. */
|
|
390
|
+
duration: number;
|
|
391
|
+
seed: string;
|
|
392
|
+
/** Extra hardness (an accent articulation). */
|
|
393
|
+
accent?: number;
|
|
394
|
+
/** Cents at t seconds from the onset (0.5 bends and glides). */
|
|
395
|
+
cents?: (t: number) => number;
|
|
396
|
+
/** Motor depth at t seconds from the onset (the `modal-motordepth` lane). */
|
|
397
|
+
motordepth?: (t: number) => number;
|
|
398
|
+
}>;
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* One struck note: modes as complex phasors z <- z c + x with
|
|
402
|
+
* c = r e^{i w}, read from the imaginary part. Poles are recomputed every
|
|
403
|
+
* `MODAL_BLOCK` samples when pitch, damping or Nyquist fades change.
|
|
404
|
+
*/
|
|
405
|
+
export class ModalBank {
|
|
406
|
+
private readonly count: number;
|
|
407
|
+
private readonly hz: Float64Array;
|
|
408
|
+
private readonly t60: Float64Array;
|
|
409
|
+
private readonly zr: Float64Array;
|
|
410
|
+
private readonly zi: Float64Array;
|
|
411
|
+
private readonly cr: Float64Array;
|
|
412
|
+
private readonly ci: Float64Array;
|
|
413
|
+
private readonly g: Float64Array;
|
|
414
|
+
/** Nyquist fade level at the start of this block and its per-sample step. */
|
|
415
|
+
private readonly fade: Float64Array;
|
|
416
|
+
private readonly fadeStep: Float64Array;
|
|
417
|
+
private readonly live: Uint8Array;
|
|
418
|
+
private readonly motorMode: Uint8Array;
|
|
419
|
+
private readonly pulse: Float64Array;
|
|
420
|
+
private readonly clickBurst: Float64Array;
|
|
421
|
+
private readonly random: () => number;
|
|
422
|
+
private readonly buzzFilter = new Biquad();
|
|
423
|
+
private readonly buzzVel: number;
|
|
424
|
+
private readonly offAt: number;
|
|
425
|
+
/** Sample index where the voice ends (its full ring or choke). */
|
|
426
|
+
readonly endAt: number;
|
|
427
|
+
private n = 0;
|
|
428
|
+
private motorGain = 1;
|
|
429
|
+
private released = false;
|
|
430
|
+
private fading = false;
|
|
431
|
+
|
|
432
|
+
constructor(
|
|
433
|
+
private readonly settings: ModalSettings,
|
|
434
|
+
private readonly note: ModalNote,
|
|
435
|
+
private readonly sampleRate: number,
|
|
436
|
+
maxTail: number,
|
|
437
|
+
) {
|
|
438
|
+
const table = MODE_TABLES[settings.body] ?? MODE_TABLES.marimba;
|
|
439
|
+
const f0 = note.hz;
|
|
440
|
+
const v = clamp(note.velocity, 0, 1);
|
|
441
|
+
this.random = seededRandom(`${note.seed}:modal`);
|
|
442
|
+
// Modes are kept up to an octave above the guard so an upward bend can
|
|
443
|
+
// fade them in; the per-tick fade keeps them silent while above it.
|
|
444
|
+
const guard = NYQUIST_GUARD * sampleRate;
|
|
445
|
+
const keep = 2 * guard;
|
|
446
|
+
const twins = settings.ombak > 0 ? 2 : 1;
|
|
447
|
+
const splits = table.split ? 2 : 1;
|
|
448
|
+
const capacity = table.ratios.length * twins * splits;
|
|
449
|
+
this.hz = new Float64Array(capacity);
|
|
450
|
+
this.t60 = new Float64Array(capacity);
|
|
451
|
+
this.zr = new Float64Array(capacity);
|
|
452
|
+
this.zi = new Float64Array(capacity);
|
|
453
|
+
this.cr = new Float64Array(capacity);
|
|
454
|
+
this.ci = new Float64Array(capacity);
|
|
455
|
+
this.g = new Float64Array(capacity);
|
|
456
|
+
this.fade = new Float64Array(capacity);
|
|
457
|
+
this.fadeStep = new Float64Array(capacity);
|
|
458
|
+
this.live = new Uint8Array(capacity);
|
|
459
|
+
this.motorMode = new Uint8Array(capacity);
|
|
460
|
+
let weightSum = 0;
|
|
461
|
+
let k = 0;
|
|
462
|
+
for (let i = 0; i < table.ratios.length; i += 1) {
|
|
463
|
+
const ratio = table.ratios[i]!;
|
|
464
|
+
const weight =
|
|
465
|
+
table.gains[i]! *
|
|
466
|
+
positionWeight(table, i, settings.position) *
|
|
467
|
+
ratio ** (VELOCITY_TILT * (v - 0.6));
|
|
468
|
+
weightSum += Math.abs(table.gains[i]!);
|
|
469
|
+
if (!(weight > 1e-4)) continue;
|
|
470
|
+
const decay = table.decays?.[i] ?? 1;
|
|
471
|
+
for (let s = 0; s < splits; s += 1) {
|
|
472
|
+
for (let t = 0; t < twins; t += 1) {
|
|
473
|
+
const hz =
|
|
474
|
+
f0 *
|
|
475
|
+
ratio *
|
|
476
|
+
(splits > 1 ? 1 + (s ? 0.5 : -0.5) * table.split! : 1) +
|
|
477
|
+
(twins > 1 ? (t ? 0.5 : -0.5) * settings.ombak : 0);
|
|
478
|
+
if (hz >= keep || hz <= 0) continue;
|
|
479
|
+
this.hz[k] = hz;
|
|
480
|
+
this.t60[k] = Math.max(
|
|
481
|
+
0.01,
|
|
482
|
+
settings.ring * decay * (RING_REF_HZ / hz) ** settings.tilt,
|
|
483
|
+
);
|
|
484
|
+
this.g[k] = weight / (splits * twins);
|
|
485
|
+
this.motorMode[k] = i === 0 ? 1 : 0;
|
|
486
|
+
this.live[k] = 1;
|
|
487
|
+
k += 1;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
this.count = k;
|
|
492
|
+
const norm =
|
|
493
|
+
(settings.gain * v * (table.level ?? 1)) / Math.max(1e-9, weightSum);
|
|
494
|
+
for (let i = 0; i < k; i += 1) this.g[i]! *= norm;
|
|
495
|
+
const hard = clamp(
|
|
496
|
+
settings.hardness + VELOCITY_HARDNESS * (v - 0.6) + (note.accent ?? 0),
|
|
497
|
+
0,
|
|
498
|
+
1,
|
|
499
|
+
);
|
|
500
|
+
this.pulse = new MalletPulse(
|
|
501
|
+
hard,
|
|
502
|
+
v,
|
|
503
|
+
f0 * Math.min(1, table.ratios[0]!),
|
|
504
|
+
sampleRate,
|
|
505
|
+
).samples;
|
|
506
|
+
const clickLength = settings.click > 0 ? Math.round(0.004 * sampleRate) : 0;
|
|
507
|
+
this.clickBurst = new Float64Array(clickLength);
|
|
508
|
+
let last = 0;
|
|
509
|
+
for (let i = 0; i < clickLength; i += 1) {
|
|
510
|
+
const noise = this.random() * 2 - 1;
|
|
511
|
+
this.clickBurst[i] =
|
|
512
|
+
(noise - last) *
|
|
513
|
+
Math.exp(-i / (0.0008 * sampleRate)) *
|
|
514
|
+
settings.click *
|
|
515
|
+
v *
|
|
516
|
+
settings.gain *
|
|
517
|
+
0.25;
|
|
518
|
+
last = noise;
|
|
519
|
+
}
|
|
520
|
+
this.buzzFilter.set(
|
|
521
|
+
"bpf",
|
|
522
|
+
Math.min(3200, 0.4 * sampleRate),
|
|
523
|
+
0.8,
|
|
524
|
+
sampleRate,
|
|
525
|
+
);
|
|
526
|
+
// The buzzer rattles harder when the tine swings wider.
|
|
527
|
+
this.buzzVel = v / 0.6;
|
|
528
|
+
this.offAt = Math.round(note.duration * sampleRate);
|
|
529
|
+
let longest = 0;
|
|
530
|
+
for (let i = 0; i < k; i += 1) longest = Math.max(longest, this.t60[i]!);
|
|
531
|
+
const tail =
|
|
532
|
+
settings.damp > 0.999
|
|
533
|
+
? note.duration + settings.release
|
|
534
|
+
: Math.max(note.duration, longest);
|
|
535
|
+
this.endAt = Math.max(
|
|
536
|
+
1,
|
|
537
|
+
Math.round(Math.min(tail, note.duration + maxTail) * sampleRate),
|
|
538
|
+
);
|
|
539
|
+
this.updatePoles(0);
|
|
540
|
+
// Modes above the guard at the onset start silent.
|
|
541
|
+
for (let i = 0; i < k; i += 1) {
|
|
542
|
+
this.fade[i] = this.fade[i]! + this.fadeStep[i]! * MODAL_BLOCK;
|
|
543
|
+
this.fadeStep[i] = 0;
|
|
544
|
+
}
|
|
545
|
+
this.fading = false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Recomputes poles, motor gain and Nyquist fades for the block at `n`. */
|
|
549
|
+
private updatePoles(n: number): void {
|
|
550
|
+
const t = n / this.sampleRate;
|
|
551
|
+
const { settings, note } = this;
|
|
552
|
+
let semis = 0;
|
|
553
|
+
if (settings.strikebend !== 0)
|
|
554
|
+
semis =
|
|
555
|
+
settings.strikebend *
|
|
556
|
+
clamp(note.velocity, 0, 1) *
|
|
557
|
+
Math.exp(-t / Math.max(1e-3, settings.strikedecay));
|
|
558
|
+
const cents = note.cents ? note.cents(t) : 0;
|
|
559
|
+
const scale = 2 ** (semis / 12 + cents / 1200);
|
|
560
|
+
const damping = this.released
|
|
561
|
+
? settings.damp / Math.max(1e-3, settings.release)
|
|
562
|
+
: 0;
|
|
563
|
+
const guard = NYQUIST_GUARD * this.sampleRate;
|
|
564
|
+
let fading = false;
|
|
565
|
+
for (let k = 0; k < this.count; k += 1) {
|
|
566
|
+
if (!this.live[k]) continue;
|
|
567
|
+
const hz = this.hz[k]! * scale;
|
|
568
|
+
const w = (2 * Math.PI * hz) / this.sampleRate;
|
|
569
|
+
const rate = 1 / this.t60[k]! + damping;
|
|
570
|
+
const r = radiusForT60(1 / rate, this.sampleRate);
|
|
571
|
+
this.cr[k] = r * Math.cos(w);
|
|
572
|
+
this.ci[k] = r * Math.sin(w);
|
|
573
|
+
// Fade a mode to silence over one tick while it sits above the guard.
|
|
574
|
+
const target = hz < guard ? 1 : 0;
|
|
575
|
+
const step = (target - this.fade[k]!) / MODAL_BLOCK;
|
|
576
|
+
this.fadeStep[k] = step;
|
|
577
|
+
if (step !== 0) fading = true;
|
|
578
|
+
}
|
|
579
|
+
this.fading = fading;
|
|
580
|
+
const depth = note.motordepth ? note.motordepth(t) : settings.motordepth;
|
|
581
|
+
if (settings.motor > 0 && depth > 0) {
|
|
582
|
+
const phase = (note.start + t) * settings.motor;
|
|
583
|
+
this.motorGain =
|
|
584
|
+
1 - clamp(depth, 0, 1) * (0.5 - 0.5 * Math.cos(2 * Math.PI * phase));
|
|
585
|
+
} else this.motorGain = 1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Samples rendered so far. */
|
|
589
|
+
get position(): number {
|
|
590
|
+
return this.n;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Adds `count` samples into out[offset..], each scaled by `level(i)`
|
|
595
|
+
* when given; returns false once the voice has ended or gone silent.
|
|
596
|
+
*/
|
|
597
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
598
|
+
const { zr, zi, cr, ci, g, live, motorMode, pulse, clickBurst, settings } =
|
|
599
|
+
this;
|
|
600
|
+
const modes = this.count;
|
|
601
|
+
const buzz = settings.buzz;
|
|
602
|
+
const motor =
|
|
603
|
+
settings.motor > 0 &&
|
|
604
|
+
(settings.motordepth > 0 || this.note.motordepth !== undefined);
|
|
605
|
+
const dynamic =
|
|
606
|
+
settings.strikebend !== 0 || this.note.cents !== undefined || motor;
|
|
607
|
+
const fade = this.fade;
|
|
608
|
+
const fadeStep = this.fadeStep;
|
|
609
|
+
for (let i = 0; i < count; i += 1) {
|
|
610
|
+
const n = this.n;
|
|
611
|
+
if (n >= this.endAt) return false;
|
|
612
|
+
if (n % MODAL_BLOCK === 0) {
|
|
613
|
+
if (this.fading) {
|
|
614
|
+
for (let k = 0; k < modes; k += 1) {
|
|
615
|
+
fade[k] = clamp(fade[k]!, 0, 1);
|
|
616
|
+
fadeStep[k] = 0;
|
|
617
|
+
}
|
|
618
|
+
this.fading = false;
|
|
619
|
+
}
|
|
620
|
+
let changed = false;
|
|
621
|
+
if (!this.released && n >= this.offAt && settings.damp > 0) {
|
|
622
|
+
this.released = true;
|
|
623
|
+
changed = true;
|
|
624
|
+
}
|
|
625
|
+
if (n > pulse.length && n % (MODAL_BLOCK * 16) === 0) {
|
|
626
|
+
let alive = 0;
|
|
627
|
+
for (let k = 0; k < modes; k += 1) {
|
|
628
|
+
if (!live[k]) continue;
|
|
629
|
+
if (Math.abs(g[k]!) * Math.hypot(zr[k]!, zi[k]!) < SILENT)
|
|
630
|
+
live[k] = 0;
|
|
631
|
+
else alive += 1;
|
|
632
|
+
}
|
|
633
|
+
if (alive === 0) {
|
|
634
|
+
this.n = this.endAt;
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (n > 0 && (dynamic || changed || this.released)) this.updatePoles(n);
|
|
639
|
+
}
|
|
640
|
+
const x = n < pulse.length ? pulse[n]! : 0;
|
|
641
|
+
let y = 0;
|
|
642
|
+
let ym = 0;
|
|
643
|
+
if (this.fading) {
|
|
644
|
+
for (let k = 0; k < modes; k += 1) {
|
|
645
|
+
const a = zr[k]!;
|
|
646
|
+
const b = zi[k]!;
|
|
647
|
+
const nr = a * cr[k]! - b * ci[k]! + x;
|
|
648
|
+
const ni = a * ci[k]! + b * cr[k]!;
|
|
649
|
+
zr[k] = nr;
|
|
650
|
+
zi[k] = ni;
|
|
651
|
+
const level = g[k]! * fade[k]!;
|
|
652
|
+
fade[k] = fade[k]! + fadeStep[k]!;
|
|
653
|
+
if (motor && motorMode[k]) ym += level * ni;
|
|
654
|
+
else y += level * ni;
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
for (let k = 0; k < modes; k += 1) {
|
|
658
|
+
const a = zr[k]!;
|
|
659
|
+
const b = zi[k]!;
|
|
660
|
+
const nr = a * cr[k]! - b * ci[k]! + x;
|
|
661
|
+
const ni = a * ci[k]! + b * cr[k]!;
|
|
662
|
+
zr[k] = nr;
|
|
663
|
+
zi[k] = ni;
|
|
664
|
+
const level = g[k]! * fade[k]!;
|
|
665
|
+
if (motor && motorMode[k]) ym += level * ni;
|
|
666
|
+
else y += level * ni;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
y += ym * this.motorGain;
|
|
670
|
+
if (n < clickBurst.length) y += clickBurst[n]!;
|
|
671
|
+
if (buzz > 0) {
|
|
672
|
+
const excess = Math.abs(y) - 0.04;
|
|
673
|
+
if (excess > 0)
|
|
674
|
+
y += this.buzzFilter.process(
|
|
675
|
+
(this.random() * 2 - 1) * excess * buzz * 6 * this.buzzVel,
|
|
676
|
+
);
|
|
677
|
+
else this.buzzFilter.process(0);
|
|
678
|
+
}
|
|
679
|
+
out[offset + i]! += y;
|
|
680
|
+
this.n += 1;
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
}
|