Todos os produtos
Search
Central de documentação

Alibaba Cloud Model Studio:Qwen-Omni-Realtime

Última atualização: Sep 02, 2026

O Qwen-Omni-Realtime processa entradas contínuas de áudio e imagem (incluindo frames de vídeo) e gera respostas em texto e áudio em tempo real.

Regiões suportadas: Singapore, China (Beijing). Cada região exige seu próprio API key.

Como usar

1. Estabelecer conexão

O Qwen-Omni-Realtime suporta WebSocket e WebRTC. O WebSocket é ideal para integração no lado do servidor com configuração rápida. Já o WebRTC foca em cenários de voz de baixa latência baseados em navegador, transmitindo áudio via UDP com cancelamento de eco e redução de ruído integrados.

WebSocket

Native WebSocket

Parâmetros de conexão:

ParâmetroDescrição

Endpoint

Região China (Beijing): wss://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/realtime

Região Singapore: wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime

Substitua {WorkspaceId} pelo seu workspace ID real.

Parâmetro de consulta

Use o parâmetro de consulta model para especificar o modelo. Exemplo: ?model=qwen3.5-omni-plus-realtime

Cabeçalho da requisição

Utilize um token Bearer para autenticação: Authorization: Bearer DASHSCOPE_API_KEY

DASHSCOPE_API_KEY é a API key obtida no Model Studio.

# pip install websocket-client
import json
import websocket
import os

API_KEY=os.getenv("DASHSCOPE_API_KEY")
# Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
API_URL = "wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime?model=qwen3.5-omni-plus-realtime"

headers = [
    "Authorization: Bearer " + API_KEY
]

def on_open(ws):
    print(f"Connected to server: {API_URL}")
def on_message(ws, message):
    data = json.loads(message)
    print("Received event:", json.dumps(data, indent=2))
def on_error(ws, error):
    print("Error:", error)

ws = websocket.WebSocketApp(
    API_URL,
    header=headers,
    on_open=on_open,
    on_message=on_message,
    on_error=on_error
)

ws.run_forever()

DashScope Python SDK

# SDK version 1.23.9 or later is required.
import os
import json
from dashscope.audio.qwen_omni import OmniRealtimeConversation,OmniRealtimeCallback
import dashscope
# API keys for the Singapore and China (Beijing) regions are different. To get an API key, see https://www.alibabacloud.com/help/en/model-studio/get-api-key.
# If you have not configured an API key, change the following line to dashscope.api_key = "sk-xxx".
dashscope.api_key = os.getenv("DASHSCOPE_API_KEY")

class PrintCallback(OmniRealtimeCallback):
    def on_open(self) -> None:
        print("Connected Successfully")
    def on_event(self, response: dict) -> None:
        print("Received event:")
        print(json.dumps(response, indent=2, ensure_ascii=False))
    def on_close(self, close_status_code: int, close_msg: str) -> None:
        print(f"Connection closed (code={close_status_code}, msg={close_msg}).")

callback = PrintCallback()
conversation = OmniRealtimeConversation(
    model="qwen3.5-omni-plus-realtime",
    callback=callback,
    # The following URL is for the Singapore region. When calling, replace {WorkspaceId} with your actual workspace ID. URLs vary by region.
    url="wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime"
)
try:
    conversation.connect()
    print("Conversation started. Press Ctrl+C to exit.")
    conversation.thread.join()
except KeyboardInterrupt:
    conversation.close()

DashScope Java SDK

// SDK version 2.20.9 or later is required.
import com.alibaba.dashscope.audio.omni.*;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.google.gson.JsonObject;
import java.util.concurrent.CountDownLatch;

public class Main {
    public static void main(String[] args) throws InterruptedException, NoApiKeyException {
        CountDownLatch latch = new CountDownLatch(1);
        OmniRealtimeParam param = OmniRealtimeParam.builder()
                .model("qwen3.5-omni-plus-realtime")
                .apikey(System.getenv("DASHSCOPE_API_KEY"))
                // The following URL is for the Singapore region. When calling, replace {WorkspaceId} with your actual workspace ID. URLs vary by region.
                .url("wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime")
                .build();

        OmniRealtimeConversation conversation = new OmniRealtimeConversation(param, new OmniRealtimeCallback() {
            @Override
            public void onOpen() {
                System.out.println("Connected Successfully");
            }
            @Override
            public void onEvent(JsonObject message) {
                System.out.println(message);
            }
            @Override
            public void onClose(int code, String reason) {
                System.out.println("connection closed code: " + code + ", reason: " + reason);
                latch.countDown();
            }
        });
        conversation.connect();
        latch.await();
        conversation.close(1000, "bye");
        System.exit(0);
    }
}

WebRTC

O estabelecimento de uma conexão WebRTC envolve duas etapas:

  1. Troca de SDP (HTTP): O cliente envia suas capacidades de mídia e endereços de rede (Offer SDP) ao servidor via HTTP POST. O servidor retorna suas informações (Answer SDP) para concluir a negociação de capacidades.
  2. Conexão (automática): Após a negociação, a camada WebRTC estabelece automaticamente o canal de transporte de áudio.

Configuração da troca de SDP:

Parâmetro

Descrição

URL da requisição

Região China (Beijing): https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/webrtc/realtime

Região Singapore: https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/webrtc/realtime

Substitua {WorkspaceId} pelo seu workspace ID real.

Parâmetro de consulta

Use o parâmetro de consulta model para especificar o modelo. Exemplo: ?model=qwen3.5-omni-plus-realtime

Content-Type

application/sdp

Cabeçalho da requisição

Authorization: Bearer DASHSCOPE_API_KEY

Corpo da requisição

A string Offer SDP gerada pelo cliente

Resposta

Sucesso: HTTP 200 com a string Answer SDP do servidor. Falha: HTTP 4xx com uma mensagem de erro JSON.

Exemplos de código de conexão:

# pip install aiortc aiohttp certifi
import asyncio, aiohttp, ssl, certifi
from aiortc import RTCPeerConnection, RTCConfiguration, RTCSessionDescription
from aiortc.mediastreams import AudioStreamTrack

API_KEY = "your-api-key"
MODEL = "qwen3.5-omni-plus-realtime"
# Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
SIGNALING_URL = "https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/webrtc/realtime?model=" + MODEL

async def connect():
    pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))

    # Add an audio track to ensure the Offer SDP contains m=audio (required by the server)
    pc.addTrack(AudioStreamTrack())

    # Create a DataChannel to trigger SDP negotiation (name is customizable; the server pushes events through a channel named "txt")
    pc.createDataChannel("oai-events")

    # SDP exchange: create an Offer and send it to the server
    offer = await pc.createOffer()
    await pc.setLocalDescription(offer)

    async with aiohttp.ClientSession() as session:
        async with session.post(
            SIGNALING_URL,
            ssl=ssl.create_default_context(cafile=certifi.where()),
            data=offer.sdp.encode("utf-8"),
            headers={
                "Content-Type": "application/sdp",
                "Authorization": f"Bearer {API_KEY}",
            },
        ) as resp:
            if not resp.ok:
                raise Exception(f"SDP exchange failed: {resp.status} {await resp.text()}")
            answer_sdp = await resp.text()

    print("=== Offer SDP ===")
    print(offer.sdp)
    print("=== Answer SDP ===")
    print(answer_sdp)

    # ICE connection is established automatically
    await pc.setRemoteDescription(RTCSessionDescription(sdp=answer_sdp, type="answer"))
    print("WebRTC connection established")
    return pc
const API_KEY = 'your-api-key';
// Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
const API_URL = 'https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/webrtc/realtime?model=qwen3.5-omni-plus-realtime';

async function connect() {
  const pc = new RTCPeerConnection({ iceServers: [] });

  // Add an audio track to ensure the Offer SDP contains m=audio (required by the server)
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  stream.getAudioTracks().forEach(t => pc.addTrack(t, stream));

  // Create a DataChannel to trigger SDP negotiation (name is customizable; the server pushes events through a channel named "txt")
  pc.createDataChannel('oai-events');

  // Wait for ICE gathering to complete before sending the Offer to get the Answer
  pc.onicegatheringstatechange = async () => {
    if (pc.iceGatheringState !== 'complete') return;
    const resp = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/sdp',
        'Authorization': `Bearer ${API_KEY}`,
      },
      body: pc.localDescription.sdp,
    });
    if (!resp.ok) throw new Error('SDP exchange failed: ' + resp.status);
    const answerSdp = await resp.text();
    // ICE connection is established automatically
    await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
    console.log('WebRTC connection established');
  };

  // Create the Offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  return pc;
}

2. Configurar sessão

Envie o evento de cliente session.update:

