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âmetro | Descriçã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 |
Parâmetro de consulta | Use o parâmetro de consulta |
Cabeçalho da requisição | Utilize um token Bearer para autenticação:
|
# 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:
- 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.
- 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 |
Parâmetro de consulta | Use o parâmetro de consulta |
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.appendnão é suportado.
O WebRTC suporta apenas o modo VAD no lado do servidor (
server_vadousemantic_vad). O modo manual não é suportado.
4. Receber respostas do modelo
O formato da resposta depende da modalidade de saída configurada.
WebSocket
-
Apenas texto
Receba texto em fluxo contínuo com o evento response.text.delta e o texto completo com o evento response.text.done.
-
Texto e áudio
- Texto: Receba texto em fluxo contínuo com o evento response.audio_transcript.delta e o texto completo com o evento response.audio_transcript.done.
- Áudio: Receba áudio em fluxo contínuo codificado em Base64 com o evento response.audio.delta. O evento response.audio.done indica que a geração de áudio foi concluída.
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-realtimetem 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.pye 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.pypara 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.htmle 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:
- Clique em Start Session. A página gera automaticamente o Offer SDP e o comando curl correspondente.
- Clique em Copy curl Command e execute-o no seu terminal. A saída será o Answer SDP.
- 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 eventosinput_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:
- 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.
- Ao detectar o início da fala, o servidor envia o evento input_audio_buffer.speech_started via DataChannel (WebRTC) ou WebSocket.
- Quando a fala termina, o servidor detecta o fim e dispara o evento input_audio_buffer.speech_stopped.
- Confirme o buffer de áudio no servidor, que então envia o evento input_audio_buffer.committed.
- 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.deltado WebSocket ou são transmitidas diretamente pela faixa de áudio (RTP) do WebRTC. - Durante a resposta, o servidor retorna a transcrição de texto incremental via eventos
response.audio_transcript.deltae envia o eventoresponse.doneao concluir a resposta.
| Ciclo de vida | Eventos do cliente | Eventos do servidor |
|---|---|---|
Inicialização da sessão |
|
|
Entrada de áudio do usuário |
| input_audio_buffer.speech_started
input_audio_buffer.speech_stopped
|
Saída de áudio do servidor | Nenhum |
response.audio_transcript.delta
response.audio_transcript.done
|
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:
-
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.appendantes de enviar um eventoinput_image_buffer.append. -
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.
-
Aguarde o evento input_audio_buffer.committed como confirmação do servidor.
-
Dispare o evento response.create e aguarde o retorno da saída do modelo pelo servidor.
-
Receba a resposta do servidor por meio do evento conversation.item.created.
| Ciclo de vida | Eventos do cliente | Eventos do servidor |
|---|---|---|
Inicialização da sessão |
|
|
Entrada de áudio do usuário |
|
|
Saída de áudio do servidor |
|
response.audio_transcript.delta
response.audio_transcript.done
|
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-realtimeoferece suporte à pesquisa na web. Este recurso vem desativado por padrão; para ativá-lo, usesession.update.
Para informações sobre faturamento, consulte a política
agentem billing rules .
Ativar pesquisa na web
Adicione os seguintes parâmetros ao evento session.update:
enable_search: Defina comotruepara ativar o recurso de pesquisa na web.search_options.enable_source: Configure comotruepara 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
- Áudio de entrada:
-
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) * 25Durações de áudio inferiores a 1 segundo são faturadas como 1 segundo.
Imagem
- O modelo da série
Qwen3.5-Omni-Realtimeconsome 1 token a cada32x32pixels - O modelo
Qwen3-Omni-Flash-Realtimeconsome 1 token a cada32x32pixels - O modelo
Qwen-Omni-Turbo-Realtimeconsome 1 token a cada28x28pixels
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.