Files
sundynix-agentix/sundynix-gateway/cmd/voicesim/main.go
T
Blizzard 9ae06229b8 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>
2026-07-22 11:08:17 +08:00

215 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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=%sAgent 正在思考…)\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→16k3 取 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)
}