feat: add sound system (LS.sfx) — synthesized Web Audio API sounds for classroom, gamification, quiz

- New js/sound.js: shared LS.sfx module with 21 synthesized sounds (ADSR envelope, sequences, sweeps, noise)
- Classroom: lesson_start/end, user_joined/left, hand_raise, chat_message, muted, draw_permitted
- Dashboard: achievement, level_up, xp_gain, coin via SSE events
- Live quiz: quiz_start, quiz_end on question launch and results
- Settings panel: global enable toggle + volume slider + localStorage persistence
- Replaces old _crBeep() in classroom.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 19:43:13 +03:00
parent d2bf3aba47
commit 29aa985504
4 changed files with 391 additions and 17 deletions
+342
View File
@@ -0,0 +1,342 @@
// LearnSpace — Sound System
// All sounds synthesized via Web Audio API. No external files needed.
// Usage: LS.sfx.play('achievement') LS.sfx.setVolume(0.5) LS.sfx.setEnabled(false)
(function () {
'use strict';
// ── AudioContext (lazy, respects autoplay policy) ──────────────────────────
let _ctx = null;
function _getCtx() {
if (!_ctx) {
_ctx = new (window.AudioContext || window.webkitAudioContext)();
}
if (_ctx.state === 'suspended') _ctx.resume();
return _ctx;
}
// ── Master volume node ─────────────────────────────────────────────────────
let _masterGain = null;
function _getMaster() {
if (!_masterGain) {
_masterGain = _getCtx().createGain();
_masterGain.connect(_getCtx().destination);
}
return _masterGain;
}
// ── Low-level synth: single tone with ADSR envelope ───────────────────────
// freq — Hz
// dur — total duration ms
// type — OscillatorType: 'sine'|'square'|'sawtooth'|'triangle'
// vol — 01 (before master)
// attack — ms
// release — ms
function _tone(freq, dur, type, vol, attack, release) {
type = type || 'sine';
vol = vol || 0.18;
attack = attack || 8;
release = release || Math.min(80, dur * 0.4);
const ctx = _getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(_getMaster());
osc.type = type;
osc.frequency.value = freq;
const now = ctx.currentTime;
const durS = dur / 1000;
const atkS = attack / 1000;
const relS = release / 1000;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(vol, now + atkS);
if (durS - atkS - relS > 0) {
gain.gain.setValueAtTime(vol, now + durS - relS);
}
gain.gain.linearRampToValueAtTime(0, now + durS);
osc.start(now);
osc.stop(now + durS + 0.02);
}
// ── Sequence: play notes with delay offsets ────────────────────────────────
// notes: [ [freq, dur, delayMs, type?, vol?], ... ]
function _seq(notes, defaultType, defaultVol) {
notes.forEach(function (n) {
setTimeout(function () {
_tone(n[0], n[1], n[2] || defaultType || 'sine', n[3] !== undefined ? n[3] : (defaultVol || 0.15));
}, n[4] || 0);
});
}
// ── Noise burst (for 'error' thud) ────────────────────────────────────────
function _noise(dur, vol) {
const ctx = _getCtx();
const bufSize = ctx.sampleRate * (dur / 1000);
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
const gain = ctx.createGain();
const filt = ctx.createBiquadFilter();
src.buffer = buf;
filt.type = 'lowpass';
filt.frequency.value = 400;
src.connect(filt); filt.connect(gain); gain.connect(_getMaster());
const now = ctx.currentTime;
const durS = dur / 1000;
gain.gain.setValueAtTime(vol || 0.12, now);
gain.gain.linearRampToValueAtTime(0, now + durS);
src.start(now);
src.stop(now + durS + 0.02);
}
// ── Sweep (pitch glide) ────────────────────────────────────────────────────
function _sweep(f1, f2, dur, type, vol) {
const ctx = _getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(_getMaster());
osc.type = type || 'sine';
const now = ctx.currentTime;
const durS = dur / 1000;
osc.frequency.setValueAtTime(f1, now);
osc.frequency.linearRampToValueAtTime(f2, now + durS);
gain.gain.setValueAtTime(vol || 0.14, now);
gain.gain.linearRampToValueAtTime(0, now + durS);
osc.start(now);
osc.stop(now + durS + 0.02);
}
// ── Sound definitions ──────────────────────────────────────────────────────
const _sounds = {
// ── UI ────────────────────────────────────────────────────────────────
click: function () {
_tone(1200, 40, 'sine', 0.10, 2, 30);
},
success: function () {
_tone(523, 100, 'sine', 0.14, 8, 60);
setTimeout(function () { _tone(659, 120, 'sine', 0.16, 8, 70); }, 90);
setTimeout(function () { _tone(784, 180, 'sine', 0.18, 8, 100); }, 190);
},
error: function () {
_tone(200, 180, 'sawtooth', 0.14, 5, 120);
_noise(180, 0.06);
setTimeout(function () { _tone(150, 200, 'sawtooth', 0.12, 5, 150); }, 150);
},
notification: function () {
_tone(880, 80, 'sine', 0.13, 5, 50);
setTimeout(function () { _tone(1108, 120, 'sine', 0.15, 5, 80); }, 90);
},
// ── Classroom ─────────────────────────────────────────────────────────
hand_raise: function () {
_tone(660, 100, 'sine', 0.14, 8, 60);
setTimeout(function () { _tone(880, 140, 'sine', 0.17, 8, 90); }, 110);
},
chat_message: function () {
_tone(880, 90, 'triangle', 0.13, 5, 60);
},
user_joined: function () {
_sweep(392, 523, 140, 'sine', 0.13);
setTimeout(function () { _tone(659, 120, 'sine', 0.12, 5, 80); }, 150);
},
user_left: function () {
_sweep(523, 392, 140, 'sine', 0.11);
setTimeout(function () { _tone(330, 120, 'sine', 0.09, 5, 80); }, 150);
},
muted: function () {
_tone(300, 80, 'triangle', 0.13, 5, 60);
_noise(100, 0.05);
},
draw_permitted: function () {
_tone(784, 80, 'sine', 0.13, 5, 50);
setTimeout(function () { _tone(1047, 80, 'sine', 0.14, 5, 50); }, 85);
setTimeout(function () { _tone(1319, 140, 'sine', 0.16, 5, 90); }, 170);
},
lesson_start: function () {
// C4-E4-G4 major chord arpeggio up
_tone(261, 160, 'sine', 0.14, 10, 100);
setTimeout(function () { _tone(330, 160, 'sine', 0.14, 10, 100); }, 130);
setTimeout(function () { _tone(392, 280, 'sine', 0.16, 10, 160); }, 260);
setTimeout(function () { _tone(523, 350, 'sine', 0.18, 10, 220); }, 390);
},
lesson_end: function () {
// G4-E4-C4 descending
_tone(392, 160, 'sine', 0.15, 10, 100);
setTimeout(function () { _tone(330, 160, 'sine', 0.14, 10, 100); }, 140);
setTimeout(function () { _tone(261, 350, 'sine', 0.13, 10, 220); }, 280);
},
// ── Gamification ──────────────────────────────────────────────────────
xp_gain: function () {
_tone(880, 60, 'sine', 0.10, 3, 40);
},
level_up: function () {
// Fanfare: C4-E4-G4-C5
var notes = [
[261, 120, 'sine', 0.15, 0],
[330, 120, 'sine', 0.15, 110],
[392, 120, 'sine', 0.15, 220],
[523, 360, 'sine', 0.20, 330],
];
notes.forEach(function (n) {
setTimeout(function () { _tone(n[0], n[1], n[2], n[3]); }, n[4]);
});
// Harmony on last note
setTimeout(function () { _tone(659, 360, 'sine', 0.12); }, 330);
setTimeout(function () { _tone(784, 360, 'sine', 0.10); }, 330);
},
achievement: function () {
// C5-E5-G5-E5-C6 sparkle
var notes = [
[523, 100, 0],
[659, 100, 95],
[784, 100, 190],
[659, 100, 285],
[1047, 300, 380],
];
notes.forEach(function (n) {
setTimeout(function () { _tone(n[0], n[1], 'sine', 0.16); }, n[2]);
});
// Shimmer
setTimeout(function () { _tone(1319, 200, 'triangle', 0.08); }, 450);
setTimeout(function () { _tone(1047, 300, 'triangle', 0.06); }, 550);
},
coin: function () {
_tone(1319, 50, 'sine', 0.14, 2, 35);
setTimeout(function () { _tone(1047, 80, 'triangle', 0.10, 2, 60); }, 55);
},
streak: function () {
_tone(523, 80, 'sine', 0.13, 5, 55);
setTimeout(function () { _tone(659, 80, 'sine', 0.14, 5, 55); }, 80);
setTimeout(function () { _tone(784, 160, 'sine', 0.16, 5, 100); }, 160);
},
// ── Quiz ──────────────────────────────────────────────────────────────
quiz_start: function () {
// 3-2-1 countdown beeps
_tone(440, 100, 'sine', 0.15, 5, 70);
setTimeout(function () { _tone(440, 100, 'sine', 0.15, 5, 70); }, 700);
setTimeout(function () { _tone(880, 200, 'sine', 0.20, 5, 120); }, 1400);
},
quiz_correct: function () {
_tone(659, 90, 'sine', 0.15, 5, 60);
setTimeout(function () { _tone(880, 160, 'sine', 0.18, 5, 100); }, 95);
},
quiz_wrong: function () {
_tone(220, 200, 'sawtooth', 0.13, 5, 150);
setTimeout(function () { _tone(180, 250, 'sawtooth', 0.10, 5, 200); }, 180);
},
quiz_end: function () {
// C major chord burst
_tone(523, 400, 'sine', 0.14, 10, 250);
_tone(659, 400, 'sine', 0.12, 10, 250);
_tone(784, 400, 'sine', 0.10, 10, 250);
setTimeout(function () { _tone(1047, 500, 'sine', 0.18, 10, 350); }, 350);
},
quiz_tick: function () {
_tone(660, 40, 'square', 0.08, 2, 25);
},
};
// ── Persistence ───────────────────────────────────────────────────────────
var LS_SFX_KEY = 'ls_sfx';
function _load() {
try {
var raw = localStorage.getItem(LS_SFX_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
}
function _save() {
try {
localStorage.setItem(LS_SFX_KEY, JSON.stringify({
enabled : sfx.enabled,
volume : sfx.volume,
prefs : sfx.prefs,
}));
} catch (e) {}
}
// ── Public API ─────────────────────────────────────────────────────────────
var sfx = {
enabled : true,
volume : 0.75,
prefs : {
ui : true,
classroom : true,
gamification : true,
quiz : true,
},
// Category map — which prefs key each sound belongs to
_cats: {
click: 'ui', success: 'ui', error: 'ui', notification: 'ui',
hand_raise: 'classroom', chat_message: 'classroom',
user_joined: 'classroom', user_left: 'classroom',
muted: 'classroom', draw_permitted: 'classroom',
lesson_start: 'classroom', lesson_end: 'classroom',
xp_gain: 'gamification', level_up: 'gamification',
achievement: 'gamification', coin: 'gamification', streak: 'gamification',
quiz_start: 'quiz', quiz_correct: 'quiz', quiz_wrong: 'quiz',
quiz_end: 'quiz', quiz_tick: 'quiz',
},
play: function (name) {
if (!this.enabled) return;
var cat = this._cats[name];
if (cat && !this.prefs[cat]) return;
if (!_sounds[name]) return;
// Apply volume to master gain
if (_masterGain) _masterGain.gain.value = this.volume;
try { _sounds[name](); } catch (e) {}
},
setEnabled: function (v) {
this.enabled = !!v;
_save();
},
setVolume: function (v) {
this.volume = Math.max(0, Math.min(1, parseFloat(v) || 0));
if (_masterGain) _masterGain.gain.value = this.volume;
_save();
},
setPref: function (cat, v) {
if (this.prefs.hasOwnProperty(cat)) {
this.prefs[cat] = !!v;
_save();
}
},
init: function () {
var saved = _load();
if (!saved) return;
if (saved.enabled !== undefined) this.enabled = saved.enabled;
if (saved.volume !== undefined) this.volume = saved.volume;
if (saved.prefs) {
var self = this;
Object.keys(saved.prefs).forEach(function (k) {
if (self.prefs.hasOwnProperty(k)) self.prefs[k] = saved.prefs[k];
});
}
},
};
// Attach to LS namespace
window.LS = window.LS || {};
window.LS.sfx = sfx;
// Auto-init on load
sfx.init();
})();