105 lines
2.6 KiB
TypeScript
105 lines
2.6 KiB
TypeScript
|
|
// ============================================================
|
|||
|
|
// 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?.();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|