WEBRTC WEB-CODECS-API WEB-AUDIO-API REAL-TIME MEDIA-PROCESSING BROWSER-API JAVASCRIPT FRONTEND WEB-DEVELOPMENT USER-EXPERIENCE

Memanipulasi Media Stream WebRTC di Browser: Dari Filter Video hingga Efek Audio Real-time

⏱️ 11 menit baca
👨‍💻

Memanipulasi Media Stream WebRTC di Browser: Dari Filter Video hingga Efek Audio Real-time

Pernahkah kamu membayangkan membuat aplikasi video call dengan fitur virtual background, filter Instagram-like, atau bahkan noise cancellation yang berjalan langsung di browser? WebRTC telah merevolusi komunikasi real-time di web, memungkinkan kita untuk membangun aplikasi video dan audio call yang powerful. Namun, bagaimana jika kita ingin melampaui sekadar mengirim dan menerima stream, dan mulai memanipulasinya secara dinamis?

Artikel ini akan membawa kamu menyelami dunia manipulasi media stream WebRTC di browser. Kita akan belajar bagaimana mengambil kendali penuh atas pixel video dan gelombang suara, menerapkan filter, efek, atau bahkan mengubah komposisi stream sebelum dikirim atau ditampilkan. Ini adalah kunci untuk membangun aplikasi komunikasi real-time yang lebih interaktif, personal, dan kaya fitur.

1. Pendahuluan: Mengapa Memanipulasi Media Stream Itu Penting?

WebRTC adalah fondasi luar biasa untuk video conferencing, live streaming, dan aplikasi kolaboratif lainnya. Ia menyediakan API untuk mengakses kamera, mikrofon, dan membangun koneksi peer-to-peer yang efisien. Namun, kebutuhan aplikasi modern seringkali melampaui fungsionalitas dasar ini.

Bayangkan skenario berikut:

Untuk mencapai ini, kita tidak bisa hanya mengandalkan stream mentah. Kita perlu memodifikasi stream tersebut di sisi klien (browser) sebelum mengirimnya ke peer lain atau menampilkannya ke pengguna. Di sinilah Web Codecs API, HTML Canvas, dan Web Audio API berperan penting, bekerja sama dengan WebRTC untuk membuka potensi tak terbatas.

🎯 Tujuan kita: Mengambil MediaStream dari WebRTC, memprosesnya (video dan audio secara terpisah), lalu mengintegrasikan kembali stream yang sudah dimodifikasi ini ke dalam alur WebRTC.

2. Memahami Dasar Media Stream WebRTC

Sebelum kita memanipulasi, mari kita pahami objek dasar yang akan kita kerjakan: MediaStream.

Ketika kamu memanggil navigator.mediaDevices.getUserMedia(), browser akan meminta izin untuk mengakses kamera dan mikrofonmu, lalu mengembalikan sebuah Promise yang akan me-resolve dengan objek MediaStream.

async function getLocalMediaStream() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    // Tampilkan stream lokal di elemen video
    const localVideo = document.getElementById('localVideo');
    localVideo.srcObject = stream;
    return stream;
  } catch (error) {
    console.error('Error mengakses media:', error);
  }
}

const localStream = await getLocalMediaStream();
// localStream kini berisi track video dan audio

Sebuah MediaStream terdiri dari satu atau lebih MediaStreamTrack. Kamu bisa mendapatkan track video dan audio secara terpisah:

const videoTrack = localStream.getVideoTracks()[0];
const audioTrack = localStream.getAudioTracks()[0];

console.log('Video Track:', videoTrack);
console.log('Audio Track:', audioTrack);

Setiap MediaStreamTrack adalah sumber data media yang bisa dimanipulasi. Kunci manipulasinya adalah dengan membuat MediaStreamTrack baru yang berisi data hasil olahan, lalu mengganti atau menambahkan track ini ke koneksi WebRTC (RTCPeerConnection).

3. Memanipulasi Video Stream dengan Canvas dan Web Codecs API

Memanipulasi video stream secara real-time adalah tugas yang intensif. Pendekatan umumnya adalah:

  1. Ambil setiap frame dari video track asli.
  2. Gambar frame tersebut ke elemen <canvas>.
  3. Lakukan manipulasi pixel di canvas (misalnya, filter grayscale, blur, virtual background).
  4. Ambil frame hasil olahan dari canvas.
  5. Kirim frame hasil olahan ini sebagai video track baru.

Secara tradisional, langkah 4 melibatkan canvas.captureStream(), yang cukup mudah tetapi kurang efisien untuk manipulasi tingkat lanjut. Untuk performa yang lebih baik dan kontrol yang lebih granular, kita akan menggunakan Web Codecs API.

💡 Web Codecs API memungkinkan kita mengakses frame video dan chunk audio secara langsung, tanpa harus melakukan encoding/decoding berulang kali, sehingga sangat efisien untuk pemrosesan real-time.

