Files
Learn_System/js/sound.js
T
Maxim Dolgolyov 29aa985504 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>
2026-04-14 19:43:13 +03:00

343 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// LearnSpace — Sound System
// All sounds synthesized via Web Audio API. No external files needed.
// Usage: LS.sfx.play('achievement') LS.sfx.setVolume(0.5) LS.sfx.setEnabled(false)
(function () {
'use strict';
// ── AudioContext (lazy, respects autoplay policy) ──────────────────────────
let _ctx = null;
function _getCtx() {
if (!_ctx) {
_ctx = new (window.AudioContext || window.webkitAudioContext)();
}
if (_ctx.state === 'suspended') _ctx.resume();
return _ctx;
}
// ── Master volume node ─────────────────────────────────────────────────────
let _masterGain = null;
function _getMaster() {
if (!_masterGain) {
_masterGain = _getCtx().createGain();
_masterGain.connect(_getCtx().destination);
}
return _masterGain;
}
// ── Low-level synth: single tone with ADSR envelope ───────────────────────
// freq — Hz
// dur — total duration ms
// type — OscillatorType: 'sine'|'square'|'sawtooth'|'triangle'
// vol — 01 (before master)
// attack — ms
// release — ms
function _tone(freq, dur, type, vol, attack, release) {
type = type || 'sine';
vol = vol || 0.18;
attack = attack || 8;
release = release || Math.min(80, dur * 0.4);
const ctx = _getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(_getMaster());
osc.type = type;
osc.frequency.value = freq;
const now = ctx.currentTime;
const durS = dur / 1000;
const atkS = attack / 1000;
const relS = release / 1000;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(vol, now + atkS);
if (durS - atkS - relS > 0) {
gain.gain.setValueAtTime(vol, now + durS - relS);
}
gain.gain.linearRampToValueAtTime(0, now + durS);
osc.start(now);
osc.stop(now + durS + 0.02);
}
// ── Sequence: play notes with delay offsets ────────────────────────────────
// notes: [ [freq, dur, delayMs, type?, vol?], ... ]
function _seq(notes, defaultType, defaultVol) {
notes.forEach(function (n) {
setTimeout(function () {
_tone(n[0], n[1], n[2] || defaultType || 'sine', n[3] !== undefined ? n[3] : (defaultVol || 0.15));
}, n[4] || 0);
});
}
// ── Noise burst (for 'error' thud) ────────────────────────────────────────
function _noise(dur, vol) {
const ctx = _getCtx();
const bufSize = ctx.sampleRate * (dur / 1000);
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
const gain = ctx.createGain();
const filt = ctx.createBiquadFilter();
src.buffer = buf;
filt.type = 'lowpass';
filt.frequency.value = 400;
src.connect(filt); filt.connect(gain); gain.connect(_getMaster());
const now = ctx.currentTime;
const durS = dur / 1000;
gain.gain.setValueAtTime(vol || 0.12, now);
gain.gain.linearRampToValueAtTime(0, now + durS);
src.start(now);
src.stop(now + durS + 0.02);
}
// ── Sweep (pitch glide) ────────────────────────────────────────────────────
function _sweep(f1, f2, dur, type, vol) {
const ctx = _getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(_getMaster());
osc.type = type || 'sine';
const now = ctx.currentTime;
const durS = dur / 1000;
osc.frequency.setValueAtTime(f1, now);
osc.frequency.linearRampToValueAtTime(f2, now + durS);
gain.gain.setValueAtTime(vol || 0.14, now);
gain.gain.linearRampToValueAtTime(0, now + durS);
osc.start(now);
osc.stop(now + durS + 0.02);
}
// ── Sound definitions ──────────────────────────────────────────────────────
const _sounds = {
// ── UI ────────────────────────────────────────────────────────────────
click: function () {
_tone(1200, 40, 'sine', 0.10, 2, 30);
},
success: function () {
_tone(523, 100, 'sine', 0.14, 8, 60);
setTimeout(function () { _tone(659, 120, 'sine', 0.16, 8, 70); }, 90);
setTimeout(function () { _tone(784, 180, 'sine', 0.18, 8, 100); }, 190);
},
error: function () {
_tone(200, 180, 'sawtooth', 0.14, 5, 120);
_noise(180, 0.06);
setTimeout(function () { _tone(150, 200, 'sawtooth', 0.12, 5, 150); }, 150);
},
notification: function () {
_tone(880, 80, 'sine', 0.13, 5, 50);
setTimeout(function () { _tone(1108, 120, 'sine', 0.15, 5, 80); }, 90);
},
// ── Classroom ─────────────────────────────────────────────────────────
hand_raise: function () {
_tone(660, 100, 'sine', 0.14, 8, 60);
setTimeout(function () { _tone(880, 140, 'sine', 0.17, 8, 90); }, 110);
},
chat_message: function () {
_tone(880, 90, 'triangle', 0.13, 5, 60);
},
user_joined: function () {
_sweep(392, 523, 140, 'sine', 0.13);
setTimeout(function () { _tone(659, 120, 'sine', 0.12, 5, 80); }, 150);
},
user_left: function () {
_sweep(523, 392, 140, 'sine', 0.11);
setTimeout(function () { _tone(330, 120, 'sine', 0.09, 5, 80); }, 150);
},
muted: function () {
_tone(300, 80, 'triangle', 0.13, 5, 60);
_noise(100, 0.05);
},
draw_permitted: function () {
_tone(784, 80, 'sine', 0.13, 5, 50);
setTimeout(function () { _tone(1047, 80, 'sine', 0.14, 5, 50); }, 85);
setTimeout(function () { _tone(1319, 140, 'sine', 0.16, 5, 90); }, 170);
},
lesson_start: function () {
// C4-E4-G4 major chord arpeggio up
_tone(261, 160, 'sine', 0.14, 10, 100);
setTimeout(function () { _tone(330, 160, 'sine', 0.14, 10, 100); }, 130);
setTimeout(function () { _tone(392, 280, 'sine', 0.16, 10, 160); }, 260);
setTimeout(function () { _tone(523, 350, 'sine', 0.18, 10, 220); }, 390);
},
lesson_end: function () {
// G4-E4-C4 descending
_tone(392, 160, 'sine', 0.15, 10, 100);
setTimeout(function () { _tone(330, 160, 'sine', 0.14, 10, 100); }, 140);
setTimeout(function () { _tone(261, 350, 'sine', 0.13, 10, 220); }, 280);
},
// ── Gamification ──────────────────────────────────────────────────────
xp_gain: function () {
_tone(880, 60, 'sine', 0.10, 3, 40);
},
level_up: function () {
// Fanfare: C4-E4-G4-C5
var notes = [
[261, 120, 'sine', 0.15, 0],
[330, 120, 'sine', 0.15, 110],
[392, 120, 'sine', 0.15, 220],
[523, 360, 'sine', 0.20, 330],
];
notes.forEach(function (n) {
setTimeout(function () { _tone(n[0], n[1], n[2], n[3]); }, n[4]);
});
// Harmony on last note
setTimeout(function () { _tone(659, 360, 'sine', 0.12); }, 330);
setTimeout(function () { _tone(784, 360, 'sine', 0.10); }, 330);
},
achievement: function () {
// C5-E5-G5-E5-C6 sparkle
var notes = [
[523, 100, 0],
[659, 100, 95],
[784, 100, 190],
[659, 100, 285],
[1047, 300, 380],
];
notes.forEach(function (n) {
setTimeout(function () { _tone(n[0], n[1], 'sine', 0.16); }, n[2]);
});
// Shimmer
setTimeout(function () { _tone(1319, 200, 'triangle', 0.08); }, 450);
setTimeout(function () { _tone(1047, 300, 'triangle', 0.06); }, 550);
},
coin: function () {
_tone(1319, 50, 'sine', 0.14, 2, 35);
setTimeout(function () { _tone(1047, 80, 'triangle', 0.10, 2, 60); }, 55);
},
streak: function () {
_tone(523, 80, 'sine', 0.13, 5, 55);
setTimeout(function () { _tone(659, 80, 'sine', 0.14, 5, 55); }, 80);
setTimeout(function () { _tone(784, 160, 'sine', 0.16, 5, 100); }, 160);
},
// ── Quiz ──────────────────────────────────────────────────────────────
quiz_start: function () {
// 3-2-1 countdown beeps
_tone(440, 100, 'sine', 0.15, 5, 70);
setTimeout(function () { _tone(440, 100, 'sine', 0.15, 5, 70); }, 700);
setTimeout(function () { _tone(880, 200, 'sine', 0.20, 5, 120); }, 1400);
},
quiz_correct: function () {
_tone(659, 90, 'sine', 0.15, 5, 60);
setTimeout(function () { _tone(880, 160, 'sine', 0.18, 5, 100); }, 95);
},
quiz_wrong: function () {
_tone(220, 200, 'sawtooth', 0.13, 5, 150);
setTimeout(function () { _tone(180, 250, 'sawtooth', 0.10, 5, 200); }, 180);
},
quiz_end: function () {
// C major chord burst
_tone(523, 400, 'sine', 0.14, 10, 250);
_tone(659, 400, 'sine', 0.12, 10, 250);
_tone(784, 400, 'sine', 0.10, 10, 250);
setTimeout(function () { _tone(1047, 500, 'sine', 0.18, 10, 350); }, 350);
},
quiz_tick: function () {
_tone(660, 40, 'square', 0.08, 2, 25);
},
};
// ── Persistence ───────────────────────────────────────────────────────────
var LS_SFX_KEY = 'ls_sfx';
function _load() {
try {
var raw = localStorage.getItem(LS_SFX_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
}
function _save() {
try {
localStorage.setItem(LS_SFX_KEY, JSON.stringify({
enabled : sfx.enabled,
volume : sfx.volume,
prefs : sfx.prefs,
}));
} catch (e) {}
}
// ── Public API ─────────────────────────────────────────────────────────────
var sfx = {
enabled : true,
volume : 0.75,
prefs : {
ui : true,
classroom : true,
gamification : true,
quiz : true,
},
// Category map — which prefs key each sound belongs to
_cats: {
click: 'ui', success: 'ui', error: 'ui', notification: 'ui',
hand_raise: 'classroom', chat_message: 'classroom',
user_joined: 'classroom', user_left: 'classroom',
muted: 'classroom', draw_permitted: 'classroom',
lesson_start: 'classroom', lesson_end: 'classroom',
xp_gain: 'gamification', level_up: 'gamification',
achievement: 'gamification', coin: 'gamification', streak: 'gamification',
quiz_start: 'quiz', quiz_correct: 'quiz', quiz_wrong: 'quiz',
quiz_end: 'quiz', quiz_tick: 'quiz',
},
play: function (name) {
if (!this.enabled) return;
var cat = this._cats[name];
if (cat && !this.prefs[cat]) return;
if (!_sounds[name]) return;
// Apply volume to master gain
if (_masterGain) _masterGain.gain.value = this.volume;
try { _sounds[name](); } catch (e) {}
},
setEnabled: function (v) {
this.enabled = !!v;
_save();
},
setVolume: function (v) {
this.volume = Math.max(0, Math.min(1, parseFloat(v) || 0));
if (_masterGain) _masterGain.gain.value = this.volume;
_save();
},
setPref: function (cat, v) {
if (this.prefs.hasOwnProperty(cat)) {
this.prefs[cat] = !!v;
_save();
}
},
init: function () {
var saved = _load();
if (!saved) return;
if (saved.enabled !== undefined) this.enabled = saved.enabled;
if (saved.volume !== undefined) this.volume = saved.volume;
if (saved.prefs) {
var self = this;
Object.keys(saved.prefs).forEach(function (k) {
if (self.prefs.hasOwnProperty(k)) self.prefs[k] = saved.prefs[k];
});
}
},
};
// Attach to LS namespace
window.LS = window.LS || {};
window.LS.sfx = sfx;
// Auto-init on load
sfx.init();
})();