dudu MVP:五端语音输入法初始提交
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s

- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调
- desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导)
- android:Compose 主 App + IME(键盘内录音直传)
- ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写)
- design/design-pipeline:设计系统 + token 导出 iOS/Android 主题
- doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 00:38:37 +08:00
commit 40760aa884
252 changed files with 40789 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
// 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"
"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
}
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) {
header := map[string][]string{
"Authorization": {"bearer " + g.APIKey},
"X-DashScope-DataInspection": {"enable"},
}
conn, _, err := websocket.DefaultDialer.DialContext(ctx, dashscopeWS, header)
if err != nil {
return nil, fmt.Errorf("dashscope dial: %w", err)
}
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 后才能推音频,否则服务端静默丢弃
select {
case <-s.started:
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
}
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)
}