Files
Cyrene_v2/internal/runtime/voice/tts/service_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

179 lines
6.6 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 tts
// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的三条口径:
//
// 1. **健康时用 emotionTTS**,并如实报告级别名 `emotiontts`;
// 2. **失败时回落既有链**,且**不把失败上抛**(文本优先:合成挂掉不影响回复);
// 3. **未启用时完全透传**(行为与引入本切片之前逐字相同),STT/克隆永远走既有链。
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"testing"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// discardLogger 让测试不往 stderr 刷日志(WARN 的存在由行为断言,不靠肉眼)。
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// fakeChain 是一个可编程的底链替身,记录自己被调了几次。
type fakeChain struct {
ttsCalls int
sttCalls int
cloneCall int
audio mvoice.Audio
err error
}
func (f *fakeChain) STT(context.Context, mvoice.Audio) (string, error) {
f.sttCalls++
return "转写文本", nil
}
func (f *fakeChain) STTWithProvider(context.Context, mvoice.Audio) (string, string, error) {
f.sttCalls++
return "转写文本", "local", nil
}
func (f *fakeChain) TTS(context.Context, string) (mvoice.Audio, error) {
f.ttsCalls++
return f.audio, f.err
}
func (f *fakeChain) TTSWithProvider(context.Context, string) (mvoice.Audio, string, error) {
f.ttsCalls++
return f.audio, "silent", f.err
}
func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error) {
f.cloneCall++
return "voice-id", nil
}
// TestServiceUsesPrimaryWhenHealthy 钉住主路:健康时走 emotionTTS 且报告级别名。
func TestServiceUsesPrimaryWhenHealthy(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(wavBytes("her voice"))
})
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
if err != nil {
t.Fatalf("TTSWithProvider: %v", err)
}
if rung != ProviderName {
t.Errorf("rung = %q, want %q", rung, ProviderName)
}
if audio.Format != "wav" || string(audio.Data) == "fallback" {
t.Errorf("audio = %+v, want the emotionTTS bytes", audio)
}
if base.ttsCalls != 0 {
t.Errorf("主路健康却回落了底链 %d 次", base.ttsCalls)
}
}
// TestServiceFallsBackWhenPrimaryFails 钉住降级:主路失败 ⇒ 底链出声音,
// **错误不上抛**(文本优先)。
func TestServiceFallsBackWhenPrimaryFails(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"detail":"indexTTS2 down"}`))
})
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
if err != nil {
t.Fatalf("主路失败不该让合成整体失败(文本优先): %v", err)
}
if rung != "silent" {
t.Errorf("rung = %q, want 底链报告的那一级", rung)
}
if string(audio.Data) != "fallback" {
t.Errorf("audio = %+v, want 底链产物", audio)
}
if base.ttsCalls != 1 {
t.Errorf("底链被调用 %d 次, want 1", base.ttsCalls)
}
if got := svc.Stats(); got.Failures != 1 {
t.Errorf("降级没有计数: %+v", got)
}
}
// TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务,
// 一次都不碰 emotionTTS 的 HTTP。
func TestServiceDisabledIsTransparent(t *testing.T) {
client := New(Config{Enabled: false, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
if client.Enabled() {
t.Fatal("未启用的客户端不该报告 enabled")
}
base := &fakeChain{audio: mvoice.Audio{Format: "ogg", Data: []byte("chain")}}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
if svc.PrimaryActive() {
t.Fatal("PrimaryActive = true, want false")
}
audio, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
if err != nil {
t.Fatalf("TTSWithProvider: %v", err)
}
if audio.Format != "ogg" || base.ttsCalls != 1 {
t.Errorf("未启用时没有原样透传: audio=%+v calls=%d", audio, base.ttsCalls)
}
}
// TestServiceWithoutBase 钉住"没有底链"时的诚实失败(不 panic、可分类)。
func TestServiceWithoutBase(t *testing.T) {
svc := NewService(nil, nil, WithServiceLogger(discardLogger()))
if _, _, err := svc.TTSWithProvider(context.Background(), "hi"); !errors.Is(err, mvoice.ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
if _, err := svc.STT(context.Background(), mvoice.Audio{}); !errors.Is(err, mvoice.ErrProviderFailed) {
t.Fatalf("STT err = %v, want ErrProviderFailed", err)
}
if _, err := svc.CloneVoice(context.Background(), "http://x", "p"); !errors.Is(err, mvoice.ErrProviderFailed) {
t.Fatalf("CloneVoice err = %v, want ErrProviderFailed", err)
}
}
// TestServiceDelegatesSTTAndClone 钉住"只抢合成,不抢转写/复刻"。
func TestServiceDelegatesSTTAndClone(t *testing.T) {
client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
base := &fakeChain{}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
if text, rung, err := svc.STTWithProvider(context.Background(), mvoice.Audio{}); err != nil || text != "转写文本" || rung != "local" {
t.Fatalf("STT 没有原样委托: text=%q rung=%q err=%v", text, rung, err)
}
if id, err := svc.CloneVoice(context.Background(), "http://x", "p"); err != nil || id != "voice-id" {
t.Fatalf("CloneVoice 没有原样委托: id=%q err=%v", id, err)
}
if base.sttCalls != 1 || base.cloneCall != 1 {
t.Errorf("委托次数不对: stt=%d clone=%d", base.sttCalls, base.cloneCall)
}
}
// TestProviderAdapter 钉住"作为链的一级"这条路现在就是通的(STT 方向为 nil)。
func TestProviderAdapter(t *testing.T) {
client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
p := NewProvider(client)
if p.Name() != ProviderName {
t.Errorf("Name = %q, want %q", p.Name(), ProviderName)
}
if p.STT() != nil {
t.Error("emotionTTS 不提供转写,STT 应为 nil")
}
if _, err := p.TTS().TTS(context.Background(), " "); !errors.Is(err, ErrEmptyText) {
t.Errorf("err = %v, want ErrEmptyText(穿透到 Client 的守卫)", err)
}
if _, err := NewProvider(nil).TTS().TTS(context.Background(), "hi"); !errors.Is(err, mvoice.ErrNotConfigured) {
t.Errorf("未接线 err = %v, want ErrNotConfigured", err)
}
}