import type { CallAudioChunkMeta } from "@/core/net/voice-call-transport"; export class VoiceCallPlayer { private context: AudioContext | null = null; private sources = new Set(); private decodeChain: Promise = Promise.resolve(); private scheduledUntil = 0; private pendingDecodes = 0; private generation = 0; onPlaying: (() => void) | null = null; onIdle: (() => void) | null = null; onGap: ((milliseconds: number) => void) | null = null; onError: ((error: Error) => void) | null = null; get isPlaying(): boolean { return this.sources.size > 0 || this.pendingDecodes > 0; } async prepare(): Promise { const context = this.getContext(); if (context.state === "suspended") await context.resume(); } enqueue(_meta: CallAudioChunkMeta, bytes: ArrayBuffer): void { const generation = this.generation; this.pendingDecodes += 1; this.decodeChain = this.decodeChain .then(async () => { const context = this.getContext(); if (context.state === "suspended") await context.resume(); const audioBuffer = await context.decodeAudioData(bytes.slice(0)); if (generation !== this.generation) return; this.schedule(context, audioBuffer); }) .catch((error: unknown) => { this.onError?.(error instanceof Error ? error : new Error(String(error))); }) .finally(() => { this.pendingDecodes = Math.max(0, this.pendingDecodes - 1); this.notifyIdleIfNeeded(); }); } stop(): void { this.generation += 1; for (const source of this.sources) { source.onended = null; try { source.stop(); } catch { // 已自然结束的 source 无需重复停止。 } source.disconnect(); } this.sources.clear(); this.scheduledUntil = 0; this.onIdle?.(); } dispose(): void { this.stop(); const context = this.context; this.context = null; if (context && context.state !== "closed") void context.close(); } private getContext(): AudioContext { if (!this.context || this.context.state === "closed") { this.context = new AudioContext({ latencyHint: "interactive" }); } return this.context; } private schedule(context: AudioContext, buffer: AudioBuffer): void { const leadSeconds = 0.025; const earliestStart = context.currentTime + leadSeconds; const hadPreviousSegment = this.scheduledUntil > 0; const startAt = Math.max(earliestStart, this.scheduledUntil); if (hadPreviousSegment) { this.onGap?.(Math.max(0, (startAt - this.scheduledUntil) * 1000)); } const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination); this.sources.add(source); this.scheduledUntil = startAt + buffer.duration; source.onended = () => { source.disconnect(); this.sources.delete(source); this.notifyIdleIfNeeded(); }; source.start(startAt); this.onPlaying?.(); } private notifyIdleIfNeeded(): void { if (this.sources.size || this.pendingDecodes) return; this.scheduledUntil = 0; this.onIdle?.(); } }