From 3d3de828fc3584cfd57138245f049912b872b073 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 21:31:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Xiaomi=20MiMo=20AS?= =?UTF-8?q?R=20=E8=AF=AD=E9=9F=B3=E8=AF=86=E5=88=AB=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E8=80=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MiMoService 实现 stt.Service 接口,通过 HTTP POST 调用 OpenAI 兼容的 /chat/completions 接口 - 自动将原始 PCM 数据封装为 WAV 格式(MiMo 仅支持 mp3/wav) - 语言代码映射:zh-CN→zh、en-US→en、其他→auto - main.go 添加 provider 选择逻辑(mimo/xiaomi → MiMo,其他 → Deepgram) - 更新 config.yaml 使用正确的 model 名称 mimo-v2.5-asr - 添加完整单元测试 --- backend/cmd/server/main.go | 9 +- backend/config.yaml | 4 +- backend/internal/ai/stt/mimo.go | 232 ++++++++++++++++++++++++ backend/internal/ai/stt/mimo_test.go | 255 +++++++++++++++++++++++++++ 4 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 backend/internal/ai/stt/mimo.go create mode 100644 backend/internal/ai/stt/mimo_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 134b791..5840f87 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "os/signal" + "strings" "syscall" "time" @@ -46,7 +47,13 @@ func main() { defer sessionMgr.(*session.MemoryManager).Stop() // 初始化 AI 服务 - sttService := stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log) + var sttService stt.Service + switch strings.ToLower(cfg.AI.STT.Provider) { + case "mimo", "xiaomi": + sttService = stt.NewMiMoService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log) + default: + sttService = stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log) + } llmService := llm.NewOpenAIService(cfg.AI.LLM.APIKey, cfg.AI.LLM.Model, cfg.AI.LLM.Endpoint, cfg.AI.LLM.Timeout, logger.Log) ttsService := tts.NewOpenAIService(cfg.AI.TTS.APIKey, cfg.AI.TTS.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Speed, cfg.AI.TTS.Timeout, logger.Log) diff --git a/backend/config.yaml b/backend/config.yaml index 988f336..2e9ae04 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -15,8 +15,8 @@ redis: ai: stt: - provider: Xiaomi MiMo - model: mimo-v2.5 + provider: mimo + model: mimo-v2.5-asr endpoint: "https://token-plan-cn.xiaomimimo.com/v1" llm: provider: dashscope diff --git a/backend/internal/ai/stt/mimo.go b/backend/internal/ai/stt/mimo.go new file mode 100644 index 0000000..49d96fd --- /dev/null +++ b/backend/internal/ai/stt/mimo.go @@ -0,0 +1,232 @@ +package stt + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "go.uber.org/zap" +) + +// MiMoService 基于 Xiaomi MiMo ASR HTTP API 的语音识别实现。 +// 接口兼容 OpenAI chat/completions 格式,音频仅支持 mp3/wav。 +type MiMoService struct { + apiKey string + model string + endpoint string + logger *zap.SugaredLogger +} + +// NewMiMoService 创建 MiMo STT 服务。 +func NewMiMoService(apiKey, model, endpoint string, logger *zap.SugaredLogger) *MiMoService { + if model == "" { + model = "mimo-v2.5-asr" + } + if endpoint == "" { + endpoint = "https://api.xiaomimimo.com/v1" + } + return &MiMoService{ + apiKey: apiKey, + model: model, + endpoint: endpoint, + logger: logger, + } +} + +// mimoRequest MiMo ASR 请求体。 +type mimoRequest struct { + Model string `json:"model"` + Messages []mimoMessage `json:"messages"` + ASROptions *mimoASROptions `json:"asr_options,omitempty"` +} + +type mimoMessage struct { + Role string `json:"role"` + Content []mimoContent `json:"content"` +} + +type mimoContent struct { + Type string `json:"type"` + InputAudio *mimoAudioIn `json:"input_audio,omitempty"` +} + +type mimoAudioIn struct { + Data string `json:"data"` // data URL: data:{mime};base64,{data} +} + +type mimoASROptions struct { + Language string `json:"language"` +} + +// mimoResponse MiMo ASR 非流式响应。 +type mimoResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +// Recognize 实现 stt.Service。将音频发送到 MiMo ASR API,返回识别文本。 +func (m *MiMoService) Recognize(ctx context.Context, audio []byte, opts Options) (string, error) { + if len(audio) == 0 { + return "", fmt.Errorf("stt: empty audio") + } + + // MiMo 仅支持 mp3/wav,若输入为原始 PCM 则封装为 WAV + audioData := audio + mimeType := "audio/wav" + if !isWAV(audio) && !isMP3(audio) { + wav, err := pcmToWAV(audio, opts.SampleRate, 1) + if err != nil { + return "", fmt.Errorf("stt: pcm to wav: %w", err) + } + audioData = wav + } else if isMP3(audio) { + mimeType = "audio/mpeg" + } + + b64 := base64.StdEncoding.EncodeToString(audioData) + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64) + + // 映射语言代码 + language := mapLanguage(opts.Language) + + reqBody := mimoRequest{ + Model: m.model, + Messages: []mimoMessage{ + { + Role: "user", + Content: []mimoContent{ + { + Type: "input_audio", + InputAudio: &mimoAudioIn{ + Data: dataURL, + }, + }, + }, + }, + }, + } + if language != "" { + reqBody.ASROptions = &mimoASROptions{Language: language} + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("stt: marshal request: %w", err) + } + + url := strings.TrimRight(m.endpoint, "/") + "/chat/completions" + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("stt: create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("api-key", m.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("stt: request mimo: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("stt: read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("stt: mimo returned %d: %s", resp.StatusCode, string(respBody)) + } + + var mResp mimoResponse + if err := json.Unmarshal(respBody, &mResp); err != nil { + return "", fmt.Errorf("stt: unmarshal response: %w", err) + } + + if len(mResp.Choices) == 0 { + return "", fmt.Errorf("stt: mimo returned empty choices") + } + + text := strings.TrimSpace(mResp.Choices[0].Message.Content) + return text, nil +} + +// mapLanguage 将标准语言代码映射为 MiMo 支持的值(auto/zh/en)。 +func mapLanguage(lang string) string { + switch { + case lang == "": + return "auto" + case strings.HasPrefix(lang, "zh"): + return "zh" + case strings.HasPrefix(lang, "en"): + return "en" + default: + return "auto" + } +} + +// isWAV 检查数据是否为 WAV 格式(RIFF 头)。 +func isWAV(data []byte) bool { + return len(data) > 4 && string(data[:4]) == "RIFF" +} + +// isMP3 检查数据是否为 MP3 格式(ID3 标签或帧同步字)。 +func isMP3(data []byte) bool { + if len(data) > 3 && string(data[:3]) == "ID3" { + return true + } + // 帧同步字:0xFF 0xFB/0xF3/0xF2 + return len(data) > 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 +} + +// pcmToWAV 将原始 PCM 数据封装为 WAV 文件。 +func pcmToWAV(pcm []byte, sampleRate, channels int) ([]byte, error) { + if sampleRate == 0 { + sampleRate = 16000 + } + if channels == 0 { + channels = 1 + } + + bitsPerSample := 16 + byteRate := sampleRate * channels * bitsPerSample / 8 + blockAlign := channels * bitsPerSample / 8 + dataSize := len(pcm) + + var buf bytes.Buffer + + // RIFF header + buf.WriteString("RIFF") + binary.Write(&buf, binary.LittleEndian, uint32(36+dataSize)) + buf.WriteString("WAVE") + + // fmt 子块 + buf.WriteString("fmt ") + binary.Write(&buf, binary.LittleEndian, uint32(16)) // 子块大小 + binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM 格式 + binary.Write(&buf, binary.LittleEndian, uint16(channels)) // 通道数 + binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)) // 采样率 + binary.Write(&buf, binary.LittleEndian, uint32(byteRate)) // 字节率 + binary.Write(&buf, binary.LittleEndian, uint16(blockAlign)) // 块对齐 + binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)) // 每样本位数 + + // data 子块 + buf.WriteString("data") + binary.Write(&buf, binary.LittleEndian, uint32(dataSize)) + buf.Write(pcm) + + return buf.Bytes(), nil +} diff --git a/backend/internal/ai/stt/mimo_test.go b/backend/internal/ai/stt/mimo_test.go new file mode 100644 index 0000000..80c4cf1 --- /dev/null +++ b/backend/internal/ai/stt/mimo_test.go @@ -0,0 +1,255 @@ +package stt + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" +) + +func newTestMiMoService(handler http.HandlerFunc) (*MiMoService, *httptest.Server) { + srv := httptest.NewServer(handler) + s := NewMiMoService("test-key", "mimo-v2.5-asr", srv.URL, zap.NewNop().Sugar()) + return s, srv +} + +func TestMiMoService_Recognize_Success(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + // 验证请求 + if r.Header.Get("api-key") != "test-key" { + t.Errorf("expected api-key test-key, got %s", r.Header.Get("api-key")) + } + if r.URL.Path != "/chat/completions" { + t.Errorf("expected path /chat/completions, got %s", r.URL.Path) + } + + var req mimoRequest + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if req.Model != "mimo-v2.5-asr" { + t.Errorf("expected model mimo-v2.5-asr, got %s", req.Model) + } + if len(req.Messages) == 0 || req.Messages[0].Role != "user" { + t.Error("expected user message") + } + if req.ASROptions == nil || req.ASROptions.Language != "zh" { + t.Errorf("expected language zh, got %v", req.ASROptions) + } + + resp := mimoResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{ + {Message: struct { + Content string `json:"content"` + }{Content: "你好世界"}}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + + // 发送一个简单的有效 WAV(44 字节头 + 少量 PCM) + wav := makeValidWAV([]byte{0x00, 0x00, 0x00, 0x00}) + text, err := s.Recognize(context.Background(), wav, Options{Language: "zh-CN"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if text != "你好世界" { + t.Errorf("expected '你好世界', got '%s'", text) + } +} + +func TestMiMoService_Recognize_EmptyAudio(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) {}) + defer srv.Close() + + _, err := s.Recognize(context.Background(), nil, Options{}) + if err == nil { + t.Fatal("expected error for empty audio") + } +} + +func TestMiMoService_Recognize_ServerError(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + }) + defer srv.Close() + + wav := makeValidWAV([]byte{0x00, 0x00}) + _, err := s.Recognize(context.Background(), wav, Options{}) + if err == nil { + t.Fatal("expected error for 500 response") + } +} + +func TestMiMoService_Recognize_EmptyChoices(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + resp := mimoResponse{Choices: nil} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + + wav := makeValidWAV([]byte{0x00, 0x00}) + _, err := s.Recognize(context.Background(), wav, Options{}) + if err == nil { + t.Fatal("expected error for empty choices") + } +} + +func TestMiMoService_Recognize_PCMAutoWrap(t *testing.T) { + // 测试原始 PCM 数据自动封装为 WAV + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + var req mimoRequest + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + + // 验证 data URL 格式 + if len(req.Messages) == 0 || len(req.Messages[0].Content) == 0 { + t.Fatal("empty message content") + } + dataURL := req.Messages[0].Content[0].InputAudio.Data + if len(dataURL) < 22 || dataURL[:14] != "data:audio/wav" { + t.Errorf("expected wav data URL, got prefix: %s", dataURL[:min(len(dataURL), 30)]) + } + + // 验证 base64 可解码 + b64Part := dataURL[22:] // skip "data:audio/wav;base64," + decoded, err := base64.StdEncoding.DecodeString(b64Part) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + // 应该是有效 WAV(RIFF 头) + if len(decoded) < 44 || string(decoded[:4]) != "RIFF" { + t.Error("decoded data is not a valid WAV") + } + + resp := mimoResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{ + {Message: struct { + Content string `json:"content"` + }{Content: "test"}}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + + // 发送原始 PCM(非 WAV/MP3) + pcm := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07} + text, err := s.Recognize(context.Background(), pcm, Options{SampleRate: 16000}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if text != "test" { + t.Errorf("expected 'test', got '%s'", text) + } +} + +func TestMapLanguage(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "auto"}, + {"zh-CN", "zh"}, + {"zh", "zh"}, + {"en-US", "en"}, + {"en", "en"}, + {"ja", "auto"}, + } + for _, tt := range tests { + got := mapLanguage(tt.input) + if got != tt.want { + t.Errorf("mapLanguage(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestIsWAV(t *testing.T) { + if !isWAV([]byte("RIFF....")) { + t.Error("expected true for RIFF header") + } + if isWAV([]byte("ID3...")) { + t.Error("expected false for ID3 header") + } + if isWAV([]byte{0x00}) { + t.Error("expected false for short data") + } +} + +func TestIsMP3(t *testing.T) { + if !isMP3([]byte("ID3\x03")) { + t.Error("expected true for ID3 header") + } + if !isMP3([]byte{0xFF, 0xFB, 0x00}) { + t.Error("expected true for MP3 sync word") + } + if isMP3([]byte("RIFF")) { + t.Error("expected false for RIFF header") + } +} + +func makeValidWAV(pcm []byte) []byte { + // 构造一个最小有效 WAV + wav := make([]byte, 44+len(pcm)) + copy(wav[:4], "RIFF") + // little-endian size = 36 + len(pcm) + size := uint32(36 + len(pcm)) + wav[4] = byte(size) + wav[5] = byte(size >> 8) + wav[6] = byte(size >> 16) + wav[7] = byte(size >> 24) + copy(wav[8:12], "WAVE") + copy(wav[12:16], "fmt ") + // fmt chunk size = 16 + wav[16] = 16 + // PCM format = 1 + wav[20] = 1 + // channels = 1 + wav[22] = 1 + // sample rate = 16000 + wav[24] = 0x80 + wav[25] = 0x3E + // byte rate = 32000 + wav[28] = 0x00 + wav[29] = 0x7D + // block align = 2 + wav[32] = 2 + // bits per sample = 16 + wav[34] = 16 + copy(wav[36:40], "data") + dSize := uint32(len(pcm)) + wav[40] = byte(dSize) + wav[41] = byte(dSize >> 8) + wav[42] = byte(dSize >> 16) + wav[43] = byte(dSize >> 24) + copy(wav[44:], pcm) + return wav +} + +func min(a, b int) int { + if a < b { + return a + } + return b +}