@hraness/dawg 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
package/src/audio/wav.ts CHANGED
@@ -1,12 +1,60 @@
1
1
  import {
2
2
  SCORE_LIMITS,
3
+ isSamplerInstrument,
3
4
  isTrackAudible,
5
+ isWavetableInstrument,
6
+ wavetableOf,
4
7
  type AutomationPoint,
5
8
  type Note,
6
9
  type Track,
7
10
  type TrackScore,
8
11
  } from "../../core/score.ts";
9
12
  import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
13
+ import {
14
+ planSamplerVoices,
15
+ renderSamplerVoices,
16
+ samplerTailSeconds,
17
+ } from "./sampler.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";
10
58
 
11
59
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
12
60
 
@@ -18,6 +66,11 @@ export type RenderOptions = WavOptions &
18
66
  * what the streaming engine plays; exports keep the default one-shot.
19
67
  */
20
68
  loop?: boolean;
69
+ /**
70
+ * Decoded sampler voices (`SampleLibrary.load`). Without it sampler
71
+ * tracks render silent; scores without samplers are unaffected.
72
+ */
73
+ samples?: SampleBank;
21
74
  }>;
22
75
 
23
76
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -41,16 +94,46 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
41
94
  "saw",
42
95
  "square",
43
96
  "triangle",
97
+ "wavetable",
44
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",
45
115
  ] as const);
46
116
 
47
- /** Per-track effects understood by the renderer and the agent. */
48
- export const AVAILABLE_EFFECTS = Object.freeze([
49
- "pan (equal-power stereo: -1 left .. 1 right, automatable)",
50
- "filter (low-pass: cutoff 20..20000 Hz and resonance 0..1, both automatable)",
51
- "delay (stereo send: beats 0.0625..4, feedback 0..0.9 and mix 0..1 automatable)",
52
- "reverb (stereo send: mix 0..1, size 0..1)",
53
- ] 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
+ );
54
137
 
55
138
  /** Longest drum one-shot, in seconds; hits ring past their note length. */
56
139
  const MAX_DRUM_SECONDS = 0.6;
@@ -66,6 +149,8 @@ type RenderContext = Readonly<{
66
149
  sampleRate: number;
67
150
  samples: number;
68
151
  samplesPerTick: number;
152
+ tempoBpm: number;
153
+ irs?: ReadonlyMap<string, DecodedSample>;
69
154
  }>;
70
155
 
71
156
  /** Exact (fractional) loop length in frames at a sample rate. */
