This commit is contained in:
2026-10-23 22:31:08 -04:00
commit 270b402ccc
31 changed files with 15846 additions and 0 deletions

265
src/engine/audio.js Normal file
View File

@@ -0,0 +1,265 @@
/**
* 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);
});
}
}
export const sound = new SoundEngine();