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:
+36
-17
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user