{
    // A client-generated event ID.
    "event_id": "event_ToPZqeobitzUJnt3QqtWg",
    // The event type. Must be "session.update".
    "type": "session.update",
    // The session configuration.
    "session": {
        // The output modality. Set this to ["text"] for text-only output, or ["text", "audio"] for both text and audio output.
        "modalities": [
            "text",
            "audio"
        ],
        // The voice for the audio output.
        "voice": "Ethan",
        // The input audio format. Only "pcm" is supported. The input audio must be a PCM audio stream at a 16 kHz sample rate.
        "input_audio_format": "pcm",
        // The output audio format. Only "pcm" is supported. The output audio is a PCM audio stream at a 24 kHz sample rate.
        "output_audio_format": "pcm",
        // A system instruction to define the model's goal or role.
        "instructions": "You are an AI customer service agent for a five-star hotel. Answer customer inquiries about room types, facilities, prices, and booking policies accurately and in a friendly manner. Always respond with a professional and helpful attitude. Do not provide unconfirmed information or information beyond the scope of the hotel's services.",
        // Enables server-side voice activity detection (VAD). If enabled, the server automatically detects the start and end of speech.
        // If null, the client controls when to trigger model responses.
        "turn_detection": {
            // The VAD type. Valid values: "server_vad" and "semantic_vad". We recommend "semantic_vad" for the qwen3.5-omni-realtime series model.
            "type": "semantic_vad",
            // The VAD detection threshold. We recommend increasing this value in noisy environments and decreasing it in quiet environments.
            "threshold": 0.5,
            // The silence duration in milliseconds (ms) that signals the end of an utterance. The model triggers a response if this duration is exceeded.
            "silence_duration_ms": 800
        }
    }
}

3. Entrada de áudio e imagens

A entrada de áudio é obrigatória; a entrada de imagem é opcional. O método de entrada depende do protocolo utilizado.

WebSocket

Envie dados de áudio e imagem codificados em Base64 para o buffer do servidor usando os eventos input_audio_buffer.append e input_image_buffer.append.

As imagens podem vir de arquivos locais ou de capturas de fluxo de vídeo em tempo real.

Com o VAD no lado do servidor ativado, o servidor envia os dados automaticamente e aciona uma resposta ao final da fala. Com o VAD desativado (modo manual), chame o evento input_audio_buffer.commit para enviar os dados após a transmissão.

WebRTC

As faixas de áudio e vídeo (canais de mídia RTP) adicionadas durante o estabelecimento da conexão transmitem dados para o servidor automaticamente.

  • Áudio: Transmitido diretamente pela faixa de áudio (RTP). Não são necessários eventos input_audio_buffer.append.
  • Imagens: Enviadas como frames de vídeo pela faixa de vídeo (RTP). input_image_buffer.append não é suportado.

O WebRTC suporta apenas o modo VAD no lado do servidor ( server_vad ou semantic_vad ). O modo manual não é suportado.

4. Receber respostas do modelo

O formato da resposta depende da modalidade de saída configurada.

WebSocket

WebRTC

  • Apenas texto

    Funciona como no WebSocket. Receba eventos de texto em fluxo contínuo através do DataChannel.

  • Texto e áudio
    • Texto: Recebido através do DataChannel como eventos de texto em fluxo contínuo, assim como no WebSocket.
    • Áudio: Recebido e reproduzido em tempo real através de faixas RTP. Não são necessários eventos response.audio.delta.

Seleção de modelo

O Qwen3.5-Omni-Realtime apresenta melhorias em relação ao Qwen3-Omni-Flash-Realtime nas seguintes áreas:

  • Nível de inteligência

    Equivalente ao Qwen3.5-Plus.

  • Pesquisa na web

    Possui pesquisa na web integrada, permitindo que o modelo busque autonomamente respostas para perguntas em tempo real. Para mais detalhes, consulte Web search.

  • Chamada de ferramentas

    Suporta chamada de funções, habilitando o modelo a invocar ferramentas externas de forma autônoma. Para mais detalhes, consulte Qwen-Omni-Realtime series.

  • Interrupção semântica

    Identifica a intenção conversacional para evitar interrupções causadas por sons de confirmação ou ruídos de fundo.

  • Controle de voz

    Permite controlar volume, velocidade de fala e emoção via comandos de voz (por exemplo, "fale mais rápido", "mais alto", "em tom alegre").

  • Idiomas suportados

    Oferece reconhecimento de fala para 113 languages and dialects e geração de fala para 36 languages and dialects.

  • Vozes disponíveis

    Dispõe de 55 vozes, incluindo 47 vozes multilíngues e 8 vozes dialetais. Para a lista completa, consulte Voice list.

  • Clonagem de voz

    Possibilita o uso de uma voz clonada personalizada para conversas em tempo real (Qwen3.5-omni-plus-realtime e Qwen3.5-omni-flash-realtime). Para mais detalhes, consulte Voice cloning.

Verifique no console do Model Studio os nomes dos modelos, contexto, preços e versões de snapshot. Para limites de taxa de concorrência, consulte Rate limiting .

Limitações

  • A pesquisa na web e a chamada de ferramentas são mutuamente exclusivas.

  • Uma única sessão WebSocket pode durar até 120 minutos. A conexão é encerrada automaticamente ao atingir esse limite.

  • O modelo retém o histórico de conversas até os seguintes limites de turnos e duração. Quando esses limites são excedidos, o histórico mais antigo é descartado. A duração máxima refere-se ao tempo acumulado de áudio ou vídeo (frames de imagem) retido no contexto.

    O vídeo é inserido como frames extraídos (recomendado: 1 fps). A duração máxima de vídeo corresponde ao tempo acumulado de frames retidos — por exemplo, 240 s significa que apenas os frames dos últimos 240 segundos são mantidos.

    O modelo qwen3-omni-flash-realtime tem um limite de 8 turnos de diálogo (geralmente atingido primeiro). Seu limite de duração depende do comprimento de contexto do modelo e não está listado separadamente.

    Modelo

    Máx. turnos de áudio

    Máx. turnos de vídeo

    Duração máx. de áudio

    Duração máx. de vídeo

    qwen3.5-omni-plus-realtime

    100 turnos

    50 turnos

    600 segundos

    240 segundos

    qwen3.5-omni-flash-realtime

    80 turnos

    50 turnos

    480 segundos

    120 segundos

    qwen3-omni-flash-realtime

    8 turnos

    8 turnos

Primeiros passos

Get an API key e set it as an environment variable.

Selecione uma linguagem de programação e siga as etapas para iniciar um chat em tempo real.

WebRTC

Python

  • Ambiente de execução

    É necessário ter o Python 3.10 ou superior. Instale as seguintes dependências:

