Skip to main content

使用 AOQ 接入 qwen-audio-3.1-asr-flash-streaming 實現語音輸入法

本文介紹如何使用 AOQ SDK 接入 qwen-audio-3.1-asr-flash-streaming 實時語音識別模型,實現語音輸入法功能。

方案概述

語音輸入法的典型交互流程為:用戶點擊語音輸入按鈕後立即開始說話,識別結果實時回顯到輸入框。客戶端核心實現要點如下:
  1. 點擊即採集:用戶點擊按鈕後立即啓動音頻採集並緩存 PCM 數據,同時異步申請連接憑證和建立連接。由於連接建立需要一定時間,而用戶在此期間已經開始說話,因此需要先緩存再補發。
  2. 倍速補發追平:連接成功並收到模型就緒事件後,將緩存的音頻以倍速發送給模型,追平後再切換為實時發送。
  3. Data 通道傳輸音頻:qwen-audio-3.1-asr-flash-streaming 採用 Inference 協議(run-task / finish-task)。當前場景需要先緩存音頻再倍速補發,因此通過 sendDataMsg 接口以 JSON 消息形式傳輸音頻數據。AOQ 服務端已對 JSON 格式的音頻消息做了封裝,客戶端發送 JSON 後服務端自動解析為二進制數據轉發給模型。
  4. 中間結果與最終結果:模型持續返回識別結果,中間結果(sentence_end: false)實時展示,最終結果(sentence_end: true)追加到輸入框。

準備工作

前置條件

  1. 開通阿里雲百鍊,並按獲取與配置 API Key獲取 API Key。API Key 只保存在業務 AppServer,不要寫入客戶端代碼或提交到代碼倉庫。
  2. {endpoint} 使用華北2(北京)的 {WorkspaceId}.cn-beijing.maas.aliyuncs.com 或新加坡的 {WorkspaceId}.ap-southeast-1.maas.aliyuncs.com,替換為實際業務空間 ID,並使用對應地域和業務空間的 API Key。
  3. 從 SDK 下載獲取最新版 AOQ Client SDK。
  4. 搭建業務 AppServer,並按 Token 鑒權實現服務端代理鑒權。每次建立新連接前,客戶端都應從 AppServer 獲取新的連接憑證。

導入 SDK

從 SDK 下載獲取最新版 AOQ Client SDK 後,根據開發平台導入對應 SDK。後續客戶端代碼以 Android 為例,其他平台使用相同的接口設計和事件流程。
  • Android
  • iOS
  • HarmonyOS
  • Linux (Python)
  1. 將 Opus 插件中的 libPluginOpus.so 按 ABI 放入 app/src/main/jniLibs/armeabi-v7a/ 和 app/src/main/jniLibs/arm64-v8a/,將 AoqClientSdk-release.aar 放入 app/libs,並在 app/build.gradle 中配置依賴和 SDK 支持的 ABI:
    android {
        defaultConfig {
            minSdk 21
            ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }
        }
    }
    
    dependencies {
        implementation fileTree(dir: 'libs', include: ['*.aar'])
    }
    
  2. 在 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" />
    
  3. 在使用麥克風前動態申請 RECORD_AUDIO 權限。

AppServer 獲取 Token

