gpuAudio.ts

examples/film/gpuAudio.ts · raw · all examples · site home

Exports: CHUNKGpuScore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
// The score rendered on the GPU: Radix's WgpuCompute kernels synthesize
// it in chunks of CHUNK stereo frames at 48 kHz, every stage in TSL:
//
//   voices  one thread per voice sounding in the chunk, sample by sample:
//           band-limited oscillators (PolyBLEP saw and square), FM, stereo
//           noise, a Web Audio biquad (lowpass / highpass / bandpass, its
//           cutoff automated), the waveshaper, the gain curve and the pan
//   mix     one thread per frame: every voice into the dry bus, the
//           sidechain-ducked bus (kicks duck it), the reverb and delay sends
//   delay   the ping-pong delay (the score's time each way, the feedback
//           through a lowpass): the feedback filter is a one-thread scan,
//           the rest one thread per frame (the delay is longer than a chunk)
//   reverb  a 4 s stereo convolution reverb (a decaying sparse noise
//           impulse, lowpassed): one thread per frame over 7200 taps a side
//   master  one thread: the reverb's lowpass, the compressor, the output
//           gain, a limiter and the end fade
//
// Chunks run in order (the delay, reverb and filters carry state from one
// to the next) and are read back into a Float32Array of interleaved L, R.
import { TSL, WgpuBuffer, WgpuCompute, WgpuComputeBatch, type TslNode } from "@sparkjsdev/radix";
import type { WebGPURenderer } from "three/webgpu";
import { RATE, VOICE, buildScore, type ScoreData } from "./score.js";
import { random } from "./timeline.js";

const { If, Loop, abs, bool, clamp, cos, exp2, float, fract, hash, log2, max, min, mix, pow, round, select, sin, uint, uniform, vec2 } = TSL;

// 0.2 s: shorter than the delay, so each chunk's delay reads come from
// earlier chunks
export const CHUNK = 9600;
// Chunks rendered per readback
const BATCH = 16;
const TWO_PI = Math.PI * 2;
const DELAY_RING = 1 << 16;
const REVERB_RING = 1 << 18;
const REVERB_TAPS = 7200;
const REVERB_SECONDS = 4;
// Per voice: 3 oscillator phases, the FM phase, biquad state (x1, x2, y1,
// y2) for L then R
const STATE = 16;

// The master: the old score's compressor settings (threshold, knee, ratio
// in dB, as Web Audio's DynamicsCompressorNode), output gain and limiter
// ceiling
const MASTER = { gain: 0.7, threshold: -14, knee: 30, ratio: 3, attack: 0.01, release: 0.25, output: 2.1, ceiling: 0.84, limiterRelease: 0.08 };

// A biquad's coefficients (Web Audio's formulas), for the delay's filter
function lowpass(freq: number, qDb: number) {
  const w0 = (TWO_PI * freq) / RATE;
  const alpha = Math.sin(w0) / (2 * 10 ** (qDb / 20));
  const a0 = 1 + alpha;
  const b0 = (1 - Math.cos(w0)) / 2 / a0;
  return { b0, b1: (1 - Math.cos(w0)) / a0, b2: b0, a1: (-2 * Math.cos(w0)) / a0, a2: (1 - alpha) / a0 };
}

// StereoPannerNode on a stereo input: [ll, rl, rr, lr]
function stereoPan(pan: number) {
  const x = (pan <= 0 ? pan + 1 : pan) * (Math.PI / 2);
  return pan <= 0 ? [1, Math.cos(x), Math.sin(x), 0] : [Math.cos(x), 0, 1, Math.sin(x)];
}