@@ -83,7 +168,7 @@ export function clampSampleRate(sampleRate?: number): number {
83
168
  /** Render the bounded score to a stereo 16-bit PCM WAV. */
84
169
  export function renderScoreWav(
85
170
  score: TrackScore,
86
- options: WavOptions = {},
171
+ options: WavOptions & Pick<RenderOptions, "samples"> = {},
87
172
  ): Uint8Array {
88
173
  const audio = renderScorePcm(score, options);
89
174
  return encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
@@ -98,113 +183,424 @@ export function renderScorePcm(
98
183
  score: TrackScore,
99
184
  options: RenderOptions = {},
100
185
  ): RenderedAudio {
101
- const sampleRate = clampSampleRate(options.sampleRate);
102
- const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
103
- const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
104
- const reverbTail = Math.max(
105
- 0,
106
- ...score.tracks.map((track) =>
107
- track.reverb && track.reverb.mix > 0 ? 1 + 3 * track.reverb.size : 0,
108
- ),
109
- );
110
- let frames: number;
111
- let samples: number;
112
- if (options.loop) {
113
- frames = Math.max(
114
- 1,
115
- Math.min(
116
- Math.round(loopFrames(score, sampleRate)),
117
- Math.ceil(maxSeconds * sampleRate),
186
+ return new StemRenderer({ maxCacheBytes: 0 }).render(score, options);
187
+ }
188
+
189
+ /** Default byte budget for cached per-track stems. */
190
+ export const DEFAULT_STEM_CACHE_BYTES = 256 * 1024 * 1024;
191
+
192
+ type Stem = {
193
+ key: string;
194
+ left: Float64Array;
195
+ right: Float64Array;
196
+ bytes: number;
197
+ used: number;
198
+ };
199
+
200
+ /**
201
+ * The renderer behind `renderScorePcm`, with a per-track stem cache. Each
202
+ * track's stereo stem (after its own effects) is keyed by everything that
203
+ * shapes it: the track settings, its notes, tempo, tick resolution, sample
204
+ * rate and buffer length. Edits re-render only the tracks whose key changed
205
+ * and sum the cached rest in the original order, so the result is
206
+ * byte-identical to a cold render; mute and solo only pick which stems are
207
+ * summed. Scratch buffers are reused across renders.
208
+ */
209
+ export class StemRenderer {
210
+ private readonly maxCacheBytes: number;
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
+ >();
217
+ private cacheBytes = 0;
218
+ private renders = 0;
219
+ private scratch: {
220
+ dry: Float64Array;
221
+ dryR: Float64Array;
222
+ left: Float64Array;
223
+ right: Float64Array;
224
+ mixL: Float64Array;
225
+ mixR: Float64Array;
226
+ } = {
227
+ dry: new Float64Array(0),
228
+ dryR: new Float64Array(0),
229
+ left: new Float64Array(0),
230
+ right: new Float64Array(0),
231
+ mixL: new Float64Array(0),
232
+ mixR: new Float64Array(0),
233
+ };
234
+
235
+ public constructor(options: Readonly<{ maxCacheBytes?: number }> = {}) {
236
+ this.maxCacheBytes = Math.max(
237
+ 0,
238
+ options.maxCacheBytes ?? DEFAULT_STEM_CACHE_BYTES,
239
+ );
240
+ }
241
+
242
+ /** Cached stems and their total size, for tests and diagnostics. */
243
+ public get cache(): Readonly<{ stems: number; bytes: number }> {
244
+ return { stems: this.stems.size, bytes: this.cacheBytes };
245
+ }
246
+
247
+ public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
248
+ const sampleRate = clampSampleRate(options.sampleRate);
249
+ const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
250
+ const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
251
+ const bank = options.samples ?? EMPTY_SAMPLE_BANK;
252
+ const reverbTail = Math.max(
253
+ 0,
254
+ ...score.tracks.map((track) =>
255
+ reverbTailFor(track, sampleRate, bank.irs),
118
256
  ),
119
257
  );
120
- const tailSeconds = Math.min(
258
+ // Zero without sampler voices, so synth-only renders are unchanged.
259
+ // Zero without synth-voice tracks, so legacy renders are unchanged.
260
+ const samplerTail = Math.min(
121
261
  MAX_LOOP_TAIL_SECONDS,
122
- MAX_DRUM_SECONDS + 0.1 + reverbTail + delayTailSeconds(score),
262
+ Math.max(
263
+ samplerTailSeconds(score, bank, sampleRate),
264
+ ...score.tracks.map(synthTailSeconds),
265
+ ),
123
266
  );
124
- samples = frames + Math.ceil(tailSeconds * sampleRate);
125
- } else {
126
- const seconds = Math.min(
127
- maxSeconds,
128
- loopSeconds + ONE_SHOT_TAIL_SECONDS + reverbTail,
267
+ let frames: number;
268
+ let samples: number;
269
+ if (options.loop) {
270
+ frames = Math.max(
271
+ 1,
272
+ Math.min(
273
+ Math.round(loopFrames(score, sampleRate)),
274
+ Math.ceil(maxSeconds * sampleRate),
275
+ ),
276
+ );
277
+ const tailSeconds = Math.min(
278
+ MAX_LOOP_TAIL_SECONDS,
279
+ Math.max(MAX_DRUM_SECONDS, samplerTail, kitTailSeconds(score)) +
280
+ 0.1 +
281
+ reverbTail +
282
+ Math.max(
283
+ 0,
284
+ ...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
285
+ ),
286
+ );
287
+ samples = frames + Math.ceil(tailSeconds * sampleRate);
288
+ } else {
289
+ const seconds = Math.min(
290
+ maxSeconds,
291
+ loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
292
+ );
293
+ frames = Math.max(1, Math.ceil(seconds * sampleRate));
294
+ samples = frames;
295
+ }
296
+ const context: RenderContext = {
297
+ score,
298
+ sampleRate,
299
+ samples,
300
+ samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
301
+ tempoBpm: score.tempoBpm,
302
+ ...(bank.irs ? { irs: bank.irs } : {}),
303
+ };
304
+ this.renders += 1;
305
+ const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
306
+ mixL.fill(0);
307
+ mixR.fill(0);
308
+ const tracks = new Map(score.tracks.map((track) => [track.id, track]));
309
+ const groups = new Map<string, Note[]>();
310
+ for (const note of score.notes) {
311
+ const group = groups.get(note.trackId);
312
+ if (group) group.push(note);
313
+ else groups.set(note.trackId, [note]);
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,
129
332
  );
130
- frames = Math.max(1, Math.ceil(seconds * sampleRate));
131
- samples = frames;
132
- }
133
- const context: RenderContext = {
134
- score,
135
- sampleRate,
136
- samples,
137
- samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
138
- };
139
- // Tracks render one at a time into reused scratch buffers so per-track
140
- // effects stay bounded in memory regardless of the track count.
141
- const mixL = new Float64Array(samples);
142
- const mixR = new Float64Array(samples);
143
- const dry = new Float64Array(samples);
144
- const left = new Float64Array(samples);
145
- const right = new Float64Array(samples);
146
- const tracks = new Map(score.tracks.map((track) => [track.id, track]));
147
- const groups = new Map<string, Note[]>();
148
- for (const note of score.notes) {
149
- const group = groups.get(note.trackId);
150
- if (group) group.push(note);
151
- else groups.set(note.trackId, [note]);
152
- }
153
- for (const [trackId, notes] of groups) {
154
- if (!isTrackAudible(score, trackId)) continue;
155
- const track = tracks.get(trackId);
156
- dry.fill(0);
157
- const drums = isDrumInstrument(track?.instrument);
158
- for (const note of notes) {
159
- if (drums) renderDrumNote(dry, note, track, context);
160
- else renderToneNote(dry, note, track, context);
333
+ // Shared orbit buses (src/audio/effects/bus.ts), created on first send.
334
+ const buses = new Map<number, OrbitBus>();
335
+ // Tracks render one at a time; the sum order (first note per track) is
336
+ // part of the output, so cached and cold renders keep it.
337
+ for (const [trackId, notes] of groups) {
338
+ if (!isTrackAudible(score, trackId)) continue;
339
+ const track = tracks.get(trackId);
340
+ const busOrbit = sharedOrbitOf(track);
341
+ const sampler = isSamplerInstrument(track?.instrument);
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
+ );
353
+ let stem = this.stems.get(trackId);
354
+ if (stem?.key === key) stem.used = this.renders;
355
+ else {
356
+ const caching = this.maxCacheBytes > 0;
357
+ const target = caching
358
+ ? {
359
+ left: new Float64Array(samples),
360
+ right: new Float64Array(samples),
361
+ }
362
+ : { left, right };
363
+ dry.fill(0);
364
+ const synthVoice = !sampler && usesSynthVoice(track);
365
+ const stereo = synthVoice && isStereoVoice(track);
366
+ if (stereo) dryR.fill(0);
367
+ if (sampler) {
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
+ }
389
+ } else {
390
+ const drums = isDrumInstrument(track?.instrument);
391
+ for (const note of notes) {
392
+ if (drums) renderDrumNote(dry, note, track, context);
393
+ else renderToneNote(dry, note, track, context);
394
+ }
395
+ }
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
+ );
414
+ stem = {
415
+ key,
416
+ left: target.left,
417
+ right: target.right,
418
+ bytes: target.left.byteLength + target.right.byteLength,
419
+ used: this.renders,
420
+ };
421
+ if (caching) this.store(trackId, stem);
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}`);
459
+ for (let index = 0; index < samples; index += 1) {
460
+ const g = gain ? gain[index]! : 1;
461
+ mixL[index]! += wet.left[index]! * g;
462
+ mixR[index]! += wet.right[index]! * g;
463
+ }
161
464
  }
162
- if (track) applyLowPass(dry, track, context);
163
- applyPan(dry, left, right, track, context);
164
- if (track) {
165
- applyDelay(left, right, track, context);
166
- applyReverb(left, right, track, context);
465
+ for (const orbit of [...this.buses.keys()])
466
+ if (!buses.has(orbit)) this.buses.delete(orbit);
467
+ // Stems of tracks that left the score are not worth keeping; muted and
468
+ // unsoloed tracks keep theirs so toggling them back is free.
469
+ for (const trackId of [...this.stems.keys()])
470
+ if (!groups.has(trackId)) this.evict(trackId);
471
+ if (samples > frames) {
472
+ // Linear effects superpose, so folding the tail onto the start yields
473
+ // the steady state of the loop playing forever.
474
+ for (let index = frames; index < samples; index += 1) {
475
+ mixL[index % frames]! += mixL[index]!;
476
+ mixR[index % frames]! += mixR[index]!;
477
+ }
167
478
  }
168
- for (let index = 0; index < samples; index += 1) {
169
- mixL[index]! += left[index]!;
170
- mixR[index]! += right[index]!;
479
+ const pcm = new Int16Array(frames * RENDER_CHANNELS);
480
+ for (let index = 0; index < frames; index += 1) {
481
+ pcm[index * 2] = clamp16(mixL[index]! * 32767);
482
+ pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
171
483
  }
484
+ return Object.freeze({
485
+ sampleRate,
486
+ channels: RENDER_CHANNELS,
487
+ frames,
488
+ pcm,
489
+ });
172
490
  }
173
- if (samples > frames) {
174
- // Linear effects superpose, so folding the tail onto the start yields
175
- // the steady state of the loop playing forever.
176
- for (let index = frames; index < samples; index += 1) {
177
- mixL[index % frames]! += mixL[index]!;
178
- mixR[index % frames]! += mixR[index]!;
491
+
492
+ private scratchFor(samples: number) {
493
+ if (this.scratch.dry.length < samples) {
494
+ this.scratch = {
495
+ dry: new Float64Array(samples),
496
+ dryR: new Float64Array(samples),
497
+ left: new Float64Array(samples),
498
+ right: new Float64Array(samples),
499
+ mixL: new Float64Array(samples),
500
+ mixR: new Float64Array(samples),
501
+ };
179
502
  }
503
+ const view = (buffer: Float64Array) => buffer.subarray(0, samples);
504
+ return {
505
+ dry: view(this.scratch.dry),
506
+ dryR: view(this.scratch.dryR),
507
+ left: view(this.scratch.left),
508
+ right: view(this.scratch.right),
509
+ mixL: view(this.scratch.mixL),
510
+ mixR: view(this.scratch.mixR),
511
+ };
180
512
  }
181
- const pcm = new Int16Array(frames * RENDER_CHANNELS);
182
- for (let index = 0; index < frames; index += 1) {
183
- pcm[index * 2] = clamp16(mixL[index]! * 32767);
184
- pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
513
+
514
+ private store(trackId: string, stem: Stem): void {
515
+ this.evict(trackId);
516
+ // Make room by dropping the least recently used stems from other
517
+ // renders; a score whose stems exceed the budget is simply not cached.
518
+ while (
519
+ this.cacheBytes + stem.bytes > this.maxCacheBytes &&
520
+ this.stems.size > 0
521
+ ) {
522
+ let oldest: string | undefined;
523
+ for (const [id, candidate] of this.stems)
524
+ if (
525
+ candidate.used !== this.renders &&
526
+ (oldest === undefined ||
527
+ candidate.used < this.stems.get(oldest)!.used)
528
+ )
529
+ oldest = id;
530
+ if (oldest === undefined) break;
531
+ this.evict(oldest);
532
+ }
533
+ if (this.cacheBytes + stem.bytes > this.maxCacheBytes) return;
534
+ this.stems.set(trackId, stem);
535
+ this.cacheBytes += stem.bytes;
536
+ }
537
+
538
+ private evict(trackId: string): void {
539
+ const stem = this.stems.get(trackId);
540
+ if (!stem) return;
541
+ this.stems.delete(trackId);
542
+ this.cacheBytes -= stem.bytes;
185
543
  }
186
- return Object.freeze({ sampleRate, channels: RENDER_CHANNELS, frames, pcm });
187
544
  }
188
545
 
189
- /** Seconds until the slowest delay decays below -60 dB, bounded. */
190
- function delayTailSeconds(score: TrackScore): number {
191
- let longest = 0;
192
- for (const track of score.tracks) {
193
- if (!track.delay) continue;
194
- const feedback = Math.max(
195
- track.delay.feedback,
196
- ...(track.delayFeedbackAutomation ?? []).map((point) => point.value),
197
- );
198
- const repeats =
199
- feedback <= 0.001
200
- ? 1
201
- : Math.min(64, Math.log(0.001) / Math.log(feedback));
202
- longest = Math.max(
203
- longest,
204
- ((track.delay.beats * 60) / score.tempoBpm) * (repeats + 1),
205
- );
546
+ /** Everything a track's stem depends on, except mute and solo. */
547
+ function stemKey(
548
+ track: Track | undefined,
549
+ notes: readonly Note[],
550
+ context: RenderContext,
551
+ bank?: SampleBank,
552
+ wavetableId?: string,
553
+ impulseId?: string,
554
+ ): string {
555
+ let settings: Record<string, unknown> | null = null;
556
+ if (track) {
557
+ const { muted: _muted, solo: _solo, ...rest } = track;
558
+ settings = rest;
206
559
  }
207
- return longest;
560
+ return JSON.stringify([
561
+ settings,
562
+ notes,
563
+ context.score.tempoBpm,
564
+ context.score.ticksPerBeat,
565
+ // Bar length: sampler `unit: "c"` stretches a window to whole bars.
566
+ context.score.beatsPerBar,
567
+ context.sampleRate,
568
+ context.samples,
569
+ // Sampler stems also depend on the decoded files: a replaced or missing
570
+ // sample changes the key even when the score did not change.
571
+ ...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
572
+ ...(wavetableId ? [wavetableId] : []),
573
+ // A convolution reverb's impulse (its sha256 for a sample).
574
+ ...(impulseId ? [impulseId] : []),
575
+ ]);
576
+ }
577
+
578
+ function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
579
+ return Object.keys(track.sampler?.voices ?? {})
580
+ .sort()
581
+ .map((voice) => {
582
+ const sample = bank.voices.get(sampleKey(track.id, voice));
583
+ return sample
584
+ ? `${voice}:${sample.sha256}:${sample.sampleRate}:${sample.frames}`
585
+ : `${voice}:-`;
586
+ });
587
+ }
588
+
589
+ /** Sample voices of a sampler track into its mono dry buffer. */
590
+ function renderSamplerNotes(
591
+ target: Float64Array,
592
+ notes: readonly Note[],
593
+ track: Track,
594
+ context: RenderContext,
595
+ bank: SampleBank,
596
+ ): void {
597
+ const timing = { score: context.score, sampleRate: context.sampleRate };
598
+ renderSamplerVoices(
599
+ target,
600
+ planSamplerVoices(track, notes, bank, timing),
601
+ timing,
602
+ (tick) => trackGainAt(track, tick),
603
+ );
208
604
  }
209
605
 
210
606
  /** Track volume and volume automation at a score tick (pan is applied in stereo). */
@@ -263,8 +659,7 @@ function renderToneNote(
263
659
  0.28 *
264
660
  trackGainAt(track, note.startTick + elapsed / samplesPerTick);
265
661
  const phase = (frequency * elapsed) / sampleRate;
266
- target[index]! +=
267
- synthSample(instrument, phase, frequency, sampleRate) * envelope;
662
+ target[index]! += legacyWave(instrument, phase) * envelope;
268
663
  }
269
664
  }
270
665
 
@@ -281,9 +676,13 @@ function renderDrumNote(
281
676
  ): void {
282
677
  const { sampleRate, samples, samplesPerTick } = context;
283
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;
284
683
  const end = Math.min(
285
684
  samples,
286
- start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
685
+ start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
287
686
  );
288
687
  const voice = drumVoiceForPitch(note.pitch);
289
688
  const random = seededRandom(`${note.id}:${note.startTick}`);
@@ -298,7 +697,17 @@ function renderDrumNote(
298
697
  const bright = noise - previousNoise;
299
698
  previousNoise = noise;
300
699
  let sample = 0;
301
- 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") {
302
711
  const frequency = 45 + 105 * Math.exp(-t * 28);
303
712
  phase += frequency / sampleRate;
304
713
  sample =
@@ -346,64 +755,6 @@ function renderDrumNote(
346
755
  }
347
756
  }
348
757
 
349
- /**
350
- * RBJ biquad low-pass with static or automated cutoff and resonance.
351
- * Resonance 0..1 maps to Q 0.707..8 and the cutoff is kept under 45% of the
352
- * sample rate, so the filter is stable for every value the score accepts.
353
- */
354
- function applyLowPass(
355
- buffer: Float64Array,
356
- track: Track,
357
- context: RenderContext,
358
- ): void {
359
- const automation = track.filterAutomation ?? [];
360
- const resonanceLane = track.resonanceAutomation ?? [];
361
- if (!track.filter && automation.length === 0 && resonanceLane.length === 0)
362
- return;
363
- const { sampleRate, samplesPerTick } = context;
364
- const staticCutoff = track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
365
- const staticResonance = track.filter?.resonance ?? 0;
366
- const automated = automation.length > 0 || resonanceLane.length > 0;
367
- let b0 = 0;
368
- let b1 = 0;
369
- let b2 = 0;
370
- let a1 = 0;
371
- let a2 = 0;
372
- let z1 = 0;
373
- let z2 = 0;
374
- const update = (cutoff: number, resonance: number) => {
375
- const q = 0.707 + Math.max(0, Math.min(1, resonance)) * 7.293;
376
- const frequency = Math.max(
377
- SCORE_LIMITS.minFilterCutoff,
378
- Math.min(cutoff, sampleRate * 0.45),
379
- );
380
- const omega = (2 * Math.PI * frequency) / sampleRate;
381
- const alpha = Math.sin(omega) / (2 * q);
382
- const cos = Math.cos(omega);
383
- const a0 = 1 + alpha;
384
- b0 = (1 - cos) / 2 / a0;
385
- b1 = (1 - cos) / a0;
386
- b2 = b0;
387
- a1 = (-2 * cos) / a0;
388
- a2 = (1 - alpha) / a0;
389
- };
390
- update(staticCutoff, staticResonance);
391
- for (let index = 0; index < buffer.length; index += 1) {
392
- if (automated && index % CONTROL_SAMPLES === 0) {
393
- const tick = index / samplesPerTick;
394
- update(
395
- interpolateAutomation(automation, tick, staticCutoff),
396
- interpolateAutomation(resonanceLane, tick, staticResonance),
397
- );
398
- }
399
- const input = buffer[index]!;
400
- const output = b0 * input + z1;
401
- z1 = b1 * input - a1 * output + z2;
402
- z2 = b2 * input - a2 * output;
403
- buffer[index] = output;
404
- }
405
- }
406
-
407
758
  /**
408
759
  * Equal-power pan of the mono track into the stereo pair: left = cos(θ),
409
760
  * right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
@@ -415,6 +766,7 @@ function applyPan(
415
766
  right: Float64Array,
416
767
  track: Track | undefined,
417
768
  context: RenderContext,
769
+ dryRight?: Float64Array,
418
770
  ): void {
419
771
  const lane = track?.panAutomation ?? [];
420
772
  const staticPan = track?.pan ?? 0;
@@ -430,187 +782,51 @@ function applyPan(
430
782
  );
431
783
  const sample = dry[index]!;
432
784
  left[index] = sample * gainL;
433
- right[index] = sample * gainR;
434
- }
435
- }
436
-
437
- /**
438
- * Tempo-synced stereo feedback delay. Each side echoes its own input and
439
- * feeds back into the opposite side, so repeats ping-pong across the field.
440
- * Feedback and mix follow their automation lanes when present.
441
- */
442
- function applyDelay(
443
- left: Float64Array,
444
- right: Float64Array,
445
- track: Track,
446
- context: RenderContext,
447
- ): void {
448
- const delay = track.delay;
449
- if (!delay) return;
450
- const feedbackLane = track.delayFeedbackAutomation ?? [];
451
- const mixLane = track.delayMixAutomation ?? [];
452
- if (delay.mix <= 0 && mixLane.length === 0) return;
453
- const { score, sampleRate, samplesPerTick } = context;
454
- const length = Math.max(
455
- 1,
456
- Math.round(((delay.beats * 60) / score.tempoBpm) * sampleRate),
457
- );
458
- const lineL = new Float64Array(length);
459
- const lineR = new Float64Array(length);
460
- let feedback = delay.feedback;
461
- let mix = delay.mix;
462
- const automated = feedbackLane.length > 0 || mixLane.length > 0;
463
- for (let index = 0; index < left.length; index += 1) {
464
- if (automated && index % CONTROL_SAMPLES === 0) {
465
- const tick = index / samplesPerTick;
466
- feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
467
- mix = interpolateAutomation(mixLane, tick, delay.mix);
468
- }
469
- const slot = index % length;
470
- const wetL = lineL[slot]!;
471
- const wetR = lineR[slot]!;
472
- const dryL = left[index]!;
473
- const dryR = right[index]!;
474
- lineL[slot] = dryL + wetR * feedback;
475
- lineR[slot] = dryR + wetL * feedback;
476
- left[index] = dryL + wetL * mix;
477
- 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;
478
787
  }
479
788
  }
480
789
 
481
- /** Freeverb comb and allpass tunings at 44.1 kHz; the right side is spread. */
482
- const COMB_TUNING = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617] as const;
483
- const ALLPASS_TUNING = [556, 441, 341, 225] as const;
484
- const STEREO_SPREAD = 23;
485
- const REVERB_INPUT_GAIN = 0.015;
486
- 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
+ }>;
487
795
 
488
796
  /**
489
- * Schroeder/Freeverb-style stereo reverb send: eight damped feedback combs
490
- * in parallel, then four series allpasses, per channel. Size sets the comb
491
- * feedback (0.7..0.98) and darkens the tail; the wet signal is added to the
492
- * 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.
493
799
  */
494
- function applyReverb(
495
- left: Float64Array,
496
- right: Float64Array,
800
+ function wavetableHook(
497
801
  track: Track,
802
+ bank: SampleBank,
498
803
  context: RenderContext,
499
- ): void {
500
- const reverb = track.reverb;
501
- if (!reverb || reverb.mix <= 0) return;
502
- const scale = context.sampleRate / 44_100;
503
- const feedback = 0.7 + 0.28 * reverb.size;
504
- const damp = 0.2 + 0.3 * reverb.size;
505
- const wet = reverb.mix * REVERB_WET_SCALE;
506
- const channel = (spread: number) => ({
507
- combs: COMB_TUNING.map((tuning) => ({
508
- buffer: new Float64Array(
509
- Math.max(1, Math.round((tuning + spread) * scale)),
510
- ),
511
- index: 0,
512
- store: 0,
513
- })),
514
- allpasses: ALLPASS_TUNING.map((tuning) => ({
515
- buffer: new Float64Array(
516
- Math.max(1, Math.round((tuning + spread) * scale)),
517
- ),
518
- index: 0,
519
- })),
520
- });
521
- const sides = [channel(0), channel(STEREO_SPREAD)] as const;
522
- const process = (side: (typeof sides)[number], input: number): number => {
523
- let output = 0;
524
- for (const comb of side.combs) {
525
- const delayed = comb.buffer[comb.index]!;
526
- comb.store = delayed * (1 - damp) + comb.store * damp;
527
- comb.buffer[comb.index] = input + comb.store * feedback;
528
- comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
529
- output += delayed;
530
- }
531
- for (const allpass of side.allpasses) {
532
- const delayed = allpass.buffer[allpass.index]!;
533
- allpass.buffer[allpass.index] = output + delayed * 0.5;
534
- output = delayed - output;
535
- allpass.index =
536
- allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
537
- }
538
- return output;
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
+ }),
539
829
  };
540
- for (let index = 0; index < left.length; index += 1) {
541
- const input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
542
- left[index]! += process(sides[0], input) * wet;
543
- right[index]! += process(sides[1], input) * wet;
544
- }
545
- }
546
-
547
- /** FNV-1a seeded mulberry32: integer-only, so identical on every platform. */
548
- function seededRandom(seed: string): () => number {
549
- let hash = 0x811c9dc5;
550
- for (let index = 0; index < seed.length; index += 1) {
551
- hash ^= seed.charCodeAt(index);
552
- hash = Math.imul(hash, 0x01000193) >>> 0;
553
- }
554
- let state = hash;
555
- return () => {
556
- state = (state + 0x6d2b79f5) >>> 0;
557
- let value = Math.imul(state ^ (state >>> 15), 1 | state);
558
- value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
559
- return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
560
- };
561
- }
562
-
563
- /** Resolve a piecewise-linear automation lane, holding the static value before its first point. */
564
- function interpolateAutomation(
565
- points: readonly AutomationPoint[],
566
- tick: number,
567
- fallback: number,
568
- ): number {
569
- if (points.length === 0 || !Number.isFinite(tick)) return fallback;
570
- const first = points[0]!;
571
- if (tick < first.tick) return fallback;
572
- const last = points[points.length - 1]!;
573
- if (tick >= last.tick) return last.value;
574
- for (let index = 1; index < points.length; index += 1) {
575
- const right = points[index]!;
576
- if (tick > right.tick) continue;
577
- const left = points[index - 1]!;
578
- const span = right.tick - left.tick;
579
- const ratio = span <= 0 ? 1 : (tick - left.tick) / span;
580
- return left.value + (right.value - left.value) * ratio;
581
- }
582
- return last.value;
583
- }
584
-
585
- /** A tiny deterministic instrument bank. Names come from the score's track metadata. */
586
- function synthSample(
587
- instrument: string,
588
- phase: number,
589
- frequency: number,
590
- sampleRate: number,
591
- ): number {
592
- const name = instrument.trim().toLowerCase();
593
- const cycle = phase - Math.floor(phase);
594
- const sine = Math.sin(2 * Math.PI * phase);
595
- if (name.includes("square")) return cycle < 0.5 ? 1 : -1;
596
- if (name.includes("saw")) return 2 * cycle - 1;
597
- if (name.includes("triangle")) return 1 - 4 * Math.abs(cycle - 0.5);
598
- if (name.includes("bass")) {
599
- // A rounded fundamental plus a quiet octave gives bass tracks useful weight.
600
- return Math.tanh(
601
- 0.9 * Math.sin(2 * Math.PI * phase) +
602
- 0.25 * Math.sin(4 * Math.PI * phase),
603
- );
604
- }
605
- if (name.includes("piano") || name.includes("pluck")) {
606
- // Add stable harmonics; the envelope above supplies the note decay.
607
- const harmonic =
608
- Math.sin(4 * Math.PI * phase) * 0.28 +
609
- Math.sin(6 * Math.PI * phase) * 0.12;
610
- return Math.tanh(sine + harmonic);
611
- }
612
- // Unknown instruments deliberately fall back to the original sine voice.
613
- return sine;
614
830
  }
615
831
 
616
832
  export function encodeWav(