Add configurable procedural sounds

This commit is contained in:
Daddy32
2026-01-05 09:40:41 +01:00
parent 96e8cd4f02
commit 36b3addf59
13 changed files with 456 additions and 0 deletions

354
src/sounds.js Normal file
View File

@@ -0,0 +1,354 @@
(() => {
const supportsAudio = () => typeof Audio !== "undefined";
const supportsWebAudio = () =>
typeof AudioContext !== "undefined" ||
typeof webkitAudioContext !== "undefined";
const DEFAULT_SOUND_CONFIG = {
enabled: true,
basePath: "./assets/sfx",
channels: {
buzz: {
mode: "procedural",
procedural: "buzz",
file: "drag-buzz.ogg",
volume: 0.06,
},
click: {
mode: "procedural",
procedural: "click",
file: "link-click.ogg",
volume: 0.18,
},
pop: {
mode: "procedural",
procedural: "pop",
file: "clear-pop.ogg",
volume: 0.22,
},
},
};
const createAudio = (src, { volume = 1, loop = false, rate = 1 } = {}) => {
const audio = new Audio(src);
audio.preload = "auto";
audio.loop = loop;
audio.volume = volume;
audio.playbackRate = rate;
return audio;
};
const createPoolPlayer = (src, { volume = 1, rate = 1, max = 6 } = {}) => {
const pool = [];
return () => {
if (!supportsAudio()) return;
let audio = pool.find((item) => item.paused || item.ended);
if (!audio) {
if (pool.length >= max) return;
audio = createAudio(src, { volume, rate });
pool.push(audio);
}
audio.currentTime = 0;
audio.play().catch(() => {});
};
};
const normalizeSoundConfig = (sounds = {}) => {
const defaultChannels = DEFAULT_SOUND_CONFIG.channels;
const overrideChannels = sounds.channels || {};
const mergedChannels = { ...defaultChannels };
Object.keys(overrideChannels).forEach((key) => {
mergedChannels[key] = {
...(defaultChannels[key] || {}),
...(overrideChannels[key] || {}),
};
});
return {
...DEFAULT_SOUND_CONFIG,
...sounds,
channels: mergedChannels,
};
};
const create = ({ sounds = {} } = {}) => {
let unlocked = false;
let buzzing = false;
let buzzLoop = null;
let audioCtx = null;
let masterGain = null;
let buzzNodes = null;
let soundConfig = normalizeSoundConfig(sounds);
const samplePlayers = new Map();
let sampleBuzzKey = null;
const ensureContext = () => {
if (!supportsWebAudio()) return false;
if (audioCtx) return true;
const Ctx =
typeof AudioContext !== "undefined" ? AudioContext : webkitAudioContext;
audioCtx = new Ctx();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.35;
masterGain.connect(audioCtx.destination);
return true;
};
const resolveSampleSrc = (file) => {
if (!file) return null;
if (file.includes("/") || file.startsWith("http")) {
return file;
}
return `${soundConfig.basePath}/${file}`;
};
const getSamplePlayer = (
channelKey,
{ file, volume = 1, rate = 1, max = 6 } = {},
) => {
const src = resolveSampleSrc(file);
if (!src) return null;
const key = `${channelKey}|${src}|${volume}|${rate}|${max}`;
if (!samplePlayers.has(key)) {
samplePlayers.set(
key,
createPoolPlayer(src, { volume, rate, max }),
);
}
return samplePlayers.get(key);
};
const ensureBuzzLoop = ({ file, volume = 0.06, rate = 1 } = {}) => {
if (!supportsAudio()) return;
const src = resolveSampleSrc(file);
if (!src) return;
const key = `${src}|${volume}|${rate}`;
if (buzzLoop && sampleBuzzKey === key) return;
if (buzzLoop) {
buzzLoop.pause();
}
buzzLoop = createAudio(src, {
volume,
loop: true,
rate,
});
sampleBuzzKey = key;
};
const resumeContext = () => {
if (!audioCtx || audioCtx.state !== "suspended") return;
audioCtx.resume().catch(() => {});
};
const createNoiseBuffer = (durationSec) => {
const buffer = audioCtx.createBuffer(
1,
durationSec * audioCtx.sampleRate,
audioCtx.sampleRate,
);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i += 1) {
data[i] = (Math.random() * 2 - 1) * 0.9;
}
return buffer;
};
const playClickProcedural = (volume = 0.12) => {
if (!ensureContext()) return;
resumeContext();
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(860, now);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + 0.008);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.08);
osc.connect(gain);
gain.connect(masterGain);
osc.start(now);
osc.stop(now + 0.1);
};
const playPopProcedural = (volume = 0.2) => {
if (!ensureContext()) return;
resumeContext();
const now = audioCtx.currentTime;
const duration = 0.18;
const buffer = createNoiseBuffer(duration);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(1200, now);
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(masterGain);
source.start(now);
source.stop(now + duration);
};
const startBuzzProcedural = (volume = 0.02) => {
if (!ensureContext()) return;
resumeContext();
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const lfo = audioCtx.createOscillator();
const lfoGain = audioCtx.createGain();
const gate = audioCtx.createGain();
const gateLfo = audioCtx.createOscillator();
const gateLfoGain = audioCtx.createGain();
const gain = audioCtx.createGain();
osc.type = "sawtooth";
osc.frequency.setValueAtTime(120, now);
lfo.type = "sine";
lfo.frequency.setValueAtTime(0.8, now);
lfoGain.gain.setValueAtTime(0.015, now);
gate.gain.setValueAtTime(0.0001, now);
gateLfo.type = "sine";
gateLfo.frequency.setValueAtTime(0.22, now);
gateLfoGain.gain.setValueAtTime(0.5, now);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(volume, now + 0.2);
lfo.connect(lfoGain);
lfoGain.connect(gain.gain);
gateLfo.connect(gateLfoGain);
gateLfoGain.connect(gate.gain);
osc.connect(gain);
gain.connect(gate);
gate.connect(masterGain);
osc.start(now);
lfo.start(now);
gateLfo.start(now);
buzzNodes = { osc, lfo, gain, gate, gateLfo };
};
const stopBuzzProcedural = () => {
if (!buzzNodes) return;
const now = audioCtx.currentTime;
buzzNodes.gain.gain.cancelScheduledValues(now);
buzzNodes.gain.gain.setValueAtTime(buzzNodes.gain.gain.value, now);
buzzNodes.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
buzzNodes.gate.gain.cancelScheduledValues(now);
buzzNodes.gate.gain.setValueAtTime(buzzNodes.gate.gain.value, now);
buzzNodes.gate.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
buzzNodes.osc.stop(now + 0.06);
buzzNodes.lfo.stop(now + 0.06);
buzzNodes.gateLfo.stop(now + 0.06);
buzzNodes = null;
};
const unlock = () => {
unlocked = true;
if (!soundConfig.enabled) return;
if (!ensureContext()) return;
resumeContext();
};
const playClick = () => {
const channel = soundConfig.channels.click || {};
if (!soundConfig.enabled || !unlocked || channel.mode === "off") return;
if (channel.mode === "procedural" && supportsWebAudio()) {
const procKey = channel.procedural || "click";
if (procKey === "click") {
playClickProcedural(channel.volume ?? 0.12);
}
return;
}
if (channel.mode === "sample") {
const player = getSamplePlayer("click", {
file: channel.file,
volume: channel.volume ?? 0.18,
max: 8,
});
player?.();
}
};
const playPop = () => {
const channel = soundConfig.channels.pop || {};
if (!soundConfig.enabled || !unlocked || channel.mode === "off") return;
if (channel.mode === "procedural" && supportsWebAudio()) {
const procKey = channel.procedural || "pop";
if (procKey === "pop") {
playPopProcedural(channel.volume ?? 0.2);
}
return;
}
if (channel.mode === "sample") {
const player = getSamplePlayer("pop", {
file: channel.file,
volume: channel.volume ?? 0.22,
max: 8,
});
player?.();
}
};
const startDrag = () => {
const channel = soundConfig.channels.buzz || {};
if (!soundConfig.enabled || !unlocked || buzzing) return;
if (channel.mode === "off") return;
buzzing = true;
if (channel.mode === "procedural" && supportsWebAudio()) {
const procKey = channel.procedural || "buzz";
if (procKey === "buzz") {
startBuzzProcedural(channel.volume ?? 0.05);
return;
}
}
if (channel.mode === "sample") {
ensureBuzzLoop({
file: channel.file,
volume: channel.volume ?? 0.06,
});
if (!buzzLoop) {
buzzing = false;
return;
}
buzzLoop.currentTime = 0;
buzzLoop.play().catch(() => {
buzzing = false;
});
}
};
const stopDrag = () => {
if (!buzzing) return;
buzzing = false;
const channel = soundConfig.channels.buzz || {};
if (channel.mode === "procedural" && supportsWebAudio()) {
stopBuzzProcedural();
return;
}
if (!buzzLoop) return;
buzzLoop.pause();
buzzLoop.currentTime = 0;
};
const setConfig = (nextSounds = {}) => {
soundConfig = normalizeSoundConfig(nextSounds);
samplePlayers.clear();
sampleBuzzKey = null;
if (buzzLoop) {
buzzLoop.pause();
buzzLoop = null;
}
if (!soundConfig.enabled) stopDrag();
};
return {
unlock,
playClick,
playPop,
startDrag,
stopDrag,
setConfig,
};
};
window.PhysilinksSound = { create };
})();