SOURCE / PINNED RELEASE
Made of little things.
Sickman Survival
- Release
- 7335c9fdf0a8…
- Author-recorded commit
- cbd2eeafa70d…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f
Archive hash verified: 591bab4addcbe5fc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import type { Blast, Game, Meteor } from './game.ts';
import type { Mole } from './moles.ts';
/**
* Procedural sound: every effect and ambience bed is synthesised with Web Audio
* at runtime, so the single-file napplet ships no audio assets and fetches
* nothing. Browsers only start audio after a user gesture, so the context is
* created on the first key, tap or click (see `unlock`).
*/
type Ctor = typeof AudioContext;
interface Listener {
x: number;
y: number;
/** Cells below the original ground line. */
depth: number;
}
const MAX_WHISTLES = 5;
export class Sound {
private ctx: AudioContext | null = null;
private master!: GainNode;
private sfx!: GainNode;
private reverbSend!: GainNode;
private surfaceBed!: GainNode;
private caveBed!: GainNode;
private windFilter!: BiquadFilterNode;
private windGain!: GainNode;
private stormGain!: GainNode;
private digGain!: GainNode;
private noise!: AudioBuffer;
private brown!: AudioBuffer;
private volume = 0.8;
private muted = false;
private paused = false;
private under = 0;
private storm = 0;
private time = 0;
private gust = 0;
private nextDrip = 1;
private nextBird = 2;
private nextThunder = 6;
private nextCreak = 8;
private whistles = 0;
private listener: Listener = { x: 0, y: 0, depth: 0 };
/** One ear per split-screen pane; positional sounds are heard from the nearest. */
private listeners: Listener[] = [this.listener];
private terrainDepth: (x: number, y: number) => number = () => 0;
get isMuted(): boolean {
return this.muted;
}
/** Create or resume the audio context. Call from user-gesture handlers. */
unlock(): void {
if (!this.ctx) {
const Audio = audioCtor();
if (!Audio) return;
try {
this.ctx = new Audio({ latencyHint: 'interactive' });
} catch {
return;
}
this.build(this.ctx);
}
if (this.ctx.state === 'suspended' && !this.paused) void this.ctx.resume().catch(() => {});
}
setVolume(volume: number): void {
this.volume = Math.max(0, Math.min(1, volume));
this.applyMaster();
}
setMuted(muted: boolean): void {
this.muted = muted;
this.applyMaster();
}
/** Pausing freezes all sound, including tails that are still ringing. */
setPaused(paused: boolean): void {
this.paused = paused;
if (!this.ctx) return;
if (paused) void this.ctx.suspend().catch(() => {});
else void this.ctx.resume().catch(() => {});
}
/** Per-frame: follow the stickman, mix surface/cave beds and schedule ambient one-shots. */
update(game: Game, dt: number): void {
const t = game.terrain;
this.terrainDepth = (x, y) => t.depthAt(x, y);
this.listeners = game.players.map((p) => {
const x = game.focusX(p.slot);
const y = game.focusY(p.slot);
return { x, y, depth: t.depthAt(x, y) };
});
if (this.listeners.length === 0) this.listeners = [{ x: game.focusX(), y: game.focusY(), depth: 0 }];
this.listener = this.listeners[0];
const ctx = this.ctx;
if (!ctx || this.paused || dt <= 0) return;
this.time += dt;
// The cave bed follows the share of players underground.
let underTarget = 0;
for (const l of this.listeners) underTarget += clamp((l.depth - 4) / 22, 0, 1) / this.listeners.length;
this.under += (underTarget - this.under) * (1 - Math.exp(-dt * 2));
this.storm = Math.min(1, game.stormTime() / 150);
const now = ctx.currentTime;
const surface = 1 - this.under;
// Wind gusts: a slow wander plus occasional surges, stronger as the storm builds.
this.gust = Math.max(0, this.gust - dt * 0.35);
if (Math.random() < dt * (0.08 + this.storm * 0.25)) this.gust = Math.random() * 0.8 + 0.2;
const sway = 0.5 + 0.5 * Math.sin(this.time * 0.37) * Math.sin(this.time * 0.11 + 1);
this.windGain.gain.setTargetAtTime((0.05 + sway * 0.06 + this.gust * 0.12 + this.storm * 0.1) * surface, now, 0.4);
this.windFilter.frequency.setTargetAtTime(260 + sway * 260 + this.gust * 600 + this.storm * 300, now, 0.5);
this.stormGain.gain.setTargetAtTime((0.02 + this.storm * 0.22) * (surface * 0.8 + 0.2), now, 1);
this.surfaceBed.gain.setTargetAtTime(0.25 + surface * 0.75, now, 0.3);
this.caveBed.gain.setTargetAtTime(this.under, now, 0.4);
this.reverbSend.gain.setTargetAtTime(this.under * 0.55, now, 0.4);
// Something scratching through the rock nearby.
let digging = 0;
for (const m of game.moles) {
if (m.state !== 'burrow') continue;
digging = Math.max(digging, 1 - this.distance(m.x, m.y) / 170);
}
this.digGain.gain.setTargetAtTime(digging * (0.6 + Math.random() * 0.6) * 0.22, now, 0.05);
if ((this.nextBird -= dt) <= 0) {
this.nextBird = 2 + Math.random() * 6;
if (this.storm < 0.45 && surface > 0.6) this.bird(1 - this.storm * 2);
}
if ((this.nextThunder -= dt) <= 0) {
this.nextThunder = 14 - this.storm * 10 + Math.random() * 8;
if (this.storm > 0.08) this.thunder(0.4 + this.storm * 0.6);
}
if ((this.nextDrip -= dt) <= 0) {
this.nextDrip = 0.5 + Math.random() * 2.2;
if (this.under > 0.15) this.drip();
}
if ((this.nextCreak -= dt) <= 0) {
this.nextCreak = 7 + Math.random() * 12;
if (this.under > 0.4) this.creak();
}
}
// Effects ────────────────────────────────────────────────────────────────
jump(): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const pitch = 1 + (Math.random() - 0.5) * 0.12;
const osc = this.osc('triangle', 260 * pitch, now);
osc.frequency.exponentialRampToValueAtTime(560 * pitch, now + 0.09);
this.envelope(osc, now, 0.004, 0.11, 0.09);
this.noiseHit(now, 0.05, 1800, 'bandpass', 0.05);
}
land(impact: number): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const k = clamp(impact / 900, 0.15, 1);
this.noiseHit(now, 0.05 + k * 0.08, 500 + k * 400, 'lowpass', 0.14 * k + 0.04);
const osc = this.osc('sine', 120, now);
osc.frequency.exponentialRampToValueAtTime(45, now + 0.12);
this.envelope(osc, now, 0.003, 0.18 * k, 0.14);
}
footstep(): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const deep = this.under > 0.5;
this.noiseHit(now, 0.035, deep ? 900 : 1400 + Math.random() * 900, 'bandpass', 0.035);
}
bounce(): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const osc = this.osc('sine', 150, now);
osc.frequency.exponentialRampToValueAtTime(520, now + 0.16);
osc.frequency.exponentialRampToValueAtTime(380, now + 0.32);
const wobble = this.osc('sine', 18, now);
const depth = ctx.createGain();
depth.gain.value = 40;
wobble.connect(depth).connect(osc.frequency);
wobble.start(now);
wobble.stop(now + 0.4);
this.envelope(osc, now, 0.005, 0.2, 0.34);
}
/** A falling whistle that drops in pitch until the expected impact. */
meteor(m: Meteor): void {
const ctx = this.live();
if (!ctx || this.whistles >= MAX_WHISTLES) return;
const impactX = m.x + m.vx * m.eta;
const dx = Math.abs(impactX - this.nearest(impactX, 0, true).x);
if (dx > 900) return;
const now = ctx.currentTime;
const duration = clamp(m.eta, 0.3, 4);
const loud = (1 - dx / 900) ** 2 * 0.16;
const src = this.noiseSource(now, duration + 0.1);
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.Q.value = 9;
band.frequency.setValueAtTime(2600 + Math.random() * 600, now);
band.frequency.exponentialRampToValueAtTime(500, now + duration);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(loud * this.occlusion(impactX, -100), now + duration * 0.85);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration + 0.08);
src.connect(band).connect(gain).connect(this.panner(impactX)).connect(this.sfx);
this.whistles++;
src.onended = () => this.whistles--;
}
/** Layered blast: sub thump, roaring noise sweep, crackle and falling debris. */
explosion(b: Blast): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const d = this.distance(b.x, b.y);
const size = clamp(b.radius / 40, 0.5, 1.4);
const level = clamp(1.25 / (1 + d / 220), 0, 1);
if (level < 0.02) return;
const muffle = this.occlusion(b.x, b.y);
const cutoff = clamp(9000 / (1 + d / 140), 300, 9000) * (muffle < 1 ? 0.35 : 1);
const out = ctx.createBiquadFilter();
out.type = 'lowpass';
out.frequency.value = cutoff;
out.connect(this.panner(b.x)).connect(this.sfx);
// Sub thump.
const sub = this.osc('sine', 95 / size, now);
sub.frequency.exponentialRampToValueAtTime(28, now + 0.5 * size);
this.envelope(sub, now, 0.005, 0.9 * level, 0.7 * size, out);
// Roar: noise through a closing lowpass.
const roar = this.noiseSource(now, 1.8 * size);
const sweep = ctx.createBiquadFilter();
sweep.type = 'lowpass';
sweep.frequency.setValueAtTime(4200, now);
sweep.frequency.exponentialRampToValueAtTime(140, now + 1.4 * size);
const roarGain = ctx.createGain();
roarGain.gain.setValueAtTime(0.0001, now);
roarGain.gain.exponentialRampToValueAtTime(0.75 * level, now + 0.012);
roarGain.gain.exponentialRampToValueAtTime(0.0001, now + 1.7 * size);
roar.connect(sweep).connect(roarGain).connect(out);
// Crack: the first instant, bright.
const crack = this.noiseSource(now, 0.12);
const high = ctx.createBiquadFilter();
high.type = 'highpass';
high.frequency.value = 1800;
const crackGain = ctx.createGain();
crackGain.gain.setValueAtTime(0.5 * level, now);
crackGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.1);
crack.connect(high).connect(crackGain).connect(out);
// Debris pattering down afterwards.
for (let i = Math.round(4 + size * 6); i > 0; i--) {
const at = now + 0.25 + Math.random() * 0.9 * size;
this.noiseHit(at, 0.02 + Math.random() * 0.03, 700 + Math.random() * 2400, 'bandpass', 0.12 * level * Math.random(), out);
}
// A close call leaves the ears ringing.
if (b.proximity > 0.75) {
const ring = this.osc('sine', 3400 + Math.random() * 300, now);
this.envelope(ring, now + 0.05, 0.08, 0.025 * b.proximity, 1.8);
}
}
moleSurface(m: Mole): void {
const ctx = this.live();
const level = this.near(m.x, m.y, 320);
if (!ctx || level <= 0) return;
const now = ctx.currentTime;
const out = this.panner(m.x);
out.connect(this.sfx);
this.noiseHit(now, 0.18, 380, 'lowpass', 0.3 * level, out);
this.noiseHit(now + 0.03, 0.08, 1200, 'bandpass', 0.12 * level, out);
// A little squeak as it pops out.
const squeak = this.osc('sine', 1300, now + 0.08);
squeak.frequency.exponentialRampToValueAtTime(2100, now + 0.16);
squeak.frequency.exponentialRampToValueAtTime(1500, now + 0.22);
this.envelope(squeak, now + 0.08, 0.01, 0.06 * level, 0.15, out);
}
moleDive(m: Mole): void {
const ctx = this.live();
const level = this.near(m.x, m.y, 260);
if (!ctx || level <= 0) return;
const out = this.panner(m.x);
out.connect(this.sfx);
this.noiseHit(ctx.currentTime, 0.22, 300, 'lowpass', 0.22 * level, out);
}
/** Fizzing fuse plus beeps that speed up toward detonation. */
moleArmed(m: Mole): void {
const ctx = this.live();
const level = this.near(m.x, m.y, 420);
if (!ctx || level <= 0) return;
const now = ctx.currentTime;
const fuse = Math.max(0.05, m.fuse);
const out = this.panner(m.x);
out.connect(this.sfx);
const hiss = this.noiseSource(now, fuse);
const high = ctx.createBiquadFilter();
high.type = 'highpass';
high.frequency.setValueAtTime(3000, now);
high.frequency.linearRampToValueAtTime(6000, now + fuse);
const hissGain = ctx.createGain();
hissGain.gain.setValueAtTime(0.03 * level, now);
hissGain.gain.linearRampToValueAtTime(0.11 * level, now + fuse);
hiss.connect(high).connect(hissGain).connect(out);
if (fuse < 0.3) return; // chain-reaction fuses are too short for a warning
// Beeps speed up toward detonation; the floor on the gap keeps the count finite.
for (let at = 0, gap = 0.2; at < fuse - 0.04; at += gap, gap = Math.max(0.05, gap * 0.72)) {
const beep = this.osc('square', 880 + (at / fuse) * 700, now + at);
this.envelope(beep, now + at, 0.003, 0.09 * level, 0.05, out);
}
}
death(): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const osc = this.osc('triangle', 420, now + 0.05);
osc.frequency.exponentialRampToValueAtTime(90, now + 0.6);
this.envelope(osc, now + 0.05, 0.01, 0.07, 0.6);
}
/** Small UI click for start, pause and resume. */
click(): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const osc = this.osc('square', 660, now);
osc.frequency.exponentialRampToValueAtTime(990, now + 0.04);
this.envelope(osc, now, 0.002, 0.04, 0.05);
}
// Ambient one-shots ──────────────────────────────────────────────────────
private bird(level: number): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const out = this.panner(this.listener.x + (Math.random() - 0.5) * 600);
out.connect(this.surfaceBed);
const base = 2400 + Math.random() * 1400;
for (let i = 0, n = 2 + Math.floor(Math.random() * 3); i < n; i++) {
const at = now + i * (0.09 + Math.random() * 0.05);
const chirp = this.osc('sine', base, at);
chirp.frequency.exponentialRampToValueAtTime(base * (1.3 + Math.random() * 0.3), at + 0.05);
this.envelope(chirp, at, 0.005, 0.025 * level, 0.07, out);
}
}
private thunder(level: number): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const duration = 2.5 + Math.random() * 2;
const src = this.noiseSource(now, duration, this.brown);
const low = ctx.createBiquadFilter();
low.type = 'lowpass';
low.frequency.value = 160 + Math.random() * 120;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.35 * level, now + 0.3 + Math.random() * 0.5);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
src.connect(low).connect(gain).connect(this.surfaceBed);
}
private drip(): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const out = this.panner(this.listener.x + (Math.random() - 0.5) * 500);
out.connect(this.caveBed);
const f = 1300 + Math.random() * 1500;
const plink = this.osc('sine', f, now);
plink.frequency.exponentialRampToValueAtTime(f * 0.55, now + 0.08);
this.envelope(plink, now, 0.002, 0.06 + Math.random() * 0.05, 0.12, out);
}
/** Rock settling somewhere in the dark. */
private creak(): void {
const ctx = this.live();
if (!ctx) return;
const now = ctx.currentTime;
const duration = 0.8 + Math.random() * 0.8;
const src = this.noiseSource(now, duration, this.brown);
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.Q.value = 4;
band.frequency.setValueAtTime(140 + Math.random() * 120, now);
band.frequency.linearRampToValueAtTime(90, now + duration);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.5, now + duration * 0.3);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
src.connect(band).connect(gain).connect(this.caveBed);
for (let i = 0; i < 5; i++) {
this.noiseHit(now + duration * (0.4 + Math.random() * 0.8), 0.02, 1500 + Math.random() * 1500, 'bandpass',
0.03 * Math.random(), this.caveBed);
}
}
// Graph ──────────────────────────────────────────────────────────────────
private build(ctx: AudioContext): void {
this.noise = makeNoise(ctx, 2, false);
this.brown = makeNoise(ctx, 4, true);
const compressor = ctx.createDynamicsCompressor();
compressor.threshold.value = -14;
compressor.ratio.value = 6;
compressor.attack.value = 0.003;
compressor.release.value = 0.25;
this.master = ctx.createGain();
this.master.connect(compressor).connect(ctx.destination);
this.applyMaster();
// Cave echo: a generated impulse response shared by effects and the cave bed.
const reverb = ctx.createConvolver();
reverb.buffer = makeImpulse(ctx, 2.4);
reverb.connect(this.master);
this.reverbSend = ctx.createGain();
this.reverbSend.gain.value = 0;
this.reverbSend.connect(reverb);
this.sfx = ctx.createGain();
this.sfx.connect(this.master);
this.sfx.connect(this.reverbSend);
// Surface bed: wind and a distant storm rumble.
this.surfaceBed = ctx.createGain();
this.surfaceBed.connect(this.master);
const wind = this.loop(this.noise);
this.windFilter = ctx.createBiquadFilter();
this.windFilter.type = 'bandpass';
this.windFilter.Q.value = 0.8;
this.windFilter.frequency.value = 400;
this.windGain = ctx.createGain();
this.windGain.gain.value = 0;
wind.connect(this.windFilter).connect(this.windGain).connect(this.surfaceBed);
const rumble = this.loop(this.brown);
const rumbleFilter = ctx.createBiquadFilter();
rumbleFilter.type = 'lowpass';
rumbleFilter.frequency.value = 90;
this.stormGain = ctx.createGain();
this.stormGain.gain.value = 0;
rumble.connect(rumbleFilter).connect(this.stormGain).connect(this.surfaceBed);
// Cave bed: a low hollow drone, echoing.
this.caveBed = ctx.createGain();
this.caveBed.gain.value = 0;
this.caveBed.connect(this.master);
const caveSend = ctx.createGain();
caveSend.gain.value = 0.7;
this.caveBed.connect(caveSend).connect(reverb);
const drone = this.loop(this.brown);
const droneFilter = ctx.createBiquadFilter();
droneFilter.type = 'lowpass';
droneFilter.frequency.value = 180;
const droneGain = ctx.createGain();
droneGain.gain.value = 0.22;
drone.connect(droneFilter).connect(droneGain).connect(this.caveBed);
// Moles digging nearby: a gritty scratch, gain driven per frame.
const grit = this.loop(this.noise);
const gritFilter = ctx.createBiquadFilter();
gritFilter.type = 'bandpass';
gritFilter.frequency.value = 700;
gritFilter.Q.value = 1.4;
this.digGain = ctx.createGain();
this.digGain.gain.value = 0;
grit.connect(gritFilter).connect(this.digGain).connect(this.sfx);
}
private applyMaster(): void {
if (!this.ctx) return;
const level = this.muted ? 0 : this.volume * 0.9;
this.master.gain.setTargetAtTime(level, this.ctx.currentTime, 0.05);
}
/** The context, if sound should play right now. */
private live(): AudioContext | null {
const ctx = this.ctx;
return ctx && ctx.state === 'running' && !this.muted && this.volume > 0 ? ctx : null;
}
private loop(buffer: AudioBuffer): AudioBufferSourceNode {
const src = this.ctx!.createBufferSource();
src.buffer = buffer;
src.loop = true;
src.start(0, Math.random() * buffer.duration);
return src;
}
private noiseSource(at: number, duration: number, buffer = this.noise): AudioBufferSourceNode {
const src = this.ctx!.createBufferSource();
src.buffer = buffer;
src.loop = true;
src.start(at, Math.random() * buffer.duration);
src.stop(at + duration + 0.05);
return src;
}
private osc(type: OscillatorType, frequency: number, at: number): OscillatorNode {
const osc = this.ctx!.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(frequency, at);
return osc;
}
/** Attack/decay envelope into `out`; starts and stops the oscillator. */
private envelope(
osc: OscillatorNode, at: number, attack: number, peak: number, decay: number, out: AudioNode = this.sfx,
): void {
const gain = this.ctx!.createGain();
gain.gain.setValueAtTime(0.0001, at);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), at + attack);
gain.gain.exponentialRampToValueAtTime(0.0001, at + attack + decay);
osc.connect(gain).connect(out);
osc.start(at);
osc.stop(at + attack + decay + 0.05);
}
private noiseHit(
at: number, duration: number, frequency: number, type: BiquadFilterType, peak: number, out: AudioNode = this.sfx,
): void {
const ctx = this.ctx!;
const src = this.noiseSource(at, duration);
const filter = ctx.createBiquadFilter();
filter.type = type;
filter.frequency.value = frequency;
const gain = ctx.createGain();
gain.gain.setValueAtTime(Math.max(0.0002, peak), at);
gain.gain.exponentialRampToValueAtTime(0.0001, at + duration);
src.connect(filter).connect(gain).connect(out);
}
private panner(x: number): AudioNode {
const ctx = this.ctx!;
// Split screen has no single left and right, so stereo narrows.
const width = this.listeners.length > 1 ? 0.4 : 0.8;
const pan = clamp((x - this.nearest(x, 0, true).x) / 320, -1, 1) * width;
if (typeof ctx.createStereoPanner !== 'function') return ctx.createGain();
const node = ctx.createStereoPanner();
node.pan.value = pan;
return node;
}
private distance(x: number, y: number): number {
const l = this.nearest(x, y);
return Math.hypot(x - l.x, y - l.y);
}
/** The listener closest to (x, y), or closest in x alone. */
private nearest(x: number, y: number, xOnly = false): Listener {
let best = this.listener;
let bestDistance = Infinity;
for (const l of this.listeners) {
const d = xOnly ? Math.abs(x - l.x) : Math.hypot(x - l.x, y - l.y);
if (d < bestDistance) {
best = l;
bestDistance = d;
}
}
return best;
}
/** 0–1 loudness for a sound at (x, y) that fades out by `range` units. */
private near(x: number, y: number, range: number): number {
return clamp(1 - this.distance(x, y) / range, 0, 1) ** 1.5;
}
/** 1 when source and listener share the open air or the caves; less when rock is between them. */
private occlusion(x: number, y: number): number {
const sourceDeep = this.terrainDepth(x, y) > 6;
const listenerDeep = this.nearest(x, y).depth > 6;
return sourceDeep === listenerDeep ? 1 : 0.45;
}
}
function audioCtor(): Ctor | null {
const w = window as unknown as { AudioContext?: Ctor; webkitAudioContext?: Ctor };
return w.AudioContext ?? w.webkitAudioContext ?? null;
}
function makeNoise(ctx: AudioContext, seconds: number, brown: boolean): AudioBuffer {
const length = Math.floor(ctx.sampleRate * seconds);
const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
const data = buffer.getChannelData(0);
let last = 0;
for (let i = 0; i < length; i++) {
const white = Math.random() * 2 - 1;
if (brown) {
last = (last + 0.02 * white) / 1.02;
data[i] = last * 3.5;
} else {
data[i] = white;
}
}
return buffer;
}
function makeImpulse(ctx: AudioContext, seconds: number): AudioBuffer {
const length = Math.floor(ctx.sampleRate * seconds);
const buffer = ctx.createBuffer(2, length, ctx.sampleRate);
for (let c = 0; c < 2; c++) {
const data = buffer.getChannelData(c);
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3;
}
return buffer;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
