M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话 是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名 URL,带凭据),上游报错常把它原样回显。 - ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要; message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜 - errors.Is(err, ErrProviderFailed) 不变(熔断口径不动) - 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文
460 lines
15 KiB
Go
460 lines
15 KiB
Go
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"
|
||
}
|
||
}
|