Files
Cyrene_v2/internal/runtime/multimodal/voice/dashscope.go
T
AskaEth a918819878
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染;
复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。
消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content;
inbound.go 的丢空判据放宽为「无文本且无可用媒体」。
微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。
降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位;
errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复);
无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。
独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。
kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。
设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。
变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。
诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz;
官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
2026-10-05 08:32:37 +08:00

598 lines
20 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 != "" {
return "", fmt.Errorf("voice: %w: %s", ErrProviderFailed, resp.Message)
}
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
// 状态会被包装上 DashScope 的错误体。
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, classifyDashscopeStatus(resp.StatusCode, body)
}
return body, nil
}
// classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 /
// 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入
// ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。
//
// 判据只用**状态码 + 错误码/消息里的两类关键字**(阿里云官方口径:`Arrearage`、
// `Throttling.*`、额度上限),绝不匹配自由文本描述——把「格式不支持」误判成「欠费」
// 会让一条被拒的语音关掉整个转写面。
func classifyDashscopeStatus(status int, body []byte) error {
detail := strings.TrimSpace(string(body))
if err := dashscopeClassifiedError(status, detail); err != nil {
return err
}
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, status, detail)
}
// dashscopeClassifiedError 给出状态码对应的**分类**失败,无法分类时返回 nil。
func dashscopeClassifiedError(status int, detail string) error {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrAuthFailed, status, detail)
case status == http.StatusTooManyRequests:
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
}
// 欠费/额度耗尽:阿里云用 HTTP 400(少数情况 402)配上 Arrearage /
// AllocatedQuotaExceeded 这类错误码,因此按错误码关键字归类(不按状态码)。
lowered := strings.ToLower(detail)
if strings.Contains(lowered, "arrearage") || strings.Contains(lowered, "quota") ||
strings.Contains(lowered, "throttling") {
if strings.Contains(lowered, "throttling") {
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
}
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrQuotaExceeded, status, detail)
}
return 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"
}
}
// dashscopeSTTResponse 是 DashScope ASR 响应 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 是 DashScope TTS 响应 envelope。
type dashscopeTTSResponse struct {
Output struct {
Audio struct {
URL string `json:"url"`
Data string `json:"data"`
} `json:"audio"`
} `json:"output"`
}
// dashscopeSTTRequest 是 ASR 请求体。
type dashscopeSTTRequest struct {
Model string `json:"model"`
Input dashscopeSTTInput `json:"input"`
Parameters dashscopeSTTParameters `json:"parameters"`
}
// dashscopeSTTInput 承载多模态消息列表。
type dashscopeSTTInput struct {
Messages []dashscopeSTTMessage `json:"messages"`
}
// dashscopeSTTMessage 是一个 ASR 用户回合。
type dashscopeSTTMessage struct {
Role string `json:"role"`
Content []dashscopeSTTContent `json:"content"`
}
// dashscopeSTTContent 是一个音频内容块。
type dashscopeSTTContent struct {
Type string `json:"type"`
InputAudio dashscopeInputAudio `json:"input_audio"`
}
// dashscopeInputAudio 承载 base64 data: URI 音频。
type dashscopeInputAudio struct {
Data string `json:"data"`
}
// dashscopeSTTParameters 调整 ASR 请求。
type dashscopeSTTParameters struct {
Format string `json:"format"`
SampleRate int `json:"sample_rate"`
LanguageHints []string `json:"language_hints,omitempty"`
}
// dashscopeTTSRequest 是 TTS 请求体。
type dashscopeTTSRequest struct {
Model string `json:"model"`
Input dashscopeTTSInput `json:"input"`
}
// dashscopeTTSInput 承载文字 + 音色 + 输出格式。
type dashscopeTTSInput struct {
Text string `json:"text"`
Voice string `json:"voice"`
Format string `json:"format"`
SampleRate int `json:"sample_rate"`
// Instruction 是 CosyVoice 的情绪/语气 instruction;omitempty 使
// 系统音色(必须使用固定 instruction)不携带自由形式的 instruction。
Instruction string `json:"instruction,omitempty"`
}
// dashscopeCloneRequest 是音色注册 create_voice 请求体。
type dashscopeCloneRequest struct {
Model string `json:"model"`
Input dashscopeCloneInput `json:"input"`
}
// dashscopeCloneInput 承载 create_voice 动作参数。
type dashscopeCloneInput struct {
Action string `json:"action"`
TargetModel string `json:"target_model"`
Prefix string `json:"prefix"`
URL string `json:"url"`
}
// dashscopeCloneResponse 是音色注册响应 envelope。顶层
// message 表示模型级错误——即使 HTTP 200 也会暴露。
type dashscopeCloneResponse struct {
Output struct {
VoiceID string `json:"voice_id"`
} `json:"output"`
Message string `json:"message"`
}