@hraness/dawg 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
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,50 @@ 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
+ addReverbWet,
38
+ applyMonoChain,
39
+ applyStereoChain,
40
+ delayTailFor,
41
+ interpolateAutomation,
42
+ reverbActive,
43
+ reverbImpulse,
44
+ reverbTailFor,
45
+ reverbWet,
46
+ type ReverbWet,
47
+ } from "./effects/chain.ts";
48
+ import {
49
+ createOrbitBus,
50
+ renderOrbitBus,
51
+ sendToBus,
52
+ sharedOrbitOf,
53
+ type OrbitBus,
54
+ } from "./effects/bus.ts";
55
+ import { tableFor, wavetableOscillator } from "./wavetable.ts";
56
+ import {
57
+ duckGains,
58
+ duckSettings,
59
+ orbitOf,
60
+ type Ducker,
61
+ } from "./effects/duck.ts";
17
62
 
18
63
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
19
64
 
@@ -53,16 +98,46 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
53
98
  "saw",
54
99
  "square",
55
100
  "triangle",
101
+ "wavetable",
56
102
  "kit",
103
+ // Synth-voice sounds (core/synth.ts), Strudel names; `synth` shapes them.
104
+ "sawtooth",
105
+ "supersaw",
106
+ "pulse",
107
+ "user",
108
+ "white",
109
+ "pink",
110
+ "brown",
111
+ "crackle",
112
+ // ZzFX sounds (src/audio/synth/zzfx.ts).
113
+ "z_sine",
114
+ "z_triangle",
115
+ "z_sawtooth",
116
+ "z_square",
117
+ "z_tan",
118
+ "z_noise",
57
119
  ] as const);
58
120
 
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);
121
+ /** Per-track effects understood by the renderer and the agent, in chain
122
+ * order, with each effect's simple parameters (`fx` takes the rest). */
123
+ export const AVAILABLE_EFFECTS: readonly string[] = Object.freeze([
124
+ "pan(-1..1 equal-power)",
125
+ ...EFFECT_NAMES.map((name) => {
126
+ const spec = effectSpec(name);
127
+ return `${name}(${spec.simple.join(" ")})`;
128
+ }),
129
+ ]);
130
+
131
+ /** `fx <effect> preset <name>` names, for the agent brief. */
132
+ export const AVAILABLE_FX_PRESETS: Readonly<Record<string, string>> =
133
+ Object.freeze(
134
+ Object.fromEntries(
135
+ EFFECT_NAMES.filter((name) => FX_PRESETS[name]).map((name) => [
136
+ name,
137
+ Object.keys(FX_PRESETS[name]!).join(" "),
138
+ ]),
139
+ ),
140
+ );
66
141
 
67
142
  /** Longest drum one-shot, in seconds; hits ring past their note length. */
68
143
  const MAX_DRUM_SECONDS = 0.6;
@@ -78,6 +153,8 @@ type RenderContext = Readonly<{
78
153
  sampleRate: number;
79
154
  samples: number;
80
155
  samplesPerTick: number;
156
+ tempoBpm: number;
157
+ irs?: ReadonlyMap<string, DecodedSample>;
81
158
  }>;
82
159
 
83
160
  /** Exact (fractional) loop length in frames at a sample rate. */
@@ -120,8 +197,16 @@ type Stem = {
120
197
  key: string;
121
198
  left: Float64Array;
122
199
  right: Float64Array;
200
+ /** Stem, pre-reverb pair and tail (counted in the cache budget). */
123
201
  bytes: number;
124
202
  used: number;
203
+ /** For a track with a reverb: its stem before the reverb, and the tail. */
204
+ room?: Readonly<{
205
+ /** `stemKey` of the track with its reverb mix and mix lane left out. */
206
+ key: string;
207
+ pre: Readonly<{ left: Float64Array; right: Float64Array }>;
208
+ wet: ReverbWet;
209
+ }>;
125
210
  };
126
211
 
127
212
  /**
@@ -136,16 +221,23 @@ type Stem = {
136
221
  export class StemRenderer {
137
222
  private readonly maxCacheBytes: number;
138
223
  private readonly stems = new Map<string, Stem>();
224
+ /** Shared orbit bus outputs by orbit, keyed by their members' stems. */
225
+ private readonly buses = new Map<
226
+ number,
227
+ { key: string; left: Float64Array; right: Float64Array }
228
+ >();
139
229
  private cacheBytes = 0;
140
230
  private renders = 0;
