feat: add sound system (LS.sfx) — synthesized Web Audio API sounds for classroom, gamification, quiz

- New js/sound.js: shared LS.sfx module with 21 synthesized sounds (ADSR envelope, sequences, sweeps, noise)
- Classroom: lesson_start/end, user_joined/left, hand_raise, chat_message, muted, draw_permitted
- Dashboard: achievement, level_up, xp_gain, coin via SSE events
- Live quiz: quiz_start, quiz_end on question launch and results
- Settings panel: global enable toggle + volume slider + localStorage persistence
- Replaces old _crBeep() in classroom.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 19:43:13 +03:00
parent d2bf3aba47
commit 29aa985504
4 changed files with 391 additions and 17 deletions
+36 -17
View File
@@ -3000,6 +3000,7 @@
<script src="/js/whiteboard.js"></script>
<script src="/js/classroom-rtc.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
@@ -3073,6 +3074,11 @@
_crSegActive('crs-vad', _prefs.vadSensitivity);
_crCheckSync('crs-chat-sound', _prefs.chatSound);
_crCheckSync('crs-hand-sound', _prefs.handSound);
if (window.LS && LS.sfx) {
_crCheckSync('crs-sfx-enabled', LS.sfx.enabled);
const vol = document.getElementById('crs-sfx-volume');
if (vol) vol.value = Math.round(LS.sfx.volume * 100);
}
_crSegActive('crs-def-tool', _prefs.defaultTool);
_crSegActive('crs-hand', _prefs.leftHand ? 'left' : 'right');
_crSegActive('crs-stylus', String(_prefs.stylusMultiplier));
@@ -3139,17 +3145,15 @@
}
}
/* Notification sounds (AudioContext beep) */
function _crBeep(freq = 880, dur = 120, vol = 0.18) {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = freq; gain.gain.value = vol;
osc.start(); osc.stop(ctx.currentTime + dur / 1000);
osc.onended = () => ctx.close();
} catch {}
/* Notification sounds — delegated to LS.sfx */
function _crSfx(name) {
try { if (window.LS && LS.sfx) LS.sfx.play(name); } catch {}
}
function crSfxSetEnabled(v) {
if (window.LS && LS.sfx) { LS.sfx.setEnabled(v); if (v) LS.sfx.play('click'); }
}
function crSfxSetVolume(v) {
if (window.LS && LS.sfx) { LS.sfx.setVolume(v / 100); LS.sfx.play('click'); }
}
/* ── state ── */
@@ -3496,12 +3500,13 @@
// when _sessionId comes from JSON (number) vs SSE data (could be string in edge cases).
if (data.type === 'classroom_started') {
onClassroomStarted(data);
_crSfx('lesson_start');
} else if (data.type === 'classroom_ended') {
if (_sessionId == data.sessionId) onClassroomEnded(true); // teacher ended remotely → show toast
if (_sessionId == data.sessionId) { onClassroomEnded(true); _crSfx('lesson_end'); }
} else if (data.type === 'classroom_user_joined') {
if (_sessionId == data.sessionId) onUserJoined(data);
if (_sessionId == data.sessionId) { onUserJoined(data); _crSfx('user_joined'); }
} else if (data.type === 'classroom_user_left') {
if (_sessionId == data.sessionId) onUserLeft(data);
if (_sessionId == data.sessionId) { onUserLeft(data); _crSfx('user_left'); }
} else if (data.type === 'classroom_chat') {
if (_sessionId == data.sessionId) appendChatMessage(data);
} else if (data.type === 'classroom_strokes') {
@@ -3568,6 +3573,7 @@
}
} else if (data.type === 'classroom_muted') {
if (_sessionId == data.sessionId && _rtc) {
_crSfx('muted');
_rtc.forceMute();
if (_participants[_me.id]) _participants[_me.id].micMuted = true;
updateParticipantsList();
@@ -3577,7 +3583,7 @@
'Учитель выключил ваш микрофон');
}
} else if (data.type === 'classroom_draw_permitted') {
if (_sessionId == data.sessionId) enableDrawPermission();
if (_sessionId == data.sessionId) { enableDrawPermission(); _crSfx('draw_permitted'); }
} else if (data.type === 'classroom_draw_revoked') {
if (_sessionId == data.sessionId) disableDrawPermission();
} else if (data.type === 'classroom_reaction') {
@@ -5324,7 +5330,7 @@
updateHandsList();
updateParticipantsList();
if (_prefs.handSound && String(userId) !== String(_me?.id))
_crBeep(660, 150, 0.15);
_crSfx('hand_raise');
}
function onHandLowered(userId) {
@@ -5659,7 +5665,7 @@
_chatUnread++;
const badge = document.getElementById('chat-unread');
if (badge) { badge.textContent = _chatUnread; badge.style.display = 'inline'; }
if (_prefs.chatSound) _crBeep(880, 100, 0.12);
if (_prefs.chatSound) _crSfx('chat_message');
}
const wrap = document.getElementById('cr-messages');
@@ -7676,6 +7682,19 @@
</div>
<div class="cr-sp-section">
<div class="cr-sp-section-label">Звуки</div>
<div class="cr-sp-row">
<div class="cr-sp-row-lbl">Звуки включены</div>
<label class="cr-toggle">
<input type="checkbox" id="crs-sfx-enabled" onchange="crSfxSetEnabled(this.checked)">
<span class="cr-toggle-track"></span>
</label>
</div>
<div class="cr-sp-row" style="gap:10px">
<div class="cr-sp-row-lbl">Громкость</div>
<input type="range" id="crs-sfx-volume" min="0" max="100" value="75"
style="flex:1;accent-color:var(--violet)"
oninput="crSfxSetVolume(this.value)">
</div>
<div class="cr-sp-row">
<div class="cr-sp-row-lbl">Звук при новом сообщении</div>
<label class="cr-toggle">
+10
View File
@@ -1521,6 +1521,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
@@ -3626,6 +3627,15 @@
} else if (ev.type === 'session') {
LS.toast(ev.message, 'info');
if (isTeacher) loadAdminSessions();
} else if (ev.type === 'achievement') {
if (window.LS && LS.sfx) LS.sfx.play('achievement');
} else if (ev.type === 'xp_update') {
if (window.LS && LS.sfx) {
if (ev.levelUp) LS.sfx.play('level_up');
else LS.sfx.play('xp_gain');
}
} else if (ev.type === 'coins') {
if (window.LS && LS.sfx) LS.sfx.play('coin');
}
});
+3
View File
@@ -449,6 +449,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
@@ -795,6 +796,7 @@
updateStudentCounter(0);
renderActiveQuestion(currentQuestion);
renderQuestionList();
if (window.LS && LS.sfx) LS.sfx.play('quiz_start');
} catch (e) {
LS.toast(e.message || 'Ошибка запуска вопроса', 'error');
}
@@ -860,6 +862,7 @@
try {
const data = await LS.api('/api/live/' + activeSession.id + '/results');
renderResults(data, resultsArea);
if (window.LS && LS.sfx) LS.sfx.play('quiz_end');
} catch (e) {
resultsArea.innerHTML = `<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.84rem">${esc(e.message || 'Ошибка загрузки результатов')}</div>`;
}