/** * Web Audio API synthesizer for tactile, Apple-grade acoustic feedback * Requires 0 external audio files, operates instantly with zero latency */ class SoundEngine { constructor() { this.ctx = null; this.muted = localStorage.getItem('quartiles_sound_muted') === 'true'; } initContext() { if (!this.ctx) { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (AudioCtx) { this.ctx = new AudioCtx(); } } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume(); } } isMuted() { return this.muted; } setMuted(muted) { this.muted = !!muted; localStorage.setItem('quartiles_sound_muted', this.muted ? 'true' : 'false'); } toggleMute() { this.setMuted(!this.muted); return this.muted; } // Soft tactile click for tile selection playTileClick() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const now = this.ctx.currentTime; osc.type = 'sine'; osc.frequency.setValueAtTime(620, now); osc.frequency.exponentialRampToValueAtTime(380, now + 0.04); gain.gain.setValueAtTime(0.12, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.04); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.045); } // Gentle acoustic pop when tile is removed playTileRemove() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const now = this.ctx.currentTime; osc.type = 'sine'; osc.frequency.setValueAtTime(320, now); osc.frequency.exponentialRampToValueAtTime(540, now + 0.05); gain.gain.setValueAtTime(0.1, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.055); } // Shuffle swoosh sound playShuffle() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const now = this.ctx.currentTime; for (let i = 0; i < 4; i++) { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const delay = i * 0.025; osc.type = 'triangle'; osc.frequency.setValueAtTime(280 + i * 70, now + delay); osc.frequency.exponentialRampToValueAtTime(160, now + delay + 0.06); gain.gain.setValueAtTime(0.06, now + delay); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.06); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now + delay); osc.stop(now + delay + 0.065); } } // Gentle double boop on invalid submission playError() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const now = this.ctx.currentTime; [0, 0.09].forEach(delay => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(140, now + delay); osc.frequency.exponentialRampToValueAtTime(110, now + delay + 0.08); gain.gain.setValueAtTime(0.08, now + delay); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.08); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now + delay); osc.stop(now + delay + 0.085); }); } // Cheerful chime on valid word playWordValid(tileCount) { if (this.muted) return; this.initContext(); if (!this.ctx) return; const now = this.ctx.currentTime; const baseFreqs = { 1: [523.25, 659.25], // C5, E5 2: [523.25, 659.25, 783.99], // C5, E5, G5 3: [587.33, 739.99, 880.00, 1046.50] // D5, F#5, A5, C6 }; const freqs = baseFreqs[tileCount] || baseFreqs[2]; freqs.forEach((freq, idx) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const delay = idx * 0.045; osc.type = 'sine'; osc.frequency.setValueAtTime(freq, now + delay); gain.gain.setValueAtTime(0.12, now + delay); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.22); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now + delay); osc.stop(now + delay + 0.23); }); } // Sparkling golden fanfare for 4-tile Quartile playQuartileFound() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const now = this.ctx.currentTime; // Major 7th arpeggio: C5, E5, G5, B5, C6 const notes = [523.25, 659.25, 783.99, 987.77, 1046.50]; notes.forEach((freq, idx) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const delay = idx * 0.055; osc.type = 'triangle'; osc.frequency.setValueAtTime(freq, now + delay); gain.gain.setValueAtTime(0.15, now + delay); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now + delay); osc.stop(now + delay + 0.46); }); } // Grand celebration fanfare when all 5 Quartiles are discovered (+40 pts bonus!) playBonusCelebration() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const now = this.ctx.currentTime; // Fanfare chord progression const chords = [ { delay: 0.0, freqs: [523.25, 659.25, 783.99], dur: 0.2 }, { delay: 0.2, freqs: [587.33, 739.99, 880.00], dur: 0.2 }, { delay: 0.4, freqs: [659.25, 830.61, 987.77], dur: 0.25 }, { delay: 0.7, freqs: [783.99, 987.77, 1174.66, 1567.98], dur: 0.8 } ]; chords.forEach(chord => { chord.freqs.forEach(freq => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const startTime = now + chord.delay; osc.type = 'triangle'; osc.frequency.setValueAtTime(freq, startTime); gain.gain.setValueAtTime(0.14, startTime); gain.gain.exponentialRampToValueAtTime(0.001, startTime + chord.dur); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(startTime); osc.stop(startTime + chord.dur + 0.05); }); }); } // Uplifting chord for rank progression (e.g. hitting Expert) playRankUp() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const now = this.ctx.currentTime; const freqs = [440, 554.37, 659.25, 880]; freqs.forEach((freq, idx) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); const delay = idx * 0.08; osc.type = 'sine'; osc.frequency.setValueAtTime(freq, now + delay); gain.gain.setValueAtTime(0.13, now + delay); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.35); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now + delay); osc.stop(now + delay + 0.36); }); } // Triumphant fanfare for a perfect board: rising arpeggio, big chord, sparkles playPerfect() { if (this.muted) return; this.initContext(); if (!this.ctx) return; const now = this.ctx.currentTime; const tone = (freq, start, dur, type = 'triangle', vol = 0.12) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, now + start); gain.gain.setValueAtTime(0.0001, now + start); gain.gain.exponentialRampToValueAtTime(vol, now + start + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, now + start + dur); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now + start); osc.stop(now + start + dur + 0.05); }; // C major arpeggio climbing two octaves [523.25, 659.25, 783.99, 1046.5, 1318.51, 1567.98].forEach((f, i) => tone(f, i * 0.09, 0.3)); // Sustained final chord (C - E - G - C) [523.25, 659.25, 783.99, 1046.5].forEach(f => tone(f, 0.6, 1.6, 'triangle', 0.1)); tone(261.63, 0.6, 1.6, 'sine', 0.12); // High sparkle shimmer [2093, 2637, 3136, 2637, 3520, 4186].forEach((f, i) => tone(f, 0.75 + i * 0.11, 0.25, 'sine', 0.04)); } } export const sound = new SoundEngine();