Files
go-sip/internal/ai/bailian_tts.go
T

137 lines
5.1 KiB
Go

package ai
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os/exec"
"strings"
)
const (
maxBailianReplyBytes = 64 << 10
maxBailianAudioBytes = 8 << 20
maxBailianPCMBytes = 2 << 20 // ponytail: enough for about one minute; raise only with an approved longer sentence.
)
func synthesizeBailianTTS(ctx context.Context, approved TTSConfig, text string) ([]byte, error) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil, errors.New("Bailian TTS requires the configured ffmpeg audio converter")
}
if err := bailianURL(approved.Provider.Endpoint, false); err != nil {
return nil, fmt.Errorf("approved Bailian endpoint: %w", err)
}
if approved.Model != "qwen3-tts-flash" || approved.Voice != "Cherry" || approved.LanguageType != "Chinese" || approved.Speed != 1 || approved.SampleRate != 16000 || approved.Provider.Credential == "" || text == "" {
return nil, errors.New("approved Bailian TTS settings or text are incomplete")
}
// Never follow a provider or audio redirect into an unapproved second request.
client := http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
request := struct {
Model string `json:"model"`
Input struct {
Text string `json:"text"`
Voice string `json:"voice"`
LanguageType string `json:"language_type"`
} `json:"input"`
}{Model: approved.Model}
request.Input.Text, request.Input.Voice, request.Input.LanguageType = text, approved.Voice, approved.LanguageType
body, err := json.Marshal(request)
if err != nil {
return nil, errors.New("encode approved Bailian request")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, approved.Provider.Endpoint, bytes.NewReader(body))
if err != nil {
return nil, errors.New("create approved Bailian request")
}
req.Header.Set("Authorization", "Bearer "+approved.Provider.Credential)
req.Header.Set("Content-Type", "application/json")
response, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("Bailian TTS request failed: type=%T", err)
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
return nil, fmt.Errorf("Bailian TTS rejected request: HTTP %d", response.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(response.Body, maxBailianReplyBytes+1))
if err != nil || len(raw) > maxBailianReplyBytes {
return nil, errors.New("Bailian TTS response is unreadable or oversized")
}
var result struct {
Output struct {
Audio struct {
URL string `json:"url"`
} `json:"audio"`
} `json:"output"`
}
if err := json.Unmarshal(raw, &result); err != nil || result.Output.Audio.URL == "" {
return nil, errors.New("Bailian TTS response has no complete audio reference")
}
if err := bailianURL(result.Output.Audio.URL, true); err != nil {
return nil, errors.New("Bailian TTS audio reference is invalid")
}
audioRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, result.Output.Audio.URL, nil)
if err != nil {
return nil, errors.New("create Bailian audio request")
}
audioResponse, err := client.Do(audioRequest)
if err != nil {
return nil, fmt.Errorf("Bailian audio download failed: type=%T", err)
}
defer audioResponse.Body.Close()
if audioResponse.StatusCode/100 != 2 {
return nil, fmt.Errorf("Bailian audio download rejected: HTTP %d", audioResponse.StatusCode)
}
encoded, err := io.ReadAll(io.LimitReader(audioResponse.Body, maxBailianAudioBytes+1))
if err != nil || len(encoded) == 0 || len(encoded) > maxBailianAudioBytes {
return nil, errors.New("Bailian audio is empty, unreadable or oversized")
}
cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-i", "pipe:0", "-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1")
cmd.Stdin = bytes.NewReader(encoded)
cmd.Stderr = io.Discard
var pcm boundedPCM
cmd.Stdout = &pcm
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("Bailian audio conversion failed: type=%T", err)
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("Bailian TTS deadline or cancellation: %w", err)
}
if pcm.Len() == 0 || pcm.Len()%2 != 0 {
return nil, errors.New("Bailian TTS returned incomplete PCM16 audio")
}
return pcm.Bytes(), nil
}
type boundedPCM struct{ bytes.Buffer }
func (w *boundedPCM) Write(data []byte) (int, error) {
if len(data) > maxBailianPCMBytes-w.Len() {
return 0, errors.New("Bailian TTS PCM exceeds approved media ceiling")
}
return w.Buffer.Write(data)
}
func bailianURL(raw string, signedAudio bool) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
return errors.New("HTTPS URL without embedded identity required")
}
if parsed.Scheme != "https" {
host := parsed.Hostname()
if parsed.Scheme != "http" || (host != "localhost" && !net.ParseIP(host).IsLoopback()) {
return errors.New("HTTPS URL required outside isolated local Mock")
}
}
if !signedAudio && (parsed.RawQuery != "" || !strings.HasSuffix(parsed.Path, "/api/v1/services/aigc/multimodal-generation/generation")) {
return errors.New("approved Bailian generation endpoint required")
}
return nil
}