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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user