功能:4种全屏特效增加 Web Audio API 实时合成音效
新建 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 前引入
This commit is contained in:
@@ -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") {
|
||||
|
||||
410
public/js/effects/effect-sounds.js
Normal file
410
public/js/effects/effect-sounds.js
Normal file
@@ -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 };
|
||||
})();
|
||||
@@ -115,6 +115,7 @@
|
||||
@include('chat.partials.user-actions')
|
||||
|
||||
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
||||
<script src="/js/effects/effect-sounds.js"></script>
|
||||
<script src="/js/effects/effect-manager.js"></script>
|
||||
<script src="/js/effects/fireworks.js"></script>
|
||||
<script src="/js/effects/rain.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user