298 lines
8.3 KiB
JavaScript
298 lines
8.3 KiB
JavaScript
/**
|
|
* 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();
|