@homie-rocks/studio 0.49.0 → 0.50.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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +1 -1
  4. package/guides/app/SKILL.md +107 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/plan/SKILL.md +126 -0
  21. package/guides/plan/references/CODEX.md +105 -0
  22. package/guides/plan/references/INTERVIEW.md +86 -0
  23. package/guides/playtest/SKILL.md +205 -0
  24. package/guides/playtest/references/METHOD.md +124 -0
  25. package/guides/playtest/references/REVIEWER.md +78 -0
  26. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  27. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  28. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  29. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  30. package/guides/playtest/scripts/playtest.mjs +870 -0
  31. package/guides/shop/SKILL.md +4 -0
  32. package/guides/sound/SKILL.md +156 -0
  33. package/guides/sound/references/GAME-SOUND.md +100 -0
  34. package/guides/sound/references/SCORE.md +131 -0
  35. package/guides/sound/references/TRAPS.md +93 -0
  36. package/guides/sound/scripts/lib/measure.mjs +252 -0
  37. package/guides/sound/scripts/lib/score.mjs +415 -0
  38. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  39. package/guides/sound/scripts/lib/synth.mjs +509 -0
  40. package/guides/sound/scripts/sound.mjs +520 -0
  41. package/guides/style/SKILL.md +105 -0
  42. package/lib/app-needs.mjs +63 -0
  43. package/lib/check.mjs +10 -4
  44. package/lib/codex.mjs +95 -18
  45. package/lib/decisions.mjs +9 -7
  46. package/lib/handoff.mjs +1 -1
  47. package/lib/mcp-tools.mjs +10 -9
  48. package/lib/mcp.mjs +1 -1
  49. package/lib/scaffold.mjs +13 -5
  50. package/lib/studio.mjs +3 -3
  51. package/lib/template-history.json +42 -0
  52. package/package.json +1 -1
  53. package/shop/SHOP.md +4 -0
  54. package/shop/shop.ts +4 -4
  55. package/worker/build-id.mjs +1 -1
  56. package/worker/functions-schema.mjs +15 -0
  57. package/worker/shop-page.mjs +1 -1
  58. package/worker/shop.mjs +6 -2
  59. package/worker/version.mjs +1 -1