// The impulse: a sparse random-sign tap every RATE / density frames, its
// energy in each 25 ms window set to exp(-3.2 t) (so both channels decay
// alike from the first window), in all the energy of the old score's
// ConvolverNode (its noise impulse normalized as Web Audio does: 0.257 a
// channel, measured in Chrome, after the lowpass)
function reverbTaps() {
  const next = random(2026);
  const spacing = (REVERB_SECONDS * RATE) / REVERB_TAPS;
  const window = 1200;
  const windows = Math.ceil((REVERB_SECONDS * RATE) / window);
  const target = Array.from({ length: windows }, (_, w) => Math.exp((-3.2 * (w + 0.5) * window) / RATE));
  const total = target.reduce((sum, e) => sum + e, 0);
  // The one-pole lowpass after it passes a / (2 - a) of white noise's power
  const energy = 0.257 / (0.35 / 1.65);
  const taps = new Float32Array(REVERB_TAPS * 4);
  for (const channel of [0, 1]) {
    const sums = new Float64Array(windows);
    for (let k = 0; k < REVERB_TAPS; k += 1) {
      const offset = Math.floor((k + next()) * spacing);
      taps[k * 4 + channel * 2] = offset;
      taps[k * 4 + channel * 2 + 1] = next() < 0.5 ? -1 : 1;
      sums[Math.floor(offset / window)] += 1;
    }
    for (let k = 0; k < REVERB_TAPS; k += 1) {
      const w = Math.floor(taps[k * 4 + channel * 2] / window);
      taps[k * 4 + channel * 2 + 1] *= Math.sqrt((energy * target[w]) / total / sums[w]);
    }
  }
  return taps;
}

interface Curve {
  count: TslNode;
  initial: TslNode;
  points: { t: TslNode; v: TslNode; r: TslNode }[];
}

// Web Audio's AudioParam timeline: before the first event the initial
// value; a set holds the previous value until its time; a linear or
// exponential ramp runs from the previous event to its own
function curveAt(c: Curve, t: TslNode) {
  let result = c.initial;
  let prevT: TslNode = float(-1e9);
  let prevV = c.initial;
  let settled: TslNode = bool(false);
  c.points.forEach((p, i) => {
    const valid = c.count.greaterThan(i + 0.5);
    const passed = valid.and(t.greaterThanEqual(p.t)).toVar();
    const open = valid.and(passed.not()).and(settled.not()).toVar();
    const u = clamp(t.sub(prevT).div(max(p.t.sub(prevT), 1e-6)), 0, 1);
    const from = max(prevV, 1e-7);
    const exponential = from.mul(pow(max(p.v, 1e-7).div(from), u));
    const segment = select(p.r.greaterThan(1.5), exponential, select(p.r.greaterThan(0.5), mix(prevV, p.v, u), prevV));
    result = select(open, segment, select(passed, p.v, result)).toVar();
    settled = settled.or(open).toVar();
    prevT = select(passed, p.t, prevT).toVar();
    prevV = select(passed, p.v, prevV).toVar();
  });
  return result;
}

// PolyBLEP: the band-limited step correction near a wrap
function blep(x: TslNode, dt: TslNode) {
  const a = x.div(dt);
  const b = x.sub(1).div(dt);
  return select(x.lessThan(dt), a.add(a).sub(a.mul(a)).sub(1), select(x.greaterThan(float(1).sub(dt)), b.mul(b).add(b).add(b).add(1), float(0)));
}

// Web Audio's built-in waves: each starts at 0 (saw and triangle rising,
// square high), with saw and square scaled by 0.851 as Chrome normalizes
// their band-limited tables to a peak of 1 (the Gibbs overshoot)
const GIBBS = 0.851;
function wave(kind: TslNode, phase: TslNode, dt: TslNode) {
  const sine = sin(phase.mul(TWO_PI));
  const half = fract(phase.add(0.5));
  const saw = half.mul(2).sub(1).sub(blep(half, dt)).mul(GIBBS);
  const square = select(phase.lessThan(0.5), float(1), float(-1)).add(blep(phase, dt)).sub(blep(half, dt)).mul(GIBBS);
  const triangle = float(1).sub(abs(fract(phase.add(0.25)).sub(0.5)).mul(4));
  return select(kind.lessThan(0.5), float(0), select(kind.lessThan(1.5), sine, select(kind.lessThan(2.5), saw, select(kind.lessThan(3.5), square, triangle))));
}

