侦察结论(真机 + 上游源码):
- 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。
66 lines
2.5 KiB
Go
66 lines
2.5 KiB
Go
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
|
||
}
|