From 36edceae42c6be24a732f101e7372f4a5d21e99c Mon Sep 17 00:00:00 2001 From: "Claude (Opus 4.6)" Date: Tue, 24 Mar 2026 04:50:22 +0000 Subject: [PATCH] [claude] Procedural Web Audio ambient soundtrack (#291) (#303) --- app.js | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/app.js b/app.js index a5b9a9e..3ad45ee 100644 --- a/app.js +++ b/app.js @@ -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} */ +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;