@hraness/dawg 0.3.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 (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -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 +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -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 +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -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 +280 -278
  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 +1119 -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 +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -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 +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
package/src/audio/wav.ts CHANGED
@@ -2,6 +2,8 @@ import {
2
2
  SCORE_LIMITS,
3
3
  isSamplerInstrument,
4
4
  isTrackAudible,
5
+ isWavetableInstrument,
6
+ wavetableOf,
5
7
  type AutomationPoint,
6
8
  type Note,
7
9
  type Track,
@@ -13,7 +15,46 @@ import {
13
15
  renderSamplerVoices,
14
16
  samplerTailSeconds,
15
17
  } from "./sampler.ts";
16
- import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
18
+ import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
19
+ import { seededRandom } from "./random.ts";
20
+ import {
21
+ isStereoVoice,
22
+ renderSynthNote,
23
+ synthTailSeconds,
24
+ usesSynthVoice,
25
+ type VoiceContext,
26
+ } from "./synth/voice.ts";
27
+ import { legacyWave } from "./synth/oscillators.ts";
28
+ import {
29
+ EMPTY_SAMPLE_BANK,
30
+ sampleKey,
31
+ type DecodedSample,
32
+ type SampleBank,
33
+ } from "./samples.ts";
34
+ import { synthKit } from "../../core/kits.ts";
35
+ import { kitDrumSample, kitTailSeconds, newKitVoiceState } from "./kits.ts";
36
+ import {
37
+ applyMonoChain,
38
+ applyStereoChain,
39
+ delayTailFor,
40
+ interpolateAutomation,
41
+ reverbImpulse,
42
+ reverbTailFor,
43
+ } from "./effects/chain.ts";
44
+ import {
45
+ createOrbitBus,
46
+ renderOrbitBus,
47
+ sendToBus,
48
+ sharedOrbitOf,
49
+ type OrbitBus,
50
+ } from "./effects/bus.ts";
51
+ import { tableFor, wavetableOscillator } from "./wavetable.ts";
52
+ import {
53
+ duckGains,
54
+ duckSettings,
55
+ orbitOf,
56
+ type Ducker,
57
+ } from "./effects/duck.ts";
17
58
 
18
59
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
19
60
 
@@ -53,16 +94,46 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
53
94
  "saw",
54
95
  "square",
55
96
  "triangle",
97
+ "wavetable",
56
98
  "kit",
99
+ // Synth-voice sounds (core/synth.ts), Strudel names; `synth` shapes them.
100
+ "sawtooth",
101
+ "supersaw",
102
+ "pulse",
103
+ "user",
104
+ "white",
105
+ "pink",
106
+ "brown",
107
+ "crackle",
108
+ // ZzFX sounds (src/audio/synth/zzfx.ts).
109
+ "z_sine",
110
+ "z_triangle",
111
+ "z_sawtooth",
112
+ "z_square",
113
+ "z_tan",
114
+ "z_noise",
57
115
  ] as const);
58
116
 
59
- /** Per-track effects understood by the renderer and the agent. */
60
- export const AVAILABLE_EFFECTS = Object.freeze([
61
- "pan (equal-power stereo: -1 left .. 1 right, automatable)",
62
- "filter (low-pass: cutoff 20..20000 Hz and resonance 0..1, both automatable)",
63
- "delay (stereo send: beats 0.0625..4, feedback 0..0.9 and mix 0..1 automatable)",
64
- "reverb (stereo send: mix 0..1, size 0..1)",
65
- ] as const);
117
+ /** Per-track effects understood by the renderer and the agent, in chain
118
+ * order, with each effect's simple parameters (`fx` takes the rest). */
119
+ export const AVAILABLE_EFFECTS: readonly string[] = Object.freeze([
120
+ "pan(-1..1 equal-power)",
121
+ ...EFFECT_NAMES.map((name) => {
122
+ const spec = effectSpec(name);
123
+ return `${name}(${spec.simple.join(" ")})`;
124
+ }),
125
+ ]);
126
+
127
+ /** `fx <effect> preset <name>` names, for the agent brief. */
128
+ export const AVAILABLE_FX_PRESETS: Readonly<Record<string, string>> =
129
+ Object.freeze(
130
+ Object.fromEntries(
131
+ EFFECT_NAMES.filter((name) => FX_PRESETS[name]).map((name) => [
132
+ name,
133
+ Object.keys(FX_PRESETS[name]!).join(" "),
134
+ ]),
135
+ ),
136
+ );
66
137
 