Mari kita buat contoh filter grayscale sederhana:

// HTML: <video id="localVideo" autoplay muted></video> <canvas id="canvasOutput" style="display:none;"></canvas> <video id="processedVideo" autoplay></video>

async function applyGrayscaleFilter(originalVideoTrack) {
  if (!('MediaStreamTrackProcessor' in window && 'MediaStreamTrackGenerator' in window && 'VideoFrame' in window)) {
    console.error('Browser tidak mendukung Web Codecs API atau MediaStreamTrackProcessor/Generator.');
    alert('Browser Anda mungkin tidak mendukung fitur ini.');
    return originalVideoTrack; // Kembali ke track asli jika tidak didukung
  }

  const processor = new MediaStreamTrackProcessor({ track: originalVideoTrack });
  const generator = new MediaStreamTrackGenerator({ kind: 'video' });

  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  let frameCounter = 0;

  // Worker untuk pemrosesan frame (opsional, untuk performa lebih baik)
  // const worker = new Worker('video-processor.js');
  // worker.postMessage({ type: 'init', canvas: offscreenCanvas }, [offscreenCanvas]);

  processor.readable.pipeThrough(new TransformStream({
    async transform(videoFrame, controller) {
      frameCounter++;
      // Gambar frame ke canvas
      canvas.width = videoFrame.displayWidth;
      canvas.height = videoFrame.displayHeight;
      ctx.drawImage(videoFrame, 0, 0, canvas.width, canvas.height);

      // Lakukan manipulasi pixel (grayscale)
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      const data = imageData.data;
      for (let i = 0; i < data.length; i += 4) {
        const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
        data[i] = avg;     // Red
        data[i + 1] = avg; // Green
        data[i + 2] = avg; // Blue
      }
      ctx.putImageData(imageData, 0, 0);

      // Buat VideoFrame baru dari canvas
      const newVideoFrame = new VideoFrame(canvas, { timestamp: videoFrame.timestamp });
      controller.enqueue(newVideoFrame);

      videoFrame.close(); // Penting untuk melepaskan memori
    }
  })).pipeTo(generator.writable);

  return generator.track;
}

// Cara menggunakan:
// const originalStream = await getLocalMediaStream();
// const originalVideoTrack = originalStream.getVideoTracks()[0];
// const processedVideoTrack = await applyGrayscaleFilter(originalVideoTrack);

// Tampilkan track yang sudah diproses
// const processedStream = new MediaStream([processedVideoTrack, originalStream.getAudioTracks()[0]]);
// document.getElementById('processedVideo').srcObject = processedStream;

// Jika ingin mengganti track di RTCPeerConnection:
// const sender = peerConnection.getSenders().find(s => s.track && s.track.kind === 'video');
// if (sender) {
//   sender.replaceTrack(processedVideoTrack);
// }

📌 Catatan Penting:

4. Mengolah Audio Stream dengan AudioWorklet dan Web Audio API

Memanipulasi audio stream juga memerlukan pendekatan yang efisien. Web Audio API adalah standar emas untuk pemrosesan audio di browser. Untuk pemrosesan audio kustom tingkat rendah yang berjalan di thread terpisah (sehingga tidak memblokir main thread), kita menggunakan AudioWorklet.

💡 AudioWorklet menyediakan cara untuk menjalankan kode JavaScript pemrosesan audio kustom di thread audio browser, memastikan latensi rendah dan performa yang stabil, bahkan di bawah beban komputasi tinggi.

Mari kita buat contoh sederhana untuk menambahkan efek gain (volume) kustom pada audio stream:

Pertama, buat file gain-processor.js (ini akan berjalan di AudioWorklet):

// gain-processor.js
class GainProcessor extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return [{
      name: 'gain',
      defaultValue: 1,
      minValue: 0,
      maxValue: 10,
      automationRate: 'a-rate' // Mengubah nilai per frame audio
    }];
  }

  process(inputs, outputs, parameters) {
    const input = inputs[0];
    const output = outputs[0];
    const gain = parameters.gain;

    // Iterasi per channel audio
    for (let channel = 0; channel < input.length; ++channel) {
      const inputChannel = input[channel];
      const outputChannel = output[channel];
      const currentGain = gain.length > 1 ? gain[channel] : gain[0]; // Ambil nilai gain per channel atau global

      // Terapkan gain ke setiap sampel audio
      for (let i = 0; i < inputChannel.length; ++i) {
        outputChannel[i] = inputChannel[i] * currentGain;
      }
    }
    return true; // Beri tahu bahwa prosesor masih aktif
  }
}

registerProcessor('gain-processor', GainProcessor);

Kemudian, di file JavaScript utama kamu:

