SOURCE / PINNED RELEASE
Made of little things.
Black Site
- Release
- 666bc7c74ebd…
- Author-recorded commit
- 05e95af79676…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391
Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
'use strict';
// BLACK SITE - synthesized sound effects (WebAudio, no asset files).
const Sfx = (() => {
let ac = null, master = null, noise = null, muffle = null, duck = null, ringBus = null, ringing = [];
function init() {
if (ac) { if (ac.state === 'suspended') ac.resume(); return; }
try {
ac = new (window.AudioContext || window.webkitAudioContext)();
// every normal sound -> master -> muffle (lowpass) -> duck (volume) -> compressor -> out.
// Ear ringing bypasses all of that on its own bus, so it stays loud and clear.
master = ac.createGain(); master.gain.value = 0.45;
muffle = ac.createBiquadFilter(); muffle.type = 'lowpass'; muffle.frequency.value = 22000; muffle.Q.value = 0.4;
duck = ac.createGain(); duck.gain.value = 1;
const comp = ac.createDynamicsCompressor();
master.connect(muffle); muffle.connect(duck); duck.connect(comp); comp.connect(ac.destination);
ringBus = ac.createGain(); ringBus.gain.value = 1; ringBus.connect(ac.destination);
noise = ac.createBuffer(1, ac.sampleRate * 2, ac.sampleRate);
const d = noise.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
// build the voice variants in the background, one at a time, so a first scream never stutters
const queue = ['scream', 'groan', 'scream', 'groan', 'scream', 'scream', 'groan', 'scream', 'groan', 'scream'];
const next = () => { const k = queue.shift(); if (!k) return; if (VOICES[k].length < 6) VOICES[k].push(renderVoice(k)); setTimeout(next, 60); };
setTimeout(next, 500);
} catch (e) { ac = null; }
}
// filtered noise burst. o: { type, q, sweep (end freq), delay, attack }
function burst(dur, freq, vol, o = {}) {
if (!ac || vol < 0.005) return;
const t = ac.currentTime + (o.delay || 0);
const src = ac.createBufferSource(); src.buffer = noise;
const f = ac.createBiquadFilter();
f.type = o.type || 'lowpass'; f.Q.value = o.q || 0.7;
f.frequency.setValueAtTime(freq, t);
if (o.sweep) f.frequency.exponentialRampToValueAtTime(o.sweep, t + dur);
const g = ac.createGain();
if (o.attack) { g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + o.attack); }
else g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
src.connect(f); f.connect(g); g.connect(master);
src.start(t, Math.random() * 1.5); src.stop(t + dur + 0.02);
}
function tone(freq, dur, vol, type = 'sine', to = null, delay = 0) {
if (!ac || vol < 0.005) return;
const t = ac.currentTime + delay;
const o = ac.createOscillator(); o.type = type;
o.frequency.setValueAtTime(freq, t);
if (to) o.frequency.exponentialRampToValueAtTime(to, t + dur);
const g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.005);
g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
o.connect(g); g.connect(master); o.start(t); o.stop(t + dur + 0.02);
}
// ---------------------------------------------------------------- guns
// crack: supersonic snap, body: the blast, thump: low punch, tail: the report rolling away
// [freq, duration, volume, (sweep)]
const GUN = {
m4a1: { crack: [6200, 0.03, 0.5], body: [2500, 0.13, 0.6, 520], thump: [140, 0.11, 0.42], tail: [950, 0.5, 0.17] },
ak47: { crack: [4300, 0.035, 0.45], body: [1500, 0.2, 0.72, 280], thump: [92, 0.17, 0.62], tail: [650, 0.75, 0.23] },
mp5: { crack: [4800, 0.02, 0.22], body: [1900, 0.08, 0.42, 650], thump: [175, 0.07, 0.28], tail: [1300, 0.22, 0.07] },
spas12: { crack: [2600, 0.045, 0.5], body: [1100, 0.36, 0.95, 140], thump: [66, 0.32, 0.85], tail: [450, 1.1, 0.3], after: 'pump' },
barrett: { crack: [5200, 0.05, 0.75], body: [900, 0.55, 1.0, 110], thump: [46, 0.55, 0.95], tail: [380, 1.9, 0.36], echo: 0.38, after: 'bolt' },
m249: { crack: [5600, 0.03, 0.45], body: [2100, 0.15, 0.64, 420], thump: [118, 0.13, 0.52], tail: [800, 0.55, 0.19] },
m1911: { crack: [3300, 0.025, 0.38], body: [1400, 0.13, 0.56, 380], thump: [105, 0.12, 0.52], tail: [850, 0.35, 0.1] },
glock18: { crack: [6800, 0.018, 0.33], body: [2900, 0.065, 0.4, 950], thump: [185, 0.06, 0.24], tail: [1500, 0.18, 0.06] },
deagle: { crack: [4600, 0.04, 0.62], body: [1250, 0.3, 0.88, 190], thump: [72, 0.27, 0.78], tail: [580, 0.95, 0.26], echo: 0.22 },
magnum: { crack: [5600, 0.04, 0.6], body: [1800, 0.26, 0.82, 300], thump: [84, 0.22, 0.66], tail: [820, 0.85, 0.22], ring: 2300 },
};
// reload character per gun: which sequence it uses and a pitch multiplier
const RELOAD = {
m4a1: ['rifle', 1], ak47: ['rifle', 0.78], mp5: ['rifle', 1.18], m249: ['lmg', 0.85], spas12: ['shotgun', 0.95], barrett: ['bolt', 0.7],
m1911: ['pistol', 1], glock18: ['pistol', 1.22], deagle: ['pistol', 0.82], magnum: ['revolver', 1],
};
// mechanical noises used to build reloads
function mech(kind, t, vol, p = 1) {
switch (kind) {
case 'click': burst(0.025, 3600 * p, 0.5 * vol, { type: 'bandpass', q: 4, delay: t }); tone(2100 * p, 0.02, 0.12 * vol, 'square', null, t); break;
case 'clack': burst(0.05, 1700 * p, 0.6 * vol, { type: 'bandpass', q: 2, delay: t }); tone(620 * p, 0.05, 0.22 * vol, 'triangle', 300 * p, t); break;
case 'slide': burst(0.13, 2600 * p, 0.35 * vol, { type: 'bandpass', q: 3, sweep: 900 * p, delay: t }); break;
case 'magout': burst(0.09, 2200 * p, 0.35 * vol, { type: 'bandpass', q: 2.5, sweep: 1200 * p, delay: t }); mech('click', t + 0.02, vol * 0.7, p); break;
case 'magin': mech('slide', t, vol * 0.6, p); mech('clack', t + 0.1, vol, p); break;
case 'rack': mech('slide', t, vol, p); mech('clack', t + 0.14, vol * 1.1, p); break;
case 'snap': burst(0.04, 2400 * p, 0.7 * vol, { type: 'bandpass', q: 2.5, delay: t }); tone(900 * p, 0.04, 0.2 * vol, 'square', 500 * p, t); break;
case 'shell': tone(1900 * p, 0.04, 0.14 * vol, 'triangle', 1300 * p, t); burst(0.035, 3800 * p, 0.35 * vol, { type: 'bandpass', q: 3, delay: t + 0.01 }); break;
case 'jingle': for (let k = 0; k < 6; k++) tone(3200 + Math.random() * 2400, 0.06, 0.05 * vol, 'sine', null, t + k * 0.035 + Math.random() * 0.02); break;
case 'cover': burst(0.08, 900 * p, 0.7 * vol, { type: 'bandpass', q: 1.5, delay: t }); tone(220 * p, 0.08, 0.3 * vol, 'triangle', 140 * p, t); break;
case 'belt': for (let k = 0; k < 8; k++) burst(0.02, 3000 + Math.random() * 1500, 0.18 * vol, { type: 'bandpass', q: 4, delay: t + k * 0.03 }); break;
case 'pump': burst(0.1, 1500 * p, 0.5 * vol, { type: 'bandpass', q: 2, sweep: 700 * p, delay: t }); mech('clack', t + 0.12, vol * 1.2, p * 0.9); break;
case 'bolt': mech('clack', t, vol * 0.8, p * 0.8); mech('slide', t + 0.08, vol * 0.8, p * 0.8); mech('slide', t + 0.25, vol * 0.8, p * 0.9); mech('clack', t + 0.38, vol, p * 0.8); break;
}
}
function shot(id, vol = 1) {
const s = GUN[id] || GUN.m4a1;
burst(s.crack[1], s.crack[0], s.crack[2] * vol, { type: 'highpass' });
burst(s.body[1], s.body[0], s.body[2] * vol, { sweep: s.body[3] });
tone(s.thump[0], s.thump[1], s.thump[2] * vol, 'sine', s.thump[0] * 0.35);
burst(s.tail[1], s.tail[0], s.tail[2] * vol, { sweep: s.tail[0] * 0.4, delay: 0.02 });
if (s.echo) burst(s.tail[1] * 0.8, s.tail[0] * 0.8, s.tail[2] * 0.45 * vol, { sweep: s.tail[0] * 0.3, delay: s.echo });
if (s.ring) tone(s.ring, 0.3, 0.035 * vol, 'sine', null, 0.01);
if (s.after) mech(s.after, s.after === 'pump' ? 0.38 : 0.55, 0.6 * vol, s.after === 'pump' ? 0.95 : 0.7);
}
// A full reload sequence spread over the reload time d (seconds).
function reload(id, d, vol = 1) {
const [kind, p] = RELOAD[id] || RELOAD.m4a1;
switch (kind) {
case 'rifle': mech('magout', d * 0.12, vol, p); mech('magin', d * 0.55, vol, p); mech('rack', d * 0.8, vol, p); break;
case 'lmg': mech('cover', d * 0.08, vol, p); mech('belt', d * 0.3, vol, p); mech('magout', d * 0.42, vol, p); mech('belt', d * 0.55, vol, p); mech('cover', d * 0.72, vol * 1.2, p); mech('rack', d * 0.86, vol, p); break;
case 'shotgun': for (let k = 0; k < 5; k++) mech('shell', d * (0.08 + k * 0.15), vol, p); mech('pump', d * 0.86, vol, p); break;
case 'bolt': mech('bolt', d * 0.06, vol, p); mech('magout', d * 0.32, vol, p); mech('magin', d * 0.6, vol, p); mech('bolt', d * 0.8, vol, p); break;
case 'pistol': mech('magout', d * 0.12, vol, p); mech('magin', d * 0.5, vol, p); mech('snap', d * 0.82, vol, p); break;
case 'revolver': mech('click', d * 0.06, vol, 0.8); mech('jingle', d * 0.2, vol, p); for (let k = 0; k < 3; k++) mech('shell', d * (0.42 + k * 0.1), vol, p); mech('snap', d * 0.86, vol, 0.7); break;
}
}
// ---------------------------------------------------------------- footsteps
function step(surface, vol = 1) {
const p = 0.9 + Math.random() * 0.2;
switch (surface) {
case 'wood': burst(0.06, 850 * p, 0.55 * vol, { type: 'bandpass', q: 1.6 }); tone(150 * p, 0.09, 0.3 * vol, 'triangle', 105); break;
case 'grass': burst(0.1, 1300 * p, 0.33 * vol, { type: 'bandpass', q: 0.8, attack: 0.015 }); burst(0.07, 3600, 0.1 * vol, { type: 'highpass', delay: 0.02 }); break;
case 'sand': burst(0.12, 2700 * p, 0.3 * vol, { type: 'bandpass', q: 1, attack: 0.02 }); burst(0.08, 5200, 0.12 * vol, { type: 'highpass', delay: 0.035 }); break;
case 'snow': burst(0.07, 1100 * p, 0.42 * vol, { type: 'bandpass', q: 2.2 }); burst(0.09, 2100 * p, 0.32 * vol, { type: 'bandpass', q: 3, delay: 0.05 }); break;
case 'carpet': burst(0.06, 480 * p, 0.3 * vol, { attack: 0.01 }); break;
default: burst(0.035, 2300 * p, 0.45 * vol, { type: 'bandpass', q: 1.2 }); tone(115 * p, 0.05, 0.22 * vol, 'sine', 70); break; // hard: concrete, asphalt, tile
}
}
// ---------------------------------------------------------------- voices
// ---- human voice synthesis (screams and groans)
// Voices are rendered sample by sample into buffers, the way speech synthesizers do it:
// * a glottal pulse train (Rosenberg pulse shape), with every vocal cord cycle slightly
// different in length (jitter) and loudness (shimmer), alternate cycles weaker (the rough,
// torn quality of a strained voice), and a natural wandering pitch contour with a crack;
// * a cascade of five resonators (the throat and mouth) for the vowel, gliding as the
// mouth opens and closes;
// * soft clipping for strain.
// Several variants of each are made once and picked at random, so no two cries match.
let ringId = 0;
const VOICES = { scream: [], groan: [] };
function gauss() { return (Math.random() + Math.random() + Math.random() - 1.5) * 1.15; }
function renderVoice(kind) {
const sr = ac.sampleRate;
const scream = kind === 'scream';
const dur = scream ? 0.85 + Math.random() * 0.6 : 1.1 + Math.random() * 0.6;
const gasp = 0;
const n = Math.floor((dur + gasp + 0.1) * sr);
const out = new Float32Array(n);
// pitch contour (Hz) over the cry
const f0Base = scream ? 300 + Math.random() * 90 : 115 + Math.random() * 30;
const peak = scream ? 1.25 + Math.random() * 0.2 : 1.1;
const crackAt = scream ? 0.45 + Math.random() * 0.3 : -1; // moment the voice cracks
const wander = [0, 1, 2].map(() => [0.6 + Math.random() * 2.2, Math.random() * 6.3, 0.02 + Math.random() * 0.03]);
const vibF = 4.5 + Math.random() * 2.5;
const f0At = u => { // u: 0..1 through the cry
let k;
if (u < 0.12) k = 0.62 + (peak - 0.62) * Math.sin((u / 0.12) * Math.PI / 2); // tears out
else if (u < 0.75) k = peak - (u - 0.12) * 0.25; // holds, straining
else k = (peak - 0.16) - (u - 0.75) / 0.25 * (scream ? 0.45 : 0.25); // breaks down
if (crackAt > 0 && u > crackAt && u < crackAt + 0.06) k *= 1.35; // crack
let w = 0; for (const [f, ph, a] of wander) w += Math.sin(u * dur * f * 6.283 + ph) * a;
return f0Base * k * (1 + w + Math.sin(u * dur * vibF * 6.283) * 0.018);
};
// vowel formants [start, middle, end] Hz and bandwidths: scream "AAH" opening to "AEH" then
// closing toward "UH"; groan "UHH"/"OH"
const mouth = 0.92 + Math.random() * 0.16;
const F = scream
? [[700, 880, 620, 110], [1180, 1420, 1080, 130], [2500, 2650, 2400, 190], [3450, 3550, 3350, 260], [4300, 4400, 4200, 320]]
: [[480, 560, 430, 90], [900, 980, 820, 110], [2400, 2450, 2350, 170], [3300, 3350, 3250, 250], [4200, 4200, 4200, 300]];
const state = F.map(() => [0, 0]);
// glottal source
let phase = 0, period = 1 / f0At(0), amp = 1, cyc = 0, glPrev = 0;
const oq = scream ? 0.5 : 0.62; // open quotient: tighter = brighter, harsher
const rough = scream ? 0.18 + Math.random() * 0.22 : 0.12;
const voiced = Math.floor(dur * sr);
for (let i = 0; i < voiced; i++) {
const u = i / voiced;
phase += 1 / (period * sr);
if (phase >= 1) { // a new vocal cord cycle: new jitter, shimmer and roughness
phase -= 1; cyc++;
period = (1 / f0At(u)) * (1 + gauss() * (scream ? 0.025 : 0.018));
amp = (1 + gauss() * (scream ? 0.12 : 0.08)) * (cyc % 2 ? 1 - rough : 1);
}
// Rosenberg glottal flow pulse, then its derivative (what the mouth actually radiates)
let gl;
const tp = oq * 0.62;
if (phase < tp) gl = 0.5 * (1 - Math.cos(Math.PI * phase / tp));
else if (phase < oq) gl = Math.cos((Math.PI / 2) * (phase - tp) / (oq - tp));
else gl = 0;
gl *= amp;
let x = (gl - glPrev) * 18; glPrev = gl;
// loudness envelope: hard attack, a dip and second push, release
let env;
if (u < 0.04) env = u / 0.04;
else if (u < 0.6) env = 1 - (u - 0.04) * 0.35;
else if (u < 0.7) env = 0.8 + (u - 0.6) * 1.5;
else env = 0.95 * Math.pow(1 - (u - 0.7) / 0.3, 1.6);
x *= env;
// throat and mouth: cascade of resonators, formants gliding over the cry
for (let k = 0; k < F.length; k++) {
const [a, b, c, bw] = F[k];
const f = (u < 0.3 ? a + (b - a) * (u / 0.3) : b + (c - b) * ((u - 0.3) / 0.7)) * mouth;
const C = -Math.exp(-2 * Math.PI * bw / sr), B = 2 * Math.exp(-Math.PI * bw / sr) * Math.cos(2 * Math.PI * f / sr), A = 1 - B - C;
const y = A * x + B * state[k][0] + C * state[k][1];
state[k][1] = state[k][0]; state[k][0] = y; x = y;
}
out[i] = x;
}
// strain: soft clip, then normalise
let peakV = 0;
for (let i = 0; i < n; i++) peakV = Math.max(peakV, Math.abs(out[i]));
const drive = scream ? 2.2 : 1.4;
let peak2 = 0;
for (let i = 0; i < n; i++) { out[i] = Math.tanh((out[i] / (peakV || 1)) * drive); peak2 = Math.max(peak2, Math.abs(out[i])); }
const buf = ac.createBuffer(1, n, sr);
const ch = buf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = out[i] / (peak2 || 1) * 0.9;
return buf;
}
function playVoice(kind, vol, pitch) {
if (!ac || vol < 0.01) return;
const list = VOICES[kind];
if (!list.length) list.push(renderVoice(kind)); // normally pre-built in the background
const src = ac.createBufferSource();
src.buffer = list[Math.floor(Math.random() * list.length)];
src.playbackRate.value = Math.min(1.15, Math.max(0.85, pitch)) * (0.96 + Math.random() * 0.08);
const g = ac.createGain(); g.gain.value = (kind === 'scream' ? 0.14 : 0.22) * vol;
src.connect(g); g.connect(master); src.start();
}
// A man screaming in pain.
function scream(vol = 1, pitch = 1) { playVoice('scream', vol, pitch); }
function renderVoiceForTest(kind) { return renderVoice(kind); }
// Ears ringing (death, nearby grenades): a loud high tone on its own bus while every other
// sound goes muffled, quieter and far away. Holds for 'hold' seconds, then fades over 'fade'.
function ring(hold = 5, fade = 2, vol = 0.12, attack = 0.03) {
if (!ac) return;
const t = ac.currentTime;
for (const o of ringing) { try { o.stop(t + 0.05); } catch (e) { /* already stopped */ } }
ringing = [];
for (const [p, v, back] of [[muffle.frequency, 320, 22000], [duck.gain, 0.28, 1]]) {
p.cancelScheduledValues(t); p.setValueAtTime(p.value, t);
p.exponentialRampToValueAtTime(v, t + 0.06);
p.setValueAtTime(v, t + hold);
p.exponentialRampToValueAtTime(back, t + hold + fade);
}
for (const [f, a] of [[3520, 1], [3534, 0.55], [7040, 0.1]]) { // two close tones beat against each other
const o = ac.createOscillator(); o.type = 'sine'; o.frequency.value = f * (0.98 + Math.random() * 0.04);
const g = ac.createGain();
g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol * a, t + attack);
g.gain.setValueAtTime(vol * a, t + hold); g.gain.exponentialRampToValueAtTime(0.0001, t + hold + fade);
o.connect(g); g.connect(ringBus); o.start(t); o.stop(t + hold + fade + 0.05);
ringing.push(o);
}
// backstop: once the ring is over, make sure the mix is fully restored even if the
// scheduled automation was delayed (unless a newer ring has started since)
const id = ++ringId;
setTimeout(() => {
if (id !== ringId || !ac) return;
const now = ac.currentTime;
for (const [prm, back] of [[muffle.frequency, 22000], [duck.gain, 1]]) { prm.cancelScheduledValues(now); prm.setTargetAtTime(back, now, 0.08); }
}, (hold + fade) * 1000 + 150);
}
// for tests: how muffled the mix is right now (Hz of the lowpass)
function muffleHz() { return muffle ? muffle.frequency.value : 22000; }
function audioTime() { return ac ? ac.currentTime : 0; }
// Low moaning groan of a wounded soldier (same voice model, low and closed).
function groan(vol = 1, pitch = 1) { playVoice('groan', vol, pitch); }
// ---------------------------------------------------------------- medical
function useMed(id) {
if (id === 'bandage') {
// tear the wrapper, rip the bandage strip, then wrap it round (cloth rustles)
burst(0.18, 1800, 0.35, { type: 'bandpass', q: 3, sweep: 4200 });
burst(0.12, 2600, 0.3, { type: 'bandpass', q: 4, sweep: 5200, delay: 0.22 });
for (let k = 0; k < 4; k++) burst(0.16, 1500 + k * 200, 0.12, { type: 'bandpass', q: 1.2, attack: 0.04, delay: 0.45 + k * 0.17 });
} else {
// medkit: zipper, then the same dressing sounds
for (let k = 0; k < 16; k++) burst(0.015, 4200, 0.18, { type: 'bandpass', q: 5, delay: k * 0.025 });
burst(0.2, 1800, 0.3, { type: 'bandpass', q: 3, sweep: 4200, delay: 0.55 });
for (let k = 0; k < 6; k++) burst(0.18, 1400 + k * 150, 0.12, { type: 'bandpass', q: 1.2, attack: 0.04, delay: 0.85 + k * 0.25 });
}
}
return {
init, shot, reload, step, scream, groan, useMed, ring, muffleHz, audioTime, renderVoiceForTest,
explosion(vol = 1) { burst(1.3, 900, vol, { sweep: 50 }); tone(70, 0.9, 0.6 * vol, 'sine', 25); },
flashbang(vol = 1) { burst(0.5, 3500, 0.8 * vol, { type: 'highpass' }); },
hit() { tone(1900, 0.05, 0.16, 'square'); },
kill() { tone(1400, 0.08, 0.18, 'square'); tone(900, 0.12, 0.18, 'square', null, 0.05); },
hurt() { burst(0.15, 600, 0.4); tone(160, 0.15, 0.22, 'sawtooth', 80); },
armourHit() { burst(0.05, 3800, 0.3, { type: 'bandpass', q: 2 }); tone(700, 0.06, 0.1, 'square', 400); },
click() { tone(1200, 0.03, 0.12, 'square'); },
pickup() { tone(660, 0.06, 0.13, 'triangle'); tone(990, 0.08, 0.13, 'triangle', null, 0.05); },
deny() { tone(180, 0.12, 0.18, 'square'); },
throwIt() { burst(0.15, 1200, 0.25, { type: 'bandpass' }); },
bounce() { tone(900, 0.04, 0.08, 'triangle', 500); },
heal() { tone(520, 0.12, 0.12, 'sine', 780); tone(780, 0.15, 0.1, 'sine', 1040, 0.1); },
beep() { tone(880, 0.07, 0.1, 'sine'); },
extract() { tone(440, 0.2, 0.18, 'triangle'); tone(660, 0.2, 0.18, 'triangle', null, 0.15); tone(880, 0.45, 0.18, 'triangle', null, 0.3); },
ignite(vol = 1) { burst(0.12, 2500, 0.5 * vol, { type: 'highpass' }); burst(1.2, 700, 0.6 * vol, { sweep: 200, delay: 0.05 }); },
smoke(vol = 1) { burst(1.8, 5000, 0.35 * vol, { type: 'highpass', sweep: 1500 }); },
armourBreak() { burst(0.25, 3000, 0.45, { type: 'bandpass', q: 1.5 }); tone(300, 0.25, 0.15, 'square', 120); },
rotor(vol = 1) { burst(0.09, 180, 0.5 * vol, { sweep: 60 }); },
radio() { tone(1200, 0.05, 0.08, 'square'); tone(900, 0.05, 0.08, 'square', null, 0.07); burst(0.25, 2500, 0.08, { type: 'bandpass', q: 1, delay: 0.14 }); },
death() { ring(6, 3, 0.08, 1); }, // dying: quieter ringing ears with a gradual onset
knifeThrow() { burst(0.16, 2600, 0.3, { type: 'bandpass', q: 1.5, sweep: 900 }); },
slash() { burst(0.14, 3200, 0.35, { type: 'bandpass', q: 1.2, sweep: 1200 }); burst(0.12, 700, 0.45, { delay: 0.08 }); tone(140, 0.12, 0.2, 'sine', 80, 0.08); },
knifeClink() { tone(2600, 0.08, 0.1, 'triangle', 1800); burst(0.04, 4200, 0.2, { type: 'bandpass', q: 3 }); },
};
})();
