// 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=%s(Agent 正在思考…)\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→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) }