export class GpuScore {
  readonly renderer: WebGPURenderer;
  readonly score: ScoreData;
  readonly duration: number;
  readonly chunks: number;
  private lists: Uint32Array<ArrayBuffer>[];
  private u = { frame0: uniform(0, "uint"), active: uniform(0, "uint"), outBase: uniform(0, "uint") };
  private buffers: { dispose(): void }[] = [];
  private state: WgpuBuffer<"float32">;
  private fx: WgpuBuffer<"float32x2">;
  private rings: WgpuBuffer<"float32x2">[];
  private active: WgpuBuffer<"uint32">;
  private out: WgpuBuffer<"float32x2">;
  private kernels: { voices: WgpuCompute; mix: WgpuCompute; delayFilter: WgpuCompute; delay: WgpuCompute; reverb: WgpuCompute; master: WgpuCompute };
  private batch: WgpuComputeBatch;

  // `raw`: the master without its compressor, output gain, limiter and
  // fade (for comparing instruments)
  constructor(renderer: WebGPURenderer, duration: number, { score = buildScore(), raw = false }: { score?: ScoreData; raw?: boolean } = {}) {
    this.renderer = renderer;
    this.duration = duration;
    this.score = score;
    this.chunks = Math.ceil((duration * RATE) / CHUNK);
    const { u } = this;

    // The voices sounding in each chunk
    this.lists = Array.from({ length: this.chunks }, (_, k) => {
      const from = (k * CHUNK) / RATE;
      const to = ((k + 1) * CHUNK) / RATE;
      const ids: number[] = [];
      for (let i = 0; i < score.count && score.starts[i] < to; i += 1) {
        if (score.stops[i] > from) {
          ids.push(i);
        }
      }
      return Uint32Array.from(ids);
    });
    const maxActive = Math.max(1, ...this.lists.map((list) => list.length));

    const buffer = <F extends "float32" | "float32x2" | "float32x4" | "uint32">(format: F, count: number) => {
      const made = new WgpuBuffer({ renderer, format, count });
      this.buffers.push(made);
      return made;
    };
    const voices = buffer("float32", score.table.length);
    voices.upload(score.table);
    const kicks = buffer("float32", Math.max(1, score.kicks.length));
    kicks.upload(score.kicks);
    const taps = buffer("float32x4", REVERB_TAPS);
    taps.upload(reverbTaps());
    this.state = buffer("float32", score.count * STATE);
    this.active = buffer("uint32", maxActive);
    const slots = buffer("float32x2", maxActive * CHUNK);
    const master = buffer("float32x2", CHUNK);
    const delayed = buffer("float32x2", CHUNK);
    const wet = buffer("float32x2", CHUNK);
    const reverbIn = buffer("float32x2", REVERB_RING);
    const delayIn = buffer("float32x2", DELAY_RING);
    const delayA = buffer("float32x2", DELAY_RING);
    const delayLp = buffer("float32x2", DELAY_RING);
    this.rings = [reverbIn, delayIn, delayA, delayLp];
    // Effect state: the delay filter's x1, x2, y1, y2, the reverb lowpass,
    // (compressor reduction in dB, limiter gain)
    this.fx = buffer("float32x2", 8);
    this.out = buffer("float32x2", CHUNK * BATCH);
    const out = this.out;
    const { state, fx, active } = this;
    const DELAY = Math.round(score.mix.delay * RATE);
    if (DELAY <= CHUNK || DELAY >= DELAY_RING - CHUNK) {
      throw new Error(`A delay of ${score.mix.delay} s: it reads earlier chunks only, so 0.2 to 1.3 s`);
    }
    const v = (base: TslNode, k: number) => voices.element(base.add(uint(k)));
    const delayRing = (frame: TslNode) => frame.bitAnd(uint(DELAY_RING - 1));
    const reverbRing = (frame: TslNode) => frame.bitAnd(uint(REVERB_RING - 1));

    const voiceKernel = new WgpuCompute({
      label: "scoreVoices",
      workgroupSize: [32],
      body: ({ index }) => {
        If(index.lessThan(u.active), () => {
          const id = active.element(index).toVar();
          const base = id.mul(uint(VOICE.stride)).toVar();
          const p = (k: number) => v(base, k).toVar();
          const [start, stop, ll, rl, rr, lr] = [VOICE.start, VOICE.stop, VOICE.ll, VOICE.rl, VOICE.rr, VOICE.lr].map(p);
          const [filterType, filterQ, shaper, noiseGain, noiseFiltered, fmRatio] = [VOICE.filterType, VOICE.filterQ, VOICE.shaper, VOICE.noiseGain, VOICE.noiseFiltered, VOICE.fmRatio].map(p);
          const oscs = [0, 1, 2].map((k) => {
            const at = VOICE.osc + k * VOICE.oscStride;
            return { kind: p(at), freq: p(at + 1), detune: p(at + 2), gain: p(at + 3), filtered: p(at + 4) };
          });
          const curve = (at: number): Curve => ({
            count: p(at),
            initial: p(at + 1),
            points: [0, 1, 2, 3].map((i) => ({ t: p(at + 4 + i * 3), v: p(at + 5 + i * 3), r: p(at + 6 + i * 3) })),
          });
          const gainCurve = curve(VOICE.gain);
          const pitchCurve = curve(VOICE.pitch);
          const filterCurve = curve(VOICE.filter);
          const fmCurve = curve(VOICE.fm);

          const stateBase = id.mul(uint(STATE)).toVar();
          const s = (k: number) => state.element(stateBase.add(uint(k)));
          const phases = [0, 1, 2].map((k) => s(k).toVar());
          const fmPhase = s(3).toVar();
          const z = [4, 5, 6, 7, 8, 9, 10, 11].map((k) => s(k).toVar());
          const [b0, b1, b2, a1, a2] = [1, 0, 0, 0, 0].map((c) => float(c).toVar());
          // Q in dB for lowpass and highpass, linear for bandpass
          const q = select(filterType.greaterThan(2.5), filterQ, pow(float(10), filterQ.div(20))).toVar();
          const lp = filterType.lessThan(1.5);
          const hp = filterType.lessThan(2.5);
          const none = filterType.lessThan(0.5);

          Loop({ start: uint(0), end: uint(CHUNK), type: "uint", condition: "<" }, ({ i }: { i: TslNode }) => {
            const frame = u.frame0.add(i).toVar();
            const t = float(frame).div(RATE).toVar();
            // Oscillators start at phase 0 at the voice's start, as Web Audio's
            const running = t.greaterThanEqual(start);

            // The filter's coefficients every 16 frames, from its cutoff curve
            If(i.bitAnd(uint(15)).equal(uint(0)), () => {
              const f = clamp(curveAt(filterCurve, t), 10, RATE * 0.49);
              const w0 = f.mul(TWO_PI / RATE);
              const cw = cos(w0).toVar();
              const alpha = sin(w0).div(q.mul(2)).toVar();
              const a0 = alpha.add(1).toVar();
              const nb0 = select(lp, float(1).sub(cw).mul(0.5), select(hp, cw.add(1).mul(0.5), alpha)).toVar();
              const nb1 = select(lp, float(1).sub(cw), select(hp, cw.add(1).negate(), float(0)));
              const nb2 = select(hp, nb0, alpha.negate());
              b0.assign(select(none, float(1), nb0.div(a0)));
              b1.assign(select(none, float(0), nb1.div(a0)));
              b2.assign(select(none, float(0), nb2.div(a0)));
              a1.assign(select(none, float(0), cw.mul(-2).div(a0)));
              a2.assign(select(none, float(0), float(1).sub(alpha).div(a0)));
            });

            // Oscillators: the first follows the pitch curve, frequency-
            // modulated when the voice has an FM ratio
            const pitch = curveAt(pitchCurve, t).toVar();
            const modulator = sin(fmPhase.mul(TWO_PI)).toVar();
            fmPhase.assign(select(running, fract(fmPhase.add(pitch.mul(fmRatio).div(RATE))), float(0)));
            const carrier = pitch.add(select(fmRatio.greaterThan(0), curveAt(fmCurve, t).mul(modulator), float(0))).toVar();
            const filtered = float(0).toVar();
            const direct = float(0).toVar();
            oscs.forEach((o, k) => {
              const f = (k === 0 ? carrier : o.freq).mul(o.detune).toVar();
              const dt = clamp(abs(f).div(RATE), 1e-6, 0.5);
              const sample = wave(o.kind, phases[k], dt).mul(o.gain).toVar();
              phases[k].assign(select(running, fract(phases[k].add(f.div(RATE))), float(0)));
              filtered.addAssign(sample.mul(o.filtered));
              direct.addAssign(sample.mul(float(1).sub(o.filtered)));
            });

            // Stereo noise, hashed from the frame and the voice
            const seed = frame.mul(uint(2)).add(id.mul(uint(0x9e3779b1)));
            const noiseL = hash(seed).mul(2).sub(1).mul(noiseGain).toVar();
            const noiseR = hash(seed.add(uint(1))).mul(2).sub(1).mul(noiseGain).toVar();
            const xL = filtered.add(noiseL.mul(noiseFiltered)).toVar();
            const xR = filtered.add(noiseR.mul(noiseFiltered)).toVar();

            // The biquad, direct form I, per channel
            const yL = b0.mul(xL).add(b1.mul(z[0])).add(b2.mul(z[1])).sub(a1.mul(z[2])).sub(a2.mul(z[3])).toVar();
            const yR = b0.mul(xR).add(b1.mul(z[4])).add(b2.mul(z[5])).sub(a1.mul(z[6])).sub(a2.mul(z[7])).toVar();
            z[1].assign(z[0]);
            z[0].assign(xL);
            z[3].assign(z[2]);
            z[2].assign(yL);
            z[5].assign(z[4]);
            z[4].assign(xR);
            z[7].assign(z[6]);
            z[6].assign(yR);

            const unfiltered = float(1).sub(noiseFiltered);
            const preL = yL.add(direct).add(noiseL.mul(unfiltered)).toVar();
            const preR = yR.add(direct).add(noiseR.mul(unfiltered)).toVar();
            // The 8-bit curve
            const shapedL = select(shaper.greaterThan(0.5), round(preL.mul(6)).div(6), preL);
            const shapedR = select(shaper.greaterThan(0.5), round(preR.mul(6)).div(6), preR);
            const sounding = select(t.greaterThanEqual(start).and(t.lessThan(stop)), float(1), float(0));
            const gain = curveAt(gainCurve, t).mul(sounding).toVar();
            const outL = shapedL.mul(gain).toVar();
            const outR = shapedR.mul(gain).toVar();
            slots.element(index.mul(uint(CHUNK)).add(i)).assign(vec2(outL.mul(ll).add(outR.mul(rl)), outR.mul(rr).add(outL.mul(lr))));
          });

          phases.forEach((phase, k) => s(k).assign(phase));
          s(3).assign(fmPhase);
          z.forEach((value, k) => s(4 + k).assign(value));
        });
      },
    });

    const kickCount = score.kicks.length;
    const mixKernel = new WgpuCompute({
      label: "scoreMix",
      body: ({ index }) => {
        If(index.lessThan(uint(CHUNK)), () => {
          const frame = u.frame0.add(index).toVar();
          const t = float(frame).div(RATE).toVar();
          const dry = vec2(0).toVar();
          const duck = vec2(0).toVar();
          const reverb = vec2(0).toVar();
          const delay = vec2(0).toVar();
          Loop({ start: uint(0), end: u.active, type: "uint", condition: "<" }, ({ i }: { i: TslNode }) => {
            const base = active.element(i).mul(uint(VOICE.stride)).toVar();
            const sample = slots.element(i.mul(uint(CHUNK)).add(index)).toVar();
            const level = v(base, VOICE.dry).toVar();
            const ducked = v(base, VOICE.ducked).toVar();
            dry.addAssign(sample.mul(level.mul(float(1).sub(ducked))));
            duck.addAssign(sample.mul(level.mul(ducked)));
            reverb.addAssign(sample.mul(v(base, VOICE.reverb)));
            delay.addAssign(sample.mul(v(base, VOICE.delay)));
          });
          // The sidechain: down to 0.35 at each kick, back to 1 over 0.42 s
          const duckGain = float(1).toVar();
          Loop({ start: uint(0), end: uint(kickCount), type: "uint", condition: "<" }, ({ i }: { i: TslNode }) => {
            const kick = kicks.element(i).toVar();
            If(kick.lessThanEqual(t), () => {
              duckGain.assign(clamp(t.sub(kick).div(0.42).mul(0.65).add(0.35), 0.35, 1));
            });
          });
          const ducked = duck.mul(duckGain).toVar();
          // The ducked bus feeds the master and the reverb
          master.element(index).assign(dry.add(ducked));
          reverbIn.element(reverbRing(frame)).assign(reverb.add(ducked));
          delayIn.element(delayRing(frame)).assign(delay);
          delayed.element(index).assign(delayA.element(delayRing(frame.sub(uint(DELAY)))));
        });
      },
    });

    // The feedback lowpass of the delay, over the chunk in order
    const f = lowpass(3200, 0.7);
    const delayFilterKernel = new WgpuCompute({
      label: "scoreDelayFilter",
      workgroupSize: [1],
      body: ({ index }) => {
        If(index.equal(uint(0)), () => {
          const [x1, x2, y1, y2] = [0, 1, 2, 3].map((k) => fx.element(uint(k)).toVar());
          Loop({ start: uint(0), end: uint(CHUNK), type: "uint", condition: "<" }, ({ i }: { i: TslNode }) => {
            const x = delayed.element(i).toVar();
            const y = x.mul(f.b0).add(x1.mul(f.b1)).add(x2.mul(f.b2)).sub(y1.mul(f.a1)).sub(y2.mul(f.a2)).toVar();
            x2.assign(x1);
            x1.assign(x);
            y2.assign(y1);
            y1.assign(y);
            delayLp.element(delayRing(u.frame0.add(i))).assign(y);
          });
          [x1, x2, y1, y2].forEach((value, k) => fx.element(uint(k)).assign(value));
        });
      },
    });

    // a = the delayed input plus the filtered feedback; b = a delayed again;
    // a to the left, b to the right, the left into the reverb too
    const [lll, lrl, lrr, llr] = stereoPan(-0.7);
    const [rll, rrl, rrr, rlr] = stereoPan(0.7);
    const delayKernel = new WgpuCompute({
      label: "scoreDelay",
      body: ({ index }) => {
        If(index.lessThan(uint(CHUNK)), () => {
          const frame = u.frame0.add(index).toVar();
          const past = delayRing(frame.sub(uint(DELAY))).toVar();
          const a = delayIn.element(past).add(delayLp.element(past).mul(score.mix.feedback)).toVar();
          delayA.element(delayRing(frame)).assign(a);
          const b = delayed.element(index).toVar();
          const left = vec2(a.x.mul(lll).add(a.y.mul(lrl)), a.y.mul(lrr).add(a.x.mul(llr))).toVar();
          const right = vec2(b.x.mul(rll).add(b.y.mul(rrl)), b.y.mul(rrr).add(b.x.mul(rlr)));
          master.element(index).addAssign(left.add(right));
          reverbIn.element(reverbRing(frame)).addAssign(left);
        });
      },
    });

    const reverbKernel = new WgpuCompute({
      label: "scoreReverb",
      body: ({ index }) => {
        If(index.lessThan(uint(CHUNK)), () => {
          const frame = u.frame0.add(index).toVar();
          const sum = vec2(0).toVar();
          Loop({ start: uint(0), end: uint(REVERB_TAPS), type: "uint", condition: "<" }, ({ i }: { i: TslNode }) => {
            const tap = taps.element(i).toVar();
            const left = reverbIn.element(reverbRing(frame.sub(uint(tap.x)))).x.mul(tap.y);
            const right = reverbIn.element(reverbRing(frame.sub(uint(tap.z)))).y.mul(tap.w);
            sum.addAssign(vec2(left, right));
          });
          wet.element(index).assign(sum);
        });
      },
    });

    const attack = Math.exp(-1 / (MASTER.attack * RATE));
    const release = Math.exp(-1 / (MASTER.release * RATE));
    const limiterRelease = 1 - Math.exp(-1 / (MASTER.limiterRelease * RATE));
    const slope = 1 / MASTER.ratio - 1;
    const masterKernel = new WgpuCompute({
      label: "scoreMaster",
      workgroupSize: [1],
      body: ({ index }) => {
        If(index.equal(uint(0)), () => {
          const reverbLp = fx.element(uint(4)).toVar();
          const dynamics = fx.element(uint(5)).toVar();
          const reduction = dynamics.x.toVar();
          const limiter = dynamics.y.toVar();
          Loop({ start: uint(0), end: uint(CHUNK), type: "uint", condition: "<" }, ({ i }: { i: TslNode }) => {
            const t = float(u.frame0.add(i)).div(RATE);
            // The reverb impulse's lowpass, then the wet level
            reverbLp.addAssign(wet.element(i).sub(reverbLp).mul(0.35));
            const x = master.element(i).add(reverbLp.mul(score.mix.reverb)).mul(MASTER.gain).toVar();

            // Compressor: a soft knee above the threshold, attack and
            // release on the reduction in dB
            const level = max(abs(x.x), abs(x.y));
            const over = log2(max(level, 1e-6)).mul(6.0206).sub(MASTER.threshold).toVar();
            const target = select(over.lessThanEqual(0), float(0), select(over.lessThan(MASTER.knee), over.mul(over).mul(slope / (2 * MASTER.knee)), over.sub(MASTER.knee / 2).mul(slope))).toVar();
            reduction.assign(target.add(reduction.sub(target).mul(select(target.lessThan(reduction), float(attack), float(release)))));
            const y = x.mul(exp2(reduction.div(6.0206))).mul(MASTER.output * score.mix.output).toVar();

            // Limiter: instant down, slow back up; then the end's fade
            const peak = max(abs(y.x), abs(y.y));
            limiter.assign(min(min(float(1), float(MASTER.ceiling).div(max(peak, 1e-9))), limiter.add(float(1).sub(limiter).mul(limiterRelease))));
            const fade = clamp(float(this.duration).sub(t).div(1.5), 0, 1);
            out.element(u.outBase.add(i)).assign(raw ? x : clamp(y.mul(limiter), -1, 1).mul(fade));
          });
          fx.element(uint(4)).assign(reverbLp);
          fx.element(uint(5)).assign(vec2(reduction, limiter));
        });
      },
    });

    this.kernels = { voices: voiceKernel, mix: mixKernel, delayFilter: delayFilterKernel, delay: delayKernel, reverb: reverbKernel, master: masterKernel };
    this.batch = new WgpuComputeBatch(renderer);
    this.reset();
  }

