// 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(); if (typeof TEST !== 'undefined' && TEST) return; 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(); if (typeof TEST !== 'undefined' && TEST) { if (onEnd) onEnd(); return; } 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); }, };