Files
the-nexus/modules/core/audio.js

166 lines
7.4 KiB
JavaScript
Raw Normal View History

// modules/core/audio.js — Web Audio ambient soundtrack
import * as THREE from 'three';
import { state } from './state.js';
let audioCtx = null;
let masterGain = null;
let audioRunning = false;
const audioSources = [];
const positionedPanners = [];
let portalHumsStarted = false;
let sparkleTimer = null;
let _camera;
function buildReverbIR(ctx, duration, decay) {
const rate = ctx.sampleRate;
const len = Math.ceil(rate * duration);
const buf = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
for (let i = 0; i < len; i++) {
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
return buf;
}
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;
}
export function updateAudioListener() {
if (!audioCtx || !_camera) 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);
}
}
export function startPortalHums() {
if (!audioCtx || !audioRunning || state.portals.length === 0 || portalHumsStarted) return;
portalHumsStarted = true;
const humFreqs = [58.27, 65.41, 73.42, 82.41, 87.31];
state.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];
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);
});
}
function startAmbient() {
if (audioRunning) return;
audioCtx = new AudioContext();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0;
const convolver = audioCtx.createConvolver();
convolver.buffer = buildReverbIR(audioCtx, 3.5, 2.8);
const limiter = audioCtx.createDynamicsCompressor();
limiter.threshold.value = -3; limiter.knee.value = 0; limiter.ratio.value = 20; limiter.attack.value = 0.001; limiter.release.value = 0.1;
masterGain.connect(convolver); convolver.connect(limiter); limiter.connect(audioCtx.destination);
// Layer 1: Sub-drone
[[55.0, -6], [55.0, +6]].forEach(([freq, detune]) => {
const osc = audioCtx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = freq; osc.detune.value = detune;
const g = audioCtx.createGain(); g.gain.value = 0.07; osc.connect(g); g.connect(masterGain); osc.start(); audioSources.push(osc);
});
// Layer 2: Pad
[110, 130.81, 164.81, 196].forEach((freq, i) => {
const detunes = [-8, 4, -3, 7];
const osc = audioCtx.createOscillator(); osc.type = 'triangle'; osc.frequency.value = freq; osc.detune.value = detunes[i];
const lfo = audioCtx.createOscillator(); lfo.frequency.value = 0.05 + i * 0.013;
const lfoGain = audioCtx.createGain(); lfoGain.gain.value = 0.02; lfo.connect(lfoGain);
const g = audioCtx.createGain(); g.gain.value = 0.06; lfoGain.connect(g.gain);
osc.connect(g); g.connect(masterGain); osc.start(); lfo.start(); audioSources.push(osc, lfo);
});
// Layer 3: Noise hiss
const noiseLen = audioCtx.sampleRate * 2;
const noiseBuf = audioCtx.createBuffer(1, noiseLen, audioCtx.sampleRate);
const nd = noiseBuf.getChannelData(0);
let b0 = 0;
for (let i = 0; i < noiseLen; i++) { const white = Math.random() * 2 - 1; b0 = 0.99 * b0 + white * 0.01; nd[i] = b0 * 3.5; }
const noiseNode = audioCtx.createBufferSource(); noiseNode.buffer = noiseBuf; noiseNode.loop = true;
const noiseFilter = audioCtx.createBiquadFilter(); noiseFilter.type = 'bandpass'; noiseFilter.frequency.value = 800; noiseFilter.Q.value = 0.5;
const noiseGain = audioCtx.createGain(); noiseGain.gain.value = 0.012;
noiseNode.connect(noiseFilter); noiseFilter.connect(noiseGain); noiseGain.connect(masterGain); noiseNode.start(); audioSources.push(noiseNode);
// Layer 4: Sparkle plucks
const sparkleNotes = [440, 523.25, 659.25, 880, 1046.5];
function scheduleSparkle() {
if (!audioRunning || !audioCtx) return;
const osc = audioCtx.createOscillator(); osc.type = 'sine';
osc.frequency.value = sparkleNotes[Math.floor(Math.random() * sparkleNotes.length)];
const env = audioCtx.createGain();
const now = audioCtx.currentTime;
env.gain.setValueAtTime(0, now); env.gain.linearRampToValueAtTime(0.08, now + 0.02); env.gain.exponentialRampToValueAtTime(0.0001, now + 1.8);
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(sparkPanner); osc.start(now); osc.stop(now + 1.9);
osc.addEventListener('ended', () => { try { sparkPanner.disconnect(); } catch (_) {} const idx = positionedPanners.indexOf(sparkPanner); if (idx !== -1) positionedPanners.splice(idx, 1); });
sparkleTimer = setTimeout(scheduleSparkle, 3000 + Math.random() * 6000);
}
sparkleTimer = setTimeout(scheduleSparkle, 1000 + Math.random() * 3000);
masterGain.gain.setValueAtTime(0, audioCtx.currentTime);
masterGain.gain.linearRampToValueAtTime(0.9, audioCtx.currentTime + 2.0);
audioRunning = true;
document.getElementById('audio-toggle').textContent = '\uD83D\uDD07';
startPortalHums();
}
function stopAmbient() {
if (!audioRunning || !audioCtx) return;
audioRunning = false;
if (sparkleTimer !== null) { clearTimeout(sparkleTimer); sparkleTimer = null; }
const gain = masterGain; const ctx = audioCtx;
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
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;
}, 900);
document.getElementById('audio-toggle').textContent = '\uD83D\uDD0A';
}
export function init(camera) {
_camera = camera;
document.getElementById('audio-toggle').addEventListener('click', () => {
if (audioRunning) stopAmbient(); else startAmbient();
});
}