feat(classroom): мелодия-«вызов на урок» при старте урока у ученика

Короткий нисходящий перезвон (E5-D5-C5-G4, Вестминстер-lite) через Web Audio,
без аудиофайлов: колоколообразный тембр с мягким затуханием. Играет только на
реальном событии SSE classroom_started (не при заходе в середине урока).
AudioContext разблокируется на первом действии пользователя (автоплей-политика).
Отключение: localStorage ls_cr_chime='off'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-01 09:01:28 +03:00
parent 86a08348e0
commit 7d8e2220ff
+52 -1
View File
@@ -1696,6 +1696,57 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
if (bannerEl) bannerEl.classList.remove('open');
}
/* ── Мелодия-«вызов на урок» (Web Audio, без аудиофайлов) ──────────────── */
let _ac = null;
// Браузеры блокируют звук без действия пользователя — «разблокируем» контекст
// на первом клике/нажатии, чтобы перезвон сыграл, когда придёт SSE-событие.
function unlockAudio() {
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) { _ac = _ac || new AC(); if (_ac.state === 'suspended') _ac.resume(); }
} catch {}
window.removeEventListener('pointerdown', unlockAudio);
window.removeEventListener('keydown', unlockAudio);
}
window.addEventListener('pointerdown', unlockAudio);
window.addEventListener('keydown', unlockAudio);
function playLessonChime() {
if (localStorage.getItem('ls_cr_chime') === 'off') return; // запасной выключатель
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
_ac = _ac || new AC();
const ctx = _ac;
if (ctx.state === 'suspended') ctx.resume();
const t0 = ctx.currentTime + 0.05;
const master = ctx.createGain();
master.gain.value = 0.16;
const comp = ctx.createDynamicsCompressor(); // мягкий лимитер от перегруза
master.connect(comp); comp.connect(ctx.destination);
// Нисходящий перезвон (Вестминстер-lite): E5 · D5 · C5 · G4 — «бим-бам-бом-бо-о-ом»
const notes = [659.25, 587.33, 523.25, 392.00];
const step = 0.30;
notes.forEach((freq, i) => {
const start = t0 + i * step;
const dur = (i === notes.length - 1) ? 1.7 : 0.95; // последняя нота длиннее
// Колоколообразный тембр: основной тон + обертоны, экспон. затухание
[[1, 0.5], [2, 0.16], [3, 0.06]].forEach(([mult, amp]) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq * mult;
g.gain.setValueAtTime(0.0001, start);
g.gain.exponentialRampToValueAtTime(amp, start + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(g); g.connect(master);
osc.start(start);
osc.stop(start + dur + 0.05);
});
});
} catch {}
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
@@ -1710,7 +1761,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
}
// Реалтайм: старт/конец урока
connectSSE(d => {
if (d.type === 'classroom_started') { dismissed = false; goLive({ title: d.title, sessionId: d.sessionId }); }
if (d.type === 'classroom_started') { dismissed = false; playLessonChime(); goLive({ title: d.title, sessionId: d.sessionId }); }
else if (d.type === 'classroom_ended') goEnded();
});
});