diff --git a/app.js b/app.js index 781d3e7..a9ffd80 100644 --- a/app.js +++ b/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} */ 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); }