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。
This commit is contained in:
1 parent
a895754d55
commit
3a2711a7e0
15 files changed
+1672
-1
No files matched your search
@@ -0,0 +1,115 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user