137 lines
5.1 KiB
Go
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
|
|
}
|