feat(voice): emotionTTS 接成一级发音人(UTF-8 请求体 + 失败回落既有链)
侦察结论(真机 + 上游源码):
- POST /api/analyze_emotion ⇒ {primary,complex,intensity};primary 闭集
实测 平/喜/惊/哀/怒/厌/惧/急,intensity ∈ Low/Medium/High。
- POST /api/synthesize 收 emo_vector(float[])+emo_alpha,但还要 ref_audio_filename
且只回 {"audio_url"}(服务端落盘),不适合做运行时直出。
- POST /v1/audio/speech 内部先 LLM 分析情绪 + 匹配参考音,再把情绪向量以
[EMO:{vector}|{alpha}]base64:... 注入 voice 交给 indexTTS2 ⇒ **情绪确实影响
语气,但由服务端从待念文本自推**,我方没有可映射的参数。
- 上游把下游载荷的 speed 硬编码 1.0(core/utils/llm_provider.py),客户端
speed 是空转 ⇒ 本切片不暴露 speed,也不暴露 emotion/tone(不造假开关)。
实现:
- internal/runtime/voice/tts:Client(唯一 HTTP 出口,json.Encoder 产出 UTF-8
请求体、超时/非 200/非音频/超限各自可分类)+ 字节嗅探庭裁 + Service(emotionTTS
在前、失败回落既有链,永不上抛成回复失败)+ Provider 适配(可作链的一级)。
- internal/config/runtimeconfig/voicetts.go:$DATA_DIR/config/voice-tts.json
(enabled/baseUrl/character/format/timeoutSec),零新 .env 键。
- cmd/cyrene/wire_voice_tts.go + wireFrontend 的唯一注入点:两条既有出站口
(POST /api/v1/audio/speech 与 WS voice_output)同时用上;不新建端点/帧族。
- 严守 voice 入站闭集(不扩 Source/Status),出站音频不挂 voice DTO(塔裁决 B)。
测试:中文 UTF-8 请求体、非 200、超时、非音频、超限、未启用、空文本、嗅探表、
降级回落、未启用透传、两条出站口的字节级实测、面板配置解析、组合根映射;
三条变异(去中文 / 嗅探恒真 / 去掉回落)各自打红。真机 live 用例(build tag
emotiontts_live)实测 278,036 字节 WAV。
This commit is contained in:
1 parent
a895754d55
commit
3a2711a7e0
15 files changed
+1672
-1
No files matched your search
@@ -0,0 +1,65 @@
|
||||
package tts
|
||||
|
||||
// 本文件是**音频字节嗅探**:判断上游回给我们的到底是不是音频,以及它到底是什么
|
||||
// 格式。
|
||||
//
|
||||
// 两条口径(与本仓「字节嗅探是唯一裁决权」同源,design/24 §10.1/§10.11):
|
||||
// - **不信声明**:上游的 `Content-Type` 与我自己发出去的 `response_format` 都不
|
||||
// 是判据 —— 服务端产物恒为 WAV,但会按 `response_format` 自报 media_type
|
||||
// (`tts_router.py` 的 `FileResponse`),照抄它就会把 WAV 标成 mp3。
|
||||
// - **不是音频就报错**:上游 200 也可能是一段 JSON/HTML(配置错、反代错误页),
|
||||
// 把它当音频落盘/下发等于给客户端一个打不开的文件。判不出来就 **ErrNotAudio**,
|
||||
// 由调用方回落既有链 —— 绝不"拿到字节就算成功"。
|
||||
|
||||
// sniffAudio 按魔数判断 b 是不是可下发的音频,返回格式名与是否认出。
|
||||
//
|
||||
// 认得的容器覆盖本仓音频白名单里最常见的那几种(wav / mp3 / ogg / flac /
|
||||
// m4a(mp4) / webm / amr);认不出返回 ("", false)。
|
||||
func sniffAudio(b []byte) (string, bool) {
|
||||
switch {
|
||||
case hasPrefix(b, "RIFF") && len(b) >= 12 && string(b[8:12]) == "WAVE":
|
||||
return "wav", true
|
||||
case hasPrefix(b, "ID3"):
|
||||
return "mp3", true
|
||||
case isMP3Frame(b):
|
||||
return "mp3", true
|
||||
case hasPrefix(b, "OggS"):
|
||||
return "ogg", true
|
||||
case hasPrefix(b, "fLaC"):
|
||||
return "flac", true
|
||||
case len(b) >= 12 && string(b[4:8]) == "ftyp":
|
||||
return "m4a", true
|
||||
case hasPrefix(b, "\x1a\x45\xdf\xa3"):
|
||||
return "webm", true
|
||||
case hasPrefix(b, "#!AMR"):
|
||||
return "amr", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// isMP3Frame 识别无 ID3 头的裸 MPEG 音频帧同步字(11 位全 1 + 合法版本/层)。
|
||||
// 只做**粗判**:音频帧的头两字节 + 一个合法比特率/采样率索引即可,目的是把
|
||||
// "看起来像二进制"与"真的是 MP3"区分开,不追求完整解析。
|
||||
func isMP3Frame(b []byte) bool {
|
||||
if len(b) < 3 {
|
||||
return false
|
||||
}
|
||||
if b[0] != 0xFF || b[1]&0xE0 != 0xE0 {
|
||||
return false
|
||||
}
|
||||
// 版本位(b[1] 的 bit 3-4)不能是 01(保留值);层位(bit 1-2)不能是 00。
|
||||
if b[1]&0x18 == 0x08 || b[1]&0x06 == 0x00 {
|
||||
return false
|
||||
}
|
||||
// 比特率索引(b[2] 的 bit 4-7)不能是 1111(非法)。
|
||||
return b[2]>>4 != 0x0F
|
||||
}
|
||||
|
||||
// hasPrefix 报告 b 是否以 s 开头(s 为 ASCII 字符串字面量)。
|
||||
func hasPrefix(b []byte, s string) bool {
|
||||
if len(b) < len(s) {
|
||||
return false
|
||||
}
|
||||
return string(b[:len(s)]) == s
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tts
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSniffAudio 锁定字节嗅探的判据表:认得的容器各自给出正确的格式名,
|
||||
// 「不是音频」一律 ("" , false) —— 这一条是"绝不把 JSON/HTML 当音频"的地基。
|
||||
func TestSniffAudio(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
format string
|
||||
ok bool
|
||||
}{
|
||||
{"wav", wavBytes("x"), "wav", true},
|
||||
{"mp3-id3", append([]byte("ID3"), 0, 0, 0, 0, 0, 0), "mp3", true},
|
||||
{"mp3-frame", []byte{0xFF, 0xFB, 0x90, 0x00}, "mp3", true},
|
||||
{"ogg", []byte("OggS\x00\x02"), "ogg", true},
|
||||
{"flac", []byte("fLaC\x00\x00\x00\x22"), "flac", true},
|
||||
{"m4a", []byte{0, 0, 0, 0x20, 'f', 't', 'y', 'p', 'M', '4', 'A', ' '}, "m4a", true},
|
||||
{"webm", []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01}, "webm", true},
|
||||
{"amr", []byte("#!AMR\n"), "amr", true},
|
||||
{"json", []byte(`{"detail":"nope"}`), "", false},
|
||||
{"html", []byte("<html><body>502</body></html>"), "", false},
|
||||
{"empty", nil, "", false},
|
||||
{"riff-not-wave", append([]byte("RIFF"), []byte("\x00\x00\x00\x00AVI ")...), "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
format, ok := sniffAudio(c.data)
|
||||
if ok != c.ok || format != c.format {
|
||||
t.Fatalf("sniffAudio = (%q,%v), want (%q,%v)", format, ok, c.format, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package tts
|
||||
|
||||
// 本文件是 emotionTTS 的 **HTTP 实现**:一次合成 = 一个
|
||||
// `POST /v1/audio/speech`,回的是**原始音频字节**(不是 JSON 信封)。
|
||||
//
|
||||
// 🔴🔴 **请求体必须是 UTF-8 JSON 字节**(本包最重要的一条约束)
|
||||
//
|
||||
// 该服务用 FastAPI/Pydantic 解 `application/json`;请求体里若出现**非 UTF-8** 的
|
||||
// 中文字节,它会直接回
|
||||
// `400 {"detail":"There was an error parsing the body"}`(塔已实测:Git Bash 内联
|
||||
// 中文走 GBK 送过去就是这个错)。因此本文件**只**用 `encoding/json` 编码请求体
|
||||
// (`json.Encoder` 天然产出 UTF-8 字节,且关掉 HTML 转义以免正文被改写)。
|
||||
//
|
||||
// 以下写法**一律禁止**,改一处就会把中文打回 400:
|
||||
// - `fmt.Sprintf` / 字符串拼接拼 JSON 体;
|
||||
// - 把正文按 GBK/GB18030 转码后再发;
|
||||
// - 用 `text/template` 之类产出请求体。
|
||||
//
|
||||
// `client_test.go` 里有一条**中文文本**的 httptest 用例专门钉这条:它既断言请求体
|
||||
// 能被 UTF-8 解码、正文逐字相等,也断言字节里能直接搜到那段中文的 UTF-8 编码。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// 本包的哨兵错误。每个都以 %w 包装,调用方既能按类别判断,也拿得到原始原因。
|
||||
var (
|
||||
// ErrDisabled 报告这一级**未启用**(面板开关关着)。它不是故障:调用方应保持
|
||||
// 既有链路不变,而不是把它当成"合成失败"去告警。
|
||||
ErrDisabled = errors.New("voice/tts: emotionTTS provider is disabled")
|
||||
// ErrEmptyText 报告要合成的文本为空(没有声音可合成,不必打网络)。
|
||||
ErrEmptyText = errors.New("voice/tts: empty text")
|
||||
// ErrUnreachable 报告**没拿到响应**:连接失败、超时、被取消。它与"上游拒了"
|
||||
// 刻意分开 —— 前者是"它没听见",后者是"它听见了但不同意",排查路径不同。
|
||||
ErrUnreachable = errors.New("voice/tts: emotionTTS unreachable")
|
||||
// ErrUpstream 报告上游回了**非 200**(4xx/5xx)。错误里带状态码,但**不带正文**
|
||||
// (正文是她的回复,日志与错误链里都不该出现)。
|
||||
ErrUpstream = errors.New("voice/tts: emotionTTS rejected the request")
|
||||
// ErrNotAudio 报告上游回了 200,但字节**认不出是音频**(多为 JSON/HTML 错误页)。
|
||||
// 它是"拿到的不是我们要的东西",绝不能当成功落盘或下发。
|
||||
ErrNotAudio = errors.New("voice/tts: upstream response is not audio")
|
||||
// ErrTooLarge 报告响应超过体积上限(见 defaultMaxAudioBytes)。
|
||||
ErrTooLarge = errors.New("voice/tts: upstream response too large")
|
||||
)
|
||||
|
||||
// speechPayload 是 `/v1/audio/speech` 的请求体(OpenAITTSRequest 的子集,
|
||||
// 字段名逐字照上游 openapi.json)。
|
||||
//
|
||||
// 🔴 **没有 `speed`**:上游把下游载荷的 speed 硬编码成 1.0
|
||||
// (`core/utils/llm_provider.py` 的 `synthesize_audio`),传了也是空转,因此不暴露
|
||||
// 假开关(见包注释)。也没有情绪/语气字段 —— 上游自己从 `input` 推。
|
||||
type speechPayload struct {
|
||||
// Model 是固定模型名(上游只认 emotionTTS)。
|
||||
Model string `json:"model"`
|
||||
// Input 是要合成的文本(UTF-8;见文件头的红线)。
|
||||
Input string `json:"input"`
|
||||
// Voice 是角色 id 或名字(`char_84fb88a3` / `Cyrene` 都认)。
|
||||
Voice string `json:"voice"`
|
||||
// ResponseFormat 是请求格式;产物恒为 WAV,真实格式按字节嗅探。
|
||||
ResponseFormat string `json:"response_format"`
|
||||
}
|
||||
|
||||
// Stats 是一次累计的合成计数(进程内单调递增,用于面板/日志观测"降级了多少次")。
|
||||
type Stats struct {
|
||||
// Attempts 是发起过的合成次数。
|
||||
Attempts int64
|
||||
// Failures 是失败次数(任何非 nil 错误的合成)。
|
||||
Failures int64
|
||||
}
|
||||
|
||||
// Client 是 emotionTTS 的合成客户端。零值不可用,请用 New 构造。
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
log *slog.Logger
|
||||
maxLen int64
|
||||
|
||||
attempts atomic.Int64
|
||||
failures atomic.Int64
|
||||
}
|
||||
|
||||
// Option 定制 Client 的 HTTP 客户端、日志器与响应体积上限(测试用)。
|
||||
type Option func(*Client)
|
||||
|
||||
// WithLogger 注入带来源的日志器(既有 WARN 都带上它)。
|
||||
func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } }
|
||||
|
||||
// WithHTTPClient 注入 HTTP 客户端(测试用短超时/自定义 Transport)。
|
||||
// 注入后 `Config.Timeout` 不再生效 —— 超时由注入的客户端自己负责。
|
||||
func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.http = h } }
|
||||
|
||||
// WithMaxResponseBytes 覆盖响应体积上限(测试用)。
|
||||
func WithMaxResponseBytes(n int64) Option { return func(c *Client) { c.maxLen = n } }
|
||||
|
||||
// New 构造一个 emotionTTS 客户端。配置里的默认值由 `Config.Normalized` 补齐;
|
||||
// `Enabled=false` 时客户端仍然可用,但每次合成都会立刻回 `ErrDisabled`(这样
|
||||
// "关掉开关"与"它挂了"在调用方那里是两种可区分的情形)。
|
||||
func New(cfg Config, opts ...Option) *Client {
|
||||
c := &Client{log: slog.Default(), maxLen: defaultMaxAudioBytes}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
}
|
||||
c.cfg = cfg.Normalized(c.log)
|
||||
if c.http == nil {
|
||||
c.http = &http.Client{Timeout: c.cfg.Timeout}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Config 返回**归一化之后**的实际生效配置(面板/日志据此如实展示)。
|
||||
func (c *Client) Config() Config { return c.cfg }
|
||||
|
||||
// Enabled 报告这一级是否启用。
|
||||
func (c *Client) Enabled() bool { return c.cfg.Enabled }
|
||||
|
||||
// Character 返回生效角色(默认 `char_84fb88a3`)。
|
||||
func (c *Client) Character() string { return c.cfg.Character }
|
||||
|
||||
// RedactedBaseURL 返回**可安全打日志**的服务地址(只保留 scheme 与 host ——
|
||||
// 运维若在地址里塞了凭据,日志不该把它写出来)。
|
||||
func (c *Client) RedactedBaseURL() string { return redactURL(c.cfg.BaseURL) }
|
||||
|
||||
// Stats 返回当前计数快照。
|
||||
func (c *Client) Stats() Stats {
|
||||
return Stats{Attempts: c.attempts.Load(), Failures: c.failures.Load()}
|
||||
}
|
||||
|
||||
// Speech 把文本合成成音频。这是本包的**唯一网络出口**:`Service`(降级包装)与
|
||||
// 既有语音链的 `TTS` 形状都走它,绝不存在第二处 HTTP 调用。
|
||||
//
|
||||
// 返回值里的 `Format` 是**字节嗅探**所得,不是请求里那个 `response_format`。
|
||||
func (c *Client) Speech(ctx context.Context, text string) (Audio, error) {
|
||||
if !c.cfg.Enabled {
|
||||
return Audio{}, ErrDisabled
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return Audio{}, ErrEmptyText
|
||||
}
|
||||
c.attempts.Add(1)
|
||||
audi, err := c.speak(ctx, trimmed)
|
||||
if err != nil {
|
||||
c.failures.Add(1)
|
||||
}
|
||||
return audi, err
|
||||
}
|
||||
|
||||
// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。
|
||||
func (c *Client) speak(ctx context.Context, text string) (Audio, error) {
|
||||
body, err := encodeSpeech(text, c.cfg.Character, c.cfg.Format)
|
||||
if err != nil {
|
||||
return Audio{}, err
|
||||
}
|
||||
endpoint := c.cfg.BaseURL + speechPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Audio{}, fmt.Errorf("voice/tts: build request: %w", err)
|
||||
}
|
||||
// Content-Type 必须是 application/json:正文本身已是 UTF-8(见文件头)。
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "audio/*")
|
||||
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Audio{}, c.unreachable(ctx, endpoint, err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return Audio{}, c.upstreamStatus(res)
|
||||
}
|
||||
return c.readAudio(res, text)
|
||||
}
|
||||
|
||||
// encodeSpeech 把一次请求编码成 **UTF-8 JSON 字节**。
|
||||
//
|
||||
// 🔴 全仓仅此一处构造请求体:`json.Encoder` 天然产出 UTF-8,且这里显式关掉 HTML
|
||||
// 转义,使正文里的 `<`/`>`/`&` 不会被改写成 `\u003c` 之类(内容逐字不变,下游
|
||||
// 日志与音色匹配都依赖原文)。**绝不**改成字符串拼接或 GBK 转码(见文件头)。
|
||||
func encodeSpeech(text, character, format string) ([]byte, error) {
|
||||
payload := speechPayload{
|
||||
Model: modelName,
|
||||
Input: text,
|
||||
Voice: character,
|
||||
ResponseFormat: format,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("voice/tts: encode request: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// unreachable 把"没拿到响应"收敛成 ErrUnreachable(超时/取消/连接失败都归这里),
|
||||
// 并记一条带来源的 WARN。正文**不进日志**。
|
||||
func (c *Client) unreachable(ctx context.Context, endpoint string, err error) error {
|
||||
reason := "connection failed"
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
reason = "context canceled"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context canceled"
|
||||
}
|
||||
c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", err)
|
||||
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err)
|
||||
}
|
||||
|
||||
// upstreamStatus 把非 200 收敛成 ErrUpstream,并在日志里留下状态码与一小段
|
||||
// 响应片段(上游的错误体是它自己的 `{"detail":...}`;片段只用于排查,不含我方正文)。
|
||||
func (c *Client) upstreamStatus(res *http.Response) error {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(res.Body, 512))
|
||||
c.warn("voice tts rejected", "status", res.StatusCode, "detail", string(snippet))
|
||||
return fmt.Errorf("%w: status %d", ErrUpstream, res.StatusCode)
|
||||
}
|
||||
|
||||
// readAudio 读回字节并**按内容裁决**:认不出音频 ⇒ ErrNotAudio,超限 ⇒ ErrTooLarge。
|
||||
func (c *Client) readAudio(res *http.Response, text string) (Audio, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(res.Body, c.maxLen+1))
|
||||
if err != nil {
|
||||
c.warn("voice tts read body", "err", err)
|
||||
return Audio{}, fmt.Errorf("%w: read body: %w", ErrUnreachable, err)
|
||||
}
|
||||
if int64(len(raw)) > c.maxLen {
|
||||
c.warn("voice tts response too large", "limit", c.maxLen)
|
||||
return Audio{}, fmt.Errorf("%w: over %d bytes", ErrTooLarge, c.maxLen)
|
||||
}
|
||||
format, ok := sniffAudio(raw)
|
||||
if !ok {
|
||||
ctype := res.Header.Get("Content-Type")
|
||||
c.warn("voice tts response is not audio", "content_type", ctype, "bytes", len(raw), "text_len", len(text))
|
||||
return Audio{}, fmt.Errorf("%w: content-type %q, %d bytes", ErrNotAudio, ctype, len(raw))
|
||||
}
|
||||
return Audio{Format: format, Data: raw}, nil
|
||||
}
|
||||
|
||||
// warn 记一条 WARN(日志器未注入时用默认日志器,绝不因日志缺失而 panic)。
|
||||
func (c *Client) warn(msg string, args ...any) {
|
||||
if c.log == nil {
|
||||
return
|
||||
}
|
||||
c.log.Warn(msg, args...)
|
||||
}
|
||||
|
||||
// Audio 是一段合成音频及其**嗅探所得**格式。它与
|
||||
// `internal/runtime/multimodal/voice.Audio` 字段同形,由 `Service` 一处映射
|
||||
// —— 本包因此不依赖那条链的类型,谁都构不成环。
|
||||
type Audio struct {
|
||||
// Format 是字节嗅探所得的编码名(wav/mp3/ogg/flac/m4a/webm/amr)。
|
||||
Format string
|
||||
// Data 是原始音频字节。
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host,
|
||||
// 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。
|
||||
func redactURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package tts
|
||||
|
||||
// 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"):
|
||||
//
|
||||
// 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回
|
||||
// `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按
|
||||
// UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是
|
||||
// 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。
|
||||
// 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。
|
||||
// 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。
|
||||
// 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。
|
||||
// 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。
|
||||
const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!"
|
||||
|
||||
// wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。
|
||||
func wavBytes(payload string) []byte {
|
||||
b := make([]byte, 0, 44+len(payload))
|
||||
b = append(b, []byte("RIFF")...)
|
||||
b = append(b, 0, 0, 0, 0)
|
||||
b = append(b, []byte("WAVEfmt ")...)
|
||||
b = append(b, bytes.Repeat([]byte{0}, 16)...)
|
||||
b = append(b, []byte("data")...)
|
||||
b = append(b, 0, 0, 0, 0)
|
||||
return append(b, []byte(payload)...)
|
||||
}
|
||||
|
||||
// newTestClient 指向一个 httptest 服务;可变选项留给各用例。
|
||||
func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
base := []Option{WithLogger(discardLogger())}
|
||||
base = append(base, opts...)
|
||||
return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...)
|
||||
}
|
||||
|
||||
// TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8
|
||||
// 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。
|
||||
func TestSpeechSendsUTF8ChineseText(t *testing.T) {
|
||||
var gotPayload speechPayload
|
||||
var gotRaw []byte
|
||||
var gotCT string
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
gotRaw, _ = io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(gotRaw, &gotPayload); err != nil {
|
||||
t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
_, _ = w.Write(wavBytes("ok"))
|
||||
})
|
||||
|
||||
audio, err := client.Speech(context.Background(), chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("Speech: %v", err)
|
||||
}
|
||||
if gotCT != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", gotCT)
|
||||
}
|
||||
// ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。
|
||||
if gotPayload.Input != chineseSpeech {
|
||||
t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech)
|
||||
}
|
||||
// ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码,
|
||||
// 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。
|
||||
if !bytes.Contains(gotRaw, []byte(chineseSpeech)) {
|
||||
t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw))
|
||||
}
|
||||
if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) {
|
||||
t.Fatalf("请求体里找不到「你好」的 UTF-8 字节")
|
||||
}
|
||||
if gotPayload.Model != modelName {
|
||||
t.Errorf("model = %q, want %q", gotPayload.Model, modelName)
|
||||
}
|
||||
if gotPayload.Voice != DefaultCharacter {
|
||||
t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter)
|
||||
}
|
||||
if gotPayload.ResponseFormat != DefaultFormat {
|
||||
t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat)
|
||||
}
|
||||
// ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。
|
||||
if audio.Format != "wav" || len(audio.Data) == 0 {
|
||||
t.Fatalf("audio = %+v, want wav with bytes", audio)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。
|
||||
func TestSpeechHTMLEscapingDisabled(t *testing.T) {
|
||||
var gotRaw []byte
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotRaw, _ = io.ReadAll(r.Body)
|
||||
_, _ = w.Write(wavBytes("ok"))
|
||||
})
|
||||
if _, err := client.Speech(context.Background(), "a<b & c>d"); err != nil {
|
||||
t.Fatalf("Speech: %v", err)
|
||||
}
|
||||
if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) {
|
||||
t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。
|
||||
func TestSpeechRejectsNon200(t *testing.T) {
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`))
|
||||
})
|
||||
_, err := client.Speech(context.Background(), chineseSpeech)
|
||||
if !errors.Is(err, ErrUpstream) {
|
||||
t.Fatalf("err = %v, want ErrUpstream", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "400") {
|
||||
t.Errorf("错误里应带状态码: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "苏梦") {
|
||||
t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err)
|
||||
}
|
||||
if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 {
|
||||
t.Errorf("stats = %+v, want 1 attempt / 1 failure", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。
|
||||
func TestSpeechTimeout(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() { close(release) })
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
_, _ = w.Write(wavBytes("late"))
|
||||
}
|
||||
client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond}))
|
||||
start := time.Now()
|
||||
_, err := client.Speech(context.Background(), chineseSpeech)
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Fatalf("err = %v, want ErrUnreachable", err)
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("err = %v, want wraps context.DeadlineExceeded", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("超时没有生效(用了 %v)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。
|
||||
func TestSpeechRejectsNonAudio(t *testing.T) {
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`))
|
||||
})
|
||||
_, err := client.Speech(context.Background(), chineseSpeech)
|
||||
if !errors.Is(err, ErrNotAudio) {
|
||||
t.Fatalf("err = %v, want ErrNotAudio", err)
|
||||
}
|
||||
if got := client.Stats(); got.Failures != 1 {
|
||||
t.Errorf("stats = %+v, want 1 failure", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechRejectsOversizeResponse 钉住体积上限。
|
||||
func TestSpeechRejectsOversizeResponse(t *testing.T) {
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(wavBytes(strings.Repeat("x", 128)))
|
||||
}, WithMaxResponseBytes(32))
|
||||
_, err := client.Speech(context.Background(), chineseSpeech)
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("err = %v, want ErrTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。
|
||||
func TestSpeechGuards(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = w.Write(wavBytes("ok"))
|
||||
})
|
||||
|
||||
if _, err := client.Speech(context.Background(), " "); !errors.Is(err, ErrEmptyText) {
|
||||
t.Errorf("空文本 err = %v, want ErrEmptyText", err)
|
||||
}
|
||||
disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger()))
|
||||
if _, err := disabled.Speech(context.Background(), chineseSpeech); !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("未启用 err = %v, want ErrDisabled", err)
|
||||
}
|
||||
// 被拒的两次都不该打网络;未启用也不该被计成"失败"。
|
||||
if got := hits.Load(); got != 0 {
|
||||
t.Errorf("上游被调用 %d 次,want 0", got)
|
||||
}
|
||||
if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 {
|
||||
t.Errorf("未启用却计了数: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。
|
||||
func TestConfigNormalized(t *testing.T) {
|
||||
got := Config{}.Normalized(discardLogger())
|
||||
if got.Enabled {
|
||||
t.Errorf("零值配置不该被悄悄启用")
|
||||
}
|
||||
if got.BaseURL != DefaultBaseURL || got.Character != DefaultCharacter ||
|
||||
got.Format != DefaultFormat || got.Timeout != DefaultTimeout {
|
||||
t.Fatalf("默认值不对: %+v", got)
|
||||
}
|
||||
if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" {
|
||||
t.Errorf("归一化不对: %+v", custom)
|
||||
}
|
||||
if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat {
|
||||
t.Errorf("非法格式应回落默认: %+v", bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package tts 是**半云端 emotionTTS 服务**的运行时合成适配(切片 M35)。
|
||||
//
|
||||
// 它与父包 `internal/runtime/voice` 的分工(**别混**):父包管「一条入站语音的
|
||||
// 来源事实 + 可播副本」(design/24 §10);本包只回答一件事 ——「把这段文字交给
|
||||
// emotionTTS 换成音频」。它与 `internal/runtime/multimodal/voice` 里那条 STT/TTS
|
||||
// 降级链**不是**两套 HTTP:本包的 `Client` 满足那条链的 `TTS` 形状,`Service`
|
||||
// 则把 emotionTTS 摆在那条链**前面**(失败/未启用回落既有链)。
|
||||
//
|
||||
// # 为什么它比我方参数"更懂情绪"
|
||||
//
|
||||
// 该服务(v5.1.0)的 `POST /v1/audio/speech` **自己**做情绪:先让 LLM 分析待念
|
||||
// 文本的情绪、在角色素材库里挑一条情绪相符的参考音,再把情绪向量以
|
||||
// `[EMO:{vector}|{alpha}]base64:...` 的形式塞进 `voice` 字段交给 indexTTS2
|
||||
// (`core/routers/tts_router.py` 的 `openai_tts_api` + `core/utils/llm_provider.py`
|
||||
// 的 `synthesize_audio`)。**因此我方没有、也不需要情绪参数** —— 语气由要念的
|
||||
// 文本自己带(她调措辞就是调语气)。
|
||||
//
|
||||
// # 为什么没有 speed 旋钮
|
||||
//
|
||||
// 🔴 同一个 `synthesize_audio` 把下游载荷的 `speed` **硬编码成 1.0**(同一文件
|
||||
// 的 `data_json`),所以 OpenAI 兼容口的 `speed` 收下即丢。**给它做一个旋钮等于
|
||||
// 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`:
|
||||
// 那两样上游自己推,我方传什么都是噪音。
|
||||
//
|
||||
// # 一次只暴露三样
|
||||
//
|
||||
// 对调用方只暴露 `text`(必填)、角色(默认 `Cyrene` 的 `char_84fb88a3`)与
|
||||
// `format`(默认 wav)。其余全部是代码默认值或面板文件里的运维旋钮。
|
||||
package tts
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBaseURL 是该服务的默认地址(**只绑回环**、无鉴权:绝不是公网地址)。
|
||||
DefaultBaseURL = "http://127.0.0.1:9880"
|
||||
|
||||
// DefaultCharacter 是默认角色 —— 主人指定的「Cyrene」(`GET /api/characters`
|
||||
// 实测 id = `char_84fb88a3`)。
|
||||
DefaultCharacter = "char_84fb88a3"
|
||||
|
||||
// DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用
|
||||
// `FileResponse` 回发,`response_format` 只影响它自报的 media_type),因此默认
|
||||
// wav;实际下发的格式仍由**字节嗅探**裁决(见 audio.go),不信任这个字段。
|
||||
DefaultFormat = "wav"
|
||||
|
||||
// DefaultTimeout 是单次合成超时。实测一次中文合成约 4~6 秒(它内部还要先跑一次
|
||||
// LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并回落既有链。
|
||||
DefaultTimeout = 60 * time.Second
|
||||
|
||||
// defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存
|
||||
// (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。
|
||||
defaultMaxAudioBytes = 32 << 20
|
||||
|
||||
// speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。
|
||||
speechPath = "/v1/audio/speech"
|
||||
|
||||
// modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。
|
||||
modelName = "emotionTTS"
|
||||
)
|
||||
|
||||
// Config 是 emotionTTS 客户端的运行期旋钮(由面板文件
|
||||
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
|
||||
//
|
||||
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
|
||||
type Config struct {
|
||||
// Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链,
|
||||
// 而不是把"没启用"当成"合成失败")。
|
||||
Enabled bool
|
||||
// BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。
|
||||
BaseURL string
|
||||
// Character 是角色(服务端 `/api/characters` 的 id 或名字);空 = DefaultCharacter。
|
||||
Character string
|
||||
// Format 是请求格式(`response_format`);空 = DefaultFormat。只接受
|
||||
// wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。
|
||||
Format string
|
||||
// Timeout 是单次合成超时;<=0 = DefaultTimeout。
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// supportedFormats 是允许写进 Format 的取值(上游 `response_format` 只用于自报
|
||||
// media_type;产物恒为 WAV)。写别的值不是"更灵活",而是让下游拿到一个与字节
|
||||
// 不符的标签 —— 因此这里**拒绝**而不是静默放行。
|
||||
var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true}
|
||||
|
||||
// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并
|
||||
// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。
|
||||
func (c Config) Normalized(log *slog.Logger) Config {
|
||||
out := c
|
||||
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
|
||||
if out.BaseURL == "" {
|
||||
out.BaseURL = DefaultBaseURL
|
||||
}
|
||||
out.Character = strings.TrimSpace(out.Character)
|
||||
if out.Character == "" {
|
||||
out.Character = DefaultCharacter
|
||||
}
|
||||
out.Format = strings.ToLower(strings.TrimSpace(out.Format))
|
||||
if out.Format == "" {
|
||||
out.Format = DefaultFormat
|
||||
} else if !supportedFormats[out.Format] {
|
||||
if log != nil {
|
||||
log.Warn("unsupported tts format, fell back to default",
|
||||
"format", out.Format, "default", DefaultFormat)
|
||||
}
|
||||
out.Format = DefaultFormat
|
||||
}
|
||||
if out.Timeout <= 0 {
|
||||
out.Timeout = DefaultTimeout
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build emotiontts_live
|
||||
|
||||
package tts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...`
|
||||
// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。
|
||||
//
|
||||
// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖):
|
||||
//
|
||||
// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/
|
||||
func TestLiveSpeech(t *testing.T) {
|
||||
baseURL := os.Getenv("EMOTIONTTS_BASE_URL")
|
||||
client := New(Config{Enabled: true, BaseURL: baseURL, Timeout: 120 * time.Second})
|
||||
|
||||
start := time.Now()
|
||||
audio, err := client.Speech(context.Background(), chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("对真服务合成失败: %v", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
if audio.Format != "wav" {
|
||||
t.Errorf("format = %q, want wav(产物恒为 WAV)", audio.Format)
|
||||
}
|
||||
if len(audio.Data) == 0 {
|
||||
t.Fatal("拿到的音频为空")
|
||||
}
|
||||
if string(audio.Data[:4]) != "RIFF" || string(audio.Data[8:12]) != "WAVE" {
|
||||
t.Fatalf("不是 RIFF/WAVE: %q", string(audio.Data[:12]))
|
||||
}
|
||||
// 证据:真实字节数与耗时(塔要求的"实际字节")。
|
||||
t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s",
|
||||
client.Config().BaseURL, client.Character(), len(audio.Data), audio.Format, elapsed)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package tts
|
||||
|
||||
// 本文件把 `*Client` 变成一个**既有语音链的一级**(`mvoice.Provider`)。
|
||||
//
|
||||
// 当前组装走的是 `Service`(包在整条链前面,失败回落),不是把本级插进链里
|
||||
// (`multimodal/voice.New` 组装的各级列表不外露,插进去要先改那条链,超出本切片
|
||||
// 范围)。提供这个适配是为了让"作为一级"这条路**现在就是通的**:一旦组装方式
|
||||
// 改成显式列级(`voice.NewChain(...)`),本级可以原样进链,不必重写 HTTP。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// Provider 是 emotionTTS 作为降级链一级的适配:只提供 TTS,STT 方向返回 nil
|
||||
// (它不做转写;链遇到 nil 会自动跳过这一级去找下一个能转写的)。
|
||||
type Provider struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewProvider 用客户端构造一级。client 为 nil 时 Name 仍可用,TTS 返回错误。
|
||||
func NewProvider(client *Client) *Provider { return &Provider{client: client} }
|
||||
|
||||
// Name 返回稳定的级别名(降级信号里就显示这个)。
|
||||
func (p *Provider) Name() string { return ProviderName }
|
||||
|
||||
// STT 返回 nil:本一级不提供转写。
|
||||
func (p *Provider) STT() mvoice.STT { return nil }
|
||||
|
||||
// TTS 返回本一级的合成实现。
|
||||
func (p *Provider) TTS() mvoice.TTS { return ttsAdapter{p.client} }
|
||||
|
||||
// ttsAdapter 把 `Client.Speech` 适配成链要求的 `TTS` 形状(`Audio` 类型不同)。
|
||||
type ttsAdapter struct{ client *Client }
|
||||
|
||||
// TTS 合成音频;client 未接线时返回明确错误而不是 panic。
|
||||
func (a ttsAdapter) TTS(ctx context.Context, text string) (mvoice.Audio, error) {
|
||||
if a.client == nil {
|
||||
return mvoice.Audio{}, mvoice.ErrNotConfigured
|
||||
}
|
||||
audio, err := a.client.Speech(ctx, text)
|
||||
if err != nil {
|
||||
return mvoice.Audio{}, err
|
||||
}
|
||||
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package tts
|
||||
|
||||
// 本文件把 emotionTTS 摆到**既有语音链之前**,并在它失败时回落既有链。
|
||||
//
|
||||
// 为什么是"包一层"而不是"在链里插一级":既有链由 `internal/runtime/multimodal/voice`
|
||||
// 的 `New` 按 env 配置组装(cloud → local → edge-tts/espeak → silent),它的各级
|
||||
// 列表不外露;而 `.env` 已被主人冻结,**不能**为这条新能力加 env 键。因此本切片
|
||||
// 不改那条链的任何一行,只提供 `*Service` 这一层包装:合成优先走 emotionTTS,
|
||||
// 未启用/失败则原样委托既有链。
|
||||
//
|
||||
// 两条既有出站口(`POST /api/v1/audio/speech` 与 WS `voice_output`)都通过
|
||||
// 网关的 `SetVoice` 拿到同一个 `VoiceService`,因此**只要换这一个注入点**,两条口
|
||||
// 就都用上了它 —— 不新建端点、不加帧族(切片红线)。
|
||||
//
|
||||
// 🔴 **文本优先**:本层**永不**把 TTS 的失败上抛成"这条回复失败"。失败只做两件事:
|
||||
// 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算
|
||||
// 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// ProviderName 是本级在降级信号里的名字(既有链用 cloud/local/edge-tts/silent
|
||||
// 这类小写短名,此处沿用同一形态)。
|
||||
const ProviderName = "emotiontts"
|
||||
|
||||
// logSource 是本包日志的来源标签(AGENTS §10:日志必须标来源)。
|
||||
const logSource = "runtime/voice/tts"
|
||||
|
||||
// Chain 是**既有语音链的最小面**:本包只用到这五个方法,`*mvoice.Chain` 全部满足。
|
||||
// 用窄接口而不是直接依赖 `*mvoice.Chain`,是为了让测试能给一个假底链。
|
||||
type Chain interface {
|
||||
// STT 转写音频(emotionTTS 不做转写,原样委托)。
|
||||
STT(ctx context.Context, audio mvoice.Audio) (string, error)
|
||||
// TTS 合成音频(本层未启用/失败时走这里)。
|
||||
TTS(ctx context.Context, text string) (mvoice.Audio, error)
|
||||
// CloneVoice 声音复刻(原样委托)。
|
||||
CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error)
|
||||
// STTWithProvider 同 STT,但报告实际服务的那一级。
|
||||
STTWithProvider(ctx context.Context, audio mvoice.Audio) (string, string, error)
|
||||
// TTSWithProvider 同 TTS,但报告实际服务的那一级。
|
||||
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
|
||||
}
|
||||
|
||||
// Service 是 emotionTTS + 既有链的合成门面。它既满足网关的 `VoiceService`
|
||||
// (STT/TTS/CloneVoice),也满足其可选扩展 `voiceProviderReporter`
|
||||
// (STTWithProvider/TTSWithProvider),因此面板能看到"这次是哪一级出的声"。
|
||||
type Service struct {
|
||||
primary *Client
|
||||
base Chain
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// ServiceOption 定制 Service 的日志器。
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// WithServiceLogger 注入带来源的日志器;未注入时退回默认日志器。
|
||||
func WithServiceLogger(l *slog.Logger) ServiceOption {
|
||||
return func(s *Service) { s.log = l }
|
||||
}
|
||||
|
||||
// NewService 用 emotionTTS 客户端包住既有链。primary 为 nil 或未启用时,本层
|
||||
// **完全透传**既有链(行为与切片引入之前逐字相同);base 为 nil 时无从回落,
|
||||
// 合成会回一个明确的错误(不 panic)。
|
||||
func NewService(primary *Client, base Chain, opts ...ServiceOption) *Service {
|
||||
s := &Service{primary: primary, base: base, log: slog.Default()}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
if s.log == nil {
|
||||
s.log = slog.Default()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// PrimaryActive 报告 emotionTTS 这一级是否真的在生效(面板据此如实显示"已接")。
|
||||
func (s *Service) PrimaryActive() bool { return s != nil && s.primary != nil && s.primary.Enabled() }
|
||||
|
||||
// Character 返回生效角色(面板展示用)。
|
||||
func (s *Service) Character() string {
|
||||
if s == nil || s.primary == nil {
|
||||
return ""
|
||||
}
|
||||
return s.primary.Character()
|
||||
}
|
||||
|
||||
// Stats 返回 emotionTTS 这一级的计数(尝试/失败);未接线时为 0 值。
|
||||
func (s *Service) Stats() Stats {
|
||||
if s == nil || s.primary == nil {
|
||||
return Stats{}
|
||||
}
|
||||
return s.primary.Stats()
|
||||
}
|
||||
|
||||
// STT 原样委托既有链 —— emotionTTS 只做合成,不抢转写的活。
|
||||
func (s *Service) STT(ctx context.Context, audio mvoice.Audio) (string, error) {
|
||||
if s.base == nil {
|
||||
return "", mvoice.ErrProviderFailed
|
||||
}
|
||||
return s.base.STT(ctx, audio)
|
||||
}
|
||||
|
||||
// STTWithProvider 原样委托既有链(同时透传它报告的那一级)。
|
||||
func (s *Service) STTWithProvider(ctx context.Context, audio mvoice.Audio) (string, string, error) {
|
||||
if s.base == nil {
|
||||
return "", "", mvoice.ErrProviderFailed
|
||||
}
|
||||
return s.base.STTWithProvider(ctx, audio)
|
||||
}
|
||||
|
||||
// CloneVoice 原样委托既有链(声音复刻是云端那级的活)。
|
||||
func (s *Service) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||||
if s.base == nil {
|
||||
return "", mvoice.ErrProviderFailed
|
||||
}
|
||||
return s.base.CloneVoice(ctx, referenceURL, prefix)
|
||||
}
|
||||
|
||||
// TTS 合成音频(不报告级别名;网关的口径同既有链)。
|
||||
func (s *Service) TTS(ctx context.Context, text string) (mvoice.Audio, error) {
|
||||
audio, _, err := s.TTSWithProvider(ctx, text)
|
||||
return audio, err
|
||||
}
|
||||
|
||||
// TTSWithProvider 先试 emotionTTS,失败/未启用则回落既有链,并如实报告实际
|
||||
// 服务的那一级(`emotiontts` 或既有链的 cloud/local/...)。
|
||||
//
|
||||
// 三条纪律:
|
||||
// - **失败绝不上抛成回复失败**:先回落,回落也失败才把后者的错误交给调用方
|
||||
// (既有行为逐字不变);
|
||||
// - **WARN 带来源**:哪一级、什么原因,一条说清(正文绝不进日志,只有长度);
|
||||
// - **未启用不是故障**:只记 DEBUG,不刷 WARN。
|
||||
func (s *Service) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
if s.PrimaryActive() {
|
||||
audio, err := s.primary.Speech(ctx, text)
|
||||
if err == nil {
|
||||
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, ProviderName, nil
|
||||
}
|
||||
s.degrade(ctx, text, err)
|
||||
}
|
||||
if s.base == nil {
|
||||
return mvoice.Audio{}, "", mvoice.ErrProviderFailed
|
||||
}
|
||||
return s.base.TTSWithProvider(ctx, text)
|
||||
}
|
||||
|
||||
// degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN
|
||||
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
|
||||
func (s *Service) degrade(ctx context.Context, text string, err error) {
|
||||
if ctx.Err() != nil {
|
||||
// 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句
|
||||
// 「因此这次没声音」,避免同一条失败刷两条 WARN。
|
||||
s.log.Debug("voice tts skipped after cancel", "provider", ProviderName, "text_len", len(text))
|
||||
return
|
||||
}
|
||||
s.log.Warn("voice tts degraded to fallback chain",
|
||||
"provider", ProviderName, "text_len", len(text), "err", err)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package tts
|
||||
|
||||
// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的三条口径:
|
||||
//
|
||||
// 1. **健康时用 emotionTTS**,并如实报告级别名 `emotiontts`;
|
||||
// 2. **失败时回落既有链**,且**不把失败上抛**(文本优先:合成挂掉不影响回复);
|
||||
// 3. **未启用时完全透传**(行为与引入本切片之前逐字相同),STT/克隆永远走既有链。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// discardLogger 让测试不往 stderr 刷日志(WARN 的存在由行为断言,不靠肉眼)。
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// fakeChain 是一个可编程的底链替身,记录自己被调了几次。
|
||||
type fakeChain struct {
|
||||
ttsCalls int
|
||||
sttCalls int
|
||||
cloneCall int
|
||||
audio mvoice.Audio
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChain) STT(context.Context, mvoice.Audio) (string, error) {
|
||||
f.sttCalls++
|
||||
return "转写文本", nil
|
||||
}
|
||||
|
||||
func (f *fakeChain) STTWithProvider(context.Context, mvoice.Audio) (string, string, error) {
|
||||
f.sttCalls++
|
||||
return "转写文本", "local", nil
|
||||
}
|
||||
|
||||
func (f *fakeChain) TTS(context.Context, string) (mvoice.Audio, error) {
|
||||
f.ttsCalls++
|
||||
return f.audio, f.err
|
||||
}
|
||||
|
||||
func (f *fakeChain) TTSWithProvider(context.Context, string) (mvoice.Audio, string, error) {
|
||||
f.ttsCalls++
|
||||
return f.audio, "silent", f.err
|
||||
}
|
||||
|
||||
func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error) {
|
||||
f.cloneCall++
|
||||
return "voice-id", nil
|
||||
}
|
||||
|
||||
// TestServiceUsesPrimaryWhenHealthy 钉住主路:健康时走 emotionTTS 且报告级别名。
|
||||
func TestServiceUsesPrimaryWhenHealthy(t *testing.T) {
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(wavBytes("her voice"))
|
||||
})
|
||||
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
|
||||
svc := NewService(client, base, WithServiceLogger(discardLogger()))
|
||||
|
||||
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("TTSWithProvider: %v", err)
|
||||
}
|
||||
if rung != ProviderName {
|
||||
t.Errorf("rung = %q, want %q", rung, ProviderName)
|
||||
}
|
||||
if audio.Format != "wav" || string(audio.Data) == "fallback" {
|
||||
t.Errorf("audio = %+v, want the emotionTTS bytes", audio)
|
||||
}
|
||||
if base.ttsCalls != 0 {
|
||||
t.Errorf("主路健康却回落了底链 %d 次", base.ttsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceFallsBackWhenPrimaryFails 钉住降级:主路失败 ⇒ 底链出声音,
|
||||
// **错误不上抛**(文本优先)。
|
||||
func TestServiceFallsBackWhenPrimaryFails(t *testing.T) {
|
||||
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"detail":"indexTTS2 down"}`))
|
||||
})
|
||||
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
|
||||
svc := NewService(client, base, WithServiceLogger(discardLogger()))
|
||||
|
||||
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("主路失败不该让合成整体失败(文本优先): %v", err)
|
||||
}
|
||||
if rung != "silent" {
|
||||
t.Errorf("rung = %q, want 底链报告的那一级", rung)
|
||||
}
|
||||
if string(audio.Data) != "fallback" {
|
||||
t.Errorf("audio = %+v, want 底链产物", audio)
|
||||
}
|
||||
if base.ttsCalls != 1 {
|
||||
t.Errorf("底链被调用 %d 次, want 1", base.ttsCalls)
|
||||
}
|
||||
if got := svc.Stats(); got.Failures != 1 {
|
||||
t.Errorf("降级没有计数: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务,
|
||||
// 一次都不碰 emotionTTS 的 HTTP。
|
||||
func TestServiceDisabledIsTransparent(t *testing.T) {
|
||||
client := New(Config{Enabled: false, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
|
||||
if client.Enabled() {
|
||||
t.Fatal("未启用的客户端不该报告 enabled")
|
||||
}
|
||||
base := &fakeChain{audio: mvoice.Audio{Format: "ogg", Data: []byte("chain")}}
|
||||
svc := NewService(client, base, WithServiceLogger(discardLogger()))
|
||||
if svc.PrimaryActive() {
|
||||
t.Fatal("PrimaryActive = true, want false")
|
||||
}
|
||||
audio, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
|
||||
if err != nil {
|
||||
t.Fatalf("TTSWithProvider: %v", err)
|
||||
}
|
||||
if audio.Format != "ogg" || base.ttsCalls != 1 {
|
||||
t.Errorf("未启用时没有原样透传: audio=%+v calls=%d", audio, base.ttsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceWithoutBase 钉住"没有底链"时的诚实失败(不 panic、可分类)。
|
||||
func TestServiceWithoutBase(t *testing.T) {
|
||||
svc := NewService(nil, nil, WithServiceLogger(discardLogger()))
|
||||
if _, _, err := svc.TTSWithProvider(context.Background(), "hi"); !errors.Is(err, mvoice.ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
if _, err := svc.STT(context.Background(), mvoice.Audio{}); !errors.Is(err, mvoice.ErrProviderFailed) {
|
||||
t.Fatalf("STT err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
if _, err := svc.CloneVoice(context.Background(), "http://x", "p"); !errors.Is(err, mvoice.ErrProviderFailed) {
|
||||
t.Fatalf("CloneVoice err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceDelegatesSTTAndClone 钉住"只抢合成,不抢转写/复刻"。
|
||||
func TestServiceDelegatesSTTAndClone(t *testing.T) {
|
||||
client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
|
||||
base := &fakeChain{}
|
||||
svc := NewService(client, base, WithServiceLogger(discardLogger()))
|
||||
|
||||
if text, rung, err := svc.STTWithProvider(context.Background(), mvoice.Audio{}); err != nil || text != "转写文本" || rung != "local" {
|
||||
t.Fatalf("STT 没有原样委托: text=%q rung=%q err=%v", text, rung, err)
|
||||
}
|
||||
if id, err := svc.CloneVoice(context.Background(), "http://x", "p"); err != nil || id != "voice-id" {
|
||||
t.Fatalf("CloneVoice 没有原样委托: id=%q err=%v", id, err)
|
||||
}
|
||||
if base.sttCalls != 1 || base.cloneCall != 1 {
|
||||
t.Errorf("委托次数不对: stt=%d clone=%d", base.sttCalls, base.cloneCall)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderAdapter 钉住"作为链的一级"这条路现在就是通的(STT 方向为 nil)。
|
||||
func TestProviderAdapter(t *testing.T) {
|
||||
client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
|
||||
p := NewProvider(client)
|
||||
if p.Name() != ProviderName {
|
||||
t.Errorf("Name = %q, want %q", p.Name(), ProviderName)
|
||||
}
|
||||
if p.STT() != nil {
|
||||
t.Error("emotionTTS 不提供转写,STT 应为 nil")
|
||||
}
|
||||
if _, err := p.TTS().TTS(context.Background(), " "); !errors.Is(err, ErrEmptyText) {
|
||||
t.Errorf("err = %v, want ErrEmptyText(穿透到 Client 的守卫)", err)
|
||||
}
|
||||
if _, err := NewProvider(nil).TTS().TTS(context.Background(), "hi"); !errors.Is(err, mvoice.ErrNotConfigured) {
|
||||
t.Errorf("未接线 err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user