Files
CamTalk/backend/internal/ai/stt/mimo.go
2026-06-14 19:54:22 +08:00

235 lines
5.9 KiB
Go
Raw Permalink 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 (
"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
timeout time.Duration
logger *zap.SugaredLogger
}
// NewMiMoService 创建 MiMo STT 服务。
// model、endpoint 由 config 层保证非空timeoutSec 为 0 时默认 10 秒。
func NewMiMoService(apiKey, model, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *MiMoService {
timeout := time.Duration(timeoutSec) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
return &MiMoService{
apiKey: apiKey,
model: model,
endpoint: endpoint,
timeout: timeout,
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, m.timeout)
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("Authorization", "Bearer "+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 {
// MiMo 返回空结果,视为无法识别(非错误),返回空文本
return "", nil
}
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
}