@hraness/dawg 0.0.0-stage → 0.2.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 (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
@@ -0,0 +1,647 @@
1
+ import {
2
+ SCORE_LIMITS,
3
+ isTrackAudible,
4
+ type AutomationPoint,
5
+ type Note,
6
+ type Track,
7
+ type TrackScore,
8
+ } from "../../core/score.ts";
9
+ import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
10
+
11
+ export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
12
+
13
+ export type RenderOptions = WavOptions &
14
+ Readonly<{
15
+ /**
16
+ * Render exactly one loop and fold every tail (release, delay, reverb)
17
+ * back onto the loop start, so the buffer repeats seamlessly. This is
18
+ * what the streaming engine plays; exports keep the default one-shot.
19
+ */
20
+ loop?: boolean;
21
+ }>;
22
+
23
+ /** Interleaved stereo 16-bit PCM plus its frame count. */
24
+ export type RenderedAudio = Readonly<{
25
+ sampleRate: number;
26
+ channels: 2;
27
+ frames: number;
28
+ pcm: Int16Array;
29
+ }>;
30
+
31
+ /** Output channel count for every render and export. */
32
+ export const RENDER_CHANNELS = 2 as const;
33
+ export const DEFAULT_SAMPLE_RATE = 22_050;
34
+
35
+ /** Names understood by the deterministic local voice bank and the agent. */
36
+ export const AVAILABLE_INSTRUMENTS = Object.freeze([
37
+ "sine",
38
+ "piano",
39
+ "pluck",
40
+ "bass",
41
+ "saw",
42
+ "square",
43
+ "triangle",
44
+ "kit",
45
+ ] as const);
46
+
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);
54
+
55
+ /** Longest drum one-shot, in seconds; hits ring past their note length. */
56
+ const MAX_DRUM_SECONDS = 0.6;
57
+ /** Effect parameters are refreshed at this sample interval when automated. */
58
+ const CONTROL_SAMPLES = 32;
59
+ /** One-shot renders keep this much ring-out after the last bar. */
60
+ const ONE_SHOT_TAIL_SECONDS = 0.35;
61
+ /** Loop renders fold at most this much tail back onto the loop start. */
62
+ const MAX_LOOP_TAIL_SECONDS = 8;
63
+
64
+ type RenderContext = Readonly<{
65
+ score: TrackScore;
66
+ sampleRate: number;
67
+ samples: number;
68
+ samplesPerTick: number;
69
+ }>;
70
+
71
+ /** Exact (fractional) loop length in frames at a sample rate. */
72
+ export function loopFrames(score: TrackScore, sampleRate: number): number {
73
+ return (score.bars * score.beatsPerBar * 60 * sampleRate) / score.tempoBpm;
74
+ }
75
+
76
+ export function clampSampleRate(sampleRate?: number): number {
77
+ return Math.max(
78
+ 8_000,
79
+ Math.min(48_000, Math.floor(sampleRate ?? DEFAULT_SAMPLE_RATE)),
80
+ );
81
+ }
82
+
83
+ /** Render the bounded score to a stereo 16-bit PCM WAV. */
84
+ export function renderScoreWav(
85
+ score: TrackScore,
86
+ options: WavOptions = {},
87
+ ): Uint8Array {
88
+ const audio = renderScorePcm(score, options);
89
+ return encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
90
+ }
91
+
92
+ /**
93
+ * Render the score to interleaved stereo PCM. Every step is plain float64
94
+ * arithmetic in a fixed order with seeded noise, so identical scores render
95
+ * byte-identical buffers on every run.
96
+ */
97
+ export function renderScorePcm(
98
+ score: TrackScore,
99
+ options: RenderOptions = {},
100
+ ): 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),
118
+ ),
119
+ );
120
+ const tailSeconds = Math.min(
121
+ MAX_LOOP_TAIL_SECONDS,
122
+ MAX_DRUM_SECONDS + 0.1 + reverbTail + delayTailSeconds(score),
123
+ );
124
+ samples = frames + Math.ceil(tailSeconds * sampleRate);
125
+ } else {
126
+ const seconds = Math.min(
127
+ maxSeconds,
128
+ loopSeconds + ONE_SHOT_TAIL_SECONDS + reverbTail,
129
+ );
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);
161
+ }
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);
167
+ }
168
+ for (let index = 0; index < samples; index += 1) {
169
+ mixL[index]! += left[index]!;
170
+ mixR[index]! += right[index]!;
171
+ }
172
+ }
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]!;
179
+ }
180
+ }
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);
185
+ }
186
+ return Object.freeze({ sampleRate, channels: RENDER_CHANNELS, frames, pcm });
187
+ }
188
+
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
+ );
206
+ }
207
+ return longest;
208
+ }
209
+
210
+ /** Track volume and volume automation at a score tick (pan is applied in stereo). */
211
+ function trackGainAt(track: Track | undefined, tick: number): number {
212
+ const trackGain = Math.max(0, Math.min(1, track?.volume ?? 1));
213
+ const automatedGain = interpolateAutomation(
214
+ track?.volumeAutomation ?? [],
215
+ tick,
216
+ 1,
217
+ );
218
+ return trackGain * automatedGain;
219
+ }
220
+
221
+ function noteSpan(
222
+ note: Note,
223
+ context: RenderContext,
224
+ ): { start: number; length: number } {
225
+ const { score, sampleRate } = context;
226
+ const start = Math.max(
227
+ 0,
228
+ Math.floor(
229
+ (((note.startTick / score.ticksPerBeat) * 60) / score.tempoBpm) *
230
+ sampleRate,
231
+ ),
232
+ );
233
+ const length = Math.max(
234
+ 1,
235
+ Math.floor(
236
+ (((note.durationTicks / score.ticksPerBeat) * 60) / score.tempoBpm) *
237
+ sampleRate,
238
+ ),
239
+ );
240
+ return { start, length };
241
+ }
242
+
243
+ function renderToneNote(
244
+ target: Float64Array,
245
+ note: Note,
246
+ track: Track | undefined,
247
+ context: RenderContext,
248
+ ): void {
249
+ const { sampleRate, samples, samplesPerTick } = context;
250
+ const instrument = track?.instrument ?? "sine";
251
+ const { start, length } = noteSpan(note, context);
252
+ const end = Math.min(samples, start + length);
253
+ const frequency = 440 * 2 ** ((note.pitch - 69) / 12);
254
+ const velocity = Math.max(0, Math.min(1, note.velocity));
255
+ for (let index = start; index < end; index += 1) {
256
+ const elapsed = index - start;
257
+ const remaining = end - index;
258
+ const attack = Math.min(1, elapsed / Math.max(1, sampleRate * 0.012));
259
+ const release = Math.min(1, remaining / Math.max(1, sampleRate * 0.09));
260
+ const envelope =
261
+ Math.min(attack, release) *
262
+ velocity *
263
+ 0.28 *
264
+ trackGainAt(track, note.startTick + elapsed / samplesPerTick);
265
+ const phase = (frequency * elapsed) / sampleRate;
266
+ target[index]! +=
267
+ synthSample(instrument, phase, frequency, sampleRate) * envelope;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Synthesize a drum one-shot. Noise comes from a PRNG seeded by the note's
273
+ * id and start tick, so a hit sounds identical on every render and in every
274
+ * window regardless of note order.
275
+ */
276
+ function renderDrumNote(
277
+ target: Float64Array,
278
+ note: Note,
279
+ track: Track | undefined,
280
+ context: RenderContext,
281
+ ): void {
282
+ const { sampleRate, samples, samplesPerTick } = context;
283
+ const { start } = noteSpan(note, context);
284
+ const end = Math.min(
285
+ samples,
286
+ start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
287
+ );
288
+ const voice = drumVoiceForPitch(note.pitch);
289
+ const random = seededRandom(`${note.id}:${note.startTick}`);
290
+ const velocity = Math.max(0, Math.min(1, note.velocity));
291
+ let phase = 0;
292
+ let previousNoise = 0;
293
+ let band = 0;
294
+ for (let index = start; index < end; index += 1) {
295
+ const t = (index - start) / sampleRate;
296
+ const noise = random() * 2 - 1;
297
+ // A first difference is a cheap, stable high-pass for metallic hats.
298
+ const bright = noise - previousNoise;
299
+ previousNoise = noise;
300
+ let sample = 0;
301
+ if (voice === "kick") {
302
+ const frequency = 45 + 105 * Math.exp(-t * 28);
303
+ phase += frequency / sampleRate;
304
+ sample =
305
+ Math.sin(2 * Math.PI * phase) * Math.exp(-t * 7.5) +
306
+ noise * 0.12 * Math.exp(-t * 300);
307
+ } else if (voice === "tom") {
308
+ const frequency = 105 + 95 * Math.exp(-t * 18);
309
+ phase += frequency / sampleRate;
310
+ sample = Math.sin(2 * Math.PI * phase) * Math.exp(-t * 9);
311
+ } else if (voice === "snare") {
312
+ phase += 185 / sampleRate;
313
+ sample =
314
+ Math.sin(2 * Math.PI * phase) * 0.45 * Math.exp(-t * 22) +
315
+ noise * 0.7 * Math.exp(-t * 16);
316
+ } else if (voice === "clap") {
317
+ // Three quick bursts followed by a short diffuse tail.
318
+ band += 0.35 * (bright - band);
319
+ const burst = t < 0.03 ? (Math.floor(t / 0.01) % 2 === 0 ? 1 : 0.35) : 0;
320
+ sample = band * (burst + 0.8 * Math.exp(-(t - 0.03) * 18) * +(t >= 0.03));
321
+ } else if (voice === "hat") {
322
+ sample = bright * 0.42 * Math.exp(-t * 60);
323
+ } else if (voice === "openhat") {
324
+ sample = bright * 0.36 * Math.exp(-t * 9);
325
+ } else {
326
+ sample =
327
+ (Math.sin(2 * Math.PI * 1_700 * t) * 0.6 +
328
+ Math.sin(2 * Math.PI * 820 * t) * 0.4) *
329
+ Math.exp(-t * 90);
330
+ }
331
+ const attack = Math.min(
332
+ 1,
333
+ (index - start) / Math.max(1, sampleRate * 0.001),
334
+ );
335
+ const release = Math.min(
336
+ 1,
337
+ (end - index) / Math.max(1, sampleRate * 0.005),
338
+ );
339
+ target[index]! +=
340
+ sample *
341
+ attack *
342
+ release *
343
+ velocity *
344
+ 0.5 *
345
+ trackGainAt(track, note.startTick + (index - start) / samplesPerTick);
346
+ }
347
+ }
348
+
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
+ /**
408
+ * Equal-power pan of the mono track into the stereo pair: left = cos(θ),
409
+ * right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
410
+ * and a centred track sits 3 dB down in each channel.
411
+ */
412
+ function applyPan(
413
+ dry: Float64Array,
414
+ left: Float64Array,
415
+ right: Float64Array,
416
+ track: Track | undefined,
417
+ context: RenderContext,
418
+ ): void {
419
+ const lane = track?.panAutomation ?? [];
420
+ const staticPan = track?.pan ?? 0;
421
+ const gains = (pan: number): [number, number] => {
422
+ const theta = ((Math.max(-1, Math.min(1, pan)) + 1) * Math.PI) / 4;
423
+ return [Math.cos(theta), Math.sin(theta)];
424
+ };
425
+ let [gainL, gainR] = gains(staticPan);
426
+ for (let index = 0; index < dry.length; index += 1) {
427
+ if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
428
+ [gainL, gainR] = gains(
429
+ interpolateAutomation(lane, index / context.samplesPerTick, staticPan),
430
+ );
431
+ const sample = dry[index]!;
432
+ 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;
478
+ }
479
+ }
480
+
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;
487
+
488
+ /**
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`.
493
+ */
494
+ function applyReverb(
495
+ left: Float64Array,
496
+ right: Float64Array,
497
+ track: Track,
498
+ 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;
539
+ };
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
+ }
615
+
616
+ export function encodeWav(
617
+ pcm: Int16Array,
618
+ sampleRate: number,
619
+ channels = 1,
620
+ ): Uint8Array {
621
+ const dataBytes = pcm.byteLength;
622
+ const bytes = new Uint8Array(44 + dataBytes);
623
+ const view = new DataView(bytes.buffer);
624
+ writeAscii(bytes, 0, "RIFF");
625
+ view.setUint32(4, 36 + dataBytes, true);
626
+ writeAscii(bytes, 8, "WAVEfmt ");
627
+ view.setUint32(16, 16, true);
628
+ view.setUint16(20, 1, true);
629
+ view.setUint16(22, channels, true);
630
+ view.setUint32(24, sampleRate, true);
631
+ view.setUint32(28, sampleRate * 2 * channels, true);
632
+ view.setUint16(32, 2 * channels, true);
633
+ view.setUint16(34, 16, true);
634
+ writeAscii(bytes, 36, "data");
635
+ view.setUint32(40, dataBytes, true);
636
+ bytes.set(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength), 44);
637
+ return bytes;
638
+ }
639
+
640
+ function writeAscii(target: Uint8Array, offset: number, value: string): void {
641
+ for (let index = 0; index < value.length; index += 1)
642
+ target[offset + index] = value.charCodeAt(index);
643
+ }
644
+
645
+ function clamp16(value: number): number {
646
+ return Math.max(-32_768, Math.min(32_767, Math.round(value)));
647
+ }