package llm import ( "context" "fmt" "io" "net/http" "net/url" "strings" "time" "git.yeij.top/AskaEth/Cyrene/pkg/audio" "git.yeij.top/AskaEth/Cyrene/pkg/dashscope" ) // ASRProvider handles speech-to-text transcription. type ASRProvider interface { Transcribe(ctx context.Context, audioURL, language string) (string, error) IsAvailable() bool ModelName() string } // DashScopeASRProvider uses DashScope Paraformer API for offline speech recognition. type DashScopeASRProvider struct { model string client *dashscope.RESTClient http *http.Client } // NewDashScopeASRProvider creates a DashScope ASR provider. func NewDashScopeASRProvider(baseURL, apiKey, model string) *DashScopeASRProvider { if model == "" { model = "qwen3-asr-flash-2026-02-10" } return &DashScopeASRProvider{ model: model, client: dashscope.NewRESTClient(apiKey), http: &http.Client{Timeout: 60 * time.Second}, } } // IsAvailable returns true if the API key is configured. func (p *DashScopeASRProvider) IsAvailable() bool { return p.client.IsAvailable() } // ModelName returns the ASR model name. func (p *DashScopeASRProvider) ModelName() string { return p.model } // downloadAudio fetches audio data from a URL and returns the bytes with inferred format. func (p *DashScopeASRProvider) downloadAudio(ctx context.Context, audioURL string) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, "GET", audioURL, nil) if err != nil { return nil, "", fmt.Errorf("create download request: %w", err) } resp, err := p.http.Do(req) if err != nil { return nil, "", fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB limit if err != nil { return nil, "", fmt.Errorf("read audio data: %w", err) } format := inferAudioFormat(audioURL, resp.Header.Get("Content-Type")) return data, format, nil } // inferAudioFormat determines the audio format from URL extension or Content-Type header. func inferAudioFormat(urlStr, contentType string) string { u, err := url.Parse(urlStr) if err == nil { path := u.Path if idx := strings.LastIndex(path, "."); idx >= 0 { ext := strings.ToLower(path[idx+1:]) switch ext { case "amr", "wav", "mp3", "ogg", "flac", "m4a", "aac", "opus", "webm", "pcm": return ext } } } if strings.Contains(contentType, "audio/amr") || strings.Contains(contentType, "amr") { return "amr" } if strings.Contains(contentType, "audio/wav") || strings.Contains(contentType, "wav") { return "wav" } if strings.Contains(contentType, "audio/mpeg") || strings.Contains(contentType, "mp3") { return "mp3" } if strings.Contains(contentType, "audio/ogg") || strings.Contains(contentType, "opus") { return "ogg" } return "amr" // default for OBv11 voice messages } func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL, language string) (string, error) { if !p.IsAvailable() { return "", fmt.Errorf("DashScope ASR API key not configured") } audioData, format, err := p.downloadAudio(ctx, audioURL) if err != nil { return "", fmt.Errorf("download audio: %w", err) } // 转码为 16kHz mono PCM,提升识别兼容性 pcmData, err := audio.ConvertToPCM16(audioData, format) if err != nil { return "", fmt.Errorf("audio transcode: %w", err) } if language == "" || language == "auto" { language = "zh" } return p.client.Transcribe(ctx, p.model, pcmData, "pcm", 16000, language) }