feat(voice): 火山双向流 TTS 客户端 + 下行接线(Phase 1 嘴巴)

回答 token 流 → 攒句器 → 火山双向 TTS → 音频帧回推客户端,端到端连续朗读打通。

- tts_frame.go: V3 事件族帧编解码(事件号+会话ID+gzip),与 ASR 简帧不同族;
  从官方参考实现核实 generate_header/parse_response 字节布局;3 解析单测
- tts.go: seed-tts-2.0 双向流客户端 StartTTS(握手ConnectionStarted/SessionStarted)
  /Speak(逐句TaskRequest)/Finish/Audio()/Close,PCM 24k;新版 API Key 鉴权
- voice_tts.go: speak() 先订阅token流再建TTS(core NATS无持久,握手期攒句入pending
  就绪补吐,不丢开头);音频泵首帧ServerSpeaking、收尾ServerTTSEnd;打断stopTTS
- voice.go: barge_in→stopTTS;会话结束连带停TTS;final转写→go speak(taskID)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 09:25:49 +08:00
parent 302e1ebaff
commit 6fe0a58f1b
5 changed files with 634 additions and 2 deletions
+189
View File
@@ -0,0 +1,189 @@
package voice
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
// 火山「双向流式 TTS V3」客户端(seed-tts-2.0)。边推文字边收音频,配 LLM token 流做连续朗读。
// 帧协议见 tts_frame.go;鉴权走**新版 API Key**Authorization: Bearer <APIKey>)。
//
// 一轮朗读的生命周期:StartTTS(连接+StartSession) → Speak(逐句推文字/事件200) → Finish(FinishSession)
// → 客户端 range Audio() 收音频直到 channel 关闭 → Close()。一个 TTSSession = 一次 Agent 回答。
const (
ttsEndpoint = "wss://openspeech.bytedance.com/api/v3/tts/bidirection"
TTSSampleRate = 24000 // 双向 TTS 回 PCM 24kHz 单声道(客户端按此播放)
)
// TTSSession 是一路双向 TTS 会话。Speak 推文字、Audio 出 PCM、Finish 收尾、Close 关闭。
type TTSSession struct {
conn *websocket.Conn
sessionID string
audio chan []byte
closed chan struct{}
closeOnce sync.Once
mu sync.Mutex
writeMu sync.Mutex // 串行化对火山连接的写(Speak 与 Finish 可能并发)
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,
},
},
}
b, _ := json.Marshal(req)
return b
}
// StartTTS 连火山双向 TTS,握手(StartConnection→StartSession)成功后返回会话。
func StartTTS(ctx context.Context, cfg Config) (*TTSSession, error) {
if !cfg.TTSEnabled() {
return nil, fmt.Errorf("TTS 未配置")
}
hdr := http.Header{}
hdr.Set("Authorization", "Bearer "+cfg.APIKey) // 新版 API Key 鉴权
hdr.Set("X-Api-Resource-Id", cfg.TTSResourceID)
hdr.Set("X-Api-Connect-Id", newConnectID())
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, _, err := dialer.DialContext(ctx, ttsEndpoint, hdr)
if err != nil {
return nil, fmt.Errorf("连接火山 TTS 失败: %w", err)
}
sid := newConnectID()
// StartConnection → 期待 ConnectionStarted。
if err := conn.WriteMessage(websocket.BinaryMessage, connEventFrame(evStartConnection, []byte("{}"))); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("发送 StartConnection 失败: %w", err)
}
if err := expectTTSEvent(conn, evConnectionStarted); err != nil {
_ = conn.Close()
return nil, err
}
// StartSession → 期待 SessionStarted。
if err := conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evStartSession, sid, buildTTSStartSession(cfg))); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("发送 StartSession 失败: %w", err)
}
if err := expectTTSEvent(conn, evSessionStarted); err != nil {
_ = conn.Close()
return nil, err
}
s := &TTSSession{
conn: conn, sessionID: sid,
audio: make(chan []byte, 64), closed: make(chan struct{}),
}
go s.readLoop()
return s, nil
}
// expectTTSEvent 同步读一帧,校验是期望的事件(握手阶段用,此时还没起 readLoop)。
func expectTTSEvent(conn *websocket.Conn, want int32) error {
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
_, data, err := conn.ReadMessage()
conn.SetReadDeadline(time.Time{}) // 清除
if err != nil {
return fmt.Errorf("读 TTS 握手响应失败: %w", err)
}
r, perr := parseTTSFrame(data)
if perr != nil {
return fmt.Errorf("解析 TTS 握手响应失败: %w", perr)
}
if r.MsgType == ttsServerErr {
return fmt.Errorf("TTS 握手被拒 code=%d: %s", r.Code, string(r.Payload))
}
if r.Event != want && r.Event != 0 { // 0=未带事件号的容错
return fmt.Errorf("TTS 握手期待事件 %d,得 %d", want, r.Event)
}
return nil
}
// Speak 推一段文字给 TTSTaskRequest / 事件 200)。可多次调用逐句推。
func (s *TTSSession) Speak(text string) error {
payload, _ := json.Marshal(map[string]any{"text": text})
s.writeMu.Lock()
defer s.writeMu.Unlock()
return s.conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evTaskRequest, s.sessionID, payload))
}
// Finish 发 FinishSession,告知本轮文字推完;服务端把剩余音频吐完后回 SessionFinishedAudio 随之关闭)。
func (s *TTSSession) Finish() error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
return s.conn.WriteMessage(websocket.BinaryMessage, sessionEventFrame(evFinishSession, s.sessionID, []byte("{}")))
}
// Audio 返回下行音频流(PCM 24k);会话结束/出错时关闭。
func (s *TTSSession) Audio() <-chan []byte { return s.audio }
// Err 返回会话错误(Audio 关闭后读取)。
func (s *TTSSession) Err() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.err
}
func (s *TTSSession) setErr(err error) {
s.mu.Lock()
if s.err == nil {
s.err = err
}
s.mu.Unlock()
}
// Close 关闭底层连接(读 goroutine 随之退出,Audio 关闭)。幂等。
func (s *TTSSession) Close() {
s.closeOnce.Do(func() {
close(s.closed)
_ = s.conn.Close()
})
}
func (s *TTSSession) readLoop() {
defer close(s.audio)
for {
_, data, err := s.conn.ReadMessage()
if err != nil {
return // 连接关闭 / 读错误
}
r, perr := parseTTSFrame(data)
if perr != nil {
continue
}
switch {
case r.MsgType == ttsServerErr:
s.setErr(fmt.Errorf("火山 TTS 错误 code=%d: %s", r.Code, string(r.Payload)))
return
case r.Event == evSessionFailed:
s.setErr(fmt.Errorf("火山 TTS 会话失败: %s", string(r.Payload)))
return
case r.Event == evSessionFinished || r.Event == evConnectionFinished:
return // 本轮朗读完毕
case r.IsAudio && len(r.Payload) > 0:
select {
case s.audio <- r.Payload:
case <-s.closed:
return
}
}
}
}
@@ -0,0 +1,201 @@
package voice
import (
"bytes"
"compress/gzip"
"encoding/binary"
"fmt"
"io"
)
// 火山「双向流式 TTS V3」二进制帧编解码。与 ASR(frame.go)是**不同族**协议:
// 这一族带「事件号 + 会话 ID + gzip payload」,是全双工事件协议(StartConnection/StartSession/
// TaskRequest/FinishSession…)。字节级布局从官方参考实现核实(generate_header / parse_response):
//
// byte0 = (version<<4)|headerSize version=1, headerSize=1(=4字节)
// byte1 = (msgType<<4)|flags flags 含 ttsFlagWithEvent 表示带事件号
// byte2 = (serialization<<4)|compression 请求一律 JSON+GZIP;音频响应为 raw
// byte3 = 0x00 保留
// [event 4B 大端] flags 带 withEvent 时存在
// [会话级事件]sessionIdLen 4B 大端 + sessionId
// payloadLen 4B 大端 + payload(gzip)
//
// 连接级事件(StartConnection=1 / FinishConnection=2)无会话 ID;会话级(100/200/102…)带会话 ID。
// 消息类型(byte1 高 4bit)。
const (
ttsClientFull byte = 0b0001 // 客户端完整请求(JSON
ttsClientAudio byte = 0b0010 // 客户端纯音频(TTS 上行不用;保留)
ttsServerFull byte = 0b1001 // 服务端完整响应
ttsServerAck byte = 0b1011 // 服务端 ACK(音频帧走这个)
ttsServerErr byte = 0b1111 // 服务端错误
)
// flagsbyte1 低 4bit)。
const (
ttsFlagNone byte = 0b0000
ttsFlagNegSeq byte = 0b0010 // 带序列号
ttsFlagWithEvent byte = 0b0100 // 带事件号
)
// 序列化 / 压缩(byte2)。
const (
ttsSerialNone byte = 0b0000
ttsSerialJSON byte = 0b0001
ttsCompNone byte = 0b0000
ttsCompGzip byte = 0b0001
)
// 事件号(客户端发 / 服务端回)。
const (
evStartConnection int32 = 1
evFinishConnection int32 = 2
evConnectionStarted int32 = 50
evConnectionFailed int32 = 51
evConnectionFinished int32 = 52
evStartSession int32 = 100
evFinishSession int32 = 102
evSessionStarted int32 = 150
evSessionFinished int32 = 152
evSessionFailed int32 = 153
evTaskRequest int32 = 200 // 客户端发文本(TTS 逐句推)
evTTSSentenceStart int32 = 350
evTTSSentenceEnd int32 = 351
evTTSResponse int32 = 352 // 服务端回音频帧
)
func gzipBytes(b []byte) []byte {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
_, _ = w.Write(b)
_ = w.Close()
return buf.Bytes()
}
func gunzip(b []byte) ([]byte, error) {
r, err := gzip.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
func ttsHeader(msgType, flags, serial, comp byte) []byte {
return []byte{
(0b0001 << 4) | 0b0001, // version=1, headerSize=1
(msgType << 4) | (flags & 0x0F),
(serial << 4) | comp,
0x00,
}
}
// connEventFrame 组连接级事件帧(无会话 ID),payload 为 JSONgzip)。
func connEventFrame(event int32, jsonPayload []byte) []byte {
var b bytes.Buffer
b.Write(ttsHeader(ttsClientFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip))
_ = binary.Write(&b, binary.BigEndian, event)
pl := gzipBytes(jsonPayload)
_ = binary.Write(&b, binary.BigEndian, uint32(len(pl)))
b.Write(pl)
return b.Bytes()
}
// sessionEventFrame 组会话级事件帧(带会话 ID),payload 为 JSONgzip)。
func sessionEventFrame(event int32, sessionID string, jsonPayload []byte) []byte {
var b bytes.Buffer
b.Write(ttsHeader(ttsClientFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip))
_ = binary.Write(&b, binary.BigEndian, event)
_ = binary.Write(&b, binary.BigEndian, uint32(len(sessionID)))
b.WriteString(sessionID)
pl := gzipBytes(jsonPayload)
_ = binary.Write(&b, binary.BigEndian, uint32(len(pl)))
b.Write(pl)
return b.Bytes()
}
// ttsResponse 是解析后的服务端帧。
type ttsResponse struct {
MsgType byte
Event int32
Payload []byte // 音频帧=raw PCMJSON 事件=JSON 原文;错误=错误 JSON
Code uint32 // 错误码(MsgType==ttsServerErr 时)
IsAudio bool
}
// parseTTSFrame 解析服务端帧(对齐参考实现 parse_response)。
func parseTTSFrame(data []byte) (*ttsResponse, error) {
if len(data) < 4 {
return nil, fmt.Errorf("帧过短 %d", len(data))
}
headerSize := int(data[0] & 0x0F)
msgType := data[1] >> 4
flags := data[1] & 0x0F
comp := data[2] & 0x0F
off := headerSize * 4
if off > len(data) {
return nil, fmt.Errorf("头长越界")
}
res := &ttsResponse{MsgType: msgType}
if msgType == ttsServerErr {
if len(data) < off+8 {
return nil, fmt.Errorf("错误帧过短")
}
res.Code = binary.BigEndian.Uint32(data[off : off+4])
sz := binary.BigEndian.Uint32(data[off+4 : off+8])
res.Payload = clampTail(data, off+8, sz)
return res, nil
}
p := off
if flags&ttsFlagNegSeq != 0 { // 跳过序列号
p += 4
}
if flags&ttsFlagWithEvent != 0 {
if len(data) < p+4 {
return nil, fmt.Errorf("事件号越界")
}
res.Event = int32(binary.BigEndian.Uint32(data[p : p+4]))
p += 4
}
// 会话 ID(有符号长度;服务端正常响应都带)。
if len(data) >= p+4 {
sidLen := int32(binary.BigEndian.Uint32(data[p : p+4]))
p += 4
if sidLen > 0 && len(data) >= p+int(sidLen) {
p += int(sidLen)
}
}
if len(data) < p+4 {
return res, nil // 无 payload 的控制帧(如 SessionFinished 可能无体)
}
sz := binary.BigEndian.Uint32(data[p : p+4])
p += 4
payload := clampTail(data, p, sz)
// 音频响应(TTSResponse/SERVER_ACK 且非 JSON)→ raw PCM,按压缩位解压。
res.IsAudio = res.Event == evTTSResponse || msgType == ttsServerAck
if comp == ttsCompGzip && len(payload) > 0 {
if dec, err := gunzip(payload); err == nil {
payload = dec
}
}
res.Payload = payload
return res, nil
}
// clampTail 从 off 起最多取 sz 字节(防伪造长度越界)。
func clampTail(data []byte, off int, sz uint32) []byte {
if off > len(data) {
return nil
}
end := off + int(sz)
if end > len(data) || sz == 0 {
end = len(data)
}
return data[off:end]
}
@@ -0,0 +1,110 @@
package voice
import (
"bytes"
"encoding/binary"
"testing"
)
// serverFrame 按参考实现 parse_response 的布局手搓一个服务端帧,供解析测试。
// 布局:header(4) [+event4] + sessionIdLen(4) + sessionId + payloadLen(4) + payload。
func serverFrame(msgType, flags, serial, comp byte, event int32, sid string, payload []byte) []byte {
var b bytes.Buffer
b.WriteByte((0b0001 << 4) | 0b0001)
b.WriteByte((msgType << 4) | flags)
b.WriteByte((serial << 4) | comp)
b.WriteByte(0x00)
if flags&ttsFlagWithEvent != 0 {
_ = binary.Write(&b, binary.BigEndian, event)
}
_ = binary.Write(&b, binary.BigEndian, uint32(len(sid)))
b.WriteString(sid)
_ = binary.Write(&b, binary.BigEndian, uint32(len(payload)))
b.Write(payload)
return b.Bytes()
}
// 音频响应(SERVER_ACK + 事件 352 + raw payload)应被识别为音频、取到原始 PCM。
func TestParseTTSFrame_Audio(t *testing.T) {
pcm := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0xfe}
frame := serverFrame(ttsServerAck, ttsFlagWithEvent, ttsSerialNone, ttsCompNone, evTTSResponse, "sess-1", pcm)
r, err := parseTTSFrame(frame)
if err != nil {
t.Fatalf("解析音频帧失败: %v", err)
}
if !r.IsAudio {
t.Errorf("应识别为音频帧")
}
if r.Event != evTTSResponse {
t.Errorf("event=%d,期望 %d", r.Event, evTTSResponse)
}
if !bytes.Equal(r.Payload, pcm) {
t.Errorf("PCM 不一致:得 %v,期望 %v", r.Payload, pcm)
}
}
// gzip 压缩的 JSON 事件帧(如 SessionStarted)应被正确解压。
func TestParseTTSFrame_GzipJSON(t *testing.T) {
js := []byte(`{"event":"SessionStarted"}`)
frame := serverFrame(ttsServerFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip, evSessionStarted, "sess-1", gzipBytes(js))
r, err := parseTTSFrame(frame)
if err != nil {
t.Fatalf("解析 JSON 帧失败: %v", err)
}
if r.Event != evSessionStarted {
t.Errorf("event=%d,期望 %d", r.Event, evSessionStarted)
}
if !bytes.Equal(r.Payload, js) {
t.Errorf("gzip 解压后 JSON 不一致:得 %s", string(r.Payload))
}
}
// 错误帧(SERVER_ERROR):code(4) + payloadLen(4) + payload,无 event/session。
func TestParseTTSFrame_Error(t *testing.T) {
var b bytes.Buffer
b.WriteByte((0b0001 << 4) | 0b0001)
b.WriteByte((ttsServerErr << 4) | ttsFlagNone)
b.WriteByte((ttsSerialJSON << 4) | ttsCompNone)
b.WriteByte(0x00)
_ = binary.Write(&b, binary.BigEndian, uint32(45000001)) // code
msg := []byte(`{"error":"quota"}`)
_ = binary.Write(&b, binary.BigEndian, uint32(len(msg)))
b.Write(msg)
r, err := parseTTSFrame(b.Bytes())
if err != nil {
t.Fatalf("解析错误帧失败: %v", err)
}
if r.MsgType != ttsServerErr {
t.Errorf("msgType=%d,期望错误类型", r.MsgType)
}
if r.Code != 45000001 {
t.Errorf("code=%d,期望 45000001", r.Code)
}
if !bytes.Equal(r.Payload, msg) {
t.Errorf("错误体不一致:得 %s", string(r.Payload))
}
}
// 客户端会话级事件帧应带上事件号与会话 ID,且 payload 为 gzip。
func TestSessionEventFrame_RoundTrip(t *testing.T) {
frame := sessionEventFrame(evTaskRequest, "sess-x", []byte(`{"text":"你好"}`))
// 手工核对头两个字节 + 事件号 + 会话 ID 长度。
if frame[0] != ((0b0001<<4)|0b0001) || (frame[1]>>4) != ttsClientFull {
t.Fatalf("头字节不对: %08b %08b", frame[0], frame[1])
}
if frame[1]&0x0f&ttsFlagWithEvent == 0 {
t.Errorf("应带 withEvent flag")
}
ev := int32(binary.BigEndian.Uint32(frame[4:8]))
if ev != evTaskRequest {
t.Errorf("event=%d,期望 %d", ev, evTaskRequest)
}
sidLen := binary.BigEndian.Uint32(frame[8:12])
if int(sidLen) != len("sess-x") {
t.Errorf("sessionId 长度=%d,期望 %d", sidLen, len("sess-x"))
}
if got := string(frame[12 : 12+sidLen]); got != "sess-x" {
t.Errorf("sessionId=%q", got)
}
}