fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:
1. 🔴 隐私:upstreamStatus 不再读也不记响应体
FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
等于把她的原话写进日志。现在只记 status / content_type / response_bytes
(字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
变异(把 body 塞回 WARN)实测打红。
2. format 键彻底删除(假开关)
上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。
3. Stats 接进 degrade 的 WARN(attempts/failures)
否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
变异(去掉两个字段)打红。
4. redactURL 兜底含 "@" 整体打码
解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。
另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。
Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
This commit is contained in:
1 parent
efc25c6a7f
commit
8820b0b316
12 files changed
+333
-69
No files matched your search
@@ -45,7 +45,6 @@ func (a *app) voiceForGateway() gateway.VoiceService {
|
|||||||
"base_url", client.RedactedBaseURL(),
|
"base_url", client.RedactedBaseURL(),
|
||||||
"personas", len(cfg.Personas),
|
"personas", len(cfg.Personas),
|
||||||
"default_set", cfg.Default != "",
|
"default_set", cfg.Default != "",
|
||||||
"format", cfg.Format,
|
|
||||||
"timeout_sec", int(cfg.Timeout/time.Second),
|
"timeout_sec", int(cfg.Timeout/time.Second),
|
||||||
"config", runtimeconfig.VoiceTTSConfigRelPath)
|
"config", runtimeconfig.VoiceTTSConfigRelPath)
|
||||||
return service
|
return service
|
||||||
@@ -84,7 +83,6 @@ func (a *app) voiceTTSConfig() (voicetts.Config, bool) {
|
|||||||
BaseURL: tuning.BaseURL,
|
BaseURL: tuning.BaseURL,
|
||||||
Personas: tuning.Personas,
|
Personas: tuning.Personas,
|
||||||
Default: tuning.Default,
|
Default: tuning.Default,
|
||||||
Format: tuning.Format,
|
|
||||||
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
|
Timeout: time.Duration(tuning.TimeoutSec) * time.Second,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,11 +94,6 @@ type VoiceTTSTuning struct {
|
|||||||
// 保留字段只为在启动日志里明确告诉运维"这一键不再生效,请改用 personas",
|
// 保留字段只为在启动日志里明确告诉运维"这一键不再生效,请改用 personas",
|
||||||
// 而不是让它被 json 静默丢弃。新配置**不要**写它。
|
// 而不是让它被 json 静默丢弃。新配置**不要**写它。
|
||||||
Character string `json:"character,omitempty"`
|
Character string `json:"character,omitempty"`
|
||||||
// Format 是请求格式,只接受 wav/mp3/ogg。空 = 代码默认 wav。
|
|
||||||
//
|
|
||||||
// 注意:上游产物**恒为 WAV**(它按 `response_format` 自报 media_type,但字节是
|
|
||||||
// WAV)⇒ 真正下发的格式一律由字节嗅探裁决,这一键只影响请求参数。
|
|
||||||
Format string `json:"format,omitempty"`
|
|
||||||
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
|
// TimeoutSec 是单次合成超时(**秒**)。零/负 = 没配 ⇒ 代码默认 60 秒。
|
||||||
//
|
//
|
||||||
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
|
// 用秒而不是 Go 时长字面量:这个文件是给人改的,写 `60` 比要求记住 `"60s"`
|
||||||
@@ -106,6 +101,10 @@ type VoiceTTSTuning struct {
|
|||||||
TimeoutSec int `json:"timeoutSec,omitempty"`
|
TimeoutSec int `json:"timeoutSec,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 本文件**刻意没有** `format` 键:上游只拿 `response_format` 改它自报的 media_type,
|
||||||
|
// 产物恒 `.wav` 且无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,
|
||||||
|
// 是假开关。请求体里它由 `tts.requestFormat` 固定为 wav。
|
||||||
|
|
||||||
// EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。
|
// EnabledOn 报告这一级是否被显式打开(nil = 没配 ⇒ 不启用)。
|
||||||
func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
|
func (t VoiceTTSTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
|
|||||||
// ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与)
|
// ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与)
|
||||||
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
|
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
|
||||||
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
|
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
|
||||||
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`
|
"default":"char_84fb88a3","timeoutSec":45}`
|
||||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
t.Fatalf("write: %v", err)
|
t.Fatalf("write: %v", err)
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("合法文件: %v", err)
|
t.Fatalf("合法文件: %v", err)
|
||||||
}
|
}
|
||||||
if !tuning.EnabledOn() || tuning.Format != "wav" || tuning.TimeoutSec != 45 {
|
if !tuning.EnabledOn() || tuning.TimeoutSec != 45 {
|
||||||
t.Fatalf("读取结果不对: %+v", tuning)
|
t.Fatalf("读取结果不对: %+v", tuning)
|
||||||
}
|
}
|
||||||
if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" {
|
if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" {
|
||||||
@@ -47,6 +47,18 @@ func TestLoadVoiceTTSTuning(t *testing.T) {
|
|||||||
t.Fatalf("default = %q", tuning.Default)
|
t.Fatalf("default = %q", tuning.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ③c 旧文件里若还留着 `format`,它**不再有任何效果**(结构体里没有这个键,
|
||||||
|
// json 当作未知字段忽略):加载仍成功,且交付格式只由字节嗅探决定。
|
||||||
|
if err := os.WriteFile(path, []byte(`{"enabled":true,"format":"mp3","personas":{"cyrene":"char_84fb88a3"}}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if tuning, err = LoadVoiceTTSTuning(path); err != nil {
|
||||||
|
t.Fatalf("带废弃 format 键的文件不该解不开: %v", err)
|
||||||
|
}
|
||||||
|
if !tuning.EnabledOn() || tuning.Personas["cyrene"] != "char_84fb88a3" {
|
||||||
|
t.Fatalf("废弃键影响了其余字段: %+v", tuning)
|
||||||
|
}
|
||||||
|
|
||||||
// ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是**
|
// ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是**
|
||||||
// personas/default 的一部分 —— 组合根绝不把它当发音人。
|
// personas/default 的一部分 —— 组合根绝不把它当发音人。
|
||||||
if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil {
|
||||||
|
|||||||
@@ -64,9 +64,10 @@ type speechPayload struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
// Input 是要合成的文本(UTF-8;见文件头的红线)。
|
// Input 是要合成的文本(UTF-8;见文件头的红线)。
|
||||||
Input string `json:"input"`
|
Input string `json:"input"`
|
||||||
// Voice 是角色 id 或名字(`char_84fb88a3` / `Cyrene` 都认)。
|
// Voice 是角色 id(由会话 persona 映射而来,每次请求现算)。
|
||||||
Voice string `json:"voice"`
|
Voice string `json:"voice"`
|
||||||
// ResponseFormat 是请求格式;产物恒为 WAV,真实格式按字节嗅探。
|
// ResponseFormat 恒为 `wav`(`requestFormat`):上游产物无转码、我方只按字节
|
||||||
|
// 嗅探裁决,这个字段不可配置(见 requestFormat 的注释)。
|
||||||
ResponseFormat string `json:"response_format"`
|
ResponseFormat string `json:"response_format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ func New(cfg Config, opts ...Option) *Client {
|
|||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(c)
|
o(c)
|
||||||
}
|
}
|
||||||
c.cfg = cfg.Normalized(c.log)
|
c.cfg = cfg.Normalized()
|
||||||
if c.http == nil {
|
if c.http == nil {
|
||||||
c.http = &http.Client{Timeout: c.cfg.Timeout}
|
c.http = &http.Client{Timeout: c.cfg.Timeout}
|
||||||
}
|
}
|
||||||
@@ -163,7 +164,7 @@ func (c *Client) Speech(ctx context.Context, character, text string) (Audio, err
|
|||||||
|
|
||||||
// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。
|
// speak 是 Speech 的主体:编码 → 发送 → 判状态 → 嗅探。失败原因已分类。
|
||||||
func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) {
|
func (c *Client) speak(ctx context.Context, character, text string) (Audio, error) {
|
||||||
body, err := encodeSpeech(text, character, c.cfg.Format)
|
body, err := encodeSpeech(text, character)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Audio{}, err
|
return Audio{}, err
|
||||||
}
|
}
|
||||||
@@ -193,12 +194,12 @@ func (c *Client) speak(ctx context.Context, character, text string) (Audio, erro
|
|||||||
// 🔴 全仓仅此一处构造请求体:`json.Encoder` 天然产出 UTF-8,且这里显式关掉 HTML
|
// 🔴 全仓仅此一处构造请求体:`json.Encoder` 天然产出 UTF-8,且这里显式关掉 HTML
|
||||||
// 转义,使正文里的 `<`/`>`/`&` 不会被改写成 `\u003c` 之类(内容逐字不变,下游
|
// 转义,使正文里的 `<`/`>`/`&` 不会被改写成 `\u003c` 之类(内容逐字不变,下游
|
||||||
// 日志与音色匹配都依赖原文)。**绝不**改成字符串拼接或 GBK 转码(见文件头)。
|
// 日志与音色匹配都依赖原文)。**绝不**改成字符串拼接或 GBK 转码(见文件头)。
|
||||||
func encodeSpeech(text, character, format string) ([]byte, error) {
|
func encodeSpeech(text, character string) ([]byte, error) {
|
||||||
payload := speechPayload{
|
payload := speechPayload{
|
||||||
Model: modelName,
|
Model: modelName,
|
||||||
Input: text,
|
Input: text,
|
||||||
Voice: character,
|
Voice: character,
|
||||||
ResponseFormat: format,
|
ResponseFormat: requestFormat,
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
enc := json.NewEncoder(&buf)
|
enc := json.NewEncoder(&buf)
|
||||||
@@ -225,11 +226,30 @@ func (c *Client) unreachable(ctx context.Context, endpoint string, err error) er
|
|||||||
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err)
|
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upstreamStatus 把非 200 收敛成 ErrUpstream,并在日志里留下状态码与一小段
|
// upstreamStatus 把非 200 收敛成 ErrUpstream,并只记**元数据**(状态码、Content-Type
|
||||||
// 响应片段(上游的错误体是它自己的 `{"detail":...}`;片段只用于排查,不含我方正文)。
|
// 与响应字节数)—— 上游的错误体**一个字节都不进日志**。
|
||||||
|
//
|
||||||
|
// 🔴 这条是隐私红线,不是风格问题:FastAPI 的 422 会把请求体里的 `input` **整段回显**,
|
||||||
|
// 实测报文形如
|
||||||
|
//
|
||||||
|
// {"detail":[{"type":"missing","loc":["body","voice"],…,
|
||||||
|
// "input":{"input":"你好,我是苏梦,我有点难过。"}}]}
|
||||||
|
//
|
||||||
|
// 于是"把错误体前 512 字节打进 WARN"就等于**把她说的原话写进日志**。错误链里同样
|
||||||
|
// 只有状态码(`TestSpeechRejectLogOmitsRequestText` 钉这一条,变异可打红)。
|
||||||
|
//
|
||||||
|
// 字节数优先取 `Content-Length`;缺失时只做**有界**排空(上限 maxDrainBytes)得出
|
||||||
|
// 一个下界,**不为数字节把整段读完**,读到的东西也绝不进日志。
|
||||||
func (c *Client) upstreamStatus(res *http.Response) error {
|
func (c *Client) upstreamStatus(res *http.Response) error {
|
||||||
snippet, _ := io.ReadAll(io.LimitReader(res.Body, 512))
|
size := res.ContentLength
|
||||||
c.warn("voice tts rejected", "status", res.StatusCode, "detail", string(snippet))
|
if size < 0 {
|
||||||
|
drained, _ := io.Copy(io.Discard, io.LimitReader(res.Body, maxDrainBytes))
|
||||||
|
size = drained
|
||||||
|
}
|
||||||
|
c.warn("voice tts rejected",
|
||||||
|
"status", res.StatusCode,
|
||||||
|
"content_type", res.Header.Get("Content-Type"),
|
||||||
|
"response_bytes", size)
|
||||||
return fmt.Errorf("%w: status %d", ErrUpstream, res.StatusCode)
|
return fmt.Errorf("%w: status %d", ErrUpstream, res.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +293,16 @@ type Audio struct {
|
|||||||
|
|
||||||
// redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host,
|
// redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host,
|
||||||
// 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。
|
// 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。
|
||||||
|
//
|
||||||
|
// 解析失败或没有 host 时**不能**原样返回:`http://user:pass@` 这种不合法地址会
|
||||||
|
// 把凭据整个写进日志。因此兜底判据是"含 `@` 就整体打码"(`@` 是 userinfo 的
|
||||||
|
// 分隔符,出现在地址里只可能是凭据)。
|
||||||
func redactURL(raw string) string {
|
func redactURL(raw string) string {
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
|
if strings.Contains(raw, "@") {
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
return u.Scheme + "://" + u.Host
|
return u.Scheme + "://" + u.Host
|
||||||
|
|||||||
@@ -91,8 +91,9 @@ func TestSpeechSendsUTF8ChineseText(t *testing.T) {
|
|||||||
if gotPayload.Voice != DefaultCharacter {
|
if gotPayload.Voice != DefaultCharacter {
|
||||||
t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter)
|
t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter)
|
||||||
}
|
}
|
||||||
if gotPayload.ResponseFormat != DefaultFormat {
|
// response_format 恒为 wav 且**不可配置**(上游产物无转码,见 requestFormat)。
|
||||||
t.Errorf("response_format = %q, want %q", gotPayload.ResponseFormat, DefaultFormat)
|
if gotPayload.ResponseFormat != "wav" {
|
||||||
|
t.Errorf("response_format = %q, want 硬编码的 wav", gotPayload.ResponseFormat)
|
||||||
}
|
}
|
||||||
// ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。
|
// ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。
|
||||||
if audio.Format != "wav" || len(audio.Data) == 0 {
|
if audio.Format != "wav" || len(audio.Data) == 0 {
|
||||||
@@ -216,24 +217,21 @@ func TestSpeechGuards(t *testing.T) {
|
|||||||
// 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个
|
// 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个
|
||||||
// 默认发音人,正是主人点名的病害(换人格仍用老声音)。
|
// 默认发音人,正是主人点名的病害(换人格仍用老声音)。
|
||||||
func TestConfigNormalized(t *testing.T) {
|
func TestConfigNormalized(t *testing.T) {
|
||||||
got := Config{}.Normalized(discardLogger())
|
got := Config{}.Normalized()
|
||||||
if got.Enabled {
|
if got.Enabled {
|
||||||
t.Errorf("零值配置不该被悄悄启用")
|
t.Errorf("零值配置不该被悄悄启用")
|
||||||
}
|
}
|
||||||
if got.BaseURL != DefaultBaseURL || got.Format != DefaultFormat || got.Timeout != DefaultTimeout {
|
if got.BaseURL != DefaultBaseURL || got.Timeout != DefaultTimeout {
|
||||||
t.Fatalf("默认值不对: %+v", got)
|
t.Fatalf("默认值不对: %+v", got)
|
||||||
}
|
}
|
||||||
if got.Default != "" || len(got.Personas) != 0 {
|
if got.Default != "" || len(got.Personas) != 0 {
|
||||||
t.Fatalf("归一化给发音人兜底了(红线:不许有代码默认角色): %+v", got)
|
t.Fatalf("归一化给发音人兜底了(红线:不许有代码默认角色): %+v", got)
|
||||||
}
|
}
|
||||||
if custom := (Config{BaseURL: " http://127.0.0.1:9880/ ", Format: "MP3"}).Normalized(discardLogger()); custom.BaseURL != DefaultBaseURL || custom.Format != "mp3" {
|
if custom := (Config{BaseURL: " http://127.0.0.1:9880/ "}).Normalized(); custom.BaseURL != DefaultBaseURL {
|
||||||
t.Errorf("归一化不对: %+v", custom)
|
t.Errorf("归一化不对: %+v", custom)
|
||||||
}
|
}
|
||||||
if bad := (Config{Format: "exe"}).Normalized(discardLogger()); bad.Format != DefaultFormat {
|
|
||||||
t.Errorf("非法格式应回落默认: %+v", bad)
|
|
||||||
}
|
|
||||||
// persona/character 两端的空白被清掉;空 personaId 的键被丢弃。
|
// persona/character 两端的空白被清掉;空 personaId 的键被丢弃。
|
||||||
trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized(discardLogger())
|
trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized()
|
||||||
if trimmed.Personas["a"] != "char_1" || len(trimmed.Personas) != 1 || trimmed.Default != "char_3" {
|
if trimmed.Personas["a"] != "char_1" || len(trimmed.Personas) != 1 || trimmed.Default != "char_3" {
|
||||||
t.Fatalf("映射/默认值没有被归一化: %+v", trimmed)
|
t.Fatalf("映射/默认值没有被归一化: %+v", trimmed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
// 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`:
|
// 做一个假开关** —— 因此 `Config` 刻意不含 `speed`。同理不含 `emotion`/`tone`:
|
||||||
// 那两样上游自己推,我方传什么都是噪音。
|
// 那两样上游自己推,我方传什么都是噪音。
|
||||||
//
|
//
|
||||||
// # 一次只暴露三样
|
// # 对外只有两样
|
||||||
//
|
//
|
||||||
// 对调用方只暴露 `text`(必填)、角色(默认 `Cyrene` 的 `char_84fb88a3`)与
|
// 对调用方只暴露 `text`(必填)与角色(由会话 persona 映射而来)。**没有 `format`**:
|
||||||
// `format`(默认 wav)。其余全部是代码默认值或面板文件里的运维旋钮。
|
// 上游只拿它改自报的 media_type,产物恒 `.wav` 无转码,我方只按字节嗅探裁决 ⇒
|
||||||
|
// 那个键不改变任何交付字节(假开关)。其余全是面板文件里的运维旋钮或代码默认值。
|
||||||
package tts
|
package tts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -46,30 +46,39 @@ const (
|
|||||||
// `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。
|
// `Config.Personas` / `Config.Default` 显式给出(见 Resolve)。
|
||||||
DefaultCharacter = "char_84fb88a3"
|
DefaultCharacter = "char_84fb88a3"
|
||||||
|
|
||||||
// DefaultFormat 是默认请求格式。服务端产物**恒为 WAV**(它把 .wav 落盘后用
|
// DefaultTimeout 是单次合成超时。实测一次中文合成约 4~8 秒(它内部还要先跑一次
|
||||||
// `FileResponse` 回发,`response_format` 只影响它自报的 media_type),因此默认
|
// LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并按既有口径回落。
|
||||||
// wav;实际下发的格式仍由**字节嗅探**裁决(见 audio.go),不信任这个字段。
|
|
||||||
DefaultFormat = "wav"
|
|
||||||
|
|
||||||
// DefaultTimeout 是单次合成超时。实测一次中文合成约 4~6 秒(它内部还要先跑一次
|
|
||||||
// LLM 情绪分析 + 素材匹配),故默认留足余量;超时即失败并回落既有链。
|
|
||||||
DefaultTimeout = 60 * time.Second
|
DefaultTimeout = 60 * time.Second
|
||||||
|
|
||||||
// defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存
|
// defaultMaxAudioBytes 限制读回的音频体积,使异常/畸形响应无法耗尽内存
|
||||||
// (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。
|
// (32 MiB 远超一句话的语音,实测 5 秒语音约 100~200 KB)。
|
||||||
defaultMaxAudioBytes = 32 << 20
|
defaultMaxAudioBytes = 32 << 20
|
||||||
|
|
||||||
|
// maxDrainBytes 是错误响应体的**有界**排空上限:只用来得出一个字节数下界,
|
||||||
|
// 读到的东西直接丢弃、绝不进日志(见 upstreamStatus 的隐私红线)。
|
||||||
|
maxDrainBytes = 4096
|
||||||
|
|
||||||
// speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。
|
// speechPath 是 OpenAI 兼容合成端点(已实测可用:200 / RIFF-WAVE)。
|
||||||
speechPath = "/v1/audio/speech"
|
speechPath = "/v1/audio/speech"
|
||||||
|
|
||||||
// modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。
|
// modelName 是上游要求的固定模型名(该服务只认 `emotionTTS`)。
|
||||||
modelName = "emotionTTS"
|
modelName = "emotionTTS"
|
||||||
|
|
||||||
|
// requestFormat 是请求体里 `response_format` 的**固定值**。
|
||||||
|
//
|
||||||
|
// 🔴 它刻意**不可配置**:上游只拿它改 `FileResponse` 的 media_type,产物恒为
|
||||||
|
// `.wav` 且**无转码**(`tts_router.py:437-438`),我方又从不读 `Content-Type`
|
||||||
|
// (格式一律由 `sniffAudio` 按字节裁决)⇒ `wav`/`mp3`/`ogg` 交付出去的字节与
|
||||||
|
// 响应头**完全相同**。做成配置键就是一个假开关(主人对假开关零容忍)。
|
||||||
|
requestFormat = "wav"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 是 emotionTTS 客户端的运行期旋钮(由面板文件
|
// Config 是 emotionTTS 客户端的运行期旋钮(由面板文件
|
||||||
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
|
// `$DATA_DIR/config/voice-tts.json` 映射而来;零新 `.env` 键)。
|
||||||
//
|
//
|
||||||
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
|
// 🔴 刻意**没有** speed / emotion / tone 三项,理由见包注释(上游硬编码或自推)。
|
||||||
|
// 🔴 刻意**没有** format:上游只拿 `response_format` 改它自报的 media_type,产物
|
||||||
|
// 恒为 `.wav` 无转码,我方又只按字节嗅探裁决 ⇒ 那个键不改变任何交付字节,是假开关。
|
||||||
// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或
|
// 🔴 也**没有全局 character**:发音人只能由 `Personas`(按会话角色映射)或
|
||||||
// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求
|
// `Default`(仅服务"解析不到 personaId")给出 —— 主人 2026-10-26 的硬要求
|
||||||
// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。
|
// 「TTS 的人物选择要绑定后端的角色,不然换了人格还是用老角色的声音」。
|
||||||
@@ -87,24 +96,16 @@ type Config struct {
|
|||||||
// 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有
|
// 该出口本身没有会话)时使用的发音人。它**不**服务"配了 Personas 但没有
|
||||||
// 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。
|
// 这个 persona 的键"那种情况 —— 那是 Resolve 的拒绝分支(主人点名的病害)。
|
||||||
Default string
|
Default string
|
||||||
// Format 是请求格式(`response_format`);空 = DefaultFormat。只接受
|
|
||||||
// wav/mp3/ogg —— 且**只用于请求**,回发的真实格式一律按字节嗅探。
|
|
||||||
Format string
|
|
||||||
// Timeout 是单次合成超时;<=0 = DefaultTimeout。
|
// Timeout 是单次合成超时;<=0 = DefaultTimeout。
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportedFormats 是允许写进 Format 的取值(上游 `response_format` 只用于自报
|
// Normalized 返回填好默认值、去掉首尾空白的配置。
|
||||||
// media_type;产物恒为 WAV)。写别的值不是"更灵活",而是让下游拿到一个与字节
|
|
||||||
// 不符的标签 —— 因此这里**拒绝**而不是静默放行。
|
|
||||||
var supportedFormats = map[string]bool{"wav": true, "mp3": true, "ogg": true}
|
|
||||||
|
|
||||||
// Normalized 返回填好默认值、去掉首尾空白的配置。非法 Format 回落到默认值并
|
|
||||||
// 通过 log 记一条 WARN(**不让它停摆** —— 一个写错的格式不该关掉整个语音面)。
|
|
||||||
//
|
//
|
||||||
// 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源
|
// 它**不**给 `Personas`/`Default` 补任何默认角色:发音人只有两种合法来源
|
||||||
// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。
|
// (显式映射 / 显式 default),代码里的兜底值就是主人担心的病害本身。它也不接受
|
||||||
func (c Config) Normalized(log *slog.Logger) Config {
|
// logger:当前没有任何"需要告警的回落"(格式已不是配置项,见 requestFormat)。
|
||||||
|
func (c Config) Normalized() Config {
|
||||||
out := c
|
out := c
|
||||||
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
|
out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/")
|
||||||
if out.BaseURL == "" {
|
if out.BaseURL == "" {
|
||||||
@@ -112,16 +113,6 @@ func (c Config) Normalized(log *slog.Logger) Config {
|
|||||||
}
|
}
|
||||||
out.Personas = normalizePersonas(out.Personas)
|
out.Personas = normalizePersonas(out.Personas)
|
||||||
out.Default = strings.TrimSpace(out.Default)
|
out.Default = strings.TrimSpace(out.Default)
|
||||||
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 {
|
if out.Timeout <= 0 {
|
||||||
out.Timeout = DefaultTimeout
|
out.Timeout = DefaultTimeout
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package tts
|
||||||
|
|
||||||
|
// 本文件锁定**日志侧**的两条口径(复审 r1 的 P2-2 / nit,都是"CI 抓不到"的那类):
|
||||||
|
//
|
||||||
|
// 1. 🔴 WARN / 错误链里**绝不能出现她的正文**。上游(FastAPI)的 422 会把请求体
|
||||||
|
// 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进
|
||||||
|
// 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。
|
||||||
|
// 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL
|
||||||
|
// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureLogger 返回一个把日志写进内存缓冲的 logger(断言用)。
|
||||||
|
func captureLogger() (*slog.Logger, *bytes.Buffer) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), &buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpeechRejectLogOmitsRequestText 是隐私红线的用例:上游回 422 并**回显请求体**
|
||||||
|
// (FastAPI 的真实行为),日志里因此**一个正文词都不许出现**,只许有元数据。
|
||||||
|
//
|
||||||
|
// 变异:把错误体重新塞回 WARN(`"detail", string(snippet)`)⇒ 本用例必红。
|
||||||
|
func TestSpeechRejectLogOmitsRequestText(t *testing.T) {
|
||||||
|
log, buf := captureLogger()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
// 这就是复审在真服务上抓到的形状:input 被整段回显。
|
||||||
|
_, _ = w.Write([]byte(`{"detail":[{"type":"missing","loc":["body","voice"],` +
|
||||||
|
`"msg":"Field required","input":{"model":"emotionTTS","input":"` + chineseSpeech + `"}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log))
|
||||||
|
|
||||||
|
_, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech)
|
||||||
|
if !errors.Is(err, ErrUpstream) {
|
||||||
|
t.Fatalf("err = %v, want ErrUpstream", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := buf.String()
|
||||||
|
// ① 日志里不得出现正文的任何一段(整句、半句、单个关键词都算泄漏)。
|
||||||
|
for _, leak := range []string{chineseSpeech, "苏梦", "心情特别好", "你好"} {
|
||||||
|
if strings.Contains(logs, leak) {
|
||||||
|
t.Fatalf("日志里出现了请求正文(%q)—— 隐私红线:\n%s", leak, logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ② 也不得出现上游错误体的结构键(那正是"把 body 打出来"的痕迹)。
|
||||||
|
if strings.Contains(logs, "detail") || strings.Contains(logs, "loc") {
|
||||||
|
t.Fatalf("日志里出现了上游错误体内容:\n%s", logs)
|
||||||
|
}
|
||||||
|
// ③ 该有的元数据必须在:状态码 + 响应字节数(排查靠它们,不靠正文)。
|
||||||
|
if !strings.Contains(logs, "status=422") {
|
||||||
|
t.Fatalf("日志缺状态码:\n%s", logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "response_bytes=") {
|
||||||
|
t.Fatalf("日志缺响应字节数:\n%s", logs)
|
||||||
|
}
|
||||||
|
// ④ 错误串本身也不许带正文(调用方可能把它写日志)。
|
||||||
|
if strings.Contains(err.Error(), "苏梦") || strings.Contains(err.Error(), chineseSpeech) {
|
||||||
|
t.Fatalf("错误串里出现了正文: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpeechRejectLogUsesContentLength 钉住"字节数优先取 Content-Length":
|
||||||
|
// 有长度时不必读响应体就能报出大小(不为数字节把整段读完)。
|
||||||
|
func TestSpeechRejectLogUsesContentLength(t *testing.T) {
|
||||||
|
log, buf := captureLogger()
|
||||||
|
body := `{"detail":"nope"}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Length", "17")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
client := New(Config{Enabled: true, BaseURL: srv.URL}, WithLogger(log))
|
||||||
|
|
||||||
|
if _, err := client.Speech(context.Background(), DefaultCharacter, "你好"); !errors.Is(err, ErrUpstream) {
|
||||||
|
t.Fatalf("err = %v, want ErrUpstream", err)
|
||||||
|
}
|
||||||
|
logs := buf.String()
|
||||||
|
if !strings.Contains(logs, "response_bytes=17") {
|
||||||
|
t.Fatalf("没有采用 Content-Length: 17:\n%s", logs)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs, "nope") {
|
||||||
|
t.Fatalf("日志里出现了响应体内容:\n%s", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedactURLCredentials 钉住 `redactURL` 的凭据兜底(复审 r1 的 nit)。
|
||||||
|
func TestRedactURLCredentials(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"普通地址不动", "http://127.0.0.1:9880", "http://127.0.0.1:9880"},
|
||||||
|
{"合法 URL 的 userinfo 被丢掉", "http://user:pass@127.0.0.1:9880", "http://127.0.0.1:9880"},
|
||||||
|
{"非法 URL 含 @ ⇒ 整体打码", "http://user:pass@", "<redacted>"},
|
||||||
|
{"无 scheme 含 @ ⇒ 整体打码", "user:pass@host", "<redacted>"},
|
||||||
|
{"空串原样", "", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := redactURL(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("redactURL(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "pass") {
|
||||||
|
t.Fatalf("凭据漏进可打日志的地址: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ func selectionCfg() Config {
|
|||||||
// TestResolveMapsPersonaToCharacter 分支①:有映射 ⇒ 用映射到的角色(两个 persona
|
// TestResolveMapsPersonaToCharacter 分支①:有映射 ⇒ 用映射到的角色(两个 persona
|
||||||
// 各自映射到不同角色,绝不互相串)。
|
// 各自映射到不同角色,绝不互相串)。
|
||||||
func TestResolveMapsPersonaToCharacter(t *testing.T) {
|
func TestResolveMapsPersonaToCharacter(t *testing.T) {
|
||||||
cfg := selectionCfg().Normalized(discardLogger())
|
cfg := selectionCfg().Normalized()
|
||||||
|
|
||||||
got, err := cfg.Resolve("cyrene")
|
got, err := cfg.Resolve("cyrene")
|
||||||
if err != nil || got.Character != "char_84fb88a3" || got.Source != SourceMapped {
|
if err != nil || got.Character != "char_84fb88a3" || got.Source != SourceMapped {
|
||||||
@@ -45,7 +45,7 @@ func TestResolveMapsPersonaToCharacter(t *testing.T) {
|
|||||||
// TestResolveUnmappedPersonaRefuses 分支④(红线):配了 personas 但**没有这个
|
// TestResolveUnmappedPersonaRefuses 分支④(红线):配了 personas 但**没有这个
|
||||||
// persona 的键** ⇒ 拒绝,**不看 default**、也不借用任何别的角色。
|
// persona 的键** ⇒ 拒绝,**不看 default**、也不借用任何别的角色。
|
||||||
func TestResolveUnmappedPersonaRefuses(t *testing.T) {
|
func TestResolveUnmappedPersonaRefuses(t *testing.T) {
|
||||||
cfg := selectionCfg().Normalized(discardLogger())
|
cfg := selectionCfg().Normalized()
|
||||||
|
|
||||||
got, err := cfg.Resolve("stranger")
|
got, err := cfg.Resolve("stranger")
|
||||||
if !errors.Is(err, ErrPersonaUnmapped) {
|
if !errors.Is(err, ErrPersonaUnmapped) {
|
||||||
@@ -63,7 +63,7 @@ func TestResolveUnmappedPersonaRefuses(t *testing.T) {
|
|||||||
// TestResolveUnresolvedPersonaUsesDefault 分支②:personaId 解析不到(空串)⇒ 用
|
// TestResolveUnresolvedPersonaUsesDefault 分支②:personaId 解析不到(空串)⇒ 用
|
||||||
// default(配了才用)。
|
// default(配了才用)。
|
||||||
func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) {
|
func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) {
|
||||||
cfg := selectionCfg().Normalized(discardLogger())
|
cfg := selectionCfg().Normalized()
|
||||||
got, err := cfg.Resolve("")
|
got, err := cfg.Resolve("")
|
||||||
if err != nil || got.Character != "char_fallback" || got.Source != SourceDefault {
|
if err != nil || got.Character != "char_fallback" || got.Source != SourceDefault {
|
||||||
t.Fatalf("空 persona ⇒ %+v err=%v, want default", got, err)
|
t.Fatalf("空 persona ⇒ %+v err=%v, want default", got, err)
|
||||||
@@ -73,7 +73,7 @@ func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) {
|
|||||||
// TestResolveUnresolvedPersonaWithoutDefaultRefuses 分支②的另一半:没配 default ⇒
|
// TestResolveUnresolvedPersonaWithoutDefaultRefuses 分支②的另一半:没配 default ⇒
|
||||||
// 不合成(可分类错误),绝不自己猜一个角色。
|
// 不合成(可分类错误),绝不自己猜一个角色。
|
||||||
func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) {
|
func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) {
|
||||||
cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized(discardLogger())
|
cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized()
|
||||||
if got, err := cfg.Resolve(""); !errors.Is(err, ErrNoCharacter) || got.Character != "" {
|
if got, err := cfg.Resolve(""); !errors.Is(err, ErrNoCharacter) || got.Character != "" {
|
||||||
t.Fatalf("空 persona + 无 default ⇒ %+v err=%v, want ErrNoCharacter", got, err)
|
t.Fatalf("空 persona + 无 default ⇒ %+v err=%v, want ErrNoCharacter", got, err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) {
|
|||||||
// TestResolveIsNotCachedAcrossPersonas 是主人担心那件事的**回归锁**:选角是纯函数,
|
// TestResolveIsNotCachedAcrossPersonas 是主人担心那件事的**回归锁**:选角是纯函数,
|
||||||
// 同一份配置先后解析两个 persona,第二次绝不沿用第一次的结果。
|
// 同一份配置先后解析两个 persona,第二次绝不沿用第一次的结果。
|
||||||
func TestResolveIsNotCachedAcrossPersonas(t *testing.T) {
|
func TestResolveIsNotCachedAcrossPersonas(t *testing.T) {
|
||||||
cfg := selectionCfg().Normalized(discardLogger())
|
cfg := selectionCfg().Normalized()
|
||||||
first, err := cfg.Resolve("cyrene")
|
first, err := cfg.Resolve("cyrene")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first: %v", err)
|
t.Fatalf("first: %v", err)
|
||||||
|
|||||||
@@ -158,15 +158,20 @@ func (s *Service) fallback(ctx context.Context, text string) (mvoice.Audio, stri
|
|||||||
|
|
||||||
// degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN
|
// degrade 记录一次 emotionTTS 降级:未启用是 DEBUG(正常状态),其余是 WARN
|
||||||
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
|
// (异常状态,但已被回落吸收)。日志只带文本长度,**绝不带正文**。
|
||||||
|
//
|
||||||
|
// attempts / failures 也挂在这条 WARN 上:`Stats()` 若没有消费者,生产日志里就永远
|
||||||
|
// 看不到"降级过几次",那这个计数等于没做(复审 r1 的 P2-3)。
|
||||||
func (s *Service) degrade(ctx context.Context, sel Selection, text string, err error) {
|
func (s *Service) degrade(ctx context.Context, sel Selection, text string, err error) {
|
||||||
|
stats := s.Stats()
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
// 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句
|
// 回合被取消/超时的情形已由 Client 记过(unreachable),这里只补一句
|
||||||
// 「因此这次没声音」,避免同一条失败刷两条 WARN。
|
// 「因此这次没声音」,避免同一条失败刷两条 WARN。
|
||||||
s.log.Debug("voice tts skipped after cancel", "provider", ProviderName, "text_len", len(text))
|
s.log.Debug("voice tts skipped after cancel", "provider", ProviderName,
|
||||||
|
"text_len", len(text), "attempts", stats.Attempts, "failures", stats.Failures)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.log.Warn("voice tts degraded to fallback chain",
|
s.log.Warn("voice tts degraded to fallback chain",
|
||||||
"provider", ProviderName, "persona", sel.PersonaID,
|
"provider", ProviderName, "persona", sel.PersonaID,
|
||||||
"character", sel.Character, "source", string(sel.Source),
|
"character", sel.Character, "source", string(sel.Source),
|
||||||
"text_len", len(text), "err", err)
|
"text_len", len(text), "attempts", stats.Attempts, "failures", stats.Failures, "err", err)
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -249,6 +250,36 @@ func TestServiceFallsBackWhenUpstreamRejects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestServiceDegradeLogsCounters 钉住 P2-3:降级那条 WARN 必须带上 attempts/failures ——
|
||||||
|
// 否则 `Stats()` 在生产日志里没有任何消费者,等于没做。
|
||||||
|
//
|
||||||
|
// 变异:从 degrade 的 WARN 里去掉这两个字段 ⇒ 本用例红。
|
||||||
|
func TestServiceDegradeLogsCounters(t *testing.T) {
|
||||||
|
rec := &recorder{wav: false, status: http.StatusInternalServerError}
|
||||||
|
srv := httptest.NewServer(rec.handler(t))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
log, buf := captureLogger()
|
||||||
|
cfg := personaCfg()
|
||||||
|
cfg.Enabled = true
|
||||||
|
cfg.BaseURL = srv.URL
|
||||||
|
svc := NewService(New(cfg, WithLogger(discardLogger())), &fakeChain{}, WithServiceLogger(log))
|
||||||
|
|
||||||
|
if _, _, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "cyrene"), chineseSpeech); err != nil {
|
||||||
|
t.Fatalf("主路失败应回落既有链,不该整体失败: %v", err)
|
||||||
|
}
|
||||||
|
logs := buf.String()
|
||||||
|
if !strings.Contains(logs, "failures=1") {
|
||||||
|
t.Fatalf("降级 WARN 里没有失败计数:\n%s", logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "attempts=1") {
|
||||||
|
t.Fatalf("降级 WARN 里没有尝试计数:\n%s", logs)
|
||||||
|
}
|
||||||
|
// 计数与正文同屏的风险同样要守住:这条 WARN 也不许带正文。
|
||||||
|
if strings.Contains(logs, "苏梦") {
|
||||||
|
t.Fatalf("降级 WARN 里出现了正文:\n%s", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务,
|
// TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务,
|
||||||
// 一次都不碰 emotionTTS 的 HTTP。
|
// 一次都不碰 emotionTTS 的 HTTP。
|
||||||
func TestServiceDisabledIsTransparent(t *testing.T) {
|
func TestServiceDisabledIsTransparent(t *testing.T) {
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ type speechRequest struct {
|
|||||||
SessionID string `json:"sessionId,omitempty"`
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// personaVoice 是"会按会话 persona 选发音人"的语音服务的**可选**扩展。只有
|
||||||
|
// emotionTTS 门面(`*voicetts.Service`)实现它;既有链、stub 服务都不实现 ⇒
|
||||||
|
// 未接 emotionTTS 时本片不为它额外查库,关闭态与引入之前逐字同行为。
|
||||||
|
type personaVoice interface {
|
||||||
|
// PrimaryActive 报告"按 persona 选发音"这一级是否真的在生效。
|
||||||
|
PrimaryActive() bool
|
||||||
|
}
|
||||||
|
|
||||||
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
|
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
|
||||||
// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket
|
// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket
|
||||||
// 帧上限(该上限会截断 base64 音频块)。
|
// 帧上限(该上限会截断 base64 音频块)。
|
||||||
@@ -110,7 +118,13 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或
|
// 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或
|
||||||
// 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。
|
// 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。
|
||||||
// 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。
|
// 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。
|
||||||
ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID))
|
//
|
||||||
|
// 🔴 只有"真的会按 persona 选发音"的语音服务才做这次库读取:未启用 emotionTTS
|
||||||
|
// 时注入的是既有链本身,此时本函数**不额外查库、不改 ctx** —— 关闭态与引入本
|
||||||
|
// 切片之前逐字同行为(复审要求自证的那一条)。
|
||||||
|
if pv, ok := s.voice.(personaVoice); ok && pv.PrimaryActive() {
|
||||||
|
ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID))
|
||||||
|
}
|
||||||
audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text)
|
audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.auditClaims(r, "audio.speech", "error")
|
s.auditClaims(r, "audio.speech", "error")
|
||||||
|
|||||||
@@ -229,6 +229,72 @@ func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSpeechEndpointDisabledVoiceDoesNotReadPersona 是**关闭态零行为变化**的自证
|
||||||
|
// (复审要求在当前 tip 上重跑的那一条,而我这一版动了 `gateway/audio.go`):
|
||||||
|
//
|
||||||
|
// 注入的语音服务不是"按 persona 选发音"的那一类(既有链 / stub)时,语音合成
|
||||||
|
// **一次都不查会话人格** —— 与引入本切片之前逐字同行为;反之(真 emotionTTS 门面)
|
||||||
|
// 才会查,说明这个闸不是"永远不查"。
|
||||||
|
//
|
||||||
|
// 变异:去掉 handleSpeech 里的 `pv.PrimaryActive()` 闸 ⇒ 本用例第一段红。
|
||||||
|
func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) {
|
||||||
|
env := newEnv(t, nil, false)
|
||||||
|
stub := &audioStubVoice{ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")}}
|
||||||
|
env.srv.SetVoice(stub)
|
||||||
|
counter := &countingHistory{fakeHistory: &fakeHistory{}}
|
||||||
|
env.srv.envResolver = turnEnvResolver{history: counter, log: env.srv.log}
|
||||||
|
token := env.token(t, auth.RoleOwner)
|
||||||
|
|
||||||
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||||
|
"text": "你好", "sessionId": "web:sub-1",
|
||||||
|
}, token)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if got := counter.Calls(); got != 0 {
|
||||||
|
t.Fatalf("非 persona 语音服务却查了 %d 次会话人格(关闭态行为变了)", got)
|
||||||
|
}
|
||||||
|
if string(body) != string(fakeWAV("chain")) {
|
||||||
|
t.Fatalf("关闭态的音频不是既有链产物: %q", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对照:接上真的 emotionTTS 门面后,同一个端点会查一次人格(闸不是恒关)。
|
||||||
|
env2 := newEnv(t, nil, false)
|
||||||
|
env2.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{Personas: personaCharacters}, &silentBase{}))
|
||||||
|
counter2 := &countingHistory{fakeHistory: &fakeHistory{}}
|
||||||
|
env2.srv.envResolver = turnEnvResolver{history: counter2, log: env2.srv.log}
|
||||||
|
if resp, body := doReq(t, http.MethodPost, env2.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||||||
|
"text": "你好", "sessionId": "web:sub-1",
|
||||||
|
}, env.token(t, auth.RoleOwner)); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("对照组 status = %d, body = %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if got := counter2.Calls(); got != 1 {
|
||||||
|
t.Fatalf("启用 emotionTTS 后应查 1 次人格, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingHistory 记录 SessionPersona 被查了几次(同时满足 HistoryStore 的其余方法)。
|
||||||
|
type countingHistory struct {
|
||||||
|
*fakeHistory
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionPersona 计数并回一个固定的 persona。
|
||||||
|
func (h *countingHistory) SessionPersona(context.Context, string) (string, error) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.calls++
|
||||||
|
h.mu.Unlock()
|
||||||
|
return "furina", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls 返回查询次数。
|
||||||
|
func (h *countingHistory) Calls() int {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.calls
|
||||||
|
}
|
||||||
|
|
||||||
// asString 安全地取一个 string 值(帧载荷是 map[string]any)。
|
// asString 安全地取一个 string 值(帧载荷是 map[string]any)。
|
||||||
func asString(v any) string {
|
func asString(v any) string {
|
||||||
s, _ := v.(string)
|
s, _ := v.(string)
|
||||||
|
|||||||
Reference in new issue
Block a user