Skip to main content

Uso de WebRTC con qwen3.8-omni-flash-realtime para llamadas en tiempo real

Describe cómo conectarse a la API en tiempo real en un navegador mediante WebRTC y JavaScript para habilitar llamadas de audio y video en tiempo real con el modelo qwen3.8-omni-flash-realtime.

WebRTC es adecuado para escenarios de voz de baja latencia basados en navegador. El audio se transmite directamente a través de UDP con cancelación de eco y reducción de ruido integradas. WebRTC solo admite el modo VAD del lado del servidor (server_vad o semantic_vad). El modo manual no es compatible.

Requisitos previos y notas

  1. Configure una clave de API y establézcala como variable de entorno.
  2. Utilice un navegador moderno compatible con WebRTC (Chrome, Edge, Firefox, Safari, etc.).
  3. El navegador debe tener permiso de micrófono. Para videollamadas, también se requiere permiso de cámara.
  4. Los navegadores no pueden enviar solicitudes de intercambio de SDP directamente al servidor debido a restricciones de CORS. En la demostración, ejecute el comando curl en una terminal para completar la conexión. En producción, esta restricción no aplica cuando la solicitud se procesa a través de un AppServer.

Implementar llamadas de audio/video con IA

El siguiente diagrama de secuencia muestra el flujo completo de llamada de audio/video WebRTC: Diagrama de secuencia de llamada de audio/video WebRTC
imagen

Crear RTCPeerConnection

Invoque el constructor nativo RTCPeerConnection del navegador para crear una instancia de conexión. No es necesario configurar servidores ICE (el servidor gestiona la traversía NAT).
pc = new RTCPeerConnection({ iceServers: [ ] });
Registre las devoluciones de llamada clave:
// Connection state listener
pc.onconnectionstatechange = () => {
  if (!pc) return;
  if (pc.connectionState === 'connected') {
    setStatus('Connected, please speak', 'connected');
  } else if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
    endSession(true);
  }
};

// Receive and play remote audio stream + start recording
pc.ontrack = async (e) => {
  const stream = e.streams[0];
  ensureHiddenAudioEl();
  hiddenRemoteAudioEl.srcObject = stream;
  try { await hiddenRemoteAudioEl.play(); } catch {}
  startRecordingRemoteStream(stream);
};

Obtener flujos multimedia locales

Utilice una única llamada a getUserMedia para solicitar audio (obligatorio) y video (opcional). La activación del video depende de la casilla de verificación Habilitar video del usuario.
const wantVideo = !!sendVideoCheckbox.checked;

const constraints = wantVideo
  ? {
      audio: true,
      video: {
        facingMode: { ideal: "user" },
        frameRate: { ideal: 30, max: 30 },
        width: { ideal: 640 },
        height: { ideal: 480 },
      }
    }
  : { audio: true };

localStream = await navigator.mediaDevices.getUserMedia(constraints);
Tanto el audio como el video se obtienen en una única getUserMedia llamada, no por separado. La vista previa de video se ejecuta a 30 fps (para una vista previa local fluida); la tasa de fotogramas de envío se reduce a 2 fps mediante Canvas.

Agregar pistas multimedia a PeerConnection

Agregar pista de audio:
localStream.getAudioTracks().forEach(t => {
  pc.addTrack(t, localStream);
  gatedAudioTracks.push(t);
});
Agregar pista de video (opcional, con reducción de fotogramas de Canvas a 2 fps): Las dimensiones del Canvas se obtienen dinámicamente de la resolución real de la cámara en lugar de estar codificadas de forma fija:
const sendFps = 2;
const settings = localStream.getVideoTracks()[0].getSettings();
sendCanvas = document.createElement("canvas");
sendCanvas.width = settings.width || 640;   // Dynamically obtain actual width
sendCanvas.height = settings.height || 480;  // Dynamically obtain actual height
sendCanvasCtx = sendCanvas.getContext("2d", { alpha: false });

sendCanvasStream = sendCanvas.captureStream(sendFps); // 2fps
const lowFpsTrack = sendCanvasStream.getVideoTracks()[0];
pc.addTrack(lowFpsTrack, sendCanvasStream);
gatedVideoTracks.push(lowFpsTrack);

// requestAnimationFrame loop: draw camera frames to Canvas
const pump = () => {
  if (!sendCanvasCtx || !sendCanvas) return;
  try { sendCanvasCtx.drawImage(localVideo, 0, 0, sendCanvas.width, sendCanvas.height); } catch {}
  sendRafId = requestAnimationFrame(pump);
};
sendRafId = requestAnimationFrame(pump);
Control de puerta de enlace multimedia (crítico): Después de agregar pistas, bloquee el envío inmediatamente para garantizar que no se envíe contenido multimedia antes de recibir session.created:
// 1. Disable all track.enabled
gateMedia(false);  // track.enabled = false

