Files
Cyrene_v2/internal/runtime/multimodal/voice/cloud.go
T
Aska 73dabb9dde
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(voice): 阿里百炼 STT/TTS 主链路接通(base64 STT + cosyvoice TTS按voice_id + REST /audio/* + voice_input.deviceId)
- voice/dashscope.go 新建:DashscopeSTT(qwen-audio-3.0-asr-flash, base64 data URI→text) + DashscopeTTS(cosyvoice 按 voice_id, 取非流式 24h URL 下载字节)
- cloud.go newDashboardSTT/TTS 由空 endpoint stub 改为真实百炼 adapter
- chain.go gating 从 DashscopeAPIKey!="" 改为 voiceConfigured(cfg); silent 兜底保留
- config.VoiceConfig 扩展 DASHSCOPE_BASE_URL/VOICE_STT_*/VOICE_TTS_* 字段(必填 DASHSCOPE_API_KEY)
- gateway: POST /api/v1/audio/{transcribe,speech} (owner+审计+MaxBytesReader); voiceInputRequest 增 deviceId 并透传 runner.Run
- 实测: cosyvoice-v3.5-plus+longxiaochun 回 418(引擎拒绝), 改用 cosyvoice-v3-flash+longxiaochun_v3; TTS 你好我是昔涟->106124B wav -> STT 回 你好我是西联, 链返回真实字节/文本
- 单测: dashscope_test/config voice defaults/gateway audio_test; go build/vet/test 全绿
- 声音复刻(响昔涟音色)待公网参考音频; 不做 create_voice
2026-09-08 06:06:02 +08:00

227 lines
6.8 KiB
Go

package voice
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// cloudMaxResponseBytes caps how much of a cloud voice response is read, so an
// oversized or malformed response cannot exhaust memory. The request timeout
// bounds a single call.
const (
cloudMaxResponseBytes = 8 << 20
cloudHTTPTimeout = 180 * time.Second
)
// HTTPSTT transcribes audio by POSTing a JSON body to a cloud endpoint and
// reading {"text": "..."} back. It is the stubbed default STT implementation;
// the real DashScope/whisper-facing adapter is an adaptation point.
type HTTPSTT struct {
endpoint string
apiKey string
client *http.Client
log *slog.Logger
}
// HTTPTTS synthesizes speech by POSTing a JSON body and reading the returned
// base64 audio. It is the stubbed default TTS implementation.
type HTTPTTS struct {
endpoint string
apiKey string
client *http.Client
log *slog.Logger
}
// HTTPOption customizes the client and logger of an HTTPSTT/HTTPTTS.
type HTTPOption func(*httpConfig)
type httpConfig struct {
client *http.Client
log *slog.Logger
}
// WithHTTPClient sets the *http.Client used for the call; nil keeps the default.
func WithHTTPClient(c *http.Client) HTTPOption {
return func(o *httpConfig) {
if c != nil {
o.client = c
}
}
}
// WithLogger sets the logger used on failures; nil keeps a discard logger.
func WithLogger(l *slog.Logger) HTTPOption {
return func(o *httpConfig) {
if l != nil {
o.log = l
}
}
}
// NewHTTPSTT builds an HTTPSTT for the given endpoint and API key.
func NewHTTPSTT(endpoint, apiKey string, opts ...HTTPOption) *HTTPSTT {
h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()}
for _, o := range opts {
o(h)
}
return &HTTPSTT{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log}
}
// NewHTTPTTS builds an HTTPTTS for the given endpoint and API key.
func NewHTTPTTS(endpoint, apiKey string, opts ...HTTPOption) *HTTPTTS {
h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()}
for _, o := range opts {
o(h)
}
return &HTTPTTS{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log}
}
// sttRequest is the JSON body sent to the STT endpoint.
type sttRequest struct {
Audio string `json:"audio"`
Format string `json:"format"`
}
// sttResponse is the transcription returned by the STT endpoint.
type sttResponse struct {
Text string `json:"text"`
}
// STT posts the audio and returns the recognized text. A blank endpoint returns
// ErrNotConfigured so the chain degrades.
func (s *HTTPSTT) STT(ctx context.Context, audio Audio) (string, error) {
if s.endpoint == "" {
return "", ErrNotConfigured
}
if err := ctx.Err(); err != nil {
return "", err
}
payload, err := json.Marshal(sttRequest{Audio: base64.StdEncoding.EncodeToString(audio.Data), Format: audio.Format})
if err != nil {
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
}
resp, err := postJSON(ctx, s.client, s.apiKey, s.endpoint, payload)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
var decoded sttResponse
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
}
return decoded.Text, nil
}
// ttsRequest is the JSON body sent to the TTS endpoint.
type ttsRequest struct {
Text string `json:"text"`
}
// ttsResponse is the synthesized audio returned by the TTS endpoint.
type ttsResponse struct {
Audio string `json:"audio"`
Format string `json:"format"`
}
// TTS posts the text and returns the decoded audio. A blank endpoint returns
// ErrNotConfigured so the chain degrades.
func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) {
if s.endpoint == "" {
return Audio{}, ErrNotConfigured
}
if strings.TrimSpace(text) == "" {
return Audio{}, ErrEmptyText
}
if err := ctx.Err(); err != nil {
return Audio{}, err
}
payload, err := json.Marshal(ttsRequest{Text: text})
if err != nil {
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
}
resp, err := postJSON(ctx, s.client, s.apiKey, s.endpoint, payload)
if err != nil {
return Audio{}, err
}
defer func() { _ = resp.Body.Close() }()
var decoded ttsResponse
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return Audio{}, fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
}
data, err := base64.StdEncoding.DecodeString(decoded.Audio)
if err != nil {
return Audio{}, fmt.Errorf("voice: %w: decode audio: %v", ErrProviderFailed, err)
}
format := decoded.Format
if format == "" {
format = "mp3"
}
return Audio{Format: format, Data: data}, nil
}
// postJSON POSTs a JSON payload, wires auth, and checks the status code.
func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payload []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
}
req.Header.Set("Content-Type", "application/json")
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, fmt.Errorf("voice: %w: status %d", ErrProviderFailed, resp.StatusCode)
}
return resp, nil
}
// defaultHTTPClient builds a client with a request timeout. A fresh client is
// returned per call rather than a package global, which the constraints forbid.
func defaultHTTPClient() *http.Client {
return &http.Client{Transport: http.DefaultTransport, Timeout: cloudHTTPTimeout}
}
// discardLogger returns a logger that drops all output.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newDashboardSTT returns the cloud STT adapter for a DashScope config. A call
// returns ErrNotConfigured when no API key is set so the chain degrades.
func newDashboardSTT(cfg config.VoiceConfig) STT {
return NewDashscopeSTT(cfg)
}
// newDashboardTTS returns the cloud TTS adapter for a DashScope config.
func newDashboardTTS(cfg config.VoiceConfig) TTS {
return NewDashscopeTTS(cfg)
}
// newEdgeTTS returns the edge-tts adapter point (not yet implemented).
func newEdgeTTS() TTS { return notImplementedTTS{} }
// newEspeak returns the espeak adapter point (not yet implemented).
func newEspeak() TTS { return notImplementedTTS{} }
// notImplementedTTS is an adaptation point that always degrades.
type notImplementedTTS struct{}
func (notImplementedTTS) TTS(_ context.Context, _ string) (Audio, error) {
return Audio{}, ErrNotImplemented
}