151 lines
4.0 KiB
Go
151 lines
4.0 KiB
Go
package asr
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"path/filepath"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|||
|
|
openai "github.com/sashabaranov/go-openai"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
asrDefaultTimeout = 300 * time.Second // audio transcription can be slow
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// OpenAIASR implements ASR via an OpenAI-compatible audio transcriptions API.
|
|||
|
|
type OpenAIASR struct {
|
|||
|
|
modelName string
|
|||
|
|
modelID string
|
|||
|
|
client *openai.Client
|
|||
|
|
baseURL string
|
|||
|
|
language string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewOpenAIASR creates an OpenAI-compatible ASR instance.
|
|||
|
|
func NewOpenAIASR(config *Config) (*OpenAIASR, error) {
|
|||
|
|
if err := validateASRBaseURL(config.BaseURL); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
apiCfg := openai.DefaultConfig(config.APIKey)
|
|||
|
|
if config.BaseURL != "" {
|
|||
|
|
apiCfg.BaseURL = config.BaseURL
|
|||
|
|
}
|
|||
|
|
httpClient := newASRHTTPClient(asrDefaultTimeout)
|
|||
|
|
|
|||
|
|
// 注入用户自定义 HTTP header(类似 OpenAI Python SDK 的 extra_headers)
|
|||
|
|
if len(config.CustomHeaders) > 0 {
|
|||
|
|
apiCfg.HTTPClient = secutils.WrapHTTPClientWithHeaders(httpClient, config.CustomHeaders)
|
|||
|
|
} else {
|
|||
|
|
apiCfg.HTTPClient = httpClient
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &OpenAIASR{
|
|||
|
|
modelName: config.ModelName,
|
|||
|
|
modelID: config.ModelID,
|
|||
|
|
client: openai.NewClientWithConfig(apiCfg),
|
|||
|
|
baseURL: config.BaseURL,
|
|||
|
|
language: config.Language,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Transcribe sends audio bytes to the OpenAI-compatible audio transcriptions API.
|
|||
|
|
func (s *OpenAIASR) Transcribe(ctx context.Context, audioBytes []byte, fileName string) (*TranscriptionResult, error) {
|
|||
|
|
if len(audioBytes) == 0 {
|
|||
|
|
return nil, fmt.Errorf("audio bytes are empty")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Ensure fileName has an extension for proper MIME type detection
|
|||
|
|
if fileName == "" {
|
|||
|
|
fileName = "audio.mp3"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "[ASR] Calling OpenAI-compatible transcription API, model=%s, baseURL=%s, audioSize=%d, file=%s",
|
|||
|
|
s.modelName, s.baseURL, len(audioBytes), fileName)
|
|||
|
|
|
|||
|
|
req := openai.AudioRequest{
|
|||
|
|
Model: s.modelName,
|
|||
|
|
FilePath: fileName,
|
|||
|
|
Reader: bytes.NewReader(audioBytes),
|
|||
|
|
Format: openai.AudioResponseFormatVerboseJSON,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if s.language != "" {
|
|||
|
|
req.Language = s.language
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp, err := s.client.CreateTranscription(ctx, req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("ASR transcription request failed: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
jsonData, err := json.Marshal(resp)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("marshal transcription response: %w", err)
|
|||
|
|
}
|
|||
|
|
logger.Debugf(ctx, "[ASR] Transcription response: %s", string(jsonData))
|
|||
|
|
|
|||
|
|
text := strings.TrimSpace(resp.Text)
|
|||
|
|
logger.Infof(ctx, "[ASR] Transcription completed, text length=%d", len(text))
|
|||
|
|
|
|||
|
|
var segments []Segment
|
|||
|
|
for _, s := range resp.Segments {
|
|||
|
|
segments = append(segments, Segment{
|
|||
|
|
Start: s.Start,
|
|||
|
|
End: s.End,
|
|||
|
|
Text: strings.TrimSpace(s.Text),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &TranscriptionResult{
|
|||
|
|
Text: text,
|
|||
|
|
Segments: segments,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIASR) GetModelName() string { return s.modelName }
|
|||
|
|
func (s *OpenAIASR) GetModelID() string { return s.modelID }
|
|||
|
|
|
|||
|
|
// DetectAudioFormat returns a file extension hint for the given audio bytes.
|
|||
|
|
func DetectAudioFormat(data []byte, fileName string) string {
|
|||
|
|
if fileName != "" {
|
|||
|
|
ext := strings.ToLower(filepath.Ext(fileName))
|
|||
|
|
if ext != "" {
|
|||
|
|
return ext
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Try to detect from magic bytes
|
|||
|
|
if len(data) >= 4 {
|
|||
|
|
// MP3: starts with ID3 or 0xFF 0xFB
|
|||
|
|
if data[0] == 'I' && data[1] == 'D' && data[2] == '3' {
|
|||
|
|
return ".mp3"
|
|||
|
|
}
|
|||
|
|
if data[0] == 0xFF && (data[1]&0xE0) == 0xE0 {
|
|||
|
|
return ".mp3"
|
|||
|
|
}
|
|||
|
|
// FLAC: starts with "fLaC"
|
|||
|
|
if data[0] == 'f' && data[1] == 'L' && data[2] == 'a' && data[3] == 'C' {
|
|||
|
|
return ".flac"
|
|||
|
|
}
|
|||
|
|
// OGG: starts with "OggS"
|
|||
|
|
if data[0] == 'O' && data[1] == 'g' && data[2] == 'g' && data[3] == 'S' {
|
|||
|
|
return ".ogg"
|
|||
|
|
}
|
|||
|
|
// WAV: starts with "RIFF"
|
|||
|
|
if data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' {
|
|||
|
|
return ".wav"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// M4A: check for ftyp box
|
|||
|
|
if len(data) >= 8 && data[4] == 'f' && data[5] == 't' && data[6] == 'y' && data[7] == 'p' {
|
|||
|
|
return ".m4a"
|
|||
|
|
}
|
|||
|
|
return ".mp3" // default
|
|||
|
|
}
|