// 2. Replace sender tracks with null to fully block sending
audioSender = pc.getSenders().find(s => s.track?.kind === 'audio');
videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
audioTrack = audioSender?.track;
videoTrack = videoSender?.track;
await audioSender?.replaceTrack(null);
await videoSender?.replaceTrack(videoTrack ? null : undefined);
Esto equivale a enableSendMediaStream(false) en otros SDK. No se debe restaurar el envío hasta que se reciba session.created.

Crear DataChannel

Cree un DataChannel denominado oai-events para intercambiar eventos de control de sesión con el servidor de IA.
const dc = pc.createDataChannel('oai-events');

dc.onopen = () => console.log("DC open");
dc.onmessage = (e) => {
  handleDcMessage(e.data, dc);
};

// Also listen for DataChannels created by the server
pc.ondatachannel = (event) => {
  const ch = event.channel;
  ch.onmessage = (e) => {
    handleDcMessage(e.data, ch);
  };
};

Generar SDP de oferta

Llame a createOffer() y establezca la descripción local. Espere a que finalice la recopilación de ICE antes de utilizar el SDP de oferta completo.
pc.onicegatheringstatechange = () => {
  if (!pc) return;
  if (pc.iceGatheringState === "complete" && pc.localDescription?.sdp) {
    const sdp = pc.localDescription.sdp;
    // ICE gathering complete, Offer SDP ready
    // Auto-generate curl command for users
  }
};

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
Espere a iceGatheringState === "complete" antes de utilizar el SDP. En ese momento, el SDP contiene toda la información de candidatos ICE.

Intercambiar SDP (mediante comando curl o AppServer)

Envíe el SDP de oferta al servidor y obtenga el SDP de respuesta. En la demostración, esto se realiza mediante un comando curl:
curl -X POST 'https://{endpoint}/api/v1/webrtc/realtime?model=qwen3.8-omni-flash-realtime' \
  -H 'Content-Type: application/sdp' \
  -H 'Authorization: Bearer $DASHSCOPE_API_KEY' \
  --data-binary '<Offer SDP content>'
En producción, procese este paso a través de un AppServer para evitar exponer la clave de API en el front-end. Establezca {endpoint} en {WorkspaceId}.cn-beijing.maas.aliyuncs.com para China (Beijing) o en {WorkspaceId}.ap-southeast-1.maas.aliyuncs.com para Singapur. Reemplace {WorkspaceId} con su ID de espacio de trabajo y utilice la clave de API correspondiente a la misma región y espacio de trabajo.

Establecer SDP de respuesta para establecer la conexión

Establezca el SDP de respuesta devuelto por el servidor como descripción remota para establecer la conexión WebRTC. Normalice el formato SDP antes de configurarlo:
function normalizeSdpForSetRemote(sdp) {
  sdp = String(sdp).trim().replace(/\r?\n/g, "\r\n");
  if (!sdp.endsWith("\r\n")) sdp += "\r\n";
  return sdp;
}

const answerSdp = normalizeSdpForSetRemote(txt);
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
La especificación SDP requiere saltos de línea \r\n. normalizeSdpForSetRemote gestiona la compatibilidad de saltos de línea procedentes de diferentes orígenes.

Configurar sesión de IA (session.update)

Una vez establecida la conexión, el servidor envía un evento session.created a través del DataChannel. Al recibirlo:
  1. Libere la puerta de enlace multimedia para restaurar el envío de audio y video
  2. Envíe session.update para configurar los parámetros de la sesión
Liberar la puerta de enlace y restaurar el contenido multimedia:
function handleDcMessage(data, channel) {
  let obj;
  try { obj = JSON.parse(data); } catch (err) { return; }

  if (obj?.type === "session.created") {
    // Release gate: restore track.enabled
    gateMedia(true);
    // Restore actual track to sender
    if (audioSender) audioSender.replaceTrack(audioTrack);
    if (videoSender && videoTrack) videoSender.replaceTrack(videoTrack);
    // Send session configuration
    sendUpdate(channel);
  }
}
Cuerpo del mensaje session.update:
const update = {
  event_id: `event_${Date.now()}`,
  type: "session.update",
  session: {
    input_audio_format: "pcm",
    input_audio_transcription: { model: "qwen3-asr-flash-realtime" },
    instructions: "You are a helpful assistant.",
    modalities: ["text", "audio"],
    output_audio_format: "pcm",
    smooth_output: false,
    turn_detection: {
      prefix_padding_ms: 500,
      silence_duration_ms: 800,
      threshold: 0.5,
      type: "server_vad",
    },
  },
};
if (channel && channel.readyState === "open") channel.send(JSON.stringify(update));
turn_detection.type puede configurarse como server_vad (detección basada en volumen) o semantic_vad (detección semántica). El modo VAD manual no es compatible con el modo WebRTC.

