侦察结论(真机 + 上游源码):
- 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。
277 lines
11 KiB
Go
277 lines
11 KiB
Go
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
|
||
}
|