@@ -0,0 +1,509 @@
1
+ /**
2
+ * A small offline synthesizer in plain JavaScript, for sound effects and short scores that are
3
+ * made on the creator's own computer: no provider, no account, no money.
4
+ *
5
+ * - Pitch is integrated into phase every sample, so a slide, a vibrato or an arpeggio is a change
6
+ * of frequency and never a jump of phase (a bend written as sin(2*pi*f(t)*t) runs backwards).
7
+ * - Saw and square are band-limited (PolyBLEP): a naive square at a high note aliases into
8
+ * whistles that a phone speaker makes worse.
9
+ * - Everything is a function of the sample index and a seed: the same spec renders the same
10
+ * samples on every machine, so a sound can be re-rendered from its JSON.
11
+ *
12
+ * Buffers are Float32Array at RATE. A stereo buffer is { L, R }.
13
+ */
14
+
15
+ export const RATE = 48000;
16
+ const TAU = Math.PI * 2;
17
+
18
+ /* ---------------------------------------------------------------- numbers */
19
+
20
+ export function mulberry32(seed) {
21
+ let a = (Number(seed) >>> 0) || 0x9e3779b9;
22
+ return () => {
23
+ a = (a + 0x6d2b79f5) >>> 0;
24
+ let t = a;
25
+ t = Math.imul(t ^ (t >>> 15), t | 1);
26
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
27
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
28
+ };
29
+ }
30
+
31
+ export const dbToGain = (db) => 10 ** (db / 20);
32
+ export const gainToDb = (g) => 20 * Math.log10(Math.max(1e-12, Math.abs(g)));
33
+ export const semis = (n) => 2 ** (n / 12);
34
+
35
+ const NOTE_INDEX = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
36
+ /** A note as a MIDI number: "A4" → 69, "C#3" → 49, "Bb2" → 46; a number is taken as a MIDI number already (60 → 60). */
37
+ export function midiOf(note) {
38
+ if (typeof note === 'number') return note;
39
+ const m = /^([A-Ga-g])([#b]?)(-?\d)$/.exec(String(note).trim());
40
+ if (!m) throw new Error(`not a note: "${note}" (write it like C4, F#3 or Bb2)`);
41
+ const acc = m[2] === '#' ? 1 : m[2] === 'b' ? -1 : 0;
42
+ return 12 * (Number(m[3]) + 1) + NOTE_INDEX[m[1].toLowerCase()] + acc;
43
+ }
44
+ export const hzOfMidi = (m) => 440 * 2 ** ((m - 69) / 12);
45
+ /**
46
+ * A pitch as hertz. A NUMBER IS ALWAYS HERTZ; a string is a note name ("A2" → 110). There is no guessing by size.
47
+ *
48
+ * This used to read a number of 127 or less as a MIDI note, so that 60 could mean middle C. But every bass note
49
+ * is a frequency under 127 Hz: a caller that had already converted its note (A2 → 110 Hz) had it converted again
50
+ * (MIDI 110 = 4698.6 Hz; B2 → 123.47 Hz → 10.2 kHz), and a bass line came out as a whistle five octaves up with no
51
+ * error. A MIDI number is now said out loud: hzOfMidi(57), or `midi` / `slide.toMidi` on a voice.
52
+ */
53
+ export function hzOf(pitch) {
54
+ if (typeof pitch === 'number') {
55
+ if (!(pitch > 0) || !Number.isFinite(pitch)) throw new Error(`not a frequency: ${pitch} (a number is hertz and must be above 0; for a MIDI note use "midi", for a name write "A2")`);
56
+ return pitch;
57
+ }
58
+ return hzOfMidi(midiOf(pitch));
59
+ }
60
+ /**
61
+ * The pitch of a spec that may give it either way: { freq } (hertz, or a note name) or { midi } (a MIDI note
62
+ * number, fractions allowed for a detune). Giving both is refused: one of them would be silently ignored.
63
+ */
64
+ export function pitchHz(freq, midi, what = 'freq') {
65
+ if (midi !== undefined && midi !== null) {
66
+ if (freq !== undefined && freq !== null) throw new Error(`give ${what} (hertz or a note name) or its MIDI form, not both`);
67
+ if (typeof midi !== 'number' || !Number.isFinite(midi)) throw new Error(`not a MIDI note: ${midi} (a number, 69 = A4 = 440 Hz)`);
68
+ return hzOfMidi(midi);
69
+ }
70
+ return hzOf(freq);
71
+ }
72
+
73
+ /* ---------------------------------------------------------------- buffers */
74
+
75
+ export function mono(seconds) { return new Float32Array(Math.max(1, Math.ceil(seconds * RATE))); }
76
+ export function stereo(seconds) { return { L: mono(seconds), R: mono(seconds) }; }
77
+ export const secondsOf = (buf) => (buf.L ? buf.L.length : buf.length) / RATE;
78
+
79
+ /** Add a mono buffer into a stereo one at `at` seconds, with gain and a constant-power pan (-1..1). */
80
+ export function addInto(dst, src, { at = 0, gain = 1, pan = 0 } = {}) {
81
+ const off = Math.round(at * RATE);
82
+ const p = Math.max(-1, Math.min(1, pan));
83
+ const gl = gain * Math.cos((p + 1) * Math.PI / 4);
84
+ const gr = gain * Math.sin((p + 1) * Math.PI / 4);
85
+ if (src.L) {
86
+ const n = Math.min(src.L.length, dst.L.length - off);
87
+ for (let i = Math.max(0, -off); i < n; i++) { dst.L[off + i] += src.L[i] * gain; dst.R[off + i] += src.R[i] * gain; }
88
+ return dst;
89
+ }
90
+ const n = Math.min(src.length, dst.L.length - off);
91
+ for (let i = Math.max(0, -off); i < n; i++) { dst.L[off + i] += src[i] * gl; dst.R[off + i] += src[i] * gr; }
92
+ return dst;
93
+ }
94
+
95
+ export function peakOf(buf) {
96
+ let p = 0;
97
+ for (const ch of buf.L ? [buf.L, buf.R] : [buf]) for (let i = 0; i < ch.length; i++) { const v = Math.abs(ch[i]); if (v > p) p = v; }
98
+ return p;
99
+ }
100
+
101
+ export function scale(buf, g) {
102
+ for (const ch of buf.L ? [buf.L, buf.R] : [buf]) for (let i = 0; i < ch.length; i++) ch[i] *= g;
103
+ return buf;
104
+ }
105
+
106
+ /** Trim trailing near-silence (below `floorDb` for good), keeping `keep` seconds of tail, and fade the last 5 ms. */
107
+ export function trimTail(buf, { floorDb = -70, keep = 0.01 } = {}) {
108
+ const chans = buf.L ? [buf.L, buf.R] : [buf];
109
+ const floor = dbToGain(floorDb);
110
+ let last = 0;
111
+ for (const ch of chans) for (let i = ch.length - 1; i > last; i--) if (Math.abs(ch[i]) > floor) { last = i; break; }
112
+ const n = Math.min(chans[0].length, last + Math.round(keep * RATE) + 1);
113
+ const cut = chans.map((ch) => ch.slice(0, n));
114
+ const fade = Math.min(n, Math.round(0.005 * RATE));
115
+ for (const ch of cut) for (let i = 0; i < fade; i++) ch[n - 1 - i] *= i / fade;
116
+ return buf.L ? { L: cut[0], R: cut[1] } : cut[0];
117
+ }
118
+
119
+ /* ---------------------------------------------------------------- oscillators */
120
+
121
+ function blep(t, dt) {
122
+ if (t < dt) { t /= dt; return t + t - t * t - 1; }
123
+ if (t > 1 - dt) { t = (t - 1) / dt; return t * t + t + t + 1; }
124
+ return 0;
125
+ }
126
+
127
+ /**
128
+ * One oscillator sample at phase `ph` (0..1) with phase step `dt` (freq / RATE).
129
+ * Waves: sine, triangle, saw, square (duty), pulse (= square), noise (white), crunch (noise held for one
130
+ * period: pitched grit, the 8-bit explosion), metal (six detuned squares, a cymbal's clang).
131
+ */
132
+ function osc(wave, ph, dt, duty, rnd, state) {
133
+ switch (wave) {
134
+ case 'sine': return Math.sin(TAU * ph);
135
+ case 'triangle': return 1 - 4 * Math.abs(ph - 0.5);
136
+ case 'saw': return 2 * ph - 1 - blep(ph, dt);
137
+ case 'square': case 'pulse': {
138
+ const d = duty ?? 0.5;
139
+ // Minus the pulse's mean (2d - 1): an uneven pulse is otherwise a DC offset, a click at every start and stop.
140
+ return (ph < d ? 1 : -1) + blep(ph, dt) - blep((ph + 1 - d) % 1, dt) - (2 * d - 1);
141
+ }
142
+ case 'noise': return rnd() * 2 - 1;
143
+ case 'crunch': {
144
+ if (ph < state.lastPh) state.held = rnd() * 2 - 1;
145
+ state.lastPh = ph;
146
+ return state.held ?? 0;
147
+ }
148
+ default: throw new Error(`unknown wave "${wave}" (sine, triangle, saw, square, pulse, noise, crunch)`);
149
+ }
150
+ }
151
+
152
+ /* ---------------------------------------------------------------- envelopes */
153
+
154
+ /**
155
+ * Amplitude at time t (s) of a note held for `gate` seconds.
156
+ * env: { a attack, d decay, s sustain level 0..1, r release, curve (2 = a natural exponential-ish fall) }.
157
+ * A one-shot effect is { a, d, s: 0 } and ignores the gate.
158
+ */
159
+ export function envAt(t, gate, env = {}) {
160
+ const a = Math.max(0.0005, env.a ?? 0.002);
161
+ const d = Math.max(0.001, env.d ?? 0.1);
162
+ const s = env.s ?? 0;
163
+ const r = Math.max(0.002, env.r ?? 0.05);
164
+ const c = env.curve ?? 2;
165
+ const level = (x) => {
166
+ if (x < 0) return 0;
167
+ if (x < a) return x / a;
168
+ if (x < a + d) return s + (1 - s) * (1 - (x - a) / d) ** c;
169
+ return s;
170
+ };
171
+ if (s === 0 && (env.s === undefined || env.s === 0) && gate === Infinity) return level(t);
172
+ if (t < gate) return level(t);
173
+ const from = level(gate);
174
+ const x = (t - gate) / r;
175
+ return x >= 1 ? 0 : from * (1 - x) ** c;
176
+ }
177
+
178
+ /** How long a note of `gate` seconds sounds with this envelope, tail included. */
179
+ export function envLength(gate, env = {}) {
180
+ const a = env.a ?? 0.002; const d = env.d ?? 0.1; const s = env.s ?? 0; const r = env.r ?? 0.05;
181
+ if (!s) return Math.min(gate === Infinity ? a + d : Math.max(gate, 0) + r, a + d);
182
+ return gate + r;
183
+ }
184
+
185
+ /* ---------------------------------------------------------------- filters */
186
+
187
+ /** Zavalishin's TPT state-variable filter: stable under fast sweeps. type lowpass | highpass | bandpass. */
188
+ export function svf() {
189
+ let ic1 = 0; let ic2 = 0; let g = 0; let k = 1; let a1 = 0; let a2 = 0; let a3 = 0; let lastF = -1; let lastQ = -1;
190
+ return (x, freq, q, type) => {
191
+ if (freq !== lastF || q !== lastQ) {
192
+ const f = Math.min(Math.max(freq, 10), RATE * 0.45);
193
+ g = Math.tan(Math.PI * f / RATE); k = 1 / Math.max(0.3, q); a1 = 1 / (1 + g * (g + k)); a2 = g * a1; a3 = g * a2;
194
+ lastF = freq; lastQ = q;
195
+ }
196
+ const v3 = x - ic2;
197
+ const v1 = a1 * ic1 + a2 * v3;
198
+ const v2 = ic2 + a2 * ic1 + a3 * v3;
199
+ ic1 = 2 * v1 - ic1; ic2 = 2 * v2 - ic2;
200
+ if (type === 'highpass') return x - k * v1 - v2;
201
+ if (type === 'bandpass') return v1 * k;
202
+ return v2;
203
+ };
204
+ }
205
+
206
+ /* ---------------------------------------------------------------- one voice */
207
+
208
+ /**
209
+ * Render one voice (mono). spec:
210
+ * wave, duty,
211
+ * freq (a number is hertz, a string is a note like "A4") OR midi (a MIDI note number, 57 = A2 = 110 Hz)
212
+ * slide: { to (hertz or a note name) OR toMidi, time (s), curve: 'exp' | 'lin' } pitch glide from freq to `to`
213
+ * (a number is never guessed to be one or the other: 110 is 110 Hz, and MIDI 110 is written midi: 110)
214
+ * vibrato: { rate (Hz), depth (semitones), delay (s) }
215
+ * arp: { steps: [semitones...], every (s) } stepped pitch: a coin, a power-up
216
+ * env: { a, d, s, r, curve }, gate (s held; one-shots leave it out)
217
+ * filter: { type, freq, to, time, q } a swept TPT filter
218
+ * fm: { ratio, index, decay } 2-operator FM (a bell, an electric piano)
219
+ * gain (linear) or db
220
+ * seed
221
+ */
222
+ export function voice(spec) {
223
+ const rnd = mulberry32(spec.seed ?? 1);
224
+ const gate = spec.gate ?? Infinity;
225
+ const env = spec.env ?? {};
226
+ const len = Math.min(30, (spec.length ?? envLength(gate, env)) + 0.002);
227
+ const out = mono(len);
228
+ const f0 = spec.freq === undefined && spec.midi === undefined ? 440 : pitchHz(spec.freq, spec.midi, 'freq');
229
+ const to = spec.slide ? pitchHz(spec.slide.to, spec.slide.toMidi, 'slide.to') : f0;
230
+ const slideT = Math.max(1e-4, spec.slide?.time ?? len);
231
+ const vib = spec.vibrato;
232
+ const arp = spec.arp;
233
+ const flt = spec.filter ? { f: svf(), type: spec.filter.type ?? 'lowpass', from: spec.filter.freq ?? 2000, to: spec.filter.to ?? spec.filter.freq ?? 2000, time: Math.max(1e-4, spec.filter.time ?? len), q: spec.filter.q ?? 0.707 } : null;
234
+ const fm = spec.fm;
235
+ const g = spec.db !== undefined ? dbToGain(spec.db) : (spec.gain ?? 1);
236
+ const state = {};
237
+ let ph = spec.phase ?? 0; let mph = 0;
238
+ for (let i = 0; i < out.length; i++) {
239
+ const t = i / RATE;
240
+ let f = f0;
241
+ if (spec.slide) {
242
+ const x = Math.min(1, t / slideT);
243
+ f = spec.slide.curve === 'lin' ? f0 + (to - f0) * x : f0 * (to / f0) ** x;
244
+ }
245
+ if (arp?.steps?.length) f *= semis(arp.steps[Math.min(arp.steps.length - 1, Math.floor(t / Math.max(0.005, arp.every ?? 0.06)))]);
246
+ if (vib && t >= (vib.delay ?? 0)) f *= semis((vib.depth ?? 0.3) * Math.sin(TAU * (vib.rate ?? 6) * (t - (vib.delay ?? 0))));
247
+ const dt = f / RATE;
248
+ let x;
249
+ if (fm) {
250
+ const idx = (fm.index ?? 2) * (fm.decay ? Math.exp(-t / fm.decay) : 1);
251
+ x = Math.sin(TAU * ph + idx * Math.sin(TAU * mph));
252
+ mph = (mph + dt * (fm.ratio ?? 1.4)) % 1;
253
+ } else x = osc(spec.wave ?? 'sine', ph, dt, spec.duty, rnd, state);
254
+ ph += dt; if (ph >= 1) ph -= Math.floor(ph);
255
+ if (flt) {
256
+ const y = Math.min(1, t / flt.time);
257
+ const cf = flt.from * (flt.to / flt.from) ** y;
258
+ x = flt.f(x, cf, flt.q, flt.type);
259
+ }
260
+ out[i] = x * envAt(t, gate, env) * g;
261
+ }
262
+ return out;
263
+ }
264
+
265
+ /** Karplus-Strong plucked string: a noise burst in a tuned delay line that loses its highs each pass. */
266
+ export function pluck({ freq, midi, gate = 0.5, bright = 0.5, decay = 0.996, gain = 1, seed = 1, length = null }) {
267
+ const rnd = mulberry32(seed);
268
+ // The same two explicit forms as voice(): freq (hertz or a note name) or midi; A3 when neither is given.
269
+ const f = freq === undefined && midi === undefined ? hzOf('A3') : pitchHz(freq, midi, 'freq');
270
+ const len = length ?? Math.max(gate + 0.4, 1.2);
271
+ const out = mono(len);
272
+ const n = Math.max(2, Math.round(RATE / f));
273
+ const line = new Float32Array(n);
274
+ let prev = 0;
275
+ for (let i = 0; i < n; i++) { const w = rnd() * 2 - 1; prev = prev + (w - prev) * (0.2 + 0.8 * bright); line[i] = prev; }
276
+ let idx = 0;
277
+ const rel = Math.round((gate) * RATE);
278
+ for (let i = 0; i < out.length; i++) {
279
+ const a = line[idx]; const b = line[(idx + 1) % n];
280
+ const damp = i > rel ? decay * 0.985 : decay;
281
+ line[idx] = (a + b) * 0.5 * damp;
282
+ out[i] = a * gain;
283
+ idx = (idx + 1) % n;
284
+ }
285
+ const fade = Math.round(0.004 * RATE);
286
+ for (let i = 0; i < fade; i++) out[out.length - 1 - i] *= i / fade;
287
+ return out;
288
+ }
289
+
290
+ /* ---------------------------------------------------------------- drums */
291
+
292
+ /** Synthesized drums: kick, snare, clap, hat, openhat, tom, rim, shaker. `tone` shifts the pitch (semitones). */
293
+ export function drum(kind, { gain = 1, tone = 0, decay = 1, seed = 7 } = {}) {
294
+ const k = semis(tone);
295
+ switch (kind) {
296
+ case 'kick': {
297
+ const body = voice({ wave: 'sine', freq: 150 * k, slide: { to: 44 * k, time: 0.09 }, env: { a: 0.001, d: 0.42 * decay, curve: 2.2 }, gain: 1.0 * gain });
298
+ const click = voice({ wave: 'noise', seed, filter: { type: 'highpass', freq: 3000 }, env: { a: 0.0005, d: 0.006 }, gain: 0.35 * gain });
299
+ return mixMono([body, click]);
300
+ }
301
+ case 'snare': {
302
+ const tone1 = voice({ wave: 'triangle', freq: 190 * k, slide: { to: 160 * k, time: 0.05 }, env: { a: 0.001, d: 0.11 * decay }, gain: 0.55 * gain });
303
+ const noise = voice({ wave: 'noise', seed, filter: { type: 'highpass', freq: 1400 * k, q: 0.8 }, env: { a: 0.001, d: 0.2 * decay, curve: 2.4 }, gain: 0.65 * gain });
304
+ return mixMono([tone1, noise]);
305
+ }
306
+ case 'clap': {
307
+ const parts = [0, 0.011, 0.022].map((at, i) => ({ at, buf: voice({ wave: 'noise', seed: seed + i, filter: { type: 'bandpass', freq: 1250 * k, q: 1.2 }, env: { a: 0.0005, d: 0.012 }, gain: 0.9 * gain }) }));
308
+ parts.push({ at: 0.03, buf: voice({ wave: 'noise', seed: seed + 9, filter: { type: 'bandpass', freq: 1150 * k, q: 1 }, env: { a: 0.001, d: 0.16 * decay }, gain: 0.7 * gain }) });
309
+ return mixMono(parts);
310
+ }
311
+ case 'hat': case 'openhat': {
312
+ // Six detuned square oscillators (the classic analog cymbal), band-passed high.
313
+ const fs = [205.3, 304.4, 369.6, 522.7, 540, 800].map((f) => f * k);
314
+ const d = (kind === 'hat' ? 0.045 : 0.32) * decay;
315
+ const len = d + 0.01;
316
+ const out = mono(len);
317
+ const hp = svf(); const bp = svf();
318
+ const phs = fs.map(() => 0);
319
+ for (let i = 0; i < out.length; i++) {
320
+ const t = i / RATE;
321
+ let x = 0;
322
+ for (let j = 0; j < fs.length; j++) { phs[j] = (phs[j] + fs[j] / RATE) % 1; x += phs[j] < 0.5 ? 1 : -1; }
323
+ x = hp(bp(x / 6, 10000, 0.9, 'bandpass'), 7000, 0.7, 'highpass');
324
+ out[i] = x * envAt(t, Infinity, { a: 0.0005, d, curve: 3 }) * 1.6 * gain;
325
+ }
326
+ return out;
327
+ }
328
+ case 'tom': return voice({ wave: 'sine', freq: 180 * k, slide: { to: 110 * k, time: 0.18 }, env: { a: 0.001, d: 0.35 * decay }, gain: 0.9 * gain });
329
+ case 'rim': return voice({ wave: 'square', freq: 1700 * k, filter: { type: 'bandpass', freq: 1800 * k, q: 3 }, env: { a: 0.0005, d: 0.025 }, gain: 0.8 * gain });
330
+ case 'shaker': return voice({ wave: 'noise', seed, filter: { type: 'highpass', freq: 6000, q: 0.7 }, env: { a: 0.008, d: 0.06 * decay }, gain: 0.45 * gain });
331
+ default: throw new Error(`unknown drum "${kind}" (kick, snare, clap, hat, openhat, tom, rim, shaker)`);
332
+ }
333
+ }
334
+
335
+ /** Sum mono buffers (or { at, buf, gain }) into one mono buffer. */
336
+ export function mixMono(parts) {
337
+ const items = parts.map((p) => (p instanceof Float32Array ? { at: 0, buf: p, gain: 1 } : { at: p.at ?? 0, buf: p.buf, gain: p.gain ?? 1 }));
338
+ const len = Math.max(...items.map((p) => Math.round(p.at * RATE) + p.buf.length));
339
+ const out = new Float32Array(Math.max(1, len));
340
+ for (const p of items) { const off = Math.round(p.at * RATE); for (let i = 0; i < p.buf.length; i++) out[off + i] += p.buf[i] * p.gain; }
341
+ return out;
342
+ }
343
+
344
+ /* ---------------------------------------------------------------- effects */
345
+
346
+ /** A DC blocker (one-pole high-pass at about 10 Hz): no offset left to click when a sound starts or stops. */
347
+ export function dcBlock(buf) {
348
+ const R0 = 1 - (2 * Math.PI * 10) / RATE;
349
+ for (const ch of buf.L ? [buf.L, buf.R] : [buf]) {
350
+ let x1 = 0; let y1 = 0;
351
+ for (let i = 0; i < ch.length; i++) { const y = ch[i] - x1 + R0 * y1; x1 = ch[i]; y1 = y; ch[i] = y; }
352
+ }
353
+ return buf;
354
+ }
355
+
356
+ /** Soft saturation: tanh drive, level-matched at small signals. */
357
+ export function drive(buf, amount = 2) {
358
+ if (!(amount > 1)) return buf;
359
+ const n = Math.tanh(amount);
360
+ for (const ch of buf.L ? [buf.L, buf.R] : [buf]) for (let i = 0; i < ch.length; i++) ch[i] = Math.tanh(ch[i] * amount) / n;
361
+ return buf;
362
+ }
363
+
364
+ /** Retro grit: fewer bits and a lower sample-and-hold rate. */
365
+ export function crush(buf, { bits = 8, rate = 11025 } = {}) {
366
+ const step = 2 / 2 ** bits;
367
+ const hold = Math.max(1, Math.round(RATE / rate));
368
+ for (const ch of buf.L ? [buf.L, buf.R] : [buf]) {
369
+ let held = 0;
370
+ for (let i = 0; i < ch.length; i++) { if (i % hold === 0) held = Math.round(ch[i] / step) * step; ch[i] = held; }
371
+ }
372
+ return buf;
373
+ }
374
+
375
+ /** A feedback echo; `pingpong` alternates sides. Returns a stereo buffer long enough for the tail. */
376
+ export function echo(buf, { time = 0.18, feedback = 0.35, mix = 0.3, pingpong = true, tail = null } = {}) {
377
+ const src = buf.L ? buf : { L: buf, R: buf };
378
+ const taps = Math.ceil(Math.log(0.001) / Math.log(Math.max(0.01, feedback)));
379
+ const extra = tail ?? Math.min(4, time * taps);
380
+ const out = stereo(secondsOf(src) + extra);
381
+ out.L.set(src.L); out.R.set(src.R);
382
+ const d = Math.round(time * RATE);
383
+ for (let k = 1, g = mix; k <= taps; k++, g *= feedback) {
384
+ const off = d * k;
385
+ const toL = !pingpong || k % 2 === 0; const toR = !pingpong || k % 2 === 1;
386
+ for (let i = 0; i < src.L.length && off + i < out.L.length; i++) {
387
+ const m = (src.L[i] + src.R[i]) * 0.5 * g;
388
+ if (toL) out.L[off + i] += m;
389
+ if (toR) out.R[off + i] += m;
390
+ }
391
+ }
392
+ return out;
393
+ }
394
+
395
+ /**
396
+ * A small plate-like reverb (eight damped combs and four allpasses a side, after Freeverb).
397
+ * room 0..1 (size), damp 0..1 (how fast the highs die), mix 0..1. Returns stereo with the tail.
398
+ */
399
+ export function reverb(buf, { room = 0.6, damp = 0.4, mix = 0.22, tail = null } = {}) {
400
+ const src = buf.L ? buf : { L: buf, R: buf };
401
+ const extra = tail ?? 0.6 + room * 2.2;
402
+ const out = stereo(secondsOf(src) + extra);
403
+ const sr = RATE / 44100;
404
+ const combs = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
405
+ const alls = [556, 441, 341, 225];
406
+ const fb = 0.7 + room * 0.28;
407
+ for (const [side, spread] of [['L', 0], ['R', 23]]) {
408
+ const cs = combs.map((c) => ({ buf: new Float32Array(Math.round((c + spread) * sr)), i: 0, store: 0 }));
409
+ const as = alls.map((a) => ({ buf: new Float32Array(Math.round((a + spread) * sr)), i: 0 }));
410
+ const input = src[side];
411
+ const dst = out[side];
412
+ for (let n = 0; n < dst.length; n++) {
413
+ const x = (n < input.length ? (src.L[n] + src.R[n]) * 0.5 : 0) * 0.015;
414
+ let acc = 0;
415
+ for (const c of cs) {
416
+ const y = c.buf[c.i];
417
+ c.store = y * (1 - damp) + c.store * damp;
418
+ c.buf[c.i] = x + c.store * fb;
419
+ c.i = (c.i + 1) % c.buf.length;
420
+ acc += y;
421
+ }
422
+ for (const a of as) {
423
+ const y = a.buf[a.i];
424
+ a.buf[a.i] = acc + y * 0.5;
425
+ acc = y - acc;
426
+ a.i = (a.i + 1) % a.buf.length;
427
+ }
428
+ dst[n] = (n < input.length ? input[n] : 0) + acc * mix * 3;
429
+ }
430
+ }
431
+ return out;
432
+ }
433
+
434
+ /** A gentle program limiter: an instant-attack, smooth-release peak follower holding peaks under `ceiling` (linear). */
435
+ export function limit(buf, { ceiling = dbToGain(-1), releaseMs = 80, lookaheadMs = 3 } = {}) {
436
+ const src = buf.L ? buf : { L: buf, R: buf };
437
+ const n = src.L.length;
438
+ const look = Math.round(lookaheadMs / 1000 * RATE);
439
+ const rel = Math.exp(-1 / (releaseMs / 1000 * RATE));
440
+ // Required gain per sample, then a running minimum over the lookahead, then smooth release.
441
+ const need = new Float32Array(n);
442
+ for (let i = 0; i < n; i++) { const p = Math.max(Math.abs(src.L[i]), Math.abs(src.R[i])); need[i] = p > ceiling ? ceiling / p : 1; }
443
+ const gain = new Float32Array(n);
444
+ let g = 1;
445
+ for (let i = n - 1; i >= 0; i--) { let m = need[i]; for (let j = 1; j <= look && i + j < n; j++) if (need[i + j] < m) m = need[i + j]; gain[i] = m; }
446
+ for (let i = 0; i < n; i++) { g = gain[i] < g ? gain[i] : gain[i] + (g - gain[i]) * rel; src.L[i] *= g; if (src.R !== src.L) src.R[i] *= g; }
447
+ return buf;
448
+ }
449
+
450
+ /* ---------------------------------------------------------------- measuring */
451
+
452
+ /**
453
+ * Levels a person hears, without ffmpeg: sample peak, whole-file RMS, and the loudest 50 ms window's RMS
454
+ * (the fair number for a short effect: integrated LUFS needs 400 ms blocks and gates a 0.2 s blip out).
455
+ */
456
+ export function levels(buf) {
457
+ const L = buf.L ?? buf; const R = buf.R ?? buf;
458
+ const n = L.length;
459
+ let peak = 0; let sum = 0;
460
+ for (let i = 0; i < n; i++) { const m = (L[i] + R[i]) * 0.5; sum += m * m; peak = Math.max(peak, Math.abs(L[i]), Math.abs(R[i])); }
461
+ const w = Math.max(1, Math.round(0.05 * RATE));
462
+ let win = 0; let best = 0;
463
+ for (let i = 0; i < n; i++) {
464
+ const m = (L[i] + R[i]) * 0.5; win += m * m;
465
+ if (i >= w) { const o = (L[i - w] + R[i - w]) * 0.5; win -= o * o; }
466
+ if (i >= w - 1 || i === n - 1) best = Math.max(best, win / Math.min(w, i + 1));
467
+ }
468
+ return { seconds: +(n / RATE).toFixed(3), peakDb: +gainToDb(peak).toFixed(2), rmsDb: +gainToDb(Math.sqrt(sum / Math.max(1, n))).toFixed(2), loudestDb: +gainToDb(Math.sqrt(best)).toFixed(2) };
469
+ }
470
+
471
+ /* ---------------------------------------------------------------- files */
472
+
473
+ /** 16- or 24-bit PCM WAV (mono or stereo). Samples are clipped at full scale and dithered at 16 bits. */
474
+ export function wavBytes(buf, { bits = 16, rate = RATE, channels = null, seed = 3 } = {}) {
475
+ const chans = buf.L ? (channels === 1 ? [mixDown(buf)] : [buf.L, buf.R]) : [buf];
476
+ const n = chans[0].length; const ch = chans.length; const bps = bits / 8;
477
+ const out = Buffer.alloc(44 + n * ch * bps);
478
+ out.write('RIFF', 0); out.writeUInt32LE(36 + n * ch * bps, 4); out.write('WAVE', 8);
479
+ out.write('fmt ', 12); out.writeUInt32LE(16, 16); out.writeUInt16LE(1, 20); out.writeUInt16LE(ch, 22);
480
+ out.writeUInt32LE(rate, 24); out.writeUInt32LE(rate * ch * bps, 28); out.writeUInt16LE(ch * bps, 32); out.writeUInt16LE(bits, 34);
481
+ out.write('data', 36); out.writeUInt32LE(n * ch * bps, 40);
482
+ const rnd = mulberry32(seed);
483
+ const max = 2 ** (bits - 1) - 1;
484
+ let o = 44;
485
+ for (let i = 0; i < n; i++) {
486
+ for (let c = 0; c < ch; c++) {
487
+ const d = bits === 16 ? (rnd() - rnd()) / max : 0;
488
+ const v = Math.max(-1, Math.min(1, chans[c][i] + d));
489
+ const q = Math.round(v * max);
490
+ if (bits === 16) out.writeInt16LE(q, o); else out.writeIntLE(q, o, 3);
491
+ o += bps;
492
+ }
493
+ }
494
+ return out;
495
+ }
496
+
497
+ export function mixDown(buf) {
498
+ if (!buf.L) return buf;
499
+ const out = new Float32Array(buf.L.length);
500
+ for (let i = 0; i < out.length; i++) out[i] = (buf.L[i] + buf.R[i]) * 0.5;
501
+ return out;
502
+ }
503
+
504
+ /** Is a stereo buffer really two channels, or the same signal twice? */
505
+ export function isWide(buf) {
506
+ if (!buf.L) return false;
507
+ for (let i = 0; i < buf.L.length; i += 7) if (Math.abs(buf.L[i] - buf.R[i]) > 1e-4) return true;
508
+ return false;
509
+ }