主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。
配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
{"enabled":true,
"personas":{"<personaId>":"<characterId>"},
"default":"<characterId>", // 只服务「解析不到 personaId」
"format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。
选角四条分支(Config.Resolve,纯函数、无缓存):
① persona 有映射 ⇒ 用它;
② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
**绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。
两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。
Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。
测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
55 lines
2.2 KiB
Go
55 lines
2.2 KiB
Go
package tts
|
||
|
||
// 本文件把 `*Client` 变成一个**既有语音链的一级**(`mvoice.Provider`)。
|
||
//
|
||
// 当前组装走的是 `Service`(包在整条链前面,失败回落),不是把本级插进链里
|
||
// (`multimodal/voice.New` 组装的各级列表不外露,插进去要先改那条链,超出本切片
|
||
// 范围)。提供这个适配是为了让"作为一级"这条路**现在就是通的**:一旦组装方式
|
||
// 改成显式列级(`voice.NewChain(...)`),本级可以原样进链,不必重写 HTTP。
|
||
//
|
||
// 作为链的一级时**选角同样按 ctx 里的 personaId 现算**(`Config.Resolve`),
|
||
// 没有"这一级的固定发音人"这种概念 —— 因此它也不会把角色定死。
|
||
|
||
import (
|
||
"context"
|
||
|
||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
)
|
||
|
||
// Provider 是 emotionTTS 作为降级链一级的适配:只提供 TTS,STT 方向返回 nil
|
||
// (它不做转写;链遇到 nil 会自动跳过这一级去找下一个能转写的)。
|
||
type Provider struct {
|
||
client *Client
|
||
}
|
||
|
||
// NewProvider 用客户端构造一级。client 为 nil 时 Name 仍可用,TTS 返回错误。
|
||
func NewProvider(client *Client) *Provider { return &Provider{client: client} }
|
||
|
||
// Name 返回稳定的级别名(降级信号里就显示这个)。
|
||
func (p *Provider) Name() string { return ProviderName }
|
||
|
||
// STT 返回 nil:本一级不提供转写。
|
||
func (p *Provider) STT() mvoice.STT { return nil }
|
||
|
||
// TTS 返回本一级的合成实现。
|
||
func (p *Provider) TTS() mvoice.TTS { return ttsAdapter{p.client} }
|
||
|
||
// ttsAdapter 把 `Client.Speech` 适配成链要求的 `TTS` 形状(`Audio` 类型不同)。
|
||
type ttsAdapter struct{ client *Client }
|
||
|
||
// TTS 按 ctx 里的 personaId 选发音人后合成;client 未接线时返回明确错误而不是 panic。
|
||
func (a ttsAdapter) TTS(ctx context.Context, text string) (mvoice.Audio, error) {
|
||
if a.client == nil {
|
||
return mvoice.Audio{}, mvoice.ErrNotConfigured
|
||
}
|
||
selection, err := a.client.Config().Resolve(PersonaIDFromContext(ctx))
|
||
if err != nil {
|
||
return mvoice.Audio{}, err
|
||
}
|
||
audio, err := a.client.Speech(ctx, selection.Character, text)
|
||
if err != nil {
|
||
return mvoice.Audio{}, err
|
||
}
|
||
return mvoice.Audio{Format: audio.Format, Data: audio.Data}, nil
|
||
}
|