Files
sundynix-agentix/sundynix-gateway/cmd/voicesim/main.go
T
Blizzard b6927247da feat(voice): 下行打字机文本流 + 首块快出,即时反馈
- protocol.go: 新增 ServerReply("reply") 消息——Agent 回答增量文本
- voice_tts.go: token 一到即转发客户端(打字机),同时攒句喂 TTS(音频随后)
- sentence_buffer.go: 本轮首句用低阈值(5 rune)抢首字延迟,之后回常规 12
- 桌面端 voice.ts onReply + VoiceDock 对话气泡(我说的 + JARVIS 打字机回答,思考态光标)

管线已最优:文字在 LLM 首 token 即刻上屏、音频紧随。剩余时延=大模型 TTFT(deepseek-v4-pro
4-7s 且波动大,疑似推理模型),这是模型的账、非管线——真要"马上响应"需换快模型。

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

246 lines
7.9 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, tFirstReply, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
var replyText string
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.ServerReply:
if tFirstReply.IsZero() {
tFirstReply = time.Now()
fmt.Println(" ← 💬 打字机开始(回答文字逐字冒出)…")
}
replyText += m.Text
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 !tFirstReply.IsZero() {
fmt.Printf(" · 💬 首字上屏 %.2fs ← 打字机开始(体感「马上响应」就看这个)\n", tFirstReply.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)
}