166 lines
7.4 KiB
JavaScript
166 lines
7.4 KiB
JavaScript
|
|
// 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();
|
||
|
|
});
|
||
|
|
}
|