@hraness/dawg 0.2.0 → 0.4.0

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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,301 @@
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 length = input.length;
123
+ const irLength = impulse.left.length;
124
+ if (length === 0 || irLength === 0) return;
125
+ const block = partitionSize(irLength);
126
+ const n = block * 2;
127
+ const partitions = Math.ceil(irLength / block);
128
+ // Impulse partition spectra G_p = FFT(hL_p + i·hR_p), zero-padded to n.
129
+ const gRe: Float64Array[] = [];
130
+ const gIm: Float64Array[] = [];
131
+ for (let p = 0; p < partitions; p += 1) {
132
+ const re = new Float64Array(n);
133
+ const im = new Float64Array(n);
134
+ const start = p * block;
135
+ const end = Math.min(irLength, start + block);
136
+ for (let i = start; i < end; i += 1) {
137
+ re[i - start] = impulse.left[i]!;
138
+ im[i - start] = impulse.right[i]!;
139
+ }
140
+ fftInPlace(re, im);
141
+ gRe.push(re);
142
+ gIm.push(im);
143
+ }
144
+ // Frequency-domain delay line of input spectra (ring of `partitions`).
145
+ const xRe = Array.from({ length: partitions }, () => new Float64Array(n));
146
+ const xIm = Array.from({ length: partitions }, () => new Float64Array(n));
147
+ const accRe = new Float64Array(n);
148
+ const accIm = new Float64Array(n);
149
+ const blocks = Math.ceil(length / block);
150
+ const scale = 1 / n;
151
+ for (let k = 0; k < blocks; k += 1) {
152
+ const slot = k % partitions;
153
+ const re = xRe[slot]!;
154
+ const im = xIm[slot]!;
155
+ // Window: previous block then this block.
156
+ const base = (k - 1) * block;
157
+ for (let i = 0; i < n; i += 1) {
158
+ const at = base + i;
159
+ re[i] = at >= 0 && at < length ? input[at]! : 0;
160
+ im[i] = 0;
161
+ }
162
+ fftInPlace(re, im);
163
+ accRe.fill(0);
164
+ accIm.fill(0);
165
+ for (let p = 0; p < partitions && p <= k; p += 1) {
166
+ const source = (slot - p + partitions) % partitions;
167
+ const ar = xRe[source]!;
168
+ const ai = xIm[source]!;
169
+ const br = gRe[p]!;
170
+ const bi = gIm[p]!;
171
+ for (let i = 0; i < n; i += 1) {
172
+ const r = ar[i]!;
173
+ const m = ai[i]!;
174
+ accRe[i]! += r * br[i]! - m * bi[i]!;
175
+ accIm[i]! += r * bi[i]! + m * br[i]!;
176
+ }
177
+ }
178
+ fftInPlace(accRe, accIm, true);
179
+ const outStart = k * block;
180
+ const count = Math.min(block, length - outStart);
181
+ for (let i = 0; i < count; i += 1) {
182
+ const index = outStart + i;
183
+ const g = (gainAt ? gainAt(index) : gain) * scale;
184
+ outL[index]! += accRe[block + i]! * g;
185
+ outR[index]! += accIm[block + i]! * g;
186
+ }
187
+ }
188
+ }
189
+
190
+ /** Built-in impulse names (`builtin:<name>`). */
191
+ export const BUILTIN_IMPULSES = Object.freeze({
192
+ room: { title: "small room", seconds: 0.7, damp: 6000, early: 7, attack: 0 },
193
+ hall: {
194
+ title: "concert hall",
195
+ seconds: 2.8,
196
+ damp: 4500,
197
+ early: 5,
198
+ attack: 0.06,
199
+ },
200
+ plate: { title: "plate", seconds: 1.8, damp: 9000, early: 0, attack: 0 },
201
+ } as const);
202
+ export type BuiltinImpulse = keyof typeof BUILTIN_IMPULSES;
203
+
204
+ const BUILTIN_CACHE = new Map<string, Impulse>();
205
+
206
+ /**
207
+ * A generated stereo impulse: early reflections (seeded taps in the first
208
+ * 40 ms) then decorrelated noise per side with an exact -60 dB decay at
209
+ * `seconds`, a fade-in over `attack` (diffuse build-up of a large hall) and
210
+ * a one-pole low-pass that closes from 16 kHz toward `damp` as the tail
211
+ * decays, so it darkens like air absorption.
212
+ */
213
+ export function builtinImpulse(
214
+ name: string,
215
+ sampleRate: number,
216
+ ): Impulse | undefined {
217
+ if (!Object.prototype.hasOwnProperty.call(BUILTIN_IMPULSES, name))
218
+ return undefined;
219
+ const key = `${name}@${sampleRate}`;
220
+ const cached = BUILTIN_CACHE.get(key);
221
+ if (cached) return cached;
222
+ const spec = BUILTIN_IMPULSES[name as BuiltinImpulse];
223
+ const length = Math.max(1, Math.round(spec.seconds * sampleRate));
224
+ const sides = ["L", "R"].map((side) => {
225
+ const random = seededRandom(`dawg-ir:${name}:${side}`);
226
+ const out = new Float64Array(length);
227
+ const decay = Math.log(1000) / spec.seconds;
228
+ let state = 0;
229
+ for (let index = 0; index < length; index += 1) {
230
+ const t = index / sampleRate;
231
+ const progress = t / spec.seconds;
232
+ const cutoff = 16_000 * (spec.damp / 16_000) ** progress;
233
+ const coefficient =
234
+ 1 -
235
+ Math.exp(
236
+ (-2 * Math.PI * Math.min(cutoff, sampleRate * 0.45)) / sampleRate,
237
+ );
238
+ state += coefficient * (random() * 2 - 1 - state);
239
+ const build = spec.attack > 0 ? Math.min(1, t / spec.attack) : 1;
240
+ out[index] = state * Math.exp(-decay * t) * build;
241
+ }
242
+ for (let tap = 0; tap < spec.early; tap += 1) {
243
+ const at = Math.floor((0.004 + random() * 0.036) * sampleRate);
244
+ if (at < length)
245
+ out[at]! += (random() < 0.5 ? -1 : 1) * (0.9 - tap * 0.08);
246
+ }
247
+ return out;
248
+ });
249
+ const impulse = normalizeImpulse(`builtin:${name}`, sides[0]!, sides[1]!);
250
+ BUILTIN_CACHE.set(key, impulse);
251
+ return impulse;
252
+ }
253
+
254
+ /**
255
+ * An impulse from a decoded sample (mono, any rate): resampled linearly to
256
+ * the render rate, cut to `MAX_IMPULSE_SECONDS`, energy-normalized; both
257
+ * sides play the same response.
258
+ */
259
+ export function sampleImpulse(
260
+ id: string,
261
+ mono: Float32Array,
262
+ sourceRate: number,
263
+ sampleRate: number,
264
+ ): Impulse {
265
+ const length = Math.max(
266
+ 1,
267
+ Math.min(
268
+ Math.round((mono.length * sampleRate) / sourceRate),
269
+ Math.round(MAX_IMPULSE_SECONDS * sampleRate),
270
+ ),
271
+ );
272
+ const out = new Float64Array(length);
273
+ const step = sourceRate / sampleRate;
274
+ for (let index = 0; index < length; index += 1) {
275
+ const position = index * step;
276
+ const base = Math.floor(position);
277
+ const a = mono[Math.min(mono.length - 1, base)] ?? 0;
278
+ const b = mono[Math.min(mono.length - 1, base + 1)] ?? 0;
279
+ out[index] = a + (b - a) * (position - base);
280
+ }
281
+ return normalizeImpulse(id, out, out);
282
+ }
283
+
284
+ /** Scales both sides so the mean energy per side is 1 (silence stays 0). */
285
+ function normalizeImpulse(
286
+ id: string,
287
+ left: Float64Array,
288
+ right: Float64Array,
289
+ ): Impulse {
290
+ let energy = 0;
291
+ for (let index = 0; index < left.length; index += 1)
292
+ energy += left[index]! * left[index]! + right[index]! * right[index]!;
293
+ const scale = energy > 0 ? 1 / Math.sqrt(energy / 2) : 0;
294
+ const l = new Float64Array(left.length);
295
+ const r = new Float64Array(right.length);
296
+ for (let index = 0; index < left.length; index += 1) {
297
+ l[index] = left[index]! * scale;
298
+ r[index] = right[index]! * scale;
299
+ }
300
+ return Object.freeze({ id, left: l, right: r });
301
+ }
@@ -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
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Level stages: tremolo, compressor and post gain.
3
+ */
4
+ import type { Track } from "../../../core/score.ts";
5
+ import type { FxValues } from "../../../core/fx.ts";
6
+ import {
7
+ CONTROL_SAMPLES,
8
+ Phasor,
9
+ dbToGain,
10
+ fxReader,
11
+ lfo,
12
+ lfoHz,
13
+ type EffectContext,
14
+ } from "./common.ts";
15
+
16
+ /** Warp a cycle so its midpoint lands at `skew` (0.5 leaves it unchanged). */
17
+ function skewCycle(phase: number, skew: number): number {
18
+ const cycle = phase - Math.floor(phase);
19
+ const s = Math.min(0.99, Math.max(0.01, skew));
20
+ return cycle < s ? (0.5 * cycle) / s : 0.5 + (0.5 * (cycle - s)) / (1 - s);
21
+ }
22
+
23
+ /**
24
+ * Tremolo: gain = 1 − depth·(1 − u) where u is the 0..1 LFO, so depth 0 is
25
+ * transparent and depth 1 dips to silence once per cycle.
26
+ */
27
+ export function applyTremolo(
28
+ buffer: Float64Array,
29
+ track: Track,
30
+ values: FxValues,
31
+ context: EffectContext,
32
+ ): void {
33
+ const read = fxReader(track, "tremolo", values, context);
34
+ const rate = read.param("rate");
35
+ const depth = read.param("depth");
36
+ const sync = read.number("sync");
37
+ const shape = read.text("shape");
38
+ const skew = read.number("skew");
39
+ // Start at the LFO peak, so phase 0 begins at full level.
40
+ const phasor = new Phasor(read.number("phase"));
41
+ let amount = depth.at(0);
42
+ for (let index = 0; index < buffer.length; index += 1) {
43
+ if (index % CONTROL_SAMPLES === 0) {
44
+ phasor.setHz(
45
+ lfoHz(sync, rate.at(index), context.tempoBpm),
46
+ context.sampleRate,
47
+ );
48
+ if (depth.automated) amount = depth.at(index);
49
+ }
50
+ const u = (lfo(shape, skewCycle(phasor.next(), skew) + 0.25) + 1) / 2;
51
+ buffer[index] = buffer[index]! * (1 - amount * (1 - u));
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Feed-forward compressor after Giannoulis, Massberg & Reiss (JAES 2012):
57
+ * a 5 ms RMS level detector, a soft-knee gain computer in dB, and
58
+ * attack/release smoothing of the gain reduction, then make-up gain.
59
+ */
60
+ export function applyCompressor(
61
+ buffer: Float64Array,
62
+ track: Track,
63
+ values: FxValues,
64
+ context: EffectContext,
65
+ ): void {
66
+ const read = fxReader(track, "compressor", values, context);
67
+ const threshold = read.param("threshold");
68
+ const makeup = read.param("makeup");
69
+ const ratio = read.number("ratio");
70
+ const knee = read.number("knee");
71
+ const { sampleRate } = context;
72
+ const coefficient = (seconds: number) =>
73
+ Math.exp(-1 / Math.max(1, seconds * sampleRate));
74
+ const attack = coefficient(read.number("attack"));
75
+ const release = coefficient(read.number("release"));
76
+ const detector = coefficient(0.005);
77
+ const slope = 1 - 1 / ratio;
78
+ let thresholdDb = threshold.at(0);
79
+ let makeupGain = dbToGain(makeup.at(0));
80
+ let power = 0;
81
+ let reduction = 0;
82
+ for (let index = 0; index < buffer.length; index += 1) {
83
+ if (index % CONTROL_SAMPLES === 0) {
84
+ if (threshold.automated) thresholdDb = threshold.at(index);
85
+ if (makeup.automated) makeupGain = dbToGain(makeup.at(index));
86
+ }
87
+ const input = buffer[index]!;
88
+ // Peak detector: instant rise, 5 ms fall, so onsets are seen at once
89
+ // and the attack time alone sets how much of a transient passes.
90
+ const magnitude = Math.abs(input);
91
+ power = magnitude > power ? magnitude : detector * power;
92
+ const level = 20 * Math.log10(power + 1e-12);
93
+ const over = level - thresholdDb;
94
+ let target = 0;
95
+ if (2 * over >= knee) target = slope * over;
96
+ else if (knee > 0 && 2 * over > -knee)
97
+ target = (slope * (over + knee / 2) ** 2) / (2 * knee);
98
+ reduction =
99
+ target > reduction
100
+ ? attack * reduction + (1 - attack) * target
101
+ : release * reduction + (1 - release) * target;
102
+ buffer[index] = input * dbToGain(-reduction) * makeupGain;
103
+ }
104
+ }
105
+
106
+ /** Linear post gain on the stereo pair (Strudel `postgain`). */
107
+ export function applyPostgain(
108
+ left: Float64Array,
109
+ right: Float64Array,
110
+ track: Track,
111
+ values: FxValues,
112
+ context: EffectContext,
113
+ ): void {
114
+ const gain = fxReader(track, "postgain", values, context).param("gain");
115
+ let value = gain.at(0);
116
+ for (let index = 0; index < left.length; index += 1) {
117
+ if (gain.automated && index % CONTROL_SAMPLES === 0) value = gain.at(index);
118
+ left[index]! *= value;
119
+ right[index]! *= value;
120
+ }
121
+ }