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')
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
+