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