From 7d8e2220ffa6ea9179cd099e681231133dfc57ca Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Mon, 1 Jun 2026 09:01:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(classroom):=20=D0=BC=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=8F-=C2=AB=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=83=D1=80=D0=BE=D0=BA=C2=BB=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5=20=D1=83=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=20=D1=83=20=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Короткий нисходящий перезвон (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) --- js/api.js | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/js/api.js b/js/api.js index 0728cef..edd5631 100644 --- a/js/api.js +++ b/js/api.js @@ -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(); }); });