diff --git a/frontend/classroom.html b/frontend/classroom.html
index c6ace3e..a81478e 100644
--- a/frontend/classroom.html
+++ b/frontend/classroom.html
@@ -3000,6 +3000,7 @@
+
+
@@ -3626,6 +3627,15 @@
} else if (ev.type === 'session') {
LS.toast(ev.message, 'info');
if (isTeacher) loadAdminSessions();
+ } else if (ev.type === 'achievement') {
+ if (window.LS && LS.sfx) LS.sfx.play('achievement');
+ } else if (ev.type === 'xp_update') {
+ if (window.LS && LS.sfx) {
+ if (ev.levelUp) LS.sfx.play('level_up');
+ else LS.sfx.play('xp_gain');
+ }
+ } else if (ev.type === 'coins') {
+ if (window.LS && LS.sfx) LS.sfx.play('coin');
}
});
diff --git a/frontend/live-quiz.html b/frontend/live-quiz.html
index 1e90618..fafb698 100644
--- a/frontend/live-quiz.html
+++ b/frontend/live-quiz.html
@@ -449,6 +449,7 @@
+
@@ -795,6 +796,7 @@
updateStudentCounter(0);
renderActiveQuestion(currentQuestion);
renderQuestionList();
+ if (window.LS && LS.sfx) LS.sfx.play('quiz_start');
} catch (e) {
LS.toast(e.message || 'Ошибка запуска вопроса', 'error');
}
@@ -860,6 +862,7 @@
try {
const data = await LS.api('/api/live/' + activeSession.id + '/results');
renderResults(data, resultsArea);
+ if (window.LS && LS.sfx) LS.sfx.play('quiz_end');
} catch (e) {
resultsArea.innerHTML = `
${esc(e.message || 'Ошибка загрузки результатов')}
`;
}
diff --git a/js/sound.js b/js/sound.js
new file mode 100644
index 0000000..67f7ff1
--- /dev/null
+++ b/js/sound.js
@@ -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 — 0–1 (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();
+
+})();