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 @@
悄悄话
+
+