|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { signed } from "./signed.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
| const SOUND_DISABLED = false; |
|
|
|
|
| const MASTER_LEVEL = 0.9;
|
| const BUS_LEVELS = { ambient: 1, sfx: 0.9, voice: 1 };
|
| const MUTE_KEY = "microduck-muted";
|
| let ctx = null;
|
| let master = null;
|
| const buses = {};
|
| let muted = false;
|
| try { muted = localStorage.getItem(MUTE_KEY) === "1"; } catch { }
|
|
|
| function unlockOnce() {
|
| if (ctx?.state === "suspended") ctx.resume().catch(() => {});
|
| }
|
|
|
| export function audioCtx() {
|
| if (ctx) return ctx;
|
| ctx = new (window.AudioContext ?? window.webkitAudioContext)();
|
| master = ctx.createGain(); |
| master.gain.value = SOUND_DISABLED || muted ? 0 : MASTER_LEVEL; |
| master.connect(ctx.destination); |
| |
| |
| try { |
| const capture = ctx.createMediaStreamDestination(); |
| master.connect(capture); |
| window.__audioCaptureStream = capture.stream; |
| } catch { } |
| for (const [name, level] of Object.entries(BUS_LEVELS)) {
|
| const g = ctx.createGain();
|
| g.gain.value = level;
|
| g.connect(master);
|
| buses[name] = g;
|
| }
|
|
|
|
|
|
|
|
|
| if (!SOUND_DISABLED) {
|
| window.addEventListener("pointerdown", unlockOnce, { passive: true });
|
| window.addEventListener("keydown", unlockOnce);
|
| }
|
| return ctx;
|
| }
|
|
|
| export function busNode(name) {
|
| audioCtx();
|
| return buses[name];
|
| }
|
|
|
|
|
|
|
|
|
| export const isMuted = () => muted;
|
|
|
| export function setMuted(next) {
|
| if (SOUND_DISABLED) return;
|
| muted = !!next;
|
| try { localStorage.setItem(MUTE_KEY, muted ? "1" : "0"); } catch { }
|
| if (!ctx) return;
|
| master.gain.setTargetAtTime(muted ? 0 : MASTER_LEVEL, ctx.currentTime, 0.017);
|
| }
|
|
|
|
|
| const SFX_TAKES = { step: 5, thump: 3, click: 2 };
|
| const bufCache = new Map();
|
|
|
| function bufferFor(url) {
|
| let p = bufCache.get(url);
|
| if (!p) {
|
| p = fetch(url)
|
| .then((r) => r.arrayBuffer())
|
| .then((ab) => audioCtx().decodeAudioData(ab));
|
| bufCache.set(url, p);
|
| }
|
| return p;
|
| }
|
|
|
| const takeUrl = (name, i) => signed(`./assets/sfx/${name}_${"abcde"[i]}.wav`);
|
|
|
| export function preloadSfx() {
|
| if (SOUND_DISABLED) return;
|
| for (const [name, n] of Object.entries(SFX_TAKES)) {
|
| for (let i = 0; i < n; i++) bufferFor(takeUrl(name, i)).catch(() => {});
|
| }
|
| }
|
|
|
|
|
|
|
| export function playSfx(name, { gain = 1, rate = 1, out = null } = {}) {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") return;
|
| const take = (Math.random() * SFX_TAKES[name]) | 0;
|
| bufferFor(takeUrl(name, take)).then((buf) => {
|
| const src = c.createBufferSource();
|
| src.buffer = buf;
|
| src.playbackRate.value = rate;
|
| const g = c.createGain();
|
| g.gain.value = gain;
|
| src.connect(g);
|
| g.connect(out ?? buses.sfx);
|
| src.onended = () => g.disconnect();
|
| src.start();
|
| }).catch(() => {});
|
| }
|
|
|
|
|
|
|
|
|
| export function playUrl(url, { bus = "voice", gain = 1, rate = 1 } = {}) {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") return;
|
| bufferFor(url).then((buf) => {
|
| const src = c.createBufferSource();
|
| src.buffer = buf;
|
| src.playbackRate.value = rate;
|
| const g = c.createGain();
|
| g.gain.value = gain;
|
| src.connect(g);
|
| g.connect(buses[bus]);
|
| src.onended = () => g.disconnect();
|
| src.start();
|
| }).catch(() => {});
|
| }
|
|
|
| export function uiClick() {
|
| playSfx("click", { gain: 0.16, rate: 0.95 + Math.random() * 0.1 });
|
| }
|
|
|
|
|
|
|
|
|
| const _fwd = { x: 0, y: 0, z: -1 };
|
|
|
| export function updateListener(camera) {
|
| if (!ctx || ctx.state === "suspended") return;
|
| const l = ctx.listener;
|
| const e = camera.matrixWorld.elements;
|
|
|
| _fwd.x = -e[8]; _fwd.y = -e[9]; _fwd.z = -e[10];
|
| const t = ctx.currentTime;
|
| if (l.positionX) {
|
| l.positionX.setTargetAtTime(e[12], t, 0.02);
|
| l.positionY.setTargetAtTime(e[13], t, 0.02);
|
| l.positionZ.setTargetAtTime(e[14], t, 0.02);
|
| l.forwardX.setTargetAtTime(_fwd.x, t, 0.02);
|
| l.forwardY.setTargetAtTime(_fwd.y, t, 0.02);
|
| l.forwardZ.setTargetAtTime(_fwd.z, t, 0.02);
|
| l.upX.setTargetAtTime(e[4], t, 0.02);
|
| l.upY.setTargetAtTime(e[5], t, 0.02);
|
| l.upZ.setTargetAtTime(e[6], t, 0.02);
|
| } else {
|
| l.setPosition(e[12], e[13], e[14]);
|
| l.setOrientation(_fwd.x, _fwd.y, _fwd.z, e[4], e[5], e[6]);
|
| }
|
| }
|
|
|
| export function createEmitter({ refDistance = 0.6 } = {}) {
|
| const c = audioCtx();
|
| const panner = c.createPanner();
|
| panner.panningModel = "equalpower";
|
| panner.distanceModel = "inverse";
|
| panner.refDistance = refDistance;
|
| panner.rolloffFactor = 1.1;
|
| panner.connect(buses.sfx);
|
| return {
|
| node: panner,
|
| setPosition(x, y, z) {
|
| if (panner.positionX) {
|
| const t = c.currentTime;
|
| panner.positionX.setTargetAtTime(x, t, 0.03);
|
| panner.positionY.setTargetAtTime(y, t, 0.03);
|
| panner.positionZ.setTargetAtTime(z, t, 0.03);
|
| } else {
|
| panner.setPosition(x, y, z);
|
| }
|
| },
|
| };
|
| }
|
|
|
|
|
|
|
|
|
|
|
| const AMBIENT_LEVEL = 0.05;
|
| const AMBIENT_DUCKED = 0.018;
|
| let ambient = null;
|
|
|
| export function startAmbient() {
|
| if (SOUND_DISABLED || ambient) return;
|
| const c = audioCtx();
|
| const duckFilter = c.createBiquadFilter();
|
| duckFilter.type = "lowpass";
|
| duckFilter.frequency.value = 18000;
|
| const duckGain = c.createGain();
|
| duckGain.gain.value = 0;
|
| duckFilter.connect(duckGain);
|
| duckGain.connect(buses.ambient);
|
|
|
| const hum = c.createOscillator();
|
| hum.type = "sine";
|
| hum.frequency.value = 60;
|
| const humG = c.createGain();
|
| humG.gain.value = 0.5;
|
| hum.connect(humG);
|
| humG.connect(duckFilter);
|
|
|
| const hum2 = c.createOscillator();
|
| hum2.type = "triangle";
|
| hum2.frequency.value = 120;
|
| const hum2G = c.createGain();
|
| hum2G.gain.value = 0.14;
|
| hum2.connect(hum2G);
|
| hum2G.connect(duckFilter);
|
|
|
| const noise = c.createBufferSource();
|
| noise.buffer = noiseBuffer(c, 2.0);
|
| noise.loop = true;
|
| const noiseF = c.createBiquadFilter();
|
| noiseF.type = "lowpass";
|
| noiseF.frequency.value = 260;
|
| const noiseG = c.createGain();
|
| noiseG.gain.value = 0.5;
|
| noise.connect(noiseF);
|
| noiseF.connect(noiseG);
|
| noiseG.connect(duckFilter);
|
|
|
|
|
| const lfo = c.createOscillator();
|
| lfo.frequency.value = 0.08;
|
| const lfoG = c.createGain();
|
| lfoG.gain.value = 0.15;
|
| lfo.connect(lfoG);
|
| lfoG.connect(noiseG.gain);
|
|
|
| hum.start();
|
| hum2.start();
|
| noise.start();
|
| lfo.start();
|
| duckGain.gain.setTargetAtTime(AMBIENT_LEVEL, c.currentTime, 1.2);
|
| ambient = { duckGain, duckFilter };
|
| }
|
|
|
| export function setAmbientDucked(ducked) {
|
| if (!ambient) return;
|
| const t = ctx.currentTime;
|
| ambient.duckGain.gain.setTargetAtTime(ducked ? AMBIENT_DUCKED : AMBIENT_LEVEL, t, 0.25);
|
| ambient.duckFilter.frequency.setTargetAtTime(ducked ? 300 : 18000, t, 0.25);
|
| }
|
|
|
| function noiseBuffer(c, seconds) {
|
| const n = (c.sampleRate * seconds) | 0;
|
| const buf = c.createBuffer(1, n, c.sampleRate);
|
| const d = buf.getChannelData(0);
|
| for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
| return buf;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const FX_EASE = (x) => 1 - (1 - x) * (1 - x);
|
| const FX_BASE_S = 0.9;
|
|
|
| function easedPitchCurve(f0, f1, n = 48) {
|
| const arr = new Float32Array(n);
|
| for (let i = 0; i < n; i++) arr[i] = f0 * (f1 / f0) ** FX_EASE(i / (n - 1));
|
| return arr;
|
| }
|
|
|
|
|
|
|
|
|
| function materializeSweep(c, { totalS, level = 1, pitchK = 1, hero = true }) {
|
| const T = Math.max(0.3, totalS);
|
| const t0 = c.currentTime + 0.01;
|
| const scanEnd = t0 + T * (2 / 3);
|
| const solidStart = t0 + T * (1 / 3);
|
| const tEnd = t0 + T;
|
| const k = 1 / pitchK;
|
|
|
| const out = c.createGain();
|
| out.gain.value = level;
|
| const tone = c.createBiquadFilter();
|
| tone.type = "lowpass";
|
| tone.frequency.value = 5200;
|
| tone.connect(out);
|
| out.connect(buses.sfx);
|
|
|
|
|
| const tick = c.createBufferSource();
|
| tick.buffer = noiseBuffer(c, 0.06);
|
| const tickF = c.createBiquadFilter();
|
| tickF.type = "highpass";
|
| tickF.frequency.value = 2500;
|
| const tickG = c.createGain();
|
| tickG.gain.setValueAtTime(0.1, t0);
|
| tickG.gain.exponentialRampToValueAtTime(0.001, t0 + 0.06);
|
| tick.connect(tickF);
|
| tickF.connect(tickG);
|
| tickG.connect(out);
|
| tick.start(t0);
|
|
|
|
|
| const scan = c.createOscillator();
|
| scan.type = "square";
|
| scan.frequency.setValueCurveAtTime(
|
| easedPitchCurve(150 * k, 1500 * k), t0, T * (2 / 3),
|
| );
|
| const scanG = c.createGain();
|
| scanG.gain.setValueAtTime(0, t0);
|
| scanG.gain.linearRampToValueAtTime(0.14, t0 + 0.03);
|
| scanG.gain.setValueAtTime(0.14, scanEnd - 0.05);
|
| scanG.gain.exponentialRampToValueAtTime(0.001, scanEnd + 0.08);
|
| scan.connect(scanG);
|
| scanG.connect(tone);
|
| scan.start(t0);
|
| scan.stop(scanEnd + 0.1);
|
|
|
|
|
|
|
| const solid = c.createOscillator();
|
| solid.type = "triangle";
|
| solid.frequency.setValueCurveAtTime(
|
| easedPitchCurve(85 * k, 760 * k), solidStart, T * (2 / 3),
|
| );
|
| const solidG = c.createGain();
|
| solidG.gain.setValueAtTime(0, solidStart);
|
| solidG.gain.linearRampToValueAtTime(0.08, solidStart + 0.06);
|
| solidG.gain.linearRampToValueAtTime(0.16, tEnd - 0.05);
|
| solidG.gain.exponentialRampToValueAtTime(0.001, tEnd + 0.06);
|
| solid.connect(solidG);
|
| solidG.connect(tone);
|
| solid.start(solidStart);
|
| solid.stop(tEnd + 0.1);
|
|
|
| if (hero) {
|
|
|
|
|
| const shimmer = c.createBufferSource();
|
| shimmer.buffer = noiseBuffer(c, T);
|
| const shimF = c.createBiquadFilter();
|
| shimF.type = "bandpass";
|
| shimF.frequency.setValueCurveAtTime(
|
| easedPitchCurve(1800 * k, 5200 * k), t0, T,
|
| );
|
| shimF.Q.value = 1.2;
|
| const shimG = c.createGain();
|
| shimG.gain.setValueAtTime(0, t0);
|
| shimG.gain.linearRampToValueAtTime(0.05, t0 + 0.1);
|
| shimG.gain.setValueAtTime(0.05, tEnd - 0.15);
|
| shimG.gain.linearRampToValueAtTime(0, tEnd);
|
| shimmer.connect(shimF);
|
| shimF.connect(shimG);
|
| shimG.connect(out);
|
| shimmer.start(t0);
|
| }
|
|
|
|
|
|
|
| const thunk = c.createOscillator();
|
| thunk.type = "sine";
|
| thunk.frequency.setValueAtTime(170 * k, tEnd);
|
| thunk.frequency.exponentialRampToValueAtTime(62 * k, tEnd + 0.13);
|
| const thunkG = c.createGain();
|
| thunkG.gain.setValueAtTime(hero ? 0.3 : 0.22, tEnd);
|
| thunkG.gain.exponentialRampToValueAtTime(0.001, tEnd + 0.22);
|
| thunk.connect(thunkG);
|
| thunkG.connect(out);
|
| thunk.start(tEnd);
|
| thunk.stop(tEnd + 0.25);
|
|
|
| if (hero) {
|
| const ping = c.createOscillator();
|
| ping.type = "sine";
|
| ping.frequency.value = 1320;
|
| const pingG = c.createGain();
|
| pingG.gain.setValueAtTime(0.07, tEnd);
|
| pingG.gain.exponentialRampToValueAtTime(0.001, tEnd + 0.3);
|
| ping.connect(pingG);
|
| pingG.connect(out);
|
| ping.start(tEnd);
|
| ping.stop(tEnd + 0.32);
|
| ping.onended = () => out.disconnect();
|
| } else {
|
| thunk.onended = () => out.disconnect();
|
| }
|
| }
|
|
|
|
|
| export function playEntranceSweep(totalS = 0.9) {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") return;
|
| materializeSweep(c, { totalS, level: 1, pitchK: 1, hero: true });
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| export function playPropSweep(totalS = 0.9) {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") return;
|
| const durK = Math.max(1, totalS / FX_BASE_S);
|
| const detune = 2 ** ((Math.random() - 0.5) * 0.16);
|
| materializeSweep(c, {
|
| totalS,
|
| level: 0.4,
|
| pitchK: durK * detune,
|
| hero: false,
|
| });
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| export function playBiosBeep() {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") return;
|
| const t0 = c.currentTime + 0.01;
|
| const osc = c.createOscillator();
|
| osc.type = "square";
|
| osc.frequency.value = 940;
|
| const g = c.createGain();
|
| g.gain.setValueAtTime(0, t0);
|
| g.gain.linearRampToValueAtTime(0.08, t0 + 0.008);
|
| g.gain.setValueAtTime(0.08, t0 + 0.1);
|
| g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.16);
|
| osc.connect(g);
|
| g.connect(buses.sfx);
|
| osc.start(t0);
|
| osc.stop(t0 + 0.18);
|
| osc.onended = () => g.disconnect();
|
| }
|
|
|
|
|
| export function playBiosTick() {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") return;
|
| const t0 = c.currentTime;
|
| const osc = c.createOscillator();
|
| osc.type = "square";
|
| osc.frequency.value = 1750 + Math.random() * 350;
|
| const g = c.createGain();
|
| g.gain.setValueAtTime(0.02, t0);
|
| g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.018);
|
| osc.connect(g);
|
| g.connect(buses.sfx);
|
| osc.start(t0);
|
| osc.stop(t0 + 0.02);
|
| osc.onended = () => g.disconnect();
|
| }
|
|
|
|
|
|
|
|
|
| export function playConfirmBloop() {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") {
|
| c.resume().then(() => confirmNow(c)).catch(() => {});
|
| return;
|
| }
|
| confirmNow(c);
|
| }
|
|
|
| function confirmNow(c) {
|
| const t0 = c.currentTime + 0.01;
|
| const tone = c.createBiquadFilter();
|
| tone.type = "lowpass";
|
| tone.frequency.value = 4200;
|
| tone.connect(buses.sfx);
|
| const osc = c.createOscillator();
|
| osc.type = "square";
|
| osc.frequency.setValueAtTime(523, t0);
|
| osc.frequency.setValueAtTime(784, t0 + 0.07);
|
| const g = c.createGain();
|
| g.gain.setValueAtTime(0, t0);
|
| g.gain.linearRampToValueAtTime(0.13, t0 + 0.01);
|
| g.gain.setValueAtTime(0.13, t0 + 0.12);
|
| g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.2);
|
| osc.connect(g);
|
| g.connect(tone);
|
| osc.start(t0);
|
| osc.stop(t0 + 0.22);
|
| osc.onended = () => tone.disconnect();
|
| }
|
|
|
|
|
|
|
|
|
|
|
| export function playLineBlip(u = 0.5) {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (c.state === "suspended") return;
|
| const t0 = c.currentTime;
|
| const osc = c.createOscillator();
|
| osc.type = "triangle";
|
| osc.frequency.value = 1400 * 2 ** (u * 0.9);
|
| const g = c.createGain();
|
| g.gain.setValueAtTime(0.05, t0);
|
| g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.05);
|
| osc.connect(g);
|
| g.connect(buses.sfx);
|
| osc.start(t0);
|
| osc.stop(t0 + 0.06);
|
| osc.onended = () => g.disconnect();
|
| }
|
|
|
|
|
|
|
|
|
| let rumble = null;
|
|
|
| export function setRumble(level, out = null) {
|
| if (SOUND_DISABLED) return;
|
| const c = audioCtx();
|
| if (!rumble) {
|
| if (level <= 0) return;
|
| const src = c.createBufferSource();
|
| src.buffer = noiseBuffer(c, 1.5);
|
| src.loop = true;
|
| const f = c.createBiquadFilter();
|
| f.type = "bandpass";
|
| f.frequency.value = 110;
|
| f.Q.value = 0.8;
|
| const g = c.createGain();
|
| g.gain.value = 0;
|
| src.connect(f);
|
| f.connect(g);
|
| g.connect(out?.node ?? buses.sfx);
|
| src.start();
|
| rumble = { gain: g, src };
|
| }
|
| const t = c.currentTime;
|
| rumble.gain.gain.setTargetAtTime(Math.min(1, level) * 0.22, t, 0.08);
|
| rumble.src.playbackRate.setTargetAtTime(0.85 + 0.5 * Math.min(1, level), t, 0.12);
|
| }
|
|
|