930 lines
30 KiB
JavaScript
930 lines
30 KiB
JavaScript
/**
|
||
* 文件功能:聊天室特效音效引擎(Web Audio API 实时合成)
|
||
*
|
||
* 所有音效通过 Web Audio API 实时合成,无需外部音频文件。
|
||
*
|
||
* 对外 API:
|
||
* EffectSounds.play(type) 播放指定特效的背景音效
|
||
* EffectSounds.stop() 停止并释放当前音效资源
|
||
* EffectSounds.ding() 播放简短叮咚通知音(大/小卡片弹出时使用)
|
||
*
|
||
* 支持的 type:
|
||
* lightning 雷鸣闪电(三层合成:放电啪声 + 低频轰鸣 + 极低频滚动余韵)
|
||
* fireworks 烟花(发射滑音 + 高频爆炸噪声)
|
||
* rain 下雨(双层带通白噪声,低音量,与视觉 8s 同步)
|
||
* snow 下雪(仅五声音阶铃音,无风声)
|
||
* sakura 樱花(轻柔风铃 + 微风扫过)
|
||
* meteors 流星(高速掠空呼啸)
|
||
* gold-rain 金币雨(金属叮当)
|
||
* hearts 爱心飘落(温暖双音)
|
||
* confetti 彩带庆典(礼炮碎响 + 清亮点缀)
|
||
* fireflies 萤火虫(稀疏微光铃音)
|
||
*/
|
||
|
||
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) {
|
||
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++) {
|
||
data[i] = Math.random() * 2 - 1;
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
/**
|
||
* 调度一个带包络的简单音符。
|
||
*
|
||
* @param {AudioContext} ctx
|
||
* @param {GainNode} masterGain
|
||
* @param {{delay?:number, duration?:number, freq:number, endFreq?:number, volume?:number, type?:OscillatorType}} options
|
||
*/
|
||
function _scheduleTone(ctx, masterGain, options) {
|
||
const {
|
||
delay = 0,
|
||
duration = 0.8,
|
||
freq,
|
||
endFreq = freq,
|
||
volume = 0.18,
|
||
type = "sine",
|
||
} = options;
|
||
|
||
const t0 = ctx.currentTime + delay;
|
||
const osc = ctx.createOscillator();
|
||
const env = ctx.createGain();
|
||
osc.type = type;
|
||
osc.frequency.setValueAtTime(freq, t0);
|
||
osc.frequency.exponentialRampToValueAtTime(
|
||
Math.max(20, endFreq),
|
||
t0 + duration,
|
||
);
|
||
env.gain.setValueAtTime(Math.max(0.0001, volume), t0);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t0 + duration);
|
||
osc.connect(env);
|
||
env.connect(masterGain);
|
||
osc.start(t0);
|
||
osc.stop(t0 + duration + 0.03);
|
||
}
|
||
|
||
/**
|
||
* 调度一段带滤波扫频的噪声音色,用于呼啸、爆裂等环境声。
|
||
*
|
||
* @param {AudioContext} ctx
|
||
* @param {GainNode} masterGain
|
||
* @param {{delay?:number, duration?:number, startFreq?:number, endFreq?:number, volume?:number, q?:number, filterType?:BiquadFilterType}} options
|
||
*/
|
||
function _scheduleNoiseSweep(ctx, masterGain, options) {
|
||
const {
|
||
delay = 0,
|
||
duration = 0.6,
|
||
startFreq = 2200,
|
||
endFreq = 500,
|
||
volume = 0.16,
|
||
q = 0.8,
|
||
filterType = "bandpass",
|
||
} = options;
|
||
|
||
const t0 = ctx.currentTime + delay;
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = _makeNoise(ctx, duration + 0.15);
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = filterType;
|
||
filter.Q.value = q;
|
||
filter.frequency.setValueAtTime(startFreq, t0);
|
||
filter.frequency.exponentialRampToValueAtTime(
|
||
Math.max(60, endFreq),
|
||
t0 + duration,
|
||
);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t0);
|
||
env.gain.linearRampToValueAtTime(volume, t0 + duration * 0.18);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t0 + duration);
|
||
|
||
src.connect(filter);
|
||
filter.connect(env);
|
||
env.connect(masterGain);
|
||
src.start(t0);
|
||
src.stop(t0 + duration + 0.08);
|
||
}
|
||
|
||
// ─── 雷电音效(三层合成,贴近真实雷声)────────────────────────
|
||
|
||
/**
|
||
* 单次真实雷声 = 放电啪声 + 低频轰鸣 + 极低频滚动余韵。
|
||
*
|
||
* 物理模型:
|
||
* crack — 闪电通道瞬间放电产生的高频尖啪(30~50ms)
|
||
* boom — 空气急速膨胀产生的低频冲击(40~120Hz,~150ms 达峰值,衰减 2s)
|
||
* rumble — 回声与地面反射产生的极低频滚动(20~60Hz,缓慢衰减 3.5s)
|
||
*
|
||
* @param {AudioContext} ctx
|
||
* @param {GainNode} masterGain
|
||
* @param {number} delay 延迟(秒)
|
||
*/
|
||
function _thunderCrack(ctx, masterGain, delay) {
|
||
const t0 = ctx.currentTime + delay;
|
||
|
||
// ① 放电啪声(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 秒。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startLightning() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.8;
|
||
master.connect(ctx.destination);
|
||
|
||
// 10 次闪电,间隔 400~800ms,雷声滞后闪电 50~300ms(光速>声速)
|
||
let t = 0.3;
|
||
for (let i = 0; i < 10; i++) {
|
||
_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 + 1.0);
|
||
setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 1100);
|
||
}, 8000);
|
||
|
||
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;
|
||
|
||
// 发射音:200→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);
|
||
}
|
||
|
||
/**
|
||
* 启动烟花音效。
|
||
*
|
||
* @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 (_) {}
|
||
};
|
||
}
|
||
|
||
// ─── 下雨音效(低音量,与视觉 8000ms 对齐)─────────────────────
|
||
|
||
/**
|
||
* 启动下雨音效:双层带通白噪声,主音量 0.15,随视觉效果结束淡出。
|
||
* 视觉雨效持续 8000ms,音效在相同时间开始淡出。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startRain() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0;
|
||
master.connect(ctx.destination);
|
||
|
||
// 主雨声:中频 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;
|
||
|
||
// 细密层:高频 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.35;
|
||
|
||
src1.connect(bpf1);
|
||
bpf1.connect(master);
|
||
src2.connect(bpf2);
|
||
bpf2.connect(g2);
|
||
g2.connect(master);
|
||
src1.start();
|
||
src2.start();
|
||
|
||
// 淡入 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 + 2.0);
|
||
setTimeout(() => {
|
||
try {
|
||
src1.stop();
|
||
src2.stop();
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 2200);
|
||
}, 8000);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||
setTimeout(() => {
|
||
try {
|
||
src1.stop();
|
||
src2.stop();
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 100);
|
||
};
|
||
}
|
||
|
||
// ─── 下雪音效(仅五声音阶铃音,无风声)─────────────────────────
|
||
|
||
/**
|
||
* 播放单次铃声(五声音阶 C/E/G/C,含泛音模拟铃铛共鸣)。
|
||
*
|
||
* @param {AudioContext} ctx
|
||
* @param {GainNode} masterGain
|
||
* @param {number} delay 延迟(秒)
|
||
*/
|
||
function _snowBell(ctx, masterGain, delay) {
|
||
// 五声音阶: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 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.22, t0);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t0 + 2.0);
|
||
|
||
osc1.connect(env);
|
||
osc2.connect(g2);
|
||
g2.connect(env);
|
||
env.connect(masterGain);
|
||
|
||
osc1.start(t0);
|
||
osc1.stop(t0 + 2.1);
|
||
osc2.start(t0);
|
||
osc2.stop(t0 + 2.1);
|
||
}
|
||
|
||
/**
|
||
* 启动下雪音效:在 10 秒内随机播放 8 次铃音,无背景风声。
|
||
* 与视觉雪效持续时间(10000ms)对齐。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startSnow() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.9;
|
||
master.connect(ctx.destination);
|
||
|
||
// 随机分布 8 次铃声,间隔在 0.5~9.5s 之间
|
||
const bellTimers = [];
|
||
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 + 0.5);
|
||
setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 600);
|
||
}, 10800);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
bellTimers.forEach((t) => clearTimeout(t));
|
||
try {
|
||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
|
||
// ─── 樱花 / 流星 / 金币雨 / 爱心 / 彩带 / 萤火虫音效 ────────────
|
||
|
||
/**
|
||
* 启动樱花音效:低音量风铃加轻微风声。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startSakura() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.42;
|
||
master.connect(ctx.destination);
|
||
|
||
const notes = [523.25, 659.25, 783.99, 880];
|
||
const plan = [0.35, 1.2, 2.1, 3.6, 5.1, 6.8, 8.2];
|
||
plan.forEach((delay, index) => {
|
||
_scheduleTone(ctx, master, {
|
||
delay,
|
||
duration: 1.8,
|
||
freq: notes[index % notes.length],
|
||
endFreq: notes[index % notes.length] * 0.98,
|
||
volume: 0.11,
|
||
type: "sine",
|
||
});
|
||
});
|
||
|
||
_scheduleNoiseSweep(ctx, master, {
|
||
delay: 0.2,
|
||
duration: 3.2,
|
||
startFreq: 1400,
|
||
endFreq: 500,
|
||
volume: 0.04,
|
||
q: 0.35,
|
||
});
|
||
_scheduleNoiseSweep(ctx, master, {
|
||
delay: 4.8,
|
||
duration: 2.8,
|
||
startFreq: 1200,
|
||
endFreq: 420,
|
||
volume: 0.035,
|
||
q: 0.3,
|
||
});
|
||
|
||
const endTimer = setTimeout(() => {
|
||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||
setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 900);
|
||
}, 9800);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
try {
|
||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 启动流星音效:多段高速呼啸掠空。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startMeteors() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.55;
|
||
master.connect(ctx.destination);
|
||
|
||
[0.4, 1.5, 2.8, 4.2, 5.7, 7.1].forEach((delay) => {
|
||
_scheduleNoiseSweep(ctx, master, {
|
||
delay,
|
||
duration: 0.85,
|
||
startFreq: 3600,
|
||
endFreq: 320,
|
||
volume: 0.14,
|
||
q: 1.1,
|
||
});
|
||
_scheduleTone(ctx, master, {
|
||
delay: delay + 0.08,
|
||
duration: 0.55,
|
||
freq: 1100,
|
||
endFreq: 420,
|
||
volume: 0.06,
|
||
type: "triangle",
|
||
});
|
||
});
|
||
|
||
const endTimer = setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 9300);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 启动金币雨音效:连续金属叮当声。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startGoldRain() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.62;
|
||
master.connect(ctx.destination);
|
||
|
||
const notes = [880, 987.77, 1174.66, 1318.51];
|
||
for (let i = 0; i < 16; i++) {
|
||
const delay = 0.25 + i * 0.42;
|
||
const freq = notes[i % notes.length];
|
||
_scheduleTone(ctx, master, {
|
||
delay,
|
||
duration: 0.5,
|
||
freq,
|
||
endFreq: freq * 0.92,
|
||
volume: 0.16,
|
||
type: "triangle",
|
||
});
|
||
_scheduleTone(ctx, master, {
|
||
delay: delay + 0.02,
|
||
duration: 0.38,
|
||
freq: freq * 2.1,
|
||
endFreq: freq * 1.9,
|
||
volume: 0.06,
|
||
type: "sine",
|
||
});
|
||
}
|
||
|
||
const endTimer = setTimeout(() => {
|
||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.6);
|
||
setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 700);
|
||
}, 7600);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
try {
|
||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 启动爱心飘落音效:柔和双音和声。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startHearts() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.5;
|
||
master.connect(ctx.destination);
|
||
|
||
[0.35, 1.5, 2.9, 4.2, 5.8, 7.2].forEach((delay) => {
|
||
_scheduleTone(ctx, master, {
|
||
delay,
|
||
duration: 0.9,
|
||
freq: 523.25,
|
||
endFreq: 493.88,
|
||
volume: 0.12,
|
||
type: "triangle",
|
||
});
|
||
_scheduleTone(ctx, master, {
|
||
delay: delay + 0.12,
|
||
duration: 1.05,
|
||
freq: 659.25,
|
||
endFreq: 622.25,
|
||
volume: 0.1,
|
||
type: "sine",
|
||
});
|
||
});
|
||
|
||
const endTimer = setTimeout(() => {
|
||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||
setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 900);
|
||
}, 9000);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
try {
|
||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 启动彩带庆典音效:礼炮碎响与亮点装饰音。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startConfetti() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.58;
|
||
master.connect(ctx.destination);
|
||
|
||
[0.2, 1.4, 2.8, 4.1, 5.6].forEach((delay) => {
|
||
_scheduleNoiseSweep(ctx, master, {
|
||
delay,
|
||
duration: 0.38,
|
||
startFreq: 1800,
|
||
endFreq: 700,
|
||
volume: 0.2,
|
||
q: 0.7,
|
||
});
|
||
_scheduleTone(ctx, master, {
|
||
delay: delay + 0.04,
|
||
duration: 0.42,
|
||
freq: 1046.5,
|
||
endFreq: 880,
|
||
volume: 0.08,
|
||
type: "square",
|
||
});
|
||
});
|
||
|
||
const endTimer = setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 7200);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 启动萤火虫音效:低音量稀疏闪烁音。
|
||
*
|
||
* @returns {Function} 停止函数
|
||
*/
|
||
function _startFireflies() {
|
||
const ctx = _getCtx();
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.28;
|
||
master.connect(ctx.destination);
|
||
|
||
[0.6, 1.8, 2.7, 3.9, 5.2, 6.4, 7.6, 8.8].forEach((delay, index) => {
|
||
_scheduleTone(ctx, master, {
|
||
delay,
|
||
duration: 1.4,
|
||
freq: [659.25, 783.99, 987.77][index % 3],
|
||
endFreq: [659.25, 783.99, 987.77][index % 3] * 0.99,
|
||
volume: 0.06,
|
||
type: "sine",
|
||
});
|
||
});
|
||
|
||
const endTimer = setTimeout(() => {
|
||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0);
|
||
setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 1100);
|
||
}, 10200);
|
||
|
||
return () => {
|
||
clearTimeout(endTimer);
|
||
try {
|
||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
};
|
||
}
|
||
|
||
// ─── 公开 API ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 播放指定特效对应的音效(自动停止上一个)。
|
||
* 静音状态下直接跳过,不做任何音频操作。
|
||
* 当 AudioContext 处于 suspended 状态时,先 resume() 再播放,
|
||
* 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。
|
||
*
|
||
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies'
|
||
*/
|
||
function play(type) {
|
||
// 用户开启禁音则跳过
|
||
if (localStorage.getItem("chat_sound_muted") === "1") return;
|
||
stop();
|
||
|
||
try {
|
||
const ctx = _getCtx();
|
||
|
||
const _doPlay = () => {
|
||
try {
|
||
switch (type) {
|
||
case "lightning":
|
||
_stopFn = _startLightning();
|
||
break;
|
||
case "fireworks":
|
||
_stopFn = _startFireworks();
|
||
break;
|
||
case "rain":
|
||
_stopFn = _startRain();
|
||
break;
|
||
case "snow":
|
||
_stopFn = _startSnow();
|
||
break;
|
||
case "sakura":
|
||
_stopFn = _startSakura();
|
||
break;
|
||
case "meteors":
|
||
_stopFn = _startMeteors();
|
||
break;
|
||
case "gold-rain":
|
||
_stopFn = _startGoldRain();
|
||
break;
|
||
case "hearts":
|
||
_stopFn = _startHearts();
|
||
break;
|
||
case "confetti":
|
||
_stopFn = _startConfetti();
|
||
break;
|
||
case "fireflies":
|
||
_stopFn = _startFireflies();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
console.warn("[EffectSounds] 音效内部错误:", e);
|
||
}
|
||
};
|
||
|
||
if (ctx.state === "suspended") {
|
||
// AudioContext 尚未被用户手势激活,先 resume 再播放
|
||
ctx.resume()
|
||
.then(_doPlay)
|
||
.catch(() => {
|
||
// 浏览器拒绝 resume(无用户手势),静默处理
|
||
});
|
||
} else {
|
||
_doPlay();
|
||
}
|
||
} catch (e) {
|
||
console.warn(
|
||
"[EffectSounds] 音效播放失败(可能未能解锁 AudioContext):",
|
||
e,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── 叮咚通知音(大卡片 / 小卡片弹出时调用)─────────────────
|
||
|
||
/**
|
||
* 播放简短的叮咚两音通知音效。
|
||
*
|
||
* 音型:A5(880Hz)→ 间隔 110ms → E5(659Hz),均为正弦波,
|
||
* 快速冲击 + 铃铛式缓慢衰减,总时长约 0.5 秒。
|
||
* 禁音状态下自动跳过。
|
||
*/
|
||
function ding() {
|
||
if (localStorage.getItem("chat_sound_muted") === "1") return;
|
||
try {
|
||
const ctx = _getCtx();
|
||
|
||
const _doDing = () => {
|
||
try {
|
||
const master = ctx.createGain();
|
||
master.gain.value = 0.45;
|
||
master.connect(ctx.destination);
|
||
|
||
function _tone(freq, t0, decay) {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = "sine";
|
||
osc.frequency.value = freq;
|
||
const osc2 = ctx.createOscillator();
|
||
osc2.type = "sine";
|
||
osc2.frequency.value = freq * 2.76;
|
||
const g2 = ctx.createGain();
|
||
g2.gain.value = 0.1;
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(1.0, t0);
|
||
env.gain.exponentialRampToValueAtTime(
|
||
0.001,
|
||
t0 + decay,
|
||
);
|
||
osc.connect(env);
|
||
osc2.connect(g2);
|
||
g2.connect(env);
|
||
env.connect(master);
|
||
osc.start(t0);
|
||
osc.stop(t0 + decay + 0.05);
|
||
osc2.start(t0);
|
||
osc2.stop(t0 + decay + 0.05);
|
||
}
|
||
|
||
const now = ctx.currentTime;
|
||
_tone(880, now, 0.35); // 叮:A5
|
||
_tone(659, now + 0.11, 0.4); // 咚:E5
|
||
|
||
setTimeout(() => {
|
||
try {
|
||
master.disconnect();
|
||
} catch (_) {}
|
||
}, 700);
|
||
} catch (e) {
|
||
console.warn("[EffectSounds.ding] 通知音内部错误:", e);
|
||
}
|
||
};
|
||
|
||
if (ctx.state === "suspended") {
|
||
ctx.resume()
|
||
.then(_doDing)
|
||
.catch(() => {});
|
||
} else {
|
||
_doDing();
|
||
}
|
||
} catch (e) {
|
||
console.warn("[EffectSounds.ding] 通知音播放失败:", e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 停止当前音效并释放资源。
|
||
*/
|
||
function stop() {
|
||
if (_stopFn) {
|
||
try {
|
||
_stopFn();
|
||
} catch (_) {}
|
||
_stopFn = null;
|
||
}
|
||
}
|
||
|
||
return { play, stop, ding };
|
||
})();
|
||
|
||
// 将叮咚通知音暴露为独立全局变量,供 toast/banner 等组件直接调用
|
||
window.chatSound = { ding: () => EffectSounds.ding() };
|