fix: 修复语音与文字不同步的问题,改为句子级流式播放 #67
@@ -114,15 +114,15 @@ func (m *MiMoService) SynthesizeStream(ctx context.Context, textStream <-chan st
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- Chunk{Audio: audio, IsLast: false}:
|
||||
case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// textStream 关闭,发送 IsLast 标记
|
||||
// textStream 关闭,发送 Final 标记
|
||||
select {
|
||||
case ch <- Chunk{Audio: nil, IsLast: true}:
|
||||
case ch <- Chunk{Audio: nil, IsLast: false, Final: true}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -109,24 +109,27 @@ func TestMiMoService_SynthesizeStream_Success(t *testing.T) {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 应该有 3 个音频 chunk + 1 个 IsLast 标记
|
||||
// 应该有 3 个音频 chunk + 1 个 Final 标记
|
||||
if len(chunks) != 4 {
|
||||
t.Fatalf("got %d chunks, want 4", len(chunks))
|
||||
}
|
||||
|
||||
// 验证前 3 个有音频数据
|
||||
// 验证前 3 个有音频数据,IsLast 为 true(每句结束)
|
||||
for i := 0; i < 3; i++ {
|
||||
if string(chunks[i].Audio) != "fake-mp3-data" {
|
||||
t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data")
|
||||
}
|
||||
if chunks[i].IsLast {
|
||||
t.Errorf("chunk[%d].IsLast should be false", i)
|
||||
if !chunks[i].IsLast {
|
||||
t.Errorf("chunk[%d].IsLast should be true (sentence end)", i)
|
||||
}
|
||||
if chunks[i].Final {
|
||||
t.Errorf("chunk[%d].Final should be false", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证最后一个是 IsLast
|
||||
if !chunks[3].IsLast {
|
||||
t.Error("last chunk should be IsLast")
|
||||
// 验证最后一个是 Final(整轮结束)
|
||||
if !chunks[3].Final {
|
||||
t.Error("last chunk should be Final")
|
||||
}
|
||||
if chunks[3].Audio != nil {
|
||||
t.Error("last chunk Audio should be nil")
|
||||
@@ -154,17 +157,17 @@ func TestMiMoService_SynthesizeStream_APIError(t *testing.T) {
|
||||
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||
}
|
||||
|
||||
// 应该只有一个 IsLast chunk(音频被跳过)
|
||||
// 应该只有一个 Final chunk(音频被跳过)
|
||||
var chunks []Chunk
|
||||
for c := range ch {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("got %d chunks, want 1 (IsLast only)", len(chunks))
|
||||
t.Fatalf("got %d chunks, want 1 (Final only)", len(chunks))
|
||||
}
|
||||
if !chunks[0].IsLast {
|
||||
t.Error("chunk should be IsLast")
|
||||
if !chunks[0].Final {
|
||||
t.Error("chunk should be Final")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +198,12 @@ func TestMiMoService_SynthesizeStream_Timeout(t *testing.T) {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 超时后音频被跳过,只有 IsLast
|
||||
// 超时后音频被跳过,只有 Final
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("got %d chunks, want 1", len(chunks))
|
||||
}
|
||||
if !chunks[0].IsLast {
|
||||
t.Error("chunk should be IsLast")
|
||||
if !chunks[0].Final {
|
||||
t.Error("chunk should be Final")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +237,7 @@ func TestMiMoService_SynthesizeStream_EmptyText(t *testing.T) {
|
||||
t.Errorf("API called %d times, want 1", callCount)
|
||||
}
|
||||
|
||||
// 1 个音频 + 1 个 IsLast
|
||||
// 1 个音频(IsLast: true)+ 1 个 Final
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("got %d chunks, want 2", len(chunks))
|
||||
}
|
||||
@@ -304,12 +307,18 @@ func TestMiMoService_SynthesizeStream_PartialFailure(t *testing.T) {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 2 个成功音频 + 1 个 IsLast(第二句被跳过)
|
||||
// 2 个成功音频(IsLast: true)+ 1 个 Final(第二句被跳过)
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("got %d chunks, want 3", len(chunks))
|
||||
}
|
||||
if !chunks[len(chunks)-1].IsLast {
|
||||
t.Error("last chunk should be IsLast")
|
||||
if !chunks[0].IsLast {
|
||||
t.Error("first audio chunk should be IsLast")
|
||||
}
|
||||
if !chunks[1].IsLast {
|
||||
t.Error("second audio chunk should be IsLast")
|
||||
}
|
||||
if !chunks[len(chunks)-1].Final {
|
||||
t.Error("last chunk should be Final")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,11 +404,11 @@ func TestMiMoService_SynthesizeStream_EmptyAudioData(t *testing.T) {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 空音频数据导致错误,句子被跳过,只有 IsLast
|
||||
// 空音频数据导致错误,句子被跳过,只有 Final
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("got %d chunks, want 1", len(chunks))
|
||||
}
|
||||
if !chunks[0].IsLast {
|
||||
t.Error("chunk should be IsLast")
|
||||
if !chunks[0].Final {
|
||||
t.Error("chunk should be Final")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,15 +87,15 @@ func (o *OpenAIService) SynthesizeStream(ctx context.Context, textStream <-chan
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- Chunk{Audio: audio, IsLast: false}:
|
||||
case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// textStream 关闭,发送 IsLast 标记
|
||||
// textStream 关闭,发送 Final 标记
|
||||
select {
|
||||
case ch <- Chunk{Audio: nil, IsLast: true}:
|
||||
case ch <- Chunk{Audio: nil, IsLast: false, Final: true}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -74,24 +74,27 @@ func TestOpenAIService_SynthesizeStream_Success(t *testing.T) {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 应该有 3 个音频 chunk + 1 个 IsLast 标记
|
||||
// 应该有 3 个音频 chunk + 1 个 Final 标记
|
||||
if len(chunks) != 4 {
|
||||
t.Fatalf("got %d chunks, want 4", len(chunks))
|
||||
}
|
||||
|
||||
// 验证前 3 个有音频数据
|
||||
// 验证前 3 个有音频数据,IsLast 为 true(每句结束)
|
||||
for i := 0; i < 3; i++ {
|
||||
if string(chunks[i].Audio) != "fake-mp3-data" {
|
||||
t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data")
|
||||
}
|
||||
if chunks[i].IsLast {
|
||||
t.Errorf("chunk[%d].IsLast should be false", i)
|
||||
if !chunks[i].IsLast {
|
||||
t.Errorf("chunk[%d].IsLast should be true (sentence end)", i)
|
||||
}
|
||||
if chunks[i].Final {
|
||||
t.Errorf("chunk[%d].Final should be false", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证最后一个是 IsLast
|
||||
if !chunks[3].IsLast {
|
||||
t.Error("last chunk should be IsLast")
|
||||
// 验证最后一个是 Final(整轮结束)
|
||||
if !chunks[3].Final {
|
||||
t.Error("last chunk should be Final")
|
||||
}
|
||||
if chunks[3].Audio != nil {
|
||||
t.Error("last chunk Audio should be nil")
|
||||
@@ -119,17 +122,17 @@ func TestOpenAIService_SynthesizeStream_APIError(t *testing.T) {
|
||||
t.Fatalf("SynthesizeStream() error: %v", err)
|
||||
}
|
||||
|
||||
// 应该只有一个 IsLast chunk(音频被跳过)
|
||||
// 应该只有一个 Final chunk(音频被跳过)
|
||||
var chunks []Chunk
|
||||
for c := range ch {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("got %d chunks, want 1 (IsLast only)", len(chunks))
|
||||
t.Fatalf("got %d chunks, want 1 (Final only)", len(chunks))
|
||||
}
|
||||
if !chunks[0].IsLast {
|
||||
t.Error("chunk should be IsLast")
|
||||
if !chunks[0].Final {
|
||||
t.Error("chunk should be Final")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,12 +162,12 @@ func TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 超时后音频被跳过,只有 IsLast
|
||||
// 超时后音频被跳过,只有 Final
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("got %d chunks, want 1", len(chunks))
|
||||
}
|
||||
if !chunks[0].IsLast {
|
||||
t.Error("chunk should be IsLast")
|
||||
if !chunks[0].Final {
|
||||
t.Error("chunk should be Final")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +200,7 @@ func TestOpenAIService_SynthesizeStream_EmptyText(t *testing.T) {
|
||||
t.Errorf("API called %d times, want 1", callCount)
|
||||
}
|
||||
|
||||
// 1 个音频 + 1 个 IsLast
|
||||
// 1 个音频(IsLast: true)+ 1 个 Final
|
||||
if len(chunks) != 2 {
|
||||
t.Fatalf("got %d chunks, want 2", len(chunks))
|
||||
}
|
||||
@@ -266,12 +269,18 @@ func TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) {
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
|
||||
// 2 个成功音频 + 1 个 IsLast(第二句被跳过)
|
||||
// 2 个成功音频(IsLast: true)+ 1 个 Final(第二句被跳过)
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("got %d chunks, want 3", len(chunks))
|
||||
}
|
||||
if !chunks[len(chunks)-1].IsLast {
|
||||
t.Error("last chunk should be IsLast")
|
||||
if !chunks[0].IsLast {
|
||||
t.Error("first audio chunk should be IsLast")
|
||||
}
|
||||
if !chunks[1].IsLast {
|
||||
t.Error("second audio chunk should be IsLast")
|
||||
}
|
||||
if !chunks[len(chunks)-1].Final {
|
||||
t.Error("last chunk should be Final")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ type Options struct {
|
||||
// Chunk 一个音频片段。
|
||||
type Chunk struct {
|
||||
Audio []byte // MP3 音频数据(未 Base64 编码)
|
||||
IsLast bool // 是否为最后一片
|
||||
IsLast bool // 当前句子是否为最后一片(每句结束时为 true)
|
||||
Final bool // 整轮 TTS 是否结束(所有句子合成完毕后为 true,此时 Audio 为 nil)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ type WsTTSAudio struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Audio string `json:"audio"` // base64
|
||||
MimeType string `json:"mime_type"` // "audio/mp3" 或 "audio/pcm"
|
||||
IsLast bool `json:"is_last"`
|
||||
IsLast bool `json:"is_last"` // 当前句子的音频是否完整(每句结束时为 true)
|
||||
Final bool `json:"final"` // 整轮 TTS 是否结束(所有句子合成完毕后为 true)
|
||||
}
|
||||
|
||||
// WsError 服务端 error 消息。
|
||||
|
||||
@@ -368,6 +368,7 @@ func (p *Pipeline) synthesizeTTS(
|
||||
Audio: audioBase64,
|
||||
MimeType: "audio/mp3",
|
||||
IsLast: chunk.IsLast,
|
||||
Final: chunk.Final,
|
||||
}); err != nil {
|
||||
log.Errorw("发送 tts_audio 失败", "error", err)
|
||||
}
|
||||
|
||||
@@ -147,10 +147,16 @@ interface TTSAudioMessage {
|
||||
request_id: string;
|
||||
audio: string; // Base64 编码的音频片段
|
||||
mime_type: string; // "audio/mp3"
|
||||
is_last: boolean; // 是否为最后一片
|
||||
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true)
|
||||
final: boolean; // 整轮 TTS 是否结束(所有句子合成完毕后为 true)
|
||||
}
|
||||
```
|
||||
|
||||
**字段语义**:
|
||||
|
||||
- `is_last`: 每个句子合成完毕后为 `true`,前端收到此信号即可将该句子加入播放队列。每句 TTS 音频由一次独立的 API 调用生成,对应一个 `tts_audio` 消息。
|
||||
- `final`: 所有句子合成完毕后为 `true`(此时 `audio` 为空字符串),用于前端判断本轮 TTS 已全部到齐。
|
||||
|
||||
**音频格式规范**(前端播放依赖此约定):
|
||||
|
||||
| 属性 | 值 | 说明 |
|
||||
@@ -165,31 +171,37 @@ interface TTSAudioMessage {
|
||||
|
||||
**前端播放实现要点**:
|
||||
|
||||
1. **排队播放**:收到 `tts_audio` 时,将 Base64 解码为 Blob URL 并加入播放队列。第一片到达即开始播放,后续片段在 `onended` 回调中自动衔接。
|
||||
2. **错误容错**:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
||||
1. **排队播放**:收到 `is_last: true` 时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一句到达即开始播放,后续句子在 `onended` 回调中自动衔接。
|
||||
2. **错误容错**:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
||||
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
|
||||
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
|
||||
|
||||
```typescript
|
||||
// 前端播放器伪代码
|
||||
class AudioPlayer {
|
||||
private queue: string[] = []; // Blob URL 队列
|
||||
private sentenceChunks: string[] = []; // 当前句子的音频片段缓冲
|
||||
private queue: string[] = []; // 已就绪的句子 Blob URL 队列
|
||||
|
||||
enqueue(base64: string) {
|
||||
const url = decodeBase64Audio(base64, "audio/mp3");
|
||||
this.queue.push(url);
|
||||
if (this.queue.length === 1) this.playNext(); // 第一片到了就开始播
|
||||
enqueue(base64: string, isLast: boolean) {
|
||||
this.sentenceChunks.push(base64);
|
||||
if (isLast) {
|
||||
// 当前句子音频完整,拼接并加入播放队列
|
||||
const url = decodeBase64Audio(this.sentenceChunks.join(""), "audio/mp3");
|
||||
this.sentenceChunks = [];
|
||||
this.queue.push(url);
|
||||
if (this.queue.length === 1) this.playNext(); // 第一句到了就开始播
|
||||
}
|
||||
}
|
||||
|
||||
private playNext() {
|
||||
if (this.queue.length === 0) return;
|
||||
const audio = new Audio(this.queue[0]);
|
||||
audio.onended = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
||||
audio.onerror = () => { URL.revokeObjectURL(this.queue.shift()!); this.playNext(); };
|
||||
const audio = new Audio(this.queue.shift()!);
|
||||
audio.onended = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
||||
audio.onerror = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
|
||||
audio.play();
|
||||
}
|
||||
|
||||
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; }
|
||||
clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; this.sentenceChunks = []; }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -228,7 +240,8 @@ Client Server
|
||||
|<-- llm_done {full_text} ------|
|
||||
| |
|
||||
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
||||
|<-- tts_audio {is_last: true} -|
|
||||
|<-- tts_audio {is_last: true} -| (句子完成)
|
||||
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
||||
```
|
||||
|
||||
**文本输入模式**(麦克风关闭,手动输入文字):
|
||||
@@ -244,7 +257,8 @@ Client Server
|
||||
|<-- llm_done {full_text} ------|
|
||||
| |
|
||||
|<-- tts_audio {audio} ---------| (TTS 音频流)
|
||||
|<-- tts_audio {is_last: true} -|
|
||||
|<-- tts_audio {is_last: true} -| (句子完成)
|
||||
|<-- tts_audio {final: true} ---| (TTS 全部结束)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -266,9 +266,7 @@ export function useVisionSession() {
|
||||
|
||||
case "tts_audio":
|
||||
getTTSPlayer().enqueue(msg.audio, msg.mime_type, msg.is_last);
|
||||
if (!msg.is_last) {
|
||||
setIsAudioPlaying(true);
|
||||
}
|
||||
setIsAudioPlaying(true);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
// ============================================================
|
||||
// TTS Player — 语音播放器
|
||||
// 职责:收集后端流式 tts_audio 片段,拼接后播放
|
||||
// 格式:MVP 仅支持 audio/mp3,pcm 为 TODO
|
||||
// 职责:接收后端流式 tts_audio 片段,按句子排队播放
|
||||
// 设计:第一句到达即开始播放,后续句子在 onended 回调中自动衔接
|
||||
// ============================================================
|
||||
|
||||
type OnEndCallback = () => void;
|
||||
|
||||
export class TTSPlayer {
|
||||
private chunks: string[] = [];
|
||||
/** 当前句子的音频片段缓冲(每句可能含多个 chunk) */
|
||||
private sentenceChunks: string[] = [];
|
||||
/** 已就绪的句子 Blob URL 播放队列 */
|
||||
private queue: string[] = [];
|
||||
private audio: HTMLAudioElement | null = null;
|
||||
private _isPlaying = false;
|
||||
private onEndCallback: OnEndCallback | null = null;
|
||||
|
||||
/** 注册播放完成回调 */
|
||||
/** 注册播放完成回调(所有句子播完后触发) */
|
||||
onEnd(cb: OnEndCallback): void {
|
||||
this.onEndCallback = cb;
|
||||
}
|
||||
@@ -25,38 +28,40 @@ export class TTSPlayer {
|
||||
/**
|
||||
* 入队一个 TTS 音频片段
|
||||
* @param base64 Base64 编码的音频数据
|
||||
* @param mimeType 音频格式("audio/mp3" 或 "audio/pcm")
|
||||
* @param isLast 是否为最后一个片段
|
||||
* @param mimeType 音频格式("audio/mp3")
|
||||
* @param isLast 当前句子是否合成完毕(每句结束时为 true)
|
||||
*/
|
||||
enqueue(base64: string, mimeType: string, isLast: boolean): void {
|
||||
this.chunks.push(base64);
|
||||
this.sentenceChunks.push(base64);
|
||||
|
||||
if (isLast) {
|
||||
this.play(mimeType);
|
||||
// 当前句子的音频已完整,拼接并加入播放队列
|
||||
const combined = this.sentenceChunks.join("");
|
||||
this.sentenceChunks = [];
|
||||
|
||||
const url = this.base64ToBlobUrl(combined, mimeType);
|
||||
this.queue.push(url);
|
||||
|
||||
// 如果当前没有在播,立即开始播放
|
||||
if (!this._isPlaying) {
|
||||
this.playNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止播放并清空缓冲区 */
|
||||
/** 停止播放并清空所有队列 */
|
||||
stop(): void {
|
||||
if (this.audio) {
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute("src");
|
||||
this.audio = null;
|
||||
}
|
||||
this.chunks = [];
|
||||
this._isPlaying = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前正在播放的音频(不清空 chunks)
|
||||
* 用于 play() 开始前,防止新旧音频重叠
|
||||
*/
|
||||
private stopCurrentAudio(): void {
|
||||
if (this.audio) {
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute("src");
|
||||
this.audio = null;
|
||||
// 释放队列中的 Blob URL
|
||||
for (const url of this.queue) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
this.queue = [];
|
||||
this.sentenceChunks = [];
|
||||
this._isPlaying = false;
|
||||
}
|
||||
|
||||
@@ -70,51 +75,48 @@ export class TTSPlayer {
|
||||
this.audio?.play();
|
||||
}
|
||||
|
||||
/** 拼接所有片段并播放 */
|
||||
private play(mimeType: string): void {
|
||||
if (this.chunks.length === 0) return;
|
||||
|
||||
// 停止当前正在播放的音频,防止重叠
|
||||
this.stopCurrentAudio();
|
||||
|
||||
// 拼接所有 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);
|
||||
/** 播放队列中的下一个句子 */
|
||||
private playNext(): void {
|
||||
if (this.queue.length === 0) {
|
||||
this._isPlaying = false;
|
||||
this.onEndCallback?.();
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 播放
|
||||
const url = this.queue.shift()!;
|
||||
const audio = new Audio(url);
|
||||
this.audio = audio;
|
||||
this._isPlaying = true;
|
||||
|
||||
audio.onended = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
this._isPlaying = false;
|
||||
this.audio = null;
|
||||
this.onEndCallback?.();
|
||||
this.playNext();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
console.error("[TTS] 播放失败");
|
||||
console.error("[TTS] 播放失败,跳过");
|
||||
URL.revokeObjectURL(url);
|
||||
this._isPlaying = false;
|
||||
this.audio = null;
|
||||
this.onEndCallback?.();
|
||||
this.playNext();
|
||||
};
|
||||
|
||||
audio.play().catch((err) => {
|
||||
console.error("[TTS] play() 被拒绝:", err);
|
||||
this._isPlaying = false;
|
||||
URL.revokeObjectURL(url);
|
||||
this.audio = null;
|
||||
this.onEndCallback?.();
|
||||
this.playNext();
|
||||
});
|
||||
}
|
||||
|
||||
/** Base64 字符串转 Blob URL */
|
||||
private base64ToBlobUrl(base64: string, mimeType: string): string {
|
||||
const binary = atob(base64);
|
||||
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 });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@ export interface TTSAudioMessage {
|
||||
request_id: string;
|
||||
audio: string; // Base64 音频片段
|
||||
mime_type: string; // "audio/mp3" 或 "audio/pcm"
|
||||
is_last: boolean;
|
||||
is_last: boolean; // 当前句子的音频是否完整(每句结束时为 true)
|
||||
final: boolean; // 整轮 TTS 是否结束
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
|
||||
Reference in New Issue
Block a user