通過 AOQ 接入 fun-asr-realtime,發送麥克風音頻並即時接收語音辨識結果。用戶端代碼以 Android Java 為例,AOQ 支援的其他平台使用相同的介面。
方案概述
fun-asr-realtime 將音頻流即時轉寫為帶標點的文本。AOQ SDK 將媒體和事件分軌傳輸:用戶端通過 Audio 軌上行音頻,通過 Data 軌發送控制事件並接收識別事件。該模型使用 Inference 事件協議,而不是 Realtime 事件協議。
該方案適用於即時字幕、會議轉寫、語音輸入和智能助手。Audio 軌避免用戶端把音頻編碼成事件訊息,Data 軌則保留 run-task、result-generated 和 finish-task 等完整任務語義。
- 用戶端向業務 AppServer 請求臨時 AOQ 串連憑證。
- AppServer 使用 API Key 向百鍊申請 Token,並把串連欄位返回用戶端。
- 用戶端建立 AOQ 串連並發送 run-task;收到 task-started 後開始上行麥克風音頻。
- 服務端持續返回 result-generated;用戶端發送 finish-task 後等待最終結果和 task-finished。
準備工作
- 開通阿里雲百鍊,並按擷取與配置 API Key。API Key 只儲存在業務 AppServer,不要寫入用戶端代碼或提交到代碼倉庫。
- 根據業務部署地區確認 AOQ Endpoint。地區和接入地址的選擇方法請參見選擇地區、服務部署範圍和接入網域名稱。
- 從SDK 下載擷取最新版 AOQ Client SDK。本文傳輸 PCM 音頻,不需要額外整合 Opus 外掛程式。
- 搭建業務 AppServer,並按Token 鑒權實現 AOQ Inference 協議的服務端代理鑒權。每次建立新串連前,用戶端都應從 AppServer 擷取新的串連憑證。
匯入 SDK
根據開發平台選擇相應的 SDK 匯入方式。後續用戶端實現以 Android Java 為例;其他平台使用相同的介面設計和事件流程。
Android
- 將 AoqClientSdk-release.aar 放入 app/libs 目錄,並在 app/build.gradle 中配置依賴和 ABI:
android {
defaultConfig {
minSdk 21
ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.aar'])
}
- 在 AndroidManifest.xml 中聲明網路和錄音許可權:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
- 在開始錄音前動態申請 RECORD_AUDIO 許可權。純語音辨識不需要 CAMERA 許可權。
iOS
- 將 AoqClientSdk.framework 拖入 Xcode 工程,在 Target > General > Frameworks, Libraries, and Embedded Content 中選擇 Embed & Sign。SDK 支援 iOS 13.0 及以上 arm64 裝置。
- 在 Info.plist 中添加 NSMicrophoneUsageDescription。純語音辨識不需要 NSCameraUsageDescription。
- Swift 工程使用 import AoqClientSdk;Objective-C 工程使用 #import <AoqClientSdk/AoqClientSdk.h>。
HarmonyOS
- 將 AoqClientSdk.har 放入 entry/libs,並在 entry/oh-package.json5 中聲明依賴。該 SDK 相容 API 12,支援 arm64-v8a:
{
"dependencies": {
"@aoq/client-sdk": "file:./libs/AoqClientSdk.har"
}
}
- 在 entry/src/main/module.json5 中聲明網路和麥克風許可權:
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:perm_mic_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
- 在開始錄音前調用 abilityAccessCtrl.createAtManager().requestPermissionsFromUser 申請麥克風許可權。
Linux (Python)
- 解壓 SDK,並保持 aoq_client_sdk.py、libAoqClientSdk.so 和 libonnxruntime.so.1.16.3 位於同一目錄。
- 將 SDK 目錄加入 Python 和動態庫搜尋路徑:
export PYTHONPATH="$PWD/AoqClientSdk:$PYTHONPATH"
export LD_LIBRARY_PATH="$PWD/AoqClientSdk:$LD_LIBRARY_PATH"
- 在 Python 代碼中使用 import aoq_client_sdk。也可通過 AOQ_CLIENT_SDK_LIB 指定 libAoqClientSdk.so 的絕對路徑。
體驗 Demo
阿里雲百鍊提供適用於 Android 平台的 Demo,可用於快速驗證 AOQ 接入效果。下載 APK 並配置 API Key 和 workspaceId 後,即可體驗部分模型。
掃描以下二維碼下載 Demo:
實現流程
- AppServer 使用 Inference Token 地址擷取 fun-asr-realtime 的 AOQ 串連參數。
- 用戶端把 Token 響應轉換為 AoqConnectConfig,發布 Audio 和 Data 軌,並訂閱 Data 軌。
- 用戶端按業務需求和模型要求配置音頻編碼參數,啟動麥克風採集但暫不發送音頻,然後建立 AOQ 串連。
- 串連成功後發送 run-task;收到 task-started 後開啟 Audio 軌發送。
- 用戶端在 onDataMsg 中處理 result-generated;結束錄音時先關閉 Audio 軌發送,再發送 finish-task。
- 收到 task-finished 後,可在同一串連上使用新的 task_id 發起下一輪識別,或中斷連線並銷毀引擎。
AppServer 擷取 Token
在 AppServer 設定 DASHSCOPE_API_KEY,並使用所選地區的 Endpoint 發送請求。clientIp 為終端的真實公網 IP;該欄位可選,但建議傳入,以便服務分配合適的 Relay 存取點。
curl -X POST \
"https://{endpoint}/api/v1/webrtc/inference?model=fun-asr-realtime" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${DASHSCOPE_API_KEY}" \
-H "x-dashscope-rtc-transport: moq" \
-d "{\"clientIp\": \"${CLIENT_REAL_IP}\"}"
說明如果 AppServer 無法擷取終端真實公網 IP,請從請求體中刪除 clientIp 欄位,不要傳Null 字元串。
AppServer 將響應中的以下欄位返回用戶端。生產環境中不要把 API Key 返回用戶端。完整請求參數和響應欄位請參見Token 鑒權。
響應欄位 | SDK 欄位 |
aoqTokenForClient | AoqConnectConfig.token |
sid | AoqConnectConfig.sid |
clientRelayCertFingerprint | AoqConnectConfig.certFingerprint |
clientRelayEndpoints | AoqConnectConfig.relayEndpoints |
extraInfo.workspaceIdHash | AoqConnectConfig.workspaceIdHash |
實現 Android 用戶端
以下步驟按串連和任務的實際執行順序拆分 Android Java 用戶端代碼。各片段來自後文的完整樣本。
1. 建立引擎並設定回調
建立 AOQ 用戶端引擎,並註冊串連狀態和 Data 軌事件回調。請根據商務邏輯實現回調處理;串連成功後再啟動識別任務。
AoqClientListener listener = new AoqClientListener() {
@Override
public void onConnectionStatusChange(AoqClientEngine.AoqConnectionStatus status) {
connected = status == AoqClientEngine.AoqConnectionStatus
.AoqConnectionStatusConnected;
if (connected) {
beginRecognition();
}
}
@Override
public void onDataMsg(AoqClientEngine.AoqDataMsg msg) {
handleServerEvent(msg);
}
};
AoqClientEngine.AoqCreateConfig createConfig = new AoqClientEngine.AoqCreateConfig();
createConfig.workDir = context.getFilesDir().getAbsolutePath();
engine = AoqClientEngine.createEngine(context, createConfig, listener);
2. 配置音頻編碼
配置發送給模型的音頻編碼。請根據業務需求和模型要求設定格式、採樣率和聲道數。以下代碼以 16 kHz 單聲道 PCM 為例;支援範圍請參見用戶端事件中的 run-task 參數。
AoqClientEngine.AoqAudioCodecConfig encoder =
new AoqClientEngine.AoqAudioCodecConfig();
encoder.trackType = AoqClientEngine.AoqTrackType.AoqTrackTypeAudio;
encoder.codecType = AoqClientEngine.AoqEncoderType.AoqEncoderTypeAudioPCM;
encoder.sampleRate = 16000;
encoder.channel = 1;
encoder.bitrate = 24000;
engine.setAudioEncoderConfig(encoder);
3. 配置串連和傳輸軌道
使用 AppServer 返回的憑證配置 AOQ 串連,並根據業務需要選擇發布和訂閱的軌道。以下代碼為即時語音辨識發布 Audio 和 Data 軌,並訂閱 Data 軌。
addTrack(connectConfig, true,
AoqClientEngine.AoqTrackType.AoqTrackTypeAudio);
addTrack(connectConfig, true,
AoqClientEngine.AoqTrackType.AoqTrackTypeData);
addTrack(connectConfig, false,
AoqClientEngine.AoqTrackType.AoqTrackTypeData);
4. 啟動音頻採集並建立串連
配置音頻採集方式並建立 AOQ 串連。請根據業務選擇內建或外部採集、是否啟用 VoIP 模式以及聲道數。收到 task-started 前保持 Audio 軌發送關閉。
AoqClientEngine.AoqAudioCaptureConfig capture =
new AoqClientEngine.AoqAudioCaptureConfig();
capture.isExternal = false;
capture.isVoipMode = true;
capture.channel = 1;
engine.startAudioCapture(capture);
// 收到 task-started 前不要發送音頻。
engine.enableSendMediaStream(
AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);
engine.connect(connectConfig);
5. 啟動識別任務
串連成功後產生任務 ID,並發送 run-task 啟動識別。請根據實際使用的模型和音頻輸入配置 model、format、sample_rate 及其他任務參數,完整說明請參見用戶端事件。
taskId = UUID.randomUUID().toString();
JSONObject header = createHeader("run-task");
JSONObject parameters = new JSONObject()
.put("format", "pcm")
.put("sample_rate", 16000);
JSONObject payload = new JSONObject()
.put("task_group", "audio")
.put("task", "asr")
.put("function", "recognition")
.put("model", "fun-asr-realtime")
.put("parameters", parameters)
.put("input", new JSONObject());
send(new JSONObject().put("header", header).put("payload", payload));
6. 處理服務端事件
處理任務狀態、識別結果和錯誤事件,並將結果傳遞給業務層。請根據應用的展示和狀態管理需求實現回調邏輯;收到 task-started 後再發送音頻,展示結果時過濾心跳事件。完整響應結構請參見服務端事件。
String eventName = header.optString("event", "");
if ("task-started".equals(eventName)) {
taskStarted = true;
engine.enableSendMediaStream(
AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, true);
} else if ("result-generated".equals(eventName)) {
JSONObject payload = event.optJSONObject("payload");
JSONObject output = payload == null ? null : payload.optJSONObject("output");
JSONObject sentence = output == null ? null : output.optJSONObject("sentence");
if (sentence != null && !sentence.optBoolean("heartbeat", false)) {
String text = sentence.optString("text", "");
if (!text.isEmpty()) {
resultListener.onResult(
text, sentence.optBoolean("sentence_end", false));
}
}
} else if ("task-finished".equals(eventName)) {
resetTaskState();
resultListener.onTaskFinished();
} else if ("task-failed".equals(eventName)) {
String message = header.optString("error_message", "識別失敗");
resetTaskState();
resultListener.onError(message);
}
7. 結束識別任務
使用者結束本輪錄音時,停止音頻上行並發送 finish-task。保持串連直至收到最終識別結果和 task-finished;後續可按業務需要啟動新任務或釋放串連。事件格式請參見用戶端事件。
engine.enableSendMediaStream(
AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);
JSONObject payload = new JSONObject().put("input", new JSONObject());
send(new JSONObject()
.put("header", createHeader("finish-task"))
.put("payload", payload));
8. 中斷連線並銷毀引擎
頁面銷毀或不再需要識別時,釋放音頻採集、AOQ 串連和引擎資源。請根據應用生命週期決定釋放時機,不要在剛發送 finish-task 時立即釋放。
engine.enableSendMediaStream(
AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);
engine.stopAudioCapture();
engine.disconnect();
AoqClientEngine.destroy();
完整樣本
該 Android Java 類將 AppServer 返回的 JSON 轉換為 AoqConnectConfig,並組合前述串連、採集、任務和資源釋放邏輯。
import android.content.Context;
import com.alibaba.aoq.clientsdk.AoqClientEngine;
import com.alibaba.aoq.clientsdk.AoqClientListener;
import org.json.JSONArray;
import org.json.JSONObject;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
public final class AsrClient {
public interface ResultListener {
void onResult(String text, boolean sentenceEnd);
void onTaskFinished();
void onError(String message);
}
private final AoqClientEngine engine;
private final ResultListener resultListener;
private String taskId;
private boolean connected;
private boolean taskStarted;
public AsrClient(Context context, AoqClientEngine.AoqConnectConfig connectConfig,
ResultListener resultListener) {
this.resultListener = resultListener;
AoqClientListener listener = new AoqClientListener() {
@Override
public void onConnectionStatusChange(AoqClientEngine.AoqConnectionStatus status) {
connected = status == AoqClientEngine.AoqConnectionStatus
.AoqConnectionStatusConnected;
if (connected) {
beginRecognition();
}
}
@Override
public void onDataMsg(AoqClientEngine.AoqDataMsg msg) {
handleServerEvent(msg);
}
};
AoqClientEngine.AoqCreateConfig createConfig = new AoqClientEngine.AoqCreateConfig();
createConfig.workDir = context.getFilesDir().getAbsolutePath();
engine = AoqClientEngine.createEngine(context, createConfig, listener);
configureAudioEncoder();
configureTracks(connectConfig);
startAudioCapture();
engine.connect(connectConfig);
}
private void configureAudioEncoder() {
AoqClientEngine.AoqAudioCodecConfig encoder = new AoqClientEngine.AoqAudioCodecConfig();
encoder.trackType = AoqClientEngine.AoqTrackType.AoqTrackTypeAudio;
encoder.codecType = AoqClientEngine.AoqEncoderType.AoqEncoderTypeAudioPCM;
encoder.sampleRate = 16000;
encoder.channel = 1;
encoder.bitrate = 24000;
engine.setAudioEncoderConfig(encoder);
}
private static void configureTracks(AoqClientEngine.AoqConnectConfig connectConfig) {
addTrack(connectConfig, true, AoqClientEngine.AoqTrackType.AoqTrackTypeAudio);
addTrack(connectConfig, true, AoqClientEngine.AoqTrackType.AoqTrackTypeData);
addTrack(connectConfig, false, AoqClientEngine.AoqTrackType.AoqTrackTypeData);
}
private void startAudioCapture() {
AoqClientEngine.AoqAudioCaptureConfig capture =
new AoqClientEngine.AoqAudioCaptureConfig();
capture.isExternal = false;
capture.isVoipMode = true;
capture.channel = 1;
engine.startAudioCapture(capture);
engine.enableSendMediaStream(AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);
}
/** 在現有 AOQ 串連上啟動新一輪識別任務。 */
public void beginRecognition() {
if (!connected || taskStarted || taskId != null) {
return;
}
taskId = UUID.randomUUID().toString();
JSONObject header = createHeader("run-task");
JSONObject parameters = new JSONObject()
.put("format", "pcm")
.put("sample_rate", 16000);
JSONObject payload = new JSONObject()
.put("task_group", "audio")
.put("task", "asr")
.put("function", "recognition")
.put("model", "fun-asr-realtime")
.put("parameters", parameters)
.put("input", new JSONObject());
send(new JSONObject().put("header", header).put("payload", payload));
}
/** 結束當前任務。收到 task-finished 後再中斷連線。 */
public void finishRecognition() {
if (taskId == null) {
return;
}
engine.enableSendMediaStream(AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);
JSONObject payload = new JSONObject().put("input", new JSONObject());
send(new JSONObject()
.put("header", createHeader("finish-task"))
.put("payload", payload));
}
public void close() {
engine.enableSendMediaStream(AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);
engine.stopAudioCapture();
engine.disconnect();
AoqClientEngine.destroy();
}
private void handleServerEvent(AoqClientEngine.AoqDataMsg msg) {
if (msg == null || msg.data == null) {
return;
}
JSONObject event = new JSONObject(new String(msg.data, StandardCharsets.UTF_8));
JSONObject header = event.optJSONObject("header");
if (header == null) {
return;
}
String eventName = header.optString("event", "");
if ("task-started".equals(eventName)) {
taskStarted = true;
engine.enableSendMediaStream(
AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, true);
} else if ("result-generated".equals(eventName)) {
handleRecognitionResult(event);
} else if ("task-finished".equals(eventName)) {
resetTaskState();
resultListener.onTaskFinished();
} else if ("task-failed".equals(eventName)) {
String message = header.optString("error_message", "識別失敗");
resetTaskState();
resultListener.onError(message);
}
}
private void handleRecognitionResult(JSONObject event) {
JSONObject payload = event.optJSONObject("payload");
JSONObject output = payload == null ? null : payload.optJSONObject("output");
JSONObject sentence = output == null ? null : output.optJSONObject("sentence");
if (sentence == null || sentence.optBoolean("heartbeat", false)) {
return;
}
String text = sentence.optString("text", "");
if (!text.isEmpty()) {
resultListener.onResult(text, sentence.optBoolean("sentence_end", false));
}
}
private void resetTaskState() {
engine.enableSendMediaStream(AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);
taskStarted = false;
taskId = null;
}
private JSONObject createHeader(String action) {
return new JSONObject()
.put("action", action)
.put("task_id", taskId)
.put("streaming", "duplex");
}
private void send(JSONObject event) {
AoqClientEngine.AoqDataMsg msg = new AoqClientEngine.AoqDataMsg();
msg.data = event.toString().getBytes(StandardCharsets.UTF_8);
engine.sendDataMsg(msg);
}
private static void addTrack(AoqClientEngine.AoqConnectConfig config, boolean publish,
AoqClientEngine.AoqTrackType type) {
AoqClientEngine.AoqTrackParam track = new AoqClientEngine.AoqTrackParam();
track.trackType = type;
if (publish) {
config.publishTracks.add(track);
} else {
config.subscribeTracks.add(track);
}
}
/** 將 AppServer 返回的 Token 響應轉換為 SDK 串連配置。 */
public static AoqClientEngine.AoqConnectConfig parseConnectConfig(String responseText) {
JSONObject response = new JSONObject(responseText);
AoqClientEngine.AoqConnectConfig config = new AoqClientEngine.AoqConnectConfig();
config.token = response.optString("aoqTokenForClient", "");
config.sid = response.optString("sid", "");
config.certFingerprint = response.optString("clientRelayCertFingerprint", "");
JSONArray endpoints = response.optJSONArray("clientRelayEndpoints");
if (endpoints != null) {
for (int i = 0; i < endpoints.length(); i++) {
JSONObject item = endpoints.optJSONObject(i);
if (item == null) {
continue;
}
AoqClientEngine.AoqRelayEndpoint endpoint =
new AoqClientEngine.AoqRelayEndpoint();
endpoint.routeIndex = item.has("route_index")
? item.optInt("route_index", i) : i;
endpoint.endpoint = item.optString("endpoint", "");
endpoint.port = item.optInt("port", 0);
config.relayEndpoints.add(endpoint);
}
}
JSONObject extraInfo = response.optJSONObject("extraInfo");
config.workspaceIdHash = extraInfo == null
? "" : extraInfo.optString("workspaceIdHash", "");
return config;
}
}
調用樣本
將 AppServer 的 Token 響應傳給 parseConnectConfig,然後建立用戶端。首次串連成功後自動開始識別。停止按鈕只結束當前任務;頁面銷毀時才釋放串連和本地資源。
private AsrClient client;
void startRecognition(Context context, String tokenResponseText) {
AoqClientEngine.AoqConnectConfig config =
AsrClient.parseConnectConfig(tokenResponseText);
client = new AsrClient(context, config, new AsrClient.ResultListener() {
@Override
public void onResult(String text, boolean sentenceEnd) {
// 使用中間句或最終句更新介面。
}
@Override
public void onTaskFinished() {
// 啟用開始按鈕,或調用 beginRecognition() 開始新任務。
}
@Override
public void onError(String message) {
// 展示或記錄錯誤。
}
});
}
void onStopButtonClick() {
// 結束當前任務,並保持 AOQ 串連,直至收到 task-finished。
client.finishRecognition();
}
void onPageDestroyed() {
// 僅在頁面關閉時釋放本地資源。
client.close();
}
運行並驗證
- 啟動 AppServer,確認 Token 請求返回 HTTP 200,並包含 sid、aoqTokenForClient、clientRelayEndpoints、clientRelayCertFingerprint 和 extraInfo.workspaceIdHash。
- 在 Android 裝置上安裝並運行應用,授予麥克風許可權,然後說一段話。
- 觀察回調。正常事件順序如下:
task-started
result-generated (sentence_end=false)
result-generated (sentence_end=true)
task-finished
說話過程中應持續收到中間識別文本。調用 finishRecognition 後,應收到當前句的最終文本和 task-finished。不要在 finish-task 發送後立即中斷連線。
典型情境
同一串連多次識別
收到 task-finished 後調用 beginRecognition,可在同一 AOQ 串連上啟動下一輪識別。每輪任務必須使用新的 task_id,不需要重新申請 Token 或重建串連;如果串連已經斷開,則需要重新擷取串連憑證。
Android 後台識別
Android 10 及以上版本中,如需在應用進入後台後繼續採集麥克風音頻,應使用 foregroundServiceType=microphone 的前台服務,並在應用仍對使用者可見時啟動該服務。
常見問題
問題 | 處理方法 |
串連失敗 | 確認 Token 尚未到期、Endpoint 與業務地區一致,並檢查 AppServer 是否傳入了終端真實公網 IP。串連斷開後不要複用舊 Token。 |
任務已啟動但沒有識別結果 | 確認收到 task-started 後才開啟 Audio 軌發送,並根據當前模型的用戶端事件檢查音頻格式、採樣率等輸入參數。 |
收不到最終結果 | 先關閉 Audio 軌發送,再發送 finish-task;等待最終 result-generated 和 task-finished,不要立即中斷連線。 |
Android 載入 SDK 失敗 | 確認 AAR 已加入依賴,並且應用只打包 SDK 支援的 armeabi-v7a 或 arm64-v8a ABI。 |
同一串連的下一輪任務被拒絕 | 確認上一輪已經收到 task-finished,並為新一輪 run-task 產生新的 task_id。 |
相關文檔
如需查詢完整參數、事件欄位或其他平台介面,請參見: