Refactor TTS configuration to use pointer type and add validation for TTS mode. Update help messages to include TTS command documentation. Fix ffmpeg command construction for audio conversion. generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
340 lines
8.5 KiB
Go
340 lines
8.5 KiB
Go
package core
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SpeechToText transcribes audio to text.
|
|
type SpeechToText interface {
|
|
Transcribe(ctx context.Context, audio []byte, format string, lang string) (string, error)
|
|
}
|
|
|
|
// SpeechConfig holds STT configuration for the engine.
|
|
type SpeechCfg struct {
|
|
Enabled bool
|
|
Provider string
|
|
Language string
|
|
STT SpeechToText
|
|
}
|
|
|
|
// OpenAIWhisper implements SpeechToText using the OpenAI-compatible Whisper API.
|
|
// Works with OpenAI, Groq, and any endpoint that implements the same multipart API.
|
|
type OpenAIWhisper struct {
|
|
APIKey string
|
|
BaseURL string
|
|
Model string
|
|
Client *http.Client
|
|
}
|
|
|
|
func NewOpenAIWhisper(apiKey, baseURL, model string) *OpenAIWhisper {
|
|
if baseURL == "" {
|
|
baseURL = "https://api.openai.com/v1"
|
|
}
|
|
if model == "" {
|
|
model = "whisper-1"
|
|
}
|
|
return &OpenAIWhisper{
|
|
APIKey: apiKey,
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
Model: model,
|
|
Client: &http.Client{Timeout: 60 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (w *OpenAIWhisper) Transcribe(ctx context.Context, audio []byte, format string, lang string) (string, error) {
|
|
ext := formatToExt(format)
|
|
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
|
|
part, err := writer.CreateFormFile("file", "audio."+ext)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(audio); err != nil {
|
|
return "", fmt.Errorf("write audio: %w", err)
|
|
}
|
|
_ = writer.WriteField("model", w.Model)
|
|
_ = writer.WriteField("response_format", "text")
|
|
if lang != "" {
|
|
_ = writer.WriteField("language", lang)
|
|
}
|
|
writer.Close()
|
|
|
|
url := w.BaseURL + "/audio/transcriptions"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+w.APIKey)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
resp, err := w.Client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("whisper request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("whisper API %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// response_format=text returns plain text; try to handle JSON fallback
|
|
text := strings.TrimSpace(string(body))
|
|
if strings.HasPrefix(text, "{") {
|
|
var jr struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if json.Unmarshal(body, &jr) == nil {
|
|
text = jr.Text
|
|
}
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
// QwenASR implements SpeechToText using the Qwen ASR model via DashScope's
|
|
// OpenAI-compatible chat completions API. Unlike Whisper, audio is sent as a
|
|
// base64 data URI inside the messages array.
|
|
type QwenASR struct {
|
|
APIKey string
|
|
BaseURL string
|
|
Model string
|
|
Client *http.Client
|
|
}
|
|
|
|
func NewQwenASR(apiKey, baseURL, model string) *QwenASR {
|
|
if baseURL == "" {
|
|
baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
}
|
|
if model == "" {
|
|
model = "qwen3-asr-flash"
|
|
}
|
|
return &QwenASR{
|
|
APIKey: apiKey,
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
Model: model,
|
|
Client: &http.Client{Timeout: 60 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (q *QwenASR) Transcribe(ctx context.Context, audio []byte, format string, lang string) (string, error) {
|
|
b64 := base64.StdEncoding.EncodeToString(audio)
|
|
dataURI := fmt.Sprintf("data:%s;base64,%s", formatToAudioMIME(format), b64)
|
|
|
|
reqBody := map[string]any{
|
|
"model": q.Model,
|
|
"messages": []map[string]any{
|
|
{
|
|
"role": "user",
|
|
"content": []map[string]any{
|
|
{
|
|
"type": "input_audio",
|
|
"input_audio": map[string]any{
|
|
"data": dataURI,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
jsonData, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
url := q.BaseURL + "/chat/completions"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+q.APIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := q.Client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("qwen asr request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("qwen asr API %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return "", fmt.Errorf("parse response: %w", err)
|
|
}
|
|
if len(result.Choices) == 0 {
|
|
return "", fmt.Errorf("qwen asr: empty choices in response")
|
|
}
|
|
|
|
return strings.TrimSpace(result.Choices[0].Message.Content), nil
|
|
}
|
|
|
|
// ConvertAudioToMP3 uses ffmpeg to convert audio from unsupported formats to mp3.
|
|
// Returns the mp3 bytes. If ffmpeg is not installed, returns an error.
|
|
func ConvertAudioToMP3(audio []byte, srcFormat string) ([]byte, error) {
|
|
ffmpegPath, err := exec.LookPath("ffmpeg")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ffmpeg not found in PATH: install ffmpeg to enable voice message support")
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
if srcFormat == "amr" || srcFormat == "silk" {
|
|
cmd = exec.Command(ffmpegPath,
|
|
"-f", srcFormat,
|
|
"-i", "pipe:0",
|
|
"-f", "mp3",
|
|
"-ac", "1",
|
|
"-ar", "16000",
|
|
"-y",
|
|
"pipe:1",
|
|
)
|
|
} else {
|
|
cmd = exec.Command(ffmpegPath,
|
|
"-i", "pipe:0",
|
|
"-f", "mp3",
|
|
"-ac", "1",
|
|
"-ar", "16000",
|
|
"-y",
|
|
"pipe:1",
|
|
)
|
|
}
|
|
|
|
cmd.Stdin = bytes.NewReader(audio)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg conversion failed: %w (stderr: %s)", err, stderr.String())
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
// ConvertAudioToOpus uses ffmpeg to convert audio to opus format (ogg container).
|
|
// Returns the opus bytes. If ffmpeg is not installed, returns an error.
|
|
func ConvertAudioToOpus(ctx context.Context, audio []byte, srcFormat string) ([]byte, error) {
|
|
ffmpegPath, err := exec.LookPath("ffmpeg")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ffmpeg not found in PATH: install ffmpeg to enable audio conversion")
|
|
}
|
|
|
|
args := []string{"-i", "pipe:0", "-c:a", "libopus", "-f", "opus", "-y", "pipe:1"}
|
|
if srcFormat == "amr" || srcFormat == "silk" {
|
|
args = append([]string{"-f", srcFormat}, args...)
|
|
}
|
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
|
cmd.Stdin = bytes.NewReader(audio)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg opus conversion failed: %w (stderr: %s)", err, stderr.String())
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
// NeedsConversion returns true if the audio format is not directly supported by Whisper API.
|
|
func NeedsConversion(format string) bool {
|
|
switch strings.ToLower(format) {
|
|
case "mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// HasFFmpeg checks if ffmpeg is available.
|
|
func HasFFmpeg() bool {
|
|
_, err := exec.LookPath("ffmpeg")
|
|
return err == nil
|
|
}
|
|
|
|
func formatToExt(format string) string {
|
|
switch strings.ToLower(format) {
|
|
case "amr":
|
|
return "amr"
|
|
case "ogg", "oga", "opus":
|
|
return "ogg"
|
|
case "m4a", "mp4", "aac":
|
|
return "m4a"
|
|
case "mp3":
|
|
return "mp3"
|
|
case "wav":
|
|
return "wav"
|
|
case "webm":
|
|
return "webm"
|
|
case "silk":
|
|
return "silk"
|
|
default:
|
|
return format
|
|
}
|
|
}
|
|
|
|
func formatToAudioMIME(format string) string {
|
|
switch strings.ToLower(format) {
|
|
case "mp3", "mpeg", "mpga":
|
|
return "audio/mpeg"
|
|
case "wav":
|
|
return "audio/wav"
|
|
case "ogg", "oga", "opus":
|
|
return "audio/ogg"
|
|
case "m4a", "mp4", "aac":
|
|
return "audio/mp4"
|
|
case "webm":
|
|
return "audio/webm"
|
|
default:
|
|
return "audio/octet-stream"
|
|
}
|
|
}
|
|
|
|
// TranscribeAudio is a convenience function used by the Engine.
|
|
// It handles format conversion (if needed) and calls the STT provider.
|
|
func TranscribeAudio(ctx context.Context, stt SpeechToText, audio *AudioAttachment, lang string) (string, error) {
|
|
data := audio.Data
|
|
format := strings.ToLower(audio.Format)
|
|
|
|
if NeedsConversion(format) {
|
|
slog.Debug("speech: converting audio", "from", format, "to", "mp3")
|
|
converted, err := ConvertAudioToMP3(data, format)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
data = converted
|
|
format = "mp3"
|
|
}
|
|
|
|
slog.Debug("speech: transcribing", "format", format, "size", len(data))
|
|
return stt.Transcribe(ctx, data, format, lang)
|
|
}
|