feat(voice): 双向流式 TTS 真机跑通——TTS合成→ASR往返全绿✅
对齐官方 python demo 修 TTS payload:
- TaskRequest 必须带完整 req_params(speaker+audio_params)再加 text,只发 {text} 火山收不到
(会回空 text 的 TTSSentenceStart 后直接结束、无音频)
- StartSession 去掉自造的 namespace/user,就是 {req_params:{speaker,audio_params}}
- ttsReqBase 基底 StartSession/TaskRequest 复用;TTSSession 存 reqBase
- 加 VOICE_DEBUG 帧级调试日志(联调用,默认关无开销)
真机验证(voicecheck):TTS 合成 143KB PCM24k → 降采样喂 ASR → 转写"北京今天的天气怎么样?
需要带伞吗?"≈原句。ASR(volc.bigasr.sauc.duration)+TTS(seed-tts-2.0)两协议全通。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,35 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ttsDebug 打开时(环境变量 VOICE_DEBUG=1)打印每个 TTS 帧,便于联调双向流事件/音频。
|
||||
var ttsDebug = os.Getenv("VOICE_DEBUG") != ""
|
||||
|
||||
func ttsDebugf(format string, a ...any) {
|
||||
if ttsDebug {
|
||||
log.Printf("[tts-debug] "+format, a...)
|
||||
}
|
||||
}
|
||||
|
||||
// preview 取 payload 前 n 字节的可读预览(音频用长度代替)。
|
||||
func preview(b []byte, isAudio bool) string {
|
||||
if isAudio {
|
||||
return fmt.Sprintf("<audio %d bytes>", len(b))
|
||||
}
|
||||
if len(b) > 160 {
|
||||
return string(b[:160]) + "…"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 火山「双向流式 TTS V3」客户端(seed-tts-2.0)。边推文字边收音频,配 LLM token 流做连续朗读。
|
||||
// 帧协议见 tts_frame.go;鉴权走**新版控制台 API Key**(单个 X-Api-Key,见 frame.go)。
|
||||
//
|
||||
@@ -26,6 +48,7 @@ const (
|
||||
type TTSSession struct {
|
||||
conn *websocket.Conn
|
||||
sessionID string
|
||||
reqBase map[string]any // req_params 基底(speaker+audio_params),Speak 时加 text 复用
|
||||
audio chan []byte
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
@@ -34,21 +57,21 @@ type TTSSession struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// buildTTSStartSession 组 StartSession 的 req_params:音色 + 音频参数(PCM 24k)。
|
||||
// namespace=BidirectionalTTS 是双向流式 TTS 的服务命名空间。
|
||||
func buildTTSStartSession(cfg Config) []byte {
|
||||
req := map[string]any{
|
||||
"user": map[string]any{"uid": "sundynix"},
|
||||
"namespace": "BidirectionalTTS",
|
||||
"req_params": map[string]any{
|
||||
"speaker": cfg.TTSVoiceType,
|
||||
"audio_params": map[string]any{
|
||||
"format": "pcm",
|
||||
"sample_rate": TTSSampleRate,
|
||||
},
|
||||
// ttsReqBase 组双向流式 TTS 的 req_params 基底(音色 + 音频参数 PCM 24k)。
|
||||
// 对齐官方 python demo:StartSession 与 TaskRequest 都带这个 req_params,TaskRequest 再往里加 text。
|
||||
func ttsReqBase(cfg Config) map[string]any {
|
||||
return map[string]any{
|
||||
"speaker": cfg.TTSVoiceType,
|
||||
"audio_params": map[string]any{
|
||||
"format": "pcm",
|
||||
"sample_rate": TTSSampleRate,
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(req)
|
||||
}
|
||||
|
||||
// buildTTSStartSession 组 StartSession payload:{req_params:{speaker,audio_params}}(无 namespace/user)。
|
||||
func buildTTSStartSession(base map[string]any) []byte {
|
||||
b, _ := json.Marshal(map[string]any{"req_params": base})
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -66,6 +89,7 @@ func StartTTS(ctx context.Context, cfg Config) (*TTSSession, error) {
|
||||
return nil, fmt.Errorf("连接火山 TTS 失败: %w%s", err, handshakeDetail(resp))
|
||||
}
|
||||
sid := newConnectID()
|
||||
base := ttsReqBase(cfg)
|
||||
|
||||
// StartConnection → 期待 ConnectionStarted。
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, connEventFrame(evStartConnection, []byte("{}"))); err != nil {
|
||||
@@ -77,7 +101,7 @@ func StartTTS(ctx context.Context, cfg Config) (*TTSSession, error) {
|
||||
return nil, err
|
||||
}
|
||||
// StartSession → 期待 SessionStarted。
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evStartSession, sid, buildTTSStartSession(cfg))); err != nil {
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evStartSession, sid, buildTTSStartSession(base))); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("发送 StartSession 失败: %w", err)
|
||||
}
|
||||
@@ -87,7 +111,7 @@ func StartTTS(ctx context.Context, cfg Config) (*TTSSession, error) {
|
||||
}
|
||||
|
||||
s := &TTSSession{
|
||||
conn: conn, sessionID: sid,
|
||||
conn: conn, sessionID: sid, reqBase: base,
|
||||
audio: make(chan []byte, 64), closed: make(chan struct{}),
|
||||
}
|
||||
go s.readLoop()
|
||||
@@ -106,6 +130,7 @@ func expectTTSEvent(conn *websocket.Conn, want int32) error {
|
||||
if perr != nil {
|
||||
return fmt.Errorf("解析 TTS 握手响应失败: %w", perr)
|
||||
}
|
||||
ttsDebugf("握手帧: event=%d msgType=%d payload=%s", r.Event, r.MsgType, preview(r.Payload, r.IsAudio))
|
||||
if r.MsgType == ttsServerErr {
|
||||
return fmt.Errorf("TTS 握手被拒 code=%d: %s", r.Code, string(r.Payload))
|
||||
}
|
||||
@@ -116,8 +141,15 @@ func expectTTSEvent(conn *websocket.Conn, want int32) error {
|
||||
}
|
||||
|
||||
// Speak 推一段文字给 TTS(TaskRequest / 事件 200)。可多次调用逐句推。
|
||||
// payload 必须带完整 req_params(speaker+audio_params)再加 text——只发 {text} 火山收不到文字
|
||||
// (联调实证:会回一个空 text 的 TTSSentenceStart 后直接结束,无音频)。
|
||||
func (s *TTSSession) Speak(text string) error {
|
||||
payload, _ := json.Marshal(map[string]any{"text": text})
|
||||
rp := make(map[string]any, len(s.reqBase)+1)
|
||||
for k, v := range s.reqBase {
|
||||
rp[k] = v
|
||||
}
|
||||
rp["text"] = text
|
||||
payload, _ := json.Marshal(map[string]any{"req_params": rp})
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
return s.conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evTaskRequest, s.sessionID, payload))
|
||||
@@ -165,8 +197,10 @@ func (s *TTSSession) readLoop() {
|
||||
}
|
||||
r, perr := parseTTSFrame(data)
|
||||
if perr != nil {
|
||||
ttsDebugf("解析失败(%d 字节): %v", len(data), perr)
|
||||
continue
|
||||
}
|
||||
ttsDebugf("帧: event=%d msgType=%d isAudio=%v payload=%s", r.Event, r.MsgType, r.IsAudio, preview(r.Payload, r.IsAudio))
|
||||
switch {
|
||||
case r.MsgType == ttsServerErr:
|
||||
s.setErr(fmt.Errorf("火山 TTS 错误 code=%d: %s", r.Code, string(r.Payload)))
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user