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>
This commit is contained in:
Maxim Dolgolyov
2026-06-01 09:11:44 +03:00
parent 63ceeaabc2
commit ec2a207fb8
3 changed files with 107 additions and 75 deletions
+59 -13
View File
@@ -191,11 +191,31 @@
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);
// «Вызов на урок» — полный вестминстерский бой: 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
@@ -335,17 +355,19 @@
function _save() {
try {
localStorage.setItem(LS_SFX_KEY, JSON.stringify({
enabled : sfx.enabled,
volume : sfx.volume,
prefs : sfx.prefs,
enabled : sfx.enabled,
volume : sfx.volume,
lessonCall : sfx.lessonCall,
prefs : sfx.prefs,
}));
} catch (e) {}
}
// ── Public API ─────────────────────────────────────────────────────────────
var sfx = {
enabled : true,
volume : 0.75,
enabled : true,
volume : 0.75,
lessonCall : true, // мелодия-«вызов на урок» — отдельный тумблер
prefs : {
ui : true,
navigation : true,
@@ -362,7 +384,7 @@
hand_raise: 'classroom', chat_message: 'classroom',
user_joined: 'classroom', user_left: 'classroom',
muted: 'classroom', draw_permitted: 'classroom',
lesson_start: 'classroom', lesson_end: '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',
@@ -373,6 +395,7 @@
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;
@@ -381,6 +404,13 @@
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();
@@ -392,6 +422,11 @@
_save();
},
setLessonCall: function (v) {
this.lessonCall = !!v;
_save();
},
setPref: function (cat, v) {
if (this.prefs.hasOwnProperty(cat)) {
this.prefs[cat] = !!v;
@@ -402,8 +437,9 @@
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.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) {
@@ -420,4 +456,14 @@
// 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);
})();