Menjelaskan cara menghubungkan ke Realtime API di browser menggunakan WebRTC dan JavaScript untuk mengaktifkan panggilan audio/video real-time dengan model qwen3.5-omni-plus-realtime.
CatatanWebRTC cocok untuk skenario voice berbasis browser dengan latensi rendah. Audio ditransmisikan langsung melalui UDP dengan pembatalan gema (echo cancellation) dan pengurangan noise bawaan. WebRTC hanya mendukung mode VAD sisi server (server_vad atau semantic_vad). Mode manual tidak didukung.
Prasyarat dan catatan
- Konfigurasikan Kunci API dan tetapkan sebagai variabel lingkungan.
- Gunakan browser modern yang mendukung WebRTC (Chrome, Edge, Firefox, Safari, dan sebagainya).
- Browser harus memiliki izin mikrofon. Untuk panggilan video, izin kamera juga diperlukan.
- Browser tidak dapat langsung mengirim permintaan pertukaran SDP ke server karena pembatasan CORS. Dalam demo, jalankan perintah curl di terminal untuk menyelesaikan koneksi. Di lingkungan produksi, pembatasan ini tidak berlaku ketika permintaan diproksi melalui AppServer.
Implementasikan panggilan audio/video AI
Diagram urutan berikut menunjukkan alur lengkap panggilan audio/video WebRTC:
Diagram urutan panggilan audio/video WebRTC
Buat RTCPeerConnection
Panggil konstruktor RTCPeerConnection bawaan browser untuk membuat instans koneksi. Tidak perlu mengonfigurasi ICE server (server menangani Penelusuran NAT).
pc = new RTCPeerConnection({ iceServers: [ ] });
Daftarkan callback utama:
// Pendengar status koneksi
pc.onconnectionstatechange = () => {
if (!pc) return;
if (pc.connectionState === 'connected') {
setStatus('Terhubung, silakan bicara', 'connected');
} else if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
endSession(true);
}
};
// Terima dan putar aliran audio remote + mulai merekam
pc.ontrack = async (e) => {
const stream = e.streams[0];
ensureHiddenAudioEl();
hiddenRemoteAudioEl.srcObject = stream;
try { await hiddenRemoteAudioEl.play(); } catch {}
startRecordingRemoteStream(stream);
};
Ambil aliran media lokal
Gunakan satu panggilan getUserMedia untuk meminta audio (wajib) dan video (opsional). Aktivasi video ditentukan oleh centang pengguna pada kotak Enable video.
const wantVideo = !!sendVideoCheckbox.checked;
const constraints = wantVideo
? {
audio: true,
video: {
facingMode: { ideal: "user" },
frameRate: { ideal: 30, max: 30 },
width: { ideal: 640 },
height: { ideal: 480 },
}
}
: { audio: true };
localStream = await navigator.mediaDevices.getUserMedia(constraints);
CatatanAudio dan video keduanya diperoleh dalam satu panggilan getUserMedia, bukan secara terpisah. Pratinjau video berjalan pada 30 fps (untuk pratinjau lokal yang lancar); laju frame pengiriman dikurangi menjadi 2 fps melalui Canvas.
Tambahkan track media ke PeerConnection
Tambahkan track audio:localStream.getAudioTracks().forEach(t => {
pc.addTrack(t, localStream);
gatedAudioTracks.push(t);
});
Tambahkan track video (opsional, dengan pengurangan frame Canvas menjadi 2 fps):
Dimensi Canvas diperoleh secara dinamis dari resolusi kamera aktual, bukan hard-coded:
const sendFps = 2;
const settings = localStream.getVideoTracks()[0].getSettings();
sendCanvas = document.createElement("canvas");
sendCanvas.width = settings.width || 640; // Peroleh lebar aktual secara dinamis
sendCanvas.height = settings.height || 480; // Peroleh tinggi aktual secara dinamis
sendCanvasCtx = sendCanvas.getContext("2d", { alpha: false });
sendCanvasStream = sendCanvas.captureStream(sendFps); // 2fps
const lowFpsTrack = sendCanvasStream.getVideoTracks()[0];
pc.addTrack(lowFpsTrack, sendCanvasStream);
gatedVideoTracks.push(lowFpsTrack);
// Loop requestAnimationFrame: gambar frame kamera ke Canvas
const pump = () => {
if (!sendCanvasCtx || !sendCanvas) return;
try { sendCanvasCtx.drawImage(localVideo, 0, 0, sendCanvas.width, sendCanvas.height); } catch {}
sendRafId = requestAnimationFrame(pump);
};
sendRafId = requestAnimationFrame(pump);
Media gating (kritis):
Setelah menambahkan track, segera blokir pengiriman agar tidak ada media yang didorong sebelum menerima session.created:
// 1. Nonaktifkan semua track.enabled
gateMedia(false); // track.enabled = false
// 2. Ganti track pengirim dengan null untuk sepenuhnya memblokir pengiriman
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);
CatatanIni setara dengan enableSendMediaStream(false) di SDK lain. Pengiriman tidak boleh dipulihkan hingga menerima session.created.
Buat DataChannel
Buat DataChannel bernama oai-events untuk bertukar event kontrol sesi dengan server AI.
const dc = pc.createDataChannel('oai-events');
dc.onopen = () => console.log("DC open");
dc.onmessage = (e) => {
handleDcMessage(e.data, dc);
};
// Dengarkan juga DataChannel yang dibuat oleh server
pc.ondatachannel = (event) => {
const ch = event.channel;
ch.onmessage = (e) => {
handleDcMessage(e.data, ch);
};
};
Hasilkan Offer SDP
Panggil createOffer() dan atur deskripsi lokal. Tunggu hingga pengumpulan ICE selesai sebelum menggunakan Offer SDP lengkap.
pc.onicegatheringstatechange = () => {
if (!pc) return;
if (pc.iceGatheringState === "complete" && pc.localDescription?.sdp) {
const sdp = pc.localDescription.sdp;
// Pengumpulan ICE selesai, Offer SDP siap
// Hasilkan otomatis perintah curl untuk pengguna
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
CatatanTunggu hingga iceGatheringState === "complete" sebelum menggunakan SDP. Pada titik tersebut, SDP berisi semua informasi kandidat ICE.
Pertukaran SDP (melalui perintah curl atau AppServer)
Kirim Offer SDP ke server dan peroleh Answer SDP. Dalam demo, langkah ini dilakukan melalui perintah curl:
curl -X POST 'https://{endpoint}/api/v1/webrtc/realtime?model=qwen3.5-omni-plus-realtime' \
-H 'Content-Type: application/sdp' \
-H 'Authorization: Bearer $DASHSCOPE_API_KEY' \
--data-binary '<Offer SDP content>'
CatatanDi lingkungan produksi, proksi langkah ini melalui AppServer untuk menghindari pengeksposan Kunci API di front end. {endpoint} adalah Titik akhir Realtime API.
Atur Answer SDP untuk menetapkan koneksi
Atur Answer SDP yang dikembalikan server sebagai deskripsi remote untuk menetapkan koneksi WebRTC. Normalisasi format SDP sebelum mengaturnya:
function normalizeSdpForSetRemote(sdp) {
sdp = String(sdp).trim().replace(/\r?\n/g, "\r\n");
if (!sdp.endsWith("\r\n")) sdp += "\r\n";
return sdp;
}
const answerSdp = normalizeSdpForSetRemote(txt);
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
CatatanSpesifikasi SDP mengharuskan akhiran baris \r\n. normalizeSdpForSetRemote menangani kompatibilitas akhiran baris dari berbagai sumber.
Konfigurasi sesi AI (session.update)
Setelah koneksi ditetapkan, server mengirim event session.created melalui DataChannel. Saat menerimanya:
- Lepaskan pemblokiran media untuk memulihkan pengiriman audio/video
- Kirim
session.updateuntuk mengonfigurasi parameter sesi
function handleDcMessage(data, channel) {
let obj;
try { obj = JSON.parse(data); } catch (err) { return; }
if (obj?.type === "session.created") {
// Lepaskan pemblokiran: pulihkan track.enabled
gateMedia(true);
// Pulihkan track aktual ke pengirim
if (audioSender) audioSender.replaceTrack(audioTrack);
if (videoSender && videoTrack) videoSender.replaceTrack(videoTrack);
// Kirim konfigurasi sesi
sendUpdate(channel);
}
}
Isi pesan session.update:
const update = {
event_id: `event_${Date.now()}`,
type: "session.update",
session: {
input_audio_format: "pcm",
input_audio_transcription: { model: "qwen3-asr-flash-realtime" },
instructions: "You are a helpful assistant.",
modalities: ["text", "audio"],
output_audio_format: "pcm",
smooth_output: false,
turn_detection: {
prefix_padding_ms: 500,
silence_duration_ms: 800,
threshold: 0.5,
type: "server_vad",
},
},
};
if (channel && channel.readyState === "open") channel.send(JSON.stringify(update));
Catatanturn_detection.type dapat diatur ke server_vad (deteksi berbasis volume) atau semantic_vad (deteksi semantik). VAD manual tidak didukung dalam mode WebRTC.
Percakapan real-time
Setelah koneksi ditetapkan, audio/video ditransmisikan secara real-time melalui RTP. Tanggapan suara AI remote diterima melalui callback ontrack dan diputar kembali. MediaRecorder digunakan untuk merekam tanggapan guna diunduh.
pc.ontrack = async (e) => {
const stream = e.streams[0];
ensureHiddenAudioEl();
hiddenRemoteAudioEl.srcObject = stream;
try { await hiddenRemoteAudioEl.play(); } catch {}
startRecordingRemoteStream(stream); // Mulai merekam
};
function startRecordingRemoteStream(remoteStream) {
const audioTracks = remoteStream.getAudioTracks();
if (!audioTracks.length) return;
const audioStream = new MediaStream(audioTracks);
recordedChunks = [ ];
mediaRecorder = new MediaRecorder(audioStream, { mimeType: 'audio/webm' });
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) recordedChunks.push(e.data);
};
mediaRecorder.onstop = () => {
audioBlob = new Blob(recordedChunks, { type: 'audio/webm' });
// Tersedia untuk diunduh setelah perekaman berhenti
};
mediaRecorder.start();
}
Tampilan event DataChannel:
Semua event yang dikirim dan diterima melalui DataChannel (termasuk session.created, response.audio_transcript.done, dan sebagainya) ditampilkan di panel event dengan dukungan ekspansi untuk melihat JSON lengkap:
function pushEventFromDataChannel(eventObj) {
const ts = eventObj.timestamp || nowTs();
events.unshift({ event: eventObj, timestamp: ts });
renderEvents();
}
Akhiri sesi dan lepaskan sumber daya
Lepaskan semua sumber daya secara berurutan saat mengakhiri panggilan. Urutan ini penting:
function endSession(silent = false) {
// 1. Hentikan loop pengurangan frame-rate Canvas
if (sendRafId) cancelAnimationFrame(sendRafId);
sendRafId = 0;
if (sendCanvasStream) sendCanvasStream.getTracks().forEach(t => t.stop());
sendCanvasStream = null; sendCanvasCtx = null; sendCanvas = null;
// 2. Hentikan perekaman
try { if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop(); } catch {}
mediaRecorder = null;
// 3. Hentikan aliran media lokal
if (localStream) {
localStream.getTracks().forEach(t => t.stop());
localStream = null;
}
// 4. Tutup PeerConnection
if (pc) { try { pc.close(); } catch {} pc = null; }
// 5. Bersihkan elemen audio remote
if (hiddenRemoteAudioEl) {
try { hiddenRemoteAudioEl.pause(); } catch {}
hiddenRemoteAudioEl.srcObject = null;
hiddenRemoteAudioEl.remove();
hiddenRemoteAudioEl = null;
}
}
CatatanSetelah sesi berakhir, gunakan tombol Download remote audio untuk menyimpan rekaman tanggapan AI dalam format WebM.
Catatan penting
- Pemblokiran media harus dilepas setelah session.created: Data media yang dikirim sebelum server mengirim
session.createdakan dibuang. GunakanreplaceTrack(null)untuk sepenuhnya memblokir pengiriman hingga saat itu. - Pengurangan frame video diimplementasikan melalui Canvas: Pratinjau lokal berjalan pada 30 fps; hanya 2 fps yang dikirim ke server, dikendalikan oleh
captureStream(2). Ini menghemat bandwidth. - Normalisasi format SDP: Pastikan akhiran baris adalah
\r\nsebelum mengatur Answer SDP. Jika tidak,setRemoteDescriptionmungkin gagal. - Video bersifat opsional: Jika pengguna tidak mengaktifkan video, hanya izin mikrofon yang diminta — tidak muncul prompt izin kamera.
- Audio remote direkam secara otomatis: MediaRecorder merekam aliran audio tanggapan AI. File WebM tersedia untuk diunduh setelah sesi berakhir.
- WebRTC hanya mendukung VAD sisi server: Mode
manualtidak didukung. Gunakanserver_vad(deteksi volume) atausemantic_vad(deteksi semantik).
Unduh demo lengkap
Unduh kode contoh lengkap: webrtc_demo.html.
Dokumen terkait
- WebRTC API (MDN)
- RTCPeerConnection (MDN)
- Event client model qwen3.5-omni-plus-realtime: Client events
- Event server model qwen3.5-omni-plus-realtime: Server events