[claude] Procedural Web Audio ambient soundtrack (#291) (#303)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled

This commit was merged in pull request #303.
This commit is contained in:
2026-03-24 04:50:22 +00:00
parent dc02d8fdc5
commit 36edceae42

199
app.js
View File

@@ -989,6 +989,205 @@ function animate() {
animate();
// === AMBIENT SOUNDTRACK ===
// Procedural ambient score synthesised in-browser via Web Audio API.
// Research: Google MusicFX (musicfx.sandbox.google.com) uses AI text prompts to
// generate music clips. Since MusicFX has no public API, we replicate the desired
// "deep space / sovereign" aesthetic procedurally.
//
// Architecture (4 layers):
// 1. Sub-drone — two slow-detuned sawtooth oscillators at ~55 Hz (octave below A2)
// 2. Pad — four detuned triangle oscillators in a minor 7th chord
// 3. Sparkle — random high-register sine plucks on a pentatonic scale
// 4. Noise hiss — pink-ish filtered noise for texture
// All routed through: gain → convolver reverb → limiter → destination.
/** @type {AudioContext|null} */
let audioCtx = null;
/** @type {GainNode|null} */
let masterGain = null;
/** @type {boolean} */
let audioRunning = false;
/** @type {Array<OscillatorNode|AudioBufferSourceNode>} */
const audioSources = [];
/** @type {number|null} */
let sparkleTimer = null;
/**
* Builds a simple impulse-response buffer for reverb (synthetic room).
* @param {AudioContext} ctx
* @param {number} duration seconds
* @param {number} decay
* @returns {AudioBuffer}
*/
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;
}
/**
* Starts the ambient soundtrack. Safe to call multiple times (idempotent).
*/
function startAmbient() {
if (audioRunning) return;
audioCtx = new AudioContext();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0;
// Reverb
const convolver = audioCtx.createConvolver();
convolver.buffer = buildReverbIR(audioCtx, 3.5, 2.8);
// Limiter (DynamicsCompressor as brickwall)
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 (two detuned saws) --
[[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 (minor 7th chord: A2, C3, E3, G3) --
[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];
// Slow LFO for gentle swell
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 (filtered white noise) --
const noiseLen = audioCtx.sampleRate * 2;
const noiseBuf = audioCtx.createBuffer(1, noiseLen, audioCtx.sampleRate);
const nd = noiseBuf.getChannelData(0);
// Simple pink-ish noise via first-order IIR
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 (pentatonic: A4 C5 E5 A5 C6) --
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);
osc.connect(env);
env.connect(masterGain);
osc.start(now);
osc.stop(now + 1.9);
// Schedule next sparkle: 3-9 seconds
const nextMs = 3000 + Math.random() * 6000;
sparkleTimer = setTimeout(scheduleSparkle, nextMs);
}
sparkleTimer = setTimeout(scheduleSparkle, 1000 + Math.random() * 3000);
// Fade in master gain
masterGain.gain.setValueAtTime(0, audioCtx.currentTime);
masterGain.gain.linearRampToValueAtTime(0.9, audioCtx.currentTime + 2.0);
audioRunning = true;
document.getElementById('audio-toggle').textContent = '🔇';
}
/**
* Stops and tears down the ambient soundtrack.
*/
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;
ctx.close();
audioCtx = null;
masterGain = null;
}, 900);
document.getElementById('audio-toggle').textContent = '🔊';
}
document.getElementById('audio-toggle').addEventListener('click', () => {
if (audioRunning) {
stopAmbient();
} else {
startAmbient();
}
});
// === DEBUG MODE ===
let debugMode = false;