  // Silence: every voice, filter, delay line and the reverb start over
  reset() {
    this.state.clear();
    this.rings.forEach((ring) => ring.clear());
    this.fx.upload(new Float32Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0]));
  }

  // Chunk `chunk` (frames chunk * CHUNK on) into slot `slot` of the output
  private dispatch(chunk: number, slot: number) {
    const list = this.lists[chunk];
    const { u, kernels } = this;
    u.frame0.value = chunk * CHUNK;
    u.active.value = list.length;
    u.outBase.value = slot * CHUNK;
    if (list.length > 0) {
      this.active.upload(list);
    }
    this.batch
      .add(kernels.voices, list.length)
      .add(kernels.mix, CHUNK)
      .add(kernels.delayFilter, 1)
      .add(kernels.delay, CHUNK)
      .add(kernels.reverb, CHUNK)
      .add(kernels.master, 1)
      .submit();
  }

  // Chunks [first, first + count) in order, after the chunk rendered before
  // them (or a reset): interleaved L, R
  async render(first: number, count = 1): Promise<Float32Array> {
    const result = new Float32Array(count * CHUNK * 2);
    for (let done = 0; done < count; done += BATCH) {
      const n = Math.min(BATCH, count - done);
      for (let k = 0; k < n; k += 1) {
        this.dispatch(first + done + k, k);
      }
      result.set(await this.out.read(0, n * CHUNK), done * CHUNK * 2);
    }
    return result;
  }

  // The whole film from the start (the film's audio track)
  async renderAll(onProgress?: (fraction: number) => void) {
    this.reset();
    const samples = new Float32Array(this.chunks * CHUNK * 2);
    for (let k = 0; k < this.chunks; k += BATCH) {
      const n = Math.min(BATCH, this.chunks - k);
      samples.set(await this.render(k, n), k * CHUNK * 2);
      onProgress?.((k + n) / this.chunks);
    }
    return samples.subarray(0, Math.round(this.duration * RATE) * 2);
  }

  dispose() {
    this.buffers.forEach((b) => b.dispose());
    Object.values(this.kernels).forEach((k) => k.dispose());
  }
}