420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
/**
|
||
* AudioEngine — 纯 Web Audio API 程序化合成,零音频文件依赖
|
||
*
|
||
* 使用单例模式,所有音效通过 AudioEngine.getInstance().play('soundName') 调用
|
||
* 背景音乐通过 startBGM() / stopBGM() 控制
|
||
*/
|
||
export class AudioEngine {
|
||
private static _instance: AudioEngine | null = null
|
||
private ctx: AudioContext | null = null
|
||
private bgmNodes: AudioNode[] = []
|
||
private bgmRunning = false
|
||
private muted = false
|
||
private masterGain!: GainNode
|
||
|
||
private constructor() {}
|
||
|
||
static getInstance(): AudioEngine {
|
||
if (!AudioEngine._instance) AudioEngine._instance = new AudioEngine()
|
||
return AudioEngine._instance
|
||
}
|
||
|
||
/** 首次用户交互后调用,初始化 AudioContext */
|
||
init(): void {
|
||
if (this.ctx) return
|
||
try {
|
||
this.ctx = new AudioContext()
|
||
this.masterGain = this.ctx.createGain()
|
||
this.masterGain.gain.value = 0.55
|
||
this.masterGain.connect(this.ctx.destination)
|
||
} catch {
|
||
// 浏览器不支持 Web Audio,静默失败
|
||
}
|
||
}
|
||
|
||
setMuted(v: boolean): void {
|
||
this.muted = v
|
||
if (this.masterGain) this.masterGain.gain.value = v ? 0 : 0.55
|
||
}
|
||
isMuted(): boolean { return this.muted }
|
||
|
||
private get ac(): AudioContext | null { return this.ctx }
|
||
|
||
// ─── 工具方法 ────────────────────────────────────────────────────────────────
|
||
|
||
private osc(
|
||
type: OscillatorType,
|
||
freq: number,
|
||
startTime: number,
|
||
endTime: number,
|
||
gainStart = 0.3,
|
||
gainEnd = 0,
|
||
destination: AudioNode | null = null
|
||
): void {
|
||
if (!this.ac) return
|
||
const g = this.ac.createGain()
|
||
g.gain.setValueAtTime(gainStart, startTime)
|
||
g.gain.exponentialRampToValueAtTime(Math.max(gainEnd, 0.001), endTime)
|
||
g.connect(destination ?? this.masterGain)
|
||
const o = this.ac.createOscillator()
|
||
o.type = type
|
||
o.frequency.setValueAtTime(freq, startTime)
|
||
o.connect(g)
|
||
o.start(startTime)
|
||
o.stop(endTime)
|
||
}
|
||
|
||
private noise(
|
||
startTime: number,
|
||
duration: number,
|
||
gainVal = 0.15,
|
||
hipass = 0,
|
||
destination: AudioNode | null = null
|
||
): void {
|
||
if (!this.ac) return
|
||
const bufLen = this.ac.sampleRate * duration
|
||
const buf = this.ac.createBuffer(1, bufLen, this.ac.sampleRate)
|
||
const data = buf.getChannelData(0)
|
||
for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1
|
||
const src = this.ac.createBufferSource()
|
||
src.buffer = buf
|
||
const g = this.ac.createGain()
|
||
g.gain.setValueAtTime(gainVal, startTime)
|
||
g.gain.exponentialRampToValueAtTime(0.001, startTime + duration)
|
||
if (hipass > 0) {
|
||
const f = this.ac.createBiquadFilter()
|
||
f.type = 'highpass'
|
||
f.frequency.value = hipass
|
||
src.connect(f)
|
||
f.connect(g)
|
||
} else {
|
||
src.connect(g)
|
||
}
|
||
g.connect(destination ?? this.masterGain)
|
||
src.start(startTime)
|
||
src.stop(startTime + duration)
|
||
}
|
||
|
||
// ─── 音效库 ─────────────────────────────────────────────────────────────────
|
||
|
||
/** 钉钉消息通知音:建塔 / 波次开始 */
|
||
playDingTalk(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
// 叮 — 高频正弦
|
||
this.osc('sine', 1047, t, t + 0.18, 0.4, 0.001)
|
||
// 咚 — 低频跟随
|
||
this.osc('sine', 523, t + 0.1, t + 0.35, 0.25, 0.001)
|
||
}
|
||
|
||
/** 钉钉消息发送音(更轻):波次开始 */
|
||
playNotify(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.osc('sine', 880, t, t + 0.12, 0.3, 0.001)
|
||
this.osc('sine', 1320, t + 0.07, t + 0.2, 0.2, 0.001)
|
||
}
|
||
|
||
/** 拳击音:实习生近战 */
|
||
playPunch(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.noise(t, 0.06, 0.3, 200)
|
||
this.osc('sine', 120, t, t + 0.06, 0.35, 0.001)
|
||
}
|
||
|
||
/** 键盘敲击音:资深开发射击 */
|
||
playKeyboard(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.noise(t, 0.04, 0.18, 1200)
|
||
this.osc('square', 440, t, t + 0.03, 0.12, 0.001)
|
||
}
|
||
|
||
/** PPT 大师 AOE — 空洞回声 */
|
||
playPPTBlast(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.osc('sawtooth', 180, t, t + 0.4, 0.2, 0.001)
|
||
this.osc('sine', 90, t, t + 0.5, 0.15, 0.001)
|
||
this.noise(t, 0.3, 0.08, 100)
|
||
}
|
||
|
||
/** 产品经理 — 需求变更铃 */
|
||
playPMAttack(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.osc('triangle', 660, t, t + 0.15, 0.25, 0.001)
|
||
this.osc('triangle', 440, t + 0.08, t + 0.25, 0.2, 0.001)
|
||
}
|
||
|
||
/** 运营专员 — 数据上涨 */
|
||
playOpsAttack(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.osc('sine', 523, t, t + 0.08, 0.2, 0.001)
|
||
this.osc('sine', 659, t + 0.05, t + 0.15, 0.2, 0.001)
|
||
this.osc('sine', 784, t + 0.1, t + 0.22, 0.2, 0.001)
|
||
}
|
||
|
||
/** 外包程序员 — 闷声丢出 */
|
||
playOutsourceAttack(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.noise(t, 0.08, 0.2, 300)
|
||
this.osc('sawtooth', 200, t, t + 0.08, 0.15, 0.001)
|
||
}
|
||
|
||
/** 怪物死亡 — 碎纸机 */
|
||
playEnemyDeath(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.noise(t, 0.12, 0.22, 800)
|
||
this.noise(t + 0.05, 0.08, 0.15, 2000)
|
||
}
|
||
|
||
/** Boss 登场 — 警报 + 低沉冲击 */
|
||
playBossAppear(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
// 低沉轰鸣
|
||
this.osc('sawtooth', 55, t, t + 1.2, 0.5, 0.001)
|
||
this.osc('sawtooth', 60, t + 0.1, t + 1.0, 0.3, 0.001)
|
||
// 警报扫频
|
||
for (let i = 0; i < 4; i++) {
|
||
const st = t + i * 0.25
|
||
this.osc('square', 440, st, st + 0.1, 0.25, 0.001)
|
||
this.osc('square', 660, st + 0.12, st + 0.22, 0.2, 0.001)
|
||
}
|
||
}
|
||
|
||
/** Boss 技能触发 — 电话挂断 */
|
||
playBossSkill(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.osc('square', 1480, t, t + 0.06, 0.3, 0.001)
|
||
this.osc('square', 1109, t + 0.07, t + 0.13, 0.3, 0.001)
|
||
this.osc('square', 740, t + 0.14, t + 0.22, 0.3, 0.001)
|
||
this.noise(t, 0.25, 0.1, 1000)
|
||
}
|
||
|
||
/** 塔被摧毁 — 电话挂断 */
|
||
playTowerDestroyed(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.osc('square', 440, t, t + 0.1, 0.35, 0.001)
|
||
this.osc('square', 350, t + 0.1, t + 0.25, 0.35, 0.001)
|
||
this.osc('square', 220, t + 0.2, t + 0.4, 0.35, 0.001)
|
||
this.noise(t, 0.4, 0.12, 500)
|
||
}
|
||
|
||
/** 新波次开始 — 钉钉来电铃声 */
|
||
playWaveStart(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
const pattern = [0, 0.18, 0.36, 0.64, 0.82, 1.0]
|
||
const freqs = [784, 784, 784, 659, 784, 880]
|
||
pattern.forEach((delay, i) => {
|
||
this.osc('sine', freqs[i], t + delay, t + delay + 0.14, 0.3, 0.001)
|
||
})
|
||
}
|
||
|
||
/** KPI 告警(KPI < 30%)— 紧张低音 */
|
||
playKPIWarning(): void {
|
||
if (!this.ac) return
|
||
const t = this.ac.currentTime
|
||
this.osc('sawtooth', 110, t, t + 0.3, 0.4, 0.001)
|
||
this.osc('sawtooth', 146, t + 0.1, t + 0.3, 0.3, 0.001)
|
||
}
|
||
|
||
// ─── 背景音乐 ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 启动背景音乐
|
||
* 风格:轻快 chiptune / 像素塔防,BPM=128,C大调,4层结构
|
||
* 完全程序化合成,无音频文件依赖
|
||
*/
|
||
startBGM(): void {
|
||
if (!this.ac || this.bgmRunning) return
|
||
this.bgmRunning = true
|
||
this._scheduleBGM()
|
||
}
|
||
|
||
stopBGM(): void {
|
||
this.bgmRunning = false
|
||
this.bgmNodes.forEach(n => {
|
||
try { (n as OscillatorNode).stop?.() } catch { /* ignore */ }
|
||
})
|
||
this.bgmNodes = []
|
||
}
|
||
|
||
private _scheduleBGM(): void {
|
||
if (!this.ac || !this.bgmRunning) return
|
||
const ac = this.ac
|
||
const t = ac.currentTime
|
||
|
||
const BPM = 128
|
||
const beat = 60 / BPM // 0.469s / 拍
|
||
const bar = beat * 4 // 1.875s / 小节
|
||
const BARS = 8 // 循环 8 小节
|
||
const loopLen = bar * BARS // ≈ 15s
|
||
|
||
// ── 辅助:创建带 Gain 的振荡器,自动连接到 masterGain ──────────────────
|
||
const addOsc = (
|
||
type: OscillatorType,
|
||
freq: number,
|
||
start: number,
|
||
end: number,
|
||
vol: number,
|
||
freqEnd?: number
|
||
): void => {
|
||
const g = ac.createGain()
|
||
g.gain.setValueAtTime(vol, start)
|
||
g.gain.exponentialRampToValueAtTime(0.0001, end)
|
||
g.connect(this.masterGain)
|
||
const o = ac.createOscillator()
|
||
o.type = type
|
||
o.frequency.setValueAtTime(freq, start)
|
||
if (freqEnd !== undefined) {
|
||
o.frequency.linearRampToValueAtTime(freqEnd, end)
|
||
}
|
||
o.connect(g)
|
||
o.start(start)
|
||
o.stop(end)
|
||
this.bgmNodes.push(o)
|
||
}
|
||
|
||
// ── 辅助:创建打击音(noise burst) ────────────────────────────────────
|
||
const addDrum = (start: number, dur: number, vol: number, hp = 0): void => {
|
||
const bufLen = Math.ceil(ac.sampleRate * dur)
|
||
const buf = ac.createBuffer(1, bufLen, ac.sampleRate)
|
||
const d = buf.getChannelData(0)
|
||
for (let i = 0; i < bufLen; i++) d[i] = Math.random() * 2 - 1
|
||
const src = ac.createBufferSource()
|
||
src.buffer = buf
|
||
const g = ac.createGain()
|
||
g.gain.setValueAtTime(vol, start)
|
||
g.gain.exponentialRampToValueAtTime(0.0001, start + dur)
|
||
if (hp > 0) {
|
||
const f = ac.createBiquadFilter()
|
||
f.type = 'highpass'
|
||
f.frequency.value = hp
|
||
src.connect(f); f.connect(g)
|
||
} else {
|
||
src.connect(g)
|
||
}
|
||
g.connect(this.masterGain)
|
||
src.start(start)
|
||
src.stop(start + dur)
|
||
}
|
||
|
||
// ── 音符频率表(C4 = 261.63 Hz) ──────────────────────────────────────
|
||
// C4 D4 E4 F4 G4 A4 B4 C5 D5 E5 G5
|
||
const N: Record<string, number> = {
|
||
C4: 261.63, D4: 293.66, E4: 329.63, F4: 349.23,
|
||
G4: 392.00, A4: 440.00, B4: 493.88,
|
||
C5: 523.25, D5: 587.33, E5: 659.25, G5: 783.99,
|
||
// 低八度
|
||
C3: 130.81, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00,
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 层 1:底鼓 + 军鼓(kick on 1,3;snare on 2,4)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
for (let b = 0; b < BARS * 4; b++) {
|
||
const st = t + b * beat
|
||
const pos = b % 4 // 每小节内位置 0-3
|
||
|
||
if (pos === 0 || pos === 2) {
|
||
// Kick:低频 sine 扫频(100→30Hz)+ noise burst
|
||
addOsc('sine', 100, st, st + 0.18, 0.45, 30)
|
||
addDrum(st, 0.12, 0.12, 0)
|
||
}
|
||
if (pos === 1 || pos === 3) {
|
||
// Snare:中频 noise + 轻微 sine
|
||
addDrum(st, 0.1, 0.18, 800)
|
||
addOsc('sine', 220, st, st + 0.07, 0.08)
|
||
}
|
||
// Hi-hat 每半拍(8 分音符)
|
||
addDrum(st, 0.05, 0.07, 6000)
|
||
addDrum(st + beat / 2, 0.04, 0.05, 6000)
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 层 2:和弦低音线 (square, 低八度)
|
||
// 进行:C - F - G - C - Am - F - G - C
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const bassChord = [
|
||
N.C3, N.F3, N.G3, N.C3,
|
||
N.A3, N.F3, N.G3, N.C3,
|
||
]
|
||
for (let b = 0; b < BARS; b++) {
|
||
const st = t + b * bar
|
||
const freq = bassChord[b]
|
||
// 每小节 4 拍低音 arpeggio(根音每拍重复)
|
||
for (let i = 0; i < 4; i++) {
|
||
addOsc('square', freq, st + i * beat, st + i * beat + beat * 0.8, 0.1)
|
||
}
|
||
// 五度音(和声感)
|
||
addOsc('triangle', freq * 1.5, st, st + bar * 0.9, 0.035)
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 层 3:主旋律(triangle,亮而柔和,8小节 melody)
|
||
// 轻快的 C 大调旋律,8小节/32拍
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 每项:[频率, 持续拍数]
|
||
const melody: [number, number][] = [
|
||
// 小节 1-2:上行跳跃
|
||
[N.E4, 0.5], [N.G4, 0.5], [N.C5, 1], [N.B4, 0.5], [N.A4, 0.5],
|
||
[N.G4, 1], [N.A4, 0.5], [N.G4, 0.5],
|
||
// 小节 3-4:中段装饰
|
||
[N.E4, 0.5], [N.F4, 0.5], [N.G4, 1], [N.E4, 0.5], [N.D4, 0.5],
|
||
[N.C4, 1], [N.D4, 0.5], [N.E4, 0.5],
|
||
// 小节 5-6:高潮段
|
||
[N.G4, 0.5], [N.A4, 0.5], [N.C5, 1], [N.D5, 0.5], [N.C5, 0.5],
|
||
[N.B4, 1], [N.A4, 0.5], [N.G4, 0.5],
|
||
// 小节 7-8:收尾回落
|
||
[N.E4, 0.5], [N.D4, 0.5], [N.C4, 0.5], [N.E4, 0.5],
|
||
[N.G4, 0.5], [N.F4, 0.5], [N.E4, 0.5], [N.D4, 0.5],
|
||
[N.C4, 1.5], [0, 0.5],
|
||
]
|
||
let melodyT = t
|
||
for (const [freq, dur] of melody) {
|
||
const durSec = dur * beat
|
||
if (freq > 0) {
|
||
addOsc('triangle', freq, melodyT, melodyT + durSec * 0.85, 0.18)
|
||
// 加一层八度泛音(更亮更饱满)
|
||
addOsc('sine', freq * 2, melodyT, melodyT + durSec * 0.6, 0.06)
|
||
}
|
||
melodyT += durSec
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 层 4:装饰音(高八度 sine 短促音符,活跃感)
|
||
// 每2小节出现一次 arpegio 装饰
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const decoPattern = [
|
||
// 小节2结尾装饰
|
||
{ st: t + bar * 1.75, notes: [N.C5, N.E5, N.G5] },
|
||
// 小节4结尾装饰
|
||
{ st: t + bar * 3.75, notes: [N.G4, N.C5, N.E5] },
|
||
// 小节6结尾装饰
|
||
{ st: t + bar * 5.75, notes: [N.C5, N.D5, N.E5] },
|
||
]
|
||
for (const { st, notes } of decoPattern) {
|
||
notes.forEach((freq, i) => {
|
||
addOsc('sine', freq, st + i * 0.08, st + i * 0.08 + 0.12, 0.1)
|
||
})
|
||
}
|
||
|
||
// 循环调度
|
||
setTimeout(() => {
|
||
if (this.bgmRunning) {
|
||
this.bgmNodes = []
|
||
this._scheduleBGM()
|
||
}
|
||
}, (loopLen - 0.3) * 1000)
|
||
}
|
||
}
|