[claude] Procedural Web Audio ambient soundtrack (#291) (#303)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
This commit was merged in pull request #303.
This commit is contained in:
199
app.js
199
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<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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user