Skip to main content

AOQ 経由で qwen3.5-omni-plus-realtime を使用したプッシュツートーク音声会話の構築

AOQ を使用して qwen3.5-omni-plus-realtime に接続し、クライアントがプッシュツートーク会話とオプションの画像による質問のためにターンの境界を制御できるようにします。クライアントコードは iOS Swift を使用します。

ソリューション概要

Qwen-Omni-Realtime は、サーバー側の VAD とクライアント制御のマニュアルモードをサポートしています。本チュートリアルでは、session.turn_detection を null に設定します。クライアントは、ユーザーがボタンを押している間に音声を送信し、ユーザーがボタンを離したときに音声をコミットして明示的にレスポンスをリクエストします。 マニュアルモードは、ハードウェアインターコムボタン、長押しコントロール、アプリケーションがターンの境界を決定するノイズの多い環境、およびオプションで画像を含むターンに適しています。音声は AOQ 音声トラック経由で転送されます。input_audio_buffer.append は送信しないでください。

項目

VAD モード

マニュアルモード

ターンの境界

server_vad または semantic_vad によって検出

ボタンまたはアプリケーションの状態によって制御

セッション設定

turn_detection に VAD 設定が含まれる

turn_detection は null

音声のコミット

サービスによって自動的に実行

クライアントが input_audio_buffer.commit を送信

レスポンストリガー

サービスによって自動的にトリガー

クライアントが response.create を送信

画像入力

連続ビデオトラックまたはデータトラック経由の画像

連続ビデオトラックまたはデータトラック経由の画像

前提条件

  1. Model Studio を有効化し、「API キーの取得と設定」に従ってください。API キーはアプリケーションサーバーにのみ保存してください。クライアントコードに含めたり、コードリポジトリにコミットしたりしないでください。
  2. アプリケーションがデプロイされているリージョンの AOQ エンドポイントを確認してください。選択のガイダンスについては、「リージョン、デプロイスコープ、およびエンドポイントの選択」をご参照ください。
  3. 「SDKのダウンロード」に記載されているように、最新の AOQ Client SDK をダウンロードしてください。
  4. Qwen-Omni-Realtime の AOQ 認証手順に従って AppServer を構築します。新しい接続のたびに、クライアントは AppServer から新しい接続認証情報を取得する必要があります。

SDKのインポート

開発プラットフォーム用の SDK をインポートします。クライアント実装では iOS Swift を使用します。他のプラットフォームでも同じインターフェースとイベントフローが提供されています。本チュートリアルでは PCM 音声ストリームを使用します。Opus エンコーディングはプラグインによって提供されます。アップリンクで Opus を使用する場合は、Opus プラグインをインポートしてください。
  • Android
  • iOS
  • HarmonyOS
  • Linux (Python)
  1. 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'])
}
  1. 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" />
  1. 対応するデバイスを使用する前に、実行時に RECORD_AUDIO と CAMERA のパーミッションをリクエストします。

実装フロー

  1. アプリケーションサーバーは、Realtime トークン URL から qwen3.5-omni-plus-realtime の AOQ 接続パラメータを取得します。
  2. クライアントはエンジンを作成し、音声コーデックとトラックを設定します。連続的な視覚理解が必要な場合は、ビデオトラックも設定します。
  3. クライアントはローカルキャプチャと再生を開始し、デフォルトで音声トラックの送信を無効にし、AOQ に接続して session.update を送信します。
  4. session.updated を受信した後、continuous-video オプションはビデオトラックを有効にします。音声トラックは、ユーザーがトークボタンを押すまで無効のままです。
  5. ユーザーがボタンを押すと、クライアントは音声トラックを有効にします。離すと、音声トラックを無効にし、オプションで画像を送信してから、input_audio_buffer.commit と response.create を送信します。
  6. response.done を受信した後、別のターンを開始できます。終了するには、デバイスを停止し、切断してエンジンを破棄します。
  • 連続ビデオトラック
  • データトラック経由で画像を送信
ビデオトラックをパブリッシュし、session.updated の後にビデオ送信を有効にします。モデルは最新のフレームを継続的に認識します。各音声ターンは、音声をコミットしてレスポンスをリクエストするだけで済みます。
Sequence diagram for AOQ Manual mode with continuous video streaming

アプリケーションサーバーからのトークンの取得

アプリケーションサーバーで DASHSCOPE_API_KEY を設定し、選択したリージョンのエンドポイントにリクエストを送信します。clientIp はクライアントの実際のパブリック IP アドレスです。このフィールドはオプションですが、指定すると、サービスが適切なリレーエンドポイントを割り当てるのに役立ちます。
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}\"}"
アプリケーションサーバーがクライアントの実際のパブリック IP アドレスを取得できない場合は、空の文字列を渡すのではなく、clientIp を省略してください。
AppServer は、レスポンスの次のフィールドをクライアントへ返します。API キーは AppServer にのみ保存してください。

