// Spirit voice playback: a single FIFO queue so overlapping audio frames // never talk over each other. The currently-playing element is routed // through an AnalyserNode so the ghost scene can pulse with the voice. export type PlayingInfo = { utteranceId: string url: string } export type SpiritAudioCallbacks = { onStart?: (info: PlayingInfo) => void onEnd?: (info: PlayingInfo) => void } export class SpiritAudioPlayer { private queue: PlayingInfo[] = [] private current: PlayingInfo | null = null private el: HTMLAudioElement | null = null private ctx: AudioContext | null = null private analyser: AnalyserNode | null = null private source: MediaElementAudioSourceNode | null = null private cb: SpiritAudioCallbacks = {} private ampBuf: Uint8Array = new Uint8Array(0) setCallbacks(cb: SpiritAudioCallbacks): void { this.cb = cb } get isPlaying(): boolean { return this.current !== null } get currentId(): string | null { return this.current?.utteranceId ?? null } get queuedCount(): number { return this.queue.length } /** Enqueue audio for an utterance id. */ enqueue(utteranceId: string, url: string): void { this.queue.push({ utteranceId, url }) void this.pump() } /** Instantaneous playback amplitude 0..1 (0 when silent/not playing). */ getAmplitude(): number { if (!this.analyser || !this.current) return 0 if (this.ampBuf.length !== this.analyser.fftSize) { this.ampBuf = new Uint8Array(this.analyser.fftSize) } this.analyser.getByteTimeDomainData(this.ampBuf) let sum = 0 for (let i = 0; i < this.ampBuf.length; i++) { const v = (this.ampBuf[i] - 128) / 128 sum += v * v } return Math.min(1, Math.sqrt(sum / this.ampBuf.length) * 3) } clear(): void { this.queue = [] this.el?.pause() this.el = null this.current = null } private ensureGraph(el: HTMLAudioElement): void { if (this.source && this.el === el) return if (!this.ctx) { this.ctx = new AudioContext() this.analyser = this.ctx.createAnalyser() this.analyser.fftSize = 512 this.analyser.smoothingTimeConstant = 0.7 this.analyser.connect(this.ctx.destination) } if (this.source) { this.source.disconnect() this.source = null } this.source = this.ctx.createMediaElementSource(el) this.source.connect(this.analyser!) } private async pump(): Promise { if (this.current || this.queue.length === 0) return const next = this.queue.shift()! this.current = next const el = new Audio(next.url) this.el = el el.crossOrigin = 'anonymous' el.onended = () => { const done = this.current this.current = null this.el = null if (done) this.cb.onEnd?.(done) void this.pump() } el.onerror = () => { const done = this.current this.current = null this.el = null if (done) this.cb.onEnd?.(done) void this.pump() } try { this.ensureGraph(el) // AudioContext may be suspended until a user gesture; resume best-effort. if (this.ctx && this.ctx.state === 'suspended') { await this.ctx.resume().catch(() => undefined) } this.cb.onStart?.(next) await el.play() } catch { // Autoplay blocked or unroutable: play without the analyser as fallback. try { await el.play() this.cb.onStart?.(next) } catch { const done = this.current this.current = null this.el = null if (done) this.cb.onEnd?.(done) void this.pump() } } } }