/* ========================================================================= Procedural audio: Chinese-pentatonic generative score + SFX (WebAudio) ========================================================================= */ (function () { if (W.HEADLESS) { W.audio = { init() { }, setMood() { }, sfx() { }, setVol() { }, tick() { } }; return; } const A = W.audio = { ctx: null, master: null, musicG: null, sfxG: null, mood: null, moodName: '', started: false, vol: { music: 0.6, sfx: 0.8 }, nextBeat: 0, beat: 0, timer: null, }; const MOODS = { title: { bpm: 52, root: 220, scale: [0, 2, 4, 7, 9, 12, 14], pluck: 0.5, erhu: 0.35, flute: 0, drum: 0, bright: 0.5 }, sect: { bpm: 56, root: 196, scale: [0, 2, 4, 7, 9, 12], pluck: 0.55, erhu: 0.3, flute: 0.15, drum: 0, bright: 0.6 }, travel: { bpm: 66, root: 220, scale: [0, 3, 5, 7, 10, 12], pluck: 0.6, erhu: 0.3, flute: 0.1, drum: 0.08, bright: 0.55 }, town: { bpm: 72, root: 233, scale: [0, 2, 4, 7, 9, 12], pluck: 0.65, erhu: 0.25, flute: 0.2, drum: 0.1, bright: 0.7 }, night: { bpm: 44, root: 174, scale: [0, 3, 5, 7, 10], pluck: 0.3, erhu: 0.4, flute: 0.35, drum: 0, bright: 0.25 }, event: { bpm: 50, root: 185, scale: [0, 2, 3, 7, 8], pluck: 0.25, erhu: 0.5, flute: 0.1, drum: 0.05, bright: 0.35 }, combat: { bpm: 132, root: 220, scale: [0, 3, 5, 7, 10, 12], pluck: 0.5, erhu: 0.2, flute: 0, drum: 0.5, bright: 0.5 }, dark: { bpm: 40, root: 146, scale: [0, 1, 5, 6, 10], pluck: 0.2, erhu: 0.45, flute: 0.1, drum: 0.05, bright: 0.15 }, }; function ensure() { if (A.ctx) return true; try { const AC = window.AudioContext || window.webkitAudioContext; A.ctx = new AC(); A.master = A.ctx.createGain(); A.master.gain.value = 0.9; A.master.connect(A.ctx.destination); A.musicG = A.ctx.createGain(); A.musicG.gain.value = A.vol.music; A.musicG.connect(A.master); A.sfxG = A.ctx.createGain(); A.sfxG.gain.value = A.vol.sfx; A.sfxG.connect(A.master); // noise buffer const len = A.ctx.sampleRate * 2; A.noiseBuf = A.ctx.createBuffer(1, len, A.ctx.sampleRate); const d = A.noiseBuf.getChannelData(0); for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; return true; } catch (e) { return false; } } A.init = function () { if (!ensure()) return; if (A.ctx.state === 'suspended') A.ctx.resume(); if (!A.started) { A.started = true; A.nextBeat = A.ctx.currentTime + 0.1; A.timer = setInterval(A.tick, 120); } }; A.setVol = function (music, sfx) { if (music != null) A.vol.music = music; if (sfx != null) A.vol.sfx = sfx; if (A.musicG) A.musicG.gain.value = A.vol.music; if (A.sfxG) A.sfxG.gain.value = A.vol.sfx; }; A.setMood = function (name) { if (!MOODS[name] || A.moodName === name) return; A.moodName = name; A.mood = MOODS[name]; A.beat = 0; }; /* ------- instruments ------- */ function pluck(freq, t, dur, g, bright) { const c = A.ctx, o1 = c.createOscillator(), o2 = c.createOscillator(), f = c.createBiquadFilter(), gn = c.createGain(); o1.type = 'triangle'; o2.type = 'sine'; o1.frequency.value = freq; o2.frequency.value = freq * 2.001; f.type = 'lowpass'; f.frequency.value = 900 + bright * 2600; f.Q.value = 0.7; gn.gain.setValueAtTime(0.0001, t); gn.gain.exponentialRampToValueAtTime(g, t + 0.008); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur); o1.connect(f); o2.connect(f); f.connect(gn); gn.connect(A.musicG); o1.start(t); o2.start(t); o1.stop(t + dur + 0.05); o2.stop(t + dur + 0.05); } function erhu(freq, t, dur, g) { const c = A.ctx, o = c.createOscillator(), f = c.createBiquadFilter(), gn = c.createGain(), lfo = c.createOscillator(), lg = c.createGain(); o.type = 'sawtooth'; o.frequency.setValueAtTime(freq * 0.98, t); o.frequency.linearRampToValueAtTime(freq, t + Math.min(0.12, dur * 0.3)); f.type = 'lowpass'; f.frequency.value = 1500; f.Q.value = 2; lfo.frequency.value = 5.2; lg.gain.value = freq * 0.008; lfo.connect(lg); lg.connect(o.frequency); gn.gain.setValueAtTime(0.0001, t); gn.gain.linearRampToValueAtTime(g, t + dur * 0.25); gn.gain.linearRampToValueAtTime(0.0001, t + dur); o.connect(f); f.connect(gn); gn.connect(A.musicG); o.start(t); lfo.start(t); o.stop(t + dur + 0.05); lfo.stop(t + dur + 0.05); } function flute(freq, t, dur, g) { const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), f = c.createBiquadFilter(), n = c.createBufferSource(), nf = c.createBiquadFilter(), ng = c.createGain(); o.type = 'sine'; o.frequency.value = freq; f.type = 'lowpass'; f.frequency.value = 1800; gn.gain.setValueAtTime(0.0001, t); gn.gain.linearRampToValueAtTime(g, t + dur * 0.3); gn.gain.linearRampToValueAtTime(0.0001, t + dur); n.buffer = A.noiseBuf; nf.type = 'bandpass'; nf.frequency.value = freq * 2; ng.gain.value = g * 0.15; o.connect(f); f.connect(gn); gn.connect(A.musicG); n.connect(nf); nf.connect(ng); ng.connect(A.musicG); o.start(t); n.start(t); o.stop(t + dur + 0.05); n.stop(t + dur + 0.05); } function drum(t, g, low) { const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), n = c.createBufferSource(), nf = c.createBiquadFilter(), ng = c.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(low ? 90 : 160, t); o.frequency.exponentialRampToValueAtTime(40, t + 0.18); gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); o.connect(gn); gn.connect(A.musicG); o.start(t); o.stop(t + 0.25); n.buffer = A.noiseBuf; nf.type = 'bandpass'; nf.frequency.value = low ? 200 : 900; ng.gain.setValueAtTime(g * 0.5, t); ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.12); n.connect(nf); nf.connect(ng); ng.connect(A.musicG); n.start(t); n.stop(t + 0.15); } A.tick = function () { if (!A.ctx || !A.mood || A.ctx.state !== 'running') return; const m = A.mood, spb = 60 / m.bpm, now = A.ctx.currentTime; while (A.nextBeat < now + 0.4) { const t = A.nextBeat, b = A.beat; const sc = m.scale, deg = sc[Math.floor(W.rng() * sc.length)]; const oct = W.chance(0.2) ? 2 : 1; if (W.chance(m.pluck * 0.8)) pluck(m.root * oct * Math.pow(2, deg / 12), t, spb * W.rf(1.5, 3), 0.16, m.bright); if (b % 4 === 0 && W.chance(m.erhu)) erhu(m.root * Math.pow(2, sc[Math.floor(W.rng() * 3)] / 12), t, spb * W.rf(2.5, 4.5), 0.075); if (b % 8 === 4 && W.chance(m.flute)) flute(m.root * 2 * Math.pow(2, sc[0] / 12), t, spb * 3, 0.05); if (m.drum > 0) { if (b % 4 === 0) drum(t, 0.12 * m.drum * 2, true); if (b % 2 === 1 && W.chance(0.5)) drum(t, 0.05 * m.drum * 2, false); } A.nextBeat += spb; A.beat++; } }; /* ------- SFX ------- */ const SFX = { click(t) { blip(t, 1800, 0.05, 0.12, 'square'); }, hover(t) { blip(t, 2400, 0.03, 0.05, 'sine'); }, seal(t) { thud(t, 0.25, 120); noise(t, 0.1, 3000, 0.1); }, brush(t) { noise(t, 0.22, 1400, 0.12, 'bandpass'); }, paper(t) { noise(t, 0.15, 5000, 0.06, 'highpass'); }, coin(t) { blip(t, 1300, 0.09, 0.12, 'triangle'); blip(t + 0.07, 1750, 0.12, 0.1, 'triangle'); }, eat(t) { noise(t, 0.08, 800, 0.08, 'lowpass'); }, sword(t) { noise(t, 0.12, 4200, 0.16, 'highpass'); blip(t, 2800, 0.15, 0.08, 'sawtooth', true); }, clash(t) { blip(t, 3200, 0.18, 0.14, 'square', true); noise(t, 0.1, 5000, 0.14, 'highpass'); }, hit(t) { thud(t, 0.3, 150); noise(t, 0.08, 900, 0.12, 'lowpass'); }, crit(t) { thud(t, 0.4, 100); noise(t, 0.16, 2500, 0.2, 'bandpass'); blip(t + 0.03, 220, 0.25, 0.15, 'sawtooth', true); }, block(t) { blip(t, 500, 0.1, 0.14, 'square'); noise(t, 0.06, 2000, 0.08, 'bandpass'); }, heal(t) { arpeggio(t, [523, 659, 784], 0.09, 0.09, 'sine'); }, poison(t) { noise(t, 0.3, 400, 0.1, 'lowpass'); blip(t + 0.1, 180, 0.2, 0.08, 'sawtooth', true); }, thunder(t) { noise(t, 0.5, 1200, 0.3, 'lowpass'); thud(t, 0.4, 70); }, bell(t) { bell(t, 440, 0.3); }, gong(t) { bell(t, 98, 0.5); bell(t + 0.02, 147, 0.4, 0.5); }, levelup(t) { arpeggio(t, [392, 523, 659, 784], 0.1, 0.12, 'triangle'); }, whoosh(t) { noise(t, 0.25, 900, 0.12, 'bandpass', 0.5); }, death(t) { thud(t, 0.4, 80); blip(t + 0.1, 140, 0.5, 0.1, 'sawtooth', true); }, win(t) { arpeggio(t, [523, 659, 784, 1046], 0.12, 0.14, 'triangle'); bell(t + 0.5, 1046, 0.2, 0.3); }, lose(t) { arpeggio(t, [392, 330, 262, 196], 0.16, 0.12, 'sine'); thud(t + 0.7, 0.3, 70); }, rain_on(t) { }, }; function blip(t, freq, dur, g, type, slide) { const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), f = c.createBiquadFilter(); o.type = type || 'sine'; o.frequency.setValueAtTime(freq, t); if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq * 0.25), t + dur); f.type = 'lowpass'; f.frequency.value = 6000; gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.connect(f); f.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + dur + 0.05); } function thud(t, g, freq) { const c = A.ctx, o = c.createOscillator(), gn = c.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(freq * 2.2, t); o.frequency.exponentialRampToValueAtTime(freq * 0.5, t + 0.16); gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.2); o.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + 0.25); } function noise(t, dur, freq, g, type, sweep) { const c = A.ctx, n = c.createBufferSource(), f = c.createBiquadFilter(), gn = c.createGain(); n.buffer = A.noiseBuf; n.loop = true; f.type = type || 'bandpass'; f.frequency.setValueAtTime(freq, t); f.Q.value = 0.8; if (sweep) f.frequency.exponentialRampToValueAtTime(freq * (0.4 + sweep), t + dur); gn.gain.setValueAtTime(0.0001, t); gn.gain.linearRampToValueAtTime(g, t + dur * 0.2); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur); n.connect(f); f.connect(gn); gn.connect(A.sfxG); n.start(t); n.stop(t + dur + 0.05); } function bell(t, freq, g, mul) { const c = A.ctx, partials = [1, 2.01, 2.9, 4.2], base = g || 0.2; for (let i = 0; i < partials.length; i++) { const o = c.createOscillator(), gn = c.createGain(); o.type = 'sine'; o.frequency.value = freq * partials[i] * (mul || 1); const gg = base / (i + 1.5); gn.gain.setValueAtTime(gg, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 1.6 - i * 0.3); o.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + 1.8); } } function arpeggio(t, freqs, step, g, type) { freqs.forEach((f, i) => blip(t + i * step, f, step * 2.2, g, type || 'sine')); } A.sfx = function (name) { if (!A.ctx || A.ctx.state !== 'running' || !SFX[name]) return; try { SFX[name](A.ctx.currentTime + 0.001); } catch (e) { } }; // ambient loops (rain/wind) as filtered noise with slow LFO let ambSrc = null, ambFilter = null, ambGain = null, ambKind = ''; A.ambient = function (kind) { if (!A.ctx) return; if (kind === ambKind) return; ambKind = kind; if (ambSrc) { try { ambSrc.stop(); } catch (e) { } ambSrc = null; } if (!kind) return; const c = A.ctx; ambSrc = c.createBufferSource(); ambSrc.buffer = A.noiseBuf; ambSrc.loop = true; ambFilter = c.createBiquadFilter(); ambGain = c.createGain(); if (kind === 'rain') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 2600; ambFilter.Q.value = 0.4; ambGain.gain.value = 0.055; } else if (kind === 'storm') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 1500; ambFilter.Q.value = 0.3; ambGain.gain.value = 0.09; } else if (kind === 'wind') { ambFilter.type = 'lowpass'; ambFilter.frequency.value = 500; ambGain.gain.value = 0.05; } else if (kind === 'river') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 5200; ambFilter.Q.value = 0.2; ambGain.gain.value = 0.03; } else return; ambSrc.connect(ambFilter); ambFilter.connect(ambGain); ambGain.connect(A.master); ambSrc.start(); }; })();