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 音声メッセージ形式に対応しており、受信したメッセージを自動的にバイナリデータに変換してモデルに転送します。
  4. 中間結果と最終結果:モデルは認識結果を継続的に返します。中間結果(sentence_end: false)はリアルタイムで表示し、最終結果(sentence_end: true)は入力欄に追加します。

準備

前提条件

  1. Alibaba Cloud Model Studio を有効にし、API キーの取得と設定に従って API キーを取得します。API キーは AppServer のみに保存してください。クライアントコードに埋め込んだり、コードリポジトリにコミットしたりしないでください。
  2. {endpoint} は中国(北京)の {WorkspaceId}.cn-beijing.maas.aliyuncs.com またはシンガポールの {WorkspaceId}.ap-southeast-1.maas.aliyuncs.com にします。実際のワークスペース ID に置き換え、対応するリージョンとワークスペースの API キーを使用します。
  3. SDK ダウンロードから最新の AOQ Client SDK を取得します。
  4. Qwen-Audio-ASR-Streaming の AOQ 認証手順に従って AppServer を構築します。新しい接続のたびに、クライアントは 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 で依存関係と対応 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 でトークンを取得

AppServer は Model Studio の割り当て API を呼び出し、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 メッセージコールバックでサーバーイベントを処理します。
// Create the engine
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) {
            // Connected: send run-task (see step 4)
        }
    }

    @Override
    public void onDataMsg(@NonNull AoqClientEngine.AoqDataMsg msg) {
        // Parse server events (see step 5)
    }

    @Override
    public void onError(int code, String message) {
        // Handle errors
    }
});

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;

// Configure PCM encoding
AoqClientEngine.AoqAudioCodecConfig codec = new AoqClientEngine.AoqAudioCodecConfig();
codec.codecType = AoqClientEngine.AoqEncoderType.AoqEncoderTypeAudioPCM;
codec.sampleRate = SAMPLE_RATE;
codec.channel = 1;
codec.bitrate = 256000;
engine.setAudioEncoderConfig(codec);

// Register the capture callback
engine.setAudioFrameObserver(new AoqClientListener.AoqAudioFrameListener() {
    @Override
    public void onCapturedAudioFrame(
            @NonNull AoqClientEngine.AoqAudioFrameData frame) {
        if (frame.dataPtr != null && frame.dataSize > 0) {
            // Buffer PCM data in the queue (see step 5)
            pcmQueue.offer(frame.dataPtr, frame.dataSize);
        }
        Arrays.fill(frame.dataPtr, (byte) 0);  // Clear the buffer to prevent outgoing audio media
    }
});

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);

// Disable outgoing audio media (send audio over the Data channel)
engine.enableSendMediaStream(AoqClientEngine.AoqTrackType.AoqTrackTypeAudio, false);

// Start capture immediately, without waiting for the connection
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 による収集音声の自動送信を防ぎます。
// Build the connection configuration after obtaining credentials from AppServer
AoqClientEngine.AoqConnectConfig config = new AoqClientEngine.AoqConnectConfig();
config.token = aoqTokenForClient;
config.sid = sid;
config.certFingerprint = clientRelayCertFingerprint;
config.workspaceIdHash = workspaceIdHash;
// Parse relayEndpoints from the response and add them to the configuration

// Publish Audio + Data tracks
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);

// Subscribe to the Data track
AoqClientEngine.AoqTrackParam subData = new AoqClientEngine.AoqTrackParam();
subData.trackType = AoqClientEngine.AoqTrackType.AoqTrackTypeData;
config.subscribeTracks.add(subData);

// Establish the connection
engine.connect(config);

4. run-task を送信して認識を開始

接続成功後、音声入力のラウンドごとに UUID.randomUUID().toString() などで新しい task_id を生成します。接続成功コールバックで sendDataMsg を使って次の JSON を送信します。各ラウンドで run-task は一度だけ送信します。 run-task イベントの構造は次のとおりです。タスク ID は例示値のため、今回のラウンドで生成した 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関連付けと重複排除に使用する一意のイベント ID
audiostringBase64 エンコードされた PCM 音声データ
クライアントは収集した PCM を Base64 エンコードしてこの JSON に格納し、sendDataMsg で送信します。AOQ サーバーは 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);  // Read one PCM frame

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)) {
    // Model ready: send buffered audio at double speed, then stream in real time
} else if ("result-generated".equals(event)) {
    // Parse recognition results (see step 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");
        // Display recognition results
    }
} else if ("task-finished".equals(event)) {
    // Task finished
} else if ("task-failed".equals(event)) {
    // Task failed
}

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");

    // Display interim results in real time; append final results
    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();  // Stop capture

// Wait for the buffer to drain, then send 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. 最後の音声までモデルが処理できるよう、バッファが空(queue.size() == 0)になってから finish-task を送信します。
  3. task-finished の直後に切断しないでください。同じ接続を、新しい task_id のタスクに再利用できます。
  4. result-generated のハートビート(heartbeat: true)は無視し、認識結果として扱わないでください。

一般的なシナリオ

一つの接続で複数回認識

task-finished の後も接続は使用できます。新しい task_id で run-task を再送信すると、再接続せずに次の認識を開始できます。継続的な音声入力に適し、繰り返しの接続遅延を減らせます。
// After the first round finishes (task-finished received)
// Send run-task with a new task_id to start the second round
taskId = UUID.randomUUID().toString();
sendRunTask();

独自の音声収集

この例では AOQ SDK の内部収集(startAudioCapture + onCapturedAudioFrame)を使用しますが、独自収集も可能です。収集した PCM(16 kHz、モノラル、16 ビット)を PcmQueue に追加すると、以降の保存音声の追いつき送信と送信ロジックは同じです。

関連ドキュメント