@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,656 @@
1
+ /**
2
+ * A bowed string (0.6.1 lane f061-bowed), ported from the 0.6 strings
3
+ * prototype's `BowedString`: the STK `Bowed` topology (Cook and Scavone)
4
+ * with a neck and a bridge delay line split at the bow point, the bow
5
+ * table `(|dv| x slope + 0.75)^-4` and a one-pole bridge loss, plus the
6
+ * review fixes:
7
+ *
8
+ * - Exact tuning: the neck line carries a first-order Thiran fraction and
9
+ * the loop length counts the loss filter's phase delay (the junction adds
10
+ * no measurable lag, so the prototype's bow-lag calibration is the
11
+ * frozen constant 0, not module state).
12
+ * - Playable beta: `pos` maps onto 0.08..0.4 of the loop, and the bridge
13
+ * segment never falls under 4 samples. A period under 24 samples runs
14
+ * the loop at twice the rate through the shared half-band decimator.
15
+ * - Schelleng window: bow force follows `pressure` between the minimum
16
+ * (proportional to v / beta^2) and maximum (proportional to v / beta)
17
+ * force for the bow speed and beta (Schelleng 1973; Woodhouse 2014), so
18
+ * every pressure stays in Helmholtz motion.
19
+ * - Dynamics: `dyn` x velocity drives bow speed over 8:1, a per-voice gain
20
+ * law (28 dB across the range) and a tone filter whose corner rises with
21
+ * force and falls with beta and the mute (`sord`).
22
+ * - Slur: `slurTo` retunes over 7 ms and keeps the bow (no new attack).
23
+ * - Tremolo: `tremhz` reverses the bow that many times a second, with a
24
+ * seeded per-voice rate jitter.
25
+ */
26
+ import { Decimate2 } from "../dsp/oversample.ts";
27
+ import { thiranFor } from "../dsp/interp.ts";
28
+ import { unit } from "../dsp/rng.ts";
29
+ import { clamp, designLoss, onePoleDelay } from "./loop.ts";
30
+ import { C4 } from "./pluck.ts";
31
+
32
+ /** Control-rate step (base-rate samples), the same as dawg's lanes. */
33
+ const CONTROL = 32;
34
+ /** Playable bow positions (fraction of the loop from the bridge). */
35
+ export const BETA_MIN = 0.08;
36
+ export const BETA_MAX = 0.4;
37
+ /** Shortest bridge segment in loop samples. */
38
+ export const BRIDGE_FLOOR = 4;
39
+ /** Periods shorter than this (base-rate samples) run at 2x. */
40
+ export const OVERSAMPLE_BELOW = 24;
41
+ /** Slur retune time in seconds (the review asks for 5-10 ms). */
42
+ export const SLUR_SECONDS = 0.007;
43
+ /** Widest slur in semitones (the loop buffer holds two octaves down). */
44
+ export const SLUR_RANGE = 24;
45
+ /**
46
+ * Pitch lock: the stick-slip corner travels the loop faster than the loss
47
+ * filter's phase delay at f0 says (its high partials see less delay), so
48
+ * an open loop plays up to 15 cents sharp. Each period the lock measures
49
+ * the band-passed bridge velocity's zero-crossing period and moves the
50
+ * loop length by LOCK_GAIN of the error, inside +-LOCK_RANGE. Its state is
51
+ * per voice, so renders stay order-independent.
52
+ */
53
+ const LOCK_GAIN = 0.25;
54
+ const LOCK_RANGE = 0.03;
55
+ /** Speed ratio between dyn 0 and dyn 1 (the review's 8:1). */
56
+ const SPEED_RATIO = 8;
57
+ /** Extra gain law across dyn, in dB (with the 18 dB speed law: 28 dB). */
58
+ const GAIN_LAW_DB = 10;
59
+ /**
60
+ * Schelleng window, measured. In the bow table's units the friction
61
+ * capacity over bow speed is r = 0.25 / (slope x v); Schelleng's minimum
62
+ * and maximum bow force bound r from below and above, and both move with
63
+ * beta and the loop length (Schelleng 1973; Woodhouse 2014). Rather than
64
+ * fit one constant (the prototype's guard, which the review showed stalls
65
+ * the string over part of the range), the window is a frozen table. Each
66
+ * cell covers a log2 loop length step of 0.25 from 4.5 (rows) and a beta
67
+ * step of 0.02 from 0.08 (columns); it holds the steps i of
68
+ * r = 0.01 x 2^(i/4) (one character each, i = code - 48) that stay
69
+ * f0-dominant at velocity 0.15, 0.5 and 0.95, at 22.05 and 48 kHz, at all
70
+ * four cell corners.
71
+ * `pressure` moves log-linearly inside the window less a margin, so every
72
+ * pressure stays in Helmholtz motion (bow.test.ts sweeps it).
73
+ */
74
+ const WINDOW_LP0 = 4.5;
75
+ const WINDOW_LO: readonly string[] = [
76
+ "CLLDC??<;:988GG7",
77
+ "CCCJJ??>>;988887",
78
+ "CCDJJEE>>;998888",
79
+ "EELLEEE>>:999988",
80
+ "EEMMEAA>>::99999",
81
+ "FCMMEAA?>;:::999",
82
+ "FFFFBBB@=;:::::9",
83
+ "HFGGBBBAA;;;::::",
84
+ "HFGGECCCB<<<;;;;",
85
+ "HHHHFCDDB==<<<<;",
86
+ "JHHHGCDDCB===<<<",
87
+ "JIIIGDDDDC====<<",
88
+ "JKKKHEEEDD>=====",
89
+ "KKKKHGEEED>>====",
90
+ "KLLJIGEEEE>>>===",
91
+ "MMMLJHFFFF>>>===",
92
+ "MMMLJIFGGF>>>>==",
93
+ "KKKKJIGGGF?>>>==",
94
+ "JJJJJIHOOH?>>>>=",
95
+ "HHHHHHHOOI?>>>>=",
96
+ "MMMMMMMMMJD>>>>=",
97
+ "MMMMMMMMMKK>>>>=",
98
+ "KKKKKKKKKKK>>>>=",
99
+ ">>>>>>>>>>>>>>>=",
100
+ ];
101
+ const WINDOW_HI: readonly string[] = [
102
+ "KKKKIHEEEICCCFGA",
103
+ "IIIIIIEEEFCCCGGA",
104
+ "IIIIKKHHHFFFFHHA",
105
+ "XLIIHHIIJLJJHHHB",
106
+ "NLLNHHHHLLLLIIMB",
107
+ "NNPMMSHHKXLHHIRC",
108
+ "TXXMMKJJKXLHHKXC",
109
+ "TVWRRKJJXXXXLLXD",
110
+ "TVWXXJJKXXXXXXXD",
111
+ "TVWXXJJKXXXXXXFD",
112
+ "TVWXXJJLXXXXXKFD",
113
+ "TVVWXJJLXXXXXJFE",
114
+ "SUVWWLLMXXXXXJFE",
115
+ "SUVVWXXXXXXXXJFE",
116
+ "STUUVWWXXXXXXJFE",
117
+ "TTTTUVWXXXXXXJFE",
118
+ "TTTTTUVWXXXXXIFE",
119
+ "TTTTTTUVXXXXXIFE",
120
+ "TTTTTTTTXXXXXIFE",
121
+ "RRRRRRRRTXXXXIFF",
122
+ "OOOOOOOOOSXXXJFF",
123
+ "OOOOOOOOOSXXXKFF",
124
+ "XXXXXXXXXXXXXLGF",
125
+ "XXXXXXXXXXXXXXGG",
126
+ ];
127
+ /** Widest window used (steps of 2^(1/4)): pressure spans 8x at most. */
128
+ const WINDOW_SPAN = 10;
129
+ /** Margin inside each measured edge, in steps. */
130
+ const WINDOW_MARGIN = 3;
131
+
132
+ function cell(table: readonly string[], lp: number, beta: number): number {
133
+ const y = clamp(Math.floor((lp - WINDOW_LP0) / 0.25), 0, table.length - 1);
134
+ const row = table[y]!;
135
+ const x = clamp(
136
+ Math.floor((beta - BETA_MIN + 1e-9) / 0.02),
137
+ 0,
138
+ row.length - 1,
139
+ );
140
+ return row.charCodeAt(x) - 48;
141
+ }
142
+
143
+ /** Resolved bowed-string parameters for one note. */
144
+ export type BowSpec = Readonly<{
145
+ /** Free-string T60 (s) at C4 with the bow lifted. */
146
+ decay: number;
147
+ /** Decay key tracking: T60 x (f/C4)^-track. */
148
+ track: number;
149
+ /** 0..1 high-frequency loss. */
150
+ damp: number;
151
+ /** Bow position, 0.01..0.5 asked; played inside BETA_MIN..BETA_MAX. */
152
+ pos: number;
153
+ /** 0..1 of the Schelleng window. */
154
+ pressure: number;
155
+ /** 0..1 bow speed at full dynamics. */
156
+ speed: number;
157
+ /** Seconds of bow-speed ramp at the start of a stroke. */
158
+ attack: number;
159
+ /** Seconds for the bow to lift after note-off. */
160
+ release: number;
161
+ /** Bow reversals per second (0 = a sustained stroke). */
162
+ tremhz: number;
163
+ /** 0..1 practice mute. */
164
+ sord: number;
165
+ }>;
166
+
167
+ /** The continuously read (every 32 samples) bow controls. */
168
+ export type BowControl = Readonly<{
169
+ pressure: number;
170
+ speed: number;
171
+ sord: number;
172
+ dyn: number;
173
+ }>;
174
+
175
+ export type BowNote = Readonly<{
176
+ hz: number;
177
+ /** 0..1 after the velocity curve and the preset's sensitivity. */
178
+ velocity: number;
179
+ /** Seconds to note-off. */
180
+ hold: number;
181
+ seed: number;
182
+ /** The note's own pitch curve in cents at `t` seconds from its start. */
183
+ cents?: (t: number) => number;
184
+ /** Vibrato in cents at `t` seconds from the bow's start (kept by a slur). */
185
+ vibrato?: (t: number) => number;
186
+ /** Lane values at `t` seconds from the bow's start (absent: the spec). */
187
+ control?: (t: number) => BowControl;
188
+ /** Calibration only: a fixed friction capacity r (slope = 0.25 / (r v)). */
189
+ capacity?: number;
190
+ }>;
191
+
192
+ /**
193
+ * Playable bow positions, measured (0.6.1 review). Helmholtz motion holds
194
+ * only on some bow positions, and which ones depends on the loop length
195
+ * and on the loop's own loss: a long, slowly decaying loop (a cello or
196
+ * double bass, `ring` 0.9-1.2) has holes in beta where the string flips
197
+ * to its octave or goes raucous at every pressure. Each cell covers a
198
+ * log2 loop length step of 0.25 from 5 (rows) and a loop T60 interval
199
+ * between BETA_T60S (4 base-36 characters per interval); it holds a
200
+ * bitmask of the betas 0.08 + 0.02 k that stay f0-dominant at pressure 0,
201
+ * 0.5 and 1, speed 0.1 and 1, velocity 0.2 and 1, at 22.05 and 48 kHz,
202
+ * at all four cell corners (a few cells with none hold the least bad).
203
+ */
204
+ const BETA_LP0 = 5;
205
+ const BETA_T60S: readonly number[] = [0.4, 0.6, 1.0, 1.5, 2.2, 3.2, 4.5];
206
+ const BETA_SAFE: readonly string[] = [
207
+ "2t4v2sjj1dvz1dvz1dvz118v",
208
+ "2sqn2sqn1ekf1ekf1ehr1e3j",
209
+ "2sql2sql1ejh1ejj1ea71dvz",
210
+ "2t4t2sjg1dy40ov20olq0olq",
211
+ "2t4v2sji0ony0ota0olq0olq",
212
+ "2t4v2sqn0ov30osv0olr0olr",
213
+ "2t4v2sqm1e660omn0oj30oj3",
214
+ "2t4u2sqm1e260oj30oi70oi7",
215
+ "2t4u2t4u0orz0oi70olr0olr",
216
+ "2t4s2t4u0onj0olr0olr0omn",
217
+ "2t4o2t4u0oi70oi70olr0olr",
218
+ "2t4g2t4s0nq70npr0nta0npq",
219
+ "1k2o2t0w1dem0npr0npq0npq",
220
+ "00ow2t0g2rks0npq0npr0npq",
221
+ "00lc2szk2rmg1czy0npq0npq",
222
+ "06bk0k5c2se82rk01czq1czy",
223
+ "06bk00e82shs2rk02rk61czq",
224
+ "06bk0cn42shs2sg02rnm1d3a",
225
+ "06bk06bk21hc2sg02rnk2rnq",
226
+ "0cn40cn40cn42scg2scg2scg",
227
+ "0cn40cn40cn42rk02rk02rk0",
228
+ "0cn40cn42pz42rk02rk02rk0",
229
+ "0cn40cn42pz42rk02rk02rk0",
230
+ ];
231
+
232
+ function safeBetas(lp: number, t60: number): number {
233
+ const y = clamp(Math.floor((lp - BETA_LP0) / 0.25), 0, BETA_SAFE.length - 1);
234
+ let x = 0;
235
+ while (x < BETA_T60S.length - 2 && t60 >= BETA_T60S[x + 1]!) x += 1;
236
+ return parseInt(BETA_SAFE[y]!.slice(4 * x, 4 * x + 4), 36);
237
+ }
238
+
239
+ /**
240
+ * Beta actually played for `pos` on a loop of `period` samples whose T60
241
+ * is `t60` seconds: inside 0.08..0.4, at least 4 samples of bridge
242
+ * segment, then the measured playable beta nearest to it (the review's
243
+ * "playable beta per register").
244
+ */
245
+ export function playableBeta(pos: number, period: number, t60 = 1): number {
246
+ const floor = Math.min(BETA_MAX, BRIDGE_FLOOR / Math.max(1, period));
247
+ const want = clamp(pos, Math.max(BETA_MIN, floor), BETA_MAX);
248
+ const raw = safeBetas(Math.log2(Math.max(1, period)), t60);
249
+ // Prefer a beta whose neighbours are playable too: the cells are
250
+ // measured at their corners, and an isolated safe beta is a thin margin.
251
+ const inner = raw & (raw << 1) & (raw >> 1);
252
+ const mask = inner || raw;
253
+ let best = want;
254
+ let gap = Infinity;
255
+ for (let k = 0; k < 17; k += 1) {
256
+ if (!(mask & (1 << k))) continue;
257
+ const beta = BETA_MIN + 0.02 * k;
258
+ if (beta < floor - 1e-9) continue;
259
+ const d = Math.abs(beta - want);
260
+ if (d < gap - 1e-9) {
261
+ gap = d;
262
+ best = beta;
263
+ }
264
+ }
265
+ return best;
266
+ }
267
+
268
+ /** Whether a bowed note at `hz` runs at twice the rate `sr`. */
269
+ export function bowOversample(hz: number, sr: number): 1 | 2 {
270
+ return sr / hz < OVERSAMPLE_BELOW ? 2 : 1;
271
+ }
272
+
273
+ /**
274
+ * Bow-table slope for `pressure` inside the Schelleng window at `beta` on
275
+ * a loop of `period` samples, for bow speed `v`. Pressure 0 sits near the
276
+ * minimum bow force (light, flautando), 1 near the maximum (gritty).
277
+ */
278
+ export function schellengSlope(
279
+ pressure: number,
280
+ beta: number,
281
+ v: number,
282
+ period: number,
283
+ ): number {
284
+ const lp = Math.log2(Math.max(1, period));
285
+ const lo = cell(WINDOW_LO, lp, beta);
286
+ const hi = Math.min(cell(WINDOW_HI, lp, beta), lo + WINDOW_SPAN);
287
+ const a = lo + WINDOW_MARGIN;
288
+ const b = hi - WINDOW_MARGIN;
289
+ const step = b > a ? a + (b - a) * clamp(pressure, 0, 1) : (lo + hi) / 2;
290
+ const r = 0.01 * 2 ** (step / 4);
291
+ return 0.25 / (r * Math.max(1e-6, v));
292
+ }
293
+
294
+ /** One bowed string, streamed in blocks. */
295
+ export class BowedString {
296
+ private readonly os: 1 | 2;
297
+ private readonly rate: number;
298
+ private readonly nb: Float64Array;
299
+ private readonly bb: Float64Array;
300
+ private readonly mask: number;
301
+ private nw = 0;
302
+ private bw = 0;
303
+ private Nn = 1;
304
+ private Nb = 1;
305
+ private a = 0;
306
+ private tx1 = 0;
307
+ private ty1 = 0;
308
+ private sg: number;
309
+ private sp: number;
310
+ private sy = 0;
311
+ private beta: number;
312
+ private fromBeta = 0;
313
+ private toBeta = 0;
314
+ private slurLoss?: { g: number; p: number };
315
+ private readonly f0: number;
316
+ private hz: number;
317
+ private fromHz: number;
318
+ private slurAt = -1;
319
+ private slurLen = 1;
320
+ private cents?: (t: number) => number;
321
+ private centsAt = 0;
322
+ private readonly vibrato?: (t: number) => number;
323
+ private readonly decim?: Decimate2;
324
+ private readonly tremRate: number;
325
+ private readonly tremPhase: number;
326
+ /** Internal-rate sample count. */
327
+ private n = 0;
328
+ private off: number;
329
+ private quiet = 0;
330
+ // Control-rate state.
331
+ private slope = 3;
332
+ private vtarget = 0.1;
333
+ private gain = 1;
334
+ private toneA = 1;
335
+ private toneY = 0;
336
+ // Pitch lock: loop length x (1 + lock); band-pass at the target pitch.
337
+ private lock = 0;
338
+ private target = 1;
339
+ private bpB = 0;
340
+ private bpA1 = 0;
341
+ private bpA2 = 0;
342
+ private bpX1 = 0;
343
+ private bpX2 = 0;
344
+ private bpY1 = 0;
345
+ private bpY2 = 0;
346
+ private lastCross = -1;
347
+ // Output DC blocker (10 Hz): a lifted loop with near-unity DC gain keeps
348
+ // a tiny offset that would otherwise hold the voice open.
349
+ private dcR = 1;
350
+ private dcX = 0;
351
+ private dcY = 0;
352
+ done = false;
353
+
354
+ constructor(
355
+ readonly spec: BowSpec,
356
+ readonly note: BowNote,
357
+ readonly sr: number,
358
+ readonly maxSeconds = 12,
359
+ ) {
360
+ const f0 = note.hz;
361
+ this.f0 = f0;
362
+ this.os = bowOversample(f0, sr);
363
+ this.rate = sr * this.os;
364
+ const loss = this.loss(f0);
365
+ this.sg = loss.g;
366
+ this.sp = loss.p;
367
+ // Room for a two-octave slur down (SLUR_RANGE) and a wide vibrato.
368
+ let size = 16;
369
+ while (size < (this.rate / f0) * 4.5 + 16) size <<= 1;
370
+ this.nb = new Float64Array(size);
371
+ this.bb = new Float64Array(size);
372
+ this.mask = size - 1;
373
+ if (this.os === 2) this.decim = new Decimate2();
374
+ this.hz = f0;
375
+ this.fromHz = f0;
376
+ this.beta = playableBeta(spec.pos, this.rate / f0, this.t60(f0));
377
+ if (note.cents) this.cents = note.cents;
378
+ if (note.vibrato) this.vibrato = note.vibrato;
379
+ this.tremRate = spec.tremhz * (1 + 0.08 * (unit(note.seed, 0, 7) - 0.5));
380
+ this.tremPhase = unit(note.seed, 0, 8);
381
+ this.off = Math.round(note.hold * this.rate);
382
+ this.dcR = 1 - (2 * Math.PI * 10) / sr;
383
+ this.control(0);
384
+ this.retune(f0, true);
385
+ }
386
+
387
+ /** Loop T60 (s) at `hz`: the free-string decay with key tracking. */
388
+ private t60(hz: number): number {
389
+ return this.spec.decay * (hz / C4) ** -this.spec.track;
390
+ }
391
+
392
+ /** The loop's loss filter at `hz` (sordino darkens it). */
393
+ private loss(hz: number): { g: number; p: number } {
394
+ const spec = this.spec;
395
+ const t0 = this.t60(hz);
396
+ const damp = clamp(spec.damp + (1 - spec.damp) * 0.5 * spec.sord, 0, 1);
397
+ return designLoss(
398
+ hz,
399
+ t0,
400
+ Math.max(3000, 2 * hz),
401
+ t0 / (1 + 99 * damp * damp),
402
+ this.rate,
403
+ );
404
+ }
405
+
406
+ /**
407
+ * Whether this string can slur to `hz` without a new stroke: within
408
+ * SLUR_RANGE semitones of its first note and at the same internal rate
409
+ * (a slur up into the oversampled register restarts the bow instead).
410
+ */
411
+ canSlurTo(hz: number): boolean {
412
+ const semis = Math.abs(12 * Math.log2(hz / this.f0));
413
+ return semis <= SLUR_RANGE && bowOversample(hz, this.sr) === this.os;
414
+ }
415
+
416
+ /** Note-off in base-rate samples from the string's start. */
417
+ get offAt(): number {
418
+ return Math.round(this.off / this.os);
419
+ }
420
+
421
+ /** Moves note-off to `at` base-rate samples (restrike, steal). */
422
+ releaseAt(at: number): void {
423
+ const n = at * this.os;
424
+ if (n < this.off) this.off = Math.max(this.n, n);
425
+ }
426
+
427
+ /**
428
+ * Slurs to `hz` at `at` base-rate samples from the start: the pitch moves
429
+ * over SLUR_SECONDS, the bow keeps going, and note-off moves to `hold`
430
+ * seconds after `at`. The new note's own pitch curve replaces the old.
431
+ */
432
+ slurTo(
433
+ hz: number,
434
+ at: number,
435
+ hold: number,
436
+ cents?: (t: number) => number,
437
+ ): void {
438
+ const n = Math.max(this.n, at * this.os);
439
+ this.fromHz = this.currentHz();
440
+ this.hz = hz;
441
+ this.slurAt = n;
442
+ this.slurLen = Math.max(1, Math.round(SLUR_SECONDS * this.rate));
443
+ this.off = n + Math.round(hold * this.rate);
444
+ this.cents = cents;
445
+ this.centsAt = n;
446
+ // The bow point, its gain and Schelleng slope, and the loop loss follow
447
+ // the new pitch: the bridge tap glides with the pitch over the slur.
448
+ this.fromBeta = this.beta;
449
+ this.toBeta = playableBeta(this.spec.pos, this.rate / hz, this.t60(hz));
450
+ this.slurLoss = this.loss(hz);
451
+ }
452
+
453
+ /** The loop's base pitch now (inside a slur, the glide position). */
454
+ private currentHz(): number {
455
+ if (this.slurAt < 0 || this.n >= this.slurAt + this.slurLen) return this.hz;
456
+ if (this.n < this.slurAt) return this.fromHz;
457
+ const k = (this.n - this.slurAt) / this.slurLen;
458
+ return this.fromHz * (this.hz / this.fromHz) ** k;
459
+ }
460
+
461
+ /** Loop delay for `hz`: bridge segment at beta, Thiran on the neck. */
462
+ private retune(hz: number, first = false): void {
463
+ const rate = this.rate;
464
+ const w0 = (2 * Math.PI * hz) / rate;
465
+ const total = (rate / hz) * (1 + this.lock) - onePoleDelay(this.sp, w0);
466
+ this.target = rate / hz;
467
+ // Band-pass (Q 3) at the target pitch for the lock's period detector.
468
+ const alpha = Math.sin(w0) / 6;
469
+ const a0 = 1 + alpha;
470
+ this.bpB = alpha / a0;
471
+ this.bpA1 = (-2 * Math.cos(w0)) / a0;
472
+ this.bpA2 = (1 - alpha) / a0;
473
+ if (first) {
474
+ this.beta = playableBeta(this.spec.pos, total, this.t60(hz));
475
+ this.Nb = Math.max(1, Math.round(this.beta * total));
476
+ } else if (this.slurAt >= 0 && this.n >= this.slurAt) {
477
+ if (this.slurLoss) {
478
+ this.sg = this.slurLoss.g;
479
+ this.sp = this.slurLoss.p;
480
+ this.slurLoss = undefined;
481
+ this.lock = 0;
482
+ }
483
+ const k = Math.min(1, (this.n - this.slurAt) / this.slurLen);
484
+ this.beta = this.fromBeta + (this.toBeta - this.fromBeta) * k;
485
+ this.Nb = Math.max(1, Math.round(this.beta * total));
486
+ }
487
+ const rest = total - this.Nb;
488
+ const Nn = Math.max(1, Math.floor(rest - 0.5));
489
+ const D = clamp(rest - Nn, 0.5, 1.5);
490
+ const a = thiranFor(D, w0);
491
+ if (!first && Nn !== this.Nn) {
492
+ const m = this.mask;
493
+ this.tx1 = this.nb[(this.nw - 1 - Nn) & m]!;
494
+ const pos = this.nw - 1 - Nn - D;
495
+ const i0 = Math.floor(pos);
496
+ const fr = pos - i0;
497
+ this.ty1 = this.nb[i0 & m]! * (1 - fr) + this.nb[(i0 + 1) & m]! * fr;
498
+ }
499
+ this.Nn = Nn;
500
+ this.a = a;
501
+ }
502
+
503
+ /** Reads the lanes and sets bow speed, slope, gain and tone. */
504
+ private control(t: number): void {
505
+ const spec = this.spec;
506
+ const c = this.note.control?.(t);
507
+ const pressure = c ? c.pressure : spec.pressure;
508
+ const speed = c ? c.speed : spec.speed;
509
+ const sord = clamp(c ? c.sord : spec.sord, 0, 1);
510
+ const d = clamp(this.note.velocity * (c ? c.dyn : 1), 0, 1);
511
+ const v =
512
+ (0.05 + 0.25 * clamp(speed, 0, 1)) * SPEED_RATIO ** (d - 1) * 0.9 + 0.005;
513
+ this.vtarget = v;
514
+ this.slope =
515
+ this.note.capacity !== undefined
516
+ ? 0.25 / (this.note.capacity * v)
517
+ : schellengSlope(pressure, this.beta, v, this.rate / this.hz);
518
+ // Amplitude at the bridge is proportional to v / beta: undo the beta.
519
+ this.gain =
520
+ 1.6 *
521
+ (this.beta / 0.12) *
522
+ 10 ** ((-GAIN_LAW_DB * (1 - d)) / 20) *
523
+ Math.min(1, d / 0.05) *
524
+ (1 - 0.45 * sord);
525
+ const fc = Math.min(
526
+ 0.45 * this.sr,
527
+ 1400 *
528
+ 2 ** (3 * d) *
529
+ (0.6 + 0.8 * clamp(pressure, 0, 1)) *
530
+ Math.sqrt(0.12 / this.beta) *
531
+ (1 - 0.7 * sord),
532
+ );
533
+ this.toneA = 1 - Math.exp((-2 * Math.PI * fc) / this.sr);
534
+ }
535
+
536
+ /** One internal-rate sample of the bridge velocity. */
537
+ private tick(attack: number, lift: number): number {
538
+ const { nb, bb, mask } = this;
539
+ const n = this.n;
540
+ const off = this.off;
541
+ const on = n < off;
542
+ const env = on
543
+ ? Math.min(1, (n + 1) / attack)
544
+ : Math.max(0, 1 - (n - off) / lift);
545
+ let dir = 1;
546
+ if (this.tremRate > 0) {
547
+ const s = Math.sin(
548
+ Math.PI * (this.tremRate * (n / this.rate) + this.tremPhase),
549
+ );
550
+ dir = Math.tanh(4 * s);
551
+ }
552
+ // Note-off lifts the bow: the normal force falls over `release` (the
553
+ // friction curve narrows, STK's bow pressure), with the bow still
554
+ // moving, so the string leaves Helmholtz motion swinging and rings
555
+ // freely (`ring`). Fading the contact instead would pass through a
556
+ // half-reflecting junction that brakes the string.
557
+ const bowVel = this.vtarget * (on ? env : 1) * dir;
558
+ const contact = on || env > 0 ? 1 : 0;
559
+ const slope = on ? this.slope : this.slope / Math.max(1e-3, env * env);
560
+ const bOut = bb[(this.bw - this.Nb) & mask]!;
561
+ const raw = nb[(this.nw - this.Nn) & mask]!;
562
+ const nOut = this.a * raw + this.tx1 - this.a * this.ty1;
563
+ this.tx1 = raw;
564
+ this.ty1 = nOut;
565
+ this.sy = this.sg * (1 - this.sp) * bOut + this.sp * this.sy;
566
+ const bridgeRefl = -this.sy;
567
+ const nutRefl = -nOut;
568
+ const dv = bowVel - (bridgeRefl + nutRefl);
569
+ let f = (Math.abs(dv * slope) + 0.75) ** -4;
570
+ if (f > 1) f = 1;
571
+ const nv = contact * dv * f;
572
+ nb[this.nw & mask] = bridgeRefl + nv;
573
+ bb[this.bw & mask] = nutRefl + nv;
574
+ this.nw += 1;
575
+ this.bw += 1;
576
+ if (on) this.track(bOut, n);
577
+ this.n += 1;
578
+ return bOut;
579
+ }
580
+
581
+ /** Pitch lock: one bridge sample into the period detector. */
582
+ private track(x: number, n: number): void {
583
+ const y =
584
+ this.bpB * (x - this.bpX2) -
585
+ this.bpA1 * this.bpY1 -
586
+ this.bpA2 * this.bpY2;
587
+ this.bpX2 = this.bpX1;
588
+ this.bpX1 = x;
589
+ const prev = this.bpY1;
590
+ this.bpY2 = prev;
591
+ this.bpY1 = y;
592
+ if (!(prev < 0 && y >= 0)) return;
593
+ const cross = n - 1 + prev / (prev - y);
594
+ const last = this.lastCross;
595
+ this.lastCross = cross;
596
+ // Settle first: the attack and a slur's glide are not steady periods.
597
+ if (last < 0 || n < 0.05 * this.rate) return;
598
+ if (this.slurAt >= 0 && n < this.slurAt + this.slurLen + 0.02 * this.rate)
599
+ return;
600
+ const period = cross - last;
601
+ const err = period / this.target - 1;
602
+ if (Math.abs(err) > 0.1) return;
603
+ this.lock = clamp(this.lock - LOCK_GAIN * err, -LOCK_RANGE, LOCK_RANGE);
604
+ }
605
+
606
+ /**
607
+ * Adds `count` samples x `gain` into out[offset..] (and x `gainR` into
608
+ * `right` when given); returns false once silent.
609
+ */
610
+ process(
611
+ out: Float64Array,
612
+ offset: number,
613
+ count: number,
614
+ gain = 1,
615
+ right?: Float64Array,
616
+ gainR = 0,
617
+ ): boolean {
618
+ if (this.done) return false;
619
+ const os = this.os;
620
+ const attack = Math.max(1, this.spec.attack * this.rate);
621
+ const lift = Math.max(1, this.spec.release * this.rate);
622
+ const decim = this.decim;
623
+ let peak = 0;
624
+ for (let i = 0; i < count; i += 1) {
625
+ const base = this.n / os;
626
+ if (base % CONTROL === 0) {
627
+ const t = base / this.sr;
628
+ this.control(t);
629
+ let cents = this.vibrato ? this.vibrato(this.n / this.rate) : 0;
630
+ if (this.cents)
631
+ cents += this.cents((this.n - this.centsAt) / this.rate);
632
+ this.retune(this.currentHz() * 2 ** (cents / 1200));
633
+ }
634
+ let y: number;
635
+ if (decim) {
636
+ const a = this.tick(attack, lift);
637
+ y = decim.process(a, this.tick(attack, lift));
638
+ } else y = this.tick(attack, lift);
639
+ y *= this.gain;
640
+ this.toneY += this.toneA * (y - this.toneY);
641
+ const s = this.toneY - this.dcX + this.dcR * this.dcY;
642
+ this.dcX = this.toneY;
643
+ this.dcY = s;
644
+ out[offset + i] = out[offset + i]! + s * gain;
645
+ if (right) right[offset + i] = right[offset + i]! + s * gainR;
646
+ const m = Math.abs(s);
647
+ if (m > peak) peak = m;
648
+ }
649
+ if (this.n > this.off + this.spec.release * this.rate && peak < 1e-5) {
650
+ this.quiet += count;
651
+ if (this.quiet >= 2048) this.done = true;
652
+ } else this.quiet = 0;
653
+ if (this.n >= this.maxSeconds * this.rate) this.done = true;
654
+ return !this.done;
655
+ }
656
+ }