H-294: 修复 CosyVoice 模型音色契约 #5

Merged
rogee merged 1 commits from agent/integration/h-294-bailian-invalid-parameter into main 2026-08-18 17:28:45 +08:00
3 changed files with 49 additions and 29 deletions
+2 -1
View File
@@ -31,8 +31,9 @@ cmd/sip-demo 通过 UDP 向 61.132.228.221:5060 发起一通 IP 白名单、无
export SIP_TEST_NUMBER='<已授权内部测试号码>'
export BAILIAN_BASE_URL='wss://<WorkspaceId>.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference'
export BAILIAN_API_KEY='<server-side-key>'
export BAILIAN_TTS_VOICE='<cosyvoice-v3.5-plus voice-design ID>'
go run ./cmd/sip-demo
BAILIAN_BASE_URL 必须是上述北京地域工作空间的 CosyVoice WebSocket 完整端点;程序不拼接、回退或重试其他路径。除 loopback 测试外只接受 WSS。Key 只进入 WebSocket 握手的 Bearer 请求头,不写日志。可用 -audio music.wav 追加单声道 PCM16 WAV;未指定时追加两秒测试音。NAT 环境通过 SIP_ADVERTISE_IP 或 -advertise-ip 指定 SDP/Via 公网 IP。
BAILIAN_BASE_URL 必须是上述北京地域工作空间的 CosyVoice WebSocket 完整端点;BAILIAN_TTS_VOICE 必须是在同一工作空间中为 cosyvoice-v3.5-plus 创建的声音设计 voice ID,该模型不支持 longxiaochun 等系统音色。程序不拼接、回退或重试其他路径。除 loopback 测试外只接受 WSS。Key 只进入 WebSocket 握手的 Bearer 请求头,不写日志。可用 -audio music.wav 追加单声道 PCM16 WAV;未指定时追加两秒测试音。NAT 环境通过 SIP_ADVERTISE_IP 或 -advertise-ip 指定 SDP/Via 公网 IP。
本 MVP 不做注册、鉴权、重拨、ASR/LLM、AEC 或抖动缓冲。打断依赖线路提供独立上行;若远端回声触发误打断,用 -vad-threshold 校准,正式方案仍需 AEC/线路能力验证。
+4 -1
View File
@@ -48,7 +48,7 @@ func main() {
flag.StringVar(&cfg.server, "sip-server", envOr("SIP_SERVER", defaultSIPServer), "SIP host:port")
flag.StringVar(&cfg.audio, "audio", "", "optional mono PCM16 WAV to play after TTS")
flag.StringVar(&cfg.ttsText, "tts-text", defaultTTS, "text synthesized before the call")
flag.StringVar(&cfg.ttsVoice, "tts-voice", "longxiaochun", "Bailian voice ID")
flag.StringVar(&cfg.ttsVoice, "tts-voice", os.Getenv("BAILIAN_TTS_VOICE"), "Bailian cosyvoice-v3.5-plus voice-design ID")
flag.StringVar(&cfg.logPath, "sip-log", "logs/sip.log", "redacted SIP log path")
flag.StringVar(&cfg.recordDir, "record-dir", "recordings", "untracked RX/TX WAV directory")
flag.StringVar(&cfg.localAddr, "sip-local", envOr("SIP_LOCAL_ADDR", "0.0.0.0:0"), "local SIP UDP address")
@@ -212,6 +212,9 @@ func endAnsweredSession(session *sipgo.DialogClientSession, log *signalLog, acke
func mediaProgram(ctx context.Context, cfg config) ([]int16, error) {
var out []int16
if cfg.ttsText != "" {
if cfg.ttsVoice == "" {
return nil, errors.New("BAILIAN_TTS_VOICE or -tts-voice is required when tts-text is set")
}
base, key := os.Getenv("BAILIAN_BASE_URL"), os.Getenv("BAILIAN_API_KEY")
if base == "" || key == "" {
return nil, errors.New("BAILIAN_BASE_URL and BAILIAN_API_KEY are required when tts-text is set")
+43 -27
View File
@@ -89,24 +89,10 @@ func TestOfflineMediaAndSafety(t *testing.T) {
func TestBailianTTSContract(t *testing.T) {
const key = "test-key-that-must-not-leak"
type command struct {
Raw []byte
Header struct {
Action string `json:"action"`
TaskID string `json:"task_id"`
Streaming string `json:"streaming"`
TaskID string `json:"task_id"`
} `json:"header"`
Payload struct {
TaskGroup string `json:"task_group"`
Task string `json:"task"`
Function string `json:"function"`
Model string `json:"model"`
Parameters struct {
TextType string `json:"text_type"`
Voice string `json:"voice"`
Format string `json:"format"`
SampleRate int `json:"sample_rate"`
} `json:"parameters"`
Input map[string]any `json:"input"`
} `json:"payload"`
}
serverErrors := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -141,6 +127,7 @@ func TestBailianTTSContract(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
fail("decode command: %v", err)
}
got.Raw = append([]byte(nil), data...)
return got
}
writeEvent := func(event, taskID string) {
@@ -151,25 +138,37 @@ func TestBailianTTSContract(t *testing.T) {
}
run := read()
if run.Header.Action != "run-task" || run.Header.Streaming != "duplex" ||
run.Payload.TaskGroup != "audio" || run.Payload.Task != "tts" || run.Payload.Function != "SpeechSynthesizer" ||
run.Payload.Model != "cosyvoice-v3.5-plus" || run.Payload.Parameters.TextType != "PlainText" ||
run.Payload.Parameters.Voice != "test-voice" || run.Payload.Parameters.Format != "pcm" || run.Payload.Parameters.SampleRate != 24000 {
fail("invalid run-task contract")
}
if !isUUID(run.Header.TaskID) {
fail("task_id is not a UUID")
}
wantRun, _ := json.Marshal(map[string]any{
"header": map[string]any{"action": "run-task", "task_id": run.Header.TaskID, "streaming": "duplex"},
"payload": map[string]any{
"task_group": "audio", "task": "tts", "function": "SpeechSynthesizer", "model": "cosyvoice-v3.5-plus",
"parameters": map[string]any{"text_type": "PlainText", "voice": "test-voice", "format": "pcm", "sample_rate": 24000},
"input": map[string]any{},
},
})
if !bytes.Equal(run.Raw, wantRun) {
fail("invalid run-task payload")
}
writeEvent("task-started", run.Header.TaskID)
continued := read()
if continued.Header.Action != "continue-task" || continued.Header.TaskID != run.Header.TaskID ||
continued.Header.Streaming != "duplex" || continued.Payload.Input["text"] != "测试" {
fail("invalid continue-task contract")
wantContinue, _ := json.Marshal(map[string]any{
"header": map[string]any{"action": "continue-task", "task_id": run.Header.TaskID, "streaming": "duplex"},
"payload": map[string]any{"input": map[string]any{"text": "测试"}},
})
if !bytes.Equal(continued.Raw, wantContinue) {
fail("invalid continue-task payload")
}
finished := read()
if finished.Header.Action != "finish-task" || finished.Header.TaskID != run.Header.TaskID || finished.Header.Streaming != "duplex" {
fail("invalid finish-task contract")
wantFinish, _ := json.Marshal(map[string]any{
"header": map[string]any{"action": "finish-task", "task_id": run.Header.TaskID, "streaming": "duplex"},
"payload": map[string]any{"input": map[string]any{}},
})
if !bytes.Equal(finished.Raw, wantFinish) {
fail("invalid finish-task payload")
}
if err := wsutil.WriteServerBinary(conn, pcmToBytes([]int16{1, 2, 3})); err != nil {
fail("write audio: %v", err)
@@ -190,6 +189,23 @@ func TestBailianTTSContract(t *testing.T) {
}
}
func TestBailianTTSRequiresVoiceAndRedactsFailureDetail(t *testing.T) {
if _, err := mediaProgram(context.Background(), config{ttsText: "测试"}); err == nil || !strings.Contains(err.Error(), "BAILIAN_TTS_VOICE") {
t.Fatalf("missing voice error = %v", err)
}
const taskID = "2bf83b9a-baeb-4fda-8d9a-000000000000"
const secret = "test-key-that-must-not-leak"
event, err := parseTTSEvent([]byte(`{"header":{"task_id":"`+taskID+`","event":"task-failed","error_code":"InvalidParameter","error_message":"raw server detail `+secret+`"},"payload":{}}`), taskID)
if err != nil {
t.Fatal(err)
}
got := ttsFailure(event).Error()
if got != "TTS task failed: InvalidParameter" || strings.Contains(got, secret) || strings.Contains(got, "raw server detail") {
t.Fatalf("failure error = %q", got)
}
}
func TestBailianTTSEndpointMustBeExactAndSecure(t *testing.T) {
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits.Add(1) }))