Conversación en tiempo real

Una vez establecida la conexión, el audio y el video se transmiten en tiempo real a través de RTP. Las respuestas de voz remotas de la IA se reciben mediante la devolución de llamada ontrack y se reproducen. Se utiliza MediaRecorder para grabar las respuestas y poder descargarlas. Recibir y grabar audio remoto:
pc.ontrack = async (e) => {
  const stream = e.streams[0];
  ensureHiddenAudioEl();
  hiddenRemoteAudioEl.srcObject = stream;
  try { await hiddenRemoteAudioEl.play(); } catch {}
  startRecordingRemoteStream(stream); // Start recording
};

function startRecordingRemoteStream(remoteStream) {
  const audioTracks = remoteStream.getAudioTracks();
  if (!audioTracks.length) return;
  const audioStream = new MediaStream(audioTracks);

  recordedChunks = [ ];

  mediaRecorder = new MediaRecorder(audioStream, { mimeType: 'audio/webm' });
  mediaRecorder.ondataavailable = (e) => {
    if (e.data && e.data.size > 0) recordedChunks.push(e.data);
  };
  mediaRecorder.onstop = () => {
    audioBlob = new Blob(recordedChunks, { type: 'audio/webm' });
    // Available for download after recording stops
  };
  mediaRecorder.start();
}
Visualización de eventos de DataChannel: Todos los eventos enviados y recibidos a través del DataChannel (incluidos session.created, response.audio_transcript.done, etc.) se muestran en el panel de eventos con soporte para expandirlos y ver el JSON completo:
function pushEventFromDataChannel(eventObj) {
  const ts = eventObj.timestamp || nowTs();
  events.unshift({ event: eventObj, timestamp: ts });
  renderEvents();
}

Finalizar sesión y liberar recursos

Libere todos los recursos en orden al finalizar una llamada. El orden es importante:
function endSession(silent = false) {
  // 1. Stop Canvas frame-rate reduction loop
  if (sendRafId) cancelAnimationFrame(sendRafId);
  sendRafId = 0;
  if (sendCanvasStream) sendCanvasStream.getTracks().forEach(t => t.stop());
  sendCanvasStream = null; sendCanvasCtx = null; sendCanvas = null;

  // 2. Stop recording
  try { if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop(); } catch {}
  mediaRecorder = null;

  // 3. Stop local media stream
  if (localStream) {
    localStream.getTracks().forEach(t => t.stop());
    localStream = null;
  }

  // 4. Close PeerConnection
  if (pc) { try { pc.close(); } catch {} pc = null; }

  // 5. Clean up remote audio element
  if (hiddenRemoteAudioEl) {
    try { hiddenRemoteAudioEl.pause(); } catch {}
    hiddenRemoteAudioEl.srcObject = null;
    hiddenRemoteAudioEl.remove();
    hiddenRemoteAudioEl = null;
  }
}
Después de que finalice la sesión, utilice el botón Download remote audio para guardar la grabación de respuesta de la IA en formato WebM.

Notas importantes

  1. La puerta de enlace multimedia debe liberarse después de session.created: Los datos multimedia enviados antes de que el servidor envíe session.created se descartarán. Utilice replaceTrack(null) para bloquear completamente el envío hasta ese momento.
  2. La reducción de fotogramas de video se implementa mediante Canvas: La vista previa local se ejecuta a 30 fps; solo se envían 2 fps al servidor, controlados por captureStream(2). Esto conserva el ancho de banda.
  3. Normalización del formato SDP: Asegúrese de que los saltos de línea sean \r\n antes de establecer el SDP de respuesta. De lo contrario, setRemoteDescription podría fallar.
  4. El video es opcional: Si el usuario no habilita el video, solo se solicita permiso de micrófono; no aparece ninguna solicitud de permiso de cámara.
  5. El audio remoto se graba automáticamente: MediaRecorder registra el flujo de audio de respuesta de la IA. Un archivo WebM está disponible para su descarga una vez finalizada la sesión.
  6. WebRTC solo admite VAD del lado del servidor: El modo manual no es compatible. Utilice server_vad (detección de volumen) o semantic_vad (detección semántica).

Descargar la demostración completa

Descargue el código de ejemplo completo: webrtc_demo.html.

Documentos relacionados