Files
go-sip/internal/ai/bailian_realtime_test.go
T

196 lines
7.1 KiB
Go

package ai
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
)
type mockCommand struct {
Header struct {
Action string `json:"action"`
TaskID string `json:"task_id"`
} `json:"header"`
Payload map[string]any `json:"payload"`
}
func mockRead(t *testing.T, c *websocket.Conn, ctx context.Context) mockCommand {
t.Helper()
kind, body, err := c.Read(ctx)
if err != nil || kind != websocket.MessageText {
t.Errorf("expected command: %v", err)
return mockCommand{}
}
var cmd mockCommand
if json.Unmarshal(body, &cmd) != nil {
t.Error("invalid command")
}
return cmd
}
func mockEvent(c *websocket.Conn, ctx context.Context, id, event string, payload any) error {
body, _ := json.Marshal(map[string]any{"header": map[string]any{"task_id": id, "event": event}, "payload": payload})
return c.Write(ctx, websocket.MessageText, body)
}
func TestCosyVoiceTaskUsesExactModelVoiceRateAndCompletePCM(t *testing.T) {
bound := bailianFixture(t)
expected := []byte{1, 2, 3, 4, 5, 6}
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Header.Get("Authorization") != "Bearer mock-key" {
t.Error("credential not preserved")
}
c, err := websocket.Accept(w, r, nil)
if err != nil {
t.Error(err)
return
}
defer c.CloseNow()
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
cmd := mockRead(t, c, ctx)
params := cmd.Payload["parameters"].(map[string]any)
if cmd.Header.Action != "run-task" || cmd.Payload["model"] != "cosyvoice-v3-flash" || params["voice"] != "longanyang" || params["rate"] != 1.25 || params["sample_rate"] != float64(16000) || params["format"] != "pcm" {
t.Errorf("task synthesis parameters changed: %+v", cmd.Payload)
}
if err = mockEvent(c, ctx, cmd.Header.TaskID, "task-started", map[string]any{}); err != nil {
return
}
text := mockRead(t, c, ctx)
if text.Header.Action != "continue-task" || text.Header.TaskID != cmd.Header.TaskID || text.Payload["input"].(map[string]any)["text"] != "本地模拟。" {
t.Error("text/identity changed")
}
finish := mockRead(t, c, ctx)
if finish.Header.Action != "finish-task" || finish.Header.TaskID != cmd.Header.TaskID {
t.Error("task not explicitly finished")
}
c.Write(ctx, websocket.MessageBinary, expected[:3])
c.Write(ctx, websocket.MessageBinary, expected[3:])
mockEvent(c, ctx, cmd.Header.TaskID, "task-finished", map[string]any{})
}))
defer server.Close()
bound.TTS.Provider.WSEndpoint = "ws" + strings.TrimPrefix(server.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
audio, err := bound.Synthesize(ctx, "本地模拟。")
if err != nil || !bytes.Equal(audio, expected) || requests.Load() != 1 {
t.Fatalf("synthesis: %v %v requests=%d", audio, err, requests.Load())
}
}
func TestFunASRPreservesPCMAndReturnsOnlyConfirmedFinalSentences(t *testing.T) {
bound := bailianFixture(t)
var bytesReceived atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, nil)
if err != nil {
t.Error(err)
return
}
defer c.CloseNow()
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
cmd := mockRead(t, c, ctx)
if cmd.Payload["model"] != "fun-asr-flash-8k-realtime-2026-01-28" || cmd.Payload["parameters"].(map[string]any)["sample_rate"] != float64(16000) {
t.Error("ASR model/sample rate changed")
}
mockEvent(c, ctx, cmd.Header.TaskID, "task-started", map[string]any{})
for {
kind, raw, err := c.Read(ctx)
if err != nil {
t.Error(err)
return
}
if kind == websocket.MessageBinary {
bytesReceived.Add(int64(len(raw)))
continue
}
var finish mockCommand
json.Unmarshal(raw, &finish)
if finish.Header.Action != "finish-task" || finish.Header.TaskID != cmd.Header.TaskID {
t.Error("wrong finish")
}
break
}
for _, s := range []map[string]any{{"text": "忽略中间文本", "sentence_end": false}, {"text": "第一句。", "sentence_end": true, "begin_time": 0, "end_time": 500}, {"text": "第一句。", "sentence_end": true, "begin_time": 0, "end_time": 500}, {"text": "第二句。", "sentence_end": true, "begin_time": 500, "end_time": 1000}} {
mockEvent(c, ctx, cmd.Header.TaskID, "result-generated", map[string]any{"output": map[string]any{"sentence": s}})
}
mockEvent(c, ctx, cmd.Header.TaskID, "task-finished", map[string]any{})
}))
defer server.Close()
bound.ASR.Provider.Endpoint = "ws" + strings.TrimPrefix(server.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
text, err := bound.Recognize(ctx, make([]byte, 32000))
if err != nil || text != "第一句。第二句。" || bytesReceived.Load() != 32000 {
t.Fatalf("ASR: text=%q err=%v sent=%d", text, err, bytesReceived.Load())
}
}
func TestCosyVoiceFailureNeverReturnsPartialAudioOrRetries(t *testing.T) {
for _, scenario := range []string{"failed", "wrong-task", "premature-audio", "closed", "empty", "odd", "timeout", "unexpected", "oversized"} {
t.Run(scenario, func(t *testing.T) {
bound := bailianFixture(t)
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
c, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer c.CloseNow()
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
cmd := mockRead(t, c, ctx)
if scenario == "premature-audio" {
c.Write(ctx, websocket.MessageBinary, []byte{1, 2})
return
}
id := cmd.Header.TaskID
if scenario == "wrong-task" {
id = "wrong"
}
mockEvent(c, ctx, id, "task-started", map[string]any{})
if scenario == "wrong-task" {
return
}
mockRead(t, c, ctx)
mockRead(t, c, ctx)
switch scenario {
case "failed":
c.Write(ctx, websocket.MessageBinary, []byte{1, 2})
mockEvent(c, ctx, id, "task-failed", map[string]any{"secret": "never exposed"})
case "closed":
c.Write(ctx, websocket.MessageBinary, []byte{1, 2})
case "timeout":
<-ctx.Done()
case "unexpected":
mockEvent(c, ctx, id, "other-event", nil)
case "oversized":
c.Write(ctx, websocket.MessageBinary, make([]byte, maxBailianPCMBytes))
c.Write(ctx, websocket.MessageBinary, []byte{1, 2})
default:
if scenario == "odd" {
c.Write(ctx, websocket.MessageBinary, []byte{1})
}
mockEvent(c, ctx, id, "task-finished", map[string]any{})
}
}))
defer server.Close()
bound.TTS.Provider.WSEndpoint = "ws" + strings.TrimPrefix(server.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
audio, err := bound.Synthesize(ctx, "失败模拟")
if err == nil || len(audio) != 0 || requests.Load() != 1 || strings.Contains(err.Error(), "never exposed") || strings.Contains(err.Error(), "mock-key") {
t.Fatalf("failure hidden/retried/leaked: audio=%d err=%v requests=%d", len(audio), err, requests.Load())
}
})
}
}