Files
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 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。
2026-10-26 12:57:35 +08:00

55 lines
2.2 KiB
Go
Raw Permalink 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
// 本文件把 `*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
}