// ============================================================ // TTS Player — 语音播放器 // 职责:收集后端流式 tts_audio 片段,拼接后播放 // 格式:MVP 仅支持 audio/mp3,pcm 为 TODO // ============================================================ type OnEndCallback = () => void; export class TTSPlayer { private chunks: string[] = []; private audio: HTMLAudioElement | null = null; private _isPlaying = false; private onEndCallback: OnEndCallback | null = null; /** 注册播放完成回调 */ onEnd(cb: OnEndCallback): void { this.onEndCallback = cb; } /** 当前是否正在播放 */ get isPlaying(): boolean { return this._isPlaying; } /** * 入队一个 TTS 音频片段 * @param base64 Base64 编码的音频数据 * @param mimeType 音频格式("audio/mp3" 或 "audio/pcm") * @param isLast 是否为最后一个片段 */ enqueue(base64: string, mimeType: string, isLast: boolean): void { this.chunks.push(base64); if (isLast) { this.play(mimeType); } } /** 停止播放并清空缓冲区 */ stop(): void { if (this.audio) { this.audio.pause(); this.audio.removeAttribute("src"); this.audio = null; } this.chunks = []; this._isPlaying = false; } /** 暂停播放 */ pause(): void { this.audio?.pause(); } /** 恢复播放 */ resume(): void { this.audio?.play(); } /** 拼接所有片段并播放 */ private play(mimeType: string): void { if (this.chunks.length === 0) return; // 拼接所有 Base64 片段 const combined = this.chunks.join(""); this.chunks = []; // Base64 → Uint8Array → Blob const binary = atob(combined); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: mimeType }); const url = URL.createObjectURL(blob); // 播放 const audio = new Audio(url); this.audio = audio; this._isPlaying = true; audio.onended = () => { URL.revokeObjectURL(url); this._isPlaying = false; this.audio = null; this.onEndCallback?.(); }; audio.onerror = () => { console.error("[TTS] 播放失败"); URL.revokeObjectURL(url); this._isPlaying = false; this.audio = null; this.onEndCallback?.(); }; audio.play().catch((err) => { console.error("[TTS] play() 被拒绝:", err); this._isPlaying = false; this.audio = null; this.onEndCallback?.(); }); } }