/** * 文件功能:聊天室特效音效引擎(Web Audio API 实时合成) * * 所有音效通过 Web Audio API 实时合成,无需外部音频文件。 * * 对外 API: * EffectSounds.play(type) 播放指定特效的背景音效 * EffectSounds.stop() 停止并释放当前音效资源 * EffectSounds.ding() 播放简短叮咚通知音(大/小卡片弹出时使用) * * 支持的 type: * lightning 雷鸣闪电(三层合成:放电啪声 + 低频轰鸣 + 极低频滚动余韵) * fireworks 烟花(发射滑音 + 高频爆炸噪声) * rain 下雨(双层带通白噪声,低音量,与视觉 8s 同步) * snow 下雪(仅五声音阶铃音,无风声) * sakura 樱花(轻柔风铃 + 微风扫过) * meteors 流星(高速掠空呼啸) * gold-rain 金币雨(金属叮当) * hearts 爱心飘落(温暖双音) * confetti 彩带庆典(礼炮碎响 + 清亮点缀) * fireflies 萤火虫(稀疏微光铃音) */ 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; } /** * 调度一个带包络的简单音符。 * * @param {AudioContext} ctx * @param {GainNode} masterGain * @param {{delay?:number, duration?:number, freq:number, endFreq?:number, volume?:number, type?:OscillatorType}} options */ function _scheduleTone(ctx, masterGain, options) { const { delay = 0, duration = 0.8, freq, endFreq = freq, volume = 0.18, type = "sine", } = options; const t0 = ctx.currentTime + delay; const osc = ctx.createOscillator(); const env = ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, t0); osc.frequency.exponentialRampToValueAtTime( Math.max(20, endFreq), t0 + duration, ); env.gain.setValueAtTime(Math.max(0.0001, volume), t0); env.gain.exponentialRampToValueAtTime(0.001, t0 + duration); osc.connect(env); env.connect(masterGain); osc.start(t0); osc.stop(t0 + duration + 0.03); } /** * 调度一段带滤波扫频的噪声音色,用于呼啸、爆裂等环境声。 * * @param {AudioContext} ctx * @param {GainNode} masterGain * @param {{delay?:number, duration?:number, startFreq?:number, endFreq?:number, volume?:number, q?:number, filterType?:BiquadFilterType}} options */ function _scheduleNoiseSweep(ctx, masterGain, options) { const { delay = 0, duration = 0.6, startFreq = 2200, endFreq = 500, volume = 0.16, q = 0.8, filterType = "bandpass", } = options; const t0 = ctx.currentTime + delay; const src = ctx.createBufferSource(); src.buffer = _makeNoise(ctx, duration + 0.15); const filter = ctx.createBiquadFilter(); filter.type = filterType; filter.Q.value = q; filter.frequency.setValueAtTime(startFreq, t0); filter.frequency.exponentialRampToValueAtTime( Math.max(60, endFreq), t0 + duration, ); const env = ctx.createGain(); env.gain.setValueAtTime(0.001, t0); env.gain.linearRampToValueAtTime(volume, t0 + duration * 0.18); env.gain.exponentialRampToValueAtTime(0.001, t0 + duration); src.connect(filter); filter.connect(env); env.connect(masterGain); src.start(t0); src.stop(t0 + duration + 0.08); } // ─── 雷电音效(三层合成,贴近真实雷声)──────────────────────── /** * 单次真实雷声 = 放电啪声 + 低频轰鸣 + 极低频滚动余韵。 * * 物理模型: * 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 (_) {} }; } // ─── 樱花 / 流星 / 金币雨 / 爱心 / 彩带 / 萤火虫音效 ──────────── /** * 启动樱花音效:低音量风铃加轻微风声。 * * @returns {Function} 停止函数 */ function _startSakura() { const ctx = _getCtx(); const master = ctx.createGain(); master.gain.value = 0.42; master.connect(ctx.destination); const notes = [523.25, 659.25, 783.99, 880]; const plan = [0.35, 1.2, 2.1, 3.6, 5.1, 6.8, 8.2]; plan.forEach((delay, index) => { _scheduleTone(ctx, master, { delay, duration: 1.8, freq: notes[index % notes.length], endFreq: notes[index % notes.length] * 0.98, volume: 0.11, type: "sine", }); }); _scheduleNoiseSweep(ctx, master, { delay: 0.2, duration: 3.2, startFreq: 1400, endFreq: 500, volume: 0.04, q: 0.35, }); _scheduleNoiseSweep(ctx, master, { delay: 4.8, duration: 2.8, startFreq: 1200, endFreq: 420, volume: 0.035, q: 0.3, }); const endTimer = setTimeout(() => { master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8); setTimeout(() => { try { master.disconnect(); } catch (_) {} }, 900); }, 9800); return () => { clearTimeout(endTimer); try { master.gain.setValueAtTime(0, ctx.currentTime); master.disconnect(); } catch (_) {} }; } /** * 启动流星音效:多段高速呼啸掠空。 * * @returns {Function} 停止函数 */ function _startMeteors() { const ctx = _getCtx(); const master = ctx.createGain(); master.gain.value = 0.55; master.connect(ctx.destination); [0.4, 1.5, 2.8, 4.2, 5.7, 7.1].forEach((delay) => { _scheduleNoiseSweep(ctx, master, { delay, duration: 0.85, startFreq: 3600, endFreq: 320, volume: 0.14, q: 1.1, }); _scheduleTone(ctx, master, { delay: delay + 0.08, duration: 0.55, freq: 1100, endFreq: 420, volume: 0.06, type: "triangle", }); }); const endTimer = setTimeout(() => { try { master.disconnect(); } catch (_) {} }, 9300); return () => { clearTimeout(endTimer); try { master.disconnect(); } catch (_) {} }; } /** * 启动金币雨音效:连续金属叮当声。 * * @returns {Function} 停止函数 */ function _startGoldRain() { const ctx = _getCtx(); const master = ctx.createGain(); master.gain.value = 0.62; master.connect(ctx.destination); const notes = [880, 987.77, 1174.66, 1318.51]; for (let i = 0; i < 16; i++) { const delay = 0.25 + i * 0.42; const freq = notes[i % notes.length]; _scheduleTone(ctx, master, { delay, duration: 0.5, freq, endFreq: freq * 0.92, volume: 0.16, type: "triangle", }); _scheduleTone(ctx, master, { delay: delay + 0.02, duration: 0.38, freq: freq * 2.1, endFreq: freq * 1.9, volume: 0.06, type: "sine", }); } const endTimer = setTimeout(() => { master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.6); setTimeout(() => { try { master.disconnect(); } catch (_) {} }, 700); }, 7600); return () => { clearTimeout(endTimer); try { master.gain.setValueAtTime(0, ctx.currentTime); master.disconnect(); } catch (_) {} }; } /** * 启动爱心飘落音效:柔和双音和声。 * * @returns {Function} 停止函数 */ function _startHearts() { const ctx = _getCtx(); const master = ctx.createGain(); master.gain.value = 0.5; master.connect(ctx.destination); [0.35, 1.5, 2.9, 4.2, 5.8, 7.2].forEach((delay) => { _scheduleTone(ctx, master, { delay, duration: 0.9, freq: 523.25, endFreq: 493.88, volume: 0.12, type: "triangle", }); _scheduleTone(ctx, master, { delay: delay + 0.12, duration: 1.05, freq: 659.25, endFreq: 622.25, volume: 0.1, type: "sine", }); }); const endTimer = setTimeout(() => { master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8); setTimeout(() => { try { master.disconnect(); } catch (_) {} }, 900); }, 9000); return () => { clearTimeout(endTimer); try { master.gain.setValueAtTime(0, ctx.currentTime); master.disconnect(); } catch (_) {} }; } /** * 启动彩带庆典音效:礼炮碎响与亮点装饰音。 * * @returns {Function} 停止函数 */ function _startConfetti() { const ctx = _getCtx(); const master = ctx.createGain(); master.gain.value = 0.58; master.connect(ctx.destination); [0.2, 1.4, 2.8, 4.1, 5.6].forEach((delay) => { _scheduleNoiseSweep(ctx, master, { delay, duration: 0.38, startFreq: 1800, endFreq: 700, volume: 0.2, q: 0.7, }); _scheduleTone(ctx, master, { delay: delay + 0.04, duration: 0.42, freq: 1046.5, endFreq: 880, volume: 0.08, type: "square", }); }); const endTimer = setTimeout(() => { try { master.disconnect(); } catch (_) {} }, 7200); return () => { clearTimeout(endTimer); try { master.disconnect(); } catch (_) {} }; } /** * 启动萤火虫音效:低音量稀疏闪烁音。 * * @returns {Function} 停止函数 */ function _startFireflies() { const ctx = _getCtx(); const master = ctx.createGain(); master.gain.value = 0.28; master.connect(ctx.destination); [0.6, 1.8, 2.7, 3.9, 5.2, 6.4, 7.6, 8.8].forEach((delay, index) => { _scheduleTone(ctx, master, { delay, duration: 1.4, freq: [659.25, 783.99, 987.77][index % 3], endFreq: [659.25, 783.99, 987.77][index % 3] * 0.99, volume: 0.06, type: "sine", }); }); const endTimer = setTimeout(() => { master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0); setTimeout(() => { try { master.disconnect(); } catch (_) {} }, 1100); }, 10200); return () => { clearTimeout(endTimer); try { master.gain.setValueAtTime(0, ctx.currentTime); master.disconnect(); } catch (_) {} }; } // ─── 公开 API ────────────────────────────────────────────────── /** * 播放指定特效对应的音效(自动停止上一个)。 * 静音状态下直接跳过,不做任何音频操作。 * 当 AudioContext 处于 suspended 状态时,先 resume() 再播放, * 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。 * * @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' */ 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; case "sakura": _stopFn = _startSakura(); break; case "meteors": _stopFn = _startMeteors(); break; case "gold-rain": _stopFn = _startGoldRain(); break; case "hearts": _stopFn = _startHearts(); break; case "confetti": _stopFn = _startConfetti(); break; case "fireflies": _stopFn = _startFireflies(); 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, ); } } // ─── 叮咚通知音(大卡片 / 小卡片弹出时调用)───────────────── /** * 播放简短的叮咚两音通知音效。 * * 音型:A5(880Hz)→ 间隔 110ms → E5(659Hz),均为正弦波, * 快速冲击 + 铃铛式缓慢衰减,总时长约 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() };