SOURCE / PINNED RELEASE
Made of little things.
Mini Samurai
- Release
- a7beb798adbc…
- Author-recorded commit
- 4f97be1515b8…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b
Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/*
* Synthesized Web Audio sound effects. No audio files: everything is built from
* oscillators and a cached noise buffer, routed through a master gain and a
* light compressor. Every sfx* call is a silent no-op until unlockAudio() has
* run from a user gesture, or while sound is disabled.
*/
let audio: AudioContext | null = null;
let master: GainNode | null = null;
let noiseBuf: AudioBuffer | null = null;
let enabled = true;
const MASTER_VOLUME = 0.5;
export function setSoundEnabled(v: boolean): void {
enabled = v;
if (master && audio) {
master.gain.setTargetAtTime(v ? MASTER_VOLUME : 0, audio.currentTime, 0.02);
}
}
/** Creates or resumes the audio context. Call from a user gesture (click, key, touch). */
export function unlockAudio(): void {
if (!audio) {
const Ctor: typeof AudioContext | undefined =
typeof window !== 'undefined'
? window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: undefined;
if (!Ctor) return;
try {
audio = new Ctor();
} catch {
audio = null;
return;
}
const comp = audio.createDynamicsCompressor();
comp.threshold.value = -18;
comp.knee.value = 12;
comp.ratio.value = 4;
comp.attack.value = 0.003;
comp.release.value = 0.2;
comp.connect(audio.destination);
master = audio.createGain();
master.gain.value = enabled ? MASTER_VOLUME : 0;
master.connect(comp);
const len = Math.floor(audio.sampleRate * 1.0);
noiseBuf = audio.createBuffer(1, len, audio.sampleRate);
const data = noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
}
if (audio.state === 'suspended') {
void audio.resume().catch(() => undefined);
}
}
/** Running context or null when sound should not play. */
function ready(): AudioContext | null {
if (!enabled || !audio || !master || !noiseBuf) return null;
if (audio.state !== 'running') return null;
return audio;
}
const rand = (a: number, b: number): number => a + Math.random() * (b - a);
// ---------------------------------------------------------------------------
// Building blocks
// ---------------------------------------------------------------------------
/** Percussive envelope on a fresh gain node feeding `dest`. */
function envGain(
ac: AudioContext,
t: number,
attack: number,
peak: number,
decay: number,
dest: AudioNode,
): GainNode {
const g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(Math.max(0.0002, peak), t + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
g.connect(dest);
return g;
}
interface ToneOpts {
type?: OscillatorType;
freq: number;
freqEnd?: number;
/** Seconds for the pitch glide (defaults to the whole sound). */
glide?: number;
attack?: number;
decay: number;
peak: number;
delay?: number;
dest?: AudioNode;
}
function tone(ac: AudioContext, o: ToneOpts): void {
const t = ac.currentTime + (o.delay ?? 0);
const attack = o.attack ?? 0.004;
const osc = ac.createOscillator();
osc.type = o.type ?? 'sine';
osc.frequency.setValueAtTime(o.freq, t);
if (o.freqEnd !== undefined) {
osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.freqEnd), t + (o.glide ?? attack + o.decay));
}
const g = envGain(ac, t, attack, o.peak, o.decay, o.dest ?? master!);
osc.connect(g);
osc.start(t);
osc.stop(t + attack + o.decay + 0.05);
}
interface NoiseOpts {
filter: BiquadFilterType;
freq: number;
freqEnd?: number;
q?: number;
attack?: number;
decay: number;
peak: number;
delay?: number;
dest?: AudioNode;
}
function noise(ac: AudioContext, o: NoiseOpts): void {
const t = ac.currentTime + (o.delay ?? 0);
const attack = o.attack ?? 0.002;
const dur = attack + o.decay;
const src = ac.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
const f = ac.createBiquadFilter();
f.type = o.filter;
f.Q.value = o.q ?? 0.8;
f.frequency.setValueAtTime(o.freq, t);
if (o.freqEnd !== undefined) {
f.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t + dur);
}
const g = envGain(ac, t, attack, o.peak, o.decay, o.dest ?? master!);
src.connect(f);
f.connect(g);
src.start(t, Math.random() * 0.5);
src.stop(t + dur + 0.05);
}
/** Set of inharmonic sine partials (bells, blades, gongs). */
function partials(
ac: AudioContext,
freqs: number[],
decays: number[],
peak: number,
delay = 0,
attack = 0.002,
): void {
freqs.forEach((f, i) => {
tone(ac, {
freq: f,
decay: decays[i % decays.length],
peak: peak / (1 + i * 0.35),
attack,
delay,
});
});
}
// ---------------------------------------------------------------------------
// Combat
// ---------------------------------------------------------------------------
let lastSwish = 0;
/** Blade whoosh. strength 0..1 scales volume and pitch. */
export function sfxSwish(strength: number): void {
const ac = ready();
if (!ac) return;
const now = ac.currentTime;
if (now - lastSwish < 0.025) return;
lastSwish = now;
const s = Math.min(1, Math.max(0, strength));
const dur = 0.13 + 0.07 * s;
const lo = 380 * (1 + s * 0.6) * rand(0.9, 1.1);
const hi = 2000 * (1 + s * 0.5) * rand(0.9, 1.1);
const ctxNow = ac.currentTime;
const src = ac.createBufferSource();
src.buffer = noiseBuf;
const f = ac.createBiquadFilter();
f.type = 'bandpass';
f.Q.value = 1.6;
f.frequency.setValueAtTime(lo, ctxNow);
f.frequency.exponentialRampToValueAtTime(hi, ctxNow + dur * 0.55);
f.frequency.exponentialRampToValueAtTime(lo * 0.8, ctxNow + dur);
const g = ac.createGain();
const peak = 0.12 + 0.28 * s;
g.gain.setValueAtTime(0.0001, ctxNow);
g.gain.linearRampToValueAtTime(peak, ctxNow + dur * 0.45);
g.gain.exponentialRampToValueAtTime(0.0001, ctxNow + dur);
src.connect(f);
f.connect(g);
g.connect(master!);
src.start(ctxNow, Math.random() * 0.5);
src.stop(ctxNow + dur + 0.05);
}
/** Meaty impact: low thud plus a short noise crack. */
export function sfxHit(heavy: boolean): void {
const ac = ready();
if (!ac) return;
const p = rand(0.92, 1.08);
tone(ac, {
freq: (heavy ? 130 : 170) * p,
freqEnd: (heavy ? 42 : 60) * p,
decay: heavy ? 0.26 : 0.16,
peak: heavy ? 0.85 : 0.6,
});
tone(ac, { type: 'triangle', freq: 320 * p, freqEnd: 110, decay: 0.07, peak: 0.25 });
noise(ac, { filter: 'lowpass', freq: heavy ? 2200 : 3200, freqEnd: 500, decay: heavy ? 0.09 : 0.06, peak: heavy ? 0.5 : 0.38 });
}
function clangCore(ac: AudioContext, base: number[], decayScale: number, peak: number, delay = 0): void {
const p = rand(0.94, 1.06);
const freqs = base.map((f) => f * p * rand(0.99, 1.01));
const decays = freqs.map((_, i) => (0.32 - i * 0.05) * decayScale);
partials(ac, freqs, decays, peak, delay);
noise(ac, { filter: 'highpass', freq: 3000, decay: 0.025, peak: 0.35, delay });
}
/** Metal-on-metal block. */
export function sfxClang(): void {
const ac = ready();
if (!ac) return;
clangCore(ac, [1200, 1870, 2650, 3300], 1, 0.16);
}
/** Bright, longer ringing clang for a perfect parry. */
export function sfxParry(): void {
const ac = ready();
if (!ac) return;
clangCore(ac, [1480, 2290, 3170, 4120, 5350], 2.6, 0.17);
tone(ac, { freq: 2960 * rand(0.98, 1.02), decay: 0.9, peak: 0.05, attack: 0.01 });
}
/** Clang followed by a spinning whirr as the weapon flies away. */
export function sfxDisarm(): void {
const ac = ready();
if (!ac) return;
clangCore(ac, [1250, 1930, 2720, 3410], 1.4, 0.17);
const t = ac.currentTime + 0.06;
const dur = 0.6;
const src = ac.createBufferSource();
src.buffer = noiseBuf;
const f = ac.createBiquadFilter();
f.type = 'bandpass';
f.Q.value = 3;
f.frequency.setValueAtTime(1300, t);
f.frequency.exponentialRampToValueAtTime(600, t + dur);
// Amplitude chopped by an LFO whose rate slows as the spin decays.
const chop = ac.createGain();
chop.gain.value = 0.5;
const lfo = ac.createOscillator();
lfo.type = 'triangle';
lfo.frequency.setValueAtTime(22, t);
lfo.frequency.exponentialRampToValueAtTime(7, t + dur);
const lfoDepth = ac.createGain();
lfoDepth.gain.value = 0.5;
lfo.connect(lfoDepth);
lfoDepth.connect(chop.gain);
const env = envGain(ac, t, 0.03, 0.3, dur, master!);
src.connect(f);
f.connect(chop);
chop.connect(env);
src.start(t, Math.random() * 0.3);
lfo.start(t);
src.stop(t + dur + 0.1);
lfo.stop(t + dur + 0.1);
}
/** Cracking snap for an improvised weapon breaking. */
export function sfxBreak(): void {
const ac = ready();
if (!ac) return;
tone(ac, { type: 'square', freq: 900 * rand(0.9, 1.1), freqEnd: 180, decay: 0.035, peak: 0.12 });
noise(ac, { filter: 'highpass', freq: 1800, decay: 0.03, peak: 0.45 });
for (let i = 0; i < 4; i++) {
noise(ac, {
filter: 'bandpass',
freq: rand(1500, 4500),
q: 2,
decay: rand(0.015, 0.04),
peak: rand(0.15, 0.3),
delay: 0.02 + i * rand(0.02, 0.04),
});
}
tone(ac, { type: 'triangle', freq: 260, freqEnd: 120, decay: 0.08, peak: 0.2, delay: 0.01 });
}
/** One taiko drum hit. */
export function sfxTaiko(): void {
const ac = ready();
if (!ac) return;
taiko(ac, 0, 1);
}
function taiko(ac: AudioContext, delay: number, gain: number): void {
const p = rand(0.96, 1.04);
tone(ac, { freq: 150 * p, freqEnd: 58 * p, glide: 0.18, decay: 0.42, peak: 0.85 * gain, delay });
tone(ac, { type: 'triangle', freq: 95 * p, freqEnd: 70, decay: 0.2, peak: 0.3 * gain, delay });
noise(ac, { filter: 'lowpass', freq: 1300, freqEnd: 300, decay: 0.07, peak: 0.35 * gain, delay });
}
/** Dramatic KO: low boom plus a taiko hit. */
export function sfxKO(): void {
const ac = ready();
if (!ac) return;
tone(ac, { freq: 75, freqEnd: 28, glide: 1.0, decay: 1.2, peak: 0.9, attack: 0.01 });
noise(ac, { filter: 'lowpass', freq: 500, freqEnd: 80, decay: 0.7, peak: 0.45, attack: 0.01 });
taiko(ac, 0, 1);
taiko(ac, 0.22, 0.7);
}
/** Round-start gong: low inharmonic partials with a long decay. */
export function sfxGong(): void {
const ac = ready();
if (!ac) return;
const base = 98 * rand(0.98, 1.02);
const ratios = [1, 1.006, 1.52, 2.07, 2.76, 3.49, 4.23];
const decays = [2.5, 2.4, 2.1, 1.7, 1.3, 0.9, 0.7];
partials(
ac,
ratios.map((r) => base * r),
decays,
0.3,
0,
0.012,
);
noise(ac, { filter: 'lowpass', freq: 700, freqEnd: 150, decay: 0.25, peak: 0.35 });
tone(ac, { freq: 60, freqEnd: 45, decay: 0.5, peak: 0.35, attack: 0.01 });
}
// ---------------------------------------------------------------------------
// Movement
// ---------------------------------------------------------------------------
let lastStep = 0;
/** Tiny soft footstep tap. */
export function sfxStep(volume: number): void {
const ac = ready();
if (!ac) return;
const now = ac.currentTime;
if (now - lastStep < 0.06) return;
lastStep = now;
const v = Math.min(1, Math.max(0, volume));
if (v <= 0.01) return;
noise(ac, { filter: 'lowpass', freq: rand(700, 1100), decay: 0.03, peak: 0.16 * v });
tone(ac, { freq: rand(170, 200), freqEnd: 90, decay: 0.04, peak: 0.1 * v });
}
export function sfxJump(): void {
const ac = ready();
if (!ac) return;
tone(ac, { freq: 280 * rand(0.95, 1.05), freqEnd: 560, decay: 0.1, peak: 0.1, attack: 0.01 });
noise(ac, { filter: 'bandpass', freq: 800, freqEnd: 1800, q: 1.2, decay: 0.08, peak: 0.12, attack: 0.01 });
}
export function sfxLand(heavy: boolean): void {
const ac = ready();
if (!ac) return;
tone(ac, { freq: heavy ? 110 : 150, freqEnd: heavy ? 45 : 70, decay: heavy ? 0.16 : 0.09, peak: heavy ? 0.5 : 0.28 });
noise(ac, { filter: 'lowpass', freq: heavy ? 700 : 1000, decay: heavy ? 0.1 : 0.05, peak: heavy ? 0.35 : 0.2 });
}
/** Soft steam chuff of the toy train; volume 0..1 (by distance). */
export function sfxChuff(volume: number): void {
const ac = ready();
if (!ac) return;
const v = Math.min(1, Math.max(0, volume));
if (v <= 0.02) return;
noise(ac, { filter: 'bandpass', freq: rand(900, 1200), freqEnd: 450, q: 0.9, attack: 0.012, decay: 0.15, peak: 0.1 * v });
tone(ac, { freq: 95, freqEnd: 70, decay: 0.08, peak: 0.05 * v });
}
/** Two-note toy-train whistle; volume 0..1 (by distance). */
export function sfxWhistle(volume: number): void {
const ac = ready();
if (!ac) return;
const v = Math.min(1, Math.max(0, volume));
if (v <= 0.02) return;
for (const delay of [0, 0.42]) {
const len = delay ? 0.55 : 0.32;
tone(ac, { type: 'triangle', freq: 880, decay: len, peak: 0.07 * v, attack: 0.04, delay });
tone(ac, { type: 'triangle', freq: 1109, decay: len, peak: 0.06 * v, attack: 0.04, delay });
noise(ac, { filter: 'bandpass', freq: 2200, q: 2, decay: len, peak: 0.025 * v, attack: 0.04, delay });
}
}
/** The toaster popping: a springy boing and a little bell. */
export function sfxPop(volume: number): void {
const ac = ready();
if (!ac) return;
const v = Math.min(1, Math.max(0, volume));
tone(ac, { type: 'triangle', freq: 180, freqEnd: 620, glide: 0.12, decay: 0.22, peak: 0.22 * v, attack: 0.005 });
noise(ac, { filter: 'bandpass', freq: 2400, q: 1.5, decay: 0.05, peak: 0.12 * v });
partials(ac, [1568, 3136, 4700], [0.6, 0.35, 0.2], 0.08 * v, 0.06);
}
/** One tick of the grandfather clock. */
export function sfxTick(volume: number): void {
const ac = ready();
if (!ac) return;
const v = Math.min(1, Math.max(0, volume));
noise(ac, { filter: 'bandpass', freq: 3200, q: 4, decay: 0.03, peak: 0.16 * v });
tone(ac, { freq: 900, freqEnd: 600, decay: 0.04, peak: 0.05 * v });
}
/** Tyres squealing in a drift. */
export function sfxSkid(volume: number): void {
const ac = ready();
if (!ac) return;
const v = Math.min(1, Math.max(0, volume));
if (v <= 0.03) return;
noise(ac, { filter: 'bandpass', freq: rand(1800, 2400), q: 6, attack: 0.02, decay: 0.16, peak: 0.08 * v });
tone(ac, { type: 'sawtooth', freq: rand(780, 860), decay: 0.14, peak: 0.02 * v, attack: 0.02 });
}
/** Hand slap on a ledge. */
export function sfxGrab(): void {
const ac = ready();
if (!ac) return;
noise(ac, { filter: 'bandpass', freq: rand(1600, 2000), q: 1, decay: 0.04, peak: 0.38 });
tone(ac, { freq: 230, freqEnd: 150, decay: 0.05, peak: 0.12 });
}
/** Scrambling up over a ledge. */
export function sfxClimb(): void {
const ac = ready();
if (!ac) return;
noise(ac, { filter: 'bandpass', freq: 1100, q: 1.5, decay: 0.05, peak: 0.18, attack: 0.01 });
noise(ac, { filter: 'bandpass', freq: 1400, q: 1.5, decay: 0.05, peak: 0.16, attack: 0.01, delay: 0.09 });
tone(ac, { freq: 220, freqEnd: 330, decay: 0.12, peak: 0.06, attack: 0.02, delay: 0.05 });
}
/** Pleasant two-note pluck when picking up an item. */
export function sfxPickup(): void {
const ac = ready();
if (!ac) return;
tone(ac, { type: 'triangle', freq: 659.3, decay: 0.22, peak: 0.2 });
tone(ac, { freq: 1318.5, decay: 0.08, peak: 0.05 });
tone(ac, { type: 'triangle', freq: 987.8, decay: 0.3, peak: 0.2, delay: 0.08 });
tone(ac, { freq: 1975.5, decay: 0.1, peak: 0.05, delay: 0.08 });
}
// ---------------------------------------------------------------------------
// UI (hyoshigi-like wooden clicks)
// ---------------------------------------------------------------------------
function woodClick(ac: AudioContext, freq: number, peak: number, delay = 0): void {
tone(ac, { type: 'triangle', freq, freqEnd: freq * 0.92, decay: 0.045, peak, attack: 0.001, delay });
tone(ac, { freq: freq * 2.71, decay: 0.02, peak: peak * 0.35, attack: 0.001, delay });
noise(ac, { filter: 'bandpass', freq: freq * 1.6, q: 5, decay: 0.015, peak: peak * 0.8, delay });
}
export function sfxUiMove(): void {
const ac = ready();
if (!ac) return;
woodClick(ac, 1500, 0.1);
}
export function sfxUiSelect(): void {
const ac = ready();
if (!ac) return;
woodClick(ac, 1250, 0.16);
woodClick(ac, 1330, 0.18, 0.075);
}
