Files
Cyrene_v2/internal/runtime/multimodal/voice/dashscope.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

398 lines
12 KiB
Go

package voice
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// DashScope (Alibaba Bailian) STT/TTS adapters for the cloud rung of the voice
// chain. Requests follow the DashScope public API:
//
// - STT: POST {base}/services/aigc/multimodal-generation/generation
// (qwen-audio-3.0-asr-flash, base64 input audio)
// - TTS: POST {base}/services/audio/tts/SpeechSynthesizer
// (cosyvoice-v3-flash, synthesized by voice_id)
//
// Both take a non-streaming response here: STT returns the transcript in the
// output envelope; TTS (no X-DashScope-SSE header) returns a 24h audio URL that
// we download so the caller always gets the actual bytes. Audio format ↔ mime
// is mapped in both directions (mimeOf / the data: URI in the STT request).
const (
// defaultSTTSampleRate is the input audio sample rate the ASR expects.
defaultSTTSampleRate = 16000
// dashscopeSTTPath is the multimodal-generation service path (ASR).
dashscopeSTTPath = "/services/aigc/multimodal-generation/generation"
// dashscopeTTSPath is the SpeechSynthesizer service path (TTS).
dashscopeTTSPath = "/services/audio/tts/SpeechSynthesizer"
// defaultDashscopeBaseURL is the Bailian API root when none is configured.
defaultDashscopeBaseURL = "https://dashscope.aliyuncs.com/api/v1"
)
// DashscopeSTT transcribes audio via DashScope multimodal-generation.
type DashscopeSTT struct {
endpoint string
apiKey string
model string
format string
language string
sampleRate int
client *http.Client
}
// NewDashscopeSTT builds the DashScope STT adapter from cfg. Missing model /
// language / base URL fall back to the DashScope defaults.
func NewDashscopeSTT(cfg config.VoiceConfig) *DashscopeSTT {
model := firstNonEmpty(cfg.STTModel, "qwen-audio-3.0-asr-flash")
language := firstNonEmpty(cfg.STTLanguage, "zh")
return &DashscopeSTT{
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeSTTPath,
apiKey: cfg.DashscopeAPIKey,
model: model,
format: "wav",
language: language,
sampleRate: defaultSTTSampleRate,
client: defaultHTTPClient(),
}
}
// STT posts the audio as a base64 data: URI and returns the recognized text.
func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) {
if s.apiKey == "" {
return "", ErrNotConfigured
}
if len(audio.Data) == 0 {
return "", fmt.Errorf("voice: %w: empty audio", ErrProviderFailed)
}
if err := ctx.Err(); err != nil {
return "", err
}
format := audio.Format
if format == "" {
format = "wav"
}
payload, err := json.Marshal(dashscopeSTTRequest{
Model: s.model,
Input: dashscopeSTTInput{
Messages: []dashscopeSTTMessage{{
Role: "user",
Content: []dashscopeSTTContent{{
Type: "input_audio",
InputAudio: dashscopeInputAudio{
Data: "data:" + mimeOf(format) + ";base64," + base64.StdEncoding.EncodeToString(audio.Data),
},
}},
}},
},
Parameters: dashscopeSTTParameters{
Format: format,
SampleRate: s.sampleRate,
LanguageHints: []string{s.language},
},
})
if err != nil {
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
}
body, err := dashscopePost(ctx, s.client, s.apiKey, s.endpoint, payload, "disable")
if err != nil {
return "", err
}
text, err := extractSTTText(body)
if err != nil {
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
}
return text, nil
}
// DashscopeTTS synthesizes speech video via DashScope SpeechSynthesizer.
type DashscopeTTS struct {
endpoint string
apiKey string
model string
voiceID string
format string
sampleRate int
client *http.Client
}
// NewDashscopeTTS builds the DashScope TTS adapter from cfg. Missing model /
// voice / format / base URL fall back to the DashScope defaults.
func NewDashscopeTTS(cfg config.VoiceConfig) *DashscopeTTS {
model := firstNonEmpty(cfg.TTSModel, "cosyvoice-v3-flash")
voiceID := firstNonEmpty(cfg.TTSVoiceID, "longxiaochun_v3")
format := firstNonEmpty(cfg.TTSFormat, "wav")
sampleRate := cfg.TTSSampleRate
if sampleRate == 0 {
sampleRate = 24000
}
return &DashscopeTTS{
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeTTSPath,
apiKey: cfg.DashscopeAPIKey,
model: model,
voiceID: voiceID,
format: format,
sampleRate: sampleRate,
client: defaultHTTPClient(),
}
}
// TTS synthesizes audio for text. The non-streaming DashScope response carries a
// 24h audio URL (or a base64 blob); we resolve it to bytes and return them so the
// caller holds a complete audio file.
func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
if t.apiKey == "" {
return Audio{}, ErrNotConfigured
}
if strings.TrimSpace(text) == "" {
return Audio{}, ErrEmptyText
}
if err := ctx.Err(); err != nil {
return Audio{}, err
}
payload, err := json.Marshal(dashscopeTTSRequest{
Model: t.model,
Input: dashscopeTTSInput{
Text: text,
Voice: t.voiceID,
Format: t.format,
SampleRate: t.sampleRate,
},
})
if err != nil {
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
}
// No X-DashScope-SSE header → non-streaming URL response.
body, err := dashscopePost(ctx, t.client, t.apiKey, t.endpoint, payload, "")
if err != nil {
return Audio{}, err
}
data, err := t.audioBytes(ctx, body)
if err != nil {
return Audio{}, err
}
return Audio{Format: t.format, Data: data}, nil
}
// audioBytes resolves the synthesized audio from the DashScope response: a 24h
// URL is downloaded, or an inline base64 blob is decoded.
func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, error) {
var r dashscopeTTSResponse
if err := json.Unmarshal(body, &r); err != nil {
return nil, fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
}
if url := r.Output.Audio.URL; url != "" {
data, err := t.download(ctx, url)
if err != nil {
return nil, err
}
return data, nil
}
if b64 := r.Output.Audio.Data; b64 != "" {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("voice: %w: decode audio: %v", ErrProviderFailed, err)
}
return data, nil
}
return nil, fmt.Errorf("voice: %w: no audio in response", ErrProviderFailed)
}
// download fetches an audio URL into memory, capping the size so an oversized
// media response cannot exhaust memory.
func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
}
resp, err := t.client.Do(req)
if err != nil {
return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("voice: %w: download status %d", ErrProviderFailed, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, cloudMaxResponseBytes))
if err != nil {
return nil, fmt.Errorf("voice: %w: download read: %v", ErrProviderFailed, err)
}
return data, nil
}
// dashscopeEndpoint returns the DashScope API base with a trailing slash trimmed,
// defaulting to the public Bailian root when none is configured.
func dashscopeEndpoint(baseURL string) string {
base := strings.TrimRight(baseURL, "/")
if base == "" {
return defaultDashscopeBaseURL
}
return base
}
// firstNonEmpty returns a when non-empty, else b.
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// dashscopePost POSTs a JSON payload with the DashScope bearer auth and an
// optional X-DashScope-SSE header, returning the decoded response body. A non-200
// status is wrapped with the DashScope error body.
func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, payload []byte, sse string) ([]byte, 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)
}
if sse != "" {
req.Header.Set("X-DashScope-SSE", sse)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, cloudMaxResponseBytes))
if err != nil {
return nil, fmt.Errorf("voice: %w: read: %v", ErrProviderFailed, err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// extractSTTText pulls the transcript out of a DashScope ASR response. The ASR
// output shape varies by model, so several candidate paths are tried.
func extractSTTText(body []byte) (string, error) {
var r dashscopeSTTResponse
if err := json.Unmarshal(body, &r); err != nil {
return "", err
}
if s := r.Output.Output.Sentence.Text; s != "" {
return s, nil
}
if s := r.Output.Output.Text; s != "" {
return s, nil
}
if s := r.Output.Text; s != "" {
return s, nil
}
for _, ch := range r.Output.Choices {
if len(ch.Message.Content) > 0 {
if s := ch.Message.Content[0].Text; s != "" {
return s, nil
}
}
}
return r.Text, nil
}
// mimeOf maps an audio format to its MIME type for the data: URI / Content-Type.
func mimeOf(format string) string {
switch strings.ToLower(format) {
case "mp3", "mpeg":
return "audio/mpeg"
case "ogg", "oga":
return "audio/ogg"
case "webm":
return "audio/webm"
default:
return "audio/wav"
}
}
// dashscopeSTTResponse is the DashScope ASR response envelope.
type dashscopeSTTResponse struct {
Output struct {
Output struct {
Sentence struct {
Text string `json:"text"`
} `json:"sentence"`
Text string `json:"text"`
} `json:"output"`
Text string `json:"text"`
Choices []struct {
Message struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"message"`
} `json:"choices"`
} `json:"output"`
Text string `json:"text"`
}
// dashscopeTTSResponse is the DashScope TTS response envelope.
type dashscopeTTSResponse struct {
Output struct {
Audio struct {
URL string `json:"url"`
Data string `json:"data"`
} `json:"audio"`
} `json:"output"`
}
// dashscopeSTTRequest is the ASR request body.
type dashscopeSTTRequest struct {
Model string `json:"model"`
Input dashscopeSTTInput `json:"input"`
Parameters dashscopeSTTParameters `json:"parameters"`
}
// dashscopeSTTInput carries the multimodal messages list.
type dashscopeSTTInput struct {
Messages []dashscopeSTTMessage `json:"messages"`
}
// dashscopeSTTMessage is one ASR user turn.
type dashscopeSTTMessage struct {
Role string `json:"role"`
Content []dashscopeSTTContent `json:"content"`
}
// dashscopeSTTContent is one audio content block.
type dashscopeSTTContent struct {
Type string `json:"type"`
InputAudio dashscopeInputAudio `json:"input_audio"`
}
// dashscopeInputAudio carries the base64 data: URI audio.
type dashscopeInputAudio struct {
Data string `json:"data"`
}
// dashscopeSTTParameters tunes the ASR request.
type dashscopeSTTParameters struct {
Format string `json:"format"`
SampleRate int `json:"sample_rate"`
LanguageHints []string `json:"language_hints,omitempty"`
}
// dashscopeTTSRequest is the TTS request body.
type dashscopeTTSRequest struct {
Model string `json:"model"`
Input dashscopeTTSInput `json:"input"`
}
// dashscopeTTSInput carries text + voice + output format.
type dashscopeTTSInput struct {
Text string `json:"text"`
Voice string `json:"voice"`
Format string `json:"format"`
SampleRate int `json:"sample_rate"`
}