All checks were successful
Deploy / build-and-deploy (push) Successful in 1m52s
180 lines
5.8 KiB
TypeScript
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;
|
|
}
|
|
}
|