Files
Cyrene_v2/internal/runtime/voice/tts/client.go
T
AskaEth 3a2711a7e0 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。
2026-10-26 12:46:57 +08:00

277 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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
}