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>
This commit is contained in:
Blizzard
2026-07-22 11:08:17 +08:00
parent 08e6425fec
commit 9ae06229b8
5 changed files with 245 additions and 11 deletions
+31 -11
View File
@@ -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()})