本文介紹通過WebSocket串連訪問CosyVoice即時語音合成服務的互動流程、介面地址和要求標頭。
介面地址
WebSocket 接口使用wss:// 协议,地址如下:
- 新加坡
- 華北2(北京)
wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference調用時請將{WorkspaceId}替換為真實的Workspace ID。wss://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference調用時請將{WorkspaceId}替換為真實的Workspace ID。阿里雲百鍊為華北2(北京)、新加坡地區推出了業務空間專屬網域名稱,能夠為推理請求提供卓越的效能和更高的穩定性,建議遷移至新網域名稱:
- 華北2(北京)地區:從
dashscope.aliyuncs.com遷移至{WorkspaceId}.cn-beijing.maas.aliyuncs.com - 新加坡地區:從
dashscope-intl.aliyuncs.com遷移至{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com
要求標頭
要求標頭中需添加如下資訊:| 參數 | 類型 | 是否必選 | 說明 |
|---|---|---|---|
| Authorization | string | 是 | 鑒權令牌,格式為 Bearer <your_api_key>,將 <your_api_key> 替換為實際的 API Key。 |
| user-agent | string | 否 | 用戶端標識,便於服務端追蹤來源。 |
| X-DashScope-WorkSpace | string | 否 | 阿里雲百鍊業務空間ID。 |
| X-DashScope-DataInspection | string | 否 | 是否啟用資料合規檢測功能。預設不傳或設為enable。如非必要,請勿啟用該參數。 |
Authorization 鑒權在 WebSocket 握手階段驗證。如果 API Key 無效或缺失,握手將失敗並返回 HTTP 401/403 錯誤。
互動流程
用戶端事件和服務端事件的詳細說明,請參見用戶端事件和服務端事件。 按時間順序,用戶端與服務端的互動流程如下:- 建立串連:用戶端與服務端建立WebSocket串連。
- 開啟任務:用戶端發送run-task事件以開啟任務。
- 等待確認:用戶端收到服務端返回的task-started事件,標誌著任務已成功開啟,可以進行後續步驟。
-
發送待合成文本:
用戶端按順序向服務端發送一個或多個包含待合成文本的continue-task事件,服務端接收到完整語句後返回result-generated事件和音頻流(文本長度有約束, 詳情參見continue-task事件中
text欄位描述)。支援多次發送continue-task事件,按順序提交文本片段。服務端接收文本片段後自動進行分句:- 完整語句立即合成,此時用戶端能夠接收到服務端返回的音頻
- 不完整語句緩衝至完整後合成,語句不完整時服務端不返迴音頻
-
接收音頻:通過
binary通道接收音頻流 - 通知服務端結束任務: 待文本發送完畢後,用戶端發送finish-task事件通知服務端結束任務,並繼續接收服務端返回的音頻流。此步驟不可省略,否則可能導致語音資料不完整。
- 任務結束: 用戶端收到服務端返回的task-finished事件,標誌著任務結束。
- 關閉串連:用戶端關閉WebSocket串連。
同一次合成任務中,run-task、所有 continue-task、finish-task 必須使用相同的
task_id。每次發起新任務時產生新的 task_id(如使用 UUID)。使用不同 task_id 會導致音頻錯亂或任務失敗。範例程式碼
以下範例使用通用 WebSocket 程式庫直接傳送協議事件,使用cosyvoice-v3-flash 和音色 longanyang。先取得 API Key並設定 DASHSCOPE_API_KEY 環境變數,將 {WorkspaceId} 替換為對應地區的業務空間 ID;使用其他地區時同時替換介面位址和 API Key。
- Java
- Python
- Go
- C#
- PHP
- Node.js
建議使用 Java DashScope SDK 進行開發,請參見CosyVoice Java SDK。以下是 Java WebSocket 直連樣本,運行前請匯入以下依賴:Java代碼如下:
Java-WebSocketjackson-databind
pom.xml
Copy
<dependencies>
<!-- WebSocket Client -->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.3</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.0</version>
</dependency>
</dependencies>
Copy
import com.fasterxml.jackson.databind.ObjectMapper;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.util.*;
public class TTSWebSocketClient extends WebSocketClient {
private final String taskId = UUID.randomUUID().toString();
private final String outputFile = "output_" + System.currentTimeMillis() + ".mp3";
private boolean taskFinished = false;
public TTSWebSocketClient(URI serverUri, Map<String, String> headers) {
super(serverUri, headers);
}
@Override
public void onOpen(ServerHandshake serverHandshake) {
System.out.println("串連成功");
// 發送run-task事件
// 如果enable_ssml設為true,只允許發送一次continue-task事件,否則會報錯“Text request limit violated, expected 1.”
String runTaskCommand = "{ \"header\": { \"action\": \"run-task\", \"task_id\": \"" + taskId + "\", \"streaming\": \"duplex\" }, \"payload\": { \"task_group\": \"audio\", \"task\": \"tts\", \"function\": \"SpeechSynthesizer\", \"model\": \"cosyvoice-v3-flash\", \"parameters\": { \"text_type\": \"PlainText\", \"voice\": \"longanyang\", \"format\": \"mp3\", \"sample_rate\": 22050, \"volume\": 50, \"rate\": 1, \"pitch\": 1, \"enable_ssml\": false }, \"input\": {} }}";
send(runTaskCommand);
}
@Override
public void onMessage(String message) {
System.out.println("收到服務端返回的訊息:" + message);
try {
// Parse JSON message
Map<String, Object> messageMap = new ObjectMapper().readValue(message, Map.class);
if (messageMap.containsKey("header")) {
Map<String, Object> header = (Map<String, Object>) messageMap.get("header");
if (header.containsKey("event")) {
String event = (String) header.get("event");
if ("task-started".equals(event)) {
System.out.println("收到服務端返回的task-started事件");
List<String> texts = Arrays.asList(
"床前明月光,疑是地上霜",
"舉頭望明月,低頭思故鄉"
);
for (String text : texts) {
// 發送continue-task事件
sendContinueTask(text);
}
// 發送finish-task事件
sendFinishTask();
} else if ("task-finished".equals(event)) {
System.out.println("收到服務端返回的task-finished事件");
taskFinished = true;
closeConnection();
} else if ("task-failed".equals(event)) {
System.out.println("任務失敗:" + message);
closeConnection();
}
}
}
} catch (Exception e) {
System.err.println("出現異常:" + e.getMessage());
}
}
@Override
public void onMessage(ByteBuffer message) {
System.out.println("收到的二進位音頻資料大小為:" + message.remaining());
try (FileOutputStream fos = new FileOutputStream(outputFile, true)) {
byte[] buffer = new byte[message.remaining()];
message.get(buffer);
fos.write(buffer);
System.out.println("音頻資料已寫入本地檔案" + outputFile + "中");
} catch (IOException e) {
System.err.println("音頻資料寫入本地檔案失敗:" + e.getMessage());
}
}
@Override
public void onClose(int code, String reason, boolean remote) {
System.out.println("串連關閉:" + reason + " (" + code + ")");
}
@Override
public void onError(Exception ex) {
System.err.println("報錯:" + ex.getMessage());
ex.printStackTrace();
}
private void sendContinueTask(String text) {
String command = "{ \"header\": { \"action\": \"continue-task\", \"task_id\": \"" + taskId + "\", \"streaming\": \"duplex\" }, \"payload\": { \"input\": { \"text\": \"" + text + "\" } }}";
send(command);
}
private void sendFinishTask() {
String command = "{ \"header\": { \"action\": \"finish-task\", \"task_id\": \"" + taskId + "\", \"streaming\": \"duplex\" }, \"payload\": { \"input\": {} }}";
send(command);
}
private void closeConnection() {
if (!isClosed()) {
close();
}
}
public static void main(String[] args) {
try {
// 新加坡和北京地區的API Key不同。擷取API Key:https://www.alibabacloud.com/help/zh/model-studio/get-api-key
// 若沒有配置環境變數,請用阿里雲百鍊API Key將下行替換為:String apiKey = "sk-xxx"
String apiKey = System.getenv("DASHSCOPE_API_KEY");
if (apiKey == null || apiKey.isEmpty()) {
System.err.println("請設定 DASHSCOPE_API_KEY 環境變數");
return;
}
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "bearer " + apiKey);
// 以下為新加坡地區的配置,調用時請將"{WorkspaceId}"替換為真實的業務空間ID,各地區的配置不同。
TTSWebSocketClient client = new TTSWebSocketClient(new URI("wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference"), headers);
client.connect();
while (!client.isClosed() && !client.taskFinished) {
Thread.sleep(1000);
}
} catch (Exception e) {
System.err.println("串連WebSocket服務失敗:" + e.getMessage());
e.printStackTrace();
}
}
}
建議使用 Python DashScope SDK 進行開發,請參見CosyVoice Python SDK。以下是 Python WebSocket 直連樣本,運行前請匯入以下依賴:
Copy
pip uninstall websocket-client
pip uninstall websocket
pip install websocket-client
請不要將運行範例程式碼的Python檔案命名為“websocket.py”,否則會報錯(AttributeError: module 'websocket' has no attribute 'WebSocketApp'. Did you mean: 'WebSocket'?)。
Copy
import websocket
import json
import uuid
import os
import time
class TTSClient:
def __init__(self, api_key, uri):
"""
初始化 TTSClient 執行個體
參數:
api_key (str): 鑒權用的 API Key
uri (str): WebSocket 服務地址
"""
self.api_key = api_key # 替換為你的 API Key
self.uri = uri # 替換為你的 WebSocket 地址
self.task_id = str(uuid.uuid4()) # 產生唯一任務 ID
self.output_file = f"output_{int(time.time())}.mp3" # 輸出音頻檔案路徑
self.ws = None # WebSocketApp 執行個體
self.task_started = False # 是否收到 task-started
self.task_finished = False # 是否收到 task-finished / task-failed
def on_open(self, ws):
"""
WebSocket 串連建立時回呼函數
發送 run-task 事件開啟語音合成任務
"""
print("WebSocket 已串連")
# 構造 run-task 事件
run_task_cmd = {
"header": {
"action": "run-task",
"task_id": self.task_id,
"streaming": "duplex"
},
"payload": {
"task_group": "audio",
"task": "tts",
"function": "SpeechSynthesizer",
"model": "cosyvoice-v3-flash",
"parameters": {
"text_type": "PlainText",
"voice": "longanyang",
"format": "mp3",
"sample_rate": 22050,
"volume": 50,
"rate": 1,
"pitch": 1,
# 如果enable_ssml設為True,只允許發送一次continue-task事件,否則會報錯“Text request limit violated, expected 1.”
"enable_ssml": False
},
"input": {}
}
}
# 發送 run-task 事件
ws.send(json.dumps(run_task_cmd))
print("已發送 run-task 事件")
def on_message(self, ws, message):
"""
接收到訊息時的回呼函數
區分文本和二進位訊息處理
"""
if isinstance(message, str):
# 處理 JSON 簡訊
try:
msg_json = json.loads(message)
print(f"收到 JSON 訊息: {msg_json}")
if "header" in msg_json:
header = msg_json["header"]
if "event" in header:
event = header["event"]
if event == "task-started":
print("任務已啟動")
self.task_started = True
# 發送 continue-task 事件
texts = [
"床前明月光,疑是地上霜",
"舉頭望明月,低頭思故鄉"
]
for text in texts:
self.send_continue_task(text)
# 所有 continue-task 發送完成後發送 finish-task
self.send_finish_task()
elif event == "task-finished":
print("任務已完成")
self.task_finished = True
self.close(ws)
elif event == "task-failed":
error_msg = msg_json.get("header", {}).get("error_message", "未知錯誤")
print(f"任務失敗: {error_msg}")
self.task_finished = True
self.close(ws)
except json.JSONDecodeError as e:
print(f"JSON 解析失敗: {e}")
else:
# 處理二進位訊息(音頻資料)
print(f"收到二進位訊息,大小: {len(message)} 位元組")
with open(self.output_file, "ab") as f:
f.write(message)
print(f"已將音頻資料寫入本地檔案{self.output_file}中")
def on_error(self, ws, error):
"""發生錯誤時的回調"""
print(f"WebSocket 出錯: {error}")
def on_close(self, ws, close_status_code, close_msg):
"""串連關閉時的回調"""
print(f"WebSocket 已關閉: {close_msg} ({close_status_code})")
def send_continue_task(self, text):
"""發送 continue-task 事件,附帶要合成的常值內容"""
cmd = {
"header": {
"action": "continue-task",
"task_id": self.task_id,
"streaming": "duplex"
},
"payload": {
"input": {
"text": text
}
}
}
self.ws.send(json.dumps(cmd))
print(f"已發送 continue-task 事件,常值內容: {text}")
def send_finish_task(self):
"""發送 finish-task 事件,結束語音合成任務"""
cmd = {
"header": {
"action": "finish-task",
"task_id": self.task_id,
"streaming": "duplex"
},
"payload": {
"input": {}
}
}
self.ws.send(json.dumps(cmd))
print("已發送 finish-task 事件")
def close(self, ws):
"""主動關閉串連"""
if ws and ws.sock and ws.sock.connected:
ws.close()
print("已主動關閉串連")
def run(self):
"""啟動 WebSocket 用戶端"""
# 佈建要求頭部(鑒權)
header = {
"Authorization": f"bearer {self.api_key}",
"X-DashScope-DataInspection": "enable"
}
# 建立 WebSocketApp 執行個體
self.ws = websocket.WebSocketApp(
self.uri,
header=header,
on_open=self.on_open,
on_message=self.on_message,
on_error=self.on_error,
on_close=self.on_close
)
print("正在監聽 WebSocket 訊息...")
self.ws.run_forever() # 啟動長串連監聽
# 樣本使用方式
if __name__ == "__main__":
# 新加坡和北京地區的API Key不同。擷取API Key:https://www.alibabacloud.com/help/zh/model-studio/get-api-key
# 若沒有配置環境變數,請用阿里雲百鍊API Key將下行替換為:API_KEY = "sk-xxx"
API_KEY = os.environ.get("DASHSCOPE_API_KEY")
# 以下為新加坡地區的配置,調用時請將"{WorkspaceId}"替換為真實的業務空間ID,各地區的配置不同。
SERVER_URI = "wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference" # 替換為你的 WebSocket 地址
client = TTSClient(API_KEY, SERVER_URI)
client.run()
Copy
go mod init tts-example
go get github.com/gorilla/websocket@v1.5.3 github.com/google/uuid@v1.6.0
Copy
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
// 以下為新加坡地區的配置,調用時請將"{WorkspaceId}"替換為真實的業務空間ID,各地區的配置不同。
wsURL = "wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference"
outputFile = "output.mp3"
)
func main() {
// 新加坡和北京地區的API Key不同。擷取API Key:https://www.alibabacloud.com/help/zh/model-studio/get-api-key
// 若沒有配置環境變數,請用阿里雲百鍊API Key將下行替換為:apiKey := "sk-xxx"
apiKey := os.Getenv("DASHSCOPE_API_KEY")
// 清空輸出檔案
os.Remove(outputFile)
os.Create(outputFile)
// 串連WebSocket
header := make(http.Header)
header.Add("X-DashScope-DataInspection", "enable")
header.Add("Authorization", fmt.Sprintf("bearer %s", apiKey))
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
if resp != nil {
fmt.Printf("串連失敗 HTTP狀態代碼: %d\n", resp.StatusCode)
}
fmt.Println("串連失敗:", err)
return
}
defer conn.Close()
// 產生任務ID
taskID := uuid.New().String()
fmt.Printf("產生任務ID: %s\n", taskID)
// 發送run-task事件
runTaskCmd := map[string]interface{}{
"header": map[string]interface{}{
"action": "run-task",
"task_id": taskID,
"streaming": "duplex",
},
"payload": map[string]interface{}{
"task_group": "audio",
"task": "tts",
"function": "SpeechSynthesizer",
"model": "cosyvoice-v3-flash",
"parameters": map[string]interface{}{
"text_type": "PlainText",
"voice": "longanyang",
"format": "mp3",
"sample_rate": 22050,
"volume": 50,
"rate": 1,
"pitch": 1,
// 如果enable_ssml設為true,只允許發送一次continue-task事件,否則會報錯“Text request limit violated, expected 1.”
"enable_ssml": false,
},
"input": map[string]interface{}{},
},
}
runTaskJSON, _ := json.Marshal(runTaskCmd)
fmt.Printf("發送run-task事件: %s\n", string(runTaskJSON))
err = conn.WriteMessage(websocket.TextMessage, runTaskJSON)
if err != nil {
fmt.Println("發送run-task失敗:", err)
return
}
textSent := false
// 處理訊息
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
fmt.Println("讀取訊息失敗:", err)
break
}
// 處理二進位訊息
if messageType == websocket.BinaryMessage {
fmt.Printf("收到二進位訊息,長度: %d\n", len(message))
file, _ := os.OpenFile(outputFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
file.Write(message)
file.Close()
continue
}
// 處理簡訊
messageStr := string(message)
fmt.Printf("收到簡訊: %s\n", strings.ReplaceAll(messageStr, "\n", ""))
// 簡單解析JSON擷取event類型
var msgMap map[string]interface{}
if json.Unmarshal(message, &msgMap) == nil {
if header, ok := msgMap["header"].(map[string]interface{}); ok {
if event, ok := header["event"].(string); ok {
fmt.Printf("事件類型: %s\n", event)
switch event {
case "task-started":
fmt.Println("=== 收到task-started事件 ===")
if !textSent {
// 發送continue-task事件
texts := []string{"床前明月光,疑是地上霜。", "舉頭望明月,低頭思故鄉。"}
for _, text := range texts {
continueTaskCmd := map[string]interface{}{
"header": map[string]interface{}{
"action": "continue-task",
"task_id": taskID,
"streaming": "duplex",
},
"payload": map[string]interface{}{
"input": map[string]interface{}{
"text": text,
},
},
}
continueTaskJSON, _ := json.Marshal(continueTaskCmd)
fmt.Printf("發送continue-task事件: %s\n", string(continueTaskJSON))
err = conn.WriteMessage(websocket.TextMessage, continueTaskJSON)
if err != nil {
fmt.Println("發送continue-task失敗:", err)
return
}
}
textSent = true
// 延遲發送finish-task
time.Sleep(500 * time.Millisecond)
// 發送finish-task事件
finishTaskCmd := map[string]interface{}{
"header": map[string]interface{}{
"action": "finish-task",
"task_id": taskID,
"streaming": "duplex",
},
"payload": map[string]interface{}{
"input": map[string]interface{}{},
},
}
finishTaskJSON, _ := json.Marshal(finishTaskCmd)
fmt.Printf("發送finish-task事件: %s\n", string(finishTaskJSON))
err = conn.WriteMessage(websocket.TextMessage, finishTaskJSON)
if err != nil {
fmt.Println("發送finish-task失敗:", err)
return
}
}
case "task-finished":
fmt.Println("=== 任務完成 ===")
return
case "task-failed":
fmt.Println("=== 任務失敗 ===")
if header["error_message"] != nil {
fmt.Printf("錯誤資訊: %s\n", header["error_message"])
}
return
case "result-generated":
fmt.Println("收到result-generated事件")
}
}
}
}
}
}
Copy
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
class Program {
// 新加坡和北京地區的API Key不同。擷取API Key:https://www.alibabacloud.com/help/zh/model-studio/get-api-key
// 若沒有配置環境變數,請用阿里雲百鍊API Key將下行替換為:private static readonly string ApiKey = "sk-xxx"
private static readonly string ApiKey = Environment.GetEnvironmentVariable("DASHSCOPE_API_KEY") ?? throw new InvalidOperationException("DASHSCOPE_API_KEY environment variable is not set.");
// 以下為新加坡地區的配置,調用時請將"{WorkspaceId}"替換為真實的業務空間ID,各地區的配置不同。
private const string WebSocketUrl = "wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference";
// 輸出檔案路徑
private const string OutputFilePath = "output.mp3";
// WebSocket用戶端
private static ClientWebSocket _webSocket = new ClientWebSocket();
// 取消令牌源
private static CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
// 任務ID
private static string? _taskId;
// 任務是否已啟動
private static TaskCompletionSource<bool> _taskStartedTcs = new TaskCompletionSource<bool>();
static async Task Main(string[] args) {
try {
// 清空輸出檔案
ClearOutputFile(OutputFilePath);
// 串連WebSocket服務
await ConnectToWebSocketAsync(WebSocketUrl);
// 啟動接收訊息的任務
Task receiveTask = ReceiveMessagesAsync();
// 發送run-task事件
_taskId = GenerateTaskId();
await SendRunTaskCommandAsync(_taskId);
// 等待task-started事件
await _taskStartedTcs.Task;
// 持續發送continue-task事件
string[] texts = {
"床前明月光",
"疑是地上霜",
"舉頭望明月",
"低頭思故鄉"
};
foreach (string text in texts) {
await SendContinueTaskCommandAsync(text);
}
// 發送finish-task事件
await SendFinishTaskCommandAsync(_taskId);
// 等待接收任務完成
await receiveTask;
Console.WriteLine("任務完成,串連已關閉。");
} catch (OperationCanceledException) {
Console.WriteLine("任務被取消。");
} catch (Exception ex) {
Console.WriteLine($"發生錯誤:{ex.Message}");
} finally {
_cancellationTokenSource.Cancel();
_webSocket.Dispose();
}
}
private static void ClearOutputFile(string filePath) {
if (File.Exists(filePath)) {
File.WriteAllText(filePath, string.Empty);
Console.WriteLine("輸出檔案已清空。");
} else {
Console.WriteLine("輸出檔案不存在,無需清空。");
}
}
private static async Task ConnectToWebSocketAsync(string url) {
var uri = new Uri(url);
if (_webSocket.State == WebSocketState.Connecting || _webSocket.State == WebSocketState.Open) {
return;
}
// 設定WebSocket串連的頭部資訊
_webSocket.Options.SetRequestHeader("Authorization", $"bearer {ApiKey}");
_webSocket.Options.SetRequestHeader("X-DashScope-DataInspection", "enable");
try {
await _webSocket.ConnectAsync(uri, _cancellationTokenSource.Token);
Console.WriteLine("已成功串連到WebSocket服務。");
} catch (OperationCanceledException) {
Console.WriteLine("WebSocket串連被取消。");
} catch (Exception ex) {
Console.WriteLine($"WebSocket串連失敗: {ex.Message}");
throw;
}
}
private static async Task SendRunTaskCommandAsync(string taskId) {
var command = CreateCommand("run-task", taskId, "duplex", new {
task_group = "audio",
task = "tts",
function = "SpeechSynthesizer",
model = "cosyvoice-v3-flash",
parameters = new
{
text_type = "PlainText",
voice = "longanyang",
format = "mp3",
sample_rate = 22050,
volume = 50,
rate = 1,
pitch = 1,
// 如果enable_ssml設為true,只允許發送一次continue-task事件,否則會報錯“Text request limit violated, expected 1.”
enable_ssml = false
},
input = new { }
});
await SendJsonMessageAsync(command);
Console.WriteLine("已發送run-task事件。");
}
private static async Task SendContinueTaskCommandAsync(string text) {
if (_taskId == null) {
throw new InvalidOperationException("任務ID未初始化。");
}
var command = CreateCommand("continue-task", _taskId, "duplex", new {
input = new {
text
}
});
await SendJsonMessageAsync(command);
Console.WriteLine("已發送continue-task事件。");
}
private static async Task SendFinishTaskCommandAsync(string taskId) {
var command = CreateCommand("finish-task", taskId, "duplex", new {
input = new { }
});
await SendJsonMessageAsync(command);
Console.WriteLine("已發送finish-task事件。");
}
private static async Task SendJsonMessageAsync(string message) {
var buffer = Encoding.UTF8.GetBytes(message);
try {
await _webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, _cancellationTokenSource.Token);
} catch (OperationCanceledException) {
Console.WriteLine("訊息發送被取消。");
}
}
private static async Task ReceiveMessagesAsync() {
while (_webSocket.State == WebSocketState.Open) {
var response = await ReceiveMessageAsync();
if (response != null) {
var eventStr = response.RootElement.GetProperty("header").GetProperty("event").GetString();
switch (eventStr) {
case "task-started":
Console.WriteLine("任務已啟動。");
_taskStartedTcs.TrySetResult(true);
break;
case "task-finished":
Console.WriteLine("任務已完成。");
_cancellationTokenSource.Cancel();
break;
case "task-failed":
Console.WriteLine("任務失敗:" + response.RootElement.GetProperty("header").GetProperty("error_message").GetString());
_cancellationTokenSource.Cancel();
break;
default:
// result-generated可在此處理
break;
}
}
}
}
private static async Task<JsonDocument?> ReceiveMessageAsync() {
var buffer = new byte[1024 * 4];
var segment = new ArraySegment<byte>(buffer);
try {
WebSocketReceiveResult result = await _webSocket.ReceiveAsync(segment, _cancellationTokenSource.Token);
if (result.MessageType == WebSocketMessageType.Close) {
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", _cancellationTokenSource.Token);
return null;
}
if (result.MessageType == WebSocketMessageType.Binary) {
// 處理位元據
Console.WriteLine("接收到位元據...");
// 將位元據儲存到檔案
using (var fileStream = new FileStream(OutputFilePath, FileMode.Append)) {
fileStream.Write(buffer, 0, result.Count);
}
return null;
}
string message = Encoding.UTF8.GetString(buffer, 0, result.Count);
return JsonDocument.Parse(message);
} catch (OperationCanceledException) {
Console.WriteLine("訊息接收被取消。");
return null;
}
}
private static string GenerateTaskId() {
return Guid.NewGuid().ToString("N").Substring(0, 32);
}
private static string CreateCommand(string action, string taskId, string streaming, object payload) {
var command = new {
header = new {
action,
task_id = taskId,
streaming
},
payload
};
return JsonSerializer.Serialize(command);
}
}
範例程式碼目錄結構為:my-php-project/├── composer.json├── vendor/└── index.phpcomposer.json內容如下,相關依賴的版本號碼請根據實際情況自行決定:index.php內容如下:
Copy
{
"require": {
"react/event-loop": "^1.3",
"react/socket": "^1.11",
"react/stream": "^1.2",
"react/http": "^1.1",
"ratchet/pawl": "^0.4"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Copy
<?php
require __DIR__ . '/vendor/autoload.php';
use Ratchet\Client\Connector;
use React\EventLoop\Loop;
use React\Socket\Connector as SocketConnector;
// 新加坡和北京地區的API Key不同。擷取API Key:https://www.alibabacloud.com/help/zh/model-studio/get-api-key
// 若沒有配置環境變數,請用阿里雲百鍊API Key將下行替換為:$api_key = "sk-xxx"
$api_key = getenv("DASHSCOPE_API_KEY");
// 以下為新加坡地區的配置,調用時請將"{WorkspaceId}"替換為真實的業務空間ID,各地區的配置不同。
$websocket_url = 'wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference'; // WebSocket伺服器位址
$output_file = 'output.mp3'; // 輸出檔案路徑
$loop = Loop::get();
if (file_exists($output_file)) {
// 清空檔案內容
file_put_contents($output_file, '');
}
// 建立自訂的連接器
$socketConnector = new SocketConnector($loop, [
'tcp' => [
'bindto' => '0.0.0.0:0',
],
'tls' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$connector = new Connector($loop, $socketConnector);
$headers = [
'Authorization' => 'bearer ' . $api_key,
'X-DashScope-DataInspection' => 'enable'
];
$connector($websocket_url, [], $headers)->then(function ($conn) use ($loop, $output_file) {
echo "串連到WebSocket伺服器\n";
// 產生任務ID
$taskId = generateTaskId();
// 發送 run-task 事件
sendRunTaskMessage($conn, $taskId);
// 定義發送 continue-task 事件的函數
$sendContinueTask = function() use ($conn, $loop, $taskId) {
// 待發送的文本
$texts = ["床前明月光", "疑是地上霜", "舉頭望明月", "低頭思故鄉"];
$continueTaskCount = 0;
foreach ($texts as $text) {
$continueTaskMessage = json_encode([
"header" => [
"action" => "continue-task",
"task_id" => $taskId,
"streaming" => "duplex"
],
"payload" => [
"input" => [
"text" => $text
]
]
]);
echo "準備發送continue-task事件: " . $continueTaskMessage . "\n";
$conn->send($continueTaskMessage);
$continueTaskCount++;
}
echo "發送的continue-task事件個數為:" . $continueTaskCount . "\n";
// 發送 finish-task 事件
sendFinishTaskMessage($conn, $taskId);
};
// 標記是否收到 task-started 事件
$taskStarted = false;
// 監聽訊息
$conn->on('message', function($msg) use ($conn, $sendContinueTask, $loop, &$taskStarted, $taskId, $output_file) {
if ($msg->isBinary()) {
// 寫入位元據到本地檔案
file_put_contents($output_file, $msg->getPayload(), FILE_APPEND);
} else {
// 處理非二進位訊息
$response = json_decode($msg, true);
if (isset($response['header']['event'])) {
handleEvent($conn, $response, $sendContinueTask, $loop, $taskId, $taskStarted);
} else {
echo "未知的訊息格式\n";
}
}
});
// 監聽串連關閉
$conn->on('close', function($code = null, $reason = null) {
echo "串連已關閉\n";
if ($code !== null) {
echo "關閉代碼: " . $code . "\n";
}
if ($reason !== null) {
echo "關閉原因:" . $reason . "\n";
}
});
}, function ($e) {
echo "無法串連:{$e->getMessage()}\n";
});
$loop->run();
/**
* 產生任務ID
* @return string
*/
function generateTaskId(): string {
return bin2hex(random_bytes(16));
}
/**
* 發送 run-task 事件
* @param $conn
* @param $taskId
*/
function sendRunTaskMessage($conn, $taskId) {
$runTaskMessage = json_encode([
"header" => [
"action" => "run-task",
"task_id" => $taskId,
"streaming" => "duplex"
],
"payload" => [
"task_group" => "audio",
"task" => "tts",
"function" => "SpeechSynthesizer",
"model" => "cosyvoice-v3-flash",
"parameters" => [
"text_type" => "PlainText",
"voice" => "longanyang",
"format" => "mp3",
"sample_rate" => 22050,
"volume" => 50,
"rate" => 1,
"pitch" => 1,
// 如果enable_ssml設為true,只允許發送一次continue-task事件,否則會報錯“Text request limit violated, expected 1.”
"enable_ssml" => false
],
"input" => (object) []
]
]);
echo "準備發送run-task事件: " . $runTaskMessage . "\n";
$conn->send($runTaskMessage);
echo "run-task事件已發送\n";
}
/**
* 讀取音頻檔案
* @param string $filePath
* @return bool|string
*/
function readAudioFile(string $filePath) {
$voiceData = file_get_contents($filePath);
if ($voiceData === false) {
echo "無法讀取音頻檔案\n";
}
return $voiceData;
}
/**
* 分割音頻資料
* @param string $data
* @param int $chunkSize
* @return array
*/
function splitAudioData(string $data, int $chunkSize): array {
return str_split($data, $chunkSize);
}
/**
* 發送 finish-task 事件
* @param $conn
* @param $taskId
*/
function sendFinishTaskMessage($conn, $taskId) {
$finishTaskMessage = json_encode([
"header" => [
"action" => "finish-task",
"task_id" => $taskId,
"streaming" => "duplex"
],
"payload" => [
"input" => (object) []
]
]);
echo "準備發送finish-task事件: " . $finishTaskMessage . "\n";
$conn->send($finishTaskMessage);
echo "finish-task事件已發送\n";
}
/**
* 處理事件
* @param $conn
* @param $response
* @param $sendContinueTask
* @param $loop
* @param $taskId
* @param $taskStarted
*/
function handleEvent($conn, $response, $sendContinueTask, $loop, $taskId, &$taskStarted) {
switch ($response['header']['event']) {
case 'task-started':
echo "任務開始,發送continue-task事件...\n";
$taskStarted = true;
// 發送 continue-task 事件
$sendContinueTask();
break;
case 'result-generated':
// 收到result-generated事件
break;
case 'task-finished':
echo "任務完成\n";
$conn->close();
break;
case 'task-failed':
echo "任務失敗\n";
echo "錯誤碼:" . $response['header']['error_code'] . "\n";
echo "錯誤資訊:" . $response['header']['error_message'] . "\n";
$conn->close();
break;
case 'error':
echo "錯誤:" . $response['payload']['message'] . "\n";
break;
default:
echo "未知事件:" . $response['header']['event'] . "\n";
break;
}
// 如果任務已完成,關閉串連
if ($response['header']['event'] == 'task-finished') {
// 等待1秒以確保所有資料都已傳輸完畢
$loop->addTimer(1, function() use ($conn) {
$conn->close();
echo "用戶端關閉串連\n";
});
}
// 如果沒有收到 task-started 事件,關閉串連
if (!$taskStarted && in_array($response['header']['event'], ['task-failed', 'error'])) {
$conn->close();
}
}
需安裝相關依賴:範例程式碼如下:
Copy
npm install ws
npm install uuid
Copy
const WebSocket = require('ws');
const fs = require('fs');
const uuid = require('uuid').v4;
// 新加坡和北京地區的API Key不同。擷取API Key:https://www.alibabacloud.com/help/zh/model-studio/get-api-key
// 若沒有配置環境變數,請用阿里雲百鍊API Key將下行替換為:const apiKey = "sk-xxx"
const apiKey = process.env.DASHSCOPE_API_KEY;
// 以下為新加坡地區的配置,調用時請將"{WorkspaceId}"替換為真實的業務空間ID,各地區的配置不同。
const url = 'wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference';
// 輸出檔案路徑
const outputFilePath = 'output.mp3';
// 清空輸出檔案
fs.writeFileSync(outputFilePath, '');
// 建立WebSocket用戶端
const ws = new WebSocket(url, {
headers: {
Authorization: `bearer ${apiKey}`,
'X-DashScope-DataInspection': 'enable'
}
});
let taskStarted = false;
let taskId = uuid();
ws.on('open', () => {
console.log('已串連到WebSocket伺服器');
// 發送run-task事件
const runTaskMessage = JSON.stringify({
header: {
action: 'run-task',
task_id: taskId,
streaming: 'duplex'
},
payload: {
task_group: 'audio',
task: 'tts',
function: 'SpeechSynthesizer',
model: 'cosyvoice-v3-flash',
parameters: {
text_type: 'PlainText',
voice: 'longanyang', // 音色
format: 'mp3', // 音頻格式
sample_rate: 22050, // 採樣率
volume: 50, // 音量
rate: 1, // 語速
pitch: 1, // 音調
enable_ssml: false // 是否開啟SSML功能。如果enable_ssml設為true,只允許發送一次continue-task事件,否則會報錯“Text request limit violated, expected 1.”
},
input: {}
}
});
ws.send(runTaskMessage);
console.log('已發送run-task訊息');
});
const fileStream = fs.createWriteStream(outputFilePath, { flags: 'a' });
ws.on('message', (data, isBinary) => {
if (isBinary) {
// 寫入位元據到檔案
fileStream.write(data);
} else {
const message = JSON.parse(data);
switch (message.header.event) {
case 'task-started':
taskStarted = true;
console.log('任務已開始');
// 發送continue-task事件
sendContinueTasks(ws);
break;
case 'task-finished':
console.log('任務已完成');
ws.close();
fileStream.end(() => {
console.log('檔案流已關閉');
});
break;
case 'task-failed':
console.error('任務失敗:', message.header.error_message);
ws.close();
fileStream.end(() => {
console.log('檔案流已關閉');
});
break;
default:
// 可以在這裡處理result-generated
break;
}
}
});
function sendContinueTasks(ws) {
const texts = [
'床前明月光,',
'疑是地上霜。',
'舉頭望明月,',
'低頭思故鄉。'
];
texts.forEach((text, index) => {
setTimeout(() => {
if (taskStarted) {
const continueTaskMessage = JSON.stringify({
header: {
action: 'continue-task',
task_id: taskId,
streaming: 'duplex'
},
payload: {
input: {
text: text
}
}
});
ws.send(continueTaskMessage);
console.log(`已發送continue-task,文本:${text}`);
}
}, index * 1000); // 每隔1秒發送一次
});
// 發送finish-task事件
setTimeout(() => {
if (taskStarted) {
const finishTaskMessage = JSON.stringify({
header: {
action: 'finish-task',
task_id: taskId,
streaming: 'duplex'
},
payload: {
input: {}
}
});
ws.send(finishTaskMessage);
console.log('已發送finish-task');
}
}, texts.length * 1000 + 1000); // 在所有continue-task事件發送完畢後1秒發送
}
ws.on('close', () => {
console.log('已斷開與WebSocket伺服器的串連');
});