ec2a207fb8
Мелодию-вызов перевёл с кастомного Web Audio на общий движок звуков LS.sfx: - длинный вестминстерский бой теперь в sound.js (звук lesson_start); - api.js лениво подгружает sound.js на любой странице и играет lesson_start по SSE classroom_started (вместо собственного синтезатора); - отдельный pref lessonCall + тумблер «Вызов на урок» и кнопка прослушивания в профиле (Настройки → Звуки); уважает мастер-тумблер и громкость; - lesson_start выведен из категории classroom (управляется своим тумблером); - разблокировка AudioContext по первому жесту перенесена в sound.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
470 lines
20 KiB
JavaScript
470 lines
20 KiB
JavaScript
// 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 — 0–1 (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 () {
|
||
// «Вызов на урок» — полный вестминстерский бой: 5 фраз по 4 ноты
|
||
// (G4 · C5 · D5 · E5). Колоколообразно: тон + обертон-октава.
|
||
var G4 = 392, C5 = 523, D5 = 587, E5 = 659;
|
||
var phrases = [
|
||
[E5, D5, C5, G4],
|
||
[C5, E5, D5, G4],
|
||
[C5, D5, E5, C5],
|
||
[E5, C5, D5, G4],
|
||
[G4, D5, E5, C5]
|
||
];
|
||
var step = 320, gap = 220, t = 0;
|
||
phrases.forEach(function (ph, pi) {
|
||
ph.forEach(function (freq, ni) {
|
||
var lastOfPhrase = ni === ph.length - 1;
|
||
var lastOverall = pi === phrases.length - 1 && lastOfPhrase;
|
||
var dur = lastOverall ? 2000 : (lastOfPhrase ? 1100 : 760); // концы фраз длиннее
|
||
(function (f, when, d) {
|
||
setTimeout(function () {
|
||
_tone(f, d, 'sine', 0.16, 10, Math.round(d * 0.7)); // основной тон
|
||
_tone(f * 2, d, 'sine', 0.05, 10, Math.round(d * 0.7)); // обертон-октава
|
||
}, when);
|
||
})(freq, t, dur);
|
||
t += step + (lastOfPhrase ? gap : 0);
|
||
});
|
||
});
|
||
},
|
||
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,
|
||
lessonCall : sfx.lessonCall,
|
||
prefs : sfx.prefs,
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ── Public API ─────────────────────────────────────────────────────────────
|
||
var sfx = {
|
||
enabled : true,
|
||
volume : 0.75,
|
||
lessonCall : true, // мелодия-«вызов на урок» — отдельный тумблер
|
||
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_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;
|
||
if (name === 'lesson_start' && !this.lessonCall) 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) {}
|
||
},
|
||
|
||
// Проиграть звук в обход тумблеров (для кнопки «прослушать» в профиле)
|
||
preview: function (name) {
|
||
if (!_sounds[name]) return;
|
||
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();
|
||
},
|
||
|
||
setLessonCall: function (v) {
|
||
this.lessonCall = !!v;
|
||
_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.lessonCall !== undefined) this.lessonCall = saved.lessonCall;
|
||
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();
|
||
|
||
// Разблокировать AudioContext после первого действия пользователя (политика автоплея),
|
||
// чтобы звук, пришедший по событию (напр. «вызов на урок»), смог прозвучать.
|
||
function _unlock() {
|
||
try { _getCtx(); } catch (e) {}
|
||
window.removeEventListener('pointerdown', _unlock, true);
|
||
window.removeEventListener('keydown', _unlock, true);
|
||
}
|
||
window.addEventListener('pointerdown', _unlock, true);
|
||
window.addEventListener('keydown', _unlock, true);
|
||
|
||
})();
|