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:
AskaEth committed 2026-10-26 12:46:57 +08:00
1 parent a895754d55
commit 3a2711a7e0
15 files changed
+1672 -1

No files matched your search

+276
View File
@@ -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
}