Skip to main content

Entrada por voz con AOQ y qwen-audio-3.1-asr-flash-streaming

Este tutorial explica cómo usar el AOQ SDK con qwen-audio-3.1-asr-flash-streaming para implementar la entrada por voz.

Descripción de la solución

En una interacción típica de entrada por voz, el usuario hace clic en el botón de entrada por voz y empieza a hablar de inmediato. Los resultados se muestran en el campo de entrada en tiempo real. La implementación del cliente tiene los siguientes puntos clave:
  1. Captura inmediata: Inicie la captura de audio y el almacenamiento de datos PCM en un búfer en cuanto se haga clic, mientras solicita credenciales y establece la conexión de forma asíncrona. La conexión tarda un tiempo y el usuario puede estar hablando durante ese periodo; por eso, almacene el audio primero y envíelo después.
  2. Alcance el audio actual al doble de velocidad: Tras establecer la conexión y recibir el evento que indica que el modelo está listo, envíe el audio almacenado al doble de velocidad. Cuando alcance el audio actual, cambie al envío en tiempo real.
  3. Audio por el canal Data: qwen-audio-3.1-asr-flash-streaming utiliza el protocolo Inference (run-task / finish-task). Este escenario requiere almacenar el audio antes de enviarlo al doble de velocidad. Use sendDataMsg para transmitir audio como mensajes JSON. El servidor AOQ admite este formato de mensajes de audio JSON y los convierte automáticamente en datos binarios para reenviarlos al modelo.
  4. Resultados intermedios y finales: El modelo devuelve resultados de reconocimiento continuamente. Muestre los resultados intermedios (sentence_end: false) en tiempo real y agregue los finales (sentence_end: true) al campo de entrada.

Preparación

Requisitos previos

  1. Active Alibaba Cloud Model Studio y obtenga una API key conforme a Obtener y configurar una API key. Guárdela solo en el AppServer. No la incluya en el código del cliente ni la registre en el repositorio de código.
  2. Use {WorkspaceId}.cn-beijing.maas.aliyuncs.com para China (Beijing) o {WorkspaceId}.ap-southeast-1.maas.aliyuncs.com para Singapur como {endpoint}. Sustituya el marcador por el ID real del workspace y use la API key de la región y el workspace correspondientes.
  3. Obtenga el AOQ Client SDK más reciente en Descarga del SDK.
  4. Configure un AppServer según los Pasos de autenticación de AOQ para Qwen-Audio-ASR-Streaming. Antes de cada conexión nueva, el cliente debe obtener credenciales de conexión nuevas de AppServer.

Importar el SDK

Descargue el AOQ Client SDK más reciente en Descarga del SDK e importe el correspondiente a su plataforma. Los ejemplos del cliente usan Android. Las demás plataformas utilizan el mismo diseño de interfaces y flujo de eventos.
  • Android
  • iOS
  • HarmonyOS
  • Linux (Python)
  1. Coloque libPluginOpus.so del complemento Opus en app/src/main/jniLibs/armeabi-v7a/ y app/src/main/jniLibs/arm64-v8a/ según la ABI. Coloque AoqClientSdk-release.aar en app/libs y configure las dependencias y ABI admitidas en app/build.gradle:
    android {
        defaultConfig {
            minSdk 21
            ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }
        }
    }
    
    dependencies {
        implementation fileTree(dir: 'libs', include: ['*.aar'])
    }
    
  2. Declare estos permisos en 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. Solicite el permiso RECORD_AUDIO en tiempo de ejecución antes de usar el micrófono.

Obtener un token en el AppServer

AppServer llama a la API de asignación de Model Studio y devuelve el token, SID, los endpoints de Relay y la demás información que requiere el SDK para establecer una conexión.
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}\"}"
Campo de respuestaCampo del SDK
aoqTokenForClientAoqConnectConfig.token
sidAoqConnectConfig.sid
clientRelayCertFingerprintAoqConnectConfig.certFingerprint
clientRelayEndpointsAoqConnectConfig.relayEndpoints
extraInfo.workspaceIdHashAoqConnectConfig.workspaceIdHash