async function applyCustomAudioEffect(originalAudioTrack, customGain = 2) {
  if (!('AudioContext' in window && 'AudioWorklet' in window)) {
    console.error('Browser tidak mendukung Web Audio API atau AudioWorklet.');
    return originalAudioTrack;
  }

  const audioContext = new AudioContext();

  // Tambahkan AudioWorklet module
  await audioContext.audioWorklet.addModule('gain-processor.js');

  // Buat sumber audio dari MediaStreamTrack
  const source = audioContext.createMediaStreamSource(new MediaStream([originalAudioTrack]));

  // Buat AudioWorkletNode kustom
  const gainWorkletNode = new AudioWorkletNode(audioContext, 'gain-processor');

  // Set parameter gain
  gainWorkletNode.parameters.get('gain').setValueAtTime(customGain, audioContext.currentTime);

  // Buat tujuan untuk audio stream yang sudah diproses
  const destination = audioContext.createMediaStreamDestination();

  // Hubungkan node: source -> worklet -> destination
  source.connect(gainWorkletNode);
  gainWorkletNode.connect(destination);

  // Kembalikan track audio yang sudah diproses
  return destination.stream.getAudioTracks()[0];
}

// Cara menggunakan:
// const originalStream = await getLocalMediaStream();
// const originalAudioTrack = originalStream.getAudioTracks()[0];
// const processedAudioTrack = await applyCustomAudioEffect(originalAudioTrack, 3); // Gain 3x

// Tampilkan track yang sudah diproses
// const processedStream = new MediaStream([originalStream.getVideoTracks()[0], processedAudioTrack]);
// document.getElementById('processedVideo').srcObject = processedStream; // Atau elemen video lain

// Jika ingin mengganti track di RTCPeerConnection:
// const sender = peerConnection.getSenders().find(s => s.track && s.track.kind === 'audio');
// if (sender) {
//   sender.replaceTrack(processedAudioTrack);
// }

✅ Dengan AudioWorklet, kamu bisa membuat pemrosesan audio yang sangat kompleks, seperti noise reduction, echo cancellation, atau bahkan synthesizer, semuanya berjalan di thread terpisah untuk kinerja optimal.

5. Mengintegrasikan Stream yang Dimanipulasi ke WebRTC

Setelah kita berhasil memanipulasi MediaStreamTrack (baik video maupun audio) dan mendapatkan MediaStreamTrack baru hasil olahan, langkah selanjutnya adalah mengintegrasikannya ke dalam koneksi WebRTC.

Ada dua skenario utama:

  1. Mengganti Track yang Sudah Ada: Ini adalah skenario paling umum jika kamu ingin menerapkan efek pada stream yang sudah aktif dalam panggilan.

    // Misalkan kamu sudah punya peerConnection yang aktif
    const peerConnection = new RTCPeerConnection();
    // ... setup peerConnection ...
    
    // Ambil sender untuk track video atau audio
    const videoSender = peerConnection.getSenders().find(sender => sender.track && sender.track.kind === 'video');
    const audioSender = peerConnection.getSenders().find(sender => sender.track && sender.track.kind === 'audio');
    
    // Asumsikan processedVideoTrack dan processedAudioTrack sudah kamu dapatkan dari langkah sebelumnya
    const processedVideoTrack = await applyGrayscaleFilter(originalStream.getVideoTracks()[0]);
    const processedAudioTrack = await applyCustomAudioEffect(originalStream.getAudioTracks()[0], 2);
    
    // Ganti track video
    if (videoSender) {
      await videoSender.replaceTrack(processedVideoTrack);
      console.log('Video track berhasil diganti dengan yang sudah diproses.');
    }
    
    // Ganti track audio
    if (audioSender) {
      await audioSender.replaceTrack(processedAudioTrack);
      console.log('Audio track berhasil diganti dengan yang sudah diproses.');
    }

    RTCRtpSender.replaceTrack() adalah metode yang efisien karena ia mengganti sumber data tanpa harus menegosiasikan ulang koneksi secara penuh.

  2. Menambahkan Track Baru: Jika kamu ingin menambahkan track yang dimanipulasi sebagai track tambahan (misalnya, stream kamera kedua yang sudah difilter), kamu bisa menggunakan addTrack().

    // Misalkan kamu ingin menambahkan track video yang sudah diproses sebagai stream terpisah
    peerConnection.addTrack(processedVideoTrack, new MediaStream([processedVideoTrack])); // Perlu MediaStream dummy untuk addTrack

💡 Tips Sinkronisasi: Saat memanipulasi video, sangat penting untuk menjaga timestamp frame agar video tetap sinkron dengan audionya. VideoFrame dari Web Codecs API memungkinkan kita mempertahankan timestamp asli atau membuat yang baru. Untuk memastikan rendering yang mulus di canvas, gunakan requestVideoFrameCallback atau requestAnimationFrame untuk menjadwalkan pemrosesan frame.

6. Tantangan dan Best Practices

Memanipulasi media stream secara real-time adalah tugas yang kompleks dan memiliki beberapa tantangan: