Files
Cyrene_v2/internal/services/gateway/voice.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

231 lines
8.0 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 (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// VoiceService 为 voice_input WS 路由运行语音转文本与
// 文本转语音。*voice.Chain(降级 provider 链)与 *voice.Service
// (后台工作池)都满足它。它容忍 nil:未注入服务时
// voice 路由返回明确的错误。
type VoiceService interface {
// STT 把音频转写为文本。
STT(ctx context.Context, audio voice.Audio) (string, error)
// TTS 从文本合成音频。每请求的情绪/语气指令可以
// 经 ctx(voice.WithTTSInstruction)串下来,以在单次调用中覆盖
// 已配置的 VOICE_TTS_INSTRUCTION。
TTS(ctx context.Context, text string) (voice.Audio, error)
// CloneVoice 从公开参考音频 URL 创建自定义音色(声音复刻
// create_voice)并返回其 voice_id。
CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error)
}
// voiceProviderHeader 是二进制 /api/v1/audio/speech 响应上携带 provider
// 档位的响应头,因为那里无法添加 JSON 信号字段。
const voiceProviderHeader = "X-Voice-Provider"
// voiceProviderReporter 是 VoiceService 的可选扩展:一个能报告
// 结果是哪个 provider 档位(cloud / local / edge-tts / espeak / silent)
// 产出的服务。*voice.Chain 与 *voice.Service 实现了它;未实现的
// 自定义 VoiceService 仍被支持,回退到朴素的
// STT/TTS 方法且不报告档位。
type voiceProviderReporter interface {
STTWithProvider(ctx context.Context, audio voice.Audio) (string, string, error)
TTSWithProvider(ctx context.Context, text string) (voice.Audio, string, error)
}
// sttWithProvider 运行 STT 并返回转写文本加上服务它的
// provider 档位(注入的服务无法报告时为 "")。
func sttWithProvider(ctx context.Context, v VoiceService, audio voice.Audio) (string, string, error) {
if r, ok := v.(voiceProviderReporter); ok {
return r.STTWithProvider(ctx, audio)
}
text, err := v.STT(ctx, audio)
return text, "", err
}
// ttsWithProvider 运行 TTS 并返回音频加上服务它的
// provider 档位。
func ttsWithProvider(ctx context.Context, v VoiceService, text string) (voice.Audio, string, error) {
if r, ok := v.(voiceProviderReporter); ok {
return r.TTSWithProvider(ctx, text)
}
audio, err := v.TTS(ctx, text)
return audio, "", err
}
// providerSignal 为语音结果返回 "provider" 降级信号:结果为
// 空时返回实际 provider 档位,档位未知时返回 "silent",
// 而结果携带真实文本/音频时返回 ""(省略该字段)。它
// 只标记「没有真实语音/音频」;它绝不是业务错误。
func providerSignal(rung string, empty bool) string {
if !empty {
return ""
}
if rung == "" {
return "silent"
}
return rung
}
// voiceInputRequest 是 voice_input 帧解码后的载荷。Audio 是
// base64(或 data: URI)音频块;Format 是其编码("wav"/"mp3"/"ogg");
// Text 存在时覆盖 STT(已自行转写的客户端可跳过
// 识别);SessionID 默认 "web";DeviceID 是客户端产出的
// 端标识(design/16 §4.5),串进持久化的语音回合。
type voiceInputRequest struct {
Audio string `json:"audio"`
Format string `json:"format"`
Text string `json:"text"`
SessionID string `json:"sessionId"`
DeviceID string `json:"deviceId,omitempty"`
}
// SetVoice 注入 voice_input WS 路由所用的语音 STT/TTS 服务。
// 它是网关暴露的注入点,使组合根能接线 *voice.Chain 或
// *voice.Service 而无需触碰 Deps。nil 值会禁用该
// 路由(它以明确错误应答)。
func (s *Server) SetVoice(v VoiceService) {
s.voice = v
s.hub.voice = v
}
// handleVoiceInput 路由一个 voice_input 帧。未注入语音
// 服务时它以明确错误应答(绝不崩溃);否则它解码
// 请求并异步运行 STT→chat→TTS 回合,使读泵绝不被
// provider 延迟阻塞。
func (c *wsConn) handleVoiceInput(in wsInbound) {
if c.h.voice == nil {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "voice unavailable"}})
return
}
var req voiceInputRequest
_ = json.Unmarshal(in.Data, &req)
if strings.TrimSpace(req.Audio) == "" && strings.TrimSpace(req.Text) == "" {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "voice audio or text required"}})
return
}
go c.h.runVoiceTurn(c, req)
}
// runVoiceTurn 驱动一个语音回合:STT(或提供的转写)→ agent chat
// → TTS,然后向请求连接回复一个 voice_output 帧,
// 携带 base64 音频加上识别出的转写与助手回复。
func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
ctx := context.Background()
// 把客户端的 deviceID 串下来,使持久化的语音回合被发送端
// 标注(design/16 §4.5),与 ws_hub.go:240 一致。
ctx = withDeviceID(ctx, req.DeviceID)
if req.SessionID == "" {
req.SessionID = "web"
}
transcript := strings.TrimSpace(req.Text)
if transcript == "" {
if strings.TrimSpace(req.Audio) == "" {
h.voiceError(c, "voice audio or text required")
return
}
audio, err := decodeVoiceAudio(req.Audio, req.Format)
if err != nil {
h.log.Warn("voice audio decode", "err", err)
h.voiceError(c, "voice audio could not be decoded")
return
}
stt, err := h.voice.STT(ctx, audio)
if err != nil {
h.log.Warn("voice stt", "err", err)
h.voiceError(c, "voice transcription failed")
return
}
transcript = strings.TrimSpace(stt)
}
if transcript == "" {
h.voiceError(c, "no speech recognized")
return
}
if h.runner == nil {
h.voiceError(c, "chat service is not configured")
return
}
// Web 主会话是主人的私密对话,因此它绑定
// 全访问 env:记忆读+写,行为不变。角色来自该
// 会话的绑定(design/22 §4.5)。
env := h.envResolver.owner(ctx, req.SessionID)
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
if err != nil {
h.log.Warn("voice chat run", "err", err, "session", req.SessionID)
h.voiceError(c, "voice chat failed")
return
}
reply := res.Text
if strings.TrimSpace(reply) == "" {
reply = res.StopReason
}
audi, rung, err := ttsWithProvider(ctx, h.voice, reply)
if err != nil {
h.log.Warn("voice tts", "err", err)
h.voiceError(c, "voice synthesis failed")
return
}
data := map[string]any{
"audio": base64.StdEncoding.EncodeToString(audi.Data),
"format": audi.Format,
"text": transcript,
"reply": reply,
}
if sig := providerSignal(rung, len(audi.Data) == 0); sig != "" {
data["provider"] = sig
}
c.enqueueJSON(wsEvent{Type: wsVoiceOutput, Data: data})
}
// voiceError 为失败的语音回合回复一个带类型的错误帧。
func (h *hub) voiceError(c *wsConn, message string) {
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": message}})
}
// decodeVoiceAudio 把 base64(或 data:)音频载荷转成 voice.Audio。
// 格式在 req 设置时取自 req,否则从 data: URI mime 猜测,
// 再默认到 "wav"。
func decodeVoiceAudio(src, format string) (voice.Audio, error) {
payload := src
if strings.HasPrefix(src, "data:") {
if comma := strings.IndexByte(src, ','); comma >= 0 {
if format == "" {
format = formatFromMime(src[:comma])
}
payload = src[comma+1:]
}
}
if strings.TrimSpace(payload) == "" {
return voice.Audio{}, fmt.Errorf("voice: empty audio")
}
data, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return voice.Audio{}, fmt.Errorf("voice: decode audio: %w", err)
}
if format == "" {
format = "wav"
}
return voice.Audio{Format: format, Data: data}, nil
}
// formatFromMime 从 data: URI 的 mime 段猜测音频格式。
func formatFromMime(meta string) string {
lower := strings.ToLower(meta)
for _, f := range []string{"wav", "mp3", "ogg", "webm", "mpeg"} {
if strings.Contains(lower, f) {
return f
}
}
return "wav"
}