Implementación del cliente

Secuencia del cliente de entrada por voz
Los siguientes pasos usan Android y siguen el orden de implementación. Las otras plataformas emplean el mismo diseño de interfaces y flujo de eventos.

1. Crear el motor y registrar callbacks

Cree una instancia AoqClientEngine y registre los callbacks de estado de conexión y mensajes Data. Envíe run-task tras establecer la conexión. El callback de mensajes Data procesa los eventos del servidor.
// 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. Configurar la codificación de audio e iniciar la captura

Configure PCM (16 kHz, mono), registre un callback de captura con setAudioFrameObserver para obtener PCM y llame a enableSendMediaStream(Audio, false) para desactivar el envío de medios de audio (el audio se envía por Data). Finalmente, llame a startAudioCapture() para iniciar la captura. Este ejemplo usa la captura interna del AOQ SDK y obtiene PCM mediante onCapturedAudioFrame. También puede implementar su propia captura, por ejemplo con AudioRecord de Android. Agregue el PCM a la cola del búfer; la lógica de envío posterior es idéntica.
  • Forma A: Captura interna del SDK
  • Forma B: Captura propia
El ejemplo llama a startAudioCapture, obtiene PCM mediante onCapturedAudioFrame y lo copia al búfer de la aplicación. Registre el callback de captura sin procesar AoqAudioSourceCaptured para almacenar audio antes de establecer la conexión.
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());
Elija solo una de las dos formas para no iniciar dos grabadoras a la vez. En la captura propia, la aplicación se encarga de la conversión de formato, la confirmación de la detención y la liberación de dispositivos de audio.

3. Configurar credenciales y pistas y establecer la conexión

Complete AoqConnectConfig con las credenciales del AppServer. Publique las pistas Audio + Data y suscríbase a Data. La declaración de Audio es necesaria para conectarse, aunque el envío de medios de audio está desactivado. El audio real se transmite por el canal Data.
Se debe publicar la pista Audio: El modelo ASR requiere declarar Audio al conectar; de lo contrario, la conexión falla. Los datos se envían por Data en mensajes input_audio_buffer.append, no por el canal de medios de audio. Llame a enableSendMediaStream(Audio, false) antes de conectar para impedir que el SDK envíe automáticamente el audio capturado.
// 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. Enviar run-task para iniciar el reconocimiento

Tras conectarse, genere un task_id nuevo para cada ronda de entrada por voz, por ejemplo con UUID.randomUUID().toString(). En el callback de conexión correcta, envíe el siguiente JSON mediante sendDataMsg. Envíe run-task solo una vez por ronda. El evento run-task tiene la siguiente estructura. El ID de tarea es un ejemplo; sustitúyalo por el ID generado para la ronda actual. Consulte Eventos del cliente.
{
  "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": {}
  }
}
Los mensajes de control y audio se envían mediante la misma interfaz 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. Enviar el audio almacenado al doble de velocidad tras task-started

