feat: Phase 5 STT — DashScope Gummy 实时语音识别 + 本地 Whisper 回退

- DashScope WebSocket STT 客户端 (gummy-chat-v1)
- 双引擎架构: DashScope 优先, Whisper 本地回退
- 实时流式 STT WebSocket 端点
- DevTools 模型搜索框焦点修复

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 22:15:43 +08:00
parent 0717928496
commit b1e89c606e
9 changed files with 545 additions and 84 deletions
@@ -1,11 +1,13 @@
package service
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/yourname/cyrene-ai/voice-service/internal/config"
)
@@ -13,48 +15,64 @@ import (
// SupportedLanguages STT 支持的语言列表
var SupportedLanguages = []string{"zh", "en", "ja", "ko", "auto"}
// STTService 语音转文字服务
// STTService 语音转文字服务
// 优先使用 DashScope Gummy API,不可用时回退到本地 Whisper。
type STTService struct {
whisperBinary string
whisperModel string
language string
dashscope *DashScopeSTT
}
// NewSTTService 创建 STT 服务
// NewSTTService 创建 STT 服务
func NewSTTService(cfg *config.Config) *STTService {
return &STTService{
whisperBinary: cfg.WhisperBinary,
whisperModel: cfg.WhisperModel,
language: cfg.WhisperLanguage,
dashscope: NewDashScopeSTT(cfg.DashScopeAPIKey, cfg.DashScopeModel),
}
}
// IsAvailable 检查 whisper binary 是否存在
// IsAvailable 检查是否有任一 STT 引擎可用。
func (s *STTService) IsAvailable() bool {
if s.dashscope.IsAvailable() {
return true
}
_, err := os.Stat(s.whisperBinary)
return err == nil
}
// Transcribe 将音频数据转录为文字
// audioData: 音频文件的二进制数据
// format: 音频格式 (wav, mp3, ogg, flac, m4a)
// language: 转录语言 (zh, en, ja, ko, auto),为空则使用默认语言
// Transcribe 将音频数据转录为文字
// 优先使用 DashScope,不可用时回退到本地 Whisper。
func (s *STTService) Transcribe(audioData []byte, format string, language string) (string, error) {
if !s.IsAvailable() {
return "", fmt.Errorf("STT 引擎未安装,请运行 scripts/setup-whisper.sh")
}
// 如果未指定语言,使用默认语言
if language == "" {
language = s.language
}
// 验证语言是否支持
if !isSupportedLanguage(language) {
return "", fmt.Errorf("不支持的语言: %s,支持的语言: %s", language, strings.Join(SupportedLanguages, ", "))
}
// 将音频数据写入临时文件
// 优先 DashScope
if s.dashscope.IsAvailable() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
text, err := s.dashscope.Transcribe(ctx, audioData, format, language)
if err == nil && text != "" {
return text, nil
}
}
// 回退到本地 Whisper
return s.transcribeWhisper(audioData, format, language)
}
// transcribeWhisper 使用本地 Whisper 引擎转录。
func (s *STTService) transcribeWhisper(audioData []byte, format string, language string) (string, error) {
if _, err := os.Stat(s.whisperBinary); err != nil {
return "", fmt.Errorf("STT 引擎不可用: DashScope API Key 未配置且 Whisper 未安装")
}
ext := normalizeExt(format)
tmpFile, err := os.CreateTemp("/tmp", "cyrene-stt-*"+ext)
if err != nil {
@@ -69,7 +87,6 @@ func (s *STTService) Transcribe(audioData []byte, format string, language string
}
tmpFile.Close()
// 如果不是 WAV 格式,尝试用 ffmpeg 转换
inputPath := tmpPath
if format != "wav" && format != "" {
convertedPath := tmpPath + ".wav"
@@ -77,11 +94,8 @@ func (s *STTService) Transcribe(audioData []byte, format string, language string
defer os.Remove(convertedPath)
inputPath = convertedPath
}
// 转换失败则仍使用原始文件(whisper.cpp 也支持其他格式)
}
// 调用 whisper.cpp
// whisper-cli 的 -of 标志会在去掉扩展名后追加 .txt
outputPrefix := strings.TrimSuffix(inputPath, filepath.Ext(inputPath))
outputTxt := outputPrefix + ".txt"
@@ -99,42 +113,41 @@ func (s *STTService) Transcribe(audioData []byte, format string, language string
return "", fmt.Errorf("whisper 转录失败: %w", err)
}
// 读取输出文本
defer os.Remove(outputTxt)
txtData, err := os.ReadFile(outputTxt)
if err != nil {
return "", fmt.Errorf("读取转录结果失败: %w", err)
}
text := strings.TrimSpace(string(txtData))
return text, nil
return strings.TrimSpace(string(txtData)), nil
}
// GetStatus 返回服务状态
// GetStatus 返回服务状态
func (s *STTService) GetStatus() map[string]interface{} {
binaryAvailable := s.IsAvailable()
binaryAvailable := false
if _, err := os.Stat(s.whisperBinary); err == nil {
binaryAvailable = true
}
modelExists := false
if _, err := os.Stat(s.whisperModel); err == nil {
modelExists = true
}
modelName := filepath.Base(s.whisperModel)
return map[string]interface{}{
"available": binaryAvailable && modelExists,
"binary_available": binaryAvailable,
"model_loaded": modelExists,
"binary_path": s.whisperBinary,
"model_path": s.whisperModel,
"model_name": modelName,
"default_language": s.language,
"available": s.IsAvailable(),
"primary": "dashscope",
"dashscope": s.dashscope.GetStatus(),
"whisper": map[string]interface{}{
"available": binaryAvailable && modelExists,
"binary_available": binaryAvailable,
"model_loaded": modelExists,
"model_name": filepath.Base(s.whisperModel),
},
"default_language": s.language,
"supported_languages": SupportedLanguages,
}
}
// normalizeExt 规范化文件扩展名
// normalizeExt 规范化文件扩展名
func normalizeExt(format string) string {
switch strings.ToLower(format) {
case "wav":
@@ -152,7 +165,7 @@ func normalizeExt(format string) string {
}
}
// isSupportedLanguage 检查语言是否支持
// isSupportedLanguage 检查语言是否支持
func isSupportedLanguage(lang string) bool {
for _, l := range SupportedLanguages {
if l == lang {
@@ -162,7 +175,7 @@ func isSupportedLanguage(lang string) bool {
return false
}
// convertToWav 使用 ffmpeg 将音频转换为 WAV 格式
// convertToWav 使用 ffmpeg 将音频转换为 WAV 格式
func convertToWav(inputPath, outputPath string) error {
cmd := exec.Command("ffmpeg",
"-i", inputPath,