diff --git a/public/js/effects/effect-sounds.js b/public/js/effects/effect-sounds.js index 483c15c..3b61c93 100644 --- a/public/js/effects/effect-sounds.js +++ b/public/js/effects/effect-sounds.js @@ -1,18 +1,17 @@ /** * 文件功能:聊天室特效音效引擎(Web Audio API 实时合成) * - * 所有音效通过 Web Audio API 实时合成,无需外部音频文件, - * 跨浏览器兼容(Chrome / Firefox / Safari / Edge)。 + * 所有音效通过 Web Audio API 实时合成,无需外部音频文件。 * * 对外 API: * EffectSounds.play(type) 播放指定特效的背景音效 * EffectSounds.stop() 停止并释放当前音效资源 * * 支持的 type: - * lightning 雷鸣闪电(噪声爆裂 + 低频渐衰雷鸣) + * lightning 雷鸣闪电(三层合成:放电啪声 + 低频轰鸣 + 极低频滚动余韵) * fireworks 烟花(发射滑音 + 高频爆炸噪声) - * rain 下雨(带通白噪声持续淡入淡出) - * snow 下雪(极低音风声 + 偶发轻柔铃音) + * rain 下雨(双层带通白噪声,低音量,与视觉 8s 同步) + * snow 下雪(仅五声音阶铃音,无风声) */ const EffectSounds = (() => { @@ -25,7 +24,7 @@ const EffectSounds = (() => { /** * 懒加载并返回 AudioContext。 - * 浏览器要求首次创建必须在用户手势后,聊天室首次点击任何按钮即可解锁。 + * 浏览器要求首次创建必须在用户手势后,聊天室点击任何按钮即可解锁。 * * @returns {AudioContext} */ @@ -46,8 +45,8 @@ const EffectSounds = (() => { * @param {number} duration 时长(秒) * @returns {AudioBuffer} */ - function _makeNoise(ctx, duration = 2) { - const len = ctx.sampleRate * duration; + 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++) { @@ -56,69 +55,101 @@ const EffectSounds = (() => { return buf; } - // ─── 雷电音效 ────────────────────────────────────────────────── + // ─── 雷电音效(三层合成,贴近真实雷声)──────────────────────── /** - * 播放单次雷鸣:低频白噪声爆裂,快速冲击后缓慢衰减。 + * 单次真实雷声 = 放电啪声 + 低频轰鸣 + 极低频滚动余韵。 + * + * 物理模型: + * crack — 闪电通道瞬间放电产生的高频尖啪(30~50ms) + * boom — 空气急速膨胀产生的低频冲击(40~120Hz,~150ms 达峰值,衰减 2s) + * rumble — 回声与地面反射产生的极低频滚动(20~60Hz,缓慢衰减 3.5s) * * @param {AudioContext} ctx - * @param {GainNode} masterGain 主音量节点 - * @param {number} delay 相对于当前时刻的延迟(秒) + * @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); + // ① 放电啪声(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 秒。 + * 启动雷电音效:与视觉特效同步触发 10 次雷声,总时长约 7 秒。 * * @returns {Function} 停止函数 */ function _startLightning() { const ctx = _getCtx(); const master = ctx.createGain(); - master.gain.value = 0.75; + master.gain.value = 0.8; master.connect(ctx.destination); - // 10 次闪电,间隔 400~800ms(与视觉特效节奏对应) + // 10 次闪电,间隔 400~800ms,雷声滞后闪电 50~300ms(光速>声速) let t = 0.3; for (let i = 0; i < 10; i++) { - // 雷声稍晚于闪电(模拟光速 > 声速) - _thunderCrack(ctx, master, t + 0.05 + Math.random() * 0.25); + _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 + 0.5); + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0); setTimeout(() => { try { master.disconnect(); } catch (_) {} - }, 600); - }, 7800); + }, 1100); + }, 8000); return () => { clearTimeout(timer); @@ -132,16 +163,16 @@ const EffectSounds = (() => { // ─── 烟花音效 ────────────────────────────────────────────────── /** - * 播放单颗烟花音:发射滑音 + 空中爆炸噪声。 + * 播放单颗烟花:发射滑音 + 空中爆炸噪声。 * * @param {AudioContext} ctx * @param {GainNode} masterGain - * @param {number} delay 延迟(秒) + * @param {number} delay 延迟(秒) */ function _fireworkPop(ctx, masterGain, delay) { const t0 = ctx.currentTime + delay; - // 发射音:200Hz → 700Hz 上升滑音 + // 发射音:200→700Hz 上升滑音 const osc = ctx.createOscillator(); osc.type = "sine"; osc.frequency.setValueAtTime(200, t0); @@ -154,7 +185,7 @@ const EffectSounds = (() => { osc.start(t0); osc.stop(t0 + 0.23); - // 爆炸音:带通白噪声爆裂 + // 爆炸音:带通噪声 const boom = ctx.createBufferSource(); boom.buffer = _makeNoise(ctx, 0.9); const bpf = ctx.createBiquadFilter(); @@ -174,7 +205,7 @@ const EffectSounds = (() => { } /** - * 启动烟花音效:约 8 颗烟花交错触发,总时长约 8 秒。 + * 启动烟花音效。 * * @returns {Function} 停止函数 */ @@ -207,10 +238,11 @@ const EffectSounds = (() => { }; } - // ─── 下雨音效 ────────────────────────────────────────────────── + // ─── 下雨音效(低音量,与视觉 8000ms 对齐)───────────────────── /** - * 启动下雨音效:带通白噪声持续循环,淡入 1.5 秒,8 秒后淡出。 + * 启动下雨音效:双层带通白噪声,主音量 0.15,随视觉效果结束淡出。 + * 视觉雨效持续 8000ms,音效在相同时间开始淡出。 * * @returns {Function} 停止函数 */ @@ -220,143 +252,142 @@ const EffectSounds = (() => { master.gain.value = 0; master.connect(ctx.destination); - // 白噪声循环 - const src = ctx.createBufferSource(); - src.buffer = _makeNoise(ctx, 3); - src.loop = true; + // 主雨声:中频 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; - // 中高频带通:1200Hz 沙沙感 - const bpf = ctx.createBiquadFilter(); - bpf.type = "bandpass"; - bpf.frequency.value = 1200; - bpf.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.4; + g2.gain.value = 0.35; - src.connect(bpf); - bpf.connect(master); - src.connect(bpf2); + src1.connect(bpf1); + bpf1.connect(master); + src2.connect(bpf2); bpf2.connect(g2); g2.connect(master); - src.start(); + src1.start(); + src2.start(); - // 淡入 - master.gain.linearRampToValueAtTime(0.4, ctx.currentTime + 1.5); + // 淡入 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 + 1.5); + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.0); setTimeout(() => { try { - src.stop(); + src1.stop(); + src2.stop(); master.disconnect(); } catch (_) {} - }, 1600); - }, 8500); + }, 2200); + }, 8000); return () => { clearTimeout(endTimer); master.gain.setValueAtTime(0, ctx.currentTime); - try { - src.stop(); - } catch (_) {} setTimeout(() => { try { + src1.stop(); + src2.stop(); master.disconnect(); } catch (_) {} }, 100); }; } - // ─── 下雪音效 ────────────────────────────────────────────────── + // ─── 下雪音效(仅五声音阶铃音,无风声)───────────────────────── /** - * 播放单次轻柔铃声(五声音阶:C5/E5/G5/C6)。 + * 播放单次铃声(五声音阶 C/E/G/C,含泛音模拟铃铛共鸣)。 * * @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 + // 五声音阶: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 osc = ctx.createOscillator(); - osc.type = "sine"; - osc.frequency.value = freq; + // 基音 + 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.18, t0); - env.gain.exponentialRampToValueAtTime(0.001, t0 + 1.8); + env.gain.setValueAtTime(0.22, t0); + env.gain.exponentialRampToValueAtTime(0.001, t0 + 2.0); - osc.connect(env); + osc1.connect(env); + osc2.connect(g2); + g2.connect(env); env.connect(masterGain); - osc.start(t0); - osc.stop(t0 + 1.9); + + osc1.start(t0); + osc1.stop(t0 + 2.1); + osc2.start(t0); + osc2.stop(t0 + 2.1); } /** - * 启动下雪音效:极低音量高频风声 + 5 次随机轻柔铃声,总时长约 10 秒。 + * 启动下雪音效:在 10 秒内随机播放 8 次铃音,无背景风声。 + * 与视觉雪效持续时间(10000ms)对齐。 * * @returns {Function} 停止函数 */ function _startSnow() { const ctx = _getCtx(); const master = ctx.createGain(); - master.gain.value = 0; + master.gain.value = 0.9; 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); - - // 随机铃声计时器 + // 随机分布 8 次铃声,间隔在 0.5~9.5s 之间 const bellTimers = []; - for (let i = 0; i < 5; i++) { - const d = 1.5 + Math.random() * 7; + 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 + 2); + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5); setTimeout(() => { try { - src.stop(); master.disconnect(); } catch (_) {} - }, 2100); - }, 10500); + }, 600); + }, 10800); return () => { clearTimeout(endTimer); bellTimers.forEach((t) => clearTimeout(t)); - master.gain.setValueAtTime(0, ctx.currentTime); try { - src.stop(); + master.gain.setValueAtTime(0, ctx.currentTime); + master.disconnect(); } catch (_) {} - setTimeout(() => { - try { - master.disconnect(); - } catch (_) {} - }, 100); }; } @@ -364,10 +395,13 @@ const EffectSounds = (() => { /** * 播放指定特效对应的音效(自动停止上一个)。 + * 静音状态下直接跳过,不做任何音频操作。 * * @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' */ function play(type) { + // 用户开启禁音则跳过 + if (localStorage.getItem("chat_sound_muted") === "1") return; stop(); try { switch (type) { @@ -388,7 +422,7 @@ const EffectSounds = (() => { } } catch (e) { console.warn( - "[EffectSounds] 音效播放失败(可能未经用户交互解锁 AudioContext):", + "[EffectSounds] 音效播放失败(可能未能解锁 AudioContext):", e, ); } diff --git a/resources/views/chat/partials/input-bar.blade.php b/resources/views/chat/partials/input-bar.blade.php index 6217d43..9ee977d 100644 --- a/resources/views/chat/partials/input-bar.blade.php +++ b/resources/views/chat/partials/input-bar.blade.php @@ -60,6 +60,11 @@ 悄悄话 + +