Analice el JSON en onDataMsg. Cambie el estado de envío a listo únicamente al recibir el siguiente evento para la tarea actual.
{
  "header": {
    "event": "task-started",
    "task_id": "9fb212ea-6f83-4aef-9b8e-1bdd71f1d4d3"
  },
  "payload": {}
}
El ejemplo contiene los campos principales del evento. Lea los demás según sus necesidades. Consulte Eventos del servidor. Normalmente, el búfer contiene el audio capturado durante la solicitud de credenciales, la conexión AOQ y el inicio de la tarea. Compruebe que el task_id de las respuestas coincida con la tarea actual; un task-started duplicado no debe iniciar otro ciclo de envío. Formato de los mensajes de audio: El protocolo Inference de qwen-audio-3.1-asr-flash-streaming (Eventos del cliente) solo define dos eventos de control: run-task y finish-task. El audio no se envía como evento JSON. Sin embargo, el canal Data de AOQ requiere mensajes JSON de audio. Para ello, AOQ encapsula el audio en un evento input_audio_buffer.append en la capa del SDK:
CampoTipoDescripción
typestringValor fijo: input_audio_buffer.append
event_idstringIdentificador único del evento para correlación y deduplicación
audiostringDatos de audio PCM codificados en Base64
El cliente codifica el PCM capturado en Base64, lo encapsula en este JSON y lo envía mediante sendDataMsg. El servidor AOQ decodifica audio como PCM binario y lo reenvía al modelo ASR según el protocolo del modelo. El cliente no necesita gestionar la encapsulación de tramas binarias. Asegúrese de que el formato PCM (frecuencia de muestreo, canales y profundidad de bits) coincida con format y sample_rate declarados en run-task.
{
    "type": "input_audio_buffer.append",
    "event_id": "event_550e8400-e29b-41d4-a716-446655440000",
    "audio": "AQID//+AgAAEAACAAAAA..."
}
Enviar datos de audio: Lea PCM de la cola del búfer, encapsúlelo en un JSON input_audio_buffer.append y envíelo mediante 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);
}
Procesar eventos del servidor: Analice los eventos en el callback onDataMsg. Inicie el envío de audio al recibir task-started y analice los resultados en result-generated. Consulte Eventos del servidor.
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. Procesar y mostrar los resultados

result-generated contiene la oración reconocida y campos como text y sentence_end. Muestre los resultados intermedios en tiempo real y agregue los finales al campo de entrada. Consulte Eventos del servidor para sus definiciones.
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. Enviar finish-task para finalizar el reconocimiento

Cuando el usuario termine la entrada, llame primero a engine.stopAudioCapture() para detener la captura. Espere hasta enviar todo el PCM restante del búfer y envíe finish-task para notificar al servidor que se envió todo el audio. Consulte Eventos del cliente para las definiciones.
task_id debe coincidir con el del run-task de la ronda actual.
El evento finish-task tiene la siguiente estructura:
{
    "header": {
        "action": "finish-task",
        "task_id": "UUID",
        "streaming": "duplex"
    },
    "payload": {
        "input": {}
    }
}
Envíelo mediante 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);
Cuando el modelo termina, devuelve el siguiente evento principal:
{
  "header": {
    "event": "task-finished",
    "task_id": "9fb212ea-6f83-4aef-9b8e-1bdd71f1d4d3"
  },
  "payload": {}
}

8. Desconectar y destruir el motor

Después de recibir task-finished, libere los recursos como se indica a continuación si ya no necesita reutilizar la conexión. Libere los recursos también en caso de error o tiempo de espera agotado.
engine.stopAudioCapture();
engine.setAudioFrameObserver(null);
engine.disconnect();
AoqClientEngine.destroy();
engine = null;

Notas de uso

  1. Espere task-started antes de enviar audio; de lo contrario, los datos se descartan.
  2. Envíe finish-task solo cuando el búfer esté vacío (queue.size() == 0) para que el modelo procese el último segmento.
  3. No desconecte inmediatamente después de task-finished. Puede reutilizar la conexión para otra tarea con un task_id nuevo.
  4. Ignore los paquetes heartbeat (heartbeat: true) de result-generated; no los trate como resultados de reconocimiento.

Escenarios habituales

Varias rondas de reconocimiento en una conexión

La conexión sigue disponible después de task-finished. El cliente puede enviar run-task de nuevo con otro task_id para iniciar la siguiente ronda sin reconectar. Esto permite la entrada de voz continua y reduce la latencia de conexiones repetidas.
// 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();

Implementar la captura propia

Este ejemplo utiliza la captura interna del AOQ SDK (startAudioCapture + onCapturedAudioFrame), pero también puede implementar la suya. Agregue PCM (16 kHz, mono, 16 bits) a PcmQueue. La lógica de envío acelerado del búfer y de transmisión posterior es idéntica.

Documentación relacionada