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(); + +})();