From 717ea8abc6770b8020d8c06bd3fa05be92213a0f Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 18 Aug 2026 17:21:39 +0800 Subject: [PATCH] fix: require CosyVoice model-compatible voice Co-authored-by: multica-agent --- README.md | 3 +- cmd/sip-demo/main.go | 5 ++- cmd/sip-demo/main_test.go | 70 ++++++++++++++++++++++++--------------- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9bd3cb9..395b10c 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ cmd/sip-demo 通过 UDP 向 61.132.228.221:5060 发起一通 IP 白名单、无 export SIP_TEST_NUMBER='<已授权内部测试号码>' export BAILIAN_BASE_URL='wss://.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference' export BAILIAN_API_KEY='' + export BAILIAN_TTS_VOICE='' 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/线路能力验证。 diff --git a/cmd/sip-demo/main.go b/cmd/sip-demo/main.go index a0f24e0..97d521d 100644 --- a/cmd/sip-demo/main.go +++ b/cmd/sip-demo/main.go @@ -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") diff --git a/cmd/sip-demo/main_test.go b/cmd/sip-demo/main_test.go index 545e9a3..a33ee2c 100644 --- a/cmd/sip-demo/main_test.go +++ b/cmd/sip-demo/main_test.go @@ -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) })) -- 2.54.0