通過 AOQ 接入 qwen3.5-omni-plus-realtime,由用戶端控制語音起止,實現按鍵通話和可選的拍照提問。用戶端代碼以 iOS Swift 為例。
方案概述
Qwen-Omni-Realtime 支援由服務端 VAD 自動劃分輪次,也支援由用戶端控制輪次的 Manual 模式。本教程將 session.turn_detection 設為 null:使用者按下按鈕時發送音頻,鬆開按鈕時提交音頻並顯式觸發模型回複。
Manual 模式適用於硬體按鍵對講、螢幕按住說話、雜訊環境下由業務自行判停,以及在一輪語音中按需附帶圖片等情境。音頻通過 AOQ Audio 軌傳輸,不需要發送 input_audio_buffer.append。
對比項 | VAD 模式 | Manual 模式 |
語音起止 | 服務端通過 server_vad 或 semantic_vad 檢測 | 用戶端根據按鍵或業務狀態控制 |
會話配置 | turn_detection 為 VAD 參數 | turn_detection 為 null |
提交音頻 | 服務端自動認可 | 用戶端發送 input_audio_buffer.commit |
觸發回複 | 服務端自動觸發 | 用戶端發送 response.create |
圖片輸入 | 視頻軌持續推流或 Data 軌按需發圖 | 視頻軌持續推流或 Data 軌按需發圖 |
準備工作
- 開通阿里雲百鍊,並按擷取與配置 API Key。API Key 只儲存在業務 AppServer,不要寫入用戶端代碼或提交到代碼倉庫。
- 根據業務部署地區確認 AOQ Endpoint。地區和接入地址的選擇方法請參見選擇地區、服務部署範圍和接入網域名稱。
- 從SDK 下載擷取最新版 AOQ Client SDK。
- 搭建業務 AppServer,並按Token 鑒權實現服務端代理鑒權。每次建立新串連前,用戶端都應從 AppServer 擷取新的串連憑證。
匯入 SDK
根據開發平台匯入對應 SDK。後續用戶端代碼以 iOS Swift 為例,其他平台使用相同的介面設計和事件流程。本文以 PCM 音頻流為例。Opus 編碼由外掛程式提供;如果需要使用 Opus 編碼上行,請匯入 Opus 外掛程式。
Android
- 將 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'])
}
- 在 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" />
<uses-permission android:name="android.permission.CAMERA" />
- 在使用相應裝置前動態申請 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" } },
{ "name": "ohos.permission.CAMERA",
"reason": "$string:perm_camera_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }
]
- 在使用相應裝置前調用 abilityAccessCtrl.createAtManager().requestPermissionsFromUser 申請 ohos.permission.MICROPHONE、ohos.permission.CAMERA。
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 的絕對路徑。
實現流程
- AppServer 通過 Realtime Token 地址擷取 qwen3.5-omni-plus-realtime 的 AOQ 串連參數。
- 用戶端建立引擎,配置音頻編解碼與軌道;如需持續視覺理解,再配置 Video 軌。
- 用戶端啟動本地採集和播放,預設關閉 Audio 軌發送,然後建立 AOQ 串連並發送 session.update。
- 收到 session.updated 後,持續視頻方案開啟 Video 軌;Audio 軌仍保持關閉,直到使用者按下說話按鈕。
- 使用者按下按鈕時開啟 Audio 軌;鬆開時先關閉 Audio 軌,再按需發送圖片,然後依次發送 input_audio_buffer.commit 和 response.create。
- 收到 response.done 後可開始下一輪;結束使用時停止裝置、中斷連線並銷毀引擎。
視頻軌持續推流
發布 Video 軌並在 session.updated 後開啟視頻發送。模型持續看到最新畫面;每輪語音只需提交音頻並觸發回複。

Data 軌按需發圖
不發布 Video 軌。需要配圖時,在鬆開按鈕後先發送 input_image_buffer.append,再提交本輪音頻並觸發回複。

