Files
dudu/server/internal/asr/gummy.go
T
wangjia d22e02fa2e
ci / server (push) Failing after 9s
ci / design-tokens (push) Failing after 10s
fix(#23)+feat(#24): stop 后不再下发周期 usage 帧;gummy 预连接 spare 会话
#23 修复(服务端根治,五端客户端提交信号自动变安全):
- finish 先冻结会话并停 usageLoop,再 flush provider——此前 usageLoop 在
  waitResults(最长 3s)期间继续 tick,周期 usage 帧会抢在尾部 final 之前
  下发,客户端以 partial 文本提前上屏、丢失 final 修正(12B 实测 8s 会话
  4/4 复现;修复后 6/6 归零)
- 桌面端收尾兜底超时 350ms → 800ms(实测 8s 会话 final flush 需 350~540ms,
  350ms 会截丢尾 final)

#24 预连接(12B 调优):
- GummyProvider 常备一条已完成 run-task 握手的 spare 会话,start 直取,
  后台异步补位 + 40s 定期换新(DashScope 空闲 60s 断连,实测 60s 存活/
  120s Idle timeout,留余量 45s)
- 实测网关 start 处理 120~670ms → 3~4ms;消除 dial 抖动(实测 60ms~3.7s)
  对 first_partial 尾部的放大

gummycheck 增加 -model / -pace / -idle 参数,支持模型对比与闲置存活实验

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:44:25 +08:00

338 lines
8.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.
// gummy provider:阿里云百炼 DashScope 流式语音识别(gummy-realtime-v1)。
// WS 协议:run-task → 推二进制音频 → finish-task;下行 result-generated / task-finished / task-failed。
// ⚠️ 待真实 DASHSCOPE_API_KEY 到位后联调验证(6B 验收项),协议细节以
// https://help.aliyun.com/zh/model-studio/ 实时语音识别文档为准。
package asr
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const dashscopeWS = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
type GummyProvider struct {
APIKey string
Model string // 默认 gummy-realtime-v1
// spare 预连接(12B/#24):后台常备一条已完成 run-task 握手的 DashScope
// 会话,start 直接取用,把 dial+task-started(实测暖路径 ~130ms、抖动可达
// 2.5~3.7s)从 first_partial 关键路径上移除。协议固定 16kHz,spare 可互换。
mu sync.Mutex
spare *spareEntry
dialing bool
maintainOne sync.Once
}
type spareEntry struct {
sess *gummySession
born time.Time
}
// spareMaxAge spare 的可用年龄上限:DashScope 空闲 60s 断连(实测 60s 存活、
// 120s 报 Idle timeout),留余量在 45s 内取用、40s 定期换新。
const spareMaxAge = 45 * time.Second
func NewGummy(apiKey string) *GummyProvider {
return &GummyProvider{APIKey: apiKey, Model: "gummy-realtime-v1"}
}
func (g *GummyProvider) Name() string { return "gummy" }
func (g *GummyProvider) StartSession(ctx context.Context, cfg SessionConfig) (Session, error) {
g.maintainOne.Do(func() { go g.maintainSpare() })
if cfg.SampleRate == 16000 {
if s := g.takeSpare(); s != nil {
go g.replenish()
return s, nil
}
defer func() { go g.replenish() }()
}
return g.dialSession(ctx, cfg)
}
// takeSpare 取走当前 spare(若仍新鲜且存活);过期/已死的就地关闭丢弃。
func (g *GummyProvider) takeSpare() *gummySession {
g.mu.Lock()
sp := g.spare
g.spare = nil
g.mu.Unlock()
if sp == nil {
return nil
}
if time.Since(sp.born) < spareMaxAge && sp.sess.alive() {
slog.Info("gummy: session from spare", "task", sp.sess.taskID,
"age_ms", time.Since(sp.born).Milliseconds())
return sp.sess
}
_ = sp.sess.Close()
return nil
}
// replenish 异步补位一条 spare;已有 spare 或正在补位时空转返回。
func (g *GummyProvider) replenish() {
g.mu.Lock()
if g.dialing || g.spare != nil {
g.mu.Unlock()
return
}
g.dialing = true
g.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
sess, err := g.dialSession(ctx, SessionConfig{SampleRate: 16000, SessionID: "spare"})
cancel()
g.mu.Lock()
g.dialing = false
if err == nil {
if g.spare == nil {
g.spare = &spareEntry{sess: sess.(*gummySession), born: time.Now()}
g.mu.Unlock()
return
}
g.mu.Unlock()
_ = sess.Close() // 竞态下已有他人补位:丢弃多余会话
return
}
g.mu.Unlock()
slog.Warn("gummy: spare replenish failed", "err", err)
}
// maintainSpare 每 40s 换新 spare,避免超过 DashScope 60s 空闲上限后
// 取到已死会话、退化为冷启 dial。进程常驻,随 provider 生命周期运行。
func (g *GummyProvider) maintainSpare() {
t := time.NewTicker(40 * time.Second)
defer t.Stop()
for range t.C {
g.mu.Lock()
sp := g.spare
stale := sp != nil && time.Since(sp.born) >= spareMaxAge-5*time.Second
if stale {
g.spare = nil
}
g.mu.Unlock()
if stale {
_ = sp.sess.Close()
}
g.replenish()
}
}
// dialSession 建立一条全新的 DashScope 会话(原 StartSession 主体)。
func (g *GummyProvider) dialSession(ctx context.Context, cfg SessionConfig) (Session, error) {
header := map[string][]string{
"Authorization": {"bearer " + g.APIKey},
"X-DashScope-DataInspection": {"enable"},
}
tDial := time.Now()
conn, _, err := websocket.DefaultDialer.DialContext(ctx, dashscopeWS, header)
if err != nil {
return nil, fmt.Errorf("dashscope dial: %w", err)
}
dialMs := time.Since(tDial).Milliseconds()
taskID := uuid.NewString()
runTask := map[string]any{
"header": map[string]any{
"action": "run-task",
"task_id": taskID,
"streaming": "duplex",
},
"payload": map[string]any{
"task_group": "audio",
"task": "asr",
"function": "recognition",
"model": g.Model,
"parameters": map[string]any{
"sample_rate": cfg.SampleRate,
"format": "pcm",
"transcription_enabled": true,
"translation_enabled": false,
},
"input": map[string]any{},
},
}
if err := conn.WriteJSON(runTask); err != nil {
conn.Close()
return nil, fmt.Errorf("dashscope run-task: %w", err)
}
s := &gummySession{
conn: conn,
taskID: taskID,
results: make(chan Result, 64),
done: make(chan struct{}),
started: make(chan struct{}),
}
go s.readLoop()
// 协议要求:必须等 task-started 后才能推音频,否则服务端静默丢弃
tWait := time.Now()
select {
case <-s.started:
// 会话建立耗时是 first_partial 延迟的组成部分(12B):dial 冷启(DNS+TLS
// 可达 ~600ms、暖路径 ~60mstask-started 通常 ~60ms。持续观测供延迟排查。
slog.Info("gummy: session ready", "task", taskID,
"dial_ms", dialMs, "task_started_wait_ms", time.Since(tWait).Milliseconds())
return s, nil
case <-s.done:
return nil, fmt.Errorf("dashscope: closed before task-started")
case <-ctx.Done():
_ = conn.Close()
return nil, ctx.Err()
case <-time.After(10 * time.Second):
_ = conn.Close()
return nil, fmt.Errorf("dashscope: task-started timeout")
}
}
type gummySession struct {
conn *websocket.Conn
taskID string
results chan Result
done chan struct{}
started chan struct{} // task-started 已收到
writeMu sync.Mutex
closeOnce sync.Once
startOnce sync.Once
lastText string // 当前句已下发文本,用于切分 partial/final
}
// alive 会话底层连接是否仍然存活(readLoop 退出即 done 关闭)。
func (s *gummySession) alive() bool {
select {
case <-s.done:
return false
default:
return true
}
}
func (s *gummySession) SendAudio(pcm []byte) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
select {
case <-s.done:
return nil
default:
}
return s.conn.WriteMessage(websocket.BinaryMessage, pcm)
}
// dashscope 下行事件结构(按需字段)。
type dsEvent struct {
Header struct {
Event string `json:"event"` // task-started | result-generated | task-finished | task-failed
ErrorMessage string `json:"error_message"`
} `json:"header"`
Payload struct {
Output struct {
// gummy: transcriptionparaformer: sentence —— 两种都尝试解析
Transcription *dsSentence `json:"transcription"`
Sentence *dsSentence `json:"sentence"`
} `json:"output"`
} `json:"payload"`
}
type dsSentence struct {
Text string `json:"text"`
SentenceEnd flexBool `json:"sentence_end"`
IsSentenceEnd flexBool `json:"is_sentence_end"`
BeginTime int64 `json:"begin_time"`
EndTime int64 `json:"end_time"`
}
// flexBool 兼容 dashscope 同一字段混用 bool 与字符串("true"/"false")的情况。
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
switch string(data) {
case "true", `"true"`:
*b = true
default:
*b = false
}
return nil
}
func (s *gummySession) readLoop() {
defer func() {
s.closeOnce.Do(func() { close(s.done) })
close(s.results)
s.conn.Close()
}()
for {
_, data, err := s.conn.ReadMessage()
if err != nil {
select {
case <-s.done: // 主动关闭
default:
s.results <- Result{Err: err}
}
return
}
if os.Getenv("GUMMY_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[gummy] %s\n", data)
}
var ev dsEvent
if json.Unmarshal(data, &ev) != nil {
continue
}
switch ev.Header.Event {
case "task-started":
s.startOnce.Do(func() { close(s.started) })
case "result-generated":
sen := ev.Payload.Output.Transcription
if sen == nil {
sen = ev.Payload.Output.Sentence
}
if sen == nil || sen.Text == "" {
continue
}
isFinal := bool(sen.SentenceEnd) || bool(sen.IsSentenceEnd)
s.results <- Result{Text: sen.Text, IsFinal: isFinal, EndTimeMs: sen.EndTime}
if isFinal {
s.lastText = ""
} else {
s.lastText = sen.Text
}
case "task-finished":
return
case "task-failed":
s.results <- Result{Err: fmt.Errorf("dashscope: %s", ev.Header.ErrorMessage)}
return
}
}
}
func (s *gummySession) Results() <-chan Result { return s.results }
// Close 发 finish-task 让上游 flush 尾部,等 task-finished 后 readLoop 退出。
func (s *gummySession) Close() error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
select {
case <-s.done:
return nil
default:
}
finish := map[string]any{
"header": map[string]any{
"action": "finish-task",
"task_id": s.taskID,
"streaming": "duplex",
},
"payload": map[string]any{"input": map[string]any{}},
}
return s.conn.WriteJSON(finish)
}