Files
Cyrene_v2/internal/runtime/voice/tts/live_test.go
T
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

41 lines
1.3 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.
//go:build emotiontts_live
package tts
import (
"context"
"os"
"testing"
"time"
)
// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...`
// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。
//
// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖):
//
// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/
func TestLiveSpeech(t *testing.T) {
baseURL := os.Getenv("EMOTIONTTS_BASE_URL")
client := New(Config{Enabled: true, BaseURL: baseURL, Timeout: 120 * time.Second})
start := time.Now()
audio, err := client.Speech(context.Background(), chineseSpeech)
if err != nil {
t.Fatalf("对真服务合成失败: %v", err)
}
elapsed := time.Since(start)
if audio.Format != "wav" {
t.Errorf("format = %q, want wav(产物恒为 WAV)", audio.Format)
}
if len(audio.Data) == 0 {
t.Fatal("拿到的音频为空")
}
if string(audio.Data[:4]) != "RIFF" || string(audio.Data[8:12]) != "WAVE" {
t.Fatalf("不是 RIFF/WAVE: %q", string(audio.Data[:12]))
}
// 证据:真实字节数与耗时(塔要求的"实际字节")。
t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s",
client.Config().BaseURL, client.Character(), len(audio.Data), audio.Format, elapsed)
}