feat(voice): 上行接线——最终转写→组DSL→提交任务(Phase 1 打通)

ASR 最终转写触发一次任务提交,复用 HTTP SubmitTask 那条共用关卡
(preflightCore/launchCore),语音只是"嘴替键盘",编排/工具/计费一行不新造。

- task_handler.go: preflight/launch 抽出无 gin 内核 preflightCore/launchCore
  (preflightBlock 承载拦截态),gin 版做薄封装;语音会话无 gin.Context 也走同一关卡
- voice_task.go: buildVoiceGraph(转写→input→agent 单图) + submitVoiceTask(校验/关卡/落库发射)
- voice.go: 会话升级时抓租户/会话;结果 goroutine 见 Final→onFinalTranscript 提交、
  回 ServerTask{task_id};去重连发的重复 final;画布图一次性消费
- voice_task_test.go: 组图合法性 + 带转写 + input→agent 连边

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 09:16:29 +08:00
parent 4ee4a91a51
commit 302e1ebaff
5 changed files with 238 additions and 33 deletions
+43 -7
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
@@ -47,21 +48,32 @@ func (h *Handler) VoiceStream(c *gin.Context) {
}
defer conn.Close()
sess := &voiceSession{conn: conn, uid: uid, cfg: cfg}
// 租户/会话在升级时(还握着 gin.Context)一并抓取,供 WS 读循环里提交任务复用共用关卡。
sess := &voiceSession{
conn: conn, uid: uid, cfg: cfg, h: h,
tenantID: tenantID(c), sessionID: sessionID(c),
}
sess.send(voice.ServerMsg{Type: voice.ServerReady})
sess.run()
sess.stopASR() // 连接结束,收掉在跑的识别会话
}
// voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。
// 上行 = 音频→ASR→转写(→下一步 SubmitTask);下行(token流→攒句→TTS→音频)将在 TTS 步接上。
// 上行 = 音频→ASR→转写→提交任务;下行token流→攒句→TTS→音频将在 TTS 步接上。
type voiceSession struct {
conn *websocket.Conn
uid string
cfg voice.Config
conn *websocket.Conn
h *Handler // 复用 preflightCore/launchCore 提交任务
uid string
tenantID string // 升级时抓取(读循环里无 gin.Context
sessionID string
cfg voice.Config
writeMu sync.Mutex // gorilla WS 不允许并发写:读循环与 ASR 结果 goroutine 都会 send,须串行化
asr *voice.ASRSession
asrCancel context.CancelFunc
pendingGraph string // 客户端 start 时带的画布编排图(语音触发既有编排),空则按转写现组
lastFinal string // 最近一次已提交的最终转写,去重连发的重复 final
}
// send 下发一条控制/事件消息(文本帧,JSON)。并发安全。
@@ -125,13 +137,14 @@ func (s *voiceSession) onControl(m voice.ClientMsg) (done bool) {
case voice.ClientBye:
return true
case voice.ClientStart:
s.pendingGraph = m.Graph // 客户端画布图(可空):本轮若有转写则语音触发它跑
s.lastFinal = ""
s.stopASR()
s.startASR() // 新一轮:重开识别
case voice.ClientEnd:
if s.asr != nil {
_ = s.asr.Finish() // 告知火山本轮说完,等最终转写(结果流里带 Final=true)
_ = s.asr.Finish() // 告知火山本轮说完,等最终转写(结果流里带 Final=true→提交任务
}
// TODO(上行接线):拿到 Final 转写 → 组 DSL/用 m.Graph → SubmitTask → 回推 task。
case voice.ClientBargeIn:
// 打断:停当前 TTS 播放(TTS 接入后处理)。
}
@@ -156,10 +169,33 @@ func (s *voiceSession) startASR() {
return // 识别流结束/出错
}
s.send(voice.ServerMsg{Type: voice.ServerTranscript, Text: r.Text, Final: r.Final})
if r.Final {
s.onFinalTranscript(r.Text) // 最终转写 → 提交任务
} else {
s.lastFinal = "" // 新的部分结果=新一轮开口,放行下一次 final 提交
}
}
}()
}
// onFinalTranscript 拿到一段最终转写就提交一次任务。去重连发的重复 final(同一句 SAUC 可能回多条)。
// 只在结果 goroutine 里调,lastFinal 无需加锁。
func (s *voiceSession) onFinalTranscript(text string) {
txt := strings.TrimSpace(text)
if txt == "" || txt == s.lastFinal {
return
}
s.lastFinal = txt
taskID, err := s.submitVoiceTask(txt, s.pendingGraph)
if err != nil {
s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "任务提交失败:" + err.Error()})
return
}
s.pendingGraph = "" // 画布图一次性消费,避免后续转写重复触发同图
s.send(voice.ServerMsg{Type: voice.ServerTask, TaskID: taskID})
// 下行(订阅 token 流→攒句→TTS→音频帧)在 TTS 步接上;此处先只回 task_id 供客户端切运行视图。
}
// stopASR 收掉当前识别会话(幂等)。
func (s *voiceSession) stopASR() {
if s.asr != nil {