Files
sundynix-agentix/sundynix-gateway/cmd/voicesim/main.go
T
Blizzard 6b31856551 perf(voice): 砍兜底等待 1.2s→0.5s + 语音提示词强制简短 + 时延测量
- voice.go: ClientEnd 兜底等待 1200ms→500ms(白吃的时延),首字出声 6.6s→5.3s
- voice_task.go: JARVIS 系统提示词改"必须简短/最多三句/先给结论"(语音场景长答案既拖慢
  首字又难听;注:deepseek-v4-pro 仍可能不理会长度指令,可靠收短需 max_tokens 硬顶)
- voicesim: 时延拆解(提交/首字出声/朗读完毕,以"说完"为0点)

现状:首字出声 ~5.3s,大头是 deepseek 生成第一句(大模型 TTFT);管线开销(兜底+TTS握手)已压到~1s。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:17:28 +08:00

236 lines
7.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{})
var endAt, tTask, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
go func() { // 读循环:文本帧=事件,二进制帧=回答 TTS 音频
defer close(done)
for {
mt, data, err := conn.ReadMessage()
if err != nil {
return
}
if mt == websocket.BinaryMessage {
if tFirstAudio.IsZero() {
tFirstAudio = time.Now() // 首个音频帧=听到第一声
}
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:
tTask = time.Now()
fmt.Printf(" ← 任务已提交 task_id=%sAgent 正在思考…)\n", m.TaskID)
case voice.ServerSpeaking:
fmt.Println(" ← Agent 开始朗读回答…")
case voice.ServerTTSEnd:
tDone = time.Now()
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})
endAt = time.Now() // 时延起点:用户"说完"这一刻
fmt.Println("④ 已说完,等 Agent 回答 + 朗读(大模型 + TTS,稍候)…")
// 5) 等回答朗读完(或超时)。
select {
case <-done:
case <-time.After(90 * time.Second):
fmt.Println(" ⏱️ 超时(90s)——大模型/TTS 可能较慢,已收到的音频仍会保存")
}
if len(answer) > 0 {
dur := float64(len(answer)/2) / float64(voice.TTSSampleRate)
_ = writeWAV("sim_answer.wav", answer, voice.TTSSampleRate)
fmt.Printf("\n🔊 回答音频 %d 字节(时长 %.1f 秒)→ 存 sim_answer.wav\n", len(answer), dur)
fmt.Println(" afplay sim_answer.wav # 听 JARVIS 的语音回答")
// 时延拆解(以"说完"为 0 点)。首字延迟 = 听到第一声的时间,是体感关键。
fmt.Println("\n⏱️ 时延拆解(从「说完」起算):")
if !tTask.IsZero() {
fmt.Printf(" · 提交任务 %.2fs(含 ClientEnd 后 0.5s 兜底等待)\n", tTask.Sub(endAt).Seconds())
}
if !tFirstAudio.IsZero() {
fmt.Printf(" · 👂 首字出声 %.2fs ← 体感响应速度就看这个\n", tFirstAudio.Sub(endAt).Seconds())
}
if !tDone.IsZero() {
fmt.Printf(" · 朗读完毕 %.2fs= 首字 %.2fs + 念完 %.1fs 那段话)\n",
tDone.Sub(endAt).Seconds(), tFirstAudio.Sub(endAt).Seconds(), dur)
}
} 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)
}