Files
Learn_System/js/sound.js
T
Maxim Dolgolyov f3c9ab860e feat: sound system — 12 new sounds + navigation category; dashboard FAB widget button
Sounds:
- UI: modal_open, modal_close, tab_switch, delete
- Navigation (new category): page_enter, section_reveal
- Classroom: timer_warning, wb_clear, file_shared
- Gamification: challenge_complete, daily_login
- Quiz: time_up, quiz_bonus

Dashboard:
- Widget configurator moved from header to fixed FAB (bottom-right)
  no longer pushed off-screen by wide sidebar

Profile settings:
- Added Navigation category toggle
- Expanded preview section: 12 test buttons covering all categories

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:26:04 +03:00

424 lines
18 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);
},
modal_open: function () {
// Soft sweep in — airy whoosh up
_sweep(300, 520, 180, 'sine', 0.09);
setTimeout(function () { _tone(660, 100, 'sine', 0.08, 5, 70); }, 160);
},
modal_close: function () {
// Sweep down — gentle dismiss
_sweep(520, 300, 150, 'sine', 0.08);
},
tab_switch: function () {
// Double-click feel
_tone(900, 35, 'sine', 0.09, 2, 25);
setTimeout(function () { _tone(1100, 35, 'sine', 0.09, 2, 25); }, 45);
},
delete: function () {
// Descending soft thud
_sweep(400, 180, 120, 'triangle', 0.10);
_noise(80, 0.04);
},
// ── Navigation ────────────────────────────────────────────────────────
page_enter: function () {
// Subtle arrival chime
_tone(784, 80, 'sine', 0.08, 5, 55);
setTimeout(function () { _tone(1047, 120, 'sine', 0.07, 5, 80); }, 75);
},
section_reveal: function () {
// Very quiet rising whoosh
_sweep(250, 420, 200, 'sine', 0.07);
},
// ── 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);
},
timer_warning: function () {
// Urgent triple beep — last 10 seconds
_tone(880, 60, 'square', 0.12, 2, 40);
setTimeout(function () { _tone(880, 60, 'square', 0.13, 2, 40); }, 200);
setTimeout(function () { _tone(1108, 100, 'square', 0.15, 2, 70); }, 400);
},
wb_clear: function () {
// Erasing noise sweep
_noise(220, 0.08);
_sweep(600, 200, 220, 'triangle', 0.06);
},
file_shared: function () {
// Paper rustle + chime
_noise(80, 0.05);
setTimeout(function () { _tone(784, 100, 'sine', 0.12, 5, 70); }, 60);
setTimeout(function () { _tone(1047, 120, 'sine', 0.10, 5, 80); }, 160);
},
// ── 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);
},
challenge_complete: function () {
// Heroic: E4-G4-B4-E5 arpeggio
_tone(330, 140, 'sine', 0.15, 8, 90);
setTimeout(function () { _tone(392, 140, 'sine', 0.16, 8, 90); }, 130);
setTimeout(function () { _tone(494, 140, 'sine', 0.17, 8, 90); }, 260);
setTimeout(function () { _tone(659, 400, 'sine', 0.20, 8, 260); }, 390);
// Sparkle on top
setTimeout(function () { _tone(1319, 200, 'triangle', 0.07); }, 450);
},
daily_login: function () {
// Morning chime: G-A-B
_tone(392, 120, 'sine', 0.12, 8, 80);
setTimeout(function () { _tone(440, 120, 'sine', 0.13, 8, 80); }, 110);
setTimeout(function () { _tone(494, 250, 'sine', 0.15, 8, 160); }, 220);
},
// ── 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);
},
time_up: function () {
// Buzzer — descending sawtooth alarm
_tone(440, 120, 'sawtooth', 0.14, 3, 80);
_noise(120, 0.06);
setTimeout(function () { _tone(330, 160, 'sawtooth', 0.12, 3, 110); }, 110);
setTimeout(function () { _tone(220, 220, 'sawtooth', 0.10, 3, 160); }, 240);
},
quiz_bonus: function () {
// Golden sparkle — high twinkling
_tone(1047, 60, 'sine', 0.14, 2, 45);
setTimeout(function () { _tone(1319, 60, 'sine', 0.16, 2, 45); }, 65);
setTimeout(function () { _tone(1568, 60, 'sine', 0.17, 2, 45); }, 130);
setTimeout(function () { _tone(2093, 180, 'sine', 0.18, 2, 120); }, 195);
},
};
// ── 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,
navigation : 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',
modal_open: 'ui', modal_close: 'ui', tab_switch: 'ui', delete: 'ui',
page_enter: 'navigation', section_reveal: 'navigation',
hand_raise: 'classroom', chat_message: 'classroom',
user_joined: 'classroom', user_left: 'classroom',
muted: 'classroom', draw_permitted: 'classroom',
lesson_start: 'classroom', lesson_end: 'classroom',
timer_warning: 'classroom', wb_clear: 'classroom', file_shared: 'classroom',
xp_gain: 'gamification', level_up: 'gamification',
achievement: 'gamification', coin: 'gamification', streak: 'gamification',
challenge_complete: 'gamification', daily_login: 'gamification',
quiz_start: 'quiz', quiz_correct: 'quiz', quiz_wrong: 'quiz',
quiz_end: 'quiz', quiz_tick: 'quiz', time_up: 'quiz', quiz_bonus: '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();
})();