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:
Blizzard
2026-07-22 10:49:43 +08:00
parent d6c1a787f4
commit 08e6425fec
2 changed files with 50 additions and 16 deletions
+50 -16
View File
@@ -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 demoStartSession 与 TaskRequest 都带这个 req_paramsTaskRequest 再往里加 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 推一段文字给 TTSTaskRequest / 事件 200)。可多次调用逐句推。
// payload 必须带完整 req_paramsspeaker+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.