Files
Cyrene_v2/internal/runtime/multimodal/voice/dashscope.go
T
AskaEth 9e88605cc8 fix(voice): CloneVoice 的 200-with-message 泄漏收口到 dashscopeOKBodyError
M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话
是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名
URL,带凭据),上游报错常把它原样回显。

- ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要;
  message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜
- errors.Is(err, ErrProviderFailed) 不变(熔断口径不动)
- 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文
2026-10-26 13:50:10 +08:00

460 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package voice
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"unicode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 语音链云端一级的 DashScope(阿里百炼)STT/TTS 适配器。请求
// 遵循 DashScope 公开 API:
//
// - STT: POST {base}/services/aigc/multimodal-generation/generation
// (qwen-audio-3.0-asr-flash,base64 输入音频)
// - TTS: POST {base}/services/audio/tts/SpeechSynthesizer
// (cosyvoice-v3-flash,按 voice_id 合成)
//
// 两者在这里都取非流式响应:STT 从 output envelope 中返回
// 转写;TTS(不带 X-DashScope-SSE 头)返回一个 24h 音频 URL,我们
// 把它下载下来,使调用方总能拿到真实字节。音频格式 ↔ mime
// 双向映射(mimeOf / STT 请求中的 data: URI)。
const (
// defaultSTTSampleRate 是 ASR 期望的输入音频采样率。
defaultSTTSampleRate = 16000
// dashscopeSTTPath 是 multimodal-generation 服务路径(ASR)。
dashscopeSTTPath = "/services/aigc/multimodal-generation/generation"
// dashscopeTTSPath 是 SpeechSynthesizer 服务路径(TTS)。
dashscopeTTSPath = "/services/audio/tts/SpeechSynthesizer"
// dashscopeClonePath 是音色定制(声音复刻 create_voice)路径。
dashscopeClonePath = "/services/audio/tts/customization"
// defaultDashscopeBaseURL 是未配置时使用的百炼 API 根地址。
defaultDashscopeBaseURL = "https://dashscope.aliyuncs.com/api/v1"
// defaultClonePrefix 是未提供时的音色名前缀。
defaultClonePrefix = "cyrene"
// voiceEnrollmentModel 是 DashScope 音色注册模型("create_voice")。
voiceEnrollmentModel = "voice-enrollment"
)
// DashscopeSTT 经 DashScope multimodal-generation 转写音频。
type DashscopeSTT struct {
endpoint string
apiKey string
model string
format string
language string
sampleRate int
client *http.Client
}
// NewDashscopeSTT 从 cfg 构造 DashScope STT 适配器。缺失的 model /
// language / base URL 回退到 DashScope 默认值。
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 把音频作为 base64 data: URI 发送并返回识别出的文字。
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 经 DashScope SpeechSynthesizer 合成语音。
type DashscopeTTS struct {
endpoint string
apiKey string
model string
voiceID string
format string
sampleRate int
instruction string
client *http.Client
}
// NewDashscopeTTS 从 cfg 构造 DashScope TTS 适配器。缺失的 model /
// voice / format / base URL 回退到 DashScope 默认值。
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,
instruction: cfg.VoiceTTSInstruction,
client: defaultHTTPClient(),
}
}
// TTS 为文字合成音频。非流式的 DashScope 响应携带一个
// 24h 音频 URL(或一段 base64);我们把它解析成字节并返回,
// 使调用方拿到完整的音频文件。
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
}
req := dashscopeTTSRequest{
Model: t.model,
Input: dashscopeTTSInput{
Text: text,
Voice: t.voiceID,
Format: t.format,
SampleRate: t.sampleRate,
},
}
if instruction := t.EffectiveInstruction(ctx); instruction != "" {
req.Input.Instruction = instruction
}
payload, err := json.Marshal(req)
if err != nil {
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
}
// 不带 X-DashScope-SSE 头 → 返回非流式 URL 响应。
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
}
// EffectiveInstruction 返回本次合成要发送的 instruction:经 ctx 透传的
// 逐请求覆盖优先,否则用配置的默认值。结果为空表示该请求
// 不携带 instruction。系统音色永远不会收到自由形式的 instruction
// (见 canInstruct),因为 cosyvoice v3 系统音色
// 要求固定的 instruction 格式。
func (t *DashscopeTTS) EffectiveInstruction(ctx context.Context) string {
if !canInstruct(t.voiceID) {
return ""
}
if override := TTSInstructionFromContext(ctx); override != "" {
return override
}
return t.instruction
}
// canInstruct 报告 voiceID 是否接受自由形式的 instruction。cosyvoice v3
// 系统音色(long*_v3 系列,例如 longxiaochun_v3)要求固定的
// instruction 格式;复刻/设计音色(声音复刻,其 id 永不以
// "_v3" 结尾)接受任意 instruction。
func canInstruct(voiceID string) bool {
return !strings.HasSuffix(voiceID, "_v3")
}
// audioBytes 从 DashScope 响应中解析合成音频:24h
// URL 会被下载,内联的 base64 块会被解码。
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 把音频 URL 抓进内存,并限制体积,使过大的
// 媒体响应无法耗尽内存。
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
}
// DashscopeCloner 经 DashScope 音色注册 API
// (声音复刻 create_voice)创建自定义 TTS 音色。参考音频必须是公开
// URL —— create_voice 动作只接受 input.url(不支持 base64)。返回的 voice_id
// 绑定到 TTSModel,因此 target_model 必须与合成时使用的模型一致。
type DashscopeCloner struct {
endpoint string
apiKey string
model string
client *http.Client
}
// NewDashscopeCloner 从 cfg 构造 DashScope 音色复刻器。缺失的 model /
// base URL 回退到 DashScope 默认值;model 跟随 TTSModel,使
// 复刻出的音色可被 TTS 一级使用(target_model 必须一致)。
func NewDashscopeCloner(cfg config.VoiceConfig) *DashscopeCloner {
return &DashscopeCloner{
endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeClonePath,
apiKey: cfg.DashscopeAPIKey,
model: firstNonEmpty(cfg.TTSModel, "cosyvoice-v3-flash"),
client: defaultHTTPClient(),
}
}
// CloneVoice 提交一次 create_voice 请求并返回创建出的 voice_id。它
// 是 REST /api/v1/audio/voice-clone endpoint 背后的适配器。缺少 API
// key 时返回 ErrNotConfigured;空 URL 或非法 prefix 会被拒绝。
func (d *DashscopeCloner) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
if d.apiKey == "" {
return "", ErrNotConfigured
}
referenceURL = strings.TrimSpace(referenceURL)
if referenceURL == "" {
return "", fmt.Errorf("voice: %w: reference audio url is required", ErrProviderFailed)
}
prefix, err := normalizeClonePrefix(prefix)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
payload, err := json.Marshal(dashscopeCloneRequest{
Model: voiceEnrollmentModel,
Input: dashscopeCloneInput{
Action: "create_voice",
TargetModel: d.model,
Prefix: prefix,
URL: referenceURL,
},
})
if err != nil {
return "", fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
}
body, err := dashscopePost(ctx, d.client, d.apiKey, d.endpoint, payload, "")
if err != nil {
return "", err
}
var resp dashscopeCloneResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err)
}
if resp.Message != "" {
// 200 + 非空 message 是 DashScope 的「业务级失败」形状。message 原文是
// 上游自由文本(可能回显参考音频的预签名 URL),因此只留形状事实与摘要
// (见 dashscopeOKBodyError),不拼原文。
return "", dashscopeOKBodyError(d.endpoint, body)
}
if resp.Output.VoiceID == "" {
return "", fmt.Errorf("voice: %w: no voice_id in response", ErrProviderFailed)
}
return resp.Output.VoiceID, nil
}
// normalizeClonePrefix 去空白并校验音色名前缀。空值
// 回退到 defaultClonePrefix;结果必须是 ≤10 个字母/数字字符
// (音色注册对 prefix 的约束)。
func normalizeClonePrefix(prefix string) (string, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return defaultClonePrefix, nil
}
if len(prefix) > 10 {
return "", fmt.Errorf("voice: %w: prefix %q exceeds 10 chars", ErrProviderFailed, prefix)
}
for _, r := range prefix {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return "", fmt.Errorf("voice: %w: prefix must be alphanumeric", ErrProviderFailed)
}
}
return prefix, nil
}
// dashscopeEndpoint 返回去掉尾部斜杠的 DashScope API 根地址,
// 未配置时默认为公开的百炼根地址。
func dashscopeEndpoint(baseURL string) string {
base := strings.TrimRight(baseURL, "/")
if base == "" {
return defaultDashscopeBaseURL
}
return base
}
// firstNonEmpty 在 a 非空时返回 a,否则返回 b。
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// dashscopePost POST 一个 JSON 载荷,带上 DashScope bearer 鉴权与可选的
// X-DashScope-SSE 头,返回解码后的响应体。非 200 状态的错误体**不整包读**:
// 错误路径只读 maxErrorBodyBytes,且正文一个字节都不进 error(见
// classifyDashscopeStatus)。
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() }()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return nil, classifyDashscopeStatus(url, resp.StatusCode, resp.Header.Get("Content-Type"), errBody)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, cloudMaxResponseBytes))
if err != nil {
return nil, fmt.Errorf("voice: %w: read: %v", ErrProviderFailed, err)
}
return body, nil
}
// extractSTTText 从 DashScope ASR 响应中取出转写。ASR
// 的输出形状随模型而异,因此按多个候选路径依次尝试。
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 把音频格式映射为其 MIME 类型,供 data: URI / Content-Type 使用。
//
// 覆盖 IM 入站语音放行的全部格式(`mediafetch` 的音频白名单):data: URI 的 MIME 是
// 上游唯一能看到的类型线索,把它写错(例如把 AMR 写成 audio/wav)本身就是一种假话。
// ⚠️ 未确证:DashScope 对 AMR/SILK 等格式的接受度、以及 `parameters.sample_rate` 对
// 压缩容器是否有影响,官方「音频规格」页尚未抓到(design/06 §1.2 已标注)。
func mimeOf(format string) string {
switch strings.ToLower(format) {
case "mp3", "mpeg":
return "audio/mpeg"
case "ogg", "oga", "opus":
return "audio/ogg"
case "webm":
return "audio/webm"
case "m4a", "mp4":
return "audio/mp4"
case "aac":
return "audio/aac"
case "amr":
return "audio/amr"
case "flac":
return "audio/flac"
default:
return "audio/wav"
}
}