Files
blind/game/js/audio.js
T
2026-05-19 00:34:23 +08:00

284 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// audio.js — 音频管理器 + 合成 UI 音效
class AudioManager {
constructor() {
this._ctx = null;
this._master = null;
this._voiceQueue = [];
this._voiceEl = null;
this._ringEl = null;
this._bgmEl = null;
this._ambEl = null;
this._preloaded = {};
}
// ── 初始化(必须在用户手势后调用)──
async init() {
this._ctx = new (window.AudioContext || window.webkitAudioContext)();
if (this._ctx.state === 'suspended') await this._ctx.resume();
this._master = this._ctx.createGain();
this._master.gain.value = 1.0;
this._master.connect(this._ctx.destination);
}
// ── 合成短音(UI 反馈音效)──
synth({ freq = 440, freq2 = null, dur = 100, type = 'sine', vol = 0.3 } = {}) {
if (!this._ctx) return;
const t = this._ctx.currentTime;
const d = dur / 1000;
const osc = this._ctx.createOscillator();
const gain = this._ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (freq2 != null) {
osc.frequency.linearRampToValueAtTime(freq2, t + d);
}
gain.gain.setValueAtTime(vol, t);
gain.gain.setValueAtTime(vol, t + d * 0.75);
gain.gain.linearRampToValueAtTime(0, t + d);
osc.connect(gain);
gain.connect(this._master);
osc.start(t);
osc.stop(t + d + 0.05);
}
// ── 预加载短音效 ──
async preload(key, url) {
if (this._preloaded[key]) return;
try {
const res = await fetch(url);
const buf = await res.arrayBuffer();
this._preloaded[key] = await this._ctx.decodeAudioData(buf);
} catch (e) {
console.warn('[Audio] preload failed:', key, e);
}
}
// ── 播放预加载音效 ──
playFX(key, { vol = 1.0, loop = false } = {}) {
const buf = this._preloaded[key];
if (!buf) return null;
const src = this._ctx.createBufferSource();
src.buffer = buf;
src.loop = loop;
const gain = this._ctx.createGain();
gain.gain.value = vol;
src.connect(gain);
gain.connect(this._master);
src.start();
return { src, gain, stop: () => { try { src.stop(); } catch (_) {} } };
}
// ── 铃声(循环)──
async startRingtone(url) {
this.stopRingtone();
this._ringEl = new Audio(url);
this._ringEl.loop = true;
this._ringEl.volume = 1.0;
try { await this._ringEl.play(); } catch (e) { console.warn('[Audio] ring error', e); }
}
stopRingtone() {
if (this._ringEl) {
this._ringEl.pause();
this._ringEl.src = '';
this._ringEl = null;
}
}
// ── 语音播放(多段顺序)──
playVoice(urls, { vol = 1.0, onEnd = null } = {}) {
this.stopVoice();
this._voiceQueue = [...urls];
this._playNext(vol, onEnd);
}
_playNext(vol, onEnd) {
if (this._voiceQueue.length === 0) {
this._voiceEl = null;
if (onEnd) onEnd();
return;
}
const url = this._voiceQueue.shift();
const el = new Audio(url);
el.volume = vol;
this._voiceEl = el;
el.onended = () => this._playNext(vol, onEnd);
el.onerror = () => {
console.warn('[Audio] voice error:', url);
this._playNext(vol, onEnd);
};
el.play().catch(e => console.warn('[Audio] play error', e));
}
stopVoice() {
this._voiceQueue = [];
if (this._voiceEl) {
this._voiceEl.onended = null;
this._voiceEl.pause();
this._voiceEl = null;
}
}
isVoicePlaying() {
return !!(this._voiceEl && !this._voiceEl.paused);
}
// ── BGM(淡入淡出切换)──
async startBGM(url, { vol = 0.35, fade = 2000 } = {}) {
if (this._bgmEl) {
const old = this._bgmEl;
this._fadeOut(old, fade / 2).then(() => { old.pause(); old.src = ''; });
}
const el = new Audio(url);
el.loop = true;
el.volume = 0;
this._bgmEl = el;
try {
await el.play();
this._fadeTo(el, vol, fade);
} catch (e) { console.warn('[Audio] BGM error', e); }
}
stopBGM(fade = 2000) {
if (this._bgmEl) {
const el = this._bgmEl;
this._bgmEl = null;
this._fadeOut(el, fade).then(() => { el.pause(); el.src = ''; });
}
}
// ── 环境音 ──
async startAmbience(url, { vol = 0.25 } = {}) {
if (this._ambEl) { this._ambEl.pause(); this._ambEl.src = ''; }
const el = new Audio(url);
el.loop = true;
el.volume = vol;
this._ambEl = el;
try { await el.play(); } catch (e) { console.warn('[Audio] ambience error', e); }
}
stopAmbience() {
if (this._ambEl) { this._ambEl.pause(); this._ambEl.src = ''; this._ambEl = null; }
}
// ── 音量渐变 ──
_fadeTo(el, target, duration) {
const start = el.volume;
const steps = 30;
const dt = duration / steps;
const dv = (target - start) / steps;
let i = 0;
const t = setInterval(() => {
el.volume = Math.max(0, Math.min(1, start + dv * i));
if (++i >= steps) clearInterval(t);
}, dt);
}
_fadeOut(el, duration) {
return new Promise(resolve => {
const start = el.volume;
const steps = 20;
const dt = duration / steps;
const dv = start / steps;
let i = 0;
const t = setInterval(() => {
el.volume = Math.max(0, start - dv * i);
if (++i >= steps) { clearInterval(t); resolve(); }
}, dt);
});
}
}
const Snd = new AudioManager();
// ══════════════════════════════════════════════════
// SFX_UI — 合成 UI 音效(不依赖音频文件)
// ══════════════════════════════════════════════════
const SFX_UI = {
// 确认操作(单击)
tap() {
Snd.synth({ freq: 880, dur: 45, vol: 0.2 });
},
// 滑动确认
swipe() {
Snd.synth({ freq: 660, dur: 60, vol: 0.18 });
},
// 无效操作
error() {
Snd.synth({ freq: 200, dur: 100, type: 'square', vol: 0.1 });
},
// 新消息到达
notify() {
Snd.synth({ freq: 523, dur: 100, vol: 0.25 });
setTimeout(() => Snd.synth({ freq: 659, dur: 130, vol: 0.2 }), 130);
},
// 进入选择模式(三音上行)
choiceReady() {
Snd.synth({ freq: 440, dur: 80, type: 'triangle', vol: 0.18 });
setTimeout(() => Snd.synth({ freq: 523, dur: 80, type: 'triangle', vol: 0.18 }), 100);
setTimeout(() => Snd.synth({ freq: 659, dur: 120, type: 'triangle', vol: 0.18 }), 200);
},
// 选择:温暖/接受(右滑)— 上行音
choiceWarm() {
Snd.synth({ freq: 440, freq2: 659, dur: 140, vol: 0.22 });
},
// 选择:冷淡/拒绝(左滑)— 下行音
choiceCold() {
Snd.synth({ freq: 523, freq2: 349, dur: 140, vol: 0.22 });
},
// 选择:好奇/追问(上滑)— 快速上行
choiceCurious() {
Snd.synth({ freq: 523, freq2: 784, dur: 120, vol: 0.22 });
},
// 选择:沉默/退缩(下滑)— 极轻低音
choiceSilent() {
Snd.synth({ freq: 330, dur: 200, vol: 0.08 });
},
// 跳过 / 忽略
skip() {
Snd.synth({ freq: 587, freq2: 392, dur: 80, vol: 0.12 });
},
// 下一条消息即将到来
next() {
Snd.synth({ freq: 440, dur: 80, vol: 0.1 });
setTimeout(() => Snd.synth({ freq: 523, dur: 100, vol: 0.13 }), 120);
},
// 打字/录音音效(~800ms
typing() {
for (let i = 0; i < 6; i++) {
setTimeout(() => {
Snd.synth({ freq: 600 + Math.random() * 200, dur: 30, type: 'sine', vol: 0.1 });
}, i * 120);
}
},
// 发送音效
send() {
Snd.synth({ freq: 400, freq2: 900, dur: 120, type: 'sine', vol: 0.18 });
},
// 结局音效
ending() {
Snd.synth({ freq: 523, dur: 400, vol: 0.25 });
setTimeout(() => Snd.synth({ freq: 659, dur: 400, vol: 0.2 }), 450);
setTimeout(() => Snd.synth({ freq: 784, dur: 600, vol: 0.15 }), 900);
},
};