Files
Cyrene_v2/internal/runtime/voice/tts/client.go
T
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。

配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
  {"enabled":true,
   "personas":{"<personaId>":"<characterId>"},
   "default":"<characterId>",   // 只服务「解析不到 personaId」
   "format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
  Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。

选角四条分支(Config.Resolve,纯函数、无缓存):
  ① persona 有映射 ⇒ 用它;
  ② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
  ③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
  ④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
     **绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。

两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
  经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
  经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。

Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。

测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
2026-10-26 12:57:35 +08:00

283 lines
12 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 }
// 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 调用。
//
// `character` 是**每次调用显式传入**的,客户端内部**不缓存**任何发音人 —— 因此
// 同一进程先后服务两个角色,第二次绝不会沿用第一次的 voice(主人 2026-10-26 的
// 硬要求;由 `Service` 按每次请求的 personaId 现算,见 selection.go)。
// 空 character 一律拒绝:宁可没有声音,也不要用一个"默认"角色的声音冒充她。
//
// 返回值里的 `Format` 是**字节嗅探**所得,不是请求里那个 `response_format`。
func (c *Client) Speech(ctx context.Context, character, text string) (Audio, error) {
if !c.cfg.Enabled {
return Audio{}, ErrDisabled
}
voice := strings.TrimSpace(character)
if voice == "" {
return Audio{}, ErrNoCharacter
}
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return Audio{}, ErrEmptyText
}
c.attempts.Add(1)
audi, err := c.speak(ctx, voice, trimmed)
if err != nil {
c.failures.Add(1)
}
return audi, err
}
// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。
func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) {
body, err := encodeSpeech(text, 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
}