Files
CamTalk/backend/internal/ai/stt/mimo_test.go
hhs 3d3de828fc feat: 添加 Xiaomi MiMo ASR 语音识别提供者
- 新增 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
- 添加完整单元测试
2026-06-13 21:31:58 +08:00

256 lines
6.4 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.
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()
// 发送一个简单的有效 WAV44 字节头 + 少量 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)
}
// 应该是有效 WAVRIFF 头)
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
}