169 lines
7.4 KiB
Go
169 lines
7.4 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func providerSnapshotRaw(mode string, asr, llm, tts bool) []byte {
|
|
value := map[string]any{
|
|
"mode": mode,
|
|
"prompt": map[string]any{"text": "approved system"},
|
|
"asr": map[string]any{"provider_ref": "asr-ref", "model": "asr-model", "language": "zh-CN", "timeout_ms": 50},
|
|
"llm": map[string]any{"provider_ref": "llm-ref", "model": "llm-model", "temperature": 0.0, "max_tokens": 17, "timeout_ms": 50},
|
|
"tts": map[string]any{"provider_ref": "tts-ref", "model": "tts-model", "voice": "voice-a", "speed": 1.0, "timeout_ms": 50},
|
|
}
|
|
if !asr {
|
|
delete(value["asr"].(map[string]any), "provider_ref")
|
|
}
|
|
if !llm {
|
|
delete(value["llm"].(map[string]any), "provider_ref")
|
|
}
|
|
if !tts {
|
|
delete(value["tts"].(map[string]any), "provider_ref")
|
|
}
|
|
raw, _ := json.Marshal(value)
|
|
return raw
|
|
}
|
|
|
|
func wavPCM16(sampleRate int, pcm []byte) []byte {
|
|
data := make([]byte, 44+len(pcm))
|
|
copy(data[:4], "RIFF")
|
|
binary.LittleEndian.PutUint32(data[4:8], uint32(len(data)-8))
|
|
copy(data[8:12], "WAVE")
|
|
copy(data[12:16], "fmt ")
|
|
binary.LittleEndian.PutUint32(data[16:20], 16)
|
|
binary.LittleEndian.PutUint16(data[20:22], 1)
|
|
binary.LittleEndian.PutUint16(data[22:24], 1)
|
|
binary.LittleEndian.PutUint32(data[24:28], uint32(sampleRate))
|
|
binary.LittleEndian.PutUint32(data[28:32], uint32(sampleRate*2))
|
|
binary.LittleEndian.PutUint16(data[32:34], 2)
|
|
binary.LittleEndian.PutUint16(data[34:36], 16)
|
|
copy(data[36:40], "data")
|
|
binary.LittleEndian.PutUint32(data[40:44], uint32(len(pcm)))
|
|
copy(data[44:], pcm)
|
|
return data
|
|
}
|
|
|
|
func TestProviderPipelineFullTurnUsesConfiguredLLMAndTTS(t *testing.T) {
|
|
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
|
|
wav := wavPCM16(16000, pcm)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/chat/completions"):
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"approved reply"}}]}`))
|
|
case strings.HasSuffix(r.URL.Path, "/generation"):
|
|
_, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + "PLACEHOLDER" + `"}}}`))
|
|
case r.URL.Path == "/audio.wav":
|
|
w.Header().Set("Content-Type", "audio/wav")
|
|
_, _ = w.Write(wav)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
// Replace the placeholder without putting a second server or a public URL in
|
|
// the fixture; the provider still uses the same bounded test HTTP client.
|
|
server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/chat/completions"):
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"approved reply"}}]}`))
|
|
case strings.HasSuffix(r.URL.Path, "/generation"):
|
|
_, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + server.URL + `/audio.wav"}}}`))
|
|
case r.URL.Path == "/audio.wav":
|
|
w.Header().Set("Content-Type", "audio/wav")
|
|
_, _ = w.Write(wav)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
pipeline := &ProviderPipeline{cfg: ProviderPipelineConfig{
|
|
VolcAppID: "asr-app", VolcAPIKey: "asr-key", BailianAPIKey: "llm-key",
|
|
BailianBaseURL: server.URL + "/v1", HTTPClient: server.Client(), MaxAudioBytes: 1024,
|
|
}}
|
|
pipeline.recognizeFn = func(context.Context, string, string, []byte) (string, error) { return "approved transcript", nil }
|
|
result, err := pipeline.RunTurn(context.Background(), Snapshot{Mode: ModeFullAI, Raw: providerSnapshotRaw("full_ai", true, true, true)}, []byte{1, 2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Transcript != "approved transcript" || result.Reply != "approved reply" || string(result.AudioPCM16) != string(pcm) {
|
|
t.Fatalf("unexpected full turn result: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestProviderPipelineRejectsInvalidTurnInputs(t *testing.T) {
|
|
base := &ProviderPipeline{cfg: ProviderPipelineConfig{VolcAppID: "asr", VolcAPIKey: "key"}}
|
|
base.recognizeFn = func(context.Context, string, string, []byte) (string, error) { return "", nil }
|
|
cases := []struct {
|
|
name string
|
|
pipeline *ProviderPipeline
|
|
snapshot Snapshot
|
|
pcm []byte
|
|
want string
|
|
}{
|
|
{"empty pcm", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, nil, "input PCM is empty"},
|
|
{"invalid mode", base, Snapshot{Mode: Mode("invalid"), Raw: providerSnapshotRaw("invalid", true, false, false)}, []byte{1}, "unsupported AI mode"},
|
|
{"bad snapshot", base, Snapshot{Mode: ModeASROnly, Raw: []byte("{")}, []byte{1}, "decode immutable AI snapshot"},
|
|
{"missing asr", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", false, false, false)}, []byte{1}, "ASR provider ref is required"},
|
|
{"empty transcript", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, []byte{1}, "ASR returned empty transcript"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := tc.pipeline.RunTurn(context.Background(), tc.snapshot, tc.pcm); err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("error=%v, want %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
failingASR := &ProviderPipeline{cfg: base.cfg, recognizeFn: func(context.Context, string, string, []byte) (string, error) { return "", context.DeadlineExceeded }}
|
|
if _, err := failingASR.RunTurn(context.Background(), Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, []byte{1}); err == nil || !strings.Contains(err.Error(), "ASR failed") {
|
|
t.Fatalf("ASR error was hidden: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderPipelineWAVValidationAndResampling(t *testing.T) {
|
|
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
|
|
for _, tc := range []struct {
|
|
name string
|
|
data []byte
|
|
want string
|
|
}{
|
|
{"short", []byte("RIFF"), "not RIFF/WAVE"},
|
|
{"wrong container", []byte("RIFFxxxxNOPE"), "not RIFF/WAVE"},
|
|
{"bad fmt", append([]byte("RIFFxxxxWAVEfmt "), 2, 0, 0, 0, 0, 0), "invalid WAV fmt"},
|
|
{"bad format", wavPCM16(16000, pcm)[:44], "unsupported WAV format"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := decodeWAVToPCM16(tc.data); err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("error=%v, want %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
resampled, err := decodeWAVToPCM16(wavPCM16(8000, pcm))
|
|
if err != nil || len(resampled) == 0 {
|
|
t.Fatalf("valid non-16k WAV was not resampled: len=%d err=%v", len(resampled), err)
|
|
}
|
|
if got := resamplePCM16(nil, 8000, 16000); len(got) != 0 {
|
|
t.Fatalf("empty resample returned %d bytes", len(got))
|
|
}
|
|
}
|
|
|
|
func TestProviderPipelineSynthesizeFailureModes(t *testing.T) {
|
|
p := &ProviderPipeline{cfg: ProviderPipelineConfig{VolcAppID: "asr", VolcAPIKey: "key", BailianAPIKey: "key", BailianBaseURL: "://bad"}}
|
|
if _, err := p.Synthesize(context.Background(), Snapshot{Mode: Mode("other")}, "text"); err == nil || !strings.Contains(err.Error(), "unsupported AI mode") {
|
|
t.Fatal("unsupported mode accepted")
|
|
}
|
|
if _, err := p.Synthesize(context.Background(), Snapshot{Mode: ModeFullAI, Raw: []byte(`{"mode":"full_ai"}`)}, "text"); err == nil || !strings.Contains(err.Error(), "TTS provider ref") {
|
|
t.Fatal("missing TTS provider accepted")
|
|
}
|
|
if _, err := p.Synthesize(context.Background(), Snapshot{Mode: ModeFullAI, Raw: providerSnapshotRaw("full_ai", true, true, true)}, "text"); err == nil || !strings.Contains(err.Error(), "invalid BAILIAN_BASE_URL") {
|
|
t.Fatal("invalid TTS endpoint accepted")
|
|
}
|
|
}
|