From 1d7aa636a0b84c940b1589e893a94a2f27edcae7 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 1 Mar 2026 13:07:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A4=E7=A7=8D=E5=85=A8?= =?UTF-8?q?=E5=B1=8F=E7=89=B9=E6=95=88=E5=A2=9E=E5=8A=A0=20Web=20Audio=20A?= =?UTF-8?q?PI=20=E5=AE=9E=E6=97=B6=E5=90=88=E6=88=90=E9=9F=B3=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 public/js/effects/effect-sounds.js: - 雷电:低频白噪声爆裂 + 雷鸣渐衰(10次,与视觉同步) - 烟花:发射滑音(200→700Hz)+ 带通噪声爆炸(9轮) - 下雨:双层带通白噪声(1200Hz+3500Hz)持续淡入淡出 - 下雪:4000Hz+高频风声 + 五声音阶轻柔铃音(5次随机) - 所有音效纯 Web Audio API 合成,无外部音频文件 - 旧 AudioContext 若被 suspended 自动 resume effect-manager.js: - play() 调用 EffectSounds.play(type) 同步触发音效 - _cleanup() 调用 EffectSounds.stop() 兜底停止 frame.blade.php:effect-sounds.js 在 effect-manager 前引入 --- public/js/effects/effect-manager.js | 11 +- public/js/effects/effect-sounds.js | 410 +++++++++++++++++++++++++++ resources/views/chat/frame.blade.php | 1 + 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 public/js/effects/effect-sounds.js diff --git a/public/js/effects/effect-manager.js b/public/js/effects/effect-manager.js index 90edca4..8b1c5aa 100644 --- a/public/js/effects/effect-manager.js +++ b/public/js/effects/effect-manager.js @@ -38,7 +38,7 @@ const EffectManager = (() => { } /** - * 特效结束后清理 Canvas,重置状态 + * 特效结束后清理 Canvas,重置状态,并停止音效 */ function _cleanup() { if (_canvas && document.body.contains(_canvas)) { @@ -46,6 +46,10 @@ const EffectManager = (() => { } _canvas = null; _current = null; + // 通知音效引擎停止(兜底:正常情况下音效会自行计时结束) + if (typeof EffectSounds !== "undefined") { + EffectSounds.stop(); + } } /** @@ -65,6 +69,11 @@ const EffectManager = (() => { const canvas = _getCanvas(); _current = type; + // 同步触发对应音效 + if (typeof EffectSounds !== "undefined") { + EffectSounds.play(type); + } + switch (type) { case "fireworks": if (typeof FireworksEffect !== "undefined") { diff --git a/public/js/effects/effect-sounds.js b/public/js/effects/effect-sounds.js new file mode 100644 index 0000000..483c15c --- /dev/null +++ b/public/js/effects/effect-sounds.js @@ -0,0 +1,410 @@ +/** + * 文件功能:聊天室特效音效引擎(Web Audio API 实时合成) + * + * 所有音效通过 Web Audio API 实时合成,无需外部音频文件, + * 跨浏览器兼容(Chrome / Firefox / Safari / Edge)。 + * + * 对外 API: + * EffectSounds.play(type) 播放指定特效的背景音效 + * EffectSounds.stop() 停止并释放当前音效资源 + * + * 支持的 type: + * lightning 雷鸣闪电(噪声爆裂 + 低频渐衰雷鸣) + * fireworks 烟花(发射滑音 + 高频爆炸噪声) + * rain 下雨(带通白噪声持续淡入淡出) + * 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 = 2) { + const len = 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 {number} delay 相对于当前时刻的延迟(秒) + */ + function _thunderCrack(ctx, masterGain, delay) { + const src = ctx.createBufferSource(); + src.buffer = _makeNoise(ctx, 2.5); + + // 低通滤波:让雷声浑厚,从 300Hz 扫至 80Hz + const lpf = ctx.createBiquadFilter(); + lpf.type = "lowpass"; + lpf.frequency.setValueAtTime(300, ctx.currentTime + delay); + lpf.frequency.exponentialRampToValueAtTime( + 80, + ctx.currentTime + delay + 1.6, + ); + + // 音量包络:20ms 快速冲击 → 2 秒渐衰 + const env = ctx.createGain(); + const t0 = ctx.currentTime + delay; + env.gain.setValueAtTime(0, t0); + env.gain.linearRampToValueAtTime(0.85, t0 + 0.02); + env.gain.exponentialRampToValueAtTime(0.001, t0 + 2.1); + + src.connect(lpf); + lpf.connect(env); + env.connect(masterGain); + src.start(t0); + src.stop(t0 + 2.5); + } + + /** + * 启动雷电音效:与视觉特效同步触发 10 次雷鸣,总时长约 7 秒。 + * + * @returns {Function} 停止函数 + */ + function _startLightning() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0.75; + master.connect(ctx.destination); + + // 10 次闪电,间隔 400~800ms(与视觉特效节奏对应) + let t = 0.3; + for (let i = 0; i < 10; i++) { + // 雷声稍晚于闪电(模拟光速 > 声速) + _thunderCrack(ctx, master, t + 0.05 + Math.random() * 0.25); + t += 0.4 + Math.random() * 0.4; + } + + const timer = setTimeout(() => { + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5); + setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 600); + }, 7800); + + 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; + + // 发射音:200Hz → 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); + } + + /** + * 启动烟花音效:约 8 颗烟花交错触发,总时长约 8 秒。 + * + * @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 (_) {} + }; + } + + // ─── 下雨音效 ────────────────────────────────────────────────── + + /** + * 启动下雨音效:带通白噪声持续循环,淡入 1.5 秒,8 秒后淡出。 + * + * @returns {Function} 停止函数 + */ + function _startRain() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0; + master.connect(ctx.destination); + + // 白噪声循环 + const src = ctx.createBufferSource(); + src.buffer = _makeNoise(ctx, 3); + src.loop = true; + + // 中高频带通:1200Hz 沙沙感 + const bpf = ctx.createBiquadFilter(); + bpf.type = "bandpass"; + bpf.frequency.value = 1200; + bpf.Q.value = 0.3; + + // 添加第二层高频(细密雨点感) + const bpf2 = ctx.createBiquadFilter(); + bpf2.type = "bandpass"; + bpf2.frequency.value = 3500; + bpf2.Q.value = 1; + const g2 = ctx.createGain(); + g2.gain.value = 0.4; + + src.connect(bpf); + bpf.connect(master); + src.connect(bpf2); + bpf2.connect(g2); + g2.connect(master); + src.start(); + + // 淡入 + master.gain.linearRampToValueAtTime(0.4, ctx.currentTime + 1.5); + + const endTimer = setTimeout(() => { + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.5); + setTimeout(() => { + try { + src.stop(); + master.disconnect(); + } catch (_) {} + }, 1600); + }, 8500); + + return () => { + clearTimeout(endTimer); + master.gain.setValueAtTime(0, ctx.currentTime); + try { + src.stop(); + } catch (_) {} + setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 100); + }; + } + + // ─── 下雪音效 ────────────────────────────────────────────────── + + /** + * 播放单次轻柔铃声(五声音阶:C5/E5/G5/C6)。 + * + * @param {AudioContext} ctx + * @param {GainNode} masterGain + * @param {number} delay 延迟(秒) + */ + function _snowBell(ctx, masterGain, delay) { + const freqs = [523.25, 659.25, 783.99, 1046.5]; // C5 E5 G5 C6 + const freq = freqs[Math.floor(Math.random() * freqs.length)]; + const t0 = ctx.currentTime + delay; + + const osc = ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.value = freq; + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.18, t0); + env.gain.exponentialRampToValueAtTime(0.001, t0 + 1.8); + + osc.connect(env); + env.connect(masterGain); + osc.start(t0); + osc.stop(t0 + 1.9); + } + + /** + * 启动下雪音效:极低音量高频风声 + 5 次随机轻柔铃声,总时长约 10 秒。 + * + * @returns {Function} 停止函数 + */ + function _startSnow() { + const ctx = _getCtx(); + const master = ctx.createGain(); + master.gain.value = 0; + master.connect(ctx.destination); + + // 高频风声(非常安静) + const src = ctx.createBufferSource(); + src.buffer = _makeNoise(ctx, 3); + src.loop = true; + const hpf = ctx.createBiquadFilter(); + hpf.type = "highpass"; + hpf.frequency.value = 4000; + src.connect(hpf); + hpf.connect(master); + src.start(); + + // 极低音量淡入 + master.gain.linearRampToValueAtTime(0.07, ctx.currentTime + 2); + + // 随机铃声计时器 + const bellTimers = []; + for (let i = 0; i < 5; i++) { + const d = 1.5 + Math.random() * 7; + bellTimers.push( + setTimeout(() => _snowBell(ctx, master, 0), d * 1000), + ); + } + + const endTimer = setTimeout(() => { + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2); + setTimeout(() => { + try { + src.stop(); + master.disconnect(); + } catch (_) {} + }, 2100); + }, 10500); + + return () => { + clearTimeout(endTimer); + bellTimers.forEach((t) => clearTimeout(t)); + master.gain.setValueAtTime(0, ctx.currentTime); + try { + src.stop(); + } catch (_) {} + setTimeout(() => { + try { + master.disconnect(); + } catch (_) {} + }, 100); + }; + } + + // ─── 公开 API ────────────────────────────────────────────────── + + /** + * 播放指定特效对应的音效(自动停止上一个)。 + * + * @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' + */ + function play(type) { + 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 }; +})(); diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 4b3db9b..e0b0bdc 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -115,6 +115,7 @@ @include('chat.partials.user-actions') {{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}} +