// 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); }, modal_open: function () { // Soft sweep in — airy whoosh up _sweep(300, 520, 180, 'sine', 0.09); setTimeout(function () { _tone(660, 100, 'sine', 0.08, 5, 70); }, 160); }, modal_close: function () { // Sweep down — gentle dismiss _sweep(520, 300, 150, 'sine', 0.08); }, tab_switch: function () { // Double-click feel _tone(900, 35, 'sine', 0.09, 2, 25); setTimeout(function () { _tone(1100, 35, 'sine', 0.09, 2, 25); }, 45); }, delete: function () { // Descending soft thud _sweep(400, 180, 120, 'triangle', 0.10); _noise(80, 0.04); }, // ── Navigation ──────────────────────────────────────────────────────── page_enter: function () { // Subtle arrival chime _tone(784, 80, 'sine', 0.08, 5, 55); setTimeout(function () { _tone(1047, 120, 'sine', 0.07, 5, 80); }, 75); }, section_reveal: function () { // Very quiet rising whoosh _sweep(250, 420, 200, 'sine', 0.07); }, // ── 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 () { // «Вызов на урок» — полный вестминстерский бой: 5 фраз по 4 ноты // (G4 · C5 · D5 · E5). Колоколообразно: тон + обертон-октава. var G4 = 392, C5 = 523, D5 = 587, E5 = 659; var phrases = [ [E5, D5, C5, G4], [C5, E5, D5, G4], [C5, D5, E5, C5], [E5, C5, D5, G4], [G4, D5, E5, C5] ]; var step = 320, gap = 220, t = 0; phrases.forEach(function (ph, pi) { ph.forEach(function (freq, ni) { var lastOfPhrase = ni === ph.length - 1; var lastOverall = pi === phrases.length - 1 && lastOfPhrase; var dur = lastOverall ? 2000 : (lastOfPhrase ? 1100 : 760); // концы фраз длиннее (function (f, when, d) { setTimeout(function () { _tone(f, d, 'sine', 0.16, 10, Math.round(d * 0.7)); // основной тон _tone(f * 2, d, 'sine', 0.05, 10, Math.round(d * 0.7)); // обертон-октава }, when); })(freq, t, dur); t += step + (lastOfPhrase ? gap : 0); }); }); }, 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); }, timer_warning: function () { // Urgent triple beep — last 10 seconds _tone(880, 60, 'square', 0.12, 2, 40); setTimeout(function () { _tone(880, 60, 'square', 0.13, 2, 40); }, 200); setTimeout(function () { _tone(1108, 100, 'square', 0.15, 2, 70); }, 400); }, wb_clear: function () { // Erasing noise sweep _noise(220, 0.08); _sweep(600, 200, 220, 'triangle', 0.06); }, file_shared: function () { // Paper rustle + chime _noise(80, 0.05); setTimeout(function () { _tone(784, 100, 'sine', 0.12, 5, 70); }, 60); setTimeout(function () { _tone(1047, 120, 'sine', 0.10, 5, 80); }, 160); }, // ── 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); }, challenge_complete: function () { // Heroic: E4-G4-B4-E5 arpeggio _tone(330, 140, 'sine', 0.15, 8, 90); setTimeout(function () { _tone(392, 140, 'sine', 0.16, 8, 90); }, 130); setTimeout(function () { _tone(494, 140, 'sine', 0.17, 8, 90); }, 260); setTimeout(function () { _tone(659, 400, 'sine', 0.20, 8, 260); }, 390); // Sparkle on top setTimeout(function () { _tone(1319, 200, 'triangle', 0.07); }, 450); }, daily_login: function () { // Morning chime: G-A-B _tone(392, 120, 'sine', 0.12, 8, 80); setTimeout(function () { _tone(440, 120, 'sine', 0.13, 8, 80); }, 110); setTimeout(function () { _tone(494, 250, 'sine', 0.15, 8, 160); }, 220); }, // ── 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); }, time_up: function () { // Buzzer — descending sawtooth alarm _tone(440, 120, 'sawtooth', 0.14, 3, 80); _noise(120, 0.06); setTimeout(function () { _tone(330, 160, 'sawtooth', 0.12, 3, 110); }, 110); setTimeout(function () { _tone(220, 220, 'sawtooth', 0.10, 3, 160); }, 240); }, quiz_bonus: function () { // Golden sparkle — high twinkling _tone(1047, 60, 'sine', 0.14, 2, 45); setTimeout(function () { _tone(1319, 60, 'sine', 0.16, 2, 45); }, 65); setTimeout(function () { _tone(1568, 60, 'sine', 0.17, 2, 45); }, 130); setTimeout(function () { _tone(2093, 180, 'sine', 0.18, 2, 120); }, 195); }, }; // ── 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, lessonCall : sfx.lessonCall, prefs : sfx.prefs, })); } catch (e) {} } // ── Public API ───────────────────────────────────────────────────────────── var sfx = { enabled : true, volume : 0.75, lessonCall : true, // мелодия-«вызов на урок» — отдельный тумблер prefs : { ui : true, navigation : 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', modal_open: 'ui', modal_close: 'ui', tab_switch: 'ui', delete: 'ui', page_enter: 'navigation', section_reveal: 'navigation', hand_raise: 'classroom', chat_message: 'classroom', user_joined: 'classroom', user_left: 'classroom', muted: 'classroom', draw_permitted: 'classroom', lesson_end: 'classroom', timer_warning: 'classroom', wb_clear: 'classroom', file_shared: 'classroom', xp_gain: 'gamification', level_up: 'gamification', achievement: 'gamification', coin: 'gamification', streak: 'gamification', challenge_complete: 'gamification', daily_login: 'gamification', quiz_start: 'quiz', quiz_correct: 'quiz', quiz_wrong: 'quiz', quiz_end: 'quiz', quiz_tick: 'quiz', time_up: 'quiz', quiz_bonus: 'quiz', }, play: function (name) { if (!this.enabled) return; if (name === 'lesson_start' && !this.lessonCall) 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) {} }, // Проиграть звук в обход тумблеров (для кнопки «прослушать» в профиле) preview: function (name) { if (!_sounds[name]) return; 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(); }, setLessonCall: function (v) { this.lessonCall = !!v; _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.lessonCall !== undefined) this.lessonCall = saved.lessonCall; 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(); // Разблокировать AudioContext после первого действия пользователя (политика автоплея), // чтобы звук, пришедший по событию (напр. «вызов на урок»), смог прозвучать. function _unlock() { try { _getCtx(); } catch (e) {} window.removeEventListener('pointerdown', _unlock, true); window.removeEventListener('keydown', _unlock, true); } window.addEventListener('pointerdown', _unlock, true); window.addEventListener('keydown', _unlock, true); })();