Files
Learn_System/js/sound.js
Maxim Dolgolyov ec2a207fb8 feat(classroom): тумблер «Вызов на урок» в профиле + интеграция мелодии в LS.sfx
Мелодию-вызов перевёл с кастомного Web Audio на общий движок звуков LS.sfx:
- длинный вестминстерский бой теперь в sound.js (звук lesson_start);
- api.js лениво подгружает sound.js на любой странице и играет lesson_start
  по SSE classroom_started (вместо собственного синтезатора);
- отдельный pref lessonCall + тумблер «Вызов на урок» и кнопка прослушивания
  в профиле (Настройки → Звуки); уважает мастер-тумблер и громкость;
- lesson_start выведен из категории classroom (управляется своим тумблером);
- разблокировка AudioContext по первому жесту перенесена в sound.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:11:44 +03:00

470 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
},
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);
})();