67
138
  /** Longest drum one-shot, in seconds; hits ring past their note length. */
68
139
  const MAX_DRUM_SECONDS = 0.6;
@@ -78,6 +149,8 @@ type RenderContext = Readonly<{
78
149
  sampleRate: number;
79
150
  samples: number;
80
151
  samplesPerTick: number;
152
+ tempoBpm: number;
153
+ irs?: ReadonlyMap<string, DecodedSample>;
81
154
  }>;
82
155
 
83
156
  /** Exact (fractional) loop length in frames at a sample rate. */
@@ -136,16 +209,23 @@ type Stem = {
136
209
  export class StemRenderer {
137
210
  private readonly maxCacheBytes: number;
138
211
  private readonly stems = new Map<string, Stem>();
212
+ /** Shared orbit bus outputs by orbit, keyed by their members' stems. */
213
+ private readonly buses = new Map<
214
+ number,
215
+ { key: string; left: Float64Array; right: Float64Array }
216
+ >();
139
217
  private cacheBytes = 0;
140
218
  private renders = 0;
141
219
  private scratch: {
142
220
  dry: Float64Array;
221
+ dryR: Float64Array;
143
222
  left: Float64Array;
144
223
  right: Float64Array;
145
224
  mixL: Float64Array;
146
225
  mixR: Float64Array;
147
226
  } = {
148
227
  dry: new Float64Array(0),
228
+ dryR: new Float64Array(0),
149
229
  left: new Float64Array(0),
150
230
  right: new Float64Array(0),
151
231
  mixL: new Float64Array(0),
@@ -168,17 +248,21 @@ export class StemRenderer {
168
248
  const sampleRate = clampSampleRate(options.sampleRate);
169
249
  const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
170
250
  const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
251
+ const bank = options.samples ?? EMPTY_SAMPLE_BANK;
171
252
  const reverbTail = Math.max(
172
253
  0,
173
254
  ...score.tracks.map((track) =>
174
- track.reverb && track.reverb.mix > 0 ? 1 + 3 * track.reverb.size : 0,
255
+ reverbTailFor(track, sampleRate, bank.irs),
175
256
  ),
176
257
  );
177
- const bank = options.samples ?? EMPTY_SAMPLE_BANK;
178
258
  // Zero without sampler voices, so synth-only renders are unchanged.
259
+ // Zero without synth-voice tracks, so legacy renders are unchanged.
179
260
  const samplerTail = Math.min(
180
261
  MAX_LOOP_TAIL_SECONDS,
181
- samplerTailSeconds(score, bank, sampleRate),
262
+ Math.max(
263
+ samplerTailSeconds(score, bank, sampleRate),
264
+ ...score.tracks.map(synthTailSeconds),
265
+ ),
182
266
  );
183
267
  let frames: number;
184
268
  let samples: number;
@@ -192,10 +276,13 @@ export class StemRenderer {
192
276
  );
193
277
  const tailSeconds = Math.min(
194
278
  MAX_LOOP_TAIL_SECONDS,
195
- Math.max(MAX_DRUM_SECONDS, samplerTail) +
279
+ Math.max(MAX_DRUM_SECONDS, samplerTail, kitTailSeconds(score)) +
196
280
  0.1 +
197
281
  reverbTail +
198
- delayTailSeconds(score),
282
+ Math.max(
283
+ 0,
284
+ ...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
285
+ ),
199
286
  );
200
287
  samples = frames + Math.ceil(tailSeconds * sampleRate);
201
288
  } else {
@@ -211,9 +298,11 @@ export class StemRenderer {
211
298
  sampleRate,
212
299
  samples,
213
300
  samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
301
+ tempoBpm: score.tempoBpm,
302
+ ...(bank.irs ? { irs: bank.irs } : {}),
214
303
  };
215
304
  this.renders += 1;
216
- const { dry, left, right, mixL, mixR } = this.scratchFor(samples);
305
+ const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
217
306
  mixL.fill(0);
218
307
  mixR.fill(0);
219
308
  const tracks = new Map(score.tracks.map((track) => [track.id, track]));
@@ -223,13 +312,44 @@ export class StemRenderer {
223
312
  if (group) group.push(note);
224
313
  else groups.set(note.trackId, [note]);
225
314
  }
315
+ // Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
316
+ const duckers: Ducker[] = [];
317
+ for (const [trackId, notes] of groups) {
318
+ const settings = duckSettings(tracks.get(trackId));
319
+ if (settings && isTrackAudible(score, trackId))
320
+ duckers.push({
321
+ trackId,
322
+ ...settings,
323
+ onsets: notes.map((note) => noteSpan(note, context).start),
324
+ });
325
+ }
326
+ const ducked = duckGains(
327
+ [...groups.keys()].map((id) => ({ id, orbit: orbitOf(tracks.get(id)) })),
328
+ duckers,
329
+ samples,
330
+ sampleRate,
331
+ options.loop ? frames : undefined,
332
+ );
333
+ // Shared orbit buses (src/audio/effects/bus.ts), created on first send.
334
+ const buses = new Map<number, OrbitBus>();
226
335
  // Tracks render one at a time; the sum order (first note per track) is
227
336
  // part of the output, so cached and cold renders keep it.
228
337
  for (const [trackId, notes] of groups) {
229
338
  if (!isTrackAudible(score, trackId)) continue;
230
339
  const track = tracks.get(trackId);
340
+ const busOrbit = sharedOrbitOf(track);
231
341
  const sampler = isSamplerInstrument(track?.instrument);
232
- const key = stemKey(track, notes, context, sampler ? bank : undefined);
342
+ // Wavetable hook: the oscillator factory for a wavetable track (its
343
+ // table id joins the stem key), undefined for every other instrument.
344
+ const wavetable = track ? wavetableHook(track, bank, context) : undefined;
345
+ const key = stemKey(
346
+ track,
347
+ notes,
348
+ context,
349
+ sampler ? bank : undefined,
350
+ wavetable?.id,
351
+ track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
352
+ );
233
353
  let stem = this.stems.get(trackId);
234
354
  if (stem?.key === key) stem.used = this.renders;
235
355
  else {
@@ -241,8 +361,31 @@ export class StemRenderer {
241
361
  }
242
362
  : { left, right };
243
363
  dry.fill(0);
364
+ const synthVoice = !sampler && usesSynthVoice(track);
365
+ const stereo = synthVoice && isStereoVoice(track);
366
+ if (stereo) dryR.fill(0);
244
367
  if (sampler) {
245
368
  if (track) renderSamplerNotes(dry, notes, track, context, bank);
369
+ } else if (synthVoice && track) {
370
+ const gainAt = (tick: number) => trackGainAt(track, tick);
371
+ const voice = {
372
+ ...context,
373
+ ticksPerBeat: score.ticksPerBeat,
374
+ ...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
375
+ };
376
+ for (const note of notes) {
377
+ const { start, length } = noteSpan(note, context);
378
+ renderSynthNote(
379
+ dry,
380
+ stereo ? dryR : undefined,
381
+ note,
382
+ track,
383
+ start,
384
+ length,
385
+ voice,
386
+ gainAt,
387
+ );
388
+ }
246
389
  } else {
247
390
  const drums = isDrumInstrument(track?.instrument);
248
391
  for (const note of notes) {
@@ -250,12 +393,24 @@ export class StemRenderer {
250
393
  else renderToneNote(dry, note, track, context);
251
394
  }
252
395
  }
253
- if (track) applyLowPass(dry, track, context);
254
- applyPan(dry, target.left, target.right, track, context);
255
- if (track) {
256
- applyDelay(target.left, target.right, track, context);
257
- applyReverb(target.left, target.right, track, context);
258
- }
396
+ if (track) applyMonoChain(dry, track, context);
397
+ if (stereo && track) applyMonoChain(dryR, track, context);
398
+ applyPan(
399
+ dry,
400
+ target.left,
401
+ target.right,
402
+ track,
403
+ context,
404
+ stereo ? dryR : undefined,
405
+ );
406
+ if (track)
407
+ applyStereoChain(
408
+ target.left,
409
+ target.right,
410
+ track,
411
+ context,
412
+ busOrbit === undefined,
413
+ );
259
414
  stem = {
260
415
  key,
261
416
  left: target.left,
@@ -265,11 +420,50 @@ export class StemRenderer {
265
420
  };
266
421
  if (caching) this.store(trackId, stem);
267
422
  }
423
+ if (track && busOrbit !== undefined) {
424
+ let bus = buses.get(busOrbit);
425
+ if (!bus)
426
+ buses.set(busOrbit, (bus = createOrbitBus(busOrbit, samples)));
427
+ sendToBus(bus, track, stem.left, stem.right, context, key);
428
+ }
429
+ const gain = ducked.get(trackId);
430
+ if (gain)
431
+ for (let index = 0; index < samples; index += 1) {
432
+ mixL[index]! += stem.left[index]! * gain[index]!;
433
+ mixR[index]! += stem.right[index]! * gain[index]!;
434
+ }
435
+ else
436
+ for (let index = 0; index < samples; index += 1) {
437
+ mixL[index]! += stem.left[index]!;
438
+ mixR[index]! += stem.right[index]!;
439
+ }
440
+ }
441
+ // Bus returns join after every stem, in orbit order; the orbit's ducking
442
+ // applies to them as to its tracks.
443
+ const busGains = duckGains(
444
+ [...buses.keys()].map((orbit) => ({ id: `bus:${orbit}`, orbit })),
445
+ duckers,
446
+ samples,
447
+ sampleRate,
448
+ options.loop ? frames : undefined,
449
+ );
450
+ for (const orbit of [...buses.keys()].sort((a, b) => a - b)) {
451
+ const bus = buses.get(orbit)!;
452
+ const key = JSON.stringify(bus.keys);
453
+ let wet = this.buses.get(orbit);
454
+ if (wet?.key !== key) {
455
+ wet = { key, ...renderOrbitBus(bus, context) };
456
+ if (this.maxCacheBytes > 0) this.buses.set(orbit, wet);
457
+ }
458
+ const gain = busGains.get(`bus:${orbit}`);
268
459
  for (let index = 0; index < samples; index += 1) {
269
- mixL[index]! += stem.left[index]!;
270
- mixR[index]! += stem.right[index]!;
460
+ const g = gain ? gain[index]! : 1;
461
+ mixL[index]! += wet.left[index]! * g;
462
+ mixR[index]! += wet.right[index]! * g;
271
463
  }
272
464
  }
465
+ for (const orbit of [...this.buses.keys()])
466
+ if (!buses.has(orbit)) this.buses.delete(orbit);
273
467
  // Stems of tracks that left the score are not worth keeping; muted and
274
468
  // unsoloed tracks keep theirs so toggling them back is free.
275
469
  for (const trackId of [...this.stems.keys()])
@@ -299,6 +493,7 @@ export class StemRenderer {
299
493
  if (this.scratch.dry.length < samples) {
300
494
  this.scratch = {
301
495
  dry: new Float64Array(samples),
496
+ dryR: new Float64Array(samples),
302
497
  left: new Float64Array(samples),
303
498
  right: new Float64Array(samples),
304
499
  mixL: new Float64Array(samples),
@@ -308,6 +503,7 @@ export class StemRenderer {
308
503
  const view = (buffer: Float64Array) => buffer.subarray(0, samples);
309
504
  return {
310
505
  dry: view(this.scratch.dry),
506
+ dryR: view(this.scratch.dryR),
311
507
  left: view(this.scratch.left),
312
508
  right: view(this.scratch.right),
313
509
  mixL: view(this.scratch.mixL),
@@ -353,6 +549,8 @@ function stemKey(
353
549
  notes: readonly Note[],
354
550
  context: RenderContext,
355
551
  bank?: SampleBank,
552
+ wavetableId?: string,
553
+ impulseId?: string,
356
554
  ): string {
357
555
  let settings: Record<string, unknown> | null = null;
358
556
  if (track) {
@@ -364,11 +562,16 @@ function stemKey(
364
562
  notes,
365
563
  context.score.tempoBpm,
366
564
  context.score.ticksPerBeat,
565
+ // Bar length: sampler `unit: "c"` stretches a window to whole bars.
566
+ context.score.beatsPerBar,
367
567
  context.sampleRate,
368
568
  context.samples,
369
569
  // Sampler stems also depend on the decoded files: a replaced or missing
370
570
  // sample changes the key even when the score did not change.
371
571
  ...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
572
+ ...(wavetableId ? [wavetableId] : []),
573
+ // A convolution reverb's impulse (its sha256 for a sample).
574
+ ...(impulseId ? [impulseId] : []),
372
575
  ]);
373
576
  }
374
577
 
@@ -400,27 +603,6 @@ function renderSamplerNotes(
400
603
  );
401
604
  }
402
605
 
403
- /** Seconds until the slowest delay decays below -60 dB, bounded. */
404
- function delayTailSeconds(score: TrackScore): number {
405
- let longest = 0;
406
- for (const track of score.tracks) {
407
- if (!track.delay) continue;
408
- const feedback = Math.max(
409
- track.delay.feedback,
410
- ...(track.delayFeedbackAutomation ?? []).map((point) => point.value),
411
- );
412
- const repeats =
413
- feedback <= 0.001
414
- ? 1
415
- : Math.min(64, Math.log(0.001) / Math.log(feedback));
416
- longest = Math.max(
417
- longest,
418
- ((track.delay.beats * 60) / score.tempoBpm) * (repeats + 1),
419
- );
420
- }
421
- return longest;
422
- }
423
-
424
606
  /** Track volume and volume automation at a score tick (pan is applied in stereo). */
425
607
  function trackGainAt(track: Track | undefined, tick: number): number {
426
608
  const trackGain = Math.max(0, Math.min(1, track?.volume ?? 1));
@@ -477,8 +659,7 @@ function renderToneNote(
477
659
  0.28 *
478
660
  trackGainAt(track, note.startTick + elapsed / samplesPerTick);
479
661
  const phase = (frequency * elapsed) / sampleRate;
480
- target[index]! +=
481
- synthSample(instrument, phase, frequency, sampleRate) * envelope;
662
+ target[index]! += legacyWave(instrument, phase) * envelope;
482
663
  }
483
664
  }
484
665
 
@@ -495,9 +676,13 @@ function renderDrumNote(
495
676
  ): void {
496
677
  const { sampleRate, samples, samplesPerTick } = context;
497
678
  const { start } = noteSpan(note, context);
679
+ // A track `kit` swaps in a synthesized kit (src/audio/kits.ts); without
680
+ // one the voices below render exactly as they always have.
681
+ const kit = synthKit(track?.kit);
682
+ const kitState = kit ? newKitVoiceState() : undefined;
498
683
  const end = Math.min(
499
684
  samples,
500
- start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
685
+ start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
501
686
  );
502
687
  const voice = drumVoiceForPitch(note.pitch);
503
688
  const random = seededRandom(`${note.id}:${note.startTick}`);
@@ -512,7 +697,17 @@ function renderDrumNote(
512
697
  const bright = noise - previousNoise;
513
698
  previousNoise = noise;
514
699
  let sample = 0;
515
- if (voice === "kick") {
700
+ if (kit && kitState)
701
+ sample = kitDrumSample(
702
+ kit,
703
+ voice,
704
+ t,
705
+ noise,
706
+ bright,
707
+ kitState,
708
+ sampleRate,
709
+ );
710
+ else if (voice === "kick") {
516
711
  const frequency = 45 + 105 * Math.exp(-t * 28);
517
712
  phase += frequency / sampleRate;
518
713
  sample =
@@ -560,64 +755,6 @@ function renderDrumNote(
560
755
  }
561
756
  }
562
757
 
563
- /**
564
- * RBJ biquad low-pass with static or automated cutoff and resonance.
565
- * Resonance 0..1 maps to Q 0.707..8 and the cutoff is kept under 45% of the
566
- * sample rate, so the filter is stable for every value the score accepts.
567
- */
568
- function applyLowPass(
569
- buffer: Float64Array,
570
- track: Track,
571
- context: RenderContext,
572
- ): void {
573
- const automation = track.filterAutomation ?? [];
574
- const resonanceLane = track.resonanceAutomation ?? [];
575
- if (!track.filter && automation.length === 0 && resonanceLane.length === 0)
576
- return;
577
- const { sampleRate, samplesPerTick } = context;
578
- const staticCutoff = track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
579
- const staticResonance = track.filter?.resonance ?? 0;
580
- const automated = automation.length > 0 || resonanceLane.length > 0;
581
- let b0 = 0;
582
- let b1 = 0;
583
- let b2 = 0;
584
- let a1 = 0;
585
- let a2 = 0;
586
- let z1 = 0;
587
- let z2 = 0;
588
- const update = (cutoff: number, resonance: number) => {
589
- const q = 0.707 + Math.max(0, Math.min(1, resonance)) * 7.293;
590
- const frequency = Math.max(
591
- SCORE_LIMITS.minFilterCutoff,
592
- Math.min(cutoff, sampleRate * 0.45),
593
- );
594
- const omega = (2 * Math.PI * frequency) / sampleRate;
595
- const alpha = Math.sin(omega) / (2 * q);
596
- const cos = Math.cos(omega);
597
- const a0 = 1 + alpha;
598
- b0 = (1 - cos) / 2 / a0;
599
- b1 = (1 - cos) / a0;
600
- b2 = b0;
601
- a1 = (-2 * cos) / a0;
602
- a2 = (1 - alpha) / a0;
603
- };
604
- update(staticCutoff, staticResonance);
605
- for (let index = 0; index < buffer.length; index += 1) {
606
- if (automated && index % CONTROL_SAMPLES === 0) {
607
- const tick = index / samplesPerTick;
608
- update(
609
- interpolateAutomation(automation, tick, staticCutoff),
610
- interpolateAutomation(resonanceLane, tick, staticResonance),
611
- );
612
- }
613
- const input = buffer[index]!;
614
- const output = b0 * input + z1;
615
- z1 = b1 * input - a1 * output + z2;
616
- z2 = b2 * input - a2 * output;
617
- buffer[index] = output;
618
- }
619
- }
620
-
621
758
  /**
622
759
  * Equal-power pan of the mono track into the stereo pair: left = cos(θ),
623
760
  * right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
@@ -629,6 +766,7 @@ function applyPan(
629
766
  right: Float64Array,
630
767
  track: Track | undefined,
631
768
  context: RenderContext,
769
+ dryRight?: Float64Array,
632
770
  ): void {
633
771
  const lane = track?.panAutomation ?? [];
634
772
  const staticPan = track?.pan ?? 0;
@@ -644,189 +782,53 @@ function applyPan(
644
782
  );
645
783
  const sample = dry[index]!;
646
784
  left[index] = sample * gainL;
647
- right[index] = sample * gainR;
648
- }
649
- }
650
-
651
- /**
652
- * Tempo-synced stereo feedback delay. Each side echoes its own input and
653
- * feeds back into the opposite side, so repeats ping-pong across the field.
654
- * Feedback and mix follow their automation lanes when present.
655
- */
656
- function applyDelay(
657
- left: Float64Array,
658
- right: Float64Array,
659
- track: Track,
660
- context: RenderContext,
661
- ): void {
662
- const delay = track.delay;
663
- if (!delay) return;
664
- const feedbackLane = track.delayFeedbackAutomation ?? [];
665
- const mixLane = track.delayMixAutomation ?? [];
666
- if (delay.mix <= 0 && mixLane.length === 0) return;
667
- const { score, sampleRate, samplesPerTick } = context;
668
- const length = Math.max(
669
- 1,
670
- Math.round(((delay.beats * 60) / score.tempoBpm) * sampleRate),
671
- );
672
- const lineL = new Float64Array(length);
673
- const lineR = new Float64Array(length);
674
- let feedback = delay.feedback;
675
- let mix = delay.mix;
676
- const automated = feedbackLane.length > 0 || mixLane.length > 0;
677
- for (let index = 0; index < left.length; index += 1) {
678
- if (automated && index % CONTROL_SAMPLES === 0) {
679
- const tick = index / samplesPerTick;
680
- feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
681
- mix = interpolateAutomation(mixLane, tick, delay.mix);
682
- }
683
- const slot = index % length;
684
- const wetL = lineL[slot]!;
685
- const wetR = lineR[slot]!;
686
- const dryL = left[index]!;
687
- const dryR = right[index]!;
688
- lineL[slot] = dryL + wetR * feedback;
689
- lineR[slot] = dryR + wetL * feedback;
690
- left[index] = dryL + wetL * mix;
691
- right[index] = dryR + wetR * mix;
785
+ // A stereo voice pans its own right channel the same way.
786
+ right[index] = (dryRight ? dryRight[index]! : sample) * gainR;
692
787
  }
693
788
  }
694
789
 
695
- /** Freeverb comb and allpass tunings at 44.1 kHz; the right side is spread. */
696
- const COMB_TUNING = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617] as const;
697
- const ALLPASS_TUNING = [556, 441, 341, 225] as const;
698
- const STEREO_SPREAD = 23;
699
- const REVERB_INPUT_GAIN = 0.015;
700
- const REVERB_WET_SCALE = 3;
790
+ type WavetableHook = Readonly<{
791
+ /** Joins the stem key: a different or missing table re-renders. */
792
+ id: string;
793
+ oscillatorFor: NonNullable<VoiceContext["oscillatorFor"]>;
794
+ }>;
701
795
 
702
796
  /**
703
- * Schroeder/Freeverb-style stereo reverb send: eight damped feedback combs
704
- * in parallel, then four series allpasses, per channel. Size sets the comb
705
- * feedback (0.7..0.98) and darkens the tail; the wet signal is added to the
706
- * dry stereo pair at `mix`.
797
+ * Wavetable hook: for a wavetable track, each note's oscillator reads the
798
+ * track's table (`src/audio/wavetable.ts`); the synth voice does the rest.
707
799
  */
708
- function applyReverb(
709
- left: Float64Array,
710
- right: Float64Array,
800
+ function wavetableHook(
711
801
  track: Track,
802
+ bank: SampleBank,
712
803
  context: RenderContext,
713
- ): void {
714
- const reverb = track.reverb;
715
- if (!reverb || reverb.mix <= 0) return;
716
- const scale = context.sampleRate / 44_100;
717
- const feedback = 0.7 + 0.28 * reverb.size;
718
- const damp = 0.2 + 0.3 * reverb.size;
719
- const wet = reverb.mix * REVERB_WET_SCALE;
720
- const channel = (spread: number) => ({
721
- combs: COMB_TUNING.map((tuning) => ({
722
- buffer: new Float64Array(
723
- Math.max(1, Math.round((tuning + spread) * scale)),
724
- ),
725
- index: 0,
726
- store: 0,
727
- })),
728
- allpasses: ALLPASS_TUNING.map((tuning) => ({
729
- buffer: new Float64Array(
730
- Math.max(1, Math.round((tuning + spread) * scale)),
731
- ),
732
- index: 0,
733
- })),
734
- });
735
- const sides = [channel(0), channel(STEREO_SPREAD)] as const;
736
- const process = (side: (typeof sides)[number], input: number): number => {
737
- let output = 0;
738
- for (const comb of side.combs) {
739
- const delayed = comb.buffer[comb.index]!;
740
- comb.store = delayed * (1 - damp) + comb.store * damp;
741
- comb.buffer[comb.index] = input + comb.store * feedback;
742
- comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
743
- output += delayed;
744
- }
745
- for (const allpass of side.allpasses) {
746
- const delayed = allpass.buffer[allpass.index]!;
747
- allpass.buffer[allpass.index] = output + delayed * 0.5;
748
- output = delayed - output;
749
- allpass.index =
750
- allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
751
- }
752
- return output;
753
- };
754
- for (let index = 0; index < left.length; index += 1) {
755
- const input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
756
- left[index]! += process(sides[0], input) * wet;
757
- right[index]! += process(sides[1], input) * wet;
758
- }
759
- }
760
-
761
- /** FNV-1a seeded mulberry32: integer-only, so identical on every platform. */
762
- function seededRandom(seed: string): () => number {
763
- let hash = 0x811c9dc5;
764
- for (let index = 0; index < seed.length; index += 1) {
765
- hash ^= seed.charCodeAt(index);
766
- hash = Math.imul(hash, 0x01000193) >>> 0;
767
- }
768
- let state = hash;
769
- return () => {
770
- state = (state + 0x6d2b79f5) >>> 0;
771
- let value = Math.imul(state ^ (state >>> 15), 1 | state);
772
- value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
773
- return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
804
+ ): WavetableHook | undefined {
805
+ if (!isWavetableInstrument(track.instrument)) return undefined;
806
+ const settings = wavetableOf(track);
807
+ const table = tableFor(track, settings, bank.wavetables);
808
+ // A pack table that failed to load renders silent (the problem is reported).
809
+ if (!table)
810
+ return {
811
+ id: `missing:${settings.table.src}`,
812
+ oscillatorFor: () => () => () => 0,
813
+ };
814
+ const lane = track.wtAutomation ?? [];
815
+ const positionAt =
816
+ lane.length > 0
817
+ ? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
818
+ : undefined;
819
+ return {
820
+ id: table.id,
821
+ oscillatorFor: (note) =>
822
+ wavetableOscillator(table, settings, note, {
823
+ sampleRate: context.sampleRate,
824
+ samplesPerTick: context.samplesPerTick,
825
+ secondsPerTick: context.samplesPerTick / context.sampleRate,
826
+ gate: noteSpan(note, context).length / context.sampleRate,
827
+ positionAt,
828
+ }),
774
829
  };
775
830
  }
776
831
 
777
- /** Resolve a piecewise-linear automation lane, holding the static value before its first point. */
778
- function interpolateAutomation(
779
- points: readonly AutomationPoint[],
780
- tick: number,
781
- fallback: number,
782
- ): number {
783
- if (points.length === 0 || !Number.isFinite(tick)) return fallback;
784
- const first = points[0]!;
785
- if (tick < first.tick) return fallback;
786
- const last = points[points.length - 1]!;
787
- if (tick >= last.tick) return last.value;
788
- for (let index = 1; index < points.length; index += 1) {
789
- const right = points[index]!;
790
- if (tick > right.tick) continue;
791
- const left = points[index - 1]!;
792
- const span = right.tick - left.tick;
793
- const ratio = span <= 0 ? 1 : (tick - left.tick) / span;
794
- return left.value + (right.value - left.value) * ratio;
795
- }
796
- return last.value;
797
- }
798
-
799
- /** A tiny deterministic instrument bank. Names come from the score's track metadata. */
800
- function synthSample(
801
- instrument: string,
802
- phase: number,
803
- frequency: number,
804
- sampleRate: number,
805
- ): number {
806
- const name = instrument.trim().toLowerCase();
807
- const cycle = phase - Math.floor(phase);
808
- const sine = Math.sin(2 * Math.PI * phase);
809
- if (name.includes("square")) return cycle < 0.5 ? 1 : -1;
810
- if (name.includes("saw")) return 2 * cycle - 1;
811
- if (name.includes("triangle")) return 1 - 4 * Math.abs(cycle - 0.5);
812
- if (name.includes("bass")) {
813
- // A rounded fundamental plus a quiet octave gives bass tracks useful weight.
814
- return Math.tanh(
815
- 0.9 * Math.sin(2 * Math.PI * phase) +
816
- 0.25 * Math.sin(4 * Math.PI * phase),
817
- );
818
- }
819
- if (name.includes("piano") || name.includes("pluck")) {
820
- // Add stable harmonics; the envelope above supplies the note decay.
821
- const harmonic =
822
- Math.sin(4 * Math.PI * phase) * 0.28 +
823
- Math.sin(6 * Math.PI * phase) * 0.12;
824
- return Math.tanh(sine + harmonic);
825
- }
826
- // Unknown instruments deliberately fall back to the original sine voice.
827
- return sine;
828
- }
829
-
830
832
  export function encodeWav(
831
833
  pcm: Int16Array,
832
834
  sampleRate: number,