@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,354 @@
1
+ /**
2
+ * Shoegaze stages (0.6.1): wobble, bloom, swell (mono, after the cab) and
3
+ * double (stereo, right after pan). Ported from the guitar design lane's
4
+ * prototype (proto/guitar/rig.ts wobbleCurve, addFeedback, applySwell,
5
+ * applyDouble).
6
+ *
7
+ * - wobble: a held tremolo arm (the "glide guitar" of Kevin Shields). One
8
+ * seeded curve per track in render time bends the whole voice sum
9
+ * together. The voice is read through a moving, non-causal delay whose
10
+ * slope is the pitch ratio, so a sine of peak `depth` cents is exact.
11
+ * - bloom: feedback. The highest note held past `delay` grows a partial at
12
+ * `harm` × f0 with a slow seeded drift of a few cents (one seed per
13
+ * note id), the way a loud amp makes the top note sing.
14
+ * - swell: a Slow Gear style volume swell; the gain resets at each onset
15
+ * cluster and rises over `time`.
16
+ * - double: automatic double tracking. A second take is the input delayed
17
+ * `time` ms with a seeded wander of ± `drift` ms and a darker tilt,
18
+ * spread against the dry take by `width`.
19
+ *
20
+ * Every random number comes from the track id (and note id), so a render
21
+ * is byte-identical on every run.
22
+ */
23
+ import type { FxValues } from "../../../core/fx.ts";
24
+ import type { Track } from "../../../core/score.ts";
25
+ import { seededRandom } from "../dsp/rng.ts";
26
+ import { OnePole } from "../dsp/filters.ts";
27
+ import {
28
+ CONTROL_SAMPLES,
29
+ clamp,
30
+ fxReader,
31
+ type EffectContext,
32
+ type EffectNote,
33
+ } from "./common.ts";
34
+
35
+ const CENTS_TO_LOG = Math.LN2 / 1200;
36
+
37
+ /** Catmull-Rom (4-point Hermite) read of `x` at a fractional position. */
38
+ function readAt(x: Float64Array, position: number): number {
39
+ const last = x.length - 1;
40
+ if (last < 0) return 0;
41
+ const p = clamp(position, 0, last);
42
+ const i = Math.floor(p);
43
+ const t = p - i;
44
+ const a = x[Math.max(0, i - 1)]!;
45
+ const b = x[i]!;
46
+ const c = x[Math.min(last, i + 1)]!;
47
+ const d = x[Math.min(last, i + 2)]!;
48
+ const c1 = 0.5 * (c - a);
49
+ const c2 = a - 2.5 * b + 2 * c - 0.5 * d;
50
+ const c3 = 0.5 * (d - a) + 1.5 * (b - c);
51
+ return ((c3 * t + c2) * t + c1) * t + b;
52
+ }
53
+
54
+ export type WobbleShape = Readonly<{
55
+ /** Read offset in seconds at time t (positive: earlier audio). */
56
+ offset: (t: number) => number;
57
+ /** Pitch deviation in cents at time t. */
58
+ cents: (t: number) => number;
59
+ /** Largest possible |offset| in seconds (the components' summed amplitude). */
60
+ span: number;
61
+ }>;
62
+
63
+ /**
64
+ * Most a wobble moves a note in time, in seconds. Slow, deep settings
65
+ * (100 c at 0.05 Hz) would otherwise drag strums 170 ms off the grid; past
66
+ * this bound the depth gives way instead.
67
+ */
68
+ export const WOBBLE_MAX_OFFSET = 0.015;
69
+
70
+ /**
71
+ * The track's wobble curve: three seeded sines (the first at `rate`, the
72
+ * others off-ratio) weighted by `drift`. Each component's delay amplitude
73
+ * is chosen so its pitch contribution peaks at its share of `depth`.
74
+ */
75
+ export function wobbleShape(
76
+ depth: number,
77
+ rate: number,
78
+ drift: number,
79
+ seed: string,
80
+ ): WobbleShape {
81
+ const r = seededRandom(`wobble:${seed}`);
82
+ const phase = [r(), r(), r()].map((x) => x * 2 * Math.PI);
83
+ const ratio = [1, 0.61 + 0.1 * r(), 1.53 + 0.2 * r()];
84
+ const d = clamp(drift, 0, 1);
85
+ const weight = [1 - d, 0.6 * d, 0.4 * d];
86
+ const norm = weight[0]! + weight[1]! + weight[2]!;
87
+ const hz = ratio.map((k) => Math.max(0.01, rate) * k);
88
+ // Delay amplitude (s) whose derivative peaks at the component's cents.
89
+ const amp = hz.map(
90
+ (f, k) => ((weight[k]! / norm) * depth * CENTS_TO_LOG) / (2 * Math.PI * f),
91
+ );
92
+ return {
93
+ span: amp[0]! + amp[1]! + amp[2]!,
94
+ // Reading at t + offset(t): ratio = 1 + offset'(t) ≈ 2^(cents/1200).
95
+ offset: (t) => {
96
+ let s = 0;
97
+ for (let k = 0; k < 3; k += 1)
98
+ s += amp[k]! * Math.sin(2 * Math.PI * hz[k]! * t + phase[k]!);
99
+ return s;
100
+ },
101
+ cents: (t) => {
102
+ let s = 0;
103
+ for (let k = 0; k < 3; k += 1)
104
+ s +=
105
+ amp[k]! *
106
+ 2 *
107
+ Math.PI *
108
+ hz[k]! *
109
+ Math.cos(2 * Math.PI * hz[k]! * t + phase[k]!);
110
+ return s / CENTS_TO_LOG;
111
+ },
112
+ };
113
+ }
114
+
115
+ export function applyWobble(
116
+ buffer: Float64Array,
117
+ track: Track,
118
+ values: FxValues,
119
+ context: EffectContext,
120
+ ): void {
121
+ const read = fxReader(track, "wobble", values, context);
122
+ const depth = read.param("depth");
123
+ const rate = read.number("rate");
124
+ const drift = read.number("drift");
125
+ const sr = context.sampleRate;
126
+ const peak = depth.automated ? 100 : depth.at(0);
127
+ if (peak <= 0 || buffer.length === 0) return;
128
+ // Unit-depth shape, scaled by the (possibly automated) depth.
129
+ const shape = wobbleShape(1, rate, drift, track.id);
130
+ // Depth that keeps every read within WOBBLE_MAX_OFFSET of the grid.
131
+ const most = shape.span > 0 ? WOBBLE_MAX_OFFSET / shape.span : Infinity;
132
+ const source = buffer.slice();
133
+ let scale = Math.min(most, depth.at(0));
134
+ for (let index = 0; index < buffer.length; index += 1) {
135
+ if (depth.automated && index % CONTROL_SAMPLES === 0)
136
+ scale = Math.min(most, depth.at(index));
137
+ const t = index / sr;
138
+ buffer[index] = readAt(source, index + scale * shape.offset(t) * sr);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Feedback bloom. Only the highest note sounding at once feeds back (one
144
+ * dominant loop), and only once it has been held past `delay`. One sweep:
145
+ * the dominant note is found per 32-sample block from the sorted note
146
+ * starts and a small active set, and a hand-over crossfades 20 ms, so the
147
+ * cost is O(samples + notes log notes) even on a dense strum.
148
+ *
149
+ * The partial feeds back from the signal itself: its level follows an
150
+ * envelope of the incoming buffer (50 ms attack, `time` release), so a
151
+ * quiet, faded or muted track blooms quietly or not at all, and the
152
+ * partial never sits more than about -6 dB under the input's peak.
153
+ */
154
+ export function applyBloom(
155
+ buffer: Float64Array,
156
+ track: Track,
157
+ values: FxValues,
158
+ context: EffectContext,
159
+ ): void {
160
+ const notes = context.notes;
161
+ if (!notes || notes.length === 0) return;
162
+ const read = fxReader(track, "bloom", values, context);
163
+ const amount = read.number("amount");
164
+ if (amount <= 0) return;
165
+ const harm = read.number("harm");
166
+ const sr = context.sampleRate;
167
+ const delay = Math.round(read.number("delay") * sr);
168
+ const grow = Math.max(0.05, read.number("time"));
169
+ const release = Math.round(0.08 * sr);
170
+ const fade = Math.max(1, Math.round(0.02 * sr));
171
+ // Envelope (peak-equivalent of a smoothed RMS) of the input.
172
+ const follow = new Float64Array(buffer.length);
173
+ const up = 1 - Math.exp(-1 / (0.05 * sr));
174
+ const down = 1 - Math.exp(-1 / (Math.max(0.1, grow) * sr));
175
+ let power = 0;
176
+ for (let index = 0; index < buffer.length; index += 1) {
177
+ const x2 = buffer[index]! * buffer[index]!;
178
+ power += (x2 > power ? up : down) * (x2 - power);
179
+ follow[index] = Math.sqrt(2 * power);
180
+ }
181
+ const sorted = notes
182
+ .filter((note) => note.length > delay)
183
+ .sort((x, y) => x.start - y.start || y.hz - x.hz);
184
+ type Loop = {
185
+ note: EffectNote;
186
+ phase: number;
187
+ driftHz: number;
188
+ level: number;
189
+ /** Hand-over gain 0..1 and its direction. */
190
+ gain: number;
191
+ rising: boolean;
192
+ };
193
+ const loops: Loop[] = [];
194
+ const active: EffectNote[] = [];
195
+ let next = 0;
196
+ let current: EffectNote | undefined;
197
+ for (let block = 0; block < buffer.length; block += BLOCK) {
198
+ while (next < sorted.length && sorted[next]!.start <= block)
199
+ active.push(sorted[next++]!);
200
+ for (let k = active.length - 1; k >= 0; k -= 1)
201
+ if (active[k]!.start + active[k]!.length <= block) active.splice(k, 1);
202
+ let top: EffectNote | undefined;
203
+ for (const note of active)
204
+ if (note.start + delay <= block && (!top || note.hz > top.hz)) top = note;
205
+ if (top !== current) {
206
+ for (const loop of loops) if (loop.note !== top) loop.rising = false;
207
+ if (top && !loops.some((loop) => loop.note === top)) {
208
+ const r = seededRandom(`bloom:${track.id}:${top.id}`);
209
+ loops.push({
210
+ note: top,
211
+ phase: r() * 2 * Math.PI,
212
+ driftHz: 0.2 + 0.3 * r(),
213
+ // Relative to the input envelope; at most -6 dB under it.
214
+ level: Math.min(0.5, 0.6 * amount * (0.5 + 0.5 * top.velocity)),
215
+ gain: current ? 0 : 1,
216
+ rising: true,
217
+ });
218
+ }
219
+ current = top;
220
+ }
221
+ const end = Math.min(buffer.length, block + BLOCK);
222
+ for (let k = loops.length - 1; k >= 0; k -= 1) {
223
+ const loop = loops[k]!;
224
+ const from = loop.note.start + delay;
225
+ const off = loop.note.start + loop.note.length;
226
+ for (let index = block; index < end; index += 1) {
227
+ const t = (index - from) / sr;
228
+ const cents = 3 * Math.sin(2 * Math.PI * loop.driftHz * t);
229
+ loop.phase +=
230
+ (2 * Math.PI * harm * loop.note.hz * 2 ** (cents / 1200)) / sr;
231
+ loop.gain = loop.rising
232
+ ? Math.min(1, loop.gain + 1 / fade)
233
+ : Math.max(0, loop.gain - 1 / fade);
234
+ let env =
235
+ follow[index]! * loop.level * loop.gain * (1 - Math.exp(-t / grow));
236
+ if (index > off) env *= Math.max(0, 1 - (index - off) / release);
237
+ buffer[index]! += env * Math.sin(loop.phase);
238
+ }
239
+ if (loop.gain <= 0 || end > off + release) {
240
+ loops.splice(k, 1);
241
+ if (current === loop.note) current = undefined;
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Control block of the bloom sweep, in samples. */
248
+ const BLOCK = 32;
249
+
250
+ /** Onset clusters (notes within 40 ms of the cluster's first), sorted. */
251
+ function onsets(notes: readonly EffectNote[], sr: number): number[] {
252
+ const gap = 0.04 * sr;
253
+ const out: number[] = [];
254
+ for (const start of notes.map((note) => note.start).sort((a, b) => a - b))
255
+ if (out.length === 0 || start - out[out.length - 1]! > gap) out.push(start);
256
+ return out;
257
+ }
258
+
259
+ export function applySwell(
260
+ buffer: Float64Array,
261
+ track: Track,
262
+ values: FxValues,
263
+ context: EffectContext,
264
+ ): void {
265
+ const notes = context.notes;
266
+ if (!notes || notes.length === 0) return;
267
+ const read = fxReader(track, "swell", values, context);
268
+ const mix = read.param("mix");
269
+ const sr = context.sampleRate;
270
+ // Reaches about -0.4 dB at `time`.
271
+ const tau = (Math.max(0.01, read.number("time")) / 3) * sr;
272
+ // A new onset over a ringing chord first ducks the gain to 0 over at
273
+ // most `duck` (scaled by how high it was), then rises again: no click.
274
+ const duck = 0.006 * sr;
275
+ const starts = onsets(notes, sr);
276
+ let next = 0;
277
+ let last = Number.NEGATIVE_INFINITY;
278
+ let g = 1;
279
+ let from = 0;
280
+ let fall = 0;
281
+ let wet = mix.at(0);
282
+ for (let index = 0; index < buffer.length; index += 1) {
283
+ if (mix.automated && index % CONTROL_SAMPLES === 0) wet = mix.at(index);
284
+ while (next < starts.length && starts[next]! <= index) {
285
+ // Before the first onset nothing sounds: rise from silence.
286
+ from = last === Number.NEGATIVE_INFINITY ? 0 : g;
287
+ fall = Math.round(duck * from);
288
+ last = starts[next++]!;
289
+ }
290
+ if (last !== Number.NEGATIVE_INFINITY) {
291
+ const elapsed = index - last;
292
+ g =
293
+ elapsed < fall
294
+ ? from * (1 - elapsed / fall)
295
+ : 1 - Math.exp(-(elapsed - fall) / tau);
296
+ }
297
+ buffer[index] = buffer[index]! * (1 - wet + wet * g * g);
298
+ }
299
+ }
300
+
301
+ /** Seeded wandering delay of one channel, read non-causally from `x`. */
302
+ function secondTake(
303
+ x: Float64Array,
304
+ time: number,
305
+ drift: number,
306
+ seed: string,
307
+ sr: number,
308
+ ): Float64Array {
309
+ const r = seededRandom(`double:${seed}`);
310
+ const ph1 = r() * 2 * Math.PI;
311
+ const ph2 = r() * 2 * Math.PI;
312
+ const f1 = 0.35 + 0.3 * r();
313
+ const f2 = 0.11 + 0.1 * r();
314
+ const tilt = new OnePole(5500, sr);
315
+ const out = new Float64Array(x.length);
316
+ let d = (time / 1000) * sr;
317
+ for (let index = 0; index < x.length; index += 1) {
318
+ if (index % CONTROL_SAMPLES === 0) {
319
+ const t = index / sr;
320
+ const wander =
321
+ 0.65 * Math.sin(2 * Math.PI * f1 * t + ph1) +
322
+ 0.35 * Math.sin(2 * Math.PI * f2 * t + ph2);
323
+ d = ((time + drift * wander) / 1000) * sr;
324
+ }
325
+ const position = index - d;
326
+ out[index] = tilt.process(position < 0 ? 0 : readAt(x, position));
327
+ }
328
+ return out;
329
+ }
330
+
331
+ export function applyDouble(
332
+ left: Float64Array,
333
+ right: Float64Array,
334
+ track: Track,
335
+ values: FxValues,
336
+ context: EffectContext,
337
+ ): void {
338
+ const read = fxReader(track, "double", values, context);
339
+ const time = read.number("time");
340
+ const drift = read.number("drift");
341
+ const width = clamp(read.number("width"), 0, 1);
342
+ const sr = context.sampleRate;
343
+ const copyL = secondTake(left, time, drift, track.id, sr);
344
+ const copyR = secondTake(right, time, drift, track.id, sr);
345
+ for (let index = 0; index < left.length; index += 1) {
346
+ const l = left[index]!;
347
+ const r = right[index]!;
348
+ const cl = copyL[index]!;
349
+ const cr = copyR[index]!;
350
+ // width 1: the dry take left, the second take right; 0: both centred.
351
+ left[index] = 0.5 * (l + cl) + 0.5 * width * (l - cl);
352
+ right[index] = 0.5 * (r + cr) - 0.5 * width * (r - cr);
353
+ }
354
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Speaker cabinets (`fx.cab`): a parametric speaker, box and microphone
3
+ * model from cheap biquads (no impulse response, no setup cost). Ported
4
+ * from the 0.6 guitar prototype; `8x10`, `1x15` and `di` are dawg
5
+ * additions in the same model.
6
+ */
7
+ import { Rbj, clamp } from "./filters.ts";
8
+
9
+ export const CAB_TYPES = [
10
+ "1x12",
11
+ "2x12",
12
+ "4x12",
13
+ "1x10",
14
+ "open",
15
+ "8x10",
16
+ "1x15",
17
+ "di",
18
+ ] as const;
19
+ export type CabType = (typeof CAB_TYPES)[number];
20
+
21
+ type CabModel = Readonly<{
22
+ hp: number;
23
+ /** Low resonance: Hz, Q, dB. */
24
+ low: readonly [number, number, number];
25
+ peaks: readonly (readonly [number, number, number])[];
26
+ /** Speaker roll-off (two biquads, Butterworth 4th order); 0 = none. */
27
+ lp: number;
28
+ }>;
29
+
30
+ const CABS: Record<CabType, CabModel> = {
31
+ "1x12": {
32
+ hp: 75,
33
+ low: [115, 1.4, 3],
34
+ peaks: [
35
+ [2100, 2, 3],
36
+ [3600, 3, 2],
37
+ ],
38
+ lp: 5200,
39
+ },
40
+ "2x12": {
41
+ hp: 70,
42
+ low: [105, 1.3, 3.5],
43
+ peaks: [
44
+ [1200, 1.5, -2],
45
+ [2800, 2.2, 4.5],
46
+ ],
47
+ lp: 6200,
48
+ },
49
+ "4x12": {
50
+ hp: 65,
51
+ low: [95, 1.2, 5],
52
+ peaks: [
53
+ [700, 1, -2.5],
54
+ [2400, 2, 4],
55
+ [3900, 3, 2],
56
+ ],
57
+ lp: 4700,
58
+ },
59
+ "1x10": { hp: 120, low: [160, 1.5, 2], peaks: [[1500, 1.5, 3]], lp: 4300 },
60
+ open: { hp: 90, low: [120, 0.9, 1], peaks: [[2500, 1.2, 2]], lp: 7000 },
61
+ // Bass cabinets: deep, rolled off early (10s brighter than a 15).
62
+ "8x10": {
63
+ hp: 45,
64
+ low: [80, 1.0, 3],
65
+ peaks: [
66
+ [800, 1.2, -2],
67
+ [2500, 2, 3],
68
+ ],
69
+ lp: 4500,
70
+ },
71
+ "1x15": {
72
+ hp: 40,
73
+ low: [70, 1.2, 4],
74
+ peaks: [
75
+ [1000, 1, -1.5],
76
+ [2000, 1.5, 2],
77
+ ],
78
+ lp: 3500,
79
+ },
80
+ // Direct in: no speaker, only a subsonic high-pass.
81
+ di: { hp: 20, low: [100, 0.7, 0], peaks: [], lp: 0 },
82
+ };
83
+
84
+ /** The cab's filter chain at `sr` for mic position `mic` (0..1). */
85
+ export function cabFilters(type: string, mic: number, sr: number): Rbj[] {
86
+ const m = CABS[type as CabType] ?? CABS["2x12"];
87
+ if (m.lp === 0) return [new Rbj("hpf", m.hp, 0.707, sr)];
88
+ const edge = clamp(mic, 0, 1);
89
+ return [
90
+ new Rbj("hpf", m.hp, 0.707, sr),
91
+ new Rbj("peak", m.low[0], m.low[1], sr, m.low[2]),
92
+ ...m.peaks.map(
93
+ ([f, q, db]) => new Rbj("peak", f, q, sr, db * (1 - 0.6 * edge)),
94
+ ),
95
+ new Rbj("highshelf", 3000, 0.7, sr, 2 - 8 * edge),
96
+ new Rbj("lpf", m.lp * (1 - 0.3 * edge), 0.54, sr),
97
+ new Rbj("lpf", m.lp * (1 - 0.3 * edge), 1.31, sr),
98
+ ];
99
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Filters for the guitar rig: the full RBJ cookbook biquad (peaks and
3
+ * shelves, which the effects chain's `Biquad` does not offer) and a
4
+ * one-pole high-pass. Ported from the 0.6 guitar prototype; a new class so
5
+ * the shared `Biquad` is never retuned.
6
+ */
7
+
8
+ export function clamp(x: number, lo: number, hi: number): number {
9
+ return x < lo ? lo : x > hi ? hi : x;
10
+ }
11
+
12
+ export function dbToGain(db: number): number {
13
+ return 10 ** (db / 20);
14
+ }
15
+
16
+ export type RbjType = "lpf" | "hpf" | "peak" | "lowshelf" | "highshelf";
17
+
18
+ /** RBJ Audio EQ Cookbook biquad, transposed direct form II. */
19
+ export class Rbj {
20
+ b0 = 1;
21
+ b1 = 0;
22
+ b2 = 0;
23
+ a1 = 0;
24
+ a2 = 0;
25
+ z1 = 0;
26
+ z2 = 0;
27
+
28
+ constructor(type: RbjType, hz: number, q: number, sr: number, db = 0) {
29
+ this.set(type, hz, q, sr, db);
30
+ }
31
+
32
+ set(type: RbjType, hz: number, q: number, sr: number, db = 0): void {
33
+ const w = (2 * Math.PI * clamp(hz, 10, sr * 0.49)) / sr;
34
+ const cw = Math.cos(w);
35
+ const alpha = Math.sin(w) / (2 * q);
36
+ const A = 10 ** (db / 40);
37
+ let b0: number, b1: number, b2: number, a0: number, a1: number, a2: number;
38
+ switch (type) {
39
+ case "lpf":
40
+ b0 = (1 - cw) / 2;
41
+ b1 = 1 - cw;
42
+ b2 = b0;
43
+ a0 = 1 + alpha;
44
+ a1 = -2 * cw;
45
+ a2 = 1 - alpha;
46
+ break;
47
+ case "hpf":
48
+ b0 = (1 + cw) / 2;
49
+ b1 = -(1 + cw);
50
+ b2 = b0;
51
+ a0 = 1 + alpha;
52
+ a1 = -2 * cw;
53
+ a2 = 1 - alpha;
54
+ break;
55
+ case "peak":
56
+ b0 = 1 + alpha * A;
57
+ b1 = -2 * cw;
58
+ b2 = 1 - alpha * A;
59
+ a0 = 1 + alpha / A;
60
+ a1 = -2 * cw;
61
+ a2 = 1 - alpha / A;
62
+ break;
63
+ case "lowshelf": {
64
+ const s = 2 * Math.sqrt(A) * alpha;
65
+ b0 = A * (A + 1 - (A - 1) * cw + s);
66
+ b1 = 2 * A * (A - 1 - (A + 1) * cw);
67
+ b2 = A * (A + 1 - (A - 1) * cw - s);
68
+ a0 = A + 1 + (A - 1) * cw + s;
69
+ a1 = -2 * (A - 1 + (A + 1) * cw);
70
+ a2 = A + 1 + (A - 1) * cw - s;
71
+ break;
72
+ }
73
+ case "highshelf": {
74
+ const s = 2 * Math.sqrt(A) * alpha;
75
+ b0 = A * (A + 1 + (A - 1) * cw + s);
76
+ b1 = -2 * A * (A - 1 + (A + 1) * cw);
77
+ b2 = A * (A + 1 + (A - 1) * cw - s);
78
+ a0 = A + 1 - (A - 1) * cw + s;
79
+ a1 = 2 * (A - 1 - (A + 1) * cw);
80
+ a2 = A + 1 - (A - 1) * cw - s;
81
+ break;
82
+ }
83
+ }
84
+ this.b0 = b0 / a0;
85
+ this.b1 = b1 / a0;
86
+ this.b2 = b2 / a0;
87
+ this.a1 = a1 / a0;
88
+ this.a2 = a2 / a0;
89
+ }
90
+
91
+ process(x: number): number {
92
+ const y = this.b0 * x + this.z1;
93
+ this.z1 = this.b1 * x - this.a1 * y + this.z2;
94
+ this.z2 = this.b2 * x - this.a2 * y;
95
+ return y;
96
+ }
97
+ }
98
+
99
+ /** One-pole low-pass y = x + p (y - x). */
100
+ export class Lp1 {
101
+ p = 0;
102
+ y = 0;
103
+
104
+ constructor(hz: number, sr: number) {
105
+ this.set(hz, sr);
106
+ }
107
+
108
+ set(hz: number, sr: number): void {
109
+ this.p = Math.exp((-2 * Math.PI * clamp(hz, 1, sr * 0.49)) / sr);
110
+ }
111
+
112
+ process(x: number): number {
113
+ this.y = x + this.p * (this.y - x);
114
+ return this.y;
115
+ }
116
+ }
117
+
118
+ /** One-pole high-pass (leaky differentiator). */
119
+ export class Hp1 {
120
+ p = 0;
121
+ x1 = 0;
122
+ y1 = 0;
123
+
124
+ constructor(hz: number, sr: number) {
125
+ this.p = Math.exp((-2 * Math.PI * clamp(hz, 1, sr * 0.49)) / sr);
126
+ }
127
+
128
+ process(x: number): number {
129
+ const y = ((1 + this.p) / 2) * (x - this.x1) + this.p * this.y1;
130
+ this.x1 = x;
131
+ this.y1 = y;
132
+ return y;
133
+ }
134
+ }
135
+
136
+ /** DC blocker y = x - x1 + R y1. */
137
+ export class Dc {
138
+ r: number;
139
+ x1 = 0;
140
+ y1 = 0;
141
+
142
+ constructor(sr: number, hz: number) {
143
+ this.r = Math.exp((-2 * Math.PI * hz) / sr);
144
+ }
145
+
146
+ process(x: number): number {
147
+ const y = x - this.x1 + this.r * this.y1;
148
+ this.x1 = x;
149
+ this.y1 = y;
150
+ return y;
151
+ }
152
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Noise gate before the amp (`fx.head.gate`): a peak follower with
3
+ * hysteresis (opens at the threshold, closes 6 dB below it), a 50 ms hold,
4
+ * 1 ms attack and 80 ms release, so a high-gain head does not amplify the
5
+ * hiss and string noise between notes.
6
+ */
7
+ export const GATE_HYSTERESIS_DB = 6;
8
+
9
+ export function applyGate(
10
+ buffer: Float64Array,
11
+ thresholdDb: number,
12
+ sampleRate: number,
13
+ ): void {
14
+ const open = 10 ** (thresholdDb / 20);
15
+ const close = 10 ** ((thresholdDb - GATE_HYSTERESIS_DB) / 20);
16
+ const peakRelease = Math.exp(-1 / (0.01 * sampleRate));
17
+ const attack = 1 - Math.exp(-1 / (0.001 * sampleRate));
18
+ const release = 1 - Math.exp(-1 / (0.08 * sampleRate));
19
+ const holdSamples = Math.round(0.05 * sampleRate);
20
+ let peak = 0;
21
+ let gain = 0;
22
+ let isOpen = false;
23
+ let hold = 0;
24
+ for (let i = 0; i < buffer.length; i += 1) {
25
+ const x = buffer[i]!;
26
+ const level = Math.abs(x);
27
+ peak = level > peak ? level : peak * peakRelease;
28
+ if (peak >= open) {
29
+ isOpen = true;
30
+ hold = holdSamples;
31
+ } else if (peak < close) {
32
+ if (hold > 0) hold -= 1;
33
+ else isOpen = false;
34
+ }
35
+ const target = isOpen ? 1 : 0;
36
+ gain += (target - gain) * (target > gain ? attack : release);
37
+ buffer[i] = x * gain;
38
+ }
39
+ }