Files
BaraBingo/lib/sounds.ts
SlavaVlad 2dd604192f
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m52s
fix: TypeScript - capture Howl ref in closure
2026-06-15 01:36:24 +03:00

180 lines
5.8 KiB
TypeScript

import { Howl } from "howler";
let audioCtx: AudioContext | null = null;
function getCtx(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
}
return audioCtx;
}
type OscillatorConfig = {
freq: number;
type: OscillatorType;
duration: number;
gain?: number;
};
function playTone(config: OscillatorConfig) {
try {
const ctx = getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = config.type;
osc.frequency.setValueAtTime(config.freq, ctx.currentTime);
gain.gain.setValueAtTime(config.gain ?? 0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + config.duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + config.duration);
} catch {
// Audio not supported
}
}
function playNoise(duration: number, gain = 0.15) {
try {
const ctx = getCtx();
const bufferSize = Math.floor(ctx.sampleRate * duration);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2);
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
source.connect(gainNode);
gainNode.connect(ctx.destination);
source.start();
} catch {
// Audio not supported
}
}
export function playHonk() {
playTone({ freq: 800, type: "square", duration: 0.08, gain: 0.2 });
setTimeout(() => playTone({ freq: 600, type: "square", duration: 0.12, gain: 0.2 }), 80);
}
export function playReactorAlarm() {
for (let i = 0; i < 4; i++) {
setTimeout(() => {
playTone({ freq: 440 + i * 110, type: "sawtooth", duration: 0.2, gain: 0.15 });
setTimeout(() => playTone({ freq: 330 + i * 80, type: "sawtooth", duration: 0.2, gain: 0.15 }), 100);
}, i * 300);
}
}
export function playHullBreach() {
playTone({ freq: 100, type: "sine", duration: 0.5, gain: 0.4 });
setTimeout(() => playTone({ freq: 80, type: "sine", duration: 0.5, gain: 0.3 }), 200);
playNoise(0.5, 0.1);
}
export function playExplosion() {
playNoise(0.6, 0.4);
playTone({ freq: 60, type: "sine", duration: 0.5, gain: 0.5 });
setTimeout(() => playTone({ freq: 40, type: "sine", duration: 0.3, gain: 0.3 }), 200);
}
export function playMonster() {
playTone({ freq: 80, type: "sawtooth", duration: 0.4, gain: 0.2 });
setTimeout(() => playTone({ freq: 60, type: "sawtooth", duration: 0.4, gain: 0.15 }), 200);
setTimeout(() => playTone({ freq: 50, type: "square", duration: 0.5, gain: 0.2 }), 400);
}
export function playDeath() {
playTone({ freq: 400, type: "sine", duration: 0.15, gain: 0.2 });
setTimeout(() => playTone({ freq: 300, type: "sine", duration: 0.15, gain: 0.2 }), 150);
setTimeout(() => playTone({ freq: 200, type: "sine", duration: 0.3, gain: 0.2 }), 300);
}
export function playBingo() {
const notes = [523, 659, 784, 1047];
notes.forEach((freq, i) => {
setTimeout(() => playTone({ freq, type: "square", duration: 0.2, gain: 0.2 }), i * 120);
});
setTimeout(() => playNoise(0.3, 0.1), 480);
}
export function playChaosRiser() {
const pitches = [200, 250, 300, 400, 500, 600, 800, 1000];
pitches.forEach((freq, i) => {
setTimeout(() => playTone({ freq, type: "sawtooth", duration: 0.1, gain: 0.08 }), i * 50);
});
}
// ─── Howl-based URL playback ──────────────────────────────────────
const howlCache = new Map<string, Howl>();
/**
* Play a sound file URL (MP3/OGG) using howler.js.
* Howler handles autoplay policy, cross-browser codec support,
* and AudioContext resume better than raw new Audio().
*/
export function playSoundUrl(url: string) {
let h = howlCache.get(url);
if (!h) {
const newH = new Howl({
src: [url],
format: url.endsWith(".mp3") ? ["mp3"] : url.endsWith(".ogg") ? ["ogg"] : ["mp3", "ogg"],
volume: 0.4,
onloaderror: (_id: number, err: unknown) => {
console.error("[BaraBingo] Howl load error:", url, err);
},
onplayerror: (_id: number, err: unknown) => {
console.error("[BaraBingo] Howl play error:", url, err);
newH.once("unlock", () => newH.play());
},
});
howlCache.set(url, newH);
h = newH;
}
h.play();
}
/**
* One-shot play of a sound URL. Same as playSoundUrl but always creates a fresh instance.
* Useful for preview/editor buttons where you want it to play every click.
*/
export function playSoundOnce(url: string) {
const h = new Howl({
src: [url],
format: url.endsWith(".mp3") ? ["mp3"] : url.endsWith(".ogg") ? ["ogg"] : ["mp3", "ogg"],
volume: 0.4,
onloaderror: (_id: number, err: unknown) => {
console.error("[BaraBingo] Howl load error:", url, err);
},
onplayerror: (_id: number, err: unknown) => {
console.error("[BaraBingo] Howl play error:", url, err);
// On play error (e.g. AudioContext suspended), unlock and retry
h.once("unlock", () => h.play());
},
});
h.play();
}
export function playSound(category: string, soundUrl?: string | null) {
if (soundUrl) {
playSoundUrl(soundUrl);
return;
}
switch (category) {
case "horn": playHonk(); break;
case "alarm": playReactorAlarm(); break;
case "flood": playHullBreach(); break;
case "explosion": playExplosion(); break;
case "monster": playMonster(); break;
case "death": playDeath(); break;
case "bingo": playBingo(); break;
case "chaos": playChaosRiser(); break;
default: playHonk(); break;
}
}