fix: require CosyVoice model-compatible voice
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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/线路能力验证。
|
||||
|
||||
@@ -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
@@ -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) }))
|
||||
|
||||
Reference in New Issue
Block a user