182 lines
5.0 KiB
TypeScript
182 lines
5.0 KiB
TypeScript
type DuckReason = 'qte' | 'choice' | 'hotspot'
|
|
|
|
export class AudioSystem {
|
|
private ctx: AudioContext | null = null
|
|
private gainNode: GainNode | null = null
|
|
private currentSource: AudioBufferSourceNode | null = null
|
|
private currentUrl = ''
|
|
private currentVolume = 0.8
|
|
private bufferCache: Map<string, AudioBuffer> = new Map()
|
|
private maxCache = 3
|
|
private duckCount: Map<DuckReason, number> = new Map()
|
|
private duckLevel = 0.35
|
|
private duckFade = 0.5
|
|
|
|
private getCtx(): AudioContext {
|
|
if (!this.ctx) {
|
|
this.ctx = new AudioContext()
|
|
this.gainNode = this.ctx.createGain()
|
|
this.gainNode.connect(this.ctx.destination)
|
|
}
|
|
if (this.ctx.state === 'suspended') {
|
|
this.ctx.resume()
|
|
}
|
|
return this.ctx
|
|
}
|
|
|
|
async play(
|
|
url: string,
|
|
volume: number,
|
|
crossFade: number,
|
|
duckLvl?: number,
|
|
duckFd?: number,
|
|
) {
|
|
if (duckLvl !== undefined) this.duckLevel = duckLvl
|
|
if (duckFd !== undefined) this.duckFade = duckFd
|
|
this.currentVolume = volume
|
|
|
|
if (url === this.currentUrl) {
|
|
const target = this.duckTarget()
|
|
if (this.gainNode) {
|
|
this.gainNode.gain.cancelScheduledValues(this.getCtx().currentTime)
|
|
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, this.getCtx().currentTime)
|
|
this.gainNode.gain.exponentialRampToValueAtTime(
|
|
Math.max(0.001, target),
|
|
this.getCtx().currentTime + 0.3,
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
const prevSource = this.currentSource
|
|
|
|
this.currentUrl = url
|
|
const buffer = await this.loadBuffer(url)
|
|
if (!buffer) return
|
|
|
|
const ctx = this.getCtx()
|
|
const gain = this.gainNode!
|
|
|
|
const target = this.duckTarget()
|
|
|
|
if (prevSource) {
|
|
gain.gain.cancelScheduledValues(ctx.currentTime)
|
|
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + crossFade)
|
|
try { prevSource.stop(ctx.currentTime + crossFade + 0.1) } catch {}
|
|
}
|
|
|
|
const source = ctx.createBufferSource()
|
|
source.buffer = buffer
|
|
source.loop = true
|
|
source.connect(gain)
|
|
|
|
gain.gain.cancelScheduledValues(ctx.currentTime)
|
|
gain.gain.setValueAtTime(0.001, ctx.currentTime)
|
|
gain.gain.exponentialRampToValueAtTime(
|
|
Math.max(0.001, target),
|
|
ctx.currentTime + crossFade,
|
|
)
|
|
|
|
source.start(0)
|
|
this.currentSource = source
|
|
}
|
|
|
|
async stop(fadeOut: number) {
|
|
if (!this.currentSource || !this.gainNode) return
|
|
const ctx = this.getCtx()
|
|
this.currentUrl = ''
|
|
const gain = this.gainNode
|
|
try {
|
|
gain.gain.cancelScheduledValues(ctx.currentTime)
|
|
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + fadeOut)
|
|
const src = this.currentSource
|
|
setTimeout(() => {
|
|
try { src.stop() } catch {}
|
|
}, (fadeOut + 0.1) * 1000)
|
|
} catch {}
|
|
this.currentSource = null
|
|
}
|
|
|
|
duckOn(reason: DuckReason) {
|
|
this.duckCount.set(reason, (this.duckCount.get(reason) ?? 0) + 1)
|
|
this.applyDuck()
|
|
}
|
|
|
|
duckOff(reason: DuckReason) {
|
|
const cur = this.duckCount.get(reason) ?? 0
|
|
if (cur <= 1) {
|
|
this.duckCount.delete(reason)
|
|
} else {
|
|
this.duckCount.set(reason, cur - 1)
|
|
}
|
|
this.applyDuck()
|
|
}
|
|
|
|
private applyDuck() {
|
|
if (!this.gainNode || !this.currentSource) return
|
|
const target = this.duckTarget()
|
|
const ctx = this.getCtx()
|
|
const dt = this.duckCount.size > 0 ? this.duckFade : this.duckFade
|
|
this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
|
|
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, ctx.currentTime)
|
|
this.gainNode.gain.exponentialRampToValueAtTime(
|
|
Math.max(0.001, target),
|
|
ctx.currentTime + dt,
|
|
)
|
|
}
|
|
|
|
private duckTarget(): number {
|
|
if (this.duckCount.size > 0) {
|
|
return this.currentVolume * this.duckLevel
|
|
}
|
|
return this.currentVolume
|
|
}
|
|
|
|
setVolumeRaw(v: number) {
|
|
if (!this.gainNode) return
|
|
const ctx = this.getCtx()
|
|
this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
|
|
this.gainNode.gain.setValueAtTime(v, ctx.currentTime)
|
|
}
|
|
|
|
private async loadBuffer(url: string): Promise<AudioBuffer | null> {
|
|
const cached = this.bufferCache.get(url)
|
|
if (cached) return cached
|
|
|
|
try {
|
|
const resp = await fetch(url)
|
|
const arrayBuf = await resp.arrayBuffer()
|
|
const ctx = this.getCtx()
|
|
const buffer = await ctx.decodeAudioData(arrayBuf)
|
|
this.bufferCache.set(url, buffer)
|
|
|
|
// LRU eviction
|
|
if (this.bufferCache.size > this.maxCache) {
|
|
const firstKey = this.bufferCache.keys().next().value
|
|
if (firstKey) this.bufferCache.delete(firstKey)
|
|
}
|
|
|
|
return buffer
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this.currentSource) {
|
|
try { this.currentSource.stop() } catch {}
|
|
this.currentSource = null
|
|
}
|
|
this.bufferCache.clear()
|
|
this.duckCount.clear()
|
|
this.currentUrl = ''
|
|
if (this.ctx) {
|
|
this.ctx.close()
|
|
this.ctx = null
|
|
this.gainNode = null
|
|
}
|
|
}
|
|
}
|