102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import type { CallAudioChunkMeta } from "@/core/net/voice-call-transport";
|
|
|
|
export class VoiceCallPlayer {
|
|
private context: AudioContext | null = null;
|
|
private sources = new Set<AudioBufferSourceNode>();
|
|
private decodeChain: Promise<void> = 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<void> {
|
|
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?.();
|
|
}
|
|
}
|