diff --git a/sundynix-gateway/cmd/voicesim/main.go b/sundynix-gateway/cmd/voicesim/main.go new file mode 100644 index 0000000..4395a76 --- /dev/null +++ b/sundynix-gateway/cmd/voicesim/main.go @@ -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) +} diff --git a/sundynix-gateway/internal/handler/voice.go b/sundynix-gateway/internal/handler/voice.go index 88fe466..8cdd862 100644 --- a/sundynix-gateway/internal/handler/voice.go +++ b/sundynix-gateway/internal/handler/voice.go @@ -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()}) diff --git a/sundynix-gateway/sim_answer.wav b/sundynix-gateway/sim_answer.wav new file mode 100644 index 0000000..0378772 Binary files /dev/null and b/sundynix-gateway/sim_answer.wav differ diff --git a/sundynix-gateway/sim_question.wav b/sundynix-gateway/sim_question.wav new file mode 100644 index 0000000..2b8ae27 Binary files /dev/null and b/sundynix-gateway/sim_question.wav differ diff --git a/sundynix-gateway/voicesim b/sundynix-gateway/voicesim new file mode 100755 index 0000000..f2a3729 Binary files /dev/null and b/sundynix-gateway/voicesim differ