功能:特效音效三项优化 + 禁音开关
音效改进(effect-sounds.js):
1. 雷电 - 三层合成更贴近真实:
①放电啪声(带通噪声 ~50ms)
②低频轰鸣(120→38Hz 扫频,快冲击 2s 衰减)
③极低频滚动余韵(55→22Hz,缓慢堆积 3.6s 长衰减)
2. 下雨 - 音量 0.40→0.15,时长与视觉效果统一(8000ms)
3. 下雪 - 移除风声,只保留五声音阶铃音(C/E/G/C)
铃音加第二泛音(×2.76倍频)模拟真实铃铛共鸣感
8次随机铃声分布在 10 秒内
禁音开关:
- input-bar.blade.php:悄悄话旁新增「🔇 禁音」复选框
- scripts.blade.php:toggleSoundMute() 函数,
localStorage chat_sound_muted 持久化,
DOMContentLoaded 恢复复选框状态
- effect-sounds.js:play() 先检查 chat_sound_muted 标志
This commit is contained in:
+150
-116
@@ -1,18 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* 文件功能:聊天室特效音效引擎(Web Audio API 实时合成)
|
* 文件功能:聊天室特效音效引擎(Web Audio API 实时合成)
|
||||||
*
|
*
|
||||||
* 所有音效通过 Web Audio API 实时合成,无需外部音频文件,
|
* 所有音效通过 Web Audio API 实时合成,无需外部音频文件。
|
||||||
* 跨浏览器兼容(Chrome / Firefox / Safari / Edge)。
|
|
||||||
*
|
*
|
||||||
* 对外 API:
|
* 对外 API:
|
||||||
* EffectSounds.play(type) 播放指定特效的背景音效
|
* EffectSounds.play(type) 播放指定特效的背景音效
|
||||||
* EffectSounds.stop() 停止并释放当前音效资源
|
* EffectSounds.stop() 停止并释放当前音效资源
|
||||||
*
|
*
|
||||||
* 支持的 type:
|
* 支持的 type:
|
||||||
* lightning 雷鸣闪电(噪声爆裂 + 低频渐衰雷鸣)
|
* lightning 雷鸣闪电(三层合成:放电啪声 + 低频轰鸣 + 极低频滚动余韵)
|
||||||
* fireworks 烟花(发射滑音 + 高频爆炸噪声)
|
* fireworks 烟花(发射滑音 + 高频爆炸噪声)
|
||||||
* rain 下雨(带通白噪声持续淡入淡出)
|
* rain 下雨(双层带通白噪声,低音量,与视觉 8s 同步)
|
||||||
* snow 下雪(极低音风声 + 偶发轻柔铃音)
|
* snow 下雪(仅五声音阶铃音,无风声)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const EffectSounds = (() => {
|
const EffectSounds = (() => {
|
||||||
@@ -25,7 +24,7 @@ const EffectSounds = (() => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 懒加载并返回 AudioContext。
|
* 懒加载并返回 AudioContext。
|
||||||
* 浏览器要求首次创建必须在用户手势后,聊天室首次点击任何按钮即可解锁。
|
* 浏览器要求首次创建必须在用户手势后,聊天室点击任何按钮即可解锁。
|
||||||
*
|
*
|
||||||
* @returns {AudioContext}
|
* @returns {AudioContext}
|
||||||
*/
|
*/
|
||||||
@@ -46,8 +45,8 @@ const EffectSounds = (() => {
|
|||||||
* @param {number} duration 时长(秒)
|
* @param {number} duration 时长(秒)
|
||||||
* @returns {AudioBuffer}
|
* @returns {AudioBuffer}
|
||||||
*/
|
*/
|
||||||
function _makeNoise(ctx, duration = 2) {
|
function _makeNoise(ctx, duration) {
|
||||||
const len = ctx.sampleRate * duration;
|
const len = Math.ceil(ctx.sampleRate * duration);
|
||||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||||
const data = buf.getChannelData(0);
|
const data = buf.getChannelData(0);
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
@@ -56,69 +55,101 @@ const EffectSounds = (() => {
|
|||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 雷电音效 ──────────────────────────────────────────────────
|
// ─── 雷电音效(三层合成,贴近真实雷声)────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 播放单次雷鸣:低频白噪声爆裂,快速冲击后缓慢衰减。
|
* 单次真实雷声 = 放电啪声 + 低频轰鸣 + 极低频滚动余韵。
|
||||||
|
*
|
||||||
|
* 物理模型:
|
||||||
|
* crack — 闪电通道瞬间放电产生的高频尖啪(30~50ms)
|
||||||
|
* boom — 空气急速膨胀产生的低频冲击(40~120Hz,~150ms 达峰值,衰减 2s)
|
||||||
|
* rumble — 回声与地面反射产生的极低频滚动(20~60Hz,缓慢衰减 3.5s)
|
||||||
*
|
*
|
||||||
* @param {AudioContext} ctx
|
* @param {AudioContext} ctx
|
||||||
* @param {GainNode} masterGain 主音量节点
|
* @param {GainNode} masterGain
|
||||||
* @param {number} delay 相对于当前时刻的延迟(秒)
|
* @param {number} delay 延迟(秒)
|
||||||
*/
|
*/
|
||||||
function _thunderCrack(ctx, masterGain, 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;
|
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);
|
// ① 放电啪声(crack):带通白噪声,极短 ~50ms
|
||||||
lpf.connect(env);
|
const snap = ctx.createBufferSource();
|
||||||
env.connect(masterGain);
|
snap.buffer = _makeNoise(ctx, 0.12);
|
||||||
src.start(t0);
|
const snapBpf = ctx.createBiquadFilter();
|
||||||
src.stop(t0 + 2.5);
|
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} 停止函数
|
* @returns {Function} 停止函数
|
||||||
*/
|
*/
|
||||||
function _startLightning() {
|
function _startLightning() {
|
||||||
const ctx = _getCtx();
|
const ctx = _getCtx();
|
||||||
const master = ctx.createGain();
|
const master = ctx.createGain();
|
||||||
master.gain.value = 0.75;
|
master.gain.value = 0.8;
|
||||||
master.connect(ctx.destination);
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
// 10 次闪电,间隔 400~800ms(与视觉特效节奏对应)
|
// 10 次闪电,间隔 400~800ms,雷声滞后闪电 50~300ms(光速>声速)
|
||||||
let t = 0.3;
|
let t = 0.3;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
// 雷声稍晚于闪电(模拟光速 > 声速)
|
_thunderCrack(ctx, master, t + 0.06 + Math.random() * 0.25);
|
||||||
_thunderCrack(ctx, master, t + 0.05 + Math.random() * 0.25);
|
|
||||||
t += 0.4 + Math.random() * 0.4;
|
t += 0.4 + Math.random() * 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 视觉特效 ~7 秒,预留尾声淡出
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5);
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
master.disconnect();
|
master.disconnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 600);
|
}, 1100);
|
||||||
}, 7800);
|
}, 8000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -132,16 +163,16 @@ const EffectSounds = (() => {
|
|||||||
// ─── 烟花音效 ──────────────────────────────────────────────────
|
// ─── 烟花音效 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 播放单颗烟花音:发射滑音 + 空中爆炸噪声。
|
* 播放单颗烟花:发射滑音 + 空中爆炸噪声。
|
||||||
*
|
*
|
||||||
* @param {AudioContext} ctx
|
* @param {AudioContext} ctx
|
||||||
* @param {GainNode} masterGain
|
* @param {GainNode} masterGain
|
||||||
* @param {number} delay 延迟(秒)
|
* @param {number} delay 延迟(秒)
|
||||||
*/
|
*/
|
||||||
function _fireworkPop(ctx, masterGain, delay) {
|
function _fireworkPop(ctx, masterGain, delay) {
|
||||||
const t0 = ctx.currentTime + delay;
|
const t0 = ctx.currentTime + delay;
|
||||||
|
|
||||||
// 发射音:200Hz → 700Hz 上升滑音
|
// 发射音:200→700Hz 上升滑音
|
||||||
const osc = ctx.createOscillator();
|
const osc = ctx.createOscillator();
|
||||||
osc.type = "sine";
|
osc.type = "sine";
|
||||||
osc.frequency.setValueAtTime(200, t0);
|
osc.frequency.setValueAtTime(200, t0);
|
||||||
@@ -154,7 +185,7 @@ const EffectSounds = (() => {
|
|||||||
osc.start(t0);
|
osc.start(t0);
|
||||||
osc.stop(t0 + 0.23);
|
osc.stop(t0 + 0.23);
|
||||||
|
|
||||||
// 爆炸音:带通白噪声爆裂
|
// 爆炸音:带通噪声
|
||||||
const boom = ctx.createBufferSource();
|
const boom = ctx.createBufferSource();
|
||||||
boom.buffer = _makeNoise(ctx, 0.9);
|
boom.buffer = _makeNoise(ctx, 0.9);
|
||||||
const bpf = ctx.createBiquadFilter();
|
const bpf = ctx.createBiquadFilter();
|
||||||
@@ -174,7 +205,7 @@ const EffectSounds = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动烟花音效:约 8 颗烟花交错触发,总时长约 8 秒。
|
* 启动烟花音效。
|
||||||
*
|
*
|
||||||
* @returns {Function} 停止函数
|
* @returns {Function} 停止函数
|
||||||
*/
|
*/
|
||||||
@@ -207,10 +238,11 @@ const EffectSounds = (() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 下雨音效 ──────────────────────────────────────────────────
|
// ─── 下雨音效(低音量,与视觉 8000ms 对齐)─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动下雨音效:带通白噪声持续循环,淡入 1.5 秒,8 秒后淡出。
|
* 启动下雨音效:双层带通白噪声,主音量 0.15,随视觉效果结束淡出。
|
||||||
|
* 视觉雨效持续 8000ms,音效在相同时间开始淡出。
|
||||||
*
|
*
|
||||||
* @returns {Function} 停止函数
|
* @returns {Function} 停止函数
|
||||||
*/
|
*/
|
||||||
@@ -220,143 +252,142 @@ const EffectSounds = (() => {
|
|||||||
master.gain.value = 0;
|
master.gain.value = 0;
|
||||||
master.connect(ctx.destination);
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
// 白噪声循环
|
// 主雨声:中频 1200Hz 沙沙感
|
||||||
const src = ctx.createBufferSource();
|
const src1 = ctx.createBufferSource();
|
||||||
src.buffer = _makeNoise(ctx, 3);
|
src1.buffer = _makeNoise(ctx, 3);
|
||||||
src.loop = true;
|
src1.loop = true;
|
||||||
|
const bpf1 = ctx.createBiquadFilter();
|
||||||
|
bpf1.type = "bandpass";
|
||||||
|
bpf1.frequency.value = 1200;
|
||||||
|
bpf1.Q.value = 0.3;
|
||||||
|
|
||||||
// 中高频带通:1200Hz 沙沙感
|
// 细密层:高频 3500Hz,增加雨点密度感
|
||||||
const bpf = ctx.createBiquadFilter();
|
const src2 = ctx.createBufferSource();
|
||||||
bpf.type = "bandpass";
|
src2.buffer = _makeNoise(ctx, 3);
|
||||||
bpf.frequency.value = 1200;
|
src2.loop = true;
|
||||||
bpf.Q.value = 0.3;
|
|
||||||
|
|
||||||
// 添加第二层高频(细密雨点感)
|
|
||||||
const bpf2 = ctx.createBiquadFilter();
|
const bpf2 = ctx.createBiquadFilter();
|
||||||
bpf2.type = "bandpass";
|
bpf2.type = "bandpass";
|
||||||
bpf2.frequency.value = 3500;
|
bpf2.frequency.value = 3500;
|
||||||
bpf2.Q.value = 1;
|
bpf2.Q.value = 1;
|
||||||
const g2 = ctx.createGain();
|
const g2 = ctx.createGain();
|
||||||
g2.gain.value = 0.4;
|
g2.gain.value = 0.35;
|
||||||
|
|
||||||
src.connect(bpf);
|
src1.connect(bpf1);
|
||||||
bpf.connect(master);
|
bpf1.connect(master);
|
||||||
src.connect(bpf2);
|
src2.connect(bpf2);
|
||||||
bpf2.connect(g2);
|
bpf2.connect(g2);
|
||||||
g2.connect(master);
|
g2.connect(master);
|
||||||
src.start();
|
src1.start();
|
||||||
|
src2.start();
|
||||||
|
|
||||||
// 淡入
|
// 淡入 1.5s → 最高音量 0.15(比之前降低约 60%)
|
||||||
master.gain.linearRampToValueAtTime(0.4, ctx.currentTime + 1.5);
|
master.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 1.5);
|
||||||
|
|
||||||
|
// 与视觉雨效对齐:8000ms 后开始 2s 淡出
|
||||||
const endTimer = setTimeout(() => {
|
const endTimer = setTimeout(() => {
|
||||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.5);
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.0);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
src.stop();
|
src1.stop();
|
||||||
|
src2.stop();
|
||||||
master.disconnect();
|
master.disconnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 1600);
|
}, 2200);
|
||||||
}, 8500);
|
}, 8000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(endTimer);
|
clearTimeout(endTimer);
|
||||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
try {
|
|
||||||
src.stop();
|
|
||||||
} catch (_) {}
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
|
src1.stop();
|
||||||
|
src2.stop();
|
||||||
master.disconnect();
|
master.disconnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 下雪音效 ──────────────────────────────────────────────────
|
// ─── 下雪音效(仅五声音阶铃音,无风声)─────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 播放单次轻柔铃声(五声音阶:C5/E5/G5/C6)。
|
* 播放单次铃声(五声音阶 C/E/G/C,含泛音模拟铃铛共鸣)。
|
||||||
*
|
*
|
||||||
* @param {AudioContext} ctx
|
* @param {AudioContext} ctx
|
||||||
* @param {GainNode} masterGain
|
* @param {GainNode} masterGain
|
||||||
* @param {number} delay 延迟(秒)
|
* @param {number} delay 延迟(秒)
|
||||||
*/
|
*/
|
||||||
function _snowBell(ctx, masterGain, 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 freq = freqs[Math.floor(Math.random() * freqs.length)];
|
||||||
const t0 = ctx.currentTime + delay;
|
const t0 = ctx.currentTime + delay;
|
||||||
|
|
||||||
const osc = ctx.createOscillator();
|
// 基音
|
||||||
osc.type = "sine";
|
const osc1 = ctx.createOscillator();
|
||||||
osc.frequency.value = freq;
|
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();
|
const env = ctx.createGain();
|
||||||
env.gain.setValueAtTime(0.18, t0);
|
env.gain.setValueAtTime(0.22, t0);
|
||||||
env.gain.exponentialRampToValueAtTime(0.001, t0 + 1.8);
|
env.gain.exponentialRampToValueAtTime(0.001, t0 + 2.0);
|
||||||
|
|
||||||
osc.connect(env);
|
osc1.connect(env);
|
||||||
|
osc2.connect(g2);
|
||||||
|
g2.connect(env);
|
||||||
env.connect(masterGain);
|
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} 停止函数
|
* @returns {Function} 停止函数
|
||||||
*/
|
*/
|
||||||
function _startSnow() {
|
function _startSnow() {
|
||||||
const ctx = _getCtx();
|
const ctx = _getCtx();
|
||||||
const master = ctx.createGain();
|
const master = ctx.createGain();
|
||||||
master.gain.value = 0;
|
master.gain.value = 0.9;
|
||||||
master.connect(ctx.destination);
|
master.connect(ctx.destination);
|
||||||
|
|
||||||
// 高频风声(非常安静)
|
// 随机分布 8 次铃声,间隔在 0.5~9.5s 之间
|
||||||
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 = [];
|
const bellTimers = [];
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
const d = 1.5 + Math.random() * 7;
|
const d = 0.3 + Math.random() * 9.0;
|
||||||
bellTimers.push(
|
bellTimers.push(
|
||||||
setTimeout(() => _snowBell(ctx, master, 0), d * 1000),
|
setTimeout(() => _snowBell(ctx, master, 0), d * 1000),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const endTimer = setTimeout(() => {
|
const endTimer = setTimeout(() => {
|
||||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2);
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
src.stop();
|
|
||||||
master.disconnect();
|
master.disconnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 2100);
|
}, 600);
|
||||||
}, 10500);
|
}, 10800);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(endTimer);
|
clearTimeout(endTimer);
|
||||||
bellTimers.forEach((t) => clearTimeout(t));
|
bellTimers.forEach((t) => clearTimeout(t));
|
||||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
|
||||||
try {
|
try {
|
||||||
src.stop();
|
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
|
master.disconnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
master.disconnect();
|
|
||||||
} catch (_) {}
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,10 +395,13 @@ const EffectSounds = (() => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 播放指定特效对应的音效(自动停止上一个)。
|
* 播放指定特效对应的音效(自动停止上一个)。
|
||||||
|
* 静音状态下直接跳过,不做任何音频操作。
|
||||||
*
|
*
|
||||||
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow'
|
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow'
|
||||||
*/
|
*/
|
||||||
function play(type) {
|
function play(type) {
|
||||||
|
// 用户开启禁音则跳过
|
||||||
|
if (localStorage.getItem("chat_sound_muted") === "1") return;
|
||||||
stop();
|
stop();
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -388,7 +422,7 @@ const EffectSounds = (() => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[EffectSounds] 音效播放失败(可能未经用户交互解锁 AudioContext):",
|
"[EffectSounds] 音效播放失败(可能未能解锁 AudioContext):",
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@
|
|||||||
悄悄话
|
悄悄话
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label title="勾选后关闭所有特效声音">
|
||||||
|
<input type="checkbox" id="sound_muted" onchange="toggleSoundMute(this.checked)">
|
||||||
|
🔇 禁音
|
||||||
|
</label>
|
||||||
|
|
||||||
<label title="自动滚屏到最新消息">
|
<label title="自动滚屏到最新消息">
|
||||||
<input type="checkbox" id="auto_scroll" checked>
|
<input type="checkbox" id="auto_scroll" checked>
|
||||||
滚屏
|
滚屏
|
||||||
|
|||||||
@@ -1105,8 +1105,28 @@
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
applyFontSize(saved);
|
applyFontSize(saved);
|
||||||
}
|
}
|
||||||
|
// 恢复禁音复选框状态
|
||||||
|
const muted = localStorage.getItem('chat_sound_muted') === '1';
|
||||||
|
const muteChk = document.getElementById('sound_muted');
|
||||||
|
if (muteChk) muteChk.checked = muted;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 特效禁音开关 ─────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* 切换特效音效的静音状态,持久化到 localStorage。
|
||||||
|
* 开启禁音后立即停止当前正在播放的音效。
|
||||||
|
*
|
||||||
|
* @param {boolean} muted true = 禁音,false = 开启声音
|
||||||
|
*/
|
||||||
|
function toggleSoundMute(muted) {
|
||||||
|
localStorage.setItem('chat_sound_muted', muted ? '1' : '0');
|
||||||
|
if (muted && typeof EffectSounds !== 'undefined') {
|
||||||
|
EffectSounds.stop(); // 立即停止当前音效
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.toggleSoundMute = toggleSoundMute;
|
||||||
|
|
||||||
|
|
||||||
// ── 发送消息(Enter 发送,防 IME 输入法重复触发)────────
|
// ── 发送消息(Enter 发送,防 IME 输入法重复触发)────────
|
||||||
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
|
// 用 isComposing 标记中文输入法的组词状态,组词期间过滤掉 Enter
|
||||||
let _imeComposing = false;
|
let _imeComposing = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user