Files
chatroom/public/js/effects/effect-sounds.js
lkddi 48b31e7cff 修复:管理员进房烟花无声问题(AudioContext suspended)
根本原因:管理员进房特效在 800ms 后自动触发,
此时用户尚未与新页面交互,浏览器的 AudioContext
处于 suspended 状态,之前代码同步调用 resume()
但未 await 其 Promise,导致音频节点创建后无法出声。

修复方式:
- play() 和 ding() 均改为先检查 ctx.state
- 若为 suspended,用 ctx.resume().then(...) 链式执行
- resolver 成功后真正创建音频节点并播放
- 若浏览器拒绝 resume(无用户手势),catch 静默处理

此修复使所有自动触发的音效(进房烟花、任命公告等)
在 AudioContext 未激活时也能正确播放。
2026-03-01 13:32:00 +08:00

542 lines
19 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() 停止并释放当前音效资源
* EffectSounds.ding() 播放简短叮咚通知音(大/小卡片弹出时使用)
*
* 支持的 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 ──────────────────────────────────────────────────
/**
* 播放指定特效对应的音效(自动停止上一个)。
* 静音状态下直接跳过,不做任何音频操作。
* 当 AudioContext 处于 suspended 状态时,先 resume() 再播放,
* 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。
*
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow'
*/
function play(type) {
// 用户开启禁音则跳过
if (localStorage.getItem("chat_sound_muted") === "1") return;
stop();
try {
const ctx = _getCtx();
const _doPlay = () => {
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] 音效内部错误:", e);
}
};
if (ctx.state === "suspended") {
// AudioContext 尚未被用户手势激活,先 resume 再播放
ctx.resume()
.then(_doPlay)
.catch(() => {
// 浏览器拒绝 resume无用户手势静默处理
});
} else {
_doPlay();
}
} catch (e) {
console.warn(
"[EffectSounds] 音效播放失败(可能未能解锁 AudioContext:",
e,
);
}
}
// ─── 叮咚通知音(大卡片 / 小卡片弹出时调用)─────────────────
/**
* 播放简短的叮咚两音通知音效。
*
* 音型A5880Hz→ 间隔 110ms → E5659Hz均为正弦波
* 快速冲击 + 铃铛式缓慢衰减,总时长约 0.5 秒。
* 禁音状态下自动跳过。
*/
function ding() {
if (localStorage.getItem("chat_sound_muted") === "1") return;
try {
const ctx = _getCtx();
const _doDing = () => {
try {
const master = ctx.createGain();
master.gain.value = 0.45;
master.connect(ctx.destination);
function _tone(freq, t0, decay) {
const osc = ctx.createOscillator();
osc.type = "sine";
osc.frequency.value = freq;
const osc2 = ctx.createOscillator();
osc2.type = "sine";
osc2.frequency.value = freq * 2.76;
const g2 = ctx.createGain();
g2.gain.value = 0.1;
const env = ctx.createGain();
env.gain.setValueAtTime(1.0, t0);
env.gain.exponentialRampToValueAtTime(
0.001,
t0 + decay,
);
osc.connect(env);
osc2.connect(g2);
g2.connect(env);
env.connect(master);
osc.start(t0);
osc.stop(t0 + decay + 0.05);
osc2.start(t0);
osc2.stop(t0 + decay + 0.05);
}
const now = ctx.currentTime;
_tone(880, now, 0.35); // 叮A5
_tone(659, now + 0.11, 0.4); // 咚E5
setTimeout(() => {
try {
master.disconnect();
} catch (_) {}
}, 700);
} catch (e) {
console.warn("[EffectSounds.ding] 通知音内部错误:", e);
}
};
if (ctx.state === "suspended") {
ctx.resume()
.then(_doDing)
.catch(() => {});
} else {
_doDing();
}
} catch (e) {
console.warn("[EffectSounds.ding] 通知音播放失败:", e);
}
}
/**
* 停止当前音效并释放资源。
*/
function stop() {
if (_stopFn) {
try {
_stopFn();
} catch (_) {}
_stopFn = null;
}
}
return { play, stop, ding };
})();
// 将叮咚通知音暴露为独立全局变量,供 toast/banner 等组件直接调用
window.chatSound = { ding: () => EffectSounds.ding() };