141
231
  private scratch: {
142
232
  dry: Float64Array;
233
+ dryR: Float64Array;
143
234
  left: Float64Array;
144
235
  right: Float64Array;
145
236
  mixL: Float64Array;
146
237
  mixR: Float64Array;
147
238
  } = {
148
239
  dry: new Float64Array(0),
240
+ dryR: new Float64Array(0),
149
241
  left: new Float64Array(0),
150
242
  right: new Float64Array(0),
151
243
  mixL: new Float64Array(0),
@@ -168,17 +260,21 @@ export class StemRenderer {
168
260
  const sampleRate = clampSampleRate(options.sampleRate);
169
261
  const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
170
262
  const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
263
+ const bank = options.samples ?? EMPTY_SAMPLE_BANK;
171
264
  const reverbTail = Math.max(
172
265
  0,
173
266
  ...score.tracks.map((track) =>
174
- track.reverb && track.reverb.mix > 0 ? 1 + 3 * track.reverb.size : 0,
267
+ reverbTailFor(track, sampleRate, bank.irs),
175
268
  ),
176
269
  );
177
- const bank = options.samples ?? EMPTY_SAMPLE_BANK;
178
270
  // Zero without sampler voices, so synth-only renders are unchanged.
271
+ // Zero without synth-voice tracks, so legacy renders are unchanged.
179
272
  const samplerTail = Math.min(
180
273
  MAX_LOOP_TAIL_SECONDS,
181
- samplerTailSeconds(score, bank, sampleRate),
274
+ Math.max(
275
+ samplerTailSeconds(score, bank, sampleRate),
276
+ ...score.tracks.map(synthTailSeconds),
277
+ ),
182
278
  );
183
279
  let frames: number;
184
280
  let samples: number;
@@ -192,10 +288,13 @@ export class StemRenderer {
192
288
  );
193
289
  const tailSeconds = Math.min(
194
290
  MAX_LOOP_TAIL_SECONDS,
195
- Math.max(MAX_DRUM_SECONDS, samplerTail) +
291
+ Math.max(MAX_DRUM_SECONDS, samplerTail, kitTailSeconds(score)) +
196
292
  0.1 +
197
293
  reverbTail +
198
- delayTailSeconds(score),
294
+ Math.max(
295
+ 0,
296
+ ...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
297
+ ),
199
298
  );
200
299
  samples = frames + Math.ceil(tailSeconds * sampleRate);
201
300
  } else {
@@ -211,9 +310,11 @@ export class StemRenderer {
211
310
  sampleRate,
212
311
  samples,
213
312
  samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
313
+ tempoBpm: score.tempoBpm,
314
+ ...(bank.irs ? { irs: bank.irs } : {}),
214
315
  };
215
316
  this.renders += 1;
216
- const { dry, left, right, mixL, mixR } = this.scratchFor(samples);
317
+ const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
217
318
  mixL.fill(0);
218
319
  mixR.fill(0);
219
320
  const tracks = new Map(score.tracks.map((track) => [track.id, track]));
@@ -223,17 +324,77 @@ export class StemRenderer {
223
324
  if (group) group.push(note);
224
325
  else groups.set(note.trackId, [note]);
225
326
  }
327
+ // Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
328
+ const duckers: Ducker[] = [];
329
+ for (const [trackId, notes] of groups) {
330
+ const settings = duckSettings(tracks.get(trackId));
331
+ if (settings && isTrackAudible(score, trackId))
332
+ duckers.push({
333
+ trackId,
334
+ ...settings,
335
+ onsets: notes.map((note) => noteSpan(note, context).start),
336
+ });
337
+ }
338
+ const ducked = duckGains(
339
+ [...groups.keys()].map((id) => ({ id, orbit: orbitOf(tracks.get(id)) })),
340
+ duckers,
341
+ samples,
342
+ sampleRate,
343
+ options.loop ? frames : undefined,
344
+ );
345
+ // Shared orbit buses (src/audio/effects/bus.ts), created on first send.
346
+ const buses = new Map<number, OrbitBus>();
226
347
  // Tracks render one at a time; the sum order (first note per track) is
227
348
  // part of the output, so cached and cold renders keep it.
228
349
  for (const [trackId, notes] of groups) {
229
350
  if (!isTrackAudible(score, trackId)) continue;
230
351
  const track = tracks.get(trackId);
352
+ const busOrbit = sharedOrbitOf(track);
231
353
  const sampler = isSamplerInstrument(track?.instrument);
232
- const key = stemKey(track, notes, context, sampler ? bank : undefined);
354
+ // Wavetable hook: the oscillator factory for a wavetable track (its
355
+ // table id joins the stem key), undefined for every other instrument.
356
+ const wavetable = track ? wavetableHook(track, bank, context) : undefined;
357
+ const key = stemKey(
358
+ track,
359
+ notes,
360
+ context,
361
+ sampler ? bank : undefined,
362
+ wavetable?.id,
363
+ track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
364
+ );
233
365
  let stem = this.stems.get(trackId);
366
+ const caching = this.maxCacheBytes > 0;
367
+ // A cached track with a reverb also keeps its pre-reverb pair and the
368
+ // tail before its mix gain, keyed without the mix: a mix-only edit
369
+ // re-adds the tail instead of re-running the voices and the room.
370
+ const room =
371
+ caching && track && busOrbit === undefined && reverbActive(track)
372
+ ? stemKey(
373
+ roomTrack(track),
374
+ notes,
375
+ context,
376
+ sampler ? bank : undefined,
377
+ wavetable?.id,
378
+ reverbImpulse(track, sampleRate, bank.irs)?.id,
379
+ )
380
+ : undefined;
234
381
  if (stem?.key === key) stem.used = this.renders;
235
- else {
236
- const caching = this.maxCacheBytes > 0;
382
+ else if (track && room !== undefined && stem?.room?.key === room) {
383
+ const { pre, wet } = stem.room;
384
+ const target = {
385
+ left: Float64Array.from(pre.left),
386
+ right: Float64Array.from(pre.right),
387
+ };
388
+ addReverbWet(target.left, target.right, wet, track, context);
389
+ stem = {
390
+ ...stem,
391
+ key,
392
+ left: target.left,
393
+ right: target.right,
394
+ used: this.renders,
395
+ };
396
+ this.store(trackId, stem);
397
+ } else {
237
398
  const target = caching
238
399
  ? {
239
400
  left: new Float64Array(samples),
@@ -241,8 +402,31 @@ export class StemRenderer {
241
402
  }
242
403
  : { left, right };
243
404
  dry.fill(0);
405
+ const synthVoice = !sampler && usesSynthVoice(track);
406
+ const stereo = synthVoice && isStereoVoice(track);
407
+ if (stereo) dryR.fill(0);
244
408
  if (sampler) {
245
409
  if (track) renderSamplerNotes(dry, notes, track, context, bank);
410
+ } else if (synthVoice && track) {
411
+ const gainAt = (tick: number) => trackGainAt(track, tick);
412
+ const voice = {
413
+ ...context,
414
+ ticksPerBeat: score.ticksPerBeat,
415
+ ...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
416
+ };
417
+ for (const note of notes) {
418
+ const { start, length } = noteSpan(note, context);
419
+ renderSynthNote(
420
+ dry,
421
+ stereo ? dryR : undefined,
422
+ note,
423
+ track,
424
+ start,
425
+ length,
426
+ voice,
427
+ gainAt,
428
+ );
429
+ }
246
430
  } else {
247
431
  const drums = isDrumInstrument(track?.instrument);
248
432
  for (const note of notes) {
@@ -250,43 +434,99 @@ export class StemRenderer {
250
434
  else renderToneNote(dry, note, track, context);
251
435
  }
252
436
  }
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
- }
437
+ if (track) applyMonoChain(dry, track, context);
438
+ if (stereo && track) applyMonoChain(dryR, track, context);
439
+ applyPan(
440
+ dry,
441
+ target.left,
442
+ target.right,
443
+ track,
444
+ context,
445
+ stereo ? dryR : undefined,
446
+ );
447
+ let saved: Stem["room"];
448
+ if (track && room !== undefined) {
449
+ // Same chain, the reverb split in two (see `applyReverb`).
450
+ applyStereoChain(
451
+ target.left,
452
+ target.right,
453
+ track,
454
+ context,
455
+ true,
456
+ false,
457
+ );
458
+ const pre = {
459
+ left: Float64Array.from(target.left),
460
+ right: Float64Array.from(target.right),
461
+ };
462
+ const wet = reverbWet(target.left, target.right, track, context);
463
+ if (wet) {
464
+ addReverbWet(target.left, target.right, wet, track, context);
465
+ saved = { key: room, pre, wet };
466
+ }
467
+ } else if (track)
468
+ applyStereoChain(
469
+ target.left,
470
+ target.right,
471
+ track,
472
+ context,
473
+ busOrbit === undefined,
474
+ );
259
475
  stem = {
260
476
  key,
261
477
  left: target.left,
262
478
  right: target.right,
263
- bytes: target.left.byteLength + target.right.byteLength,
479
+ bytes: target.left.byteLength * (saved ? 6 : 2),
264
480
  used: this.renders,
481
+ ...(saved ? { room: saved } : {}),
265
482
  };
266
483
  if (caching) this.store(trackId, stem);
267
484
  }
268
- for (let index = 0; index < samples; index += 1) {
269
- mixL[index]! += stem.left[index]!;
270
- mixR[index]! += stem.right[index]!;
485
+ if (track && busOrbit !== undefined) {
486
+ let bus = buses.get(busOrbit);
487
+ if (!bus)
488
+ buses.set(busOrbit, (bus = createOrbitBus(busOrbit, samples)));
489
+ sendToBus(bus, track, stem.left, stem.right, context, key);
271
490
  }
491
+ addStem(mixL, mixR, stem.left, stem.right, samples, ducked.get(trackId));
272
492
  }
493
+ // Bus returns join after every stem, in orbit order; the orbit's ducking
494
+ // applies to them as to its tracks.
495
+ const busGains = duckGains(
496
+ [...buses.keys()].map((orbit) => ({ id: `bus:${orbit}`, orbit })),
497
+ duckers,
498
+ samples,
499
+ sampleRate,
500
+ options.loop ? frames : undefined,
501
+ );
502
+ for (const orbit of [...buses.keys()].sort((a, b) => a - b)) {
503
+ const bus = buses.get(orbit)!;
504
+ const key = JSON.stringify(bus.keys);
505
+ let wet = this.buses.get(orbit);
506
+ if (wet?.key !== key) {
507
+ wet = { key, ...renderOrbitBus(bus, context) };
508
+ if (this.maxCacheBytes > 0) this.buses.set(orbit, wet);
509
+ }
510
+ // (x * 1 === x, so an unducked bus adds its plain samples.)
511
+ addStem(
512
+ mixL,
513
+ mixR,
514
+ wet.left,
515
+ wet.right,
516
+ samples,
517
+ busGains.get(`bus:${orbit}`),
518
+ );
519
+ }
520
+ for (const orbit of [...this.buses.keys()])
521
+ if (!buses.has(orbit)) this.buses.delete(orbit);
273
522
  // Stems of tracks that left the score are not worth keeping; muted and
274
523
  // unsoloed tracks keep theirs so toggling them back is free.
275
524
  for (const trackId of [...this.stems.keys()])
276
525
  if (!groups.has(trackId)) this.evict(trackId);
277
- if (samples > frames) {
278
- // Linear effects superpose, so folding the tail onto the start yields
279
- // the steady state of the loop playing forever.
280
- for (let index = frames; index < samples; index += 1) {
281
- mixL[index % frames]! += mixL[index]!;
282
- mixR[index % frames]! += mixR[index]!;
283
- }
284
- }
285
- const pcm = new Int16Array(frames * RENDER_CHANNELS);
286
- for (let index = 0; index < frames; index += 1) {
287
- pcm[index * 2] = clamp16(mixL[index]! * 32767);
288
- pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
289
- }
526
+ // Linear effects superpose, so folding the tail onto the start yields
527
+ // the steady state of the loop playing forever.
528
+ if (samples > frames) foldTail(mixL, mixR, frames, samples);
529
+ const pcm = toPcm(mixL, mixR, frames);
290
530
  return Object.freeze({
291
531
  sampleRate,
292
532
  channels: RENDER_CHANNELS,
@@ -299,6 +539,7 @@ export class StemRenderer {
299
539
  if (this.scratch.dry.length < samples) {
300
540
  this.scratch = {
301
541
  dry: new Float64Array(samples),
542
+ dryR: new Float64Array(samples),
302
543
  left: new Float64Array(samples),
303
544
  right: new Float64Array(samples),
304
545
  mixL: new Float64Array(samples),
@@ -308,6 +549,7 @@ export class StemRenderer {
308
549
  const view = (buffer: Float64Array) => buffer.subarray(0, samples);
309
550
  return {
310
551
  dry: view(this.scratch.dry),
552
+ dryR: view(this.scratch.dryR),
311
553
  left: view(this.scratch.left),
312
554
  right: view(this.scratch.right),
313
555
  mixL: view(this.scratch.mixL),
@@ -347,12 +589,82 @@ export class StemRenderer {
347
589
  }
348
590
  }
349
591
 
592
+ /*
593
+ * The mix loops live in small functions of their own: inside `render` (a
594
+ * large function the JIT optimizes late) they ran several times slower.
595
+ * Same operations in the same order, so the output is unchanged.
596
+ */
597
+
598
+ /** Add a stem (times its ducking gain, if any) into the mix. */
599
+ function addStem(
600
+ mixL: Float64Array,
601
+ mixR: Float64Array,
602
+ left: Float64Array,
603
+ right: Float64Array,
604
+ samples: number,
605
+ gain: Float64Array | undefined,
606
+ ): void {
607
+ if (gain)
608
+ for (let index = 0; index < samples; index += 1) {
609
+ mixL[index]! += left[index]! * gain[index]!;
610
+ mixR[index]! += right[index]! * gain[index]!;
611
+ }
612
+ else
613
+ for (let index = 0; index < samples; index += 1) {
614
+ mixL[index]! += left[index]!;
615
+ mixR[index]! += right[index]!;
616
+ }
617
+ }
618
+
619
+ /** Fold everything past `frames` back onto the loop start. */
620
+ function foldTail(
621
+ mixL: Float64Array,
622
+ mixR: Float64Array,
623
+ frames: number,
624
+ samples: number,
625
+ ): void {
626
+ for (let index = frames; index < samples; index += 1) {
627
+ mixL[index % frames]! += mixL[index]!;
628
+ mixR[index % frames]! += mixR[index]!;
629
+ }
630
+ }
631
+
632
+ /** Interleaved 16-bit PCM of the first `frames` mix samples. */
633
+ function toPcm(
634
+ mixL: Float64Array,
635
+ mixR: Float64Array,
636
+ frames: number,
637
+ ): Int16Array {
638
+ const pcm = new Int16Array(frames * RENDER_CHANNELS);
639
+ for (let index = 0; index < frames; index += 1) {
640
+ pcm[index * 2] = clamp16(mixL[index]! * 32767);
641
+ pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
642
+ }
643
+ return pcm;
644
+ }
645
+
350
646
  /** Everything a track's stem depends on, except mute and solo. */
647
+ /**
648
+ * The track as its reverb tail sees it: everything but the reverb's mix
649
+ * and mix lane, which only scale the tail.
650
+ */
651
+ function roomTrack(track: Track): Track {
652
+ const lanes = { ...track.fxAutomation };
653
+ delete lanes["reverb-mix"];
654
+ return {
655
+ ...track,
656
+ reverb: { ...track.reverb!, mix: 1 },
657
+ fxAutomation: lanes,
658
+ } as Track;
659
+ }
660
+
351
661
  function stemKey(
352
662
  track: Track | undefined,
353
663
  notes: readonly Note[],
354
664
  context: RenderContext,
355
665
  bank?: SampleBank,
666
+ wavetableId?: string,
667
+ impulseId?: string,
356
668
  ): string {
357
669
  let settings: Record<string, unknown> | null = null;
358
670
  if (track) {
@@ -364,11 +676,16 @@ function stemKey(
364
676
  notes,
365
677
  context.score.tempoBpm,
366
678
  context.score.ticksPerBeat,
679
+ // Bar length: sampler `unit: "c"` stretches a window to whole bars.
680
+ context.score.beatsPerBar,
367
681
  context.sampleRate,
368
682
  context.samples,
369
683
  // Sampler stems also depend on the decoded files: a replaced or missing
370
684
  // sample changes the key even when the score did not change.
371
685
  ...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
686
+ ...(wavetableId ? [wavetableId] : []),
687
+ // A convolution reverb's impulse (its sha256 for a sample).
688
+ ...(impulseId ? [impulseId] : []),
372
689
  ]);
373
690
  }
374
691
 
@@ -400,27 +717,6 @@ function renderSamplerNotes(
400
717
  );
401
718
  }
402
719
 
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
720
  /** Track volume and volume automation at a score tick (pan is applied in stereo). */
425
721
  function trackGainAt(track: Track | undefined, tick: number): number {
426
722
  const trackGain = Math.max(0, Math.min(1, track?.volume ?? 1));
@@ -477,8 +773,7 @@ function renderToneNote(
477
773
  0.28 *
478
774
  trackGainAt(track, note.startTick + elapsed / samplesPerTick);
479
775
  const phase = (frequency * elapsed) / sampleRate;
480
- target[index]! +=
481
- synthSample(instrument, phase, frequency, sampleRate) * envelope;
776
+ target[index]! += legacyWave(instrument, phase) * envelope;
482
777
  }
483
778
  }
484
779
 
@@ -495,9 +790,13 @@ function renderDrumNote(
495
790
  ): void {
496
791
  const { sampleRate, samples, samplesPerTick } = context;
497
792
  const { start } = noteSpan(note, context);
793
+ // A track `kit` swaps in a synthesized kit (src/audio/kits.ts); without
794
+ // one the voices below render exactly as they always have.
795
+ const kit = synthKit(track?.kit);
796
+ const kitState = kit ? newKitVoiceState() : undefined;
498
797
  const end = Math.min(
499
798
  samples,
500
- start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
799
+ start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
501
800
  );
502
801
  const voice = drumVoiceForPitch(note.pitch);
503
802
  const random = seededRandom(`${note.id}:${note.startTick}`);
@@ -512,7 +811,17 @@ function renderDrumNote(
512
811
  const bright = noise - previousNoise;
513
812
  previousNoise = noise;
514
813
  let sample = 0;
515
- if (voice === "kick") {
814
+ if (kit && kitState)
815
+ sample = kitDrumSample(
816
+ kit,
817
+ voice,
818
+ t,
819
+ noise,
820
+ bright,
821
+ kitState,
822
+ sampleRate,
823
+ );
824
+ else if (voice === "kick") {
516
825
  const frequency = 45 + 105 * Math.exp(-t * 28);
517
826
  phase += frequency / sampleRate;
518
827
  sample =
@@ -560,64 +869,6 @@ function renderDrumNote(
560
869
  }
561
870
  }
562
871
 
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
872
  /**
622
873
  * Equal-power pan of the mono track into the stereo pair: left = cos(θ),
623
874
  * right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
@@ -629,6 +880,7 @@ function applyPan(
629
880
  right: Float64Array,
630
881
  track: Track | undefined,
631
882
  context: RenderContext,
883
+ dryRight?: Float64Array,
632
884
  ): void {
633
885
  const lane = track?.panAutomation ?? [];
634
886
  const staticPan = track?.pan ?? 0;
@@ -644,189 +896,53 @@ function applyPan(
644
896
  );
645
897
  const sample = dry[index]!;
646
898
  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;
899
+ // A stereo voice pans its own right channel the same way.
900
+ right[index] = (dryRight ? dryRight[index]! : sample) * gainR;
692
901
  }
693
902
  }
694
903
 
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;
904
+ type WavetableHook = Readonly<{
905
+ /** Joins the stem key: a different or missing table re-renders. */
906
+ id: string;
907
+ oscillatorFor: NonNullable<VoiceContext["oscillatorFor"]>;
908
+ }>;
701
909
 
702
910
  /**
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`.
911
+ * Wavetable hook: for a wavetable track, each note's oscillator reads the
912
+ * track's table (`src/audio/wavetable.ts`); the synth voice does the rest.
707
913
  */
708
- function applyReverb(
709
- left: Float64Array,
710
- right: Float64Array,
914
+ function wavetableHook(
711
915
  track: Track,
916
+ bank: SampleBank,
712
917
  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;
918
+ ): WavetableHook | undefined {
919
+ if (!isWavetableInstrument(track.instrument)) return undefined;
920
+ const settings = wavetableOf(track);
921
+ const table = tableFor(track, settings, bank.wavetables);
922
+ // A pack table that failed to load renders silent (the problem is reported).
923
+ if (!table)
924
+ return {
925
+ id: `missing:${settings.table.src}`,
926
+ oscillatorFor: () => () => () => 0,
927
+ };
928
+ const lane = track.wtAutomation ?? [];
929
+ const positionAt =
930
+ lane.length > 0
931
+ ? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
932
+ : undefined;
933
+ return {
934
+ id: table.id,
935
+ oscillatorFor: (note) =>
936
+ wavetableOscillator(table, settings, note, {
937
+ sampleRate: context.sampleRate,
938
+ samplesPerTick: context.samplesPerTick,
939
+ secondsPerTick: context.samplesPerTick / context.sampleRate,
940
+ gate: noteSpan(note, context).length / context.sampleRate,
941
+ positionAt,
942
+ }),
774
943
  };
775
944
  }
776
945
 
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
946
  export function encodeWav(
831
947
  pcm: Int16Array,
832
948
  sampleRate: number,