feat: 3D audio positioning — sounds come from the direction of their source
- 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:
136
app.js
136
app.js
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user