Implements all required sound functions with no audio files: - playClick() — mechanical keyboard sound (noise burst + square wave) - playBuild() — purchase thud + chime overlay - playProject() — ascending three-note chime (C5-E5-G5) - playMilestone() — bright four-note arpeggio (C5-E5-G5-C6) - playFanfare() — 8-note scale + final chord for phase transitions - playDriftEnding() — descending dissonant sawtooth sweep - playBeaconEnding() — warm five-note chord with harmonics - startAmbient() / updateAmbientPhase() — continuous drone with LFO All sounds respect the existing _muted toggle. Script loads before engine.js.
402 lines
13 KiB
JavaScript
402 lines
13 KiB
JavaScript
// ============================================================
|
|
// 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
|
|
};
|
|
})();
|