Files
Cyrene_v2/internal/config/runtimeconfig/voicetts.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

117 lines
6.2 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.
// voicetts.go 是 **emotionTTS 语音合成**(切片 M35)的可变源:
// `$DATA_DIR/config/voice-tts.json`(design/13 §2.1「运行期生成的配置进
// `$DATA_DIR/config/`;调优/门槛/上限/开关类参数走这里 + 代码默认值」)。
//
// 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要三个运维旋钮 ——
// 开关、地址、角色;而 `.env` 已被主人冻结(**零新 env 键**,design/13 §2.1),
// 所以它们落在这里,与 `memory-tidy.json` / `backthink.json` 同一家族口径。
//
// 🔴 **刻意没有 speed / emotion / tone 三键**(塔 2026-10-26 裁决,实测证据):
// - 上游把下游载荷的 `speed` **硬编码 1.0**(`core/utils/llm_provider.py` 的
// `synthesize_audio`),客户端传的 speed 是空转 ⇒ 做一个键就是做一个**假开关**;
// - 情绪由上游**自己**推(LLM 分析待念文本 + 匹配参考音 + 注入情绪向量),我方
// 没有可映射的参数 ⇒ 不做"情绪 → 语气"映射表(有映射表也只是自嗨)。
//
// 边界(与 backthink.go / memorytidy.go 同源的手续):
//
// 1. 本文件只管**文件形状 + 读取**:它不认识 `tts.Config`,也不做"缺省填什么值"
// 的裁决(那是 `tts.Config.Normalized` 的事)。
// 2. **没有"面板编辑"的写口**:改内容仍要**人手改文件 + 重启**(与
// `backthink.json` 同口径:启动期读一次)。本切片不新增播种文件 —— 文件不存在
// 就是"没配过",该级不启用。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
//
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
// = 返回错误,调用方记 WARN 并**回落到"不启用"**(一个坏文件绝不能把语音面变成
// 「半开」状态 —— 宁可没有这一级,也不要用一份读不懂的配置去调外部服务)。
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// voiceTTSFileName 是文件名(家族命名 = 服务/模块名 + .json)。
voiceTTSFileName = "voice-tts.json"
// voiceTTSConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与其它族
// 各自声明一份,避免某一族换位置时另一处静默跟着搬家。
voiceTTSConfigSubdir = "config"
)
// VoiceTTSConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/voice-tts.json`)。
// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件。
const VoiceTTSConfigRelPath = voiceTTSConfigSubdir + "/" + voiceTTSFileName
// ErrInvalidVoiceTTS 是 voice-tts.json 结构不合法时的分类错误(errors.Is 可判类)。
var ErrInvalidVoiceTTS = errors.New("runtimeconfig: invalid voice tts config")
// VoiceTTSTuning 是 `voice-tts.json` 的形状:emotionTTS 那个发音人的运维旋钮。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type VoiceTTSTuning struct {
// Enabled 控制这一级是否参与合成。nil = 没配(**不启用**);true = 启用;
// false = 显式关掉(与"没配"同效,写它只是为了自解释)。
//
// 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认
// 语义是**关**(它要连一台默认在本机回环上的外部服务,不能靠"猜"打开)。
// 这里指针带来的是"没写"与"写了 false"在日志里可区分,行为上二者都等于不启用。
Enabled *bool `json:"enabled"`
// BaseURL 是服务地址。空 = 代码默认 `http://127.0.0.1:9880`。
//
// 🔴 该服务**无鉴权、只绑回环**:这里填公网地址等于把一个无鉴权的合成口暴露给
// 网络,安全上由部署方自行负责(本文件不替人做这个决定,也不给默认值以外的暗示)。
BaseURL string `json:"baseUrl,omitempty"`
// Character 是角色(服务端 `/api/characters` 的 id 或名字)。
// 空 = 代码默认 `char_84fb88a3`(主人指定的 Cyrene)。
Character string `json:"character,omitempty"`
// Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。
//
// 注意:上游产物**恒为 WAV**(它按 `response_format` 自报 media_type,但字节是
// WAV)⇒ 真正下发的格式一律由字节嗅探裁决,这一键只影响请求参数。
Format string `json:"format,omitempty"`
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
//
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
// 更不容易写错,也与既有 `$DATA_DIR/config/**` 的 `*_SEC` 口径一致。
TimeoutSec int `json:"timeoutSec,omitempty"`
}
// EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。
func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
// DefaultVoiceTTSPath 返回落点:`$DATA_DIR/config/voice-tts.json`。
func DefaultVoiceTTSPath(dataDir string) string {
return filepath.Join(dataDir, voiceTTSConfigSubdir, voiceTTSFileName)
}
// LoadVoiceTTSTuning 读取 voice-tts.json(**只读**)。
//
// 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"这一级没配过(不启用)",
// **不是**错误 —— 一台没装 emotionTTS 的机器不该每次启动都刷 WARN。
//
// 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTS 的错误。调用方应记一条带 source 的
// WARN 并**回落"不启用"**(见文件头第 3 条);**绝不得覆盖**那个文件(损坏的文件
// 可以人工救回,程序盖掉它就丢了人工写的意图)。
func LoadVoiceTTSTuning(path string) (VoiceTTSTuning, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return VoiceTTSTuning{}, nil
}
return VoiceTTSTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return VoiceTTSTuning{}, nil
}
var t VoiceTTSTuning
if err := json.Unmarshal(raw, &t); err != nil {
return VoiceTTSTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTS, path, err)
}
return t, nil
}