Files
AskaEth 3a2711a7e0 feat(voice): emotionTTS 接成一级发音人(UTF-8 请求体 + 失败回落既有链)
侦察结论(真机 + 上游源码):
- POST /api/analyze_emotion ⇒ {primary,complex,intensity};primary 闭集
  实测 平/喜/惊/哀/怒/厌/惧/急,intensity ∈ Low/Medium/High。
- POST /api/synthesize 收 emo_vector(float[])+emo_alpha,但还要 ref_audio_filename
  且只回 {"audio_url"}(服务端落盘),不适合做运行时直出。
- POST /v1/audio/speech 内部先 LLM 分析情绪 + 匹配参考音,再把情绪向量以
  [EMO:{vector}|{alpha}]base64:... 注入 voice 交给 indexTTS2 ⇒ **情绪确实影响
  语气,但由服务端从待念文本自推**,我方没有可映射的参数。
- 上游把下游载荷的 speed 硬编码 1.0(core/utils/llm_provider.py),客户端
  speed 是空转 ⇒ 本切片不暴露 speed,也不暴露 emotion/tone(不造假开关)。

实现:
- internal/runtime/voice/tts:Client(唯一 HTTP 出口,json.Encoder 产出 UTF-8
  请求体、超时/非 200/非音频/超限各自可分类)+ 字节嗅探庭裁 + Service(emotionTTS
  在前、失败回落既有链,永不上抛成回复失败)+ Provider 适配(可作链的一级)。
- internal/config/runtimeconfig/voicetts.go:$DATA_DIR/config/voice-tts.json
  (enabled/baseUrl/character/format/timeoutSec),零新 .env 键。
- cmd/cyrene/wire_voice_tts.go + wireFrontend 的唯一注入点:两条既有出站口
  (POST /api/v1/audio/speech 与 WS voice_output)同时用上;不新建端点/帧族。
- 严守 voice 入站闭集(不扩 Source/Status),出站音频不挂 voice DTO(塔裁决 B)。

测试:中文 UTF-8 请求体、非 200、超时、非音频、超限、未启用、空文本、嗅探表、
降级回落、未启用透传、两条出站口的字节级实测、面板配置解析、组合根映射;
三条变异(去中文 / 嗅探恒真 / 去掉回落)各自打红。真机 live 用例(build tag
emotiontts_live)实测 278,036 字节 WAV。
2026-10-26 12:46:57 +08:00

66 lines
2.5 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
// 本文件是**音频字节嗅探**:判断上游回给我们的到底是不是音频,以及它到底是什么
// 格式。
//
// 两条口径(与本仓「字节嗅探是唯一裁决权」同源,design/24 §10.1/§10.11):
// - **不信声明**:上游的 `Content-Type` 与我自己发出去的 `response_format` 都不
// 是判据 —— 服务端产物恒为 WAV,但会按 `response_format` 自报 media_type
// (`tts_router.py` 的 `FileResponse`),照抄它就会把 WAV 标成 mp3。
// - **不是音频就报错**:上游 200 也可能是一段 JSON/HTML(配置错、反代错误页),
// 把它当音频落盘/下发等于给客户端一个打不开的文件。判不出来就 **ErrNotAudio**,
// 由调用方回落既有链 —— 绝不"拿到字节就算成功"。
// sniffAudio 按魔数判断 b 是不是可下发的音频,返回格式名与是否认出。
//
// 认得的容器覆盖本仓音频白名单里最常见的那几种(wav / mp3 / ogg / flac /
// m4a(mp4) / webm / amr);认不出返回 ("", false)。
func sniffAudio(b []byte) (string, bool) {
switch {
case hasPrefix(b, "RIFF") && len(b) >= 12 && string(b[8:12]) == "WAVE":
return "wav", true
case hasPrefix(b, "ID3"):
return "mp3", true
case isMP3Frame(b):
return "mp3", true
case hasPrefix(b, "OggS"):
return "ogg", true
case hasPrefix(b, "fLaC"):
return "flac", true
case len(b) >= 12 && string(b[4:8]) == "ftyp":
return "m4a", true
case hasPrefix(b, "\x1a\x45\xdf\xa3"):
return "webm", true
case hasPrefix(b, "#!AMR"):
return "amr", true
default:
return "", false
}
}
// isMP3Frame 识别无 ID3 头的裸 MPEG 音频帧同步字(11 位全 1 + 合法版本/层)。
// 只做**粗判**:音频帧的头两字节 + 一个合法比特率/采样率索引即可,目的是把
// "看起来像二进制"与"真的是 MP3"区分开,不追求完整解析。
func isMP3Frame(b []byte) bool {
if len(b) < 3 {
return false
}
if b[0] != 0xFF || b[1]&0xE0 != 0xE0 {
return false
}
// 版本位(b[1] 的 bit 3-4)不能是 01(保留值);层位(bit 1-2)不能是 00。
if b[1]&0x18 == 0x08 || b[1]&0x06 == 0x00 {
return false
}
// 比特率索引(b[2] 的 bit 4-7)不能是 1111(非法)。
return b[2]>>4 != 0x0F
}
// hasPrefix 报告 b 是否以 s 开头(s 为 ASCII 字符串字面量)。
func hasPrefix(b []byte, s string) bool {
if len(b) < len(s) {
return false
}
return string(b[:len(s)]) == s
}