feat(voice): 显式 end 兜底提交 + 端到端模拟工具,全链路真机跑通✅
火山流式 ASR 只在 VAD 静音时发 Final;客户端点"停"(ClientEnd)不能干等——否则整段说完 却因没触发 VAD Final 而永不提交任务(实测复现)。改为: - voice.go 累计 latestText(每条转写更新);ClientEnd 后 1.2s 用最新转写兜底提交; turnMu+submitted 保证 Final 与 ClientEnd 两路只提交一次(替换旧 lastFinal 去重) - cmd/voicesim: 端到端模拟(免麦)——TTS 合成问话→灌网关语音WS→ASR转写→提交任务→ 大模型回答→TTS朗读回推,问答音频各存 wav,签发测试用户 JWT(auth.Issue) 真机验证:问"你是谁?你能做什么?"→ task_bab1c6ee → JARVIS 语音回答 18.6s(sim_answer.wav)。 麦克风音频→ASR→任务→大模型→TTS 全程走网关跑通。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// voicesim 端到端模拟一次语音对话(免麦克风):把一句问话用火山 TTS 合成成音频,当作"麦克风输入"
|
||||
// 灌进网关的语音 WebSocket,走完整链路——ASR 转写 → 提交任务 → Agent(大模型)回答 → TTS 朗读回推,
|
||||
// 把「问题音频」和「回答音频」都存成 wav,转写/task_id/回答文字打印出来。晚上有麦克风前先这样验全链路。
|
||||
//
|
||||
// 前置:gateway/dispatcher/mcp-go/基建都在跑;语音配置已入库;LLM 已配。
|
||||
// 用法:
|
||||
//
|
||||
// export VOLC_API_KEY=... VOLC_ASR_RESOURCE_ID=volc.bigasr.sauc.duration \
|
||||
// VOLC_TTS_RESOURCE_ID=seed-tts-2.0 VOLC_TTS_VOICE=zh_male_m191_uranus_bigtts
|
||||
// go run ./cmd/voicesim # 默认问"你是谁?你能做什么?"
|
||||
// go run ./cmd/voicesim "帮我查下明天天气" # 自定义问话
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/sundynix/sundynix-gateway/internal/auth"
|
||||
"github.com/sundynix/sundynix-gateway/internal/voice"
|
||||
)
|
||||
|
||||
const (
|
||||
gatewayWS = "ws://localhost:8080/api/v1/voice/stream"
|
||||
testUser = "2067489539219263488" // blizzardzhang@icloud.com
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := voice.Config{
|
||||
APIKey: os.Getenv("VOLC_API_KEY"),
|
||||
ASRResourceID: os.Getenv("VOLC_ASR_RESOURCE_ID"),
|
||||
TTSResourceID: os.Getenv("VOLC_TTS_RESOURCE_ID"),
|
||||
TTSVoiceType: os.Getenv("VOLC_TTS_VOICE"),
|
||||
}
|
||||
if !cfg.TTSEnabled() {
|
||||
fatal("缺 VOLC_* 环境变量(需要 TTS 来合成问话音频)")
|
||||
}
|
||||
question := "你是谁?你能做什么?"
|
||||
if len(os.Args) > 1 && os.Args[1] != "" {
|
||||
question = os.Args[1]
|
||||
}
|
||||
fmt.Printf("🗣️ 模拟问话:%q\n", question)
|
||||
|
||||
// 1) 用火山 TTS 把问话合成为音频(PCM 24k)→ 降采样到 16k(ASR 上行采样率)。
|
||||
fmt.Println("① 合成问话音频…")
|
||||
q24k := synth(cfg, question)
|
||||
q16k := downsample24kTo16k(q24k)
|
||||
_ = writeWAV("sim_question.wav", q24k, 24000)
|
||||
fmt.Printf(" ✅ 问话音频 %d 字节(存 sim_question.wav)\n", len(q24k))
|
||||
|
||||
// 2) 签发测试用户 JWT(与 gateway 同一 dev 默认密钥,勿设 JWT_SECRET)。
|
||||
token, err := auth.Issue(testUser)
|
||||
if err != nil {
|
||||
fatal("签发 token 失败:" + err.Error())
|
||||
}
|
||||
|
||||
// 3) 连网关语音 WS。
|
||||
fmt.Println("② 连接网关语音 WebSocket…")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(gatewayWS+"?token="+token, nil)
|
||||
if err != nil {
|
||||
fatal("连接网关失败:" + err.Error())
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
answer := make([]byte, 0, 1<<20)
|
||||
done := make(chan struct{})
|
||||
go func() { // 读循环:文本帧=事件,二进制帧=回答 TTS 音频
|
||||
defer close(done)
|
||||
for {
|
||||
mt, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mt == websocket.BinaryMessage {
|
||||
answer = append(answer, data...)
|
||||
continue
|
||||
}
|
||||
var m voice.ServerMsg
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case voice.ServerReady:
|
||||
fmt.Println(" ← ready(会话就绪)")
|
||||
case voice.ServerTranscript:
|
||||
tag := "部分"
|
||||
if m.Final {
|
||||
tag = "最终"
|
||||
}
|
||||
fmt.Printf(" ← 转写[%s]:%q\n", tag, m.Text)
|
||||
case voice.ServerTask:
|
||||
fmt.Printf(" ← 任务已提交 task_id=%s(Agent 正在思考…)\n", m.TaskID)
|
||||
case voice.ServerSpeaking:
|
||||
fmt.Println(" ← Agent 开始朗读回答…")
|
||||
case voice.ServerTTSEnd:
|
||||
fmt.Println(" ← 回答朗读完毕")
|
||||
return
|
||||
case voice.ServerError:
|
||||
fmt.Printf(" ← 错误:%s\n", m.Msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 4) 发 start → 分帧灌音频(模拟实时)→ 发 end。
|
||||
send(conn, voice.ClientMsg{Type: voice.ClientStart})
|
||||
fmt.Println("③ 灌入问话音频…")
|
||||
const frame = 3200 // ~100ms @16k/16bit
|
||||
for i := 0; i < len(q16k); i += frame {
|
||||
end := i + frame
|
||||
if end > len(q16k) {
|
||||
end = len(q16k)
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.BinaryMessage, q16k[i:end])
|
||||
time.Sleep(90 * time.Millisecond)
|
||||
}
|
||||
send(conn, voice.ClientMsg{Type: voice.ClientEnd})
|
||||
fmt.Println("④ 已说完,等 Agent 回答 + 朗读(大模型 + TTS,稍候)…")
|
||||
|
||||
// 5) 等回答朗读完(或超时)。
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(90 * time.Second):
|
||||
fmt.Println(" ⏱️ 超时(90s)——大模型/TTS 可能较慢,已收到的音频仍会保存")
|
||||
}
|
||||
|
||||
if len(answer) > 0 {
|
||||
_ = writeWAV("sim_answer.wav", answer, voice.TTSSampleRate)
|
||||
fmt.Printf("\n🔊 回答音频 %d 字节(%.1f 秒)→ 存 sim_answer.wav\n", len(answer), float64(len(answer)/2)/float64(voice.TTSSampleRate))
|
||||
fmt.Println(" afplay sim_answer.wav # 听 JARVIS 的语音回答")
|
||||
} else {
|
||||
fmt.Println("\n⚠️ 没收到回答音频(看上面事件流定位:转写?任务?朗读?)")
|
||||
}
|
||||
fmt.Println("\n完整链路:麦克风音频 → ASR 转写 → 提交任务 → 大模型回答 → TTS 朗读 —— 全程走网关,与真麦克风一致。")
|
||||
}
|
||||
|
||||
// synth 用火山双向 TTS 合成整段文字为 PCM24k。
|
||||
func synth(cfg voice.Config, text string) []byte {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
ts, err := voice.StartTTS(ctx, cfg)
|
||||
if err != nil {
|
||||
fatal("合成连接失败:" + err.Error())
|
||||
}
|
||||
defer ts.Close()
|
||||
if err := ts.Speak(text); err != nil {
|
||||
fatal("合成推文字失败:" + err.Error())
|
||||
}
|
||||
_ = ts.Finish()
|
||||
var out []byte
|
||||
for chunk := range ts.Audio() {
|
||||
out = append(out, chunk...)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
fatal("合成没拿到音频")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func send(conn *websocket.Conn, m voice.ClientMsg) {
|
||||
b, _ := json.Marshal(m)
|
||||
_ = conn.WriteMessage(websocket.TextMessage, b)
|
||||
}
|
||||
|
||||
// downsample24kTo16k 16bit PCM 24k→16k(3 取 2 抽取)。
|
||||
func downsample24kTo16k(in []byte) []byte {
|
||||
n := len(in) / 2
|
||||
out := make([]byte, 0, n*2*2/3+4)
|
||||
for i := 0; i < n; i++ {
|
||||
if i%3 == 2 {
|
||||
continue
|
||||
}
|
||||
out = append(out, in[i*2], in[i*2+1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeWAV(path string, pcm []byte, rate int) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var h []byte
|
||||
put := func(s string) { h = append(h, s...) }
|
||||
u32 := func(v uint32) { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); h = append(h, b...) }
|
||||
u16 := func(v uint16) { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, v); h = append(h, b...) }
|
||||
put("RIFF")
|
||||
u32(uint32(36 + len(pcm)))
|
||||
put("WAVEfmt ")
|
||||
u32(16)
|
||||
u16(1)
|
||||
u16(1)
|
||||
u32(uint32(rate))
|
||||
u32(uint32(rate * 2))
|
||||
u16(2)
|
||||
u16(16)
|
||||
put("data")
|
||||
u32(uint32(len(pcm)))
|
||||
if _, err := f.Write(h); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(pcm)
|
||||
return err
|
||||
}
|
||||
|
||||
func fatal(msg string) {
|
||||
fmt.Fprintln(os.Stderr, "❌ "+msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -78,7 +78,10 @@ type voiceSession struct {
|
||||
ttsCancel context.CancelFunc
|
||||
|
||||
pendingGraph string // 客户端 start 时带的画布编排图(语音触发既有编排),空则按转写现组
|
||||
lastFinal string // 最近一次已提交的最终转写,去重连发的重复 final
|
||||
|
||||
turnMu sync.Mutex // 护住一轮的转写累计 + 提交去重(ASR 结果 goroutine 与 ClientEnd 兜底 goroutine 都访问)
|
||||
latestText string // 本轮最近一次转写(部分/最终);ClientEnd 时兜底用它提交
|
||||
submitted bool // 本轮是否已提交——Final 与 ClientEnd 两条路径只落一次
|
||||
}
|
||||
|
||||
// send 下发一条控制/事件消息(文本帧,JSON)。并发安全。
|
||||
@@ -143,13 +146,24 @@ func (s *voiceSession) onControl(m voice.ClientMsg) (done bool) {
|
||||
return true
|
||||
case voice.ClientStart:
|
||||
s.pendingGraph = m.Graph // 客户端画布图(可空):本轮若有转写则语音触发它跑
|
||||
s.lastFinal = ""
|
||||
s.turnMu.Lock()
|
||||
s.latestText, s.submitted = "", false // 新一轮:清累计与提交标记
|
||||
s.turnMu.Unlock()
|
||||
s.stopASR()
|
||||
s.startASR() // 新一轮:重开识别
|
||||
case voice.ClientEnd:
|
||||
if s.asr != nil {
|
||||
_ = s.asr.Finish() // 告知火山本轮说完,等最终转写(结果流里带 Final=true→提交任务)
|
||||
_ = s.asr.Finish() // 告知火山本轮说完
|
||||
}
|
||||
// 火山流式 ASR 只在 VAD 静音时才发 Final;客户端显式 end(点停)时不能干等——
|
||||
// 给点收尾时间让末尾部分结果到齐,再用"最新转写"兜底提交(trySubmit 去重,Final 先到就它先提交)。
|
||||
go func() {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
s.turnMu.Lock()
|
||||
txt := s.latestText
|
||||
s.turnMu.Unlock()
|
||||
s.trySubmit(txt)
|
||||
}()
|
||||
case voice.ClientBargeIn:
|
||||
s.stopTTS() // 打断:用户又开口,立刻掐掉正在朗读的 TTS
|
||||
}
|
||||
@@ -174,23 +188,29 @@ func (s *voiceSession) startASR() {
|
||||
return // 识别流结束/出错
|
||||
}
|
||||
s.send(voice.ServerMsg{Type: voice.ServerTranscript, Text: r.Text, Final: r.Final})
|
||||
if t := strings.TrimSpace(r.Text); t != "" {
|
||||
s.turnMu.Lock()
|
||||
s.latestText = r.Text // 累计最新转写,供 ClientEnd 兜底提交
|
||||
s.turnMu.Unlock()
|
||||
}
|
||||
if r.Final {
|
||||
s.onFinalTranscript(r.Text) // 最终转写 → 提交任务
|
||||
} else {
|
||||
s.lastFinal = "" // 新的部分结果=新一轮开口,放行下一次 final 提交
|
||||
s.trySubmit(r.Text) // VAD 检出句末 → 直接提交(与 ClientEnd 兜底二选一,去重)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// onFinalTranscript 拿到一段最终转写就提交一次任务。去重连发的重复 final(同一句 SAUC 可能回多条)。
|
||||
// 只在结果 goroutine 里调,lastFinal 无需加锁。
|
||||
func (s *voiceSession) onFinalTranscript(text string) {
|
||||
// trySubmit 本轮提交一次任务:Final 与 ClientEnd 兜底两条路径抢先,submitted 保证只落一次。
|
||||
func (s *voiceSession) trySubmit(text string) {
|
||||
txt := strings.TrimSpace(text)
|
||||
if txt == "" || txt == s.lastFinal {
|
||||
s.turnMu.Lock()
|
||||
if txt == "" || s.submitted {
|
||||
s.turnMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.lastFinal = txt
|
||||
s.submitted = true
|
||||
s.turnMu.Unlock()
|
||||
|
||||
taskID, err := s.submitVoiceTask(txt, s.pendingGraph)
|
||||
if err != nil {
|
||||
s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "任务提交失败:" + err.Error()})
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user