docs(comments): 注释中文化 · internal/runtime/multimodal(21 文件,纯替换)
- 只翻注释/文档字符串,代码零变化:go/scanner 词法流比对 21 文件 token+字面量全等,增删行数 1:1(467/467) - 刻意保留非散文英文:JSON Schema description、工具 Description() 返回值、adapter prompt、API 路径与命令示例、环境变量名 - 逐行确认代码前缀未变,未触碰其他模块
This commit is contained in:
1 parent
b9e042d07d
commit
b0fbb8bc0d
21 files changed
+467
-467
No files matched your search
@@ -7,31 +7,31 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// Chain is a degrading provider list. STT and TTS walk the steps in order,
|
||||
// returning the first successful result and moving to the next step when a
|
||||
// provider is unavailable (nil direction) or fails. It implements STT and TTS so
|
||||
// it can be used anywhere a single provider is expected.
|
||||
// Chain 是一条降级提供方列表。STT 与 TTS 按序走各级,
|
||||
// 返回第一个成功的结果;某级不可用(方向为 nil)或
|
||||
// 失败时则移向下一级。它实现 STT 与 TTS,
|
||||
// 因此可用在任何期望单个提供方的地方。
|
||||
type Chain struct {
|
||||
steps []Provider
|
||||
cloner VoiceCloner
|
||||
}
|
||||
|
||||
// NewChain builds a degrading chain from the given providers in the order they
|
||||
// should be tried. Passing a SilentProvider last guarantees the chain always
|
||||
// succeeds.
|
||||
// NewChain 按给定提供方的尝试顺序构造一条降级链。
|
||||
// 把 SilentProvider 放在最后,可保证
|
||||
// 这条链总是成功。
|
||||
func NewChain(steps ...Provider) *Chain {
|
||||
return &Chain{steps: steps}
|
||||
}
|
||||
|
||||
// STT walks the chain for a transcription.
|
||||
// STT 走链取一次转写。
|
||||
func (c *Chain) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
text, _, err := c.STTWithProvider(ctx, audio)
|
||||
return text, err
|
||||
}
|
||||
|
||||
// STTWithProvider is STT but also reports the name of the provider rung that
|
||||
// produced the transcription (cloud / local / edge-tts / silent), so a caller can
|
||||
// surface which tier actually served the request.
|
||||
// STTWithProvider 即 STT,但额外报告产出该转写的提供方
|
||||
// 级别名(cloud / local / edge-tts / silent),使调用方能
|
||||
// 展示实际是哪一级服务了该请求。
|
||||
func (c *Chain) STTWithProvider(ctx context.Context, audio Audio) (string, string, error) {
|
||||
if len(c.steps) == 0 {
|
||||
return "", "", ErrProviderFailed
|
||||
@@ -51,9 +51,9 @@ func (c *Chain) STTWithProvider(ctx context.Context, audio Audio) (string, strin
|
||||
return "", "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
||||
}
|
||||
|
||||
// CloneVoice creates a custom voice via the cloud provider's voice-enrollment
|
||||
// endpoint (声音复刻 create_voice). A chain without a cloud rung returns
|
||||
// ErrNotConfigured. It is not a degradation step — the caller gets one result.
|
||||
// CloneVoice 经云端提供方的音色注册 endpoint 创建自定义音色
|
||||
// (声音复刻 create_voice)。没有云端一级的链返回
|
||||
// ErrNotConfigured。它不是降级步骤 —— 调用方只拿到一个结果。
|
||||
func (c *Chain) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||||
if c.cloner == nil {
|
||||
return "", ErrNotConfigured
|
||||
@@ -61,14 +61,14 @@ func (c *Chain) CloneVoice(ctx context.Context, referenceURL, prefix string) (st
|
||||
return c.cloner.CloneVoice(ctx, referenceURL, prefix)
|
||||
}
|
||||
|
||||
// TTS walks the chain for synthesized audio.
|
||||
// TTS 走链取合成音频。
|
||||
func (c *Chain) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
audio, _, err := c.TTSWithProvider(ctx, text)
|
||||
return audio, err
|
||||
}
|
||||
|
||||
// TTSWithProvider is TTS but also reports the name of the provider rung that
|
||||
// produced the audio.
|
||||
// TTSWithProvider 即 TTS,但额外报告产出该音频的提供方
|
||||
// 级别名。
|
||||
func (c *Chain) TTSWithProvider(ctx context.Context, text string) (Audio, string, error) {
|
||||
if len(c.steps) == 0 {
|
||||
return Audio{}, "", ErrProviderFailed
|
||||
@@ -88,32 +88,32 @@ func (c *Chain) TTSWithProvider(ctx context.Context, text string) (Audio, string
|
||||
return Audio{}, "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
||||
}
|
||||
|
||||
// Option customizes a chain built by New.
|
||||
// Option 定制由 New 构造的链。
|
||||
type Option func(*chainConfig)
|
||||
|
||||
// chainConfig carries the assembled steps for New.
|
||||
// chainConfig 承载 New 组装出的各级。
|
||||
type chainConfig struct {
|
||||
steps []Provider
|
||||
}
|
||||
|
||||
// WithProviders overrides the provider steps assembled from config. Tests use it
|
||||
// to inject deterministic mock providers instead of the real adaptation-point
|
||||
// rungs.
|
||||
// WithProviders 覆盖由配置组装出的提供方各级。测试用它
|
||||
// 注入确定性的 mock 提供方,而不是真实的适配点
|
||||
// 各级。
|
||||
func WithProviders(steps ...Provider) Option {
|
||||
return func(c *chainConfig) { c.steps = steps }
|
||||
}
|
||||
|
||||
// New assembles a chain from cfg using the fixed degradation order
|
||||
// cloud → local → edge-tts/espeak → silent.
|
||||
// New 用固定降级顺序从 cfg 组装一条链:
|
||||
// cloud → local → edge-tts/espeak → silent。
|
||||
//
|
||||
// - cloud is included only when a cloud voice credential or model is present
|
||||
// (voiceConfigured) so a mis-configured build degrades quietly.
|
||||
// - local is a pure adaptation point and is only included when supplied via
|
||||
// WithProviders; by default it is skipped.
|
||||
// - the edge-tts or espeak rung is included according to cfg.TTSProvider.
|
||||
// - silent is always appended last so the chain never fails outright.
|
||||
// - 仅当存在云端语音凭据或模型(voiceConfigured)时才纳入
|
||||
// cloud,使配置有误的构建安静降级。
|
||||
// - local 是纯适配点,仅当经 WithProviders 提供时才纳入;
|
||||
// 默认跳过。
|
||||
// - 依 cfg.TTSProvider 纳入 edge-tts 或 espeak 一级。
|
||||
// - silent 始终追加在最后,使链绝不会直接失败。
|
||||
//
|
||||
// opts may supply a fully assembled step list to override this mapping.
|
||||
// opts 可提供一份完整组装好的各级列表来覆盖上述映射。
|
||||
func New(cfg config.VoiceConfig, opts ...Option) *Chain {
|
||||
conf := &chainConfig{}
|
||||
for _, o := range opts {
|
||||
@@ -141,11 +141,11 @@ func New(cfg config.VoiceConfig, opts ...Option) *Chain {
|
||||
return chain
|
||||
}
|
||||
|
||||
// voiceConfigured reports whether any cloud voice credential or model is set, so
|
||||
// the cloud rung is only added when the runtime intends to use DashScope. Any
|
||||
// single present field (API key, base URL, or an STT/TTS model) makes the cloud
|
||||
// rung worth trying; a missing key then makes the adapter return ErrNotConfigured
|
||||
// and the chain degrades quietly.
|
||||
// voiceConfigured 报告是否设置了任一云端语音凭据或模型,使
|
||||
// 云端一级只在运行时有意使用 DashScope 时才加入。任一字段
|
||||
// 存在(API key、base URL 或某个 STT/TTS 模型)都让云端
|
||||
// 一级值得一试;随后缺失的 key 会让适配器返回 ErrNotConfigured,
|
||||
// 链则安静降级。
|
||||
func voiceConfigured(cfg config.VoiceConfig) bool {
|
||||
return cfg.DashscopeAPIKey != "" || cfg.DashscopeBaseURL != "" || cfg.STTModel != "" || cfg.TTSModel != ""
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// stubSTT is a mock STT returning a fixed result.
|
||||
// stubSTT 是一个返回固定结果的 mock STT。
|
||||
type stubSTT struct {
|
||||
text string
|
||||
err error
|
||||
@@ -16,7 +16,7 @@ type stubSTT struct {
|
||||
|
||||
func (s *stubSTT) STT(_ context.Context, _ Audio) (string, error) { return s.text, s.err }
|
||||
|
||||
// stubTTS is a mock TTS returning a fixed result.
|
||||
// stubTTS 是一个返回固定结果的 mock TTS。
|
||||
type stubTTS struct {
|
||||
audio Audio
|
||||
err error
|
||||
@@ -24,7 +24,7 @@ type stubTTS struct {
|
||||
|
||||
func (s *stubTTS) TTS(_ context.Context, _ string) (Audio, error) { return s.audio, s.err }
|
||||
|
||||
// stubProvider is a mock Provider carrying optional directions.
|
||||
// stubProvider 是携带可选方向的 mock Provider。
|
||||
type stubProvider struct {
|
||||
name string
|
||||
stt STT
|
||||
@@ -158,8 +158,8 @@ func TestChain_Empty(t *testing.T) {
|
||||
|
||||
func TestNew_ConfigAssembly(t *testing.T) {
|
||||
t.Parallel()
|
||||
// With a cloud key and default edge-tts TTS, the chain starts at cloud and
|
||||
// ends at silent.
|
||||
// 配了云端 key 且 TTS 默认走 edge-tts 时,链从 cloud 开始、
|
||||
// 到 silent 结束。
|
||||
c := New(config.VoiceConfig{DashscopeAPIKey: "k"})
|
||||
names := make([]string, 0, len(c.steps))
|
||||
for _, p := range c.steps {
|
||||
@@ -172,7 +172,7 @@ func TestNew_ConfigAssembly(t *testing.T) {
|
||||
t.Errorf("first step = %q, want cloud", names[0])
|
||||
}
|
||||
|
||||
// Without a key the cloud rung is skipped.
|
||||
// 没有 key 时跳过云端一级。
|
||||
c2 := New(config.VoiceConfig{})
|
||||
foundCloud := false
|
||||
for _, p := range c2.steps {
|
||||
|
||||
@@ -15,17 +15,17 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// cloudMaxResponseBytes caps how much of a cloud voice response is read, so an
|
||||
// oversized or malformed response cannot exhaust memory. The request timeout
|
||||
// bounds a single call.
|
||||
// cloudMaxResponseBytes 限制读取云端语音响应的体积,使过大
|
||||
// 或畸形的响应无法耗尽内存。请求超时
|
||||
// 约束单次调用。
|
||||
const (
|
||||
cloudMaxResponseBytes = 8 << 20
|
||||
cloudHTTPTimeout = 180 * time.Second
|
||||
)
|
||||
|
||||
// HTTPSTT transcribes audio by POSTing a JSON body to a cloud endpoint and
|
||||
// reading {"text": "..."} back. It is the stubbed default STT implementation;
|
||||
// the real DashScope/whisper-facing adapter is an adaptation point.
|
||||
// HTTPSTT 通过向云端 endpoint POST 一个 JSON 请求体并
|
||||
// 读回 {"text": "..."} 来转写音频。它是桩式的默认 STT 实现;
|
||||
// 真正面向 DashScope/whisper 的适配器是一个适配点。
|
||||
type HTTPSTT struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
@@ -33,8 +33,8 @@ type HTTPSTT struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// HTTPTTS synthesizes speech by POSTing a JSON body and reading the returned
|
||||
// base64 audio. It is the stubbed default TTS implementation.
|
||||
// HTTPTTS 通过 POST 一个 JSON 请求体并读取返回的
|
||||
// base64 音频来合成语音。它是桩式的默认 TTS 实现。
|
||||
type HTTPTTS struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
@@ -42,7 +42,7 @@ type HTTPTTS struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// HTTPOption customizes the client and logger of an HTTPSTT/HTTPTTS.
|
||||
// HTTPOption 定制 HTTPSTT/HTTPTTS 的 client 与日志器。
|
||||
type HTTPOption func(*httpConfig)
|
||||
|
||||
type httpConfig struct {
|
||||
@@ -50,7 +50,7 @@ type httpConfig struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the *http.Client used for the call; nil keeps the default.
|
||||
// WithHTTPClient 设置该调用所用的 *http.Client;nil 表示保持默认。
|
||||
func WithHTTPClient(c *http.Client) HTTPOption {
|
||||
return func(o *httpConfig) {
|
||||
if c != nil {
|
||||
@@ -59,7 +59,7 @@ func WithHTTPClient(c *http.Client) HTTPOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets the logger used on failures; nil keeps a discard logger.
|
||||
// WithLogger 设置失败时使用的日志器;nil 表示保持丢弃日志器。
|
||||
func WithLogger(l *slog.Logger) HTTPOption {
|
||||
return func(o *httpConfig) {
|
||||
if l != nil {
|
||||
@@ -68,7 +68,7 @@ func WithLogger(l *slog.Logger) HTTPOption {
|
||||
}
|
||||
}
|
||||
|
||||
// NewHTTPSTT builds an HTTPSTT for the given endpoint and API key.
|
||||
// NewHTTPSTT 为给定 endpoint 与 API key 构造一个 HTTPSTT。
|
||||
func NewHTTPSTT(endpoint, apiKey string, opts ...HTTPOption) *HTTPSTT {
|
||||
h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()}
|
||||
for _, o := range opts {
|
||||
@@ -77,7 +77,7 @@ func NewHTTPSTT(endpoint, apiKey string, opts ...HTTPOption) *HTTPSTT {
|
||||
return &HTTPSTT{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log}
|
||||
}
|
||||
|
||||
// NewHTTPTTS builds an HTTPTTS for the given endpoint and API key.
|
||||
// NewHTTPTTS 为给定 endpoint 与 API key 构造一个 HTTPTTS。
|
||||
func NewHTTPTTS(endpoint, apiKey string, opts ...HTTPOption) *HTTPTTS {
|
||||
h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()}
|
||||
for _, o := range opts {
|
||||
@@ -86,19 +86,19 @@ func NewHTTPTTS(endpoint, apiKey string, opts ...HTTPOption) *HTTPTTS {
|
||||
return &HTTPTTS{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log}
|
||||
}
|
||||
|
||||
// sttRequest is the JSON body sent to the STT endpoint.
|
||||
// sttRequest 是发往 STT endpoint 的 JSON 请求体。
|
||||
type sttRequest struct {
|
||||
Audio string `json:"audio"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
// sttResponse is the transcription returned by the STT endpoint.
|
||||
// sttResponse 是 STT endpoint 返回的转写。
|
||||
type sttResponse struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// STT posts the audio and returns the recognized text. A blank endpoint returns
|
||||
// ErrNotConfigured so the chain degrades.
|
||||
// STT 发送音频并返回识别出的文字。endpoint 为空时返回
|
||||
// ErrNotConfigured,链随之降级。
|
||||
func (s *HTTPSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
if s.endpoint == "" {
|
||||
return "", ErrNotConfigured
|
||||
@@ -122,19 +122,19 @@ func (s *HTTPSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
return decoded.Text, nil
|
||||
}
|
||||
|
||||
// ttsRequest is the JSON body sent to the TTS endpoint.
|
||||
// ttsRequest 是发往 TTS endpoint 的 JSON 请求体。
|
||||
type ttsRequest struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// ttsResponse is the synthesized audio returned by the TTS endpoint.
|
||||
// ttsResponse 是 TTS endpoint 返回的合成音频。
|
||||
type ttsResponse struct {
|
||||
Audio string `json:"audio"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
// TTS posts the text and returns the decoded audio. A blank endpoint returns
|
||||
// ErrNotConfigured so the chain degrades.
|
||||
// TTS 发送文本并返回解码后的音频。endpoint 为空时返回
|
||||
// ErrNotConfigured,链随之降级。
|
||||
func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
if s.endpoint == "" {
|
||||
return Audio{}, ErrNotConfigured
|
||||
@@ -169,7 +169,7 @@ func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
return Audio{Format: format, Data: data}, nil
|
||||
}
|
||||
|
||||
// postJSON POSTs a JSON payload, wires auth, and checks the status code.
|
||||
// postJSON POST 一个 JSON 载荷,接好鉴权并检查状态码。
|
||||
func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payload []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
@@ -190,41 +190,41 @@ func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payl
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// defaultHTTPClient builds a client with a request timeout. A fresh client is
|
||||
// returned per call rather than a package global, which the constraints forbid.
|
||||
// defaultHTTPClient 构造一个带请求超时的 client。这里每次调用
|
||||
// 返回新 client,而不是包级全局变量——后者为约束所禁止。
|
||||
func defaultHTTPClient() *http.Client {
|
||||
return &http.Client{Transport: http.DefaultTransport, Timeout: cloudHTTPTimeout}
|
||||
}
|
||||
|
||||
// discardLogger returns a logger that drops all output.
|
||||
// discardLogger 返回一个丢弃全部输出的日志器。
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// newDashboardSTT returns the cloud STT adapter for a DashScope config. A call
|
||||
// returns ErrNotConfigured when no API key is set so the chain degrades.
|
||||
// newDashboardSTT 为 DashScope 配置返回云端 STT 适配器。未设置
|
||||
// API key 时调用会返回 ErrNotConfigured,链随之降级。
|
||||
func newDashboardSTT(cfg config.VoiceConfig) STT {
|
||||
return NewDashscopeSTT(cfg)
|
||||
}
|
||||
|
||||
// newDashboardTTS returns the cloud TTS adapter for a DashScope config.
|
||||
// newDashboardTTS 为 DashScope 配置返回云端 TTS 适配器。
|
||||
func newDashboardTTS(cfg config.VoiceConfig) TTS {
|
||||
return NewDashscopeTTS(cfg)
|
||||
}
|
||||
|
||||
// newDashboardCloner returns the cloud voice-enrollment cloner for a DashScope
|
||||
// config, used by the create_voice (声音复刻) endpoint.
|
||||
// newDashboardCloner 为 DashScope 配置返回云端音色注册 cloner,
|
||||
// 供 create_voice(声音复刻)endpoint 使用。
|
||||
func newDashboardCloner(cfg config.VoiceConfig) VoiceCloner {
|
||||
return NewDashscopeCloner(cfg)
|
||||
}
|
||||
|
||||
// newEdgeTTS returns the edge-tts adapter point (not yet implemented).
|
||||
// newEdgeTTS 返回 edge-tts 适配点(尚未实现)。
|
||||
func newEdgeTTS() TTS { return notImplementedTTS{} }
|
||||
|
||||
// newEspeak returns the espeak adapter point (not yet implemented).
|
||||
// newEspeak 返回 espeak 适配点(尚未实现)。
|
||||
func newEspeak() TTS { return notImplementedTTS{} }
|
||||
|
||||
// notImplementedTTS is an adaptation point that always degrades.
|
||||
// notImplementedTTS 是一个总是降级的适配点。
|
||||
type notImplementedTTS struct{}
|
||||
|
||||
func (notImplementedTTS) TTS(_ context.Context, _ string) (Audio, error) {
|
||||
|
||||
@@ -14,37 +14,37 @@ import (
|
||||
"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:
|
||||
// 语音链云端一级的 DashScope(阿里百炼)STT/TTS 适配器。请求
|
||||
// 遵循 DashScope 公开 API:
|
||||
//
|
||||
// - STT: POST {base}/services/aigc/multimodal-generation/generation
|
||||
// (qwen-audio-3.0-asr-flash, base64 input audio)
|
||||
// (qwen-audio-3.0-asr-flash,base64 输入音频)
|
||||
// - TTS: POST {base}/services/audio/tts/SpeechSynthesizer
|
||||
// (cosyvoice-v3-flash, synthesized by voice_id)
|
||||
// (cosyvoice-v3-flash,按 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).
|
||||
// 两者在这里都取非流式响应:STT 从 output envelope 中返回
|
||||
// 转写;TTS(不带 X-DashScope-SSE 头)返回一个 24h 音频 URL,我们
|
||||
// 把它下载下来,使调用方总能拿到真实字节。音频格式 ↔ mime
|
||||
// 双向映射(mimeOf / STT 请求中的 data: URI)。
|
||||
|
||||
const (
|
||||
// defaultSTTSampleRate is the input audio sample rate the ASR expects.
|
||||
// defaultSTTSampleRate 是 ASR 期望的输入音频采样率。
|
||||
defaultSTTSampleRate = 16000
|
||||
// dashscopeSTTPath is the multimodal-generation service path (ASR).
|
||||
// dashscopeSTTPath 是 multimodal-generation 服务路径(ASR)。
|
||||
dashscopeSTTPath = "/services/aigc/multimodal-generation/generation"
|
||||
// dashscopeTTSPath is the SpeechSynthesizer service path (TTS).
|
||||
// dashscopeTTSPath 是 SpeechSynthesizer 服务路径(TTS)。
|
||||
dashscopeTTSPath = "/services/audio/tts/SpeechSynthesizer"
|
||||
// dashscopeClonePath is the voice-customization (声音复刻 create_voice) path.
|
||||
// dashscopeClonePath 是音色定制(声音复刻 create_voice)路径。
|
||||
dashscopeClonePath = "/services/audio/tts/customization"
|
||||
// defaultDashscopeBaseURL is the Bailian API root when none is configured.
|
||||
// defaultDashscopeBaseURL 是未配置时使用的百炼 API 根地址。
|
||||
defaultDashscopeBaseURL = "https://dashscope.aliyuncs.com/api/v1"
|
||||
// defaultClonePrefix is the voice-name prefix when none is supplied.
|
||||
// defaultClonePrefix 是未提供时的音色名前缀。
|
||||
defaultClonePrefix = "cyrene"
|
||||
// voiceEnrollmentModel is the DashScope voice-enrollment model ("create_voice").
|
||||
// voiceEnrollmentModel 是 DashScope 音色注册模型("create_voice")。
|
||||
voiceEnrollmentModel = "voice-enrollment"
|
||||
)
|
||||
|
||||
// DashscopeSTT transcribes audio via DashScope multimodal-generation.
|
||||
// DashscopeSTT 经 DashScope multimodal-generation 转写音频。
|
||||
type DashscopeSTT struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
@@ -55,8 +55,8 @@ type DashscopeSTT struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDashscopeSTT builds the DashScope STT adapter from cfg. Missing model /
|
||||
// language / base URL fall back to the DashScope defaults.
|
||||
// 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")
|
||||
@@ -71,7 +71,7 @@ func NewDashscopeSTT(cfg config.VoiceConfig) *DashscopeSTT {
|
||||
}
|
||||
}
|
||||
|
||||
// STT posts the audio as a base64 data: URI and returns the recognized text.
|
||||
// STT 把音频作为 base64 data: URI 发送并返回识别出的文字。
|
||||
func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
if s.apiKey == "" {
|
||||
return "", ErrNotConfigured
|
||||
@@ -119,7 +119,7 @@ func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// DashscopeTTS synthesizes speech video via DashScope SpeechSynthesizer.
|
||||
// DashscopeTTS 经 DashScope SpeechSynthesizer 合成语音。
|
||||
type DashscopeTTS struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
@@ -131,8 +131,8 @@ type DashscopeTTS struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDashscopeTTS builds the DashScope TTS adapter from cfg. Missing model /
|
||||
// voice / format / base URL fall back to the DashScope defaults.
|
||||
// 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")
|
||||
@@ -153,9 +153,9 @@ func NewDashscopeTTS(cfg config.VoiceConfig) *DashscopeTTS {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TTS 为文字合成音频。非流式的 DashScope 响应携带一个
|
||||
// 24h 音频 URL(或一段 base64);我们把它解析成字节并返回,
|
||||
// 使调用方拿到完整的音频文件。
|
||||
func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
if t.apiKey == "" {
|
||||
return Audio{}, ErrNotConfigured
|
||||
@@ -182,7 +182,7 @@ func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
if err != nil {
|
||||
return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err)
|
||||
}
|
||||
// No X-DashScope-SSE header → non-streaming URL response.
|
||||
// 不带 X-DashScope-SSE 头 → 返回非流式 URL 响应。
|
||||
body, err := dashscopePost(ctx, t.client, t.apiKey, t.endpoint, payload, "")
|
||||
if err != nil {
|
||||
return Audio{}, err
|
||||
@@ -194,11 +194,11 @@ func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
return Audio{Format: t.format, Data: data}, nil
|
||||
}
|
||||
|
||||
// EffectiveInstruction returns the instruction to send for this synthesis: a
|
||||
// per-request override threaded through ctx wins, else the configured default. An
|
||||
// empty result means the request carries no instruction. A system voice never gets
|
||||
// a free-form instruction (see canInstruct), because cosyvoice v3 system voices
|
||||
// require a fixed instruction format.
|
||||
// EffectiveInstruction 返回本次合成要发送的 instruction:经 ctx 透传的
|
||||
// 逐请求覆盖优先,否则用配置的默认值。结果为空表示该请求
|
||||
// 不携带 instruction。系统音色永远不会收到自由形式的 instruction
|
||||
// (见 canInstruct),因为 cosyvoice v3 系统音色
|
||||
// 要求固定的 instruction 格式。
|
||||
func (t *DashscopeTTS) EffectiveInstruction(ctx context.Context) string {
|
||||
if !canInstruct(t.voiceID) {
|
||||
return ""
|
||||
@@ -209,16 +209,16 @@ func (t *DashscopeTTS) EffectiveInstruction(ctx context.Context) string {
|
||||
return t.instruction
|
||||
}
|
||||
|
||||
// canInstruct reports whether voiceID accepts a free-form instruction. cosyvoice v3
|
||||
// system voices (the long*_v3 family, e.g. longxiaochun_v3) require a fixed
|
||||
// instruction format; a cloned/designed voice (声音复刻, whose id never ends in
|
||||
// "_v3") accepts an arbitrary 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 resolves the synthesized audio from the DashScope response: a 24h
|
||||
// URL is downloaded, or an inline base64 blob is decoded.
|
||||
// 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 {
|
||||
@@ -241,8 +241,8 @@ func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, err
|
||||
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.
|
||||
// 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 {
|
||||
@@ -263,10 +263,10 @@ func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// DashscopeCloner creates custom TTS voices via the DashScope voice-enrollment
|
||||
// API (声音复刻 create_voice). The reference audio must be a public URL — the
|
||||
// create_voice action only accepts input.url (no base64). The returned voice_id is
|
||||
// bound to TTSModel, so target_model must match the model used at synthesis time.
|
||||
// DashscopeCloner 经 DashScope 音色注册 API
|
||||
// (声音复刻 create_voice)创建自定义 TTS 音色。参考音频必须是公开
|
||||
// URL —— create_voice 动作只接受 input.url(不支持 base64)。返回的 voice_id
|
||||
// 绑定到 TTSModel,因此 target_model 必须与合成时使用的模型一致。
|
||||
type DashscopeCloner struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
@@ -274,9 +274,9 @@ type DashscopeCloner struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDashscopeCloner builds the DashScope voice cloner from cfg. Missing model /
|
||||
// base URL fall back to the DashScope defaults; the model tracks TTSModel so the
|
||||
// cloned voice is usable by the TTS rung (target_model must match).
|
||||
// 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,
|
||||
@@ -286,9 +286,9 @@ func NewDashscopeCloner(cfg config.VoiceConfig) *DashscopeCloner {
|
||||
}
|
||||
}
|
||||
|
||||
// CloneVoice submits a create_voice request and returns the created voice_id. It
|
||||
// is the adapter behind the REST /api/v1/audio/voice-clone endpoint. A missing API
|
||||
// key returns ErrNotConfigured; an empty URL or an invalid prefix is rejected.
|
||||
// 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
|
||||
@@ -333,9 +333,9 @@ func (d *DashscopeCloner) CloneVoice(ctx context.Context, referenceURL, prefix s
|
||||
return resp.Output.VoiceID, nil
|
||||
}
|
||||
|
||||
// normalizeClonePrefix trims and validates the voice-name prefix. An empty value
|
||||
// falls back to defaultClonePrefix; the result must be ≤10 letter/digit chars
|
||||
// (the voice-enrollment prefix constraint).
|
||||
// normalizeClonePrefix 去空白并校验音色名前缀。空值
|
||||
// 回退到 defaultClonePrefix;结果必须是 ≤10 个字母/数字字符
|
||||
// (音色注册对 prefix 的约束)。
|
||||
func normalizeClonePrefix(prefix string) (string, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
@@ -352,8 +352,8 @@ func normalizeClonePrefix(prefix string) (string, error) {
|
||||
return prefix, nil
|
||||
}
|
||||
|
||||
// dashscopeEndpoint returns the DashScope API base with a trailing slash trimmed,
|
||||
// defaulting to the public Bailian root when none is configured.
|
||||
// dashscopeEndpoint 返回去掉尾部斜杠的 DashScope API 根地址,
|
||||
// 未配置时默认为公开的百炼根地址。
|
||||
func dashscopeEndpoint(baseURL string) string {
|
||||
base := strings.TrimRight(baseURL, "/")
|
||||
if base == "" {
|
||||
@@ -362,7 +362,7 @@ func dashscopeEndpoint(baseURL string) string {
|
||||
return base
|
||||
}
|
||||
|
||||
// firstNonEmpty returns a when non-empty, else b.
|
||||
// firstNonEmpty 在 a 非空时返回 a,否则返回 b。
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
@@ -370,9 +370,9 @@ func firstNonEmpty(a, b string) string {
|
||||
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.
|
||||
// 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 {
|
||||
@@ -400,8 +400,8 @@ func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string,
|
||||
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.
|
||||
// extractSTTText 从 DashScope ASR 响应中取出转写。ASR
|
||||
// 的输出形状随模型而异,因此按多个候选路径依次尝试。
|
||||
func extractSTTText(body []byte) (string, error) {
|
||||
var r dashscopeSTTResponse
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
@@ -426,7 +426,7 @@ func extractSTTText(body []byte) (string, error) {
|
||||
return r.Text, nil
|
||||
}
|
||||
|
||||
// mimeOf maps an audio format to its MIME type for the data: URI / Content-Type.
|
||||
// mimeOf 把音频格式映射为其 MIME 类型,供 data: URI / Content-Type 使用。
|
||||
func mimeOf(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "mp3", "mpeg":
|
||||
@@ -440,7 +440,7 @@ func mimeOf(format string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// dashscopeSTTResponse is the DashScope ASR response envelope.
|
||||
// dashscopeSTTResponse 是 DashScope ASR 响应 envelope。
|
||||
type dashscopeSTTResponse struct {
|
||||
Output struct {
|
||||
Output struct {
|
||||
@@ -461,7 +461,7 @@ type dashscopeSTTResponse struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// dashscopeTTSResponse is the DashScope TTS response envelope.
|
||||
// dashscopeTTSResponse 是 DashScope TTS 响应 envelope。
|
||||
type dashscopeTTSResponse struct {
|
||||
Output struct {
|
||||
Audio struct {
|
||||
@@ -471,66 +471,66 @@ type dashscopeTTSResponse struct {
|
||||
} `json:"output"`
|
||||
}
|
||||
|
||||
// dashscopeSTTRequest is the ASR request body.
|
||||
// dashscopeSTTRequest 是 ASR 请求体。
|
||||
type dashscopeSTTRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashscopeSTTInput `json:"input"`
|
||||
Parameters dashscopeSTTParameters `json:"parameters"`
|
||||
}
|
||||
|
||||
// dashscopeSTTInput carries the multimodal messages list.
|
||||
// dashscopeSTTInput 承载多模态消息列表。
|
||||
type dashscopeSTTInput struct {
|
||||
Messages []dashscopeSTTMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// dashscopeSTTMessage is one ASR user turn.
|
||||
// dashscopeSTTMessage 是一个 ASR 用户回合。
|
||||
type dashscopeSTTMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content []dashscopeSTTContent `json:"content"`
|
||||
}
|
||||
|
||||
// dashscopeSTTContent is one audio content block.
|
||||
// dashscopeSTTContent 是一个音频内容块。
|
||||
type dashscopeSTTContent struct {
|
||||
Type string `json:"type"`
|
||||
InputAudio dashscopeInputAudio `json:"input_audio"`
|
||||
}
|
||||
|
||||
// dashscopeInputAudio carries the base64 data: URI audio.
|
||||
// dashscopeInputAudio 承载 base64 data: URI 音频。
|
||||
type dashscopeInputAudio struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// dashscopeSTTParameters tunes the ASR request.
|
||||
// dashscopeSTTParameters 调整 ASR 请求。
|
||||
type dashscopeSTTParameters struct {
|
||||
Format string `json:"format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
LanguageHints []string `json:"language_hints,omitempty"`
|
||||
}
|
||||
|
||||
// dashscopeTTSRequest is the TTS request body.
|
||||
// dashscopeTTSRequest 是 TTS 请求体。
|
||||
type dashscopeTTSRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashscopeTTSInput `json:"input"`
|
||||
}
|
||||
|
||||
// dashscopeTTSInput carries text + voice + output format.
|
||||
// dashscopeTTSInput 承载文字 + 音色 + 输出格式。
|
||||
type dashscopeTTSInput struct {
|
||||
Text string `json:"text"`
|
||||
Voice string `json:"voice"`
|
||||
Format string `json:"format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
// Instruction is the CosyVoice emotion/tone instruction; omitempty keeps a
|
||||
// system voice (which must use a fixed instruction) free of a free-form one.
|
||||
// Instruction 是 CosyVoice 的情绪/语气 instruction;omitempty 使
|
||||
// 系统音色(必须使用固定 instruction)不携带自由形式的 instruction。
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
}
|
||||
|
||||
// dashscopeCloneRequest is the voice-enrollment create_voice request body.
|
||||
// dashscopeCloneRequest 是音色注册 create_voice 请求体。
|
||||
type dashscopeCloneRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashscopeCloneInput `json:"input"`
|
||||
}
|
||||
|
||||
// dashscopeCloneInput carries the create_voice action parameters.
|
||||
// dashscopeCloneInput 承载 create_voice 动作参数。
|
||||
type dashscopeCloneInput struct {
|
||||
Action string `json:"action"`
|
||||
TargetModel string `json:"target_model"`
|
||||
@@ -538,8 +538,8 @@ type dashscopeCloneInput struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// dashscopeCloneResponse is the voice-enrollment response envelope. A top-level
|
||||
// message marks a model-level error surfaced even on an HTTP 200.
|
||||
// dashscopeCloneResponse 是音色注册响应 envelope。顶层
|
||||
// message 表示模型级错误——即使 HTTP 200 也会暴露。
|
||||
type dashscopeCloneResponse struct {
|
||||
Output struct {
|
||||
VoiceID string `json:"voice_id"`
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestDashscopeLive verifies the real Alibaba Bailian STT + TTS end to end. It is
|
||||
// excluded from the default suite (build tag dashscope_live) so `go test ./...`
|
||||
// never hits the paid API. Run it explicitly with the key sourced from .env:
|
||||
// TestDashscopeLive 端到端验证真实的阿里百炼 STT + TTS。它
|
||||
// 被排除在默认测试集之外(构建标签 dashscope_live),使 `go test ./...`
|
||||
// 绝不触碰付费 API。用从 .env 取到的 key 显式运行:
|
||||
//
|
||||
// set -a; source .env; set +a
|
||||
// go test -tags dashscope_live -run TestDashscopeLive -v ./internal/runtime/multimodal/voice/
|
||||
@@ -25,7 +25,7 @@ func TestDashscopeLive(t *testing.T) {
|
||||
}
|
||||
cfg := config.VoiceConfig{DashscopeAPIKey: key}
|
||||
|
||||
// TTS: synthesize speech for a short line.
|
||||
// TTS:为一句短文本合成语音。
|
||||
tts := NewDashscopeTTS(cfg)
|
||||
audio, err := tts.TTS(context.Background(), "你好,我是昔涟")
|
||||
if err != nil {
|
||||
@@ -36,7 +36,7 @@ func TestDashscopeLive(t *testing.T) {
|
||||
t.Fatal("TTS returned empty audio")
|
||||
}
|
||||
|
||||
// STT closed loop: transcribe the synthesized speech back to text.
|
||||
// STT 闭环:把合成的语音转写回文字。
|
||||
stt := NewDashscopeSTT(cfg)
|
||||
text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: audio.Data})
|
||||
if err != nil {
|
||||
@@ -44,7 +44,7 @@ func TestDashscopeLive(t *testing.T) {
|
||||
}
|
||||
t.Logf("STT (of TTS audio): text=%q", text)
|
||||
|
||||
// STT on a generated silence wav, to confirm the empty-audio path is accepted.
|
||||
// 对生成的静音 wav 做 STT,确认空音频路径可被接受。
|
||||
silence := silentWAVBytes(1, 16000)
|
||||
sttText, sttErr := stt.STT(context.Background(), Audio{Format: "wav", Data: silence})
|
||||
if sttErr != nil {
|
||||
@@ -53,8 +53,8 @@ func TestDashscopeLive(t *testing.T) {
|
||||
t.Logf("STT (silence): text=%q", sttText)
|
||||
}
|
||||
|
||||
// Exercise the degrading chain (the same object the gateway wires), proving it
|
||||
// returns real bytes/text rather than degrading to the silent terminal rung.
|
||||
// 走一遍降级链(网关接线的就是同一个对象),证明它
|
||||
// 返回真实的字节/文字,而不是降级到静默末端一级。
|
||||
ch := New(cfg)
|
||||
chTTS, chTTSErr := ch.TTS(context.Background(), "你好,我是昔涟")
|
||||
if chTTSErr != nil {
|
||||
@@ -74,21 +74,21 @@ func TestDashscopeLive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// silentWAVBytes builds a mono 16-bit 16 kHz silence WAV of durS seconds.
|
||||
// silentWAVBytes 构造 durS 秒的单声道 16 位 16 kHz 静音 WAV。
|
||||
func silentWAVBytes(durS, sampleRate int) []byte {
|
||||
dataLen := durS * sampleRate * 2
|
||||
buf := &bytes.Buffer{}
|
||||
// RIFF header + fmt chunk + data chunk.
|
||||
// RIFF 头 + fmt 块 + data 块。
|
||||
buf.WriteString("RIFF")
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(36+dataLen))
|
||||
buf.WriteString("WAVEfmt ")
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(16))
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(1)) // mono
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(1)) // 单声道
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate))
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate*2))
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(2)) // block align
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(16)) // bits per sample
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(2)) // 块对齐
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint16(16)) // 每样本位数
|
||||
buf.WriteString("data")
|
||||
_ = binary.Write(buf, binary.LittleEndian, uint32(dataLen))
|
||||
buf.Write(make([]byte, dataLen))
|
||||
|
||||
@@ -2,17 +2,17 @@ package voice
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors returned by the voice package. Each is wrapped with %w so
|
||||
// callers can test for a category while keeping the cause.
|
||||
// voice 包返回的哨兵错误。每个错误都以 %w 包装,
|
||||
// 使调用方能按类别判断,同时保留原始原因。
|
||||
var (
|
||||
// ErrProviderFailed is returned when every provider in a chain fails.
|
||||
// ErrProviderFailed 在一条链上所有提供方都失败时返回。
|
||||
ErrProviderFailed = errors.New("voice: all providers failed")
|
||||
// ErrNotConfigured is returned when a provider has no endpoint/setting.
|
||||
// ErrNotConfigured 在提供方缺少 endpoint/配置项时返回。
|
||||
ErrNotConfigured = errors.New("voice: provider not configured")
|
||||
// ErrNotImplemented is returned by an adaptation-point provider.
|
||||
// ErrNotImplemented 由适配点提供方返回。
|
||||
ErrNotImplemented = errors.New("voice: provider not implemented")
|
||||
// ErrEmptyText is returned when TTS receives empty text.
|
||||
// ErrEmptyText 在 TTS 收到空文本时返回。
|
||||
ErrEmptyText = errors.New("voice: empty text")
|
||||
// ErrClosed is returned when a service call arrives after Close.
|
||||
// ErrClosed 在 Close 之后仍有服务调用到达时返回。
|
||||
ErrClosed = errors.New("voice: service closed")
|
||||
)
|
||||
@@ -2,76 +2,76 @@ package voice
|
||||
|
||||
import "context"
|
||||
|
||||
// Provider is one rung in the voice degradation chain. It may implement zero,
|
||||
// one, or both directions; a nil return means that direction is not available
|
||||
// on this rung and the chain moves to the next.
|
||||
// Provider 是语音降级链上的一级。它可实现零个、一个
|
||||
// 或两个方向;返回 nil 表示该方向在此级上不可用,
|
||||
// 链会移向下一级。
|
||||
type Provider interface {
|
||||
// Name returns a stable provider identifier.
|
||||
// Name 返回稳定的提供方标识。
|
||||
Name() string
|
||||
// STT returns the speech-to-text implementation, or nil when the provider
|
||||
// cannot transcribe.
|
||||
// STT 返回语音转文字实现,提供方无法转写时
|
||||
// 返回 nil。
|
||||
STT() STT
|
||||
// TTS returns the text-to-speech implementation, or nil when the provider
|
||||
// cannot synthesize.
|
||||
// TTS 返回文字转语音实现,提供方无法合成时
|
||||
// 返回 nil。
|
||||
TTS() TTS
|
||||
}
|
||||
|
||||
// StaticProvider is a Provider with a fixed name whose STT/TTS are set at
|
||||
// construction. It is used by the concrete providers below.
|
||||
// StaticProvider 是名字固定、STT/TTS 在构造时设定的
|
||||
// Provider。下面各具体提供方都用它。
|
||||
type StaticProvider struct {
|
||||
name string
|
||||
stt STT
|
||||
tts TTS
|
||||
}
|
||||
|
||||
// Name returns the provider identifier.
|
||||
// Name 返回提供方标识。
|
||||
func (p *StaticProvider) Name() string { return p.name }
|
||||
|
||||
// STT returns the configured STT, or nil.
|
||||
// STT 返回配置的 STT,可能为 nil。
|
||||
func (p *StaticProvider) STT() STT { return p.stt }
|
||||
|
||||
// TTS returns the configured TTS, or nil.
|
||||
// TTS 返回配置的 TTS,可能为 nil。
|
||||
func (p *StaticProvider) TTS() TTS { return p.tts }
|
||||
|
||||
// NewProvider builds a Provider with the given name and optional directions it
|
||||
// supports.
|
||||
// NewProvider 用给定名字及其支持的可选方向构造一个
|
||||
// Provider。
|
||||
func NewProvider(name string, stt STT, tts TTS) *StaticProvider {
|
||||
return &StaticProvider{name: name, stt: stt, tts: tts}
|
||||
}
|
||||
|
||||
// CloudProvider is the cloud rung of the chain. It is an adaptation point: the
|
||||
// real STT/TTS implementations are wired in via NewProvider with cloud adapters.
|
||||
// CloudProvider 是链上的云端一级。它是一个适配点:
|
||||
// 真实的 STT/TTS 实现经 NewProvider 与云端适配器接入。
|
||||
func CloudProvider(stt STT, tts TTS) *StaticProvider {
|
||||
return NewProvider("cloud", stt, tts)
|
||||
}
|
||||
|
||||
// LocalProvider is the local-model rung of the chain.
|
||||
// LocalProvider 是链上的本地模型一级。
|
||||
func LocalProvider(stt STT, tts TTS) *StaticProvider {
|
||||
return NewProvider("local", stt, tts)
|
||||
}
|
||||
|
||||
// EdgeTTSProvider is the edge-tts rung (text-to-speech only).
|
||||
// EdgeTTSProvider 是 edge-tts 一级(仅文字转语音)。
|
||||
func EdgeTTSProvider(tts TTS) *StaticProvider {
|
||||
return NewProvider("edge-tts", nil, tts)
|
||||
}
|
||||
|
||||
// EspeakProvider is the espeak rung (text-to-speech only).
|
||||
// EspeakProvider 是 espeak 一级(仅文字转语音)。
|
||||
func EspeakProvider(tts TTS) *StaticProvider {
|
||||
return NewProvider("espeak", nil, tts)
|
||||
}
|
||||
|
||||
// SilentProvider is the terminal rung. It always succeeds, returning an empty
|
||||
// transcription and empty audio, so a chain that includes it never errors.
|
||||
// SilentProvider 是末端一级。它总是成功,返回空
|
||||
// 转写与空音频,因此包含它的链永不报错。
|
||||
func SilentProvider() *StaticProvider {
|
||||
return NewProvider("silent", silenceSTT{}, silenceTTS{})
|
||||
}
|
||||
|
||||
// silenceSTT is the silent STT rung; it returns an empty transcription.
|
||||
// silenceSTT 是静默 STT 一级;它返回空转写。
|
||||
type silenceSTT struct{}
|
||||
|
||||
func (silenceSTT) STT(_ context.Context, _ Audio) (string, error) { return "", nil }
|
||||
|
||||
// silenceTTS is the silent TTS rung; it returns empty audio.
|
||||
// silenceTTS 是静默 TTS 一级;它返回空音频。
|
||||
type silenceTTS struct{}
|
||||
|
||||
func (silenceTTS) TTS(_ context.Context, _ string) (Audio, error) {
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Service runs STT/TTS requests on a background worker pool so caller
|
||||
// goroutines (the agent loop) are never blocked by provider latency. Each
|
||||
// request is enqueued on a jobs channel and answered on a per-job result
|
||||
// channel; a canceled caller context or a closed service unblocks the caller
|
||||
// immediately and no worker goroutine is leaked. It is safe for concurrent use.
|
||||
// Service 在后台工作池上运行 STT/TTS 请求,使调用方
|
||||
// goroutine(智能体循环)绝不会被提供方延迟阻塞。每个
|
||||
// 请求入队到 jobs channel,并在每个 job 自己的结果
|
||||
// channel 上应答;调用方 context 被取消或服务已关闭时,
|
||||
// 调用方立刻解除阻塞,且不泄漏任何 worker goroutine。它可安全并发使用。
|
||||
type Service struct {
|
||||
chain *Chain
|
||||
jobs chan job
|
||||
@@ -22,11 +22,11 @@ type Service struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
// ServiceOption customizes a Service.
|
||||
// ServiceOption 定制 Service。
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithWorkers sets the worker-pool size. Values <= 0 are ignored and the
|
||||
// default single worker is kept.
|
||||
// WithWorkers 设置工作池大小。小于等于 0 的值被忽略,
|
||||
// 保留默认的单 worker。
|
||||
func WithWorkers(n int) ServiceOption {
|
||||
return func(s *Service) {
|
||||
if n > 0 {
|
||||
@@ -35,7 +35,7 @@ func WithWorkers(n int) ServiceOption {
|
||||
}
|
||||
}
|
||||
|
||||
// jobKind selects the operation a worker performs.
|
||||
// jobKind 选择 worker 执行的操作。
|
||||
type jobKind int
|
||||
|
||||
const (
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
jobTTS
|
||||
)
|
||||
|
||||
// job is one request enqueued by the caller.
|
||||
// job 是调用方入队的一个请求。
|
||||
type job struct {
|
||||
kind jobKind
|
||||
audio Audio
|
||||
@@ -51,7 +51,7 @@ type job struct {
|
||||
res chan jobResult
|
||||
}
|
||||
|
||||
// jobResult carries the outcome written back to the caller.
|
||||
// jobResult 承载写回调用方的结果。
|
||||
type jobResult struct {
|
||||
text string
|
||||
audio Audio
|
||||
@@ -59,7 +59,7 @@ type jobResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// NewService builds a Service backed by chain and starts the worker pool.
|
||||
// NewService 构造一个由 chain 支撑的 Service,并启动工作池。
|
||||
func NewService(chain *Chain, opts ...ServiceOption) *Service {
|
||||
bg, cancel := context.WithCancel(context.Background())
|
||||
s := &Service{
|
||||
@@ -79,7 +79,7 @@ func NewService(chain *Chain, opts ...ServiceOption) *Service {
|
||||
return s
|
||||
}
|
||||
|
||||
// worker consumes jobs until the service closes.
|
||||
// worker 消费 job 直到服务关闭。
|
||||
func (s *Service) worker() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
@@ -92,10 +92,10 @@ func (s *Service) worker() {
|
||||
}
|
||||
}
|
||||
|
||||
// run executes a job and delivers the result. Providers run under the service's
|
||||
// background context so Close cancels in-flight work and no worker goroutine is
|
||||
// leaked; the result is written to a buffered channel so a caller that already
|
||||
// returned never blocks the worker.
|
||||
// run 执行一个 job 并投递结果。提供方在服务的
|
||||
// 后台 context 下运行,因此 Close 会取消在途工作,且不泄漏
|
||||
// worker goroutine;结果写入带缓冲的 channel,因此已
|
||||
// 返回的调用方绝不会阻塞该 worker。
|
||||
func (s *Service) run(j job) {
|
||||
res := jobResult{}
|
||||
switch j.kind {
|
||||
@@ -107,15 +107,15 @@ func (s *Service) run(j job) {
|
||||
j.res <- res
|
||||
}
|
||||
|
||||
// STT enqueues a transcription request and waits for the result. ctx cancels
|
||||
// the wait and the underlying provider call.
|
||||
// STT 入队一个转写请求并等待结果。ctx 会取消
|
||||
// 等待以及底层提供方调用。
|
||||
func (s *Service) STT(ctx context.Context, audio Audio) (string, error) {
|
||||
text, _, err := s.STTWithProvider(ctx, audio)
|
||||
return text, err
|
||||
}
|
||||
|
||||
// STTWithProvider is STT but also reports the provider rung that produced the
|
||||
// transcription.
|
||||
// STTWithProvider 即 STT,但额外报告产出该转写的
|
||||
// 提供方级别名。
|
||||
func (s *Service) STTWithProvider(ctx context.Context, audio Audio) (string, string, error) {
|
||||
res, err := s.submit(ctx, job{kind: jobSTT, audio: audio, res: make(chan jobResult, 1)})
|
||||
if err != nil {
|
||||
@@ -124,15 +124,15 @@ func (s *Service) STTWithProvider(ctx context.Context, audio Audio) (string, str
|
||||
return res.text, res.provider, res.err
|
||||
}
|
||||
|
||||
// TTS enqueues a synthesis request and waits for the result. ctx cancels the
|
||||
// wait and the underlying provider call.
|
||||
// TTS 入队一个合成请求并等待结果。ctx 会取消
|
||||
// 等待以及底层提供方调用。
|
||||
func (s *Service) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
audio, _, err := s.TTSWithProvider(ctx, text)
|
||||
return audio, err
|
||||
}
|
||||
|
||||
// TTSWithProvider is TTS but also reports the provider rung that produced the
|
||||
// audio.
|
||||
// TTSWithProvider 即 TTS,但额外报告产出该音频的
|
||||
// 提供方级别名。
|
||||
func (s *Service) TTSWithProvider(ctx context.Context, text string) (Audio, string, error) {
|
||||
res, err := s.submit(ctx, job{kind: jobTTS, text: text, res: make(chan jobResult, 1)})
|
||||
if err != nil {
|
||||
@@ -141,13 +141,13 @@ func (s *Service) TTSWithProvider(ctx context.Context, text string) (Audio, stri
|
||||
return res.audio, res.provider, res.err
|
||||
}
|
||||
|
||||
// CloneVoice creates a custom voice, delegating to the underlying chain. It is a
|
||||
// one-shot admin operation (not a worker-pool job), so it runs directly.
|
||||
// CloneVoice 创建自定义音色,交由底层链处理。它是一次性
|
||||
// 管理操作(不是工作池 job),因此直接运行。
|
||||
func (s *Service) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||||
return s.chain.CloneVoice(ctx, referenceURL, prefix)
|
||||
}
|
||||
|
||||
// submit enqueues j and blocks until the result or a cancellation.
|
||||
// submit 把 j 入队并阻塞,直到拿到结果或发生取消。
|
||||
func (s *Service) submit(ctx context.Context, j job) (jobResult, error) {
|
||||
select {
|
||||
case s.jobs <- j:
|
||||
@@ -166,9 +166,9 @@ func (s *Service) submit(ctx context.Context, j job) (jobResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close cancels the worker pool and waits for in-flight jobs to finish. It is
|
||||
// safe to call once; later calls are no-ops. In-flight provider calls are
|
||||
// bounded by each provider's own context timeout, so Close returns promptly.
|
||||
// Close 取消工作池并等待在途 job 结束。调用一次是
|
||||
// 安全的;后续调用为空操作。在途提供方调用由各提供方
|
||||
// 自己的 context 超时兜底,因此 Close 会迅速返回。
|
||||
func (s *Service) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestService_CloseStopsNewCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// blockingSTT blocks until its context is canceled, marking that it started.
|
||||
// blockingSTT 会阻塞到它的 context 被取消,同时标记它已启动。
|
||||
type blockingSTT struct {
|
||||
started chan struct{}
|
||||
once sync.Once
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
// Package voice implements asynchronous speech-to-text and text-to-speech for
|
||||
// the Cyrene runtime, following DevDocs/design/06-多模态.md §1.
|
||||
// Package voice 为 Cyrene 运行时实现异步语音转文字与文字转语音,
|
||||
// 遵循 DevDocs/design/06-多模态.md §1。
|
||||
//
|
||||
// Voice input (STT) is transcribed to text and fed into the same agent pipeline
|
||||
// as a chat message; the reply is synthesized (TTS) back to audio. Both
|
||||
// directions degrade through a provider chain — cloud → local → edge-tts/espeak
|
||||
// → silent — so an unavailable provider falls through to the next rung instead
|
||||
// of failing the whole turn. The stub default implementations are HTTP-based and
|
||||
// testable with a stdlib httptest server; real cloud/local providers remain
|
||||
// adaptation points for later milestones.
|
||||
// 语音输入(STT)被转写为文字,并作为一条 chat 消息进入同一条智能体
|
||||
// 流水线;回复则被合成(TTS)回音频。两个
|
||||
// 方向都经一条提供方链降级 —— cloud → local → edge-tts/espeak
|
||||
// → silent —— 因此某个提供方不可用时会落到下一级,而不是
|
||||
// 让整个 turn 失败。默认实现是 HTTP 桩实现,可用
|
||||
// 标准库 httptest 服务器测试;真实的云端/本地提供方仍作为
|
||||
// 后续里程碑的适配点。
|
||||
package voice
|
||||
|
||||
import "context"
|
||||
|
||||
// Audio is a piece of audio data plus its encoding.
|
||||
// Audio 是一段音频数据及其编码。
|
||||
type Audio struct {
|
||||
// Format is the encoding, e.g. "wav", "mp3", "ogg".
|
||||
// Format 是编码格式,例如 "wav"、"mp3"、"ogg"。
|
||||
Format string
|
||||
// Data is the raw audio bytes.
|
||||
// Data 是原始音频字节。
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// STT converts audio to text.
|
||||
// STT 把音频转换成文字。
|
||||
type STT interface {
|
||||
// STT transcribes audio and returns the spoken text. ctx controls
|
||||
// cancellation and must be honored.
|
||||
// STT 转写音频并返回说出的文字。ctx 控制
|
||||
// 取消,必须被遵守。
|
||||
STT(ctx context.Context, audio Audio) (string, error)
|
||||
}
|
||||
|
||||
// TTS synthesizes speech audio from text.
|
||||
// TTS 从文字合成语音音频。
|
||||
type TTS interface {
|
||||
// TTS synthesizes audio from text. ctx controls cancellation and must be
|
||||
// honored.
|
||||
// TTS 从文字合成音频。ctx 控制取消,必须
|
||||
// 被遵守。
|
||||
TTS(ctx context.Context, text string) (Audio, error)
|
||||
}
|
||||
|
||||
// VoiceCloner creates a custom voice (声音复刻) from a public reference audio URL.
|
||||
// A nil-cloner provider (no cloud rung configured) returns ErrNotConfigured.
|
||||
// VoiceCloner 从公开的参考音频 URL 创建自定义音色(声音复刻)。
|
||||
// 未配置 cloner 的提供方(云端一级未配置)返回 ErrNotConfigured。
|
||||
type VoiceCloner interface {
|
||||
// CloneVoice creates a custom voice and returns its voice_id. referenceURL is
|
||||
// a public audio URL; prefix is the voice-name prefix (≤10 alphanumeric chars).
|
||||
// CloneVoice 创建自定义音色并返回其 voice_id。referenceURL 是
|
||||
// 公开音频 URL;prefix 是音色名前缀(≤10 个字母数字字符)。
|
||||
CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error)
|
||||
}
|
||||
|
||||
// ctxKeyTTSInstruction is the context key for a per-request TTS instruction.
|
||||
// The TTS interface only carries (ctx, text), so an optional instruction is
|
||||
// threaded through ctx to reach the adapter without widening the interface.
|
||||
// ctxKeyTTSInstruction 是逐请求 TTS 指令的 context 键。
|
||||
// TTS 接口只携带 (ctx, text),因此可选指令经
|
||||
// ctx 透传以抵达适配器,而无需拓宽该接口。
|
||||
type ctxKeyTTSInstruction struct{}
|
||||
|
||||
// WithTTSInstruction returns ctx carrying an optional per-request TTS instruction
|
||||
// (emotion/tone) that overrides the configured VOICE_TTS_INSTRUCTION for one call.
|
||||
// WithTTSInstruction 返回携带一条逐请求 TTS 指令
|
||||
// (情绪/语气)的 ctx,该指令对这一次调用覆盖配置里的 VOICE_TTS_INSTRUCTION。
|
||||
func WithTTSInstruction(ctx context.Context, instruction string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyTTSInstruction{}, instruction)
|
||||
}
|
||||
|
||||
// TTSInstructionFromContext returns the per-request TTS instruction set by
|
||||
// WithTTSInstruction, or "" when none is carried.
|
||||
// TTSInstructionFromContext 返回由 WithTTSInstruction 设置的逐请求
|
||||
// TTS 指令,未携带时返回 ""。
|
||||
func TTSInstructionFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyTTSInstruction{}).(string); ok {
|
||||
return v
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestDashscopeCloner_Ok asserts the create_voice request body shape
|
||||
// (model/action/target_model/prefix/url) and the voice_id parsing.
|
||||
// TestDashscopeCloner_Ok 断言 create_voice 请求体的形状
|
||||
// (model/action/target_model/prefix/url)以及 voice_id 的解析。
|
||||
func TestDashscopeCloner_Ok(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
@@ -61,8 +61,8 @@ func TestDashscopeCloner_Ok(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeCloner_DefaultPrefix asserts the empty prefix falls back to the
|
||||
// configured VOICE_CLONE_PREFIX default ("cyrene").
|
||||
// TestDashscopeCloner_DefaultPrefix 断言空前缀回退到
|
||||
// 配置的 VOICE_CLONE_PREFIX 默认值("cyrene")。
|
||||
func TestDashscopeCloner_DefaultPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
@@ -83,8 +83,8 @@ func TestDashscopeCloner_DefaultPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeCloner_TargetModelOverride asserts target_model tracks the config
|
||||
// TTSModel (must match the synthesis model).
|
||||
// TestDashscopeCloner_TargetModelOverride 断言 target_model 跟随配置的
|
||||
// TTSModel(必须与合成模型一致)。
|
||||
func TestDashscopeCloner_TargetModelOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
@@ -105,7 +105,7 @@ func TestDashscopeCloner_TargetModelOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeCloner_NotConfigured asserts a missing API key degrades.
|
||||
// TestDashscopeCloner_NotConfigured 断言缺少 API key 时降级。
|
||||
func TestDashscopeCloner_NotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
cloner := NewDashscopeCloner(config.VoiceConfig{})
|
||||
@@ -114,7 +114,7 @@ func TestDashscopeCloner_NotConfigured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeCloner_EmptyURL asserts an empty reference URL is rejected.
|
||||
// TestDashscopeCloner_EmptyURL 断言空的参考 URL 被拒绝。
|
||||
func TestDashscopeCloner_EmptyURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
|
||||
@@ -123,8 +123,8 @@ func TestDashscopeCloner_EmptyURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeCloner_BadPrefix asserts a non-alphanumeric or >10-char prefix is
|
||||
// rejected before any request is sent.
|
||||
// TestDashscopeCloner_BadPrefix 断言非字母数字或超过 10 字符的前缀
|
||||
// 在发出任何请求之前就被拒绝。
|
||||
func TestDashscopeCloner_BadPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
|
||||
@@ -135,8 +135,8 @@ func TestDashscopeCloner_BadPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeCloner_ModelError asserts a top-level message in an HTTP 200
|
||||
// response is surfaced as an error (no voice_id).
|
||||
// TestDashscopeCloner_ModelError 断言 HTTP 200 响应中的顶层 message
|
||||
// 会被作为错误暴露出来(此时没有 voice_id)。
|
||||
func TestDashscopeCloner_ModelError(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -151,8 +151,8 @@ func TestDashscopeCloner_ModelError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeTTS_Instruction_ClonedVoice asserts a cloned voice carries the
|
||||
// configured instruction in the request input.
|
||||
// TestDashscopeTTS_Instruction_ClonedVoice 断言复刻音色会在请求 input 中
|
||||
// 携带配置的 instruction。
|
||||
func TestDashscopeTTS_Instruction_ClonedVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
@@ -182,8 +182,8 @@ func TestDashscopeTTS_Instruction_ClonedVoice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeTTS_NoInstruction_SystemVoice asserts a system voice (longxiaochun_v3)
|
||||
// never receives a free-form instruction even when one is configured.
|
||||
// TestDashscopeTTS_NoInstruction_SystemVoice 断言系统音色(longxiaochun_v3)
|
||||
// 即使配置了 instruction 也绝不会收到自由形式的 instruction。
|
||||
func TestDashscopeTTS_NoInstruction_SystemVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
@@ -210,8 +210,8 @@ func TestDashscopeTTS_NoInstruction_SystemVoice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeTTS_Instruction_ContextOverride asserts a per-request ctx
|
||||
// instruction wins over the configured one for a cloned voice.
|
||||
// TestDashscopeTTS_Instruction_ContextOverride 断言对复刻音色,逐请求的 ctx
|
||||
// instruction 优先于配置的 instruction。
|
||||
func TestDashscopeTTS_Instruction_ContextOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotBody map[string]any
|
||||
|
||||
Reference in new issue
Block a user