Files
chatroom/public/js/effects/effect-sounds.js
lkddi dac7750fe1 功能:特效音效三项优化 + 禁音开关
音效改进(effect-sounds.js):
1. 雷电 - 三层合成更贴近真实:
   ①放电啪声(带通噪声 ~50ms)
   ②低频轰鸣(120→38Hz 扫频,快冲击 2s 衰减)
   ③极低频滚动余韵(55→22Hz,缓慢堆积 3.6s 长衰减)
2. 下雨 - 音量 0.40→0.15,时长与视觉效果统一(8000ms)
3. 下雪 - 移除风声,只保留五声音阶铃音(C/E/G/C)
   铃音加第二泛音(×2.76倍频)模拟真实铃铛共鸣感
   8次随机铃声分布在 10 秒内

禁音开关:
- input-bar.blade.php:悄悄话旁新增「🔇 禁音」复选框
- scripts.blade.php:toggleSoundMute() 函数,
  localStorage chat_sound_muted 持久化,
  DOMContentLoaded 恢复复选框状态
- effect-sounds.js:play() 先检查 chat_sound_muted 标志
2026-03-01 13:19:24 +08:00

445 lines
15 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.
/**
* 文件功能聊天室特效音效引擎Web Audio API 实时合成)
*
* 所有音效通过 Web Audio API 实时合成,无需外部音频文件。
*
* 对外 API
* EffectSounds.play(type) 播放指定特效的背景音效
* EffectSounds.stop() 停止并释放当前音效资源
*
* 支持的 type
* lightning 雷鸣闪电(三层合成:放电啪声 + 低频轰鸣 + 极低频滚动余韵)
* fireworks 烟花(发射滑音 + 高频爆炸噪声)
* rain 下雨(双层带通白噪声,低音量,与视觉 8s 同步)
* snow 下雪(仅五声音阶铃音,无风声)
*/
const EffectSounds = (() => {
/** @type {AudioContext|null} */
let _ctx = null;
/** @type {Function|null} 当前音效的停止函数 */
let _stopFn = null;
// ─── 工具方法 ──────────────────────────────────────────────────
/**
* 懒加载并返回 AudioContext。
* 浏览器要求首次创建必须在用户手势后,聊天室点击任何按钮即可解锁。
*
* @returns {AudioContext}
*/
function _getCtx() {
if (!_ctx || _ctx.state === "closed") {
_ctx = new (window.AudioContext || window.webkitAudioContext)();
}
if (_ctx.state === "suspended") {
_ctx.resume();
}
return _ctx;
}
/**
* 创建包含白噪声的 AudioBuffer。
*
* @param {AudioContext} ctx
* @param {number} duration 时长(秒)
* @returns {AudioBuffer}
*/
function _makeNoise(ctx, duration) {
const len = Math.ceil(ctx.sampleRate * duration);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) {
data[i] = Math.random() * 2 - 1;
}
return buf;
}
// ─── 雷电音效(三层合成,贴近真实雷声)────────────────────────
/**
* 单次真实雷声 = 放电啪声 + 低频轰鸣 + 极低频滚动余韵。
*
* 物理模型:
* crack — 闪电通道瞬间放电产生的高频尖啪30~50ms
* boom — 空气急速膨胀产生的低频冲击40~120Hz~150ms 达峰值,衰减 2s
* rumble — 回声与地面反射产生的极低频滚动20~60Hz缓慢衰减 3.5s
*
* @param {AudioContext} ctx
* @param {GainNode} masterGain
* @param {number} delay 延迟(秒)
*/
function _thunderCrack(ctx, masterGain, delay) {
const t0 = ctx.currentTime + delay;
// ① 放电啪声crack带通白噪声极短 ~50ms
const snap = ctx.createBufferSource();
snap.buffer = _makeNoise(ctx, 0.12);
const snapBpf = ctx.createBiquadFilter();
snapBpf.type = "bandpass";
snapBpf.frequency.value = 2800 + Math.random() * 800;
snapBpf.Q.value = 0.7;
const snapEnv = ctx.createGain();
snapEnv.gain.setValueAtTime(0.55, t0);
snapEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 0.055);
snap.connect(snapBpf);
snapBpf.connect(snapEnv);
snapEnv.connect(masterGain);
snap.start(t0);
snap.stop(t0 + 0.12);
// ② 主轰鸣boom低通白噪声120→40Hz 扫频,快冲击慢衰减 ~2s
const boom = ctx.createBufferSource();
boom.buffer = _makeNoise(ctx, 2.2);
const boomLpf = ctx.createBiquadFilter();
boomLpf.type = "lowpass";
boomLpf.frequency.setValueAtTime(130, t0 + 0.03);
boomLpf.frequency.exponentialRampToValueAtTime(38, t0 + 2.0);
const boomEnv = ctx.createGain();
boomEnv.gain.setValueAtTime(0, t0 + 0.03);
boomEnv.gain.linearRampToValueAtTime(1.0, t0 + 0.12); // 快速冲击
boomEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 2.1);
boom.connect(boomLpf);
boomLpf.connect(boomEnv);
boomEnv.connect(masterGain);
boom.start(t0 + 0.03);
boom.stop(t0 + 2.2);
// ③ 滚动余韵rumble极低频缓慢堆积后长衰减 ~3.5s
const rumble = ctx.createBufferSource();
rumble.buffer = _makeNoise(ctx, 3.8);
const rumbleLpf = ctx.createBiquadFilter();
rumbleLpf.type = "lowpass";
rumbleLpf.frequency.setValueAtTime(55, t0);
rumbleLpf.frequency.exponentialRampToValueAtTime(22, t0 + 3.5);
const rumbleEnv = ctx.createGain();
rumbleEnv.gain.setValueAtTime(0, t0 + 0.05);
rumbleEnv.gain.linearRampToValueAtTime(0.65, t0 + 0.35); // 缓慢堆积
rumbleEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 3.6);
rumble.connect(rumbleLpf);
rumbleLpf.connect(rumbleEnv);
rumbleEnv.connect(masterGain);
rumble.start(t0 + 0.05);
rumble.stop(t0 + 3.8);
}
/**
* 启动雷电音效:与视觉特效同步触发 10 次雷声,总时长约 7 秒。
*
* @returns {Function} 停止函数
*/
function _startLightning() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0.8;
master.connect(ctx.destination);
// 10 次闪电,间隔 400~800ms雷声滞后闪电 50~300ms光速>声速)
let t = 0.3;
for (let i = 0; i < 10; i++) {
_thunderCrack(ctx, master, t + 0.06 + Math.random() * 0.25);
t += 0.4 + Math.random() * 0.4;
}
// 视觉特效 ~7 秒,预留尾声淡出
const timer = setTimeout(() => {
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0);
setTimeout(() => {
try {
master.disconnect();
} catch (_) {}
}, 1100);
}, 8000);
return () => {
clearTimeout(timer);
try {
master.gain.setValueAtTime(0, ctx.currentTime);
master.disconnect();
} catch (_) {}
};
}
// ─── 烟花音效 ──────────────────────────────────────────────────
/**
* 播放单颗烟花:发射滑音 + 空中爆炸噪声。
*
* @param {AudioContext} ctx
* @param {GainNode} masterGain
* @param {number} delay 延迟(秒)
*/
function _fireworkPop(ctx, masterGain, delay) {
const t0 = ctx.currentTime + delay;
// 发射音200→700Hz 上升滑音
const osc = ctx.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime(200, t0);
osc.frequency.exponentialRampToValueAtTime(700, t0 + 0.18);
const launchEnv = ctx.createGain();
launchEnv.gain.setValueAtTime(0.25, t0);
launchEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 0.22);
osc.connect(launchEnv);
launchEnv.connect(masterGain);
osc.start(t0);
osc.stop(t0 + 0.23);
// 爆炸音:带通噪声
const boom = ctx.createBufferSource();
boom.buffer = _makeNoise(ctx, 0.9);
const bpf = ctx.createBiquadFilter();
bpf.type = "bandpass";
bpf.frequency.value = 1000 + Math.random() * 1500;
bpf.Q.value = 0.5;
const boomEnv = ctx.createGain();
const t1 = t0 + 0.18;
boomEnv.gain.setValueAtTime(0, t1);
boomEnv.gain.linearRampToValueAtTime(0.45, t1 + 0.02);
boomEnv.gain.exponentialRampToValueAtTime(0.001, t1 + 0.75);
boom.connect(bpf);
bpf.connect(boomEnv);
boomEnv.connect(masterGain);
boom.start(t1);
boom.stop(t1 + 0.9);
}
/**
* 启动烟花音效。
*
* @returns {Function} 停止函数
*/
function _startFireworks() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0.6;
master.connect(ctx.destination);
let t = 0.2;
for (let i = 0; i < 9; i++) {
_fireworkPop(ctx, master, t);
t += 0.5 + Math.random() * 1.0;
}
const timer = setTimeout(
() => {
try {
master.disconnect();
} catch (_) {}
},
(t + 2) * 1000,
);
return () => {
clearTimeout(timer);
try {
master.disconnect();
} catch (_) {}
};
}
// ─── 下雨音效(低音量,与视觉 8000ms 对齐)─────────────────────
/**
* 启动下雨音效:双层带通白噪声,主音量 0.15,随视觉效果结束淡出。
* 视觉雨效持续 8000ms音效在相同时间开始淡出。
*
* @returns {Function} 停止函数
*/
function _startRain() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0;
master.connect(ctx.destination);
// 主雨声:中频 1200Hz 沙沙感
const src1 = ctx.createBufferSource();
src1.buffer = _makeNoise(ctx, 3);
src1.loop = true;
const bpf1 = ctx.createBiquadFilter();
bpf1.type = "bandpass";
bpf1.frequency.value = 1200;
bpf1.Q.value = 0.3;
// 细密层:高频 3500Hz增加雨点密度感
const src2 = ctx.createBufferSource();
src2.buffer = _makeNoise(ctx, 3);
src2.loop = true;
const bpf2 = ctx.createBiquadFilter();
bpf2.type = "bandpass";
bpf2.frequency.value = 3500;
bpf2.Q.value = 1;
const g2 = ctx.createGain();
g2.gain.value = 0.35;
src1.connect(bpf1);
bpf1.connect(master);
src2.connect(bpf2);
bpf2.connect(g2);
g2.connect(master);
src1.start();
src2.start();
// 淡入 1.5s → 最高音量 0.15(比之前降低约 60%
master.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 1.5);
// 与视觉雨效对齐8000ms 后开始 2s 淡出
const endTimer = setTimeout(() => {
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.0);
setTimeout(() => {
try {
src1.stop();
src2.stop();
master.disconnect();
} catch (_) {}
}, 2200);
}, 8000);
return () => {
clearTimeout(endTimer);
master.gain.setValueAtTime(0, ctx.currentTime);
setTimeout(() => {
try {
src1.stop();
src2.stop();
master.disconnect();
} catch (_) {}
}, 100);
};
}
// ─── 下雪音效(仅五声音阶铃音,无风声)─────────────────────────
/**
* 播放单次铃声(五声音阶 C/E/G/C含泛音模拟铃铛共鸣
*
* @param {AudioContext} ctx
* @param {GainNode} masterGain
* @param {number} delay 延迟(秒)
*/
function _snowBell(ctx, masterGain, delay) {
// 五声音阶C5 E5 G5 C6
const freqs = [523.25, 659.25, 783.99, 1046.5];
const freq = freqs[Math.floor(Math.random() * freqs.length)];
const t0 = ctx.currentTime + delay;
// 基音
const osc1 = ctx.createOscillator();
osc1.type = "sine";
osc1.frequency.value = freq;
// 第二泛音5倍频音量很低增加金属铃铛感
const osc2 = ctx.createOscillator();
osc2.type = "sine";
osc2.frequency.value = freq * 2.76; // 铃铛典型泛音比
const g2 = ctx.createGain();
g2.gain.value = 0.12;
// 共用包络:快冲击,缓慢衰减 2s
const env = ctx.createGain();
env.gain.setValueAtTime(0.22, t0);
env.gain.exponentialRampToValueAtTime(0.001, t0 + 2.0);
osc1.connect(env);
osc2.connect(g2);
g2.connect(env);
env.connect(masterGain);
osc1.start(t0);
osc1.stop(t0 + 2.1);
osc2.start(t0);
osc2.stop(t0 + 2.1);
}
/**
* 启动下雪音效:在 10 秒内随机播放 8 次铃音,无背景风声。
* 与视觉雪效持续时间10000ms对齐。
*
* @returns {Function} 停止函数
*/
function _startSnow() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0.9;
master.connect(ctx.destination);
// 随机分布 8 次铃声,间隔在 0.5~9.5s 之间
const bellTimers = [];
for (let i = 0; i < 8; i++) {
const d = 0.3 + Math.random() * 9.0;
bellTimers.push(
setTimeout(() => _snowBell(ctx, master, 0), d * 1000),
);
}
const endTimer = setTimeout(() => {
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5);
setTimeout(() => {
try {
master.disconnect();
} catch (_) {}
}, 600);
}, 10800);
return () => {
clearTimeout(endTimer);
bellTimers.forEach((t) => clearTimeout(t));
try {
master.gain.setValueAtTime(0, ctx.currentTime);
master.disconnect();
} catch (_) {}
};
}
// ─── 公开 API ──────────────────────────────────────────────────
/**
* 播放指定特效对应的音效(自动停止上一个)。
* 静音状态下直接跳过,不做任何音频操作。
*
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow'
*/
function play(type) {
// 用户开启禁音则跳过
if (localStorage.getItem("chat_sound_muted") === "1") return;
stop();
try {
switch (type) {
case "lightning":
_stopFn = _startLightning();
break;
case "fireworks":
_stopFn = _startFireworks();
break;
case "rain":
_stopFn = _startRain();
break;
case "snow":
_stopFn = _startSnow();
break;
default:
break;
}
} catch (e) {
console.warn(
"[EffectSounds] 音效播放失败(可能未能解锁 AudioContext:",
e,
);
}
}
/**
* 停止当前音效并释放资源。
*/
function stop() {
if (_stopFn) {
try {
_stopFn();
} catch (_) {}
_stopFn = null;
}
}
return { play, stop };
})();