feat: 3D audio positioning — sounds come from the direction of their source
Some checks failed
CI / validate (pull_request) Failing after 15s
CI / auto-merge (pull_request) Has been skipped

- Add updateAudioListener() called each frame to sync the Web Audio API
  listener position/orientation with the Three.js camera
- Add createPanner() helper for HRTF-positioned sound sources
- Sparkle plucks now spawn at random 3D positions floating above the
  platform so they drift in from all directions
- Add startPortalHums(): each portal emits a quiet positional hum at its
  world coordinate (x, y, z) so portals can be heard panning as the
  listener orbits; hums start when both audio and portal data are ready
- stopAmbient() disconnects and clears all positionedPanners and resets
  portalHumsStarted so restart is clean

Fixes #239

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 01:14:32 -04:00
parent 668a69ecc9
commit 0715d93d08

136
app.js
View File

@@ -1920,6 +1920,9 @@ function animate() {
updateLightningArcs();
}
// Sync Web Audio API listener to camera for 3D spatial audio
updateAudioListener();
composer.render();
}
@@ -1950,6 +1953,12 @@ let audioRunning = false;
/** @type {Array<OscillatorNode|AudioBufferSourceNode>} */
const audioSources = [];
/** @type {PannerNode[]} */
const positionedPanners = [];
/** @type {boolean} */
let portalHumsStarted = false;
/** @type {number|null} */
let sparkleTimer = null;
@@ -1973,6 +1982,103 @@ function buildReverbIR(ctx, duration, decay) {
return buf;
}
/**
* Creates a PannerNode fixed at world coordinates (x, y, z).
* HRTF model gives realistic directional cues; inverse rolloff fades
* with distance. Connect the returned node to masterGain.
* Must be called while audioCtx is initialised.
* @param {number} x
* @param {number} y
* @param {number} z
* @returns {PannerNode}
*/
function createPanner(x, y, z) {
const panner = audioCtx.createPanner();
panner.panningModel = 'HRTF';
panner.distanceModel = 'inverse';
panner.refDistance = 5;
panner.maxDistance = 80;
panner.rolloffFactor = 1.0;
if (panner.positionX) {
panner.positionX.value = x;
panner.positionY.value = y;
panner.positionZ.value = z;
} else {
panner.setPosition(x, y, z);
}
positionedPanners.push(panner);
return panner;
}
/**
* Updates the Web Audio API listener to match the camera's current
* position and orientation. Call once per animation frame.
*/
function updateAudioListener() {
if (!audioCtx) return;
const listener = audioCtx.listener;
const pos = camera.position;
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
if (listener.positionX) {
const t = audioCtx.currentTime;
listener.positionX.setValueAtTime(pos.x, t);
listener.positionY.setValueAtTime(pos.y, t);
listener.positionZ.setValueAtTime(pos.z, t);
listener.forwardX.setValueAtTime(fwd.x, t);
listener.forwardY.setValueAtTime(fwd.y, t);
listener.forwardZ.setValueAtTime(fwd.z, t);
listener.upX.setValueAtTime(up.x, t);
listener.upY.setValueAtTime(up.y, t);
listener.upZ.setValueAtTime(up.z, t);
} else {
listener.setPosition(pos.x, pos.y, pos.z);
listener.setOrientation(fwd.x, fwd.y, fwd.z, up.x, up.y, up.z);
}
}
/**
* Starts a quiet positional hum for every loaded portal.
* Each hum is placed at the portal's world position so the sound
* pans left/right as the listener orbits the scene.
* Safe to call multiple times (idempotent).
*/
function startPortalHums() {
if (!audioCtx || !audioRunning || portals.length === 0 || portalHumsStarted) return;
portalHumsStarted = true;
// Distinct base pitches per portal slot (low sub-register)
const humFreqs = [58.27, 65.41, 73.42, 82.41, 87.31];
portals.forEach((portal, i) => {
const panner = createPanner(
portal.position.x,
portal.position.y + 1.5,
portal.position.z
);
panner.connect(masterGain);
const osc = audioCtx.createOscillator();
osc.type = 'sine';
osc.frequency.value = humFreqs[i % humFreqs.length];
// Slow tremolo for organic feel
const lfo = audioCtx.createOscillator();
lfo.frequency.value = 0.07 + i * 0.02;
const lfoGain = audioCtx.createGain();
lfoGain.gain.value = 0.008;
lfo.connect(lfoGain);
const g = audioCtx.createGain();
g.gain.value = 0.035;
lfoGain.connect(g.gain);
osc.connect(g);
g.connect(panner);
osc.start();
lfo.start();
audioSources.push(osc, lfo);
});
}
/**
* Starts the ambient soundtrack. Safe to call multiple times (idempotent).
*/
@@ -2063,6 +2169,8 @@ function startAmbient() {
audioSources.push(noiseNode);
// -- Layer 4: Sparkle plucks (pentatonic: A4 C5 E5 A5 C6) --
// Each pluck spawns at a random 3D position around the platform so
// the sound appears to drift in from different directions.
const sparkleNotes = [440, 523.25, 659.25, 880, 1046.5];
function scheduleSparkle() {
if (!audioRunning || !audioCtx) return;
@@ -2074,10 +2182,28 @@ function startAmbient() {
env.gain.setValueAtTime(0, now);
env.gain.linearRampToValueAtTime(0.08, now + 0.02);
env.gain.exponentialRampToValueAtTime(0.0001, now + 1.8);
// Position sparkle at a random point floating above the platform
const angle = Math.random() * Math.PI * 2;
const radius = 3 + Math.random() * 9;
const sparkPanner = createPanner(
Math.cos(angle) * radius,
1.5 + Math.random() * 4,
Math.sin(angle) * radius
);
sparkPanner.connect(masterGain);
osc.connect(env);
env.connect(masterGain);
env.connect(sparkPanner);
osc.start(now);
osc.stop(now + 1.9);
// Disconnect panner once the note is gone
osc.addEventListener('ended', () => {
try { sparkPanner.disconnect(); } catch (_) {}
const idx = positionedPanners.indexOf(sparkPanner);
if (idx !== -1) positionedPanners.splice(idx, 1);
});
// Schedule next sparkle: 3-9 seconds
const nextMs = 3000 + Math.random() * 6000;
sparkleTimer = setTimeout(scheduleSparkle, nextMs);
@@ -2090,6 +2216,9 @@ function startAmbient() {
audioRunning = true;
document.getElementById('audio-toggle').textContent = '🔇';
// Start portal hums if portals are already loaded
startPortalHums();
}
/**
@@ -2108,6 +2237,9 @@ function stopAmbient() {
setTimeout(() => {
audioSources.forEach(n => { try { n.stop(); } catch (_) {} });
audioSources.length = 0;
positionedPanners.forEach(p => { try { p.disconnect(); } catch (_) {} });
positionedPanners.length = 0;
portalHumsStarted = false;
ctx.close();
audioCtx = null;
masterGain = null;
@@ -2633,6 +2765,8 @@ async function loadPortals() {
portals = await res.json();
console.log('Loaded portals:', portals);
createPortals();
// If audio is already running, attach positional hums to the portals now
startPortalHums();
} catch (error) {
console.error('Failed to load portals:', error);
}