Files
Cyrene_v2/internal/runtime/voice/tts/config.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

116 lines
5.3 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 服务**的运行时合成适配(切片 M35)。
//
// 它与父包 `internal/runtime/voice` 的分工(**别混**):父包管「一条入站语音的
// 来源事实 + 可播副本」(design/24 §10);本包只回答一件事 ——「把这段文字交给
// emotionTTS 换成音频」。它与 `internal/runtime/multimodal/voice` 里那条 STT/TTS
// 降级链**不是**两套 HTTP:本包的 `Client` 满足那条链的 `TTS` 形状,`Service`
// 则把 emotionTTS 摆在那条链**前面**(失败/未启用回落既有链)。
//
// # 为什么它比我方参数"更懂情绪"
//
// 该服务(v5.1.0)的 `POST /v1/audio/speech` **自己**做情绪:先让 LLM 分析待念
// 文本的情绪、在角色素材库里挑一条情绪相符的参考音,再把情绪向量以
// `[EMO:{vector}|{alpha}]base64:...` 的形式塞进 `voice` 字段交给 indexTTS2
// (`core/routers/tts_router.py` 的 `openai_tts_api` + `core/utils/llm_provider.py`
// 的 `synthesize_audio`)。**因此我方没有、也不需要情绪参数** —— 语气由要念的
// 文本自己带(她调措辞就是调语气)。
//
// # 为什么没有 speed 旋钮
//
// 🔴 同一个 `synthesize_audio` 把下游载荷的 `speed` **硬编码成 1.0**(同一文件
// 的 `data_json`),所以 OpenAI 兼容口的 `speed` 收下即丢。**给它做一个旋钮等于
// 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`:
// 那两样上游自己推,我方传什么都是噪音。
//
// # 一次只暴露三样
//
// 对调用方只暴露 `text`(必填)、角色(默认 `Cyrene` 的 `char_84fb88a3`)与
// `format`(默认 wav)。其余全部是代码默认值或面板文件里的运维旋钮。
package tts
import (
"log/slog"
"strings"
"time"
)
const (
// DefaultBaseURL 是该服务的默认地址(**只绑回环**、无鉴权:绝不是公网地址)。
DefaultBaseURL = "http://127.0.0.1:9880"
// DefaultCharacter 是默认角色 —— 主人指定的「Cyrene」(`GET /api/characters`
// 实测 id = `char_84fb88a3`)。
DefaultCharacter = "char_84fb88a3"
// DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用
// `FileResponse` 回发,`response_format` 只影响它自报的 media_type),因此默认
// wav;实际下发的格式仍由**字节嗅探**裁决(见 audio.go),不信任这个字段。
DefaultFormat = "wav"
// DefaultTimeout 是单次合成超时。实测一次中文合成约 4~6 秒(它内部还要先跑一次
// LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并回落既有链。
DefaultTimeout = 60 * time.Second
// defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存
// (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。
defaultMaxAudioBytes = 32 << 20
// speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。
speechPath = "/v1/audio/speech"
// modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。
modelName = "emotionTTS"
)
// Config 是 emotionTTS 客户端的运行期旋钮(由面板文件
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
//
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
type Config struct {
// Enabled 是总开关。false = 该级整体不参与(调用方应保留既有链,
// 而不是把"没启用"当成"合成失败")。
Enabled bool
// BaseURL 是服务地址;空 = DefaultBaseURL。约定只填回环地址。
BaseURL string
// Character 是角色(服务端 `/api/characters` 的 id 或名字);空 = DefaultCharacter。
Character string
// Format 是请求格式(`response_format`);空 = DefaultFormat。只接受
// wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。
Format string
// Timeout 是单次合成超时;<=0 = DefaultTimeout。
Timeout time.Duration
}
// supportedFormats 是允许写进 Format 的取值(上游 `response_format` 只用于自报
// media_type;产物恒为 WAV)。写别的值不是"更灵活",而是让下游拿到一个与字节
// 不符的标签 —— 因此这里**拒绝**而不是静默放行。
var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true}
// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并
// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。
func (c Config) Normalized(log *slog.Logger) Config {
out := c
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
if out.BaseURL == "" {
out.BaseURL = DefaultBaseURL
}
out.Character = strings.TrimSpace(out.Character)
if out.Character == "" {
out.Character = DefaultCharacter
}
out.Format = strings.ToLower(strings.TrimSpace(out.Format))
if out.Format == "" {
out.Format = DefaultFormat
} else if !supportedFormats[out.Format] {
if log != nil {
log.Warn("unsupported tts format, fell back to default",
"format", out.Format, "default", DefaultFormat)
}
out.Format = DefaultFormat
}
if out.Timeout <= 0 {
out.Timeout = DefaultTimeout
}
return out
}