package voice import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "unicode" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 语音链云端一级的 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, trace.URLCause(err)) } resp, err := t.client.Do(req) if err != nil { return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, trace.URLCause(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, trace.URLCause(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, trace.URLCause(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" } }