レスポンスフィールド

SDK フィールド

aoqTokenForClient

AoqConnectConfig.token

sid

AoqConnectConfig.sid

clientRelayCertFingerprint

AoqConnectConfig.certFingerprint

clientRelayEndpoints

AoqConnectConfig.relayEndpoints

extraInfo.workspaceIdHash

AoqConnectConfig.workspaceIdHash

iOSクライアントの実装

クライアントがアプリケーションサーバーから AoqConnectConfig を取得した後、以下の手順に従って iOS でプッシュツートーク音声会話を実装します。

1. エンジンの作成とコールバックの登録

シングルトン AOQ エンジンを作成し、アプリケーションオブジェクトをコールバックレシーバーとして登録します。コールバックで接続状態、サーバーイベント、エラー、および警告を処理します。
let createConfig = AoqCreateConfig()
createConfig.workDir = workDir
engine = AoqClientEngine.createEngine(createConfig, delegate: self)

2. 音声およびビデオデバイスの開始

音声キャプチャと再生を初期化します。continuous-video オプションの場合のみカメラを開始します。これらのメソッドが呼び出される前に、マイクとカメラのパーミッションを取得してください。
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. コーデックとトラックの設定

選択したモデルとアプリケーションの音声フォーマットに応じて音声コーデックを設定し、画像入力オプションに応じてトラックを選択します。以下の音声とビデオの値は例です。モデルの要件とアプリケーションシナリオに合わせて調整してください。接続が確立される前に、音声トラックの送信を無効にしてください。
  • 連続ビデオトラック
  • データトラック経由で画像を送信
音声、ビデオ、およびデータのパブリッシュトラックを設定します。必要な画像品質と利用可能な帯域幅に合わせてビデオエンコーディング設定を調整してください。
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]

4. マニュアルセッションの設定

接続が確立された後、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 イベントを処理します。このイベントを受信するまで、メディアを送信しないでください。continuous-video オプションの場合は、この時点で enableSendMediaStream を呼び出してビデオトラックを有効にしますが、ユーザーがボタンを押す前の音声が入力バッファに入らないように、音声トラックは無効のままにしてください。
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)
    }
    // ユーザーがトークボタンを押すまで、音声トラックの送信は無効のままにしてください。
}

6. プッシュツートークインタラクションの実装

ボタンが押されたら、enableSendMediaStream を呼び出して音声トラックを有効にします。離したら、enableSendMediaStream を呼び出して音声トラックを無効にし、ターンに音声が含まれていることを確認し、オプションで画像を送信してから、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. 画像入力オプションの選択

連続的な視覚理解と時折の画像による質問では、異なるトラック設定と送信動作を使用します。帯域幅、消費電力、およびインタラクションデザインに基づいてオプションを選択してください。
  • 連続ビデオトラック
  • データトラック経由で単一の画像を送信
このオプションは、ビデオ通話、急速に変化するシーン、または連続的な視覚コンテキストに適しています。ビデオトラックがパブリッシュされた後は、input_image_buffer.append を送信しないでください。

8. 切断とエンジンの破棄

セッションが終了したら、切断してエンジンを破棄します。disconnect または destroy は自動的にメディアデバイスを閉じるため、stop メソッドを個別に呼び出す必要はありません。AoqClientEngine はシングルトンであり、destroy が呼び出されるまで再度作成することはできません。
engine.disconnect()
AoqClientEngine.destroy()

完全な例

以下のクラスは、アプリケーションサーバーのトークンレスポンスからマッピングされた 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) {}
}

実行と検証

音声のみのプッシュツートークターンを 1 回と、画像を含むターンを 1 回完了します。期待される結果:
  1. 音声トラックは、ボタンが押される前は無効になっており、ボタンが押されている間は継続的に音声を送信します。
  2. 離した後、input_audio_buffer.committed、response.created、および response.done が順番に受信され、モデルの音声がサブスクライブされた音声トラック経由で再生されます。
  3. 単一画像オプションでは、モデルはターンの画像と音声を使用してレスポンスします。連続ビデオでは、最新のビデオフレームを使用します。
サーバーイベントフィールドと完全なレスポンススキーマについては、「サーバーイベント」をご参照ください。

重要な考慮事項

  1. AOQ は音声トラック経由で音声を転送します。input_audio_buffer.append も送信しないでください。
  2. input_audio_buffer.commit はターンをコミットするだけで、モデルのレスポンスをトリガーしません。その後に response.create を送信してください。
  3. 空の音声バッファをコミットしないでください。サービスはエラーを返します。
  4. session.updated の前にメディア送信を有効にしないでください。マニュアルモードでは、ユーザーがボタンを押す前に音声トラックを有効にしないでください。

関連情報

すべてのパラメータ、イベントフィールド、および他のプラットフォーム用のインターフェースについては、以下をご参照ください。