AppServer 擷取 Token
在 AppServer 設定 DASHSCOPE_API_KEY,並使用所選地區的 Endpoint 發送請求。clientIp 為用戶端的真實公網 IP;該欄位可選,但建議傳入,以便服務分配合適的 Relay 存取點。
curl -X POST \
"https://{endpoint}/api/v1/webrtc/realtime?model=qwen3.5-omni-plus-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 |
實現 iOS 用戶端
用戶端從 AppServer 擷取 AoqConnectConfig 後,按以下步驟實現 iOS 端按鍵語音對話。
1. 建立引擎並設定回調
建立 AOQ 單例引擎,並把業務對象註冊為回調接收方。客戶需要在回調中處理串連狀態、服務端事件、錯誤和警示。
let createConfig = AoqCreateConfig()
createConfig.workDir = workDir
engine = AoqClientEngine.createEngine(createConfig, delegate: self)
2. 啟動音視頻裝置
初始化音頻採集與播放。只有持續視頻軌方案需要啟動網路攝影機;客戶需要在調用前獲得麥克風和網路攝影機許可權。
let captureConfig = AoqAudioCaptureConfig()
captureConfig.channel = 1
captureConfig.isExternal = false
engine.startAudioCapture(captureConfig)
let playbackConfig = AoqAudioPlaybackConfig()
playbackConfig.channel = 1
playbackConfig.isExternal = false
playbackConfig.isDefaultSpeaker = true
engine.startAudioPlayer(playbackConfig)
3. 配置編解碼和軌道
根據接入模型和業務音頻格式配置音頻編解碼參數,並根據圖片輸入方式選擇軌道。以下音頻與視頻數值僅為樣本,請按模型要求和業務情境調整。串連前必須關閉 Audio 軌發送。
視頻軌持續推流
客戶需要配置 Audio、Video 和 Data 發布軌,並根據實際畫質與頻寬調整視頻編碼參數。
let audioEncoderConfig = AoqAudioCodecConfig()
audioEncoderConfig.trackType = .audio
audioEncoderConfig.codecType = .audioPCM
audioEncoderConfig.sampleRate = 16_000
audioEncoderConfig.channel = 1
engine.setAudioEncoderConfig(audioEncoderConfig)
let audioDecoderConfig = AoqAudioCodecConfig()
audioDecoderConfig.trackType = .audio
audioDecoderConfig.codecType = .audioPCM
audioDecoderConfig.sampleRate = 24_000
audioDecoderConfig.channel = 1
engine.setAudioDecoderConfig(audioDecoderConfig)
let videoEncoderConfig = AoqVideoCodecConfig()
videoEncoderConfig.trackType = .video
videoEncoderConfig.codecType = .videoJpeg
videoEncoderConfig.width = 960
videoEncoderConfig.height = 540
videoEncoderConfig.fps = 2
videoEncoderConfig.bitrate = 500_000
engine.setVideoEncoderConfig(videoEncoderConfig)
let publishAudioTrack = AoqTrackParam()
publishAudioTrack.trackType = .audio
let publishVideoTrack = AoqTrackParam()
publishVideoTrack.trackType = .video
let publishDataTrack = AoqTrackParam()
publishDataTrack.trackType = .data
let subscribeAudioTrack = AoqTrackParam()
subscribeAudioTrack.trackType = .audio
let subscribeDataTrack = AoqTrackParam()
subscribeDataTrack.trackType = .data
connectConfig.publishTracks = [publishAudioTrack, publishVideoTrack, publishDataTrack]
connectConfig.subscribeTracks = [subscribeAudioTrack, subscribeDataTrack]
Data 軌按需發圖
客戶僅配置 Audio 和 Data 軌,不配置視頻編碼器,從而避免持續採集、編碼和傳輸視頻。音頻數值僅為樣本。
let audioEncoderConfig = AoqAudioCodecConfig()
audioEncoderConfig.trackType = .audio
audioEncoderConfig.codecType = .audioPCM
audioEncoderConfig.sampleRate = 16_000
audioEncoderConfig.channel = 1
engine.setAudioEncoderConfig(audioEncoderConfig)
let audioDecoderConfig = AoqAudioCodecConfig()
audioDecoderConfig.trackType = .audio
audioDecoderConfig.codecType = .audioPCM
audioDecoderConfig.sampleRate = 24_000
audioDecoderConfig.channel = 1
engine.setAudioDecoderConfig(audioDecoderConfig)
let publishAudioTrack = AoqTrackParam()
publishAudioTrack.trackType = .audio
let publishDataTrack = AoqTrackParam()
publishDataTrack.trackType = .data
let subscribeAudioTrack = AoqTrackParam()
subscribeAudioTrack.trackType = .audio
let subscribeDataTrack = AoqTrackParam()
subscribeDataTrack.trackType = .data
connectConfig.publishTracks = [publishAudioTrack, publishDataTrack]
connectConfig.subscribeTracks = [subscribeAudioTrack, subscribeDataTrack]
4. 配置 Manual 會話
串連成功後,調用 sendDataMsg 發送 session.update 事件。客戶需要把 turn_detection 設為 null,並按業務選擇音色、系統指令和輸出模態。樣本中的音頻參數需要與 SDK 編解碼配置保持一致。完整欄位請參見用戶端事件。
private func sendSessionUpdate() {
let event: [String: Any] = [
"type": "session.update",
"session": [
"modalities": ["text", "audio"],
"voice": "Ethan",
"audio": [
"input": ["format": ["type": "pcm", "sample_rate": 16_000]],
"output": ["format": ["type": "pcm", "sample_rate": 24_000]]
],
"turn_detection": NSNull()
]
]
guard let data = try? JSONSerialization.data(withJSONObject: event) else { return }
let dataMessage = AoqDataMsg()
dataMessage.data = data
engine.sendDataMsg(dataMessage)
}
5. 等待會話配置生效
在 onDataMsg 回調中處理 session.updated 事件,收到該事件後才能發送媒體。持續視頻軌方案此時調用 enableSendMediaStream 開啟 Video 軌;Audio 軌仍保持關閉,避免使用者按鍵前的音頻進入緩衝區。
func onDataMsg(_ msg: AoqDataMsg) {
guard let event = try? JSONSerialization.jsonObject(with: msg.data) as? [String: Any],
let type = event["type"] as? String else { return }
if type == "session.updated", imageMode == .continuousVideo {
engine.enableSendMediaStream(.video, enable: true)
}
// 按下通話按鈕前保持 Audio 軌關閉。
}
6. 實現按鍵語音互動
按下按鈕時調用 enableSendMediaStream 開啟 Audio 軌。鬆開按鈕時先調用 enableSendMediaStream 關閉 Audio 軌,確認本輪確有音頻,再按需發送圖片,並調用 sendDataMsg 依次發送 input_audio_buffer.commit 和 response.create 事件。
func onPushToTalkPressed() {
hasAudioInCurrentTurn = true
engine.enableSendMediaStream(.audio, enable: true)
}
func onPushToTalkReleased(base64Jpeg: String? = nil) {
engine.enableSendMediaStream(.audio, enable: false)
guard hasAudioInCurrentTurn else { return }
if imageMode == .singleImage, let base64Jpeg {
let imageEvent: [String: Any] = [
"type": "input_image_buffer.append",
"image": base64Jpeg
]
if let data = try? JSONSerialization.data(withJSONObject: imageEvent) {
let dataMessage = AoqDataMsg()
dataMessage.data = data
engine.sendDataMsg(dataMessage)
}
}
for event in [
["type": "input_audio_buffer.commit"],
["type": "response.create"]
] {
guard let data = try? JSONSerialization.data(withJSONObject: event) else { continue }
let dataMessage = AoqDataMsg()
dataMessage.data = data
engine.sendDataMsg(dataMessage)
}
hasAudioInCurrentTurn = false
}
7. 選擇圖片輸入方式
持續視覺理解和偶發拍照提問使用不同的軌道配置與發送方式;客戶需要根據頻寬、功耗和互動方式選擇。
視頻軌持續推流
適合視訊通話、畫面變化較快或模型需要持續理解視覺內容相關的情境。發布 Video 軌後,不要再發送 input_image_buffer.append。
Data 軌按需發送單張圖片
適合拍照提問或偶發配圖。客戶需要把圖片處理為 JPG/JPEG 並進行 Base 64 編碼,在鬆開按鍵後、提交音頻前發送:
{
"type": "input_image_buffer.append",
"image": "<Base64-encoded JPEG data>"
}
- 建議解析度為 480p 或 720p,最高不超過 1080p。
- Base 64 編碼後不得超過 256 KB,建議編碼前不超過 190 KB,並為 JSON 封裝欄位預留空間。
- 發送圖片前,Audio 軌必須已經在本輪至少上行過一幀音頻;隨後的 input_audio_buffer.commit 會同時提交音頻和圖片。
8. 中斷連線並銷毀引擎
結束會話時中斷連線並銷毀引擎。disconnect 或 destroy 會自動關閉媒體裝置,無需額外調用停止介面。AoqClientEngine 為全域單例,只有 destroy 後才能重新建立。
engine.disconnect()
AoqClientEngine.destroy()
完整樣本
以下類接收已由 AppServer Token 響應轉換完成的 AoqConnectConfig。請在生產代碼中補充 UI 狀態、許可權、錯誤恢複和圖片壓縮邏輯。
import Foundation
import AoqClientSdk
final class ManualPushToTalkClient: NSObject, AoqEngineDelegate {
enum ImageMode: Equatable {
case none
case continuousVideo
case singleImage
}
private var engine: AoqClientEngine!
private let imageMode: ImageMode
private var hasAudioInCurrentTurn = false
init(workDir: String, connectConfig: AoqConnectConfig, imageMode: ImageMode) {
self.imageMode = imageMode
super.init()
let createConfig = AoqCreateConfig()
createConfig.workDir = workDir
self.engine = AoqClientEngine.createEngine(createConfig, delegate: self)
// 樣本參數,請按接入模型和業務音頻格式調整。
let audioEncoderConfig = AoqAudioCodecConfig()
audioEncoderConfig.trackType = .audio
audioEncoderConfig.codecType = .audioPCM
audioEncoderConfig.sampleRate = 16_000
audioEncoderConfig.channel = 1
engine.setAudioEncoderConfig(audioEncoderConfig)
let audioDecoderConfig = AoqAudioCodecConfig()
audioDecoderConfig.trackType = .audio
audioDecoderConfig.codecType = .audioPCM
audioDecoderConfig.sampleRate = 24_000
audioDecoderConfig.channel = 1
engine.setAudioDecoderConfig(audioDecoderConfig)
let publishAudioTrack = AoqTrackParam()
publishAudioTrack.trackType = .audio
let publishDataTrack = AoqTrackParam()
publishDataTrack.trackType = .data
let subscribeAudioTrack = AoqTrackParam()
subscribeAudioTrack.trackType = .audio
let subscribeDataTrack = AoqTrackParam()
subscribeDataTrack.trackType = .data
connectConfig.publishTracks = [publishAudioTrack, publishDataTrack]
connectConfig.subscribeTracks = [subscribeAudioTrack, subscribeDataTrack]
if imageMode == .continuousVideo {
let videoEncoderConfig = AoqVideoCodecConfig()
videoEncoderConfig.trackType = .video
videoEncoderConfig.codecType = .videoJpeg
videoEncoderConfig.width = 960
videoEncoderConfig.height = 540
videoEncoderConfig.fps = 2
videoEncoderConfig.bitrate = 500_000
engine.setVideoEncoderConfig(videoEncoderConfig)
let publishVideoTrack = AoqTrackParam()
publishVideoTrack.trackType = .video
connectConfig.publishTracks = [publishAudioTrack, publishVideoTrack, publishDataTrack]
}
let captureConfig = AoqAudioCaptureConfig()
captureConfig.channel = 1
captureConfig.isExternal = false
engine.startAudioCapture(captureConfig)
let playbackConfig = AoqAudioPlaybackConfig()
playbackConfig.channel = 1
playbackConfig.isExternal = false
playbackConfig.isDefaultSpeaker = true
engine.startAudioPlayer(playbackConfig)
if imageMode == .continuousVideo {
let videoCaptureConfig = AoqVideoCaptureConfig()
videoCaptureConfig.width = 1280
videoCaptureConfig.height = 720
videoCaptureConfig.fps = 15
engine.startVideoCapture(videoCaptureConfig)
}
engine.enableSendMediaStream(.audio, enable: false)
if imageMode == .continuousVideo {
engine.enableSendMediaStream(.video, enable: false)
}
engine.connect(connectConfig)
}
func onPushToTalkPressed() {
hasAudioInCurrentTurn = true
engine.enableSendMediaStream(.audio, enable: true)
}
func onPushToTalkReleased(base64Jpeg: String? = nil) {
engine.enableSendMediaStream(.audio, enable: false)
guard hasAudioInCurrentTurn else { return }
if imageMode == .singleImage, let base64Jpeg {
let imageEvent: [String: Any] = [
"type": "input_image_buffer.append",
"image": base64Jpeg
]
if let data = try? JSONSerialization.data(withJSONObject: imageEvent) {
let dataMessage = AoqDataMsg()
dataMessage.data = data
engine.sendDataMsg(dataMessage)
}
}
for event in [
["type": "input_audio_buffer.commit"],
["type": "response.create"]
] {
guard let data = try? JSONSerialization.data(withJSONObject: event) else { continue }
let dataMessage = AoqDataMsg()
dataMessage.data = data
engine.sendDataMsg(dataMessage)
}
hasAudioInCurrentTurn = false
}
private func sendSessionUpdate() {
let event: [String: Any] = [
"type": "session.update",
"session": [
"modalities": ["text", "audio"],
"voice": "Ethan",
"audio": [
"input": ["format": ["type": "pcm", "sample_rate": 16_000]],
"output": ["format": ["type": "pcm", "sample_rate": 24_000]]
],
"turn_detection": NSNull()
]
]
guard let data = try? JSONSerialization.data(withJSONObject: event) else { return }
let dataMessage = AoqDataMsg()
dataMessage.data = data
engine.sendDataMsg(dataMessage)
}
func close() {
engine.disconnect()
AoqClientEngine.destroy()
}
func onConnectionStatusChange(_ status: AoqConnectionStatus) {
if status == .connected { sendSessionUpdate() }
}
func onDataMsg(_ msg: AoqDataMsg) {
guard let event = try? JSONSerialization.jsonObject(with: msg.data) as? [String: Any],
let type = event["type"] as? String else { return }
if type == "session.updated", imageMode == .continuousVideo {
engine.enableSendMediaStream(.video, enable: true)
}
}
func onError(_ code: Int, message: String) {}
func onWarning(_ code: Int, message: String) {}
func onStats(_ stats: AoqStats) {}
func onAudioDeviceStateChanged(_ state: AoqAudioDeviceState) {}
func onAudioDeviceRouteChanged(_ routeType: Int) {}
func onAudioDeviceInterrupted(_ interrupt: Bool) {}
func onAudioFileState(_ state: AoqAudioFileState) {}
func onVideoDeviceStateChanged(_ state: AoqVideoDeviceState) {}
}
運行並驗證
分別完成一次純語音按鍵對話和一次帶圖片的按鍵對話,預期結果如下:
- 按下按鈕前不發送 Audio 軌;按住按鈕時持續上行音頻。
- 鬆開按鈕後依次收到 input_audio_buffer.committed、response.created 和 response.done,模型語音通過訂閱的 Audio 軌播放。
- 選擇單張圖片方案時,模型結合本輪圖片與語音作答;選擇持續視頻方案時,模型使用最新視頻畫面。
服務端事件欄位和完整響應結構請參見服務端事件。
注意事項
- AOQ 的 Audio 軌負責傳輸音頻,不要另外發送 input_audio_buffer.append。
- input_audio_buffer.commit 只提交本輪輸入,不會觸發模型回複;必須隨後發送 response.create。
- 空音頻緩衝區不要提交,否則服務端會返回錯誤。
- 不要在收到 session.updated 前開啟媒體發送;Manual 模式下也不要在使用者按下按鈕前開啟 Audio 軌。
相關文檔
如需查看完整參數、事件欄位或其他平台介面,請參見: