Files
CamTalk/frontend/src/lib/ttsPlayer.ts
cfy666 6a47c4dfbb feat: 实现 TTS 语音播放,完成 MVP P0 全部用户故事
- 新增 TTSPlayer:收集流式 tts_audio 片段,is_last 时拼接解码播放
- useVisionSession 接入 TTS 播放器,interrupt/stopSession 时停止播放
- App 显示 '🔊 正在播放...' 指示器
- MVP P0 四个用户故事前端全部实现(US-01 ~ US-04)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 13:57:36 +08:00

105 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// TTS Player — 语音播放器
// 职责:收集后端流式 tts_audio 片段,拼接后播放
// 格式MVP 仅支持 audio/mp3pcm 为 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?.();
});
}
}