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,116 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadVoiceTTSTuning 锁定 voice-tts.json 的四条读取口径:
|
||||
// 缺失 = 没配过(不是错误)、全空白 = 没配过、合法 = 逐键读出、坏文件 = 可分类错误。
|
||||
func TestLoadVoiceTTSTuning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "voice-tts.json")
|
||||
|
||||
// ① 缺失
|
||||
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
||||
}
|
||||
|
||||
// ② 全空白
|
||||
if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
||||
}
|
||||
|
||||
// ③ 合法
|
||||
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880","character":"char_84fb88a3",
|
||||
"format":"wav","timeoutSec":45}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tuning, err := LoadVoiceTTSTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("合法文件: %v", err)
|
||||
}
|
||||
if !tuning.EnabledOn() || tuning.Character != "char_84fb88a3" || tuning.Format != "wav" || tuning.TimeoutSec != 45 {
|
||||
t.Fatalf("读取结果不对: %+v", tuning)
|
||||
}
|
||||
|
||||
// ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。
|
||||
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if tuning, err = LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() || tuning.Enabled == nil {
|
||||
t.Fatalf("显式 false: tuning=%+v err=%v", tuning, err)
|
||||
}
|
||||
|
||||
// ⑤ 坏文件 ⇒ 可分类错误(调用方据此回落"不启用",且绝不覆盖原文件)
|
||||
if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := LoadVoiceTTSTuning(path); !errors.Is(err, ErrInvalidVoiceTTS) {
|
||||
t.Fatalf("坏文件 err = %v, want ErrInvalidVoiceTTS", err)
|
||||
}
|
||||
if raw, err := os.ReadFile(path); err != nil || string(raw) != `{"enabled":` {
|
||||
t.Fatalf("坏文件被覆盖了: %q err=%v", string(raw), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultVoiceTTSPath 钉住落点:`$DATA_DIR/config/voice-tts.json`(零新 .env 键)。
|
||||
func TestDefaultVoiceTTSPath(t *testing.T) {
|
||||
got := DefaultVoiceTTSPath(filepath.Join("data", "root"))
|
||||
want := filepath.Join("data", "root", "config", "voice-tts.json")
|
||||
if got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
if VoiceTTSConfigRelPath != "config/voice-tts.json" {
|
||||
t.Fatalf("RelPath = %q", VoiceTTSConfigRelPath)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user