pip install aiortc aiohttp sounddevice numpy certifi av
  • Executar a demonstração

    Crie um arquivo Python chamado webrtc_demo.py e cole o código a seguir:

    webrtc_demo.py

    # Dependencies: pip install aiortc aiohttp sounddevice numpy certifi av
    import asyncio
    import json
    import os
    import queue
    import ssl
    import threading
    
    import aiohttp
    import certifi
    import numpy as np
    import sounddevice as sd
    from aiortc import RTCPeerConnection, RTCConfiguration, RTCSessionDescription
    from aiortc.contrib.media import MediaPlayer
    from av import AudioFrame
    
    # Replace with your API key, or set the DASHSCOPE_API_KEY environment variable
    API_KEY = os.getenv("DASHSCOPE_API_KEY", "your-api-key")
    MODEL = "qwen3.5-omni-plus-realtime"
    # Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
    SIGNALING_URL = "https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/webrtc/realtime?model=" + MODEL
    
    # --------------- Audio frame parsing ---------------
    
    def _nb_channels(frame: AudioFrame) -> int:
        """Get the number of channels in an audio frame, compatible with different PyAV versions"""
        if hasattr(frame.layout, "nb_channels"):
            return int(frame.layout.nb_channels)
        ch = getattr(frame.layout, "channels", 1)
        if isinstance(ch, (tuple, list)):
            return len(ch)
        return int(ch)
    
    def audioframe_to_s16_samples(frame: AudioFrame) -> np.ndarray:
        """
        Server audio frames are stereo interleaved. Direct reshape causes channel misalignment.
        Rearrange to (samples, channels) based on actual channel count.
        Different aiortc decoder versions return different array shapes for the same audio,
        so unified handling is needed here.
        """
        arr = np.asarray(frame.to_ndarray())
        ch = _nb_channels(frame)
        samples = int(frame.samples)
    
        if arr.ndim == 2 and arr.shape[0] == ch and arr.shape[1] == samples:
            return arr.T.copy()
        if arr.ndim == 2 and arr.shape[0] == 1 and arr.shape[1] == samples * ch:
            return arr.reshape(-1).reshape(samples, ch).copy()
        if arr.ndim == 1 and arr.shape[0] == samples * ch:
            return arr.reshape(samples, ch).copy()
    
        flat = arr.reshape(-1)
        if ch > 0 and flat.size % ch == 0:
            return flat.reshape(flat.size // ch, ch).copy()
        raise ValueError(f"unexpected shape={arr.shape}, ch={ch}, samples={samples}")
    
    # --------------- Low-latency audio player ---------------
    
    class RemoteAudioPlayer:
        """
        Low-latency audio player that plays 5ms audio blocks to minimize delay.
        Supports voice interruption: clears the buffer when the user starts speaking,
        stopping playback of old model responses.
        Merges stereo server audio to mono (average of left and right channels) for playback.
        """
        def __init__(self, samplerate=48000, out_channels=1, blocksize=240, max_seconds=0.2):
            self.samplerate = samplerate
            self.out_channels = out_channels
            self.blocksize = blocksize
            self._q = queue.Queue(maxsize=max(5, int(max_seconds * samplerate / blocksize) + 5))
            self._lock = threading.Lock()
            self._rb_size = max(1, int(max_seconds * samplerate))
            self._rb = np.zeros((self._rb_size, out_channels), dtype=np.int16)
            self._rb_w = 0
            self._rb_r = 0
            self._rb_len = 0
            self._stream = None
            self._closed = False
    
        def start(self):
            if self._stream:
                return
    
            def callback(outdata, frames, _time, status):
                if self._closed:
                    outdata[:] = np.zeros((frames, self.out_channels), dtype=np.int16)
                    return
                while True:
                    try:
                        chunk = self._q.get_nowait()
                    except queue.Empty:
                        break
                    with self._lock:
                        self._write_rb(chunk)
                with self._lock:
                    out = self._read_rb(frames)
                outdata[:] = out
    
            self._stream = sd.OutputStream(
                samplerate=self.samplerate,
                channels=self.out_channels,
                dtype="int16",
                blocksize=self.blocksize,
                callback=callback,
            )
            self._stream.start()
    
        def clear(self):
            """Clear playback buffer for voice interruption"""
            try:
                while True:
                    self._q.get_nowait()
            except queue.Empty:
                pass
            with self._lock:
                self._rb_w = 0
                self._rb_r = 0
                self._rb_len = 0
                self._rb[:] = 0
    
        def _write_rb(self, chunk: np.ndarray):
            n = int(chunk.shape[0])
            if n <= 0:
                return
            overflow = max(0, self._rb_len + n - self._rb_size)
            if overflow > 0:
                self._rb_r = (self._rb_r + overflow) % self._rb_size
                self._rb_len -= overflow
            end = self._rb_size - self._rb_w
            if n <= end:
                self._rb[self._rb_w:self._rb_w + n] = chunk
            else:
                self._rb[self._rb_w:] = chunk[:end]
                self._rb[:n - end] = chunk[end:]
            self._rb_w = (self._rb_w + n) % self._rb_size
            self._rb_len += n
    
        def _read_rb(self, frames: int) -> np.ndarray:
            if self._rb_len <= 0:
                return np.zeros((frames, self.out_channels), dtype=np.int16)
            n = min(frames, self._rb_len)
            out = np.zeros((frames, self.out_channels), dtype=np.int16)
            end = self._rb_size - self._rb_r
            if n <= end:
                out[:n] = self._rb[self._rb_r:self._rb_r + n]
            else:
                out[:end] = self._rb[self._rb_r:]
                out[end:n] = self._rb[:n - end]
            self._rb_r = (self._rb_r + n) % self._rb_size
            self._rb_len -= n
            return out
    
        async def push_frame(self, frame: AudioFrame):
            """Receive audio frames, auto-merge channels and enqueue"""
            if self._closed:
                return
            pcm = audioframe_to_s16_samples(frame)
            in_ch = pcm.shape[1]
            if self.out_channels == 1:
                if in_ch == 1:
                    out = pcm
                else:
                    out = np.mean(pcm.astype(np.int32), axis=1).astype(np.int16).reshape(-1, 1)
            else:
                if in_ch == self.out_channels:
                    out = pcm
                elif in_ch == 1 and self.out_channels == 2:
                    out = np.repeat(pcm, 2, axis=1)
                else:
                    out = pcm[:, :self.out_channels]
            try:
                self._q.put_nowait(out)
            except queue.Full:
                try:
                    self._q.get_nowait()
                except queue.Empty:
                    pass
                try:
                    self._q.put_nowait(out)
                except queue.Full:
                    pass
    
        async def close(self):
            self._closed = True
            if self._stream:
                self._stream.stop()
                self._stream.close()
                self._stream = None
    
    # --------------- main ---------------
    
    async def main():
        pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
    
        # Initialize audio player (mono output, 5ms blocksize for low latency)
        speaker = RemoteAudioPlayer(samplerate=48000, out_channels=1, blocksize=240, max_seconds=0.2)
        speaker.start()
    
        # Initialize microphone (macOS avfoundation; for Linux use pulse or alsa)
        mic = MediaPlayer("none:0", format="avfoundation",
                          options={"sample_rate": "48000", "channels": "1"})
        if not mic.audio:
            raise RuntimeError("No microphone detected. Check the avfoundation audio device index.")
        pc.addTrack(mic.audio)
    
        # Client creates DataChannel (name is customizable); server pushes events through a channel named "txt"
        pc.createDataChannel("oai-events")
    
        remote_dc = None
        got_first_txt_msg = False
    
        def make_session_update() -> dict:
            """Build session.update config: voice, audio format, VAD strategy, inference parameters"""
            return {
                "type": "session.update",
                "session": {
                    "modalities": ["text", "audio"],
                    "voice": "Tina",
                    "input_audio_format": "pcm",
                    "output_audio_format": "pcm",
                    "instructions": "You are a friendly AI assistant.",
                    "turn_detection": {"type": "server_vad", "threshold": 0.5, "silence_duration_ms": 800},
                    "max_tokens": 16384,
                    "temperature": 0.9,
                },
            }
    
        # Handle server-pushed DataChannel events
        @pc.on("datachannel")
        def on_datachannel(ch):
            nonlocal remote_dc, got_first_txt_msg
            print(f"[DC] Received server DataChannel: {ch.label}")
            if ch.label == "txt":
                remote_dc = ch
    
            @ch.on("message")
            def on_msg(msg):
                nonlocal got_first_txt_msg
                try:
                    evt = json.loads(msg)
                except Exception:
                    return
                print(f"[{ch.label}] {evt.get('type')}")
    
                # Clear playback buffer when user starts speaking (voice interruption)
                if isinstance(evt, dict) and evt.get("type") == "input_audio_buffer.speech_started":
                    speaker.clear()
                    print("[Playback] User speech detected, clearing buffer (interruption)")
    
                # Send session.update after receiving first message on txt channel
                if ch.label == "txt" and not got_first_txt_msg:
                    got_first_txt_msg = True
                    if remote_dc and remote_dc.readyState == "open":
                        remote_dc.send(json.dumps(make_session_update(), ensure_ascii=False))
                        print("[DC] session.update sent")
    
        # Receive server audio and play with low latency
        @pc.on("track")
        async def on_track(track):
            if track.kind == "audio":
                async def _play():
                    try:
                        while True:
                            frame = await track.recv()
                            await speaker.push_frame(frame)
                    except Exception:
                        pass
                asyncio.create_task(_play())
    
        @pc.on("iceconnectionstatechange")
        def on_ice():
            print(f"[ICE] {pc.iceConnectionState}")
    
        @pc.on("connectionstatechange")
        async def on_conn():
            print(f"[Connection] {pc.connectionState}")
            if pc.connectionState in ("failed", "closed", "disconnected"):
                await pc.close()
    
        # SDP exchange: create Offer and POST to signaling server, get Answer
        offer = await pc.createOffer()
        await pc.setLocalDescription(offer)
    
        async with aiohttp.ClientSession() as session:
            async with session.post(
                SIGNALING_URL,
                ssl=ssl.create_default_context(cafile=certifi.where()),
                data=offer.sdp.encode("utf-8"),
                headers={
                    "Content-Type": "application/sdp",
                    "Authorization": f"Bearer {API_KEY}",
                },
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                if not resp.ok:
                    raise Exception(f"SDP exchange failed: {resp.status} {await resp.text()}")
                answer_sdp = await resp.text()
    
        await pc.setRemoteDescription(RTCSessionDescription(sdp=answer_sdp, type="answer"))
        print("SDP exchange complete, waiting for connection...")
    
        try:
            await asyncio.Event().wait()
        except (KeyboardInterrupt, asyncio.CancelledError):
            pass
        finally:
            print(f"\nExiting. Final state: connection={pc.connectionState}, ICE={pc.iceConnectionState}")
            await speaker.close()
            try:
                if mic and mic.audio:
                    mic.audio.stop()
            except Exception:
                pass
            await pc.close()
    
    asyncio.run(main())
    

    Execute webrtc_demo.py para iniciar uma conversa em tempo real com o modelo Qwen-Omni-Realtime por meio do seu microfone. O sistema detecta automaticamente o início da sua fala e envia o áudio para o servidor.

JavaScript

  • Pré-requisitos
    • Utilize um navegador moderno com suporte a WebRTC (Chrome, Edge, Firefox, Safari, etc.).
    • O navegador precisa ter permissão de acesso ao microfone.
    • Devido às políticas de segurança de origem cruzada do navegador, não é possível enviar a solicitação de conexão diretamente ao servidor. É necessário executar um comando curl no terminal para concluir a configuração da conexão.
  • Executar a demonstração

    Crie um arquivo HTML chamado webrtc_demo.html e cole o código a seguir:

    webrtc_demo.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8" />
        <title>WebRTC Realtime Voice Chat</title>
        <style>
            * { box-sizing: border-box; margin: 0; padding: 0; }
            body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #1d2129; padding: 24px; line-height: 1.6; }
    
            .container { max-width: 800px; margin: 0 auto; }
    
            h1 { font-size: 22px; font-weight: 600; margin-bottom: 20px; color: #1d2129; }
    
            /* Sticky top bar */
            .sticky-top { position: sticky; top: 0; z-index: 100; background: #f5f7fa; margin: 0 -24px 16px; padding: 12px 24px; border-bottom: 1px solid transparent; transition: border-color .2s; }
            .sticky-top.scrolled { border-bottom-color: #e5e6eb; }
    
            /* Toolbar */
            .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
            .toolbar label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #4e5969; cursor: pointer; }
    
            /* Buttons */
            button { padding: 8px 18px; font-size: 13px; font-weight: 500; border: 1px solid #c9cdd4; border-radius: 6px; background: #fff; color: #1d2129; cursor: pointer; transition: all .15s; }
            button:hover:not(:disabled) { border-color: #165dff; color: #165dff; }
            button:disabled { opacity: .4; cursor: not-allowed; }
            .btn-primary { background: #165dff; border-color: #165dff; color: #fff; }
            .btn-primary:hover:not(:disabled) { background: #4080ff; border-color: #4080ff; color: #fff; }
            .btn-danger { border-color: #f53f3f; color: #f53f3f; }
            .btn-danger:hover:not(:disabled) { background: #f53f3f; color: #fff; }
    
            /* Status indicator */
            .status-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; background: #fff; border: 1px solid #e5e6eb; font-size: 13px; }
            .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #c9cdd4; flex-shrink: 0; }
            .status-dot.connected { background: #00b42a; }
            .status-dot.connecting { background: #ff7d00; animation: pulse 1s infinite; }
            .status-dot.error { background: #f53f3f; }
            @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
    
            /* SDP card */
            .card { background: #fff; border: 1px solid #e5e6eb; border-radius: 10px; padding: 16px; margin-bottom: 16px; }
            .card-title { font-size: 13px; font-weight: 600; color: #4e5969; margin-bottom: 8px; }
            .step-num { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: #165dff; color: #fff; font-size: 11px; font-weight: 600; margin-right: 6px; }
            .card-hint { font-size: 12px; color: #86909c; margin-top: 6px; }
    
            textarea { width: 100%; font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; font-size: 12px; padding: 10px; border: 1px solid #e5e6eb; border-radius: 6px; resize: vertical; background: #f7f8fa; color: #1d2129; transition: border-color .15s; }
            textarea:focus { outline: none; border-color: #165dff; background: #fff; }
    
            /* Video */
            .video-section { margin-bottom: 16px; }
            .video-label { font-size: 13px; color: #86909c; margin-bottom: 6px; }
            video { width: 320px; max-width: 100%; background: #000; border-radius: 8px; display: block; }
    
            /* Events panel */
            .events-title { font-size: 14px; font-weight: 600; color: #1d2129; margin-bottom: 10px; }
            .events-container { display: flex; flex-direction: column; gap: 6px; }
            .event-item { background: #fff; border: 1px solid #e5e6eb; border-radius: 8px; overflow: hidden; }
            .event-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; user-select: none; font-size: 12px; }
            .event-header:hover { background: #f7f8fa; }
            .event-arrow { font-size: 14px; font-weight: 700; width: 18px; text-align: center; }
            .event-arrow.server { color: #00b42a; }
            .event-arrow.client { color: #165dff; }
            .event-label { color: #4e5969; }
            .event-time { color: #c9cdd4; margin-left: auto; font-size: 11px; }
            .event-body { display: none; padding: 10px 12px; background: #f7f8fa; border-top: 1px solid #e5e6eb; }
            .event-body pre { margin: 0; font-size: 11px; font-family: "SF Mono", Menlo, Consolas, monospace; color: #4e5969; white-space: pre-wrap; word-break: break-all; }
            .events-empty { font-size: 13px; color: #c9cdd4; padding: 16px 0; text-align: center; }
        </style>
    </head>
    <body>
    <div class="container">
        <h1>WebRTC Realtime Voice Chat</h1>
    
        <div class="sticky-top">
            <div class="toolbar">
                <button id="startBtn" class="btn-primary">Start Session</button>
                <button id="setAnswerBtn" disabled>Set Answer</button>
                <button id="endBtn" class="btn-danger" disabled>End Session</button>
                <button id="downloadBtn" disabled>Download Remote Audio</button>
                <label>
                    <input id="sendVideoCheckbox" type="checkbox" />
                    Enable Video
                </label>
            </div>
    
            <div class="status-bar">
                <span class="status-dot" id="statusDot"></span>
                <span id="statusText">Ready</span>
            </div>
        </div>
    
        <div class="card">
            <div class="card-title"><span class="step-num">1</span>Offer SDP</div>
            <div style="margin-bottom: 8px;">
                <button id="copyOfferBtn" disabled>Copy Offer SDP</button>
            </div>
            <textarea id="offerBox" rows="6" readonly placeholder="Auto-generated after clicking Start Session"></textarea>
            <div class="card-hint">Auto-generated after ICE gathering completes. Copy and send to the server via curl to get the Answer.</div>
        </div>
    
        <div class="card">
            <div class="card-title"><span class="step-num">2</span>curl Command</div>
            <div style="margin-bottom: 8px;">
                <button id="copyCurlBtn" disabled>Copy curl Command</button>
            </div>
            <div class="card-hint" style="margin-bottom: 4px;">Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.</div>
            <textarea id="curlBox" rows="6" placeholder="Auto-filled after Offer SDP is generated"></textarea>
            <div class="card-hint">Replace {WorkspaceId} with your workspace ID, then copy this command to your terminal. Paste the returned Answer SDP below.</div>
        </div>
    
        <div class="card">
            <div class="card-title"><span class="step-num">3</span>Answer SDP</div>
            <textarea id="answerBox" rows="6" placeholder="Paste the Answer SDP returned by curl here"></textarea>
            <div class="card-hint">After pasting, click Set Answer above to establish the connection.</div>
        </div>
    
        <div class="video-section" id="videoSection" style="display:none;">
            <div class="video-label">Local Video Preview</div>
            <video id="localVideo" autoplay playsinline muted></video>
        </div>
    
        <div class="events-title">Events (DataChannel)</div>
        <div id="events" class="events-container"></div>
    </div>
    
    <script>
        const eventsDiv = document.getElementById('events');
        const startBtn = document.getElementById('startBtn');
        const setAnswerBtn = document.getElementById('setAnswerBtn');
        const endBtn = document.getElementById('endBtn');
        const downloadBtn = document.getElementById('downloadBtn');
        const copyOfferBtn = document.getElementById('copyOfferBtn');
        const statusDot = document.getElementById('statusDot');
        const statusText = document.getElementById('statusText');
    
        const copyCurlBtn = document.getElementById('copyCurlBtn');
        const curlBox = document.getElementById('curlBox');
    
        const sendVideoCheckbox = document.getElementById('sendVideoCheckbox');
        const localVideo = document.getElementById('localVideo');
    
        const offerBox = document.getElementById('offerBox');
        const answerBox = document.getElementById('answerBox');
    
        let pc = null;
        let hiddenRemoteAudioEl = null;
    
        let mediaRecorder = null;
        let recordedChunks = [];
        let audioBlob = null;
    
        let localStream = null;
    
        let sendCanvas = null;
        let sendCanvasCtx = null;
        let sendCanvasStream = null;
        let sendRafId = 0;
    
        let gatedAudioTracks = [];
        let gatedVideoTracks = [];
        let audioSender = null;
        let videoSender = null;
        let audioTrack = null;
        let videoTrack = null;
    
        function setStatus(text, state) {
          statusText.textContent = text;
          statusDot.className = 'status-dot' + (state ? ' ' + state : '');
        }
    
        function gateMedia(on) {
          for (const t of gatedAudioTracks) t.enabled = !!on;
          for (const t of gatedVideoTracks) t.enabled = !!on;
        }
    
        function sendUpdate(channel) {
          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));
        }
    
        // ===== Events panel =====
        const events = [];
        function nowTs() { return new Date().toLocaleTimeString(); }
    
        function renderEvents() {
          eventsDiv.innerHTML = "";
          if (events.length === 0) {
            const empty = document.createElement("div");
            empty.className = "events-empty";
            empty.textContent = "Waiting for events...";
            eventsDiv.appendChild(empty);
            return;
          }
    
          for (const item of events) {
            const { event, timestamp } = item;
            const isClient = event?.type?.includes("update") || event?.type?.includes("create");
    
            const wrap = document.createElement("div");
            wrap.className = "event-item";
    
            const header = document.createElement("div");
            header.className = "event-header";
    
            const arrow = document.createElement("span");
            arrow.className = "event-arrow " + (isClient ? "client" : "server");
            arrow.textContent = isClient ? "↓" : "↑";
    
            const label = document.createElement("span");
            label.className = "event-label";
            const who = isClient ? "client" : "server";
            const type = event?.type ?? "message";
            label.textContent = `${who}: ${type}`;
    
            const time = document.createElement("span");
            time.className = "event-time";
            time.textContent = timestamp;
    
            const body = document.createElement("div");
            body.className = "event-body";
            const pre = document.createElement("pre");
            pre.textContent = JSON.stringify(event, null, 2);
            body.appendChild(pre);
    
            header.onclick = () => { body.style.display = body.style.display === "block" ? "none" : "block"; };
    
            header.appendChild(arrow);
            header.appendChild(label);
            header.appendChild(time);
            wrap.appendChild(header);
            wrap.appendChild(body);
    
            eventsDiv.appendChild(wrap);
          }
        }
    
        function clearUIEvents() { events.length = 0; renderEvents(); }
        function pushEventFromDataChannel(eventObj) {
          const ts = eventObj.timestamp || nowTs();
          if (!eventObj.timestamp) eventObj.timestamp = ts;
          events.unshift({ event: eventObj, timestamp: ts });
          renderEvents();
        }
    
        function normalizeSdpForSetRemote(sdp) {
          sdp = String(sdp).trim().replace(/\r?\n/g, "\r\n");
          if (!sdp.endsWith("\r\n")) sdp += "\r\n";
          return sdp;
        }
    
        // ===== WebRTC =====
        startBtn.onclick = () => startSession().catch(err => console.log("startSession error:", err));
        endBtn.onclick = () => endSession();
        setAnswerBtn.onclick = () => setRemoteAnswerFromUI().catch(err => console.log("setRemoteAnswer error:", err));
        copyOfferBtn.onclick = async () => {
          const txt = offerBox.value;
          if (!txt) return;
          await navigator.clipboard.writeText(txt);
          alert("Offer SDP copied");
        };
        copyCurlBtn.onclick = async () => {
          const txt = curlBox.value;
          if (!txt) return;
          await navigator.clipboard.writeText(txt);
          alert("curl command copied. Run it in your terminal.");
        };
        downloadBtn.onclick = () => {
          if (audioBlob) downloadBlob(audioBlob, 'remote-audio.webm');
          else alert('No audio recording available');
        };
    
        async function startSession() {
          if (pc) return;
    
          pc = new RTCPeerConnection({ iceServers: [] });
          clearUIEvents();
          setStatus('Requesting microphone access...', 'connecting');
    
          offerBox.value = "";
          answerBox.value = "";
          curlBox.value = "";
          setAnswerBtn.disabled = true;
          copyOfferBtn.disabled = true;
          copyCurlBtn.disabled = true;
    
          endBtn.disabled = false;
          downloadBtn.disabled = true;
    
          pc.onconnectionstatechange = () => {
            if (!pc) return;
            if (pc.connectionState === 'connected') {
              setStatus('Connected. Start speaking.', 'connected');
            } else if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
              console.log("onconnectionstatechange:", pc.connectionState);
              endSession(true);
            }
          };
    
          pc.ontrack = async (e) => {
            const stream = e.streams[0];
            ensureHiddenAudioEl();
            hiddenRemoteAudioEl.srcObject = stream;
            try { await hiddenRemoteAudioEl.play(); } catch {}
            startRecordingRemoteStream(stream);
          };
    
          const wantVideo = !!sendVideoCheckbox.checked;
    
          const localPreviewFps = 30;
          const sendFps = 2;
    
          const constraints = wantVideo
            ? {
                audio: true,
                video: {
                  facingMode: { ideal: "user" },
                  frameRate: { ideal: localPreviewFps, max: localPreviewFps },
                  width: { ideal: 640 },
                  height: { ideal: 480 },
                }
              }
            : { audio: true };
    
          localStream = await navigator.mediaDevices.getUserMedia(constraints);
    
          const videoSection = document.getElementById('videoSection');
          if (wantVideo) {
            localVideo.srcObject = localStream;
            localVideo.style.display = "block";
            videoSection.style.display = "";
            try { await localVideo.play(); } catch {}
          } else {
            localVideo.srcObject = null;
            localVideo.style.display = "none";
            videoSection.style.display = "none";
          }
    
          gatedAudioTracks = [];
          gatedVideoTracks = [];
    
          localStream.getAudioTracks().forEach(t => {
            pc.addTrack(t, localStream);
            gatedAudioTracks.push(t);
          });
    
          if (wantVideo) {
            if (sendRafId) cancelAnimationFrame(sendRafId);
            sendRafId = 0;
            if (sendCanvasStream) sendCanvasStream.getTracks().forEach(t => t.stop());
            sendCanvasStream = null;
            sendCanvasCtx = null;
            sendCanvas = null;
    
            const settings = localStream.getVideoTracks()[0].getSettings();
            sendCanvas = document.createElement("canvas");
            sendCanvas.width = settings.width || 640;
            sendCanvas.height = settings.height || 480;
            sendCanvasCtx = sendCanvas.getContext("2d", { alpha: false });
    
            sendCanvasStream = sendCanvas.captureStream(sendFps);
            const lowFpsTrack = sendCanvasStream.getVideoTracks()[0];
            pc.addTrack(lowFpsTrack, sendCanvasStream);
            gatedVideoTracks.push(lowFpsTrack);
    
            const pump = () => {
              if (!sendCanvasCtx || !sendCanvas) return;
              try { sendCanvasCtx.drawImage(localVideo, 0, 0, sendCanvas.width, sendCanvas.height); } catch {}
              sendRafId = requestAnimationFrame(pump);
            };
            sendRafId = requestAnimationFrame(pump);
          }
    
          gateMedia(false);
    
          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);
    
          const dc = pc.createDataChannel('oai-events');
    
          dc.onopen = () => console.log("DC open");
          dc.onmessage = (e) => {
            handleDcMessage(e.data, dc);
          };
    
          pc.ondatachannel = (event) => {
            const ch = event.channel;
            ch.onmessage = (e) => {
                handleDcMessage(e.data, ch);
            };
          };
    
          function handleDcMessage(data, channel) {
              let obj;
              try { obj = JSON.parse(data); }
              catch (err) {
                pushEventFromDataChannel({ type: "raw", data: String(data), parseError: String(err) });
                return;
              }
              pushEventFromDataChannel(obj);
    
              if (obj?.type === "session.created") {
                console.log("Session created, opening media gate.");
                gateMedia(true);
                if(audioSender) audioSender.replaceTrack(audioTrack);
                if(videoSender && videoTrack) videoSender.replaceTrack(videoTrack);
    
                sendUpdate(channel);
              }
          }
    
          pc.onicegatheringstatechange = () => {
            if (!pc) return;
            if (pc.iceGatheringState === "complete" && pc.localDescription?.sdp) {
              const sdp = pc.localDescription.sdp;
              offerBox.value = sdp;
              copyOfferBtn.disabled = false;
              setAnswerBtn.disabled = false;
    
              const escapedSdp = sdp.replace(/'/g, "'\\''");
              // Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
              curlBox.value = `curl -X POST 'https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/webrtc/realtime?model=qwen3.5-omni-plus-realtime' \\\n  -H 'Content-Type: application/sdp' \\\n  -H 'Authorization: Bearer $DASHSCOPE_API_KEY' \\\n  --data-binary '${escapedSdp}'`;
              copyCurlBtn.disabled = false;
    
              setStatus('Offer SDP generated. Copy the curl command to your terminal to get the Answer SDP.', 'connecting');
              console.log("ICE Gathering Complete. Ready to set remote description.");
            }
          };
    
          const offer = await pc.createOffer();
          await pc.setLocalDescription(offer);
        }
    
        async function setRemoteAnswerFromUI() {
          if (!pc) return alert('Click "Start Session" first to generate the Offer.');
          const txt = answerBox.value.trim();
          if (!txt) return alert("Please paste the Answer SDP");
    
          const answerSdp = normalizeSdpForSetRemote(txt);
          try {
              await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
              setStatus('Establishing connection...', 'connecting');
          } catch (e) {
              alert("Failed to set Answer: " + e.message);
              console.error(e);
          }
        }
    
        function endSession(silent = false) {
          if (sendRafId) cancelAnimationFrame(sendRafId);
          sendRafId = 0;
    
          if (sendCanvasStream) {
            sendCanvasStream.getTracks().forEach(t => t.stop());
          }
          sendCanvasStream = null;
          sendCanvasCtx = null;
          sendCanvas = null;
    
          try { if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop(); } catch {}
          mediaRecorder = null;
    
          if (localStream) {
            localStream.getTracks().forEach(t => t.stop());
            localStream = null;
          }
          localVideo.srcObject = null;
          localVideo.style.display = "none";
          document.getElementById('videoSection').style.display = "none";
    
          if (pc) {
            try { pc.close(); } catch {}
            pc = null;
          }
    
          gatedAudioTracks = [];
          gatedVideoTracks = [];
    
          if (hiddenRemoteAudioEl) {
            try { hiddenRemoteAudioEl.pause(); } catch {}
            hiddenRemoteAudioEl.srcObject = null;
            hiddenRemoteAudioEl.remove();
            hiddenRemoteAudioEl = null;
          }
    
          endBtn.disabled = true;
          setAnswerBtn.disabled = true;
          copyOfferBtn.disabled = true;
          copyCurlBtn.disabled = true;
          downloadBtn.disabled = !audioBlob;
    
          setStatus('Disconnected', '');
          if (!silent) console.log("session ended");
        }
    
        function ensureHiddenAudioEl() {
          if (hiddenRemoteAudioEl) return;
          hiddenRemoteAudioEl = document.createElement("audio");
          hiddenRemoteAudioEl.autoplay = true;
          hiddenRemoteAudioEl.playsInline = true;
          hiddenRemoteAudioEl.muted = false;
          hiddenRemoteAudioEl.style.display = "none";
          document.body.appendChild(hiddenRemoteAudioEl);
        }
    
        function startRecordingRemoteStream(remoteStream) {
          const audioTracks = remoteStream.getAudioTracks();
          if (!audioTracks.length) return;
    
          const audioStream = new MediaStream(audioTracks);
          recordedChunks = [];
          audioBlob = null;
          downloadBtn.disabled = true;
    
          try {
            mediaRecorder = new MediaRecorder(audioStream, { mimeType: 'audio/webm' });
          } catch (err) {
            console.log("MediaRecorder create failed:", err);
            return;
          }
    
          mediaRecorder.ondataavailable = (e) => {
            if (e.data && e.data.size > 0) recordedChunks.push(e.data);
          };
    
          mediaRecorder.onstop = () => {
            audioBlob = new Blob(recordedChunks, { type: 'audio/webm' });
            downloadBtn.disabled = !audioBlob || audioBlob.size === 0;
          };
    
          mediaRecorder.start();
        }
    
        function downloadBlob(blob, filename) {
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.style.display = 'none';
          a.href = url;
          a.download = filename;
          document.body.appendChild(a);
          a.click();
          URL.revokeObjectURL(url);
          a.remove();
        }
    
        renderEvents();
    
        const stickyTop = document.querySelector('.sticky-top');
        window.addEventListener('scroll', () => {
          stickyTop.classList.toggle('scrolled', window.scrollY > 10);
        }, { passive: true });
    </script>
    </body>
    </html>
    

    Abra este arquivo em um navegador e siga estas etapas:

    1. Clique em Start Session. A página gera automaticamente o Offer SDP e o comando curl correspondente.
    2. Clique em Copy curl Command e execute-o no seu terminal. A saída será o Answer SDP.
    3. Cole o Answer SDP na caixa de texto Answer SDP e clique em Set Answer para estabelecer a conexão e iniciar o chat de voz.

Fluxo de interação

Modo VAD

Defina session.turn_detection.type em session.update como "server_vad" ou "semantic_vad" para enable VAD mode. Esta configuração é adequada para cenários de chamada de voz. Tanto WebSocket quanto WebRTC suportam o modo VAD com os mesmos eventos de servidor; a diferença reside apenas na forma de transmissão de áudio e imagens.

O WebRTC suporta exclusivamente o modo VAD e não oferece suporte ao modo Manual. Com WebRTC, o áudio é transmitido diretamente via RTP sem o envio de eventos input_audio_buffer.append ; as imagens são transmitidas por faixas de vídeo, sem suporte a eventos input_image_buffer.append . Os comandos de controle e eventos do servidor trafegam via DataChannel, utilizando os mesmos tipos de evento do WebSocket.

O fluxo de interação ocorre da seguinte forma:

  1. Envie dados de áudio pelo cliente. O WebSocket realiza o envio por meio de eventos input_audio_buffer.append; já o WebRTC transmite automaticamente pela faixa de áudio (RTP), dispensando o envio manual de eventos.
  2. Ao detectar o início da fala, o servidor envia o evento input_audio_buffer.speech_started via DataChannel (WebRTC) ou WebSocket.
  3. Quando a fala termina, o servidor detecta o fim e dispara o evento input_audio_buffer.speech_stopped.
  4. Confirme o buffer de áudio no servidor, que então envia o evento input_audio_buffer.committed.
  5. Inicie a geração de resposta no servidor, enviando conversation.item.created e outros eventos relacionados. As respostas de áudio retornam incrementalmente pelo evento response.audio.delta do WebSocket ou são transmitidas diretamente pela faixa de áudio (RTP) do WebRTC.
  6. Durante a resposta, o servidor retorna a transcrição de texto incremental via eventos response.audio_transcript.delta e envia o evento response.done ao concluir a resposta.
Ciclo de vidaEventos do clienteEventos do servidor

Inicialização da sessão

session.update

Configuração da sessão

session.created

Sessão criada.

session.updated

Configuração da sessão atualizada.

Entrada de áudio do usuário

input_audio_buffer.append

WebSocket: Adiciona áudio ao buffer por meio deste evento.

input_image_buffer.append

WebSocket: Adiciona uma imagem ao buffer por meio deste evento.

WebRTC: O áudio é transmitido automaticamente pela faixa de áudio RTP e as imagens pela faixa de vídeo. Estes eventos não são necessários.

input_audio_buffer.speech_started

Início de fala detectado.

input_audio_buffer.speech_stopped

Fim de fala detectado.

input_audio_buffer.committed

Buffer de áudio confirmado.

Saída de áudio do servidor

Nenhum

response.created

Geração de resposta iniciada.

response.output_item.added

Novo item de saída adicionado à resposta.

conversation.item.created

Item de conversa criado.

response.content_part.added

Nova parte de conteúdo adicionada à mensagem do assistente.

response.audio_transcript.delta

Texto transcrito gerado incrementalmente.

response.audio.delta

WebSocket: O áudio gerado incrementalmente pelo modelo retorna por meio deste evento. WebRTC: O áudio é transmitido diretamente pela faixa de áudio RTP; este evento não é retornado.

response.audio_transcript.done

Transcrição de texto concluída.

response.audio.done

Geração de áudio concluída.

response.content_part.done

Streaming do conteúdo de texto ou áudio do assistente concluído.

response.output_item.done

Streaming completo do item de saída inteiro do assistente.

response.done

Resposta concluída.

conversation.item.input_audio_transcription.completed

Transcrição da entrada de áudio do usuário concluída (requer ativação de input_audio_transcription em session.update).

Modo Manual

Para usar o modo manual, defina session.turn_detection em session.update como null. Nesse modo, o cliente envia input_audio_buffer.commit e response.create para solicitar uma resposta. Essa abordagem é ideal para cenários "push-to-talk", como mensagens de voz em aplicativos de chat.

O fluxo de interação segue estas etapas:

  1. Adicione áudio e imagens ao buffer a qualquer momento enviando os eventos input_audio_buffer.append e input_image_buffer.append.

    É obrigatório enviar pelo menos um evento input_audio_buffer.append antes de enviar um evento input_image_buffer.append .

  2. Envie o evento input_audio_buffer.commit para confirmar os buffers de áudio e imagem, sinalizando ao servidor que toda a entrada do usuário (áudio e imagens) para o turno atual foi enviada.

  3. Aguarde o evento input_audio_buffer.committed como confirmação do servidor.

  4. Dispare o evento response.create e aguarde o retorno da saída do modelo pelo servidor.

  5. Receba a resposta do servidor por meio do evento conversation.item.created.

Ciclo de vidaEventos do clienteEventos do servidor

Inicialização da sessão

session.update

Configuração da sessão

session.created

Sessão criada.

session.updated

Configuração da sessão atualizada.

Entrada de áudio do usuário

input_audio_buffer.append

Adiciona áudio ao buffer.

input_image_buffer.append

Adiciona uma imagem ao buffer.

input_audio_buffer.commit

Confirma os buffers de áudio e imagem.

response.create

Solicita uma resposta do modelo.

input_audio_buffer.committed

Buffer de áudio confirmado.

Saída de áudio do servidor

input_audio_buffer.clear

Limpa o áudio do buffer.

response.created

Geração de resposta iniciada.

response.output_item.added

Novo item de saída adicionado à resposta.

conversation.item.created

Item de conversa criado.

response.content_part.added

Nova parte de conteúdo adicionada ao item de mensagem do assistente.

response.audio_transcript.delta

Texto transcrito gerado incrementalmente.

response.audio.delta

Áudio gerado incrementalmente pelo modelo.

response.audio_transcript.done

Transcrição de texto concluída.

response.audio.done

Geração de áudio concluída.

response.content_part.done

Streaming do conteúdo de texto ou áudio do assistente concluído.

response.output_item.done

Streaming completo do item de saída inteiro do assistente.

response.done

Resposta concluída.

Pesquisa na web

A pesquisa na web permite que o modelo utilize dados em tempo real para responder a perguntas sobre informações atuais, como cotações de ações e previsão do tempo. O próprio modelo determina automaticamente se uma pesquisa é necessária.

Apenas o modelo qwen3.5-omni-plus-realtime oferece suporte à pesquisa na web. Este recurso vem desativado por padrão; para ativá-lo, use session.update .

Para informações sobre faturamento, consulte a política agent em billing rules .

Ativar pesquisa na web

Adicione os seguintes parâmetros ao evento session.update:

  • enable_search: Defina como true para ativar o recurso de pesquisa na web.
  • search_options.enable_source: Configure como true para incluir as fontes dos resultados da pesquisa na resposta.

Para mais parâmetros, consulte session.update.

Formato da resposta

Com a pesquisa na web ativada, o objeto usage em response.done passa a incluir um campo plugins contendo informações de medição da pesquisa:

{
    "usage": {
        "total_tokens": 2937,
        "input_tokens": 2554,
        "output_tokens": 383,
        "input_tokens_details": {
            "text_tokens": 2512,
            "audio_tokens": 42
        },
        "output_tokens_details": {
            "text_tokens": 90,
            "audio_tokens": 293
        },
        "plugins": {
            "search": {
                "count": 1,
                "strategy": "agent"
            }
        }
    }
}

Exemplo de código

Ative a pesquisa na web em uma conversa em tempo real:

DashScope Python SDK

Passe os parâmetros enable_search e search_options na chamada update_session:

import os
import base64
import time
import json
import pyaudio
from dashscope.audio.qwen_omni import MultiModality, AudioFormat, OmniRealtimeCallback, OmniRealtimeConversation
import dashscope

dashscope.api_key = os.getenv('DASHSCOPE_API_KEY')
# Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
url = 'wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime'
model = 'qwen3.5-omni-plus-realtime'
voice = 'Tina'

class SearchCallback(OmniRealtimeCallback):
    def __init__(self, pya):
        self.pya = pya
        self.out = None
    def on_open(self):
        self.out = self.pya.open(format=pyaudio.paInt16, channels=1, rate=24000, output=True)
    def on_event(self, response):
        if response['type'] == 'response.audio.delta':
            self.out.write(base64.b64decode(response['delta']))
        elif response['type'] == 'conversation.item.input_audio_transcription.delta':
            preview = response.get('text', '') + response.get('stash', '')
            print(f"\r[User] {preview}", end='', flush=True)
        elif response['type'] == 'conversation.item.input_audio_transcription.completed':
            print(f"\r[User] {response['transcript']}")
        elif response['type'] == 'response.audio_transcript.done':
            print(f"[LLM] {response['transcript']}")
        elif response['type'] == 'response.done':
            usage = response.get('response', {}).get('usage', {})
            plugins = usage.get('plugins', {})
            if plugins.get('search'):
                print(f"[Search] count={plugins['search']['count']}, strategy={plugins['search']['strategy']}")

pya = pyaudio.PyAudio()
callback = SearchCallback(pya)
conv = OmniRealtimeConversation(model=model, callback=callback, url=url)
conv.connect()
conv.update_session(
    output_modalities=[MultiModality.AUDIO, MultiModality.TEXT],
    voice=voice,
    instructions="You are Xiaoyun, a personal assistant.",
    enable_search=True,
    search_options={'enable_source': True}
)
mic = pya.open(format=pyaudio.paInt16, channels=1, rate=16000, input=True)
print("Web search is enabled. Speak into the microphone (Ctrl+C to exit)...")
try:
    while True:
        audio_data = mic.read(3200, exception_on_overflow=False)
        conv.append_audio(base64.b64encode(audio_data).decode())
        time.sleep(0.01)
except KeyboardInterrupt:
    conv.close()
    mic.close()
    callback.out.close()
    pya.terminate()
    print("\nConversation ended.")

DashScope Java SDK

No método updateSession, passe a configuração de pesquisa na web no argumento parameters:

import com.alibaba.dashscope.audio.omni.*;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.google.gson.JsonObject;
import javax.sound.sampled.*;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;

public class OmniSearch {
    static class SequentialAudioPlayer {
        private final SourceDataLine line;
        private final Queue<byte[]> audioQueue = new ConcurrentLinkedQueue<>();
        private final Thread playerThread;
        private final AtomicBoolean shouldStop = new AtomicBoolean(false);

        public SequentialAudioPlayer() throws LineUnavailableException {
            AudioFormat format = new AudioFormat(24000, 16, 1, true, false);
            line = AudioSystem.getSourceDataLine(format);
            line.open(format);
            line.start();
            playerThread = new Thread(() -> {
                while (!shouldStop.get()) {
                    byte[] audio = audioQueue.poll();
                    if (audio != null) {
                        line.write(audio, 0, audio.length);
                    } else {
                        try { Thread.sleep(10); } catch (InterruptedException ignored) {}
                    }
                }
            }, "AudioPlayer");
            playerThread.start();
        }

        public void play(String base64Audio) {
            audioQueue.add(Base64.getDecoder().decode(base64Audio));
        }
        public void close() {
            shouldStop.set(true);
            try { playerThread.join(1000); } catch (InterruptedException ignored) {}
            line.drain();
            line.close();
        }
    }

    public static void main(String[] args) {
        try {
            SequentialAudioPlayer player = new SequentialAudioPlayer();
            AtomicBoolean shouldStop = new AtomicBoolean(false);

            OmniRealtimeParam param = OmniRealtimeParam.builder()
                    .model("qwen3.5-omni-plus-realtime")
                    .apikey(System.getenv("DASHSCOPE_API_KEY"))
                    // Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
                    .url("wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime")
                    .build();

            OmniRealtimeConversation conversation = new OmniRealtimeConversation(param, new OmniRealtimeCallback() {
                @Override public void onOpen() {
                    System.out.println("Connection established.");
                }
                @Override public void onClose(int code, String reason) {
                    System.out.println("Connection closed.");
                    shouldStop.set(true);
                }
                @Override public void onEvent(JsonObject event) {
                    String type = event.get("type").getAsString();
                    if ("response.audio.delta".equals(type)) {
                        player.play(event.get("delta").getAsString());
                    } else if ("response.audio_transcript.done".equals(type)) {
                        System.out.println("[LLM] " + event.get("transcript").getAsString());
                    } else if ("response.done".equals(type)) {
                        JsonObject response = event.getAsJsonObject("response");
                        if (response != null && response.has("usage")) {
                            JsonObject usage = response.getAsJsonObject("usage");
                            if (usage.has("plugins")) {
                                JsonObject plugins = usage.getAsJsonObject("plugins");
                                if (plugins.has("search")) {
                                    JsonObject search = plugins.getAsJsonObject("search");
                                    System.out.println("[Search] count=" + search.get("count").getAsInt()
                                            + ", strategy=" + search.get("strategy").getAsString());
                                }
                            }
                        }
                    }
                }
            });

            conversation.connect();
            conversation.updateSession(OmniRealtimeConfig.builder()
                    .modalities(Arrays.asList(OmniRealtimeModality.AUDIO, OmniRealtimeModality.TEXT))
                    .voice("Tina")
                    .enableTurnDetection(true)
                    .enableInputAudioTranscription(true)
                    .parameters(Map.of(
                            "instructions", "You are Xiaoyun, a personal assistant.",
                            "enable_search", true,
                            "search_options", Map.of("enable_source", true)
                    ))
                    .build()
            );

            System.out.println("Web search is enabled. Start speaking (press Ctrl+C to exit)...");
            AudioFormat format = new AudioFormat(16000, 16, 1, true, false);
            TargetDataLine mic = AudioSystem.getTargetDataLine(format);
            mic.open(format);
            mic.start();

            ByteBuffer buffer = ByteBuffer.allocate(3200);
            while (!shouldStop.get()) {
                int bytesRead = mic.read(buffer.array(), 0, buffer.capacity());
                if (bytesRead > 0) {
                    conversation.appendAudio(Base64.getEncoder().encodeToString(buffer.array()));
                }
                Thread.sleep(20);
            }

            conversation.close(1000, "Normal termination");
            player.close();
            mic.close();
        } catch (NoApiKeyException e) {
            System.err.println("API key not found: Set the DASHSCOPE_API_KEY environment variable.");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

WebSocket (Python)

Inclua os campos enable_search e search_options no payload JSON do evento session.update:

import json
import os
import websocket
import base64
import pyaudio
import threading

API_KEY = os.getenv("DASHSCOPE_API_KEY")
# Singapore region. Replace {WorkspaceId} with your Bailian workspace ID. URLs vary by region.
API_URL = "wss://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/realtime?model=qwen3.5-omni-plus-realtime"

pya = pyaudio.PyAudio()
out_stream = pya.open(format=pyaudio.paInt16, channels=1, rate=24000, output=True)

def on_open(ws):
    ws.send(json.dumps({
        "type": "session.update",
        "session": {
            "modalities": ["text", "audio"],
            "voice": "Tina",
            "instructions": "You are Xiaoyun, a personal assistant.",
            "input_audio_format": "pcm",
            "output_audio_format": "pcm",
            "enable_search": True,
            "search_options": {
                "enable_source": True
            }
        }
    }))
    print("Web search is enabled. Speak into the microphone...")
    def send_audio():
        mic = pya.open(format=pyaudio.paInt16, channels=1, rate=16000, input=True)
        try:
            while True:
                audio = mic.read(3200, exception_on_overflow=False)
                ws.send(json.dumps({
                    "type": "input_audio_buffer.append",
                    "audio": base64.b64encode(audio).decode()
                }))
        except Exception:
            mic.close()
    threading.Thread(target=send_audio, daemon=True).start()

def on_message(ws, message):
    event = json.loads(message)
    if event["type"] == "response.audio.delta":
        out_stream.write(base64.b64decode(event["delta"]))
    elif event["type"] == "response.audio_transcript.done":
        print(f"[LLM] {event['transcript']}")
    elif event["type"] == "response.done":
        usage = event.get("response", {}).get("usage", {})
        plugins = usage.get("plugins", {})
        if plugins.get("search"):
            print(f"[Search] count={plugins['search']['count']}, strategy={plugins['search']['strategy']}")

def on_error(ws, error):
    print(f"Error: {error}")

headers = ["Authorization: Bearer " + API_KEY]
ws = websocket.WebSocketApp(API_URL, header=headers, on_open=on_open, on_message=on_message, on_error=on_error)
ws.run_forever()

Referência da API

Faturamento e limites de taxa

Faturamento

O faturamento é baseado em tokens e medido por modalidade (áudio, imagem, texto). Consulte o console do Model Studio para verificar os preços.

ObservaçãoEm uma conversa em tempo real com múltiplas turnos, sempre que o modelo gera uma resposta, ele processa todo o conteúdo histórico da conversa dentro da janela de contexto — incluindo áudio, imagens e texto dos turnos anteriores — juntamente com a nova entrada do turno atual como tokens de entrada. Como resultado, os tokens de entrada se acumulam a cada turno, em vez de serem contados apenas para a nova entrada do turno atual.

Por exemplo, se uma entrada de áudio de 10 segundos for convertida em 70 tokens (Qwen3.5-Omni-Realtime) e esse áudio ainda estiver dentro da janela de contexto no turno 3, ele ainda será contabilizado nos tokens de entrada desse turno. Os tokens de entrada faturados correspondem à soma dos tokens de todos os turnos históricos dentro da janela de contexto mais os tokens da nova entrada do turno atual.

Regras para conversão de áudio e imagens em tokens

Áudio

  • Qwen3.5-Omni-Realtime:

    • Áudio de entrada: total tokens = Audio duration (seconds) * 7
    • Áudio de saída: total tokens = Audio duration (seconds) * 12.5
  • Qwen3-Omni-Flash-Realtime: O áudio de entrada e saída utiliza a mesma fórmula: total tokens = Audio duration (seconds) * 12.5

  • Qwen-Omni-Turbo-Realtime: O áudio de entrada e saída utiliza a mesma fórmula: total tokens = Audio duration (seconds) * 25

    Durações de áudio inferiores a 1 segundo são faturadas como 1 segundo.

Imagem

  • O modelo da série Qwen3.5-Omni-Realtime consome 1 token a cada 32x32 pixels
  • O modelo Qwen3-Omni-Flash-Realtime consome 1 token a cada 32x32 pixels
  • O modelo Qwen-Omni-Turbo-Realtime consome 1 token a cada 28x28 pixels

Uma imagem consome entre 4 e 1.280 tokens. Utilize o código abaixo para estimar o consumo de tokens com base nas dimensões da imagem e na duração da sessão:

# Install the Pillow library by running: pip install Pillow
from PIL import Image
import math

# For the Qwen-Omni-Turbo-Realtime model, the scaling factor is 28.
# factor = 28
# For the Qwen3-Omni-Flash-Realtime and Qwen3.5-Omni-Realtime models, the scaling factor is 32.
factor = 32

def token_calculate(image_path='', duration=10):
    """
    :param image_path: Image path
    :param duration: Session duration
    :return: Total tokens for the image based on session duration
    """
    if len(image_path) > 0:
        # Open the image file.
        image = Image.open(image_path)
        # Get the image's original dimensions.
        height = image.height
        width = image.width
        print(f"Image dimensions before scaling: height={height}, width={width}")
        # Adjust the height to a multiple of factor.
        h_bar = round(height / factor) * factor
        # Adjust the width to a multiple of factor.
        w_bar = round(width / factor) * factor
        # Lower limit for image tokens: 4 tokens.
        min_pixels = factor * factor * 4
        # Upper limit for image tokens: 1,280 tokens.
        max_pixels = 1280 * factor * factor
        # Scale the image to fit the pixel count limits.
        if h_bar * w_bar > max_pixels:
            # Calculate the scaling factor beta to avoid exceeding max_pixels.
            beta = math.sqrt((height * width) / max_pixels)
            # Recalculate the adjusted height to ensure it is an integer multiple of factor.
            h_bar = math.floor(height / beta / factor) * factor
            # Recalculate the adjusted width to ensure it is an integer multiple of factor.
            w_bar = math.floor(width / beta / factor) * factor
        elif h_bar * w_bar < min_pixels:
            # Calculate the scaling factor beta so the scaled image pixel count is not less than min_pixels.
            beta = math.sqrt(min_pixels / (height * width))
            # Recalculate the adjusted height to ensure it is an integer multiple of factor.
            h_bar = math.ceil(height * beta / factor) * factor
            # Recalculate the adjusted width to ensure it is an integer multiple of factor.
            w_bar = math.ceil(width * beta / factor) * factor
        print(f"Image dimensions after scaling: height={h_bar}, width={w_bar}")
        # Calculate image tokens.
        token = int((h_bar * w_bar) / (factor * factor))
        print(f"Token count after scaling: {token}")
        total_token = token * math.ceil(duration / 2)
        print(f"Total tokens: {total_token}")
        return total_token
    else:
        print("Error: image_path is empty. Cannot calculate tokens.")
        return 0

if __name__ == "__main__":
    total_token = token_calculate(image_path="xxx/test.jpg", duration=10)

Limites de taxa

Para consultar os limites de taxa dos modelos, veja Rate limiting.

Códigos de erro

Se a chamada ao modelo falhar e retornar uma mensagem de erro, consulte Error codes para obter a solução.

Lista de vozes

Para visualizar a lista de vozes disponíveis para o modelo Qwen-Omni-Realtime, consulte Voices.