@hraness/dawg 0.3.0 → 0.4.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 (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Convolution reverb for `reverb.ir` (Strudel `iresponse`/`ir`): the track's
3
+ * stereo pair, summed to mono, is convolved with a stereo impulse response
4
+ * by uniformly partitioned overlap-save FFT convolution.
5
+ *
6
+ * The impulse is cut into P blocks of B samples; each block's spectrum is
7
+ * kept once. Every input block is transformed once, multiplied with the
8
+ * last P input spectra (a frequency-domain delay line) and transformed back,
9
+ * so the cost per sample is O(P + log B) instead of O(IR length). Left and
10
+ * right impulses share one transform: G = FFT(hL + i·hR), and because the
11
+ * input is real, Re(IFFT(X·G)) is the left output and Im(…) the right.
12
+ *
13
+ * Built-in impulses (`builtin:room`, `builtin:hall`, `builtin:plate`) are
14
+ * generated from a seeded integer PRNG, so they need no files and are
15
+ * identical on every render path. Everything here is plain float64
16
+ * arithmetic over fixed loops: deterministic. Clean-room, from standard DSP
17
+ * literature (Gardner 1995; Wefers 2015), not from any Strudel source.
18
+ */
19
+ import { seededRandom } from "../random.ts";
20
+
21
+ /** Longest impulse a render uses, in seconds; longer files are cut. */
22
+ export const MAX_IMPULSE_SECONDS = 10;
23
+
24
+ /** A stereo impulse response at the render rate, energy-normalized. */
25
+ export type Impulse = Readonly<{
26
+ /** Stable identity for stem cache keys. */
27
+ id: string;
28
+ left: Float64Array;
29
+ right: Float64Array;
30
+ }>;
31
+
32
+ type Twiddles = { cos: Float64Array; sin: Float64Array; rev: Uint32Array };
33
+ const TWIDDLES = new Map<number, Twiddles>();
34
+
35
+ function twiddles(n: number): Twiddles {
36
+ let table = TWIDDLES.get(n);
37
+ if (table) return table;
38
+ const cos = new Float64Array(n >> 1);
39
+ const sin = new Float64Array(n >> 1);
40
+ for (let k = 0; k < n >> 1; k += 1) {
41
+ cos[k] = Math.cos((2 * Math.PI * k) / n);
42
+ sin[k] = Math.sin((2 * Math.PI * k) / n);
43
+ }
44
+ const rev = new Uint32Array(n);
45
+ for (let i = 1, j = 0; i < n; i += 1) {
46
+ let bit = n >> 1;
47
+ for (; j & bit; bit >>= 1) j ^= bit;
48
+ j ^= bit;
49
+ rev[i] = j;
50
+ }
51
+ table = { cos, sin, rev };
52
+ TWIDDLES.set(n, table);
53
+ return table;
54
+ }
55
+
56
+ /**
57
+ * In-place radix-2 complex FFT with cached tables. `inverse` uses e^{+i}
58
+ * and no scaling (inverse(forward(x)) = n·x).
59
+ */
60
+ export function fftInPlace(
61
+ re: Float64Array,
62
+ im: Float64Array,
63
+ inverse = false,
64
+ ): void {
65
+ const n = re.length;
66
+ const { cos, sin, rev } = twiddles(n);
67
+ for (let i = 1; i < n; i += 1) {
68
+ const j = rev[i]!;
69
+ if (i < j) {
70
+ const tr = re[i]!;
71
+ re[i] = re[j]!;
72
+ re[j] = tr;
73
+ const ti = im[i]!;
74
+ im[i] = im[j]!;
75
+ im[j] = ti;
76
+ }
77
+ }
78
+ const sign = inverse ? 1 : -1;
79
+ for (let length = 2; length <= n; length <<= 1) {
80
+ const half = length >> 1;
81
+ const stride = n / length;
82
+ for (let k = 0; k < half; k += 1) {
83
+ const wr = cos[k * stride]!;
84
+ const wi = sign * sin[k * stride]!;
85
+ for (let a = k; a < n; a += length) {
86
+ const b = a + half;
87
+ const xr = re[b]! * wr - im[b]! * wi;
88
+ const xi = re[b]! * wi + im[b]! * wr;
89
+ re[b] = re[a]! - xr;
90
+ im[b] = im[a]! - xi;
91
+ re[a]! += xr;
92
+ im[a]! += xi;
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ function nextPow2(value: number): number {
99
+ let n = 1;
100
+ while (n < value) n <<= 1;
101
+ return n;
102
+ }
103
+
104
+ /** Partition size for an impulse: large blocks offline, a few partitions. */
105
+ export function partitionSize(impulseLength: number): number {
106
+ return Math.min(32_768, Math.max(512, nextPow2(impulseLength) >> 3));
107
+ }
108
+
109
+ /**
110
+ * Adds `gain`·(input ∗ impulse) to `outL`/`outR` (same length as `input`;
111
+ * the tail past the end is dropped, as with every other effect).
112
+ * `gainAt`, when given, is sampled per output sample instead of `gain`.
113
+ */
114
+ export function convolveStereo(
115
+ input: Float64Array,
116
+ impulse: Impulse,
117
+ outL: Float64Array,
118
+ outR: Float64Array,
119
+ gain: number,
120
+ gainAt?: (index: number) => number,
121
+ ): void {
122
+ const rawL = new Float64Array(input.length);
123
+ const rawR = new Float64Array(input.length);
124
+ const scale = convolveRaw(input, impulse, rawL, rawR);
125
+ if (scale === 0) return;
126
+ addConvolved(rawL, rawR, scale, outL, outR, gain, gainAt);
127
+ }
128
+
129
+ /**
130
+ * Adds the unscaled convolution from `convolveRaw` at `gain` (or `gainAt`
131
+ * per sample): `out += raw · (gain · scale)`, the order `convolveStereo`
132
+ * always used, so a cached raw signal mixes to the same samples.
133
+ */
134
+ export function addConvolved(
135
+ rawL: Float64Array,
136
+ rawR: Float64Array,
137
+ scale: number,
138
+ outL: Float64Array,
139
+ outR: Float64Array,
140
+ gain: number,
141
+ gainAt?: (index: number) => number,
142
+ ): void {
143
+ for (let index = 0; index < rawL.length; index += 1) {
144
+ const g = (gainAt ? gainAt(index) : gain) * scale;
145
+ outL[index]! += rawL[index]! * g;
146
+ outR[index]! += rawR[index]! * g;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The convolution input ∗ impulse into `rawL`/`rawR` before its inverse-FFT
152
+ * scale, which it returns. Samples past the input are left as they were
153
+ * (zero for fresh arrays), as is everything when the impulse is empty.
154
+ */
155
+ export function convolveRaw(
156
+ input: Float64Array,
157
+ impulse: Impulse,
158
+ rawL: Float64Array,
159
+ rawR: Float64Array,
160
+ ): number {
161
+ const length = input.length;
162
+ const irLength = impulse.left.length;
163
+ if (length === 0 || irLength === 0) return 0;
164
+ const block = partitionSize(irLength);
165
+ const n = block * 2;
166
+ const partitions = Math.ceil(irLength / block);
167
+ // Impulse partition spectra G_p = FFT(hL_p + i·hR_p), zero-padded to n.
168
+ const gRe: Float64Array[] = [];
169
+ const gIm: Float64Array[] = [];
170
+ for (let p = 0; p < partitions; p += 1) {
171
+ const re = new Float64Array(n);
172
+ const im = new Float64Array(n);
173
+ const start = p * block;
174
+ const end = Math.min(irLength, start + block);
175
+ for (let i = start; i < end; i += 1) {
176
+ re[i - start] = impulse.left[i]!;
177
+ im[i - start] = impulse.right[i]!;
178
+ }
179
+ fftInPlace(re, im);
180
+ gRe.push(re);
181
+ gIm.push(im);
182
+ }
183
+ // Frequency-domain delay line of input spectra (ring of `partitions`).
184
+ const xRe = Array.from({ length: partitions }, () => new Float64Array(n));
185
+ const xIm = Array.from({ length: partitions }, () => new Float64Array(n));
186
+ const accRe = new Float64Array(n);
187
+ const accIm = new Float64Array(n);
188
+ const blocks = Math.ceil(length / block);
189
+ const scale = 1 / n;
190
+ for (let k = 0; k < blocks; k += 1) {
191
+ const slot = k % partitions;
192
+ const re = xRe[slot]!;
193
+ const im = xIm[slot]!;
194
+ // Window: previous block then this block.
195
+ const base = (k - 1) * block;
196
+ for (let i = 0; i < n; i += 1) {
197
+ const at = base + i;
198
+ re[i] = at >= 0 && at < length ? input[at]! : 0;
199
+ im[i] = 0;
200
+ }
201
+ fftInPlace(re, im);
202
+ accRe.fill(0);
203
+ accIm.fill(0);
204
+ for (let p = 0; p < partitions && p <= k; p += 1) {
205
+ const source = (slot - p + partitions) % partitions;
206
+ const ar = xRe[source]!;
207
+ const ai = xIm[source]!;
208
+ const br = gRe[p]!;
209
+ const bi = gIm[p]!;
210
+ for (let i = 0; i < n; i += 1) {
211
+ const r = ar[i]!;
212
+ const m = ai[i]!;
213
+ accRe[i]! += r * br[i]! - m * bi[i]!;
214
+ accIm[i]! += r * bi[i]! + m * br[i]!;
215
+ }
216
+ }
217
+ fftInPlace(accRe, accIm, true);
218
+ const outStart = k * block;
219
+ const count = Math.min(block, length - outStart);
220
+ for (let i = 0; i < count; i += 1) {
221
+ rawL[outStart + i] = accRe[block + i]!;
222
+ rawR[outStart + i] = accIm[block + i]!;
223
+ }
224
+ }
225
+ return scale;
226
+ }
227
+
228
+ /** Built-in impulse names (`builtin:<name>`). */
229
+ export const BUILTIN_IMPULSES = Object.freeze({
230
+ room: { title: "small room", seconds: 0.7, damp: 6000, early: 7, attack: 0 },
231
+ hall: {
232
+ title: "concert hall",
233
+ seconds: 2.8,
234
+ damp: 4500,
235
+ early: 5,
236
+ attack: 0.06,
237
+ },
238
+ plate: { title: "plate", seconds: 1.8, damp: 9000, early: 0, attack: 0 },
239
+ } as const);
240
+ export type BuiltinImpulse = keyof typeof BUILTIN_IMPULSES;
241
+
242
+ const BUILTIN_CACHE = new Map<string, Impulse>();
243
+
244
+ /**
245
+ * A generated stereo impulse: early reflections (seeded taps in the first
246
+ * 40 ms) then decorrelated noise per side with an exact -60 dB decay at
247
+ * `seconds`, a fade-in over `attack` (diffuse build-up of a large hall) and
248
+ * a one-pole low-pass that closes from 16 kHz toward `damp` as the tail
249
+ * decays, so it darkens like air absorption.
250
+ */
251
+ export function builtinImpulse(
252
+ name: string,
253
+ sampleRate: number,
254
+ ): Impulse | undefined {
255
+ if (!Object.prototype.hasOwnProperty.call(BUILTIN_IMPULSES, name))
256
+ return undefined;
257
+ const key = `${name}@${sampleRate}`;
258
+ const cached = BUILTIN_CACHE.get(key);
259
+ if (cached) return cached;
260
+ const spec = BUILTIN_IMPULSES[name as BuiltinImpulse];
261
+ const length = Math.max(1, Math.round(spec.seconds * sampleRate));
262
+ const sides = ["L", "R"].map((side) => {
263
+ const random = seededRandom(`dawg-ir:${name}:${side}`);
264
+ const out = new Float64Array(length);
265
+ const decay = Math.log(1000) / spec.seconds;
266
+ let state = 0;
267
+ for (let index = 0; index < length; index += 1) {
268
+ const t = index / sampleRate;
269
+ const progress = t / spec.seconds;
270
+ const cutoff = 16_000 * (spec.damp / 16_000) ** progress;
271
+ const coefficient =
272
+ 1 -
273
+ Math.exp(
274
+ (-2 * Math.PI * Math.min(cutoff, sampleRate * 0.45)) / sampleRate,
275
+ );
276
+ state += coefficient * (random() * 2 - 1 - state);
277
+ const build = spec.attack > 0 ? Math.min(1, t / spec.attack) : 1;
278
+ out[index] = state * Math.exp(-decay * t) * build;
279
+ }
280
+ for (let tap = 0; tap < spec.early; tap += 1) {
281
+ const at = Math.floor((0.004 + random() * 0.036) * sampleRate);
282
+ if (at < length)
283
+ out[at]! += (random() < 0.5 ? -1 : 1) * (0.9 - tap * 0.08);
284
+ }
285
+ return out;
286
+ });
287
+ const impulse = normalizeImpulse(`builtin:${name}`, sides[0]!, sides[1]!);
288
+ BUILTIN_CACHE.set(key, impulse);
289
+ return impulse;
290
+ }
291
+
292
+ /**
293
+ * An impulse from a decoded sample (mono, any rate): resampled linearly to
294
+ * the render rate, cut to `MAX_IMPULSE_SECONDS`, energy-normalized; both
295
+ * sides play the same response.
296
+ */
297
+ export function sampleImpulse(
298
+ id: string,
299
+ mono: Float32Array,
300
+ sourceRate: number,
301
+ sampleRate: number,
302
+ ): Impulse {
303
+ const length = Math.max(
304
+ 1,
305
+ Math.min(
306
+ Math.round((mono.length * sampleRate) / sourceRate),
307
+ Math.round(MAX_IMPULSE_SECONDS * sampleRate),
308
+ ),
309
+ );
310
+ const out = new Float64Array(length);
311
+ const step = sourceRate / sampleRate;
312
+ for (let index = 0; index < length; index += 1) {
313
+ const position = index * step;
314
+ const base = Math.floor(position);
315
+ const a = mono[Math.min(mono.length - 1, base)] ?? 0;
316
+ const b = mono[Math.min(mono.length - 1, base + 1)] ?? 0;
317
+ out[index] = a + (b - a) * (position - base);
318
+ }
319
+ return normalizeImpulse(id, out, out);
320
+ }
321
+
322
+ /** Scales both sides so the mean energy per side is 1 (silence stays 0). */
323
+ function normalizeImpulse(
324
+ id: string,
325
+ left: Float64Array,
326
+ right: Float64Array,
327
+ ): Impulse {
328
+ let energy = 0;
329
+ for (let index = 0; index < left.length; index += 1)
330
+ energy += left[index]! * left[index]! + right[index]! * right[index]!;
331
+ const scale = energy > 0 ? 1 / Math.sqrt(energy / 2) : 0;
332
+ const l = new Float64Array(left.length);
333
+ const r = new Float64Array(right.length);
334
+ for (let index = 0; index < left.length; index += 1) {
335
+ l[index] = left[index]! * scale;
336
+ r[index] = right[index]! * scale;
337
+ }
338
+ return Object.freeze({ id, left: l, right: r });
339
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Saturation stages on the mono track signal: bitcrush/coarse (sample-rate
3
+ * and bit-depth reduction) and the distortion waveshaper.
4
+ */
5
+ import type { Track } from "../../../core/score.ts";
6
+ import type { FxValues } from "../../../core/fx.ts";
7
+ import {
8
+ CONTROL_SAMPLES,
9
+ OnePole,
10
+ clamp,
11
+ fxReader,
12
+ type EffectContext,
13
+ } from "./common.ts";
14
+
15
+ /**
16
+ * Bitcrush: `coarse` holds every Nth sample (sample-rate reduction) and
17
+ * `bits` quantizes to 2^(bits-1) levels per polarity. Fractional bits are
18
+ * allowed so the amount can sweep smoothly.
19
+ */
20
+ export function applyCrush(
21
+ buffer: Float64Array,
22
+ track: Track,
23
+ values: FxValues,
24
+ context: EffectContext,
25
+ ): void {
26
+ const read = fxReader(track, "crush", values, context);
27
+ const bits = read.param("bits");
28
+ const mix = read.param("mix");
29
+ const coarse = Math.max(1, Math.round(read.number("coarse")));
30
+ let levels = 2 ** (bits.at(0) - 1);
31
+ let wet = mix.at(0);
32
+ let held = 0;
33
+ for (let index = 0; index < buffer.length; index += 1) {
34
+ if (index % CONTROL_SAMPLES === 0) {
35
+ if (bits.automated) levels = 2 ** (bits.at(index) - 1);
36
+ if (mix.automated) wet = mix.at(index);
37
+ }
38
+ const input = buffer[index]!;
39
+ if (index % coarse === 0) held = input;
40
+ const crushed = Math.round(held * levels) / levels;
41
+ buffer[index] = input + (crushed - input) * wet;
42
+ }
43
+ }
44
+
45
+ /** Waveshaper curves; each maps 0 → 0 and is odd unless noted. */
46
+ export function shapeCurve(type: string, x: number, amount: number): number {
47
+ switch (type) {
48
+ case "hard":
49
+ return clamp(x, -1, 1);
50
+ case "cubic": {
51
+ const y = clamp(x, -1.5, 1.5);
52
+ return y - (y * y * y) / 6.75;
53
+ }
54
+ case "diode":
55
+ // Asymmetric: positive half saturates harder, like a forward-biased diode.
56
+ return x >= 0 ? Math.tanh(1.5 * x) / 1.2 : Math.tanh(0.7 * x) / 0.85;
57
+ case "asym":
58
+ return Math.tanh(x + 0.3) - Math.tanh(0.3);
59
+ case "fold": {
60
+ // Triangle wavefolder: reflect at ±1.
61
+ const folded = (((x + 1) % 4) + 4) % 4;
62
+ return folded < 2 ? folded - 1 : 3 - folded;
63
+ }
64
+ case "sinefold":
65
+ return Math.sin((Math.PI / 2) * x);
66
+ case "chebyshev": {
67
+ // T3 Chebyshev blended in: adds a strong third harmonic.
68
+ const y = Math.tanh(x);
69
+ return 0.6 * y + 0.4 * (4 * y * y * y - 3 * y);
70
+ }
71
+ case "scurve":
72
+ return (2 / Math.PI) * Math.atan(x * 1.4);
73
+ case "shape": {
74
+ // Strudel `shape` amount 0..1 → k; the classic (1+k)x/(1+k|x|) shaper.
75
+ const a = Math.min(0.99, amount);
76
+ const k = (2 * a) / (1 - a);
77
+ return ((1 + k) * x) / (1 + k * Math.abs(x));
78
+ }
79
+ default:
80
+ return Math.tanh(x);
81
+ }
82
+ }
83
+
84
+ /** Reference level for automatic gain compensation (about -12 dBFS). */
85
+ const COMPENSATION_LEVEL = 0.25;
86
+ const COMPENSATION_RMS = COMPENSATION_LEVEL / Math.SQRT2;
87
+ const COMPENSATION_POINTS = 64;
88
+
89
+ /**
90
+ * Distortion: pre-gain from `drive` (0..10 → 0..36 dB), the chosen curve,
91
+ * gain compensation so louder drive does not mean a louder track, a
92
+ * post-shaper `tone` low-pass, then `postgain` and the wet/dry `mix`.
93
+ */
94
+ export function applyDistort(
95
+ buffer: Float64Array,
96
+ track: Track,
97
+ values: FxValues,
98
+ context: EffectContext,
99
+ ): void {
100
+ const read = fxReader(track, "distort", values, context);
101
+ const drive = read.param("drive");
102
+ const tone = read.param("tone");
103
+ const mix = read.param("mix");
104
+ const type = read.text("type");
105
+ const postgain = read.number("postgain");
106
+ // `shape` takes the drive as its 0..1 amount; other curves use pre-gain.
107
+ const setDrive = (value: number) => {
108
+ const amount = value / 10;
109
+ const pregain = type === "shape" ? 1 : 2 ** (value * 0.6);
110
+ // RMS of the curve over one sine cycle at the reference level, so
111
+ // folders (whose output is not monotonic in level) compensate too.
112
+ let sum = 0;
113
+ for (let k = 0; k < COMPENSATION_POINTS; k += 1) {
114
+ const x =
115
+ COMPENSATION_LEVEL * Math.sin((2 * Math.PI * k) / COMPENSATION_POINTS);
116
+ sum += shapeCurve(type, pregain * x, amount) ** 2;
117
+ }
118
+ const reference = Math.sqrt(sum / COMPENSATION_POINTS);
119
+ const compensation = clamp(
120
+ COMPENSATION_RMS / Math.max(1e-3, reference),
121
+ 0.05,
122
+ 1.5,
123
+ );
124
+ return { amount, pregain, compensation };
125
+ };
126
+ let state = setDrive(drive.at(0));
127
+ const lowpass = new OnePole(tone.at(0), context.sampleRate);
128
+ let wet = mix.at(0);
129
+ for (let index = 0; index < buffer.length; index += 1) {
130
+ if (index % CONTROL_SAMPLES === 0) {
131
+ if (drive.automated) state = setDrive(drive.at(index));
132
+ if (tone.automated) lowpass.set(tone.at(index), context.sampleRate);
133
+ if (mix.automated) wet = mix.at(index);
134
+ }
135
+ const input = buffer[index]!;
136
+ const shaped =
137
+ shapeCurve(type, input * state.pregain, state.amount) *
138
+ state.compensation;
139
+ const output = lowpass.process(shaped) * postgain;
140
+ buffer[index] = input + (output - input) * wet;
141
+ }
142
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Orbits and ducking, applied where track stems are summed.
3
+ *
4
+ * Every track plays on an orbit (`fx.orbit`, 1 when absent). A track with
5
+ * `fx.duck` dips its target orbit at each of its note onsets: the gain
6
+ * falls to `1 - depth` over `onset` seconds and climbs back over `attack`
7
+ * seconds (a new onset during the recovery dips again from where the gain
8
+ * is). Several duckers on one orbit multiply. A ducker never ducks itself.
9
+ *
10
+ * Ducking is a gain on finished stems, so it never invalidates a ducked
11
+ * track's cached stem and costs nothing when no track ducks. The curve is
12
+ * plain float64 arithmetic over integer sample positions: deterministic on
13
+ * every render path. Clean-room, from Strudel's documented behaviour.
14
+ */
15
+ import type { Track } from "../../../core/score.ts";
16
+
17
+ export type Ducker = Readonly<{
18
+ trackId: string;
19
+ target: number;
20
+ depth: number;
21
+ attack: number;
22
+ onset: number;
23
+ /** Note onsets in samples, any order. */
24
+ onsets: readonly number[];
25
+ }>;
26
+
27
+ /** The orbit a track plays on. */
28
+ export function orbitOf(track: Track | undefined): number {
29
+ const orbit = track?.fx?.orbit?.orbit;
30
+ return typeof orbit === "number" ? orbit : 1;
31
+ }
32
+
33
+ /** The track's duck settings without onsets, or undefined when it does not duck. */
34
+ export function duckSettings(
35
+ track: Track | undefined,
36
+ ): Omit<Ducker, "trackId" | "onsets"> | undefined {
37
+ const duck = track?.fx?.duck;
38
+ if (!duck) return undefined;
39
+ const number = (key: string, fallback: number) =>
40
+ typeof duck[key] === "number" ? duck[key] : fallback;
41
+ return {
42
+ target: number("orbit", 1),
43
+ depth: number("depth", 1),
44
+ attack: number("attack", 0.1),
45
+ onset: number("onset", 0.003),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * The duck envelope (0 = no dip, 1 = full dip) of one ducker over
51
+ * `samples` samples. With `loopFrames`, onsets repeat every loop so the
52
+ * folded tail sees the next pass's notes.
53
+ */
54
+ export function duckEnvelope(
55
+ ducker: Ducker,
56
+ samples: number,
57
+ sampleRate: number,
58
+ loopFrames?: number,
59
+ ): Float64Array {
60
+ const marks = new Uint8Array(samples);
61
+ const repeats =
62
+ loopFrames && loopFrames > 0 ? Math.ceil(samples / loopFrames) : 1;
63
+ for (const onset of ducker.onsets)
64
+ for (let pass = 0; pass < repeats; pass += 1) {
65
+ const at = onset + pass * (loopFrames ?? 0);
66
+ if (at >= 0 && at < samples) marks[at] = 1;
67
+ }
68
+ const envelope = new Float64Array(samples);
69
+ const riseSamples = Math.max(1, ducker.onset * sampleRate);
70
+ const fallStep = 1 / Math.max(1, ducker.attack * sampleRate);
71
+ let level = 0;
72
+ let rising = false;
73
+ let riseFrom = 0;
74
+ let riseAt = 0;
75
+ for (let index = 0; index < samples; index += 1) {
76
+ if (marks[index] === 1) {
77
+ rising = true;
78
+ riseFrom = level;
79
+ riseAt = 0;
80
+ }
81
+ if (rising) {
82
+ riseAt += 1;
83
+ const progress = Math.min(1, riseAt / riseSamples);
84
+ level = riseFrom + (1 - riseFrom) * progress;
85
+ if (progress >= 1) rising = false;
86
+ } else if (level > 0) level = Math.max(0, level - fallStep);
87
+ envelope[index] = level;
88
+ }
89
+ return envelope;
90
+ }
91
+
92
+ /**
93
+ * Per-track gain curves: for each track id that some other track ducks,
94
+ * the product of `1 - depth · envelope` over its duckers. Tracks absent
95
+ * from the map play at unity.
96
+ */
97
+ export function duckGains(
98
+ tracks: readonly Readonly<{ id: string; orbit: number }>[],
99
+ duckers: readonly Ducker[],
100
+ samples: number,
101
+ sampleRate: number,
102
+ loopFrames?: number,
103
+ ): Map<string, Float64Array> {
104
+ const gains = new Map<string, Float64Array>();
105
+ if (duckers.length === 0) return gains;
106
+ const envelopes = duckers.map((ducker) =>
107
+ duckEnvelope(ducker, samples, sampleRate, loopFrames),
108
+ );
109
+ for (const track of tracks) {
110
+ let gain: Float64Array | undefined;
111
+ duckers.forEach((ducker, which) => {
112
+ if (ducker.target !== track.orbit || ducker.trackId === track.id) return;
113
+ if (ducker.depth <= 0) return;
114
+ if (!gain) gain = new Float64Array(samples).fill(1);
115
+ const envelope = envelopes[which]!;
116
+ for (let index = 0; index < samples; index += 1)
117
+ gain[index]! *= 1 - ducker.depth * envelope[index]!;
118
+ });
119
+ if (gain) gains.set(track.id, gain);
120
+ }
121
+ return gains;
122
+ }