Files
lit.ruv.wtf/website/scripts/SpatialAudio.js

121 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Manages spatial audio playback with distance-based volume and stereo panning.
*/
export class SpatialAudio {
/** @type {AudioContext} */
#audioContext;
/** @type {Map<string, AudioBuffer>} */
#buffers = new Map();
/** @type {() => {x: number, y: number, width: number, height: number}} */
#getViewport;
/**
* @param {() => {x: number, y: number, width: number, height: number}} getViewport - Returns current viewport bounds in world-space
*/
constructor(getViewport) {
this.#audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.#getViewport = getViewport;
}
/**
* Preloads an audio file.
*
* @param {string} key - Identifier for this audio
* @param {string} url - Path to audio file
*/
async load(key, url) {
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.#audioContext.decodeAudioData(arrayBuffer);
this.#buffers.set(key, audioBuffer);
} catch (err) {
console.error(`[SpatialAudio] Failed to load "${url}":`, err);
}
}
/**
* Plays a sound at a world-space position with spatial audio.
* Volume is based on how much of the viewport the source occupies.
* Sounds muffle progressively as the source moves off-screen.
*
* @param {string} key - Audio identifier
* @param {number} worldX - X position in world-space (anchor center)
* @param {number} worldY - Y position in world-space (anchor center)
* @param {number} worldWidth - Width in world-space
* @param {number} worldHeight - Height in world-space
* @param {{ minVolume?: number, maxVolume?: number, sizeThreshold?: number }} [options]
*/
play(key, worldX, worldY, worldWidth, worldHeight, options = {}) {
const buffer = this.#buffers.get(key);
if (!buffer) {
console.warn(`[SpatialAudio] Audio "${key}" not loaded`);
return;
}
const viewport = this.#getViewport();
// Calculate what percentage of viewport the source occupies
const viewportArea = viewport.width * viewport.height;
const sourceArea = worldWidth * worldHeight;
const areaPercentage = sourceArea / viewportArea;
// Volume based on size percentage (larger on screen = louder)
const minVolume = options.minVolume ?? 0;
const maxVolume = options.maxVolume ?? 1.0;
const sizeThreshold = options.sizeThreshold ?? 0.05;
const sizeFactor = Math.min(1, areaPercentage / sizeThreshold);
const volume = minVolume + (maxVolume - minVolume) * sizeFactor;
// Viewport intersection — how much of the node is actually visible (01)
const nodeLeft = worldX - worldWidth / 2;
const nodeTop = worldY - worldHeight / 2;
const nodeRight = worldX + worldWidth / 2;
const nodeBottom = worldY + worldHeight / 2;
const overlapW = Math.max(0, Math.min(nodeRight, viewport.x + viewport.width) - Math.max(nodeLeft, viewport.x));
const overlapH = Math.max(0, Math.min(nodeBottom, viewport.y + viewport.height) - Math.max(nodeTop, viewport.y));
const overlapArea = overlapW * overlapH;
const visibilityFraction = sourceArea > 0 ? Math.min(1, overlapArea / sourceArea) : 0;
// Stereo panning based on horizontal position relative to viewport centre
const centerX = viewport.x + viewport.width / 2;
const dx = worldX - centerX;
const panRange = viewport.width / 2;
const pan = Math.max(-1, Math.min(1, dx / panRange));
// Muffling: low-pass filter whose cutoff drops as the node leaves the screen.
// Fully on-screen → 20 000 Hz (effectively open)
// Fully off-screen → 400 Hz (thick muffle)
const MUFFLE_OPEN = 20000;
const MUFFLE_CLOSED = 300;
const cutoff = MUFFLE_CLOSED + (MUFFLE_OPEN - MUFFLE_CLOSED) * visibilityFraction;
// Create audio nodes
const source = this.#audioContext.createBufferSource();
source.buffer = buffer;
const gainNode = this.#audioContext.createGain();
gainNode.gain.value = volume;
const panNode = this.#audioContext.createStereoPanner();
panNode.pan.value = pan;
const filterNode = this.#audioContext.createBiquadFilter();
filterNode.type = "lowpass";
filterNode.frequency.value = cutoff;
filterNode.Q.value = 0.7;
// Connect: source → filter → gain → pan → destination
source.connect(filterNode);
filterNode.connect(gainNode);
gainNode.connect(panNode);
panNode.connect(this.#audioContext.destination);
source.start(0);
}
}