feat(call): add streaming voice call experience

This commit is contained in:
Codex
2026-07-24 16:36:36 +08:00
parent 9fbf180df6
commit 19b8fc51d6
19 changed files with 1108 additions and 5 deletions
+101
View File
@@ -0,0 +1,101 @@
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?.();
}
}