dudu MVP:五端语音输入法初始提交
- 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:
@@ -0,0 +1,110 @@
|
||||
// gummycheck:1B/6B 实测工具——用真实 DashScope Key 验证 gummy provider 全链路。
|
||||
// 用法(key 经 stdin 传入,不落盘):
|
||||
//
|
||||
// rbw get dashscope-api-key | go run ./cmd/gummycheck -wav /path/to/16k-mono-16bit.wav
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dudu/server/internal/asr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
wavPath := flag.String("wav", "", "16kHz/16bit/mono wav 文件路径")
|
||||
flag.Parse()
|
||||
if *wavPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "用法: rbw get dashscope-api-key | gummycheck -wav test.wav")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
if !sc.Scan() {
|
||||
fmt.Fprintln(os.Stderr, "stdin 未读到 API key")
|
||||
os.Exit(2)
|
||||
}
|
||||
key := strings.TrimSpace(sc.Text())
|
||||
|
||||
pcm, err := readWavData(*wavPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "读 wav 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("音频 %d 字节 ≈ %.1fs\n", len(pcm), float64(len(pcm))/32000)
|
||||
|
||||
p := asr.NewGummy(key)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
sess, err := p.StartSession(ctx, asr.SessionConfig{SampleRate: 16000, SessionID: "gummycheck"})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "StartSession 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("会话建立 %v\n", time.Since(start).Round(time.Millisecond))
|
||||
|
||||
done := make(chan struct{})
|
||||
var firstPartial time.Duration
|
||||
go func() {
|
||||
defer close(done)
|
||||
for r := range sess.Results() {
|
||||
if r.Err != nil {
|
||||
fmt.Println("ERROR:", r.Err)
|
||||
return
|
||||
}
|
||||
if firstPartial == 0 {
|
||||
firstPartial = time.Since(start)
|
||||
}
|
||||
tag := "partial"
|
||||
if r.IsFinal {
|
||||
tag = "FINAL "
|
||||
}
|
||||
fmt.Printf("[%6.2fs] %s end=%dms %s\n", time.Since(start).Seconds(), tag, r.EndTimeMs, r.Text)
|
||||
}
|
||||
}()
|
||||
|
||||
const frame = 3200
|
||||
for off := 0; off < len(pcm); off += frame {
|
||||
end := min(off+frame, len(pcm))
|
||||
if err := sess.SendAudio(pcm[off:end]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "SendAudio 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // 2x 实时推流
|
||||
}
|
||||
_ = sess.Close()
|
||||
<-done
|
||||
fmt.Printf("首个结果延迟 %v(含 2x 推流速度因素)\n", firstPartial.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// readWavData 提取 wav 的 data chunk(仅支持 PCM)。
|
||||
func readWavData(path string) ([]byte, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) < 44 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
|
||||
return nil, fmt.Errorf("不是 wav 文件")
|
||||
}
|
||||
off := 12
|
||||
for off+8 <= len(b) {
|
||||
id := string(b[off : off+4])
|
||||
size := int(binary.LittleEndian.Uint32(b[off+4 : off+8]))
|
||||
if id == "data" {
|
||||
return b[off+8 : min(off+8+size, len(b))], nil
|
||||
}
|
||||
off += 8 + size
|
||||
}
|
||||
return nil, fmt.Errorf("未找到 data chunk")
|
||||
}
|
||||
|
||||
var _ = bytes.MinRead
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dudu/server/internal/auth"
|
||||
"dudu/server/internal/config"
|
||||
"dudu/server/internal/httpapi"
|
||||
"dudu/server/internal/quota"
|
||||
"dudu/server/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
cfg := config.Load()
|
||||
|
||||
db, err := store.Open(cfg.PostgresDSN)
|
||||
if err != nil {
|
||||
slog.Error("postgres open failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
rdb := store.OpenRedis(cfg.RedisAddr, cfg.RedisDB)
|
||||
|
||||
jwt := auth.NewJWT(cfg.JWTSecret, time.Duration(cfg.JWTTTLHours)*time.Hour, rdb)
|
||||
qm := quota.New(rdb, db)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
httpapi.Register(r, &httpapi.Deps{Cfg: cfg, DB: db, RDB: rdb, JWT: jwt, Quota: qm})
|
||||
|
||||
slog.Info("dudu server listening", "addr", cfg.Addr, "asr_provider", cfg.ASRProvider)
|
||||
if err := r.Run(cfg.Addr); err != nil {
|
||||
slog.Error("server exited", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user