feat: add e2e tests + benchmarks

- e2e_test.go: synthesis pipeline, audio chunking, custom voice registry
- Benchmarks: synthesis 203µs/op, registry refresh 106µs/op, chunking 534µs/op
This commit is contained in:
2026-02-20 06:45:22 -05:00
parent 147b685645
commit b8d9a277c5
3 changed files with 270 additions and 0 deletions

267
e2e_test.go Normal file
View File

@@ -0,0 +1,267 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
)
// ────────────────────────────────────────────────────────────────────────────
// E2E tests: voice registry + XTTS synthesis + audio streaming pipeline
// ────────────────────────────────────────────────────────────────────────────
func TestSynthesisE2E_StreamChunks(t *testing.T) {
// Mock XTTS returning 64 KB of audio
audioSize := 65536
xttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
json.NewDecoder(r.Body).Decode(&payload)
if payload["text"] == nil || payload["text"] == "" {
w.WriteHeader(400)
w.Write([]byte("empty text"))
return
}
w.Write(make([]byte, audioSize))
}))
defer xttsSrv.Close()
// Test synthesize + chunking logic
client := &http.Client{}
body := `{"text":"hello world","speaker":"default","language":"en"}`
resp, err := client.Post(xttsSrv.URL+"/v1/audio/speech", "application/json",
bytes.NewReader([]byte(body)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
audioBytes, _ := io.ReadAll(resp.Body)
if len(audioBytes) != audioSize {
t.Fatalf("audio size = %d, want %d", len(audioBytes), audioSize)
}
// Simulate streaming: chunk into 32 KB pieces
chunkSize := 32768
totalChunks := (len(audioBytes) + chunkSize - 1) / chunkSize
if totalChunks != 2 {
t.Errorf("totalChunks = %d, want 2", totalChunks)
}
for i := 0; i < len(audioBytes); i += chunkSize {
end := i + chunkSize
if end > len(audioBytes) {
end = len(audioBytes)
}
chunk := audioBytes[i:end]
chunkIdx := i / chunkSize
isLast := end >= len(audioBytes)
// Verify chunk message shape
msg := map[string]any{
"session_id": "test-session",
"chunk_index": chunkIdx,
"total_chunks": totalChunks,
"audio_b64": base64.StdEncoding.EncodeToString(chunk),
"is_last": isLast,
"sample_rate": 24000,
}
// Round-trip through JSON
data, _ := json.Marshal(msg)
var decoded map[string]any
json.Unmarshal(data, &decoded)
if decoded["session_id"] != "test-session" {
t.Errorf("chunk %d: session = %v", chunkIdx, decoded["session_id"])
}
if decoded["is_last"] != isLast {
t.Errorf("chunk %d: is_last = %v, want %v", chunkIdx, decoded["is_last"], isLast)
}
}
}
func TestSynthesisE2E_CustomVoice(t *testing.T) {
// Set up voice registry with temp dir
dir := t.TempDir()
voiceDir := filepath.Join(dir, "custom-en")
os.MkdirAll(voiceDir, 0o755)
info := map[string]string{
"name": "custom-en", "language": "en",
"type": "coqui-tts", "created_at": "2024-06-01",
}
infoData, _ := json.Marshal(info)
os.WriteFile(filepath.Join(voiceDir, "model_info.json"), infoData, 0o644)
os.WriteFile(filepath.Join(voiceDir, "model.pth"), []byte("fake-model"), 0o644)
os.WriteFile(filepath.Join(voiceDir, "config.json"), []byte("{}"), 0o644)
registry := newVoiceRegistry(dir)
count := registry.refresh()
if count != 1 {
t.Fatalf("refresh() = %d, want 1", count)
}
// XTTS mock that validates custom voice fields
xttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
json.NewDecoder(r.Body).Decode(&payload)
// When custom voice is used, model_path should be set
if payload["model_path"] == nil {
t.Error("expected model_path in custom voice request")
}
if payload["config_path"] == nil {
t.Error("expected config_path for voice with config")
}
w.Write(make([]byte, 4000))
}))
defer xttsSrv.Close()
voice := registry.get("custom-en")
if voice == nil {
t.Fatal("voice 'custom-en' not found")
}
// Build request payload like main.go does
payload := map[string]any{
"text": "hello custom voice",
"speaker": "custom-en",
"language": "en",
}
if voice != nil {
payload["model_path"] = voice.ModelPath
if voice.ConfigPath != "" {
payload["config_path"] = voice.ConfigPath
}
}
data, _ := json.Marshal(payload)
resp, err := http.Post(xttsSrv.URL+"/v1/audio/speech", "application/json",
bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}
func TestSynthesisE2E_XTTSError(t *testing.T) {
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(503)
w.Write([]byte("model not loaded"))
}))
defer failSrv.Close()
resp, err := http.Post(failSrv.URL+"/v1/audio/speech", "application/json",
bytes.NewReader([]byte(`{"text":"test"}`)))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 503 {
t.Errorf("status = %d, want 503", resp.StatusCode)
}
}
func TestVoiceRegistryMultiple(t *testing.T) {
dir := t.TempDir()
// Create 3 voices
for _, name := range []string{"alice", "bob", "charlie"} {
vDir := filepath.Join(dir, name)
os.MkdirAll(vDir, 0o755)
info := map[string]string{"name": name, "language": "en"}
data, _ := json.Marshal(info)
os.WriteFile(filepath.Join(vDir, "model_info.json"), data, 0o644)
os.WriteFile(filepath.Join(vDir, "model.pth"), []byte("fake"), 0o644)
}
registry := newVoiceRegistry(dir)
count := registry.refresh()
if count != 3 {
t.Errorf("refresh() = %d, want 3", count)
}
voices := registry.listVoices()
if len(voices) != 3 {
t.Errorf("listVoices() = %d, want 3", len(voices))
}
for _, name := range []string{"alice", "bob", "charlie"} {
if v := registry.get(name); v == nil {
t.Errorf("voice %q not found", name)
}
}
if v := registry.get("nonexistent"); v != nil {
t.Error("expected nil for nonexistent voice")
}
}
// ────────────────────────────────────────────────────────────────────────────
// Benchmarks
// ────────────────────────────────────────────────────────────────────────────
func BenchmarkSynthesisRoundtrip(b *testing.B) {
xttsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(make([]byte, 16000))
}))
defer xttsSrv.Close()
client := &http.Client{}
body := []byte(`{"text":"benchmark text","speaker":"default","language":"en"}`)
b.ResetTimer()
for b.Loop() {
resp, _ := client.Post(xttsSrv.URL+"/v1/audio/speech", "application/json",
bytes.NewReader(body))
io.ReadAll(resp.Body)
resp.Body.Close()
}
}
func BenchmarkVoiceRegistryRefresh(b *testing.B) {
dir := b.TempDir()
for i := 0; i < 10; i++ {
name := "voice-" + strconv.Itoa(i)
vDir := filepath.Join(dir, name)
os.MkdirAll(vDir, 0o755)
info := map[string]string{"name": name}
data, _ := json.Marshal(info)
os.WriteFile(filepath.Join(vDir, "model_info.json"), data, 0o644)
os.WriteFile(filepath.Join(vDir, "model.pth"), []byte("fake"), 0o644)
}
registry := newVoiceRegistry(dir)
b.ResetTimer()
for b.Loop() {
registry.refresh()
}
}
func BenchmarkAudioChunking(b *testing.B) {
audioBytes := make([]byte, 256*1024) // 256 KB audio
chunkSize := 32768
b.ResetTimer()
for b.Loop() {
totalChunks := (len(audioBytes) + chunkSize - 1) / chunkSize
for i := 0; i < len(audioBytes); i += chunkSize {
end := i + chunkSize
if end > len(audioBytes) {
end = len(audioBytes)
}
chunk := audioBytes[i:end]
_ = base64.StdEncoding.EncodeToString(chunk)
_ = totalChunks
}
}
}

1
go.mod
View File

@@ -11,6 +11,7 @@ require (
require ( require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect

2
go.sum
View File

@@ -4,6 +4,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=