客戶端通過 AppServer 獲取連接憑證。按 Token 鑒權實現服務端代理鑒權,AppServer 調用百鍊分配接口,返回 SDK 連接所需的 Token、SID、Relay 端點等信息。
curl -X POST \
  "https://{endpoint}/api/v1/webrtc/inference?model=qwen-audio-3.1-asr-flash-streaming" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ${DASHSCOPE_API_KEY}" \
  -H "x-dashscope-rtc-transport: moq" \
  -d "{\"clientIp\": \"${CLIENT_REAL_IP}\"}"
響應字段SDK 字段
aoqTokenForClientAoqConnectConfig.token
sidAoqConnectConfig.sid
clientRelayCertFingerprintAoqConnectConfig.certFingerprint
clientRelayEndpointsAoqConnectConfig.relayEndpoints
extraInfo.workspaceIdHashAoqConnectConfig.workspaceIdHash

客戶端實現流程

語音輸入法客戶端實現時序
以下以 Android 為例,按實現順序分步說明。其他平台使用相同的接口設計和事件流程。

1. 創建引擎並設置回調

創建 AoqClientEngine 實例,註冊連接狀態回調和 Data 消息回調。連接成功後發送 run-task,Data 消息回調負責處理服務端事件。
// 創建引擎
AoqClientEngine.AoqCreateConfig create = new AoqClientEngine.AoqCreateConfig();
create.workDir = getCacheDir().getAbsolutePath();
engine = AoqClientEngine.createEngine(this, create, new AoqClientListener() {
    @Override
    public void onConnectionStatusChange(
            @NonNull AoqClientEngine.AoqConnectionStatus state) {
        if (state == AoqClientEngine.AoqConnectionStatus.AoqConnectionStatusConnected) {
            // 連接成功,發送 run-task(見步驟 4)
        }
    }

    @Override
    public void onDataMsg(@NonNull AoqClientEngine.AoqDataMsg msg) {
        // 解析服務端事件(見步驟 5)
    }

    @Override
    public void onError(int code, String message) {
        // 錯誤處理
    }
});

2. 配置音頻編碼並啓動採集

配置 PCM 編碼(16 kHz、單聲道),通過 setAudioFrameObserver 註冊採集回調獲取 PCM 數據,調用 enableSendMediaStream(Audio, false) 關閉音頻媒體上行(音頻通過 Data 通道發送),最後調用 startAudioCapture() 啓動採集。 本文示例使用 AOQ SDK 內部採集,通過 onCapturedAudioFrame 回調獲取 PCM 數據。也可以自行實現音頻採集(如 Android 的 AudioRecord),只需將採集到的 PCM 數據放入緩存隊列,後續的發送邏輯完全相同。
  • 方式 A:使用 SDK 內部採集
  • 方式 B:業務自行採集
本文代碼示例調用 startAudioCapture,通過 onCapturedAudioFrame 獲取 PCM,然後複製到應用緩存。應註冊 AoqAudioSourceCaptured 原始採集回調,用於連接建立前的緩存。
int SAMPLE_RATE = 16000;

// 配置 PCM 編碼
AoqClientEngine.AoqAudioCodecConfig codec = new AoqClientEngine.AoqAudioCodecConfig();
codec.codecType = AoqClientEngine.AoqEncoderType.AoqEncoderTypeAudioPCM;
codec.sampleRate = SAMPLE_RATE;
codec.channel = 1;
codec.bitrate = 256000;
engine.setAudioEncoderConfig(codec);

// 註冊採集回調
engine.setAudioFrameObserver(new AoqClientListener.AoqAudioFrameListener() {
    @Override
    public void onCapturedAudioFrame(
            @NonNull AoqClientEngine.AoqAudioFrameData frame) {
        if (frame.dataPtr != null && frame.dataSize > 0) {
            // 將 PCM 數據緩存到隊列(見步驟 5)
            pcmQueue.offer(frame.dataPtr, frame.dataSize);
        }
        Arrays.fill(frame.dataPtr, (byte) 0);  // 清零防止媒體上行
    }
});

AoqClientEngine.AoqAudioObserverConfig observer = new AoqClientEngine.AoqAudioObserverConfig();
observer.sampleRate = SAMPLE_RATE;
observer.channels = 1;
observer.mode = AoqClientEngine.AoqAudioObserverMode.AoqAudioObserverModeReadWrite;
engine.enableAudioFrameObserver(true,
        AoqClientEngine.AoqAudioSource.AoqAudioSourceCaptured, observer);

// 關閉音頻媒體上行(音頻通過 Data 通道發送)
engine.enableSendMediaStream(AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);

// 啓動採集(點擊即採集,不等連接)
engine.startAudioCapture(new AoqClientEngine.AoqAudioCaptureConfig());
兩種方式選擇其一,避免同時啓動兩套錄音。自行採集時,業務負責格式轉換、停止確認和音頻設備釋放。

3. 配置連接和傳輸軌道並建立連接

將 AppServer 返回的憑證填入 AoqConnectConfig,發佈 Audio + Data 軌道,訂閱 Data 軌道。Audio 軌道聲明是連接所需的,但媒體上行已關閉,實際音頻通過 Data 通道傳輸。
必須發佈 Audio 軌道:ASR 模型要求連接時聲明 Audio 軌道,否則連接會失敗。但實際音頻數據不通過音頻媒體通道發送,而是通過 Data 通道以 input_audio_buffer.append 消息傳輸。因此需要在連接前調用 enableSendMediaStream(Audio, false) 關閉音頻媒體上行,避免 SDK 自動發送採集到的音頻。
// 從 AppServer 獲取憑證後,構建連接配置
AoqClientEngine.AoqConnectConfig config = new AoqClientEngine.AoqConnectConfig();
config.token = aoqTokenForClient;
config.sid = sid;
config.certFingerprint = clientRelayCertFingerprint;
config.workspaceIdHash = workspaceIdHash;
// relayEndpoints 從響應解析後添加

// 發布 Audio + Data 軌道
AoqClientEngine.AoqTrackParam audioTrack = new AoqClientEngine.AoqTrackParam();
audioTrack.trackType = AoqClientEngine.AoqTrackType.AoqTrackTypeAudio;
config.publishTracks.add(audioTrack);

AoqClientEngine.AoqTrackParam dataTrack = new AoqClientEngine.AoqTrackParam();
dataTrack.trackType = AoqClientEngine.AoqTrackType.AoqTrackTypeData;
config.publishTracks.add(dataTrack);

// 訂閱 Data 軌道
AoqClientEngine.AoqTrackParam subData = new AoqClientEngine.AoqTrackParam();
subData.trackType = AoqClientEngine.AoqTrackType.AoqTrackTypeData;
config.subscribeTracks.add(subData);

// 建立連接
engine.connect(config);

4. 發送 run-task 啓動識別任務

連接成功後,每輪語音輸入生成一個新的 task_id,例如使用 UUID.randomUUID().toString()。在連接成功回調中,通過 sendDataMsg 發送下列 JSON;同一輪只發送一次 run-task。 run-task 事件結構如下,任務 ID 為示例值,實際應替換為本輪新生成的值。具體事件請參考客戶端事件。
{
  "header": {
    "action": "run-task",
    "task_id": "9fb212ea-6f83-4aef-9b8e-1bdd71f1d4d3",
    "streaming": "duplex"
  },
  "payload": {
    "task_group": "audio",
    "task": "asr",
    "function": "recognition",
    "model": "qwen-audio-3.1-asr-flash-streaming",
    "parameters": {
      "format": "pcm",
      "sample_rate": 16000
    },
    "input": {}
  }
}
控制消息和音頻消息,通過同一個 sendDataMsg 接口發送:
String taskId = UUID.randomUUID().toString();
JSONObject root = new JSONObject()
        .put("header", new JSONObject()
                .put("action", "run-task")
                .put("task_id", taskId)
                .put("streaming", "duplex"))
        .put("payload", new JSONObject()
                .put("task_group", "audio")
                .put("task", "asr")
                .put("function", "recognition")
                .put("model", "qwen-audio-3.1-asr-flash-streaming")
                .put("parameters", new JSONObject()
                        .put("format", "pcm")
                        .put("sample_rate", 16000))
                .put("input", new JSONObject()));

AoqClientEngine.AoqDataMsg msg = new AoqClientEngine.AoqDataMsg();
msg.data = root.toString().getBytes(StandardCharsets.UTF_8);
engine.sendDataMsg(msg);

5. 收到 task-started 後倍速補發緩存音頻

在 onDataMsg 中解析 JSON。收到當前任務的下列事件後,才將發送狀態切換為就緒。
{
  "header": {
    "event": "task-started",
    "task_id": "9fb212ea-6f83-4aef-9b8e-1bdd71f1d4d3"
  },
  "payload": {}
}
以上為事件關鍵字段示例,其餘響應字段可按業務需要讀取,具體事件請參考服務端事件。此時音頻緩存通常已經積累了“憑證申請 + AOQ 建連 + 模型任務啓動”期間採集到的音頻。對帶有 task_id 的響應核對當前任務 ID;重復的 task-started 不應啓動第二個發送循環。 音頻數據封裝格式: qwen-audio-3.1-asr-flash-streaming 的 Inference 協議(客戶端事件)只定義了 run-task、finish-task 兩種控制事件,音頻數據本身不是通過 JSON 事件發送的。但通過 AOQ Data 通道傳輸時,音頻數據需要以 JSON 消息形式發送。為此,AOQ 在 SDK 層面封裝了 input_audio_buffer.append 事件,用於承載音頻數據:
字段類型說明
typestring固定為 input_audio_buffer.append
event_idstring事件唯一標識,用於關聯和去重
audiostringBase64 編碼的 PCM 音頻數據
客戶端將採集到的 PCM 數據編碼為 Base64 後包裝到此 JSON 中,通過 sendDataMsg 發送。AOQ 服務端收到此 JSON 後,解析 audio 字段為二進制 PCM 數據,按照模型協議轉發給 ASR 模型。客戶端無需關心二進制幀的封裝細節,只需確保 PCM 格式(採樣率、聲道數、位深)與 run-task 中聲明的 format 和 sample_rate 一致。
{
    "type": "input_audio_buffer.append",
    "event_id": "event_550e8400-e29b-41d4-a716-446655440000",
    "audio": "AQID//+AgAAEAACAAAAA..."
}
發送音頻數據: 從緩存隊列中取出 PCM 數據,包裝為 input_audio_buffer.append JSON 後通過 sendDataMsg 發送:
byte[] pcm = pcmQueue.poll(640);  // 取出一幀 PCM 數據

if (pcm != null) {
    JSONObject event = new JSONObject()
            .put("type", "input_audio_buffer.append")
            .put("event_id", "event_" + UUID.randomUUID())
            .put("audio", Base64.encodeToString(pcm, Base64.NO_WRAP));

    AoqClientEngine.AoqDataMsg msg = new AoqClientEngine.AoqDataMsg();
    msg.data = event.toString().getBytes(StandardCharsets.UTF_8);
    engine.sendDataMsg(msg);
}
處理服務端事件: 在 onDataMsg 回調中解析服務端事件,收到 task-started 後啓動音頻發送,收到 result-generated 後解析識別結果,具體事件請參考服務端事件。
JSONObject root = new JSONObject(new String(msg.data, StandardCharsets.UTF_8));
String event = root.optJSONObject("header").optString("event");

if ("task-started".equals(event)) {
    // 模型就緒,啓動音頻發送(倍速補發緩存,追平後實時發送)
} else if ("result-generated".equals(event)) {
    // 解析識別結果(見步驟 6)
    JSONObject sentence = root.optJSONObject("payload")
            .optJSONObject("output").optJSONObject("sentence");
    if (sentence != null && !sentence.optBoolean("heartbeat")) {
        String text = sentence.optString("text", "");
        boolean isFinal = sentence.optBoolean("sentence_end");
        // 展示識別結果
    }
} else if ("task-finished".equals(event)) {
    // 任務完成
} else if ("task-failed".equals(event)) {
    // 任務失敗
}

6. 處理識別結果並顯示

result-generated 事件攜帶識別句子,包含 text、sentence_end 等字段。中間結果實時展示,最終結果追加到輸入框。具體事件定義請參考服務端事件。
private void handleRecognitionResult(JSONObject root) {
    JSONObject payload = root.optJSONObject("payload");
    JSONObject output = payload == null ? null : payload.optJSONObject("output");
    JSONObject sentence = output == null ? null : output.optJSONObject("sentence");
    if (sentence == null || sentence.optBoolean("heartbeat")) return;

    String text = sentence.optString("text", "");
    if (text.isEmpty()) return;
    boolean isFinal = sentence.optBoolean("sentence_end");

    // 中間結果用於實時展示,最終結果用於追加
    listener.onResult(text, isFinal);
}

7. 發送 finish-task 結束識別任務

用戶點擊結束後,先調用 engine.stopAudioCapture() 停止採集,等待緩存中剩餘的 PCM 數據排空後,發送 finish-task 通知服務端音頻已發送完畢,具體事件定義請參考客戶端事件。
task_id 必須與本輪 run-task 一致。
finish-task 事件結構如下:
{
    "header": {
        "action": "finish-task",
        "task_id": "UUID",
        "streaming": "duplex"
    },
    "payload": {
        "input": {}
    }
}
通過 sendDataMsg 發送:
engine.stopAudioCapture();  // 停止採集

// 等待緩存排空後發送 finish-task
JSONObject root = new JSONObject()
        .put("header", new JSONObject()
                .put("action", "finish-task")
                .put("task_id", taskId)
                .put("streaming", "duplex"))
        .put("payload", new JSONObject()
                .put("input", new JSONObject()));

AoqClientEngine.AoqDataMsg msg = new AoqClientEngine.AoqDataMsg();
msg.data = root.toString().getBytes(StandardCharsets.UTF_8);
engine.sendDataMsg(msg);
模型完成後返回的關鍵事件為:
{
  "header": {
    "event": "task-finished",
    "task_id": "9fb212ea-6f83-4aef-9b8e-1bdd71f1d4d3"
  },
  "payload": {}
}

8. 斷開連接並銷毀引擎

收到 task-finished 後,如果不再需要複用連接,按以下方式清理資源;發生異常或超時時也應清理資源。
engine.stopAudioCapture();
engine.setAudioFrameObserver(null);
engine.disconnect();
AoqClientEngine.destroy();
engine = null;

注意事項

  1. 必須收到 task-started 後才能發送音頻,否則數據會被丟棄。
  2. finish-task 必須在緩存排空(queue.size() == 0)後發送,確保最後一段音頻被模型處理。
  3. 收到 task-finished 後不要立即斷開連接,可復用同一連接發起新任務(新 task_id)。
  4. result-generated 中的心跳包(heartbeat: true)應忽略,不作為識別結果。

典型場景

同一連接多次識別

收到 task-finished 後,連接仍然可用。客戶端可以使用新的 task_id 再次發送 run-task,開始新一輪識別,無需重新建立連接。這適用於連續語音輸入場景,減少重復連接的延遲。
// 第一輪識別結束後(收到 task-finished)
// 使用新的 task_id 發送 run-task,開始第二輪
taskId = UUID.randomUUID().toString();
sendRunTask();

自行實現音頻採集

本文示例使用 AOQ SDK 的內部採集(startAudioCapture + onCapturedAudioFrame 回調),也可以自行實現音頻採集。只需將採集到的 PCM 數據(16 kHz、單聲道、16 位)放入 PcmQueue,後續的緩存補發和發送邏輯完全相同。

相關文檔