Merge pull request 'feat: procedural sound engine (Web Audio API) — epic #57' (#81) from burn/20260412-1227-sound into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
Some checks failed
Smoke Test / smoke (push) Failing after 3s
This commit was merged in pull request #81.
This commit is contained in:
@@ -227,6 +227,7 @@ The light is on. The room is empty."
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/strategy.js"></script>
|
||||
<script src="js/sound.js"></script>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
12
js/engine.js
12
js/engine.js
@@ -283,6 +283,7 @@ function checkMilestones() {
|
||||
G.milestones.push(m.flag);
|
||||
log(m.msg, true);
|
||||
showToast(m.msg, 'milestone', 5000);
|
||||
if (typeof Sound !== 'undefined') Sound.playMilestone();
|
||||
|
||||
// Check phase advancement
|
||||
if (m.at) {
|
||||
@@ -296,6 +297,10 @@ function checkMilestones() {
|
||||
if (pNum > _shownPhaseTransition) {
|
||||
_shownPhaseTransition = pNum;
|
||||
showPhaseTransition(pNum);
|
||||
if (typeof Sound !== 'undefined') {
|
||||
Sound.playFanfare();
|
||||
Sound.updateAmbientPhase(pNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,6 +361,7 @@ function buyBuilding(id) {
|
||||
log(`Built ${def.name} ${label} (total: ${totalBuilt})`);
|
||||
// Particle burst on purchase
|
||||
const btn = document.querySelector('[onclick="buyBuilding(\'' + id + '\')"]');
|
||||
if (typeof Sound !== 'undefined') Sound.playBuild();
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
@@ -396,6 +402,7 @@ function buyProject(id) {
|
||||
|
||||
updateRates();
|
||||
// Gold particle burst on project completion
|
||||
if (typeof Sound !== 'undefined') Sound.playProject();
|
||||
const pBtn = document.querySelector('[onclick="buyProject(\'' + id + '\')"]');
|
||||
if (pBtn) {
|
||||
const rect = pBtn.getBoundingClientRect();
|
||||
@@ -437,6 +444,7 @@ function renderDriftEnding() {
|
||||
// Fade-in animation
|
||||
el.classList.add('fade-in');
|
||||
el.classList.add('active');
|
||||
if (typeof Sound !== 'undefined') Sound.playDriftEnding();
|
||||
|
||||
// Log the ending text with delays for dramatic effect
|
||||
const lines = [
|
||||
@@ -479,8 +487,7 @@ function renderBeaconEnding() {
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Create particle/light ray container
|
||||
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
|
||||
const particleContainer = document.createElement('div');
|
||||
particleContainer.id = 'beacon-ending-particles';
|
||||
document.body.appendChild(particleContainer);
|
||||
@@ -762,6 +769,7 @@ function writeCode() {
|
||||
}
|
||||
// Float a number at the click position
|
||||
showClickNumber(amount, comboMult);
|
||||
if (typeof Sound !== 'undefined') Sound.playClick();
|
||||
updateRates();
|
||||
checkMilestones();
|
||||
render();
|
||||
|
||||
13
js/main.js
13
js/main.js
@@ -42,6 +42,18 @@ window.addEventListener('load', function () {
|
||||
// Game loop at 10Hz (100ms tick)
|
||||
setInterval(tick, 100);
|
||||
|
||||
// Start ambient drone on first interaction
|
||||
if (typeof Sound !== 'undefined') {
|
||||
const startAmbientOnce = () => {
|
||||
Sound.startAmbient();
|
||||
Sound.updateAmbientPhase(G.phase);
|
||||
document.removeEventListener('click', startAmbientOnce);
|
||||
document.removeEventListener('keydown', startAmbientOnce);
|
||||
};
|
||||
document.addEventListener('click', startAmbientOnce);
|
||||
document.addEventListener('keydown', startAmbientOnce);
|
||||
}
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
setInterval(saveGame, CONFIG.AUTO_SAVE_INTERVAL);
|
||||
|
||||
@@ -69,6 +81,7 @@ function toggleMute() {
|
||||
}
|
||||
// Save preference
|
||||
try { localStorage.setItem('the-beacon-muted', _muted ? '1' : '0'); } catch(e) {}
|
||||
if (typeof Sound !== 'undefined') Sound.onMuteChanged(_muted);
|
||||
}
|
||||
// Restore mute state on load
|
||||
try {
|
||||
|
||||
401
js/sound.js
Normal file
401
js/sound.js
Normal file
@@ -0,0 +1,401 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Sound Engine
|
||||
// Procedural audio via Web Audio API (no audio files)
|
||||
// ============================================================
|
||||
|
||||
const Sound = (function () {
|
||||
let ctx = null;
|
||||
let masterGain = null;
|
||||
let ambientGain = null;
|
||||
let ambientOsc1 = null;
|
||||
let ambientOsc2 = null;
|
||||
let ambientOsc3 = null;
|
||||
let ambientLfo = null;
|
||||
let ambientStarted = false;
|
||||
let currentPhase = 0;
|
||||
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = 0.3;
|
||||
masterGain.connect(ctx.destination);
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function isMuted() {
|
||||
return typeof _muted !== 'undefined' && _muted;
|
||||
}
|
||||
|
||||
// --- Noise buffer helper ---
|
||||
function createNoiseBuffer(duration) {
|
||||
const c = ensureCtx();
|
||||
const len = c.sampleRate * duration;
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// --- playClick: mechanical keyboard sound ---
|
||||
function playClick() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Short noise burst (mechanical key)
|
||||
const noise = c.createBufferSource();
|
||||
noise.buffer = createNoiseBuffer(0.03);
|
||||
|
||||
const noiseGain = c.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.4, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
|
||||
|
||||
const hpFilter = c.createBiquadFilter();
|
||||
hpFilter.type = 'highpass';
|
||||
hpFilter.frequency.value = 3000;
|
||||
|
||||
noise.connect(hpFilter);
|
||||
hpFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noise.start(now);
|
||||
noise.stop(now + 0.03);
|
||||
|
||||
// Click tone
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(1800, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(600, now + 0.02);
|
||||
|
||||
const oscGain = c.createGain();
|
||||
oscGain.gain.setValueAtTime(0.15, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.025);
|
||||
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.03);
|
||||
}
|
||||
|
||||
// --- playBuild: purchase thud + chime ---
|
||||
function playBuild() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Low thud
|
||||
const thud = c.createOscillator();
|
||||
thud.type = 'sine';
|
||||
thud.frequency.setValueAtTime(150, now);
|
||||
thud.frequency.exponentialRampToValueAtTime(60, now + 0.12);
|
||||
|
||||
const thudGain = c.createGain();
|
||||
thudGain.gain.setValueAtTime(0.35, now);
|
||||
thudGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
|
||||
thud.connect(thudGain);
|
||||
thudGain.connect(masterGain);
|
||||
thud.start(now);
|
||||
thud.stop(now + 0.15);
|
||||
|
||||
// Bright chime on top
|
||||
const chime = c.createOscillator();
|
||||
chime.type = 'sine';
|
||||
chime.frequency.setValueAtTime(880, now + 0.05);
|
||||
chime.frequency.exponentialRampToValueAtTime(1200, now + 0.2);
|
||||
|
||||
const chimeGain = c.createGain();
|
||||
chimeGain.gain.setValueAtTime(0, now);
|
||||
chimeGain.gain.linearRampToValueAtTime(0.2, now + 0.06);
|
||||
chimeGain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
|
||||
|
||||
chime.connect(chimeGain);
|
||||
chimeGain.connect(masterGain);
|
||||
chime.start(now + 0.05);
|
||||
chime.stop(now + 0.25);
|
||||
}
|
||||
|
||||
// --- playProject: ascending chime ---
|
||||
function playProject() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784]; // C5, E5, G5
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.22, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.35);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playMilestone: bright arpeggio ---
|
||||
function playMilestone() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.08;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.25, t + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playFanfare: 8-note scale for phase transitions ---
|
||||
function playFanfare() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const scale = [262, 294, 330, 349, 392, 440, 494, 523]; // C4 to C5
|
||||
scale.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 2000;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.3);
|
||||
});
|
||||
|
||||
// Final chord
|
||||
const chordNotes = [523, 659, 784];
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + 0.8;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.2, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 1.2);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 1.2);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playDriftEnding: descending dissonance ---
|
||||
function playDriftEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [440, 415, 392, 370, 349, 330, 311, 294]; // A4 descending, slightly detuned
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Slight detune for dissonance
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sawtooth';
|
||||
osc2.frequency.value = freq * 1.02;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(1500, now + i * 0.2);
|
||||
filter.frequency.exponentialRampToValueAtTime(200, now + i * 0.2 + 0.5);
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.2;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.1, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
|
||||
|
||||
osc.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.8);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playBeaconEnding: warm chord ---
|
||||
function playBeaconEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Warm major chord: C3, E3, G3, C4, E4
|
||||
const chord = [131, 165, 196, 262, 330];
|
||||
chord.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Add subtle harmonics
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.value = freq * 2;
|
||||
|
||||
const gain = c.createGain();
|
||||
const gain2 = c.createGain();
|
||||
const t = now + i * 0.15;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.3);
|
||||
gain.gain.setValueAtTime(0.15, t + 2);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
gain2.gain.setValueAtTime(0, t);
|
||||
gain2.gain.linearRampToValueAtTime(0.05, t + 0.3);
|
||||
gain2.gain.setValueAtTime(0.05, t + 2);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
osc.connect(gain);
|
||||
osc2.connect(gain2);
|
||||
gain.connect(masterGain);
|
||||
gain2.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 4);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ambient drone system ---
|
||||
function startAmbient() {
|
||||
if (ambientStarted) return;
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
ambientStarted = true;
|
||||
|
||||
ambientGain = c.createGain();
|
||||
ambientGain.gain.value = 0;
|
||||
ambientGain.gain.linearRampToValueAtTime(0.06, c.currentTime + 3);
|
||||
ambientGain.connect(masterGain);
|
||||
|
||||
// Base drone
|
||||
ambientOsc1 = c.createOscillator();
|
||||
ambientOsc1.type = 'sine';
|
||||
ambientOsc1.frequency.value = 55; // A1
|
||||
ambientOsc1.connect(ambientGain);
|
||||
ambientOsc1.start();
|
||||
|
||||
// Second voice (fifth above)
|
||||
ambientOsc2 = c.createOscillator();
|
||||
ambientOsc2.type = 'sine';
|
||||
ambientOsc2.frequency.value = 82.4; // E2
|
||||
const g2 = c.createGain();
|
||||
g2.gain.value = 0.5;
|
||||
ambientOsc2.connect(g2);
|
||||
g2.connect(ambientGain);
|
||||
ambientOsc2.start();
|
||||
|
||||
// Third voice (high shimmer)
|
||||
ambientOsc3 = c.createOscillator();
|
||||
ambientOsc3.type = 'triangle';
|
||||
ambientOsc3.frequency.value = 220; // A3
|
||||
const g3 = c.createGain();
|
||||
g3.gain.value = 0.15;
|
||||
ambientOsc3.connect(g3);
|
||||
g3.connect(ambientGain);
|
||||
ambientOsc3.start();
|
||||
|
||||
// LFO for subtle movement
|
||||
ambientLfo = c.createOscillator();
|
||||
ambientLfo.type = 'sine';
|
||||
ambientLfo.frequency.value = 0.2;
|
||||
const lfoGain = c.createGain();
|
||||
lfoGain.gain.value = 3;
|
||||
ambientLfo.connect(lfoGain);
|
||||
lfoGain.connect(ambientOsc1.frequency);
|
||||
ambientLfo.start();
|
||||
}
|
||||
|
||||
function updateAmbientPhase(phase) {
|
||||
if (!ambientStarted || !ambientOsc1 || !ambientOsc2 || !ambientOsc3) return;
|
||||
if (phase === currentPhase) return;
|
||||
currentPhase = phase;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
const rampTime = 2;
|
||||
|
||||
// Phase determines the drone's character
|
||||
const phases = {
|
||||
1: { base: 55, fifth: 82.4, shimmer: 220, shimmerVol: 0.15 },
|
||||
2: { base: 65.4, fifth: 98, shimmer: 262, shimmerVol: 0.2 },
|
||||
3: { base: 73.4, fifth: 110, shimmer: 294, shimmerVol: 0.25 },
|
||||
4: { base: 82.4, fifth: 123.5, shimmer: 330, shimmerVol: 0.3 },
|
||||
5: { base: 98, fifth: 147, shimmer: 392, shimmerVol: 0.35 },
|
||||
6: { base: 110, fifth: 165, shimmer: 440, shimmerVol: 0.4 }
|
||||
};
|
||||
|
||||
const p = phases[phase] || phases[1];
|
||||
ambientOsc1.frequency.linearRampToValueAtTime(p.base, now + rampTime);
|
||||
ambientOsc2.frequency.linearRampToValueAtTime(p.fifth, now + rampTime);
|
||||
ambientOsc3.frequency.linearRampToValueAtTime(p.shimmer, now + rampTime);
|
||||
}
|
||||
|
||||
// --- Mute integration ---
|
||||
function onMuteChanged(muted) {
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.linearRampToValueAtTime(
|
||||
muted ? 0 : 0.06,
|
||||
(ctx ? ctx.currentTime : 0) + 0.3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
playClick,
|
||||
playBuild,
|
||||
playProject,
|
||||
playMilestone,
|
||||
playFanfare,
|
||||
playDriftEnding,
|
||||
playBeaconEnding,
|
||||
startAmbient,
|
||||
updateAmbientPhase,
|
||||
onMuteChanged
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user