Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
5bf9ac4a6a feat: procedural sound engine (Web Audio API)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 4s
2026-04-13 02:11:52 -04:00
2 changed files with 313 additions and 0 deletions

View File

@@ -190,6 +190,7 @@ Events Resolved: <span id="st-resolved">0</span>
<div id="log-entries"></div>
</div>
<div id="save-toast" role="status" aria-live="polite" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
<button id="mute-btn" onclick="toggleMute()" aria-label="Sound on, click to mute" title="Toggle sound (M)" style="position:fixed;bottom:16px;right:52px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:13px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s">🔊</button>
<div id="help-btn" onclick="toggleHelp()" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
<div id="help-overlay" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
@@ -224,6 +225,7 @@ The light is on. The room is empty."
<button aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
</div>
<script src="js/sound.js"></script>
<script src="js/data.js"></script>
<script src="js/utils.js"></script>
<script src="js/strategy.js"></script>

311
js/sound.js Normal file
View File

@@ -0,0 +1,311 @@
// === SOUND DESIGN ENGINE (#57) ===
// Procedural audio via Web Audio API — no external files needed.
// All sounds generated at runtime using oscillators, noise, and filters.
(function () {
'use strict';
let ctx = null;
let masterGain = null;
let ambientNode = null;
let ambientGain = null;
let _initialized = false;
let _currentAmbientPhase = 0;
// Lazily init AudioContext on first user interaction (browser policy)
function ensureCtx() {
if (_initialized) return;
if (typeof window.AudioContext === 'undefined' && typeof window.webkitAudioContext === 'undefined') return;
try {
ctx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = ctx.createGain();
masterGain.gain.value = 0.3;
masterGain.connect(ctx.destination);
_initialized = true;
} catch (e) {
// Audio not available — fail silently
}
}
function isMuted() {
return typeof _muted !== 'undefined' && _muted;
}
function playNote(freq, duration, type, vol, detune) {
ensureCtx();
if (!ctx || isMuted()) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type || 'sine';
osc.frequency.value = freq;
if (detune) osc.detune.value = detune;
gain.gain.setValueAtTime((vol || 0.15), ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(masterGain);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
}
function playNoise(duration, vol, filterFreq) {
ensureCtx();
if (!ctx || isMuted()) return;
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(vol || 0.05, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = filterFreq || 2000;
filter.Q.value = 1;
source.connect(filter);
filter.connect(gain);
gain.connect(masterGain);
source.start(ctx.currentTime);
source.stop(ctx.currentTime + duration);
}
// === PUBLIC SOUND API ===
// Click / type sound — short mechanical keypress
function playClick() {
ensureCtx();
if (!ctx || isMuted()) return;
// Two quick notes to simulate a key press
const base = 600 + Math.random() * 400;
playNote(base, 0.04, 'square', 0.08);
playNote(base * 1.5, 0.03, 'sine', 0.04);
// Tiny noise burst for the mechanical feel
playNoise(0.02, 0.03, 3000 + Math.random() * 2000);
}
// Auto-tick sound — softer than manual click
function playAutoTick() {
ensureCtx();
if (!ctx || isMuted()) return;
playNote(800 + Math.random() * 200, 0.02, 'sine', 0.03);
}
// Building purchase — satisfying thud + chime
function playBuild() {
ensureCtx();
if (!ctx || isMuted()) return;
// Low thud
playNote(120, 0.12, 'sine', 0.12);
// High chime
playNote(880, 0.2, 'sine', 0.06);
setTimeout(() => playNote(1100, 0.15, 'sine', 0.04), 50);
}
// Project complete — ascending 3-note chime
function playProject() {
ensureCtx();
if (!ctx || isMuted()) return;
const notes = [523, 659, 784]; // C5, E5, G5
notes.forEach((f, i) => {
setTimeout(() => playNote(f, 0.25, 'sine', 0.08), i * 80);
});
}
// Milestone chime — bright ascending arpeggio
function playMilestone() {
ensureCtx();
if (!ctx || isMuted()) return;
const notes = [523, 659, 784, 1047]; // C5 E5 G5 C6
notes.forEach((f, i) => {
setTimeout(() => {
playNote(f, 0.3, 'sine', 0.1);
playNote(f * 1.005, 0.3, 'sine', 0.04); // slight detune shimmer
}, i * 70);
});
}
// Phase transition fanfare — grand ascending sweep
function playFanfare() {
ensureCtx();
if (!ctx || isMuted()) return;
const notes = [262, 330, 392, 523, 660, 784, 1047, 1318]; // C major scale up
notes.forEach((f, i) => {
setTimeout(() => {
playNote(f, 0.5, 'sine', 0.07);
playNote(f * 2, 0.3, 'triangle', 0.03);
}, i * 50);
});
// Add noise burst
setTimeout(() => playNoise(0.3, 0.04, 1500), 200);
}
// Sprint activate — power-up whoosh
function playSprint() {
ensureCtx();
if (!ctx || isMuted()) return;
// Rising sweep
ensureCtx();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.3);
gain.gain.setValueAtTime(0.06, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
osc.connect(gain);
gain.connect(masterGain);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.4);
}
// Event / error — dissonant warning
function playEvent() {
ensureCtx();
if (!ctx || isMuted()) return;
playNote(220, 0.3, 'square', 0.08);
playNote(233, 0.3, 'square', 0.06); // Minor 2nd — dissonance
}
// Resolve event — resolved chord
function playResolve() {
ensureCtx();
if (!ctx || isMuted()) return;
playNote(440, 0.2, 'sine', 0.06);
playNote(554, 0.2, 'sine', 0.05); // Major third
setTimeout(() => playNote(660, 0.3, 'sine', 0.06), 100);
}
// Drift ending — descending dissonant tones
function playDriftEnding() {
ensureCtx();
if (!ctx || isMuted()) return;
const notes = [440, 370, 311, 261, 220];
notes.forEach((f, i) => {
setTimeout(() => {
playNote(f, 0.8, 'sawtooth', 0.05);
playNote(f * 0.99, 0.8, 'sawtooth', 0.03); // beat frequency = dread
}, i * 300);
});
}
// Beacon ending — warm major chord swell
function playBeaconEnding() {
ensureCtx();
if (!ctx || isMuted()) return;
const chord = [262, 330, 392, 523, 660]; // C major with extra warmth
chord.forEach((f, i) => {
setTimeout(() => {
playNote(f, 2.0, 'sine', 0.06);
playNote(f * 2, 1.5, 'triangle', 0.03);
}, i * 150);
});
}
// === BACKGROUND AMBIENT ===
// Continuous drone that shifts with game phase
function startAmbient() {
ensureCtx();
if (!ctx || ambientNode) return;
try {
// Create a low-frequency drone
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
ambientGain = ctx.createGain();
osc1.type = 'sine';
osc1.frequency.value = 55; // A1 — deep hum
osc2.type = 'sine';
osc2.frequency.value = 82.4; // E2 — fifth above
// LFO for subtle movement
lfo.type = 'sine';
lfo.frequency.value = 0.15;
lfoGain.gain.value = 3;
lfo.connect(lfoGain);
lfoGain.connect(osc1.frequency);
ambientGain.gain.value = 0.02; // Very quiet background
osc1.connect(ambientGain);
osc2.connect(ambientGain);
ambientGain.connect(masterGain);
osc1.start();
osc2.start();
lfo.start();
ambientNode = { osc1, osc2, lfo, lfoGain };
_currentAmbientPhase = 0;
} catch (e) {
// Ambient failed — ok to skip
}
}
function updateAmbientPhase(phase) {
if (!ambientNode || phase === _currentAmbientPhase) return;
_currentAmbientPhase = phase;
try {
// Shift ambient frequencies based on phase — higher phases = richer harmonics
const baseFreqs = [
[55, 82.4], // Phase 1: simple fifth
[55, 82.4, 110], // Phase 2: add octave
[65, 98, 131], // Phase 3: shift up, richer
[55, 82.4, 110, 165], // Phase 4: full chord
[65, 82.4, 110, 131, 165], // Phase 5: lush
];
const freqs = baseFreqs[Math.min(phase - 1, baseFreqs.length - 1)];
if (ambientNode.osc1) {
ambientNode.osc1.frequency.setTargetAtTime(freqs[0], ctx.currentTime, 1);
}
if (ambientNode.osc2) {
ambientNode.osc2.frequency.setTargetAtTime(freqs[1], ctx.currentTime, 1);
}
// Slightly increase volume with phase
if (ambientGain) {
ambientGain.gain.setTargetAtTime(Math.min(0.04, 0.02 + phase * 0.003), ctx.currentTime, 0.5);
}
} catch (e) { /* ok */ }
}
function stopAmbient() {
if (!ambientNode) return;
try {
ambientNode.osc1.stop();
ambientNode.osc2.stop();
ambientNode.lfo.stop();
} catch (e) { /* already stopped */ }
ambientNode = null;
}
// === EXPORT TO WINDOW ===
window.SoundEngine = {
playClick: playClick,
playAutoTick: playAutoTick,
playBuild: playBuild,
playProject: playProject,
playMilestone: playMilestone,
playFanfare: playFanfare,
playSprint: playSprint,
playEvent: playEvent,
playResolve: playResolve,
playDriftEnding: playDriftEnding,
playBeaconEnding: playBeaconEnding,
startAmbient: startAmbient,
updateAmbientPhase: updateAmbientPhase,
stopAmbient: stopAmbient
};
// Auto-init ambient on first interaction
document.addEventListener('click', function initAmbientOnce() {
startAmbient();
document.removeEventListener('click', initAmbientOnce);
}, { once: true });
})();