Files
Cyrene_v2/internal/services/gateway/audio.go
T
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

189 lines
7.5 KiB
Go
Raw 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 gateway
import (
"encoding/json"
"net/http"
"strconv"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
// maxAudioRequestBytes 限定 /api/v1/audio/* 请求可携带的 JSON 体大小,
// 使巨大的 base64 音频载荷无法耗尽内存。
const maxAudioRequestBytes = 32 << 20
// transcribeRequest 是 POST /api/v1/audio/transcribe 的请求体。Audio 是 base64
// (或 data: URI)数据块;Format 是其编码("wav"/"mp3"/"ogg")。
type transcribeRequest struct {
Audio string `json:"audio"`
Format string `json:"format"`
DeviceID string `json:"deviceId,omitempty"`
}
// speechRequest 是 POST /api/v1/audio/speech 的请求体。Voice/format 为前向
// 兼容而被接受;合成目前遵循服务端的
// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT 配置。Instruction 是可选的
// 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色
// (longxiaochun_v3)忽略它,绝不接收自由形式的指令。
//
// SessionID 是**可选**的会话标识(切片 M35,纯增量):发音人按
// 「该会话绑定的人格」解析(`chat_sessions.persona_id`)。省略它时本口没有会话
// 概念 ⇒ 走 emotionTTS 的 default 分支(没配 default 就不合成)。客户端**绝不**
// 通过它选音色:音色由服务端的 persona 映射决定,请求只声明"这是哪个会话"。
type speechRequest struct {
Text string `json:"text"`
Voice string `json:"voice,omitempty"`
Format string `json:"format,omitempty"`
Instruction string `json:"instruction,omitempty"`
SessionID string `json:"sessionId,omitempty"`
}
// handleTranscribe 对上传的音频执行 STT 并返回识别出的
// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket
// 帧上限(该上限会截断 base64 音频块)。
func (s *Server) handleTranscribe(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req transcribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
audio, err := decodeVoiceAudio(req.Audio, req.Format)
if err != nil {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "audio could not be decoded"))
return
}
if len(audio.Data) == 0 {
s.auditClaims(r, "audio.transcribe", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "audio is required"))
return
}
text, rung, err := sttWithProvider(r.Context(), s.voice, audio)
if err != nil {
s.auditClaims(r, "audio.transcribe", "error")
s.log.Warn("voice transcribe", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "stt_failed", "speech transcription failed"))
return
}
s.auditClaims(r, "audio.transcribe", "ok")
out := map[string]any{"text": text}
if sig := providerSignal(rung, strings.TrimSpace(text) == ""); sig != "" {
out["provider"] = sig
}
writeJSON(w, http.StatusOK, out)
}
// handleSpeech 为给定文本合成语音并写回原始音频
// 字节(audio/<format>)—— 二进制响应,不是 JSON 信封。
func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req speechRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Text) == "" {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text is required"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.speech", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
ctx := r.Context()
if req.Instruction != "" {
ctx = voice.WithTTSInstruction(ctx, req.Instruction)
}
// 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或
// 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。
// 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。
ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID))
audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text)
if err != nil {
s.auditClaims(r, "audio.speech", "error")
s.log.Warn("voice speech", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_failed", "speech synthesis failed"))
return
}
if len(audi.Data) == 0 {
s.auditClaims(r, "audio.speech", "error")
w.Header().Set(voiceProviderHeader, providerSignal(rung, true))
writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_empty", "no audio synthesized"))
return
}
s.auditClaims(r, "audio.speech", "ok")
w.Header().Set("Content-Type", audioMime(audi.Format))
w.Header().Set("Content-Length", strconv.Itoa(len(audi.Data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(audi.Data)
}
// voiceCloneRequest 是 POST /api/v1/audio/voice-clone 的请求体。URL 是公开的
// 参考音频 URL;prefix 是可选音色名前缀(≤10 个字母数字字符)。
// 二者都不记日志,URL 也不持久化(仅 owner + 审计,红线)。
type voiceCloneRequest struct {
URL string `json:"url"`
Prefix string `json:"prefix,omitempty"`
}
// handleVoiceClone 经百炼声音注册 API(声音复刻 create_voice)从公开
// 参考音频 URL 创建自定义 TTS 音色,并返回新的
// voice_id。仅 owner + 带审计;参考 URL 绝不写入审计
// 记录或日志(红线)。
func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes)
var req voiceCloneRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.URL) == "" {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "url is required"))
return
}
if s.voice == nil {
s.auditClaims(r, "audio.voice-clone", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_unavailable", "voice service is not configured"))
return
}
voiceID, err := s.voice.CloneVoice(r.Context(), req.URL, req.Prefix)
if err != nil {
s.auditClaims(r, "audio.voice-clone", "error")
// 不要记录原因:provider 的错误体可能回显参考 URL。
s.log.Warn("voice clone failed")
writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_clone_failed", "voice clone failed"))
return
}
s.auditClaims(r, "audio.voice-clone", "ok")
writeJSON(w, http.StatusOK, map[string]any{"voiceId": voiceID})
}
// audioMime 把音频格式映射为其用于语音响应的 Content-Type。
func audioMime(format string) string {
switch strings.ToLower(format) {
case "mp3", "mpeg":
return "audio/mpeg"
case "ogg", "oga":
return "audio/ogg"
case "webm":
return "audio/webm"
default:
return "audio/wav"
}
}