Skip to main content
Custom tracks let you send any MediaStreamTrack as an additional stream alongside your camera and microphone. Common use cases include:
  • Streaming additional camera angles (e.g., a document camera)
  • Broadcasting background music in stereo

Starting a custom track

startCustomTrack() takes a StartCustomTrackOptions object and returns a promise that resolves to the track name — which is used to identify the track in the participant’s track list and used for stopping it later.
const trackName = await call.startCustomTrack({
  track: myMediaStreamTrack,  // MediaStreamTrack — required
  trackName: 'doc-cam',       // optional; auto-generated if omitted
  mode: 'speech',             // optional audio mode hint
  ignoreAudioLevel: false,    // optional
});

console.log('Custom track started with name:', trackName);

StartCustomTrackOptions

track
MediaStreamTrack
required
The MediaStreamTrack to send. Can be audio or video.
trackName
string
A name for the track. Must be unique within the call. If omitted, Daily generates a UUID. The resolved promise value is the final track name.
Track names become part of the participant’s tracks object for the lifetime of the call. Choose descriptive, stable names — especially if remote participants need to subscribe to them by name.
mode
'music' | 'speech' | DailyMicAudioModeSettings
For audio tracks: sets the encoding mode. Use 'music' for music or wideband audio, 'speech' for voice. For fine-grained control, pass a DailyMicAudioModeSettings object.
ignoreAudioLevel
boolean
When true, this track’s audio level is excluded from the local audio level observer. Useful for background music that should not trigger speaking indicators.

DailyMicAudioModeSettings

bitrate
number
Target audio bitrate in bits per second.
stereo
boolean
When true, the track is encoded as stereo. Default: false.
// Send high-quality stereo music at 256 kbps
const trackName = await call.startCustomTrack({
  track: musicTrack,
  trackName: 'background-music',
  mode: { bitrate: 256_000, stereo: true },
  ignoreAudioLevel: true,
});

Stopping a custom track

await call.stopCustomTrack('doc-cam');
Passing the track name used in or returned by startCustomTrack() stops sending that track. The underlying MediaStreamTrack is not automatically stopped — call .stop() on it yourself if needed.
const trackName = await call.startCustomTrack({ track: videoTrack });

// Later:
await call.stopCustomTrack(trackName);
videoTrack.stop(); // release the underlying capture

Canvas-based custom video track

A common pattern is to draw content onto an HTML <canvas> element and stream it as a custom track:
async function startCanvasTrack(call) {
  const canvas  = document.createElement('canvas');
  canvas.width  = 1280;
  canvas.height = 720;
  const ctx = canvas.getContext('2d');

  // Use a Web Worker for the draw loop. Both requestAnimationFrame and
  // main-thread setInterval are throttled (or paused) when the sender's tab is
  // in the background, causing remote participants to see frozen or choppy
  // video. Web Workers run off the main thread and are not subject to the same
  // throttling.
  let frame = 0;
  const tickWorker = new Worker(
    URL.createObjectURL(
      new Blob(['setInterval(() => postMessage("tick"), 1000 / 30)'],
               { type: 'application/javascript' })
    )
  );

  tickWorker.onmessage = () => {
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    ctx.font = '48px monospace';
    ctx.fillStyle = '#e0e0e0';
    ctx.fillText(`Frame: ${frame++}`, 50, canvas.height / 2);
  };

  // Capture a 30 fps stream from the canvas
  const stream = canvas.captureStream(30);
  const [videoTrack] = stream.getVideoTracks();

  const trackName = await call.startCustomTrack({
    track: videoTrack,
    trackName: 'canvas-viz',
  });

  return {
    trackName,
    stop: () => {
      tickWorker.terminate();
      call.stopCustomTrack(trackName);
    },
  };
}

Receiving remote custom tracks

When subscribeToTracksAutomatically is enabled (the default), remote custom tracks are subscribed to automatically — no extra setup needed. Listen for track-started with the custom track name as type to render them. If you’ve disabled automatic subscriptions, use updateParticipant() with setSubscribedTracks to opt in. See the startCustomTrack() reference for the full custom subscription options.

Reading custom tracks from a participant

Custom tracks appear in the participant’s tracks object keyed by their track name:
const participants = call.participants();

for (const [id, participant] of Object.entries(participants)) {
  const customTrack = participant.tracks['canvas-viz'];
  if (customTrack?.state === 'playable') {
    const video = document.createElement('video');
    video.srcObject = new MediaStream([customTrack.track]);
    video.autoplay = true;
    document.getElementById('custom-tracks').appendChild(video);
  }
}
participant.tracks is typed as DailyParticipantTracks, which allows arbitrary string keys:
interface DailyParticipantTracks {
  audio: DailyTrackState;
  video: DailyTrackState;
  screenAudio: DailyTrackState;
  screenVideo: DailyTrackState;
  [customTrackKey: string]: DailyTrackState | undefined;
}

track-started and track-stopped events

Custom track events arrive on the standard track-started / track-stopped events. The type field holds the custom track name:
call.on('track-started', ({ participant, track, type }) => {
  // type is 'video' | 'audio' | 'screenVideo' | 'screenAudio' | <custom-name>
  if (type === 'canvas-viz') {
    console.log(
      `Custom track '${type}' started from`,
      participant?.user_name
    );

    const video = document.createElement('video');
    video.srcObject = new MediaStream([track]);
    video.autoplay = true;
    document.getElementById('custom-tracks').appendChild(video);
  }
});

call.on('track-stopped', ({ participant, type }) => {
  if (type === 'canvas-viz') {
    console.log(`Custom track '${type}' stopped.`);
    // Clean up your video element here
  }
});

Complete example

const call = Daily.createCallObject();

const joinBtn      = document.getElementById('join');
const leaveBtn     = document.getElementById('leave');
const startBtn     = document.getElementById('start-custom');
const stopBtn      = document.getElementById('stop-custom');

// --- Join / leave ---

joinBtn.onclick = async () => {
  await call.join({ url: 'https://your-domain.daily.co/room' });
  joinBtn.disabled  = true;
  leaveBtn.disabled = false;
  startBtn.disabled = false;
};

leaveBtn.onclick = async () => {
  if (customTrackName) {
    tickWorker.terminate();
    await call.stopCustomTrack(customTrackName);
    videoTrack.stop();
    customTrackName = null;
  }
  await call.leave();
  joinBtn.disabled  = false;
  leaveBtn.disabled = true;
  startBtn.disabled = true;
  stopBtn.disabled  = true;
  document.getElementById('custom-tracks').innerHTML = '';
};

// --- Sender side ---

const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 480;
const ctx = canvas.getContext('2d');

let angle = 0;

// Use a Web Worker for the draw loop so it isn't throttled when the sender's
// tab is in the background. Main-thread setInterval can drop to ~1 fps or
// worse in hidden tabs, causing remote participants to see frozen/choppy video.
const tickWorker = new Worker(
  URL.createObjectURL(
    new Blob(['setInterval(() => postMessage("tick"), 1000 / 30)'],
             { type: 'application/javascript' })
  )
);

tickWorker.onmessage = () => {
  ctx.clearRect(0, 0, 640, 480);
  ctx.fillStyle = '#0f3460';
  ctx.fillRect(0, 0, 640, 480);
  ctx.save();
  ctx.translate(320, 240);
  ctx.rotate(angle);
  ctx.fillStyle = '#e94560';
  ctx.fillRect(-60, -60, 120, 120);
  ctx.restore();
  angle += 0.02;
};

const [videoTrack] = canvas.captureStream(30).getVideoTracks();
let customTrackName;

startBtn.onclick = async () => {
  customTrackName = await call.startCustomTrack({
    track: videoTrack,
    trackName: 'spinning-box',
  });
  startBtn.disabled = true;
  stopBtn.disabled = false;
};

stopBtn.onclick = async () => {
  if (customTrackName) {
    await call.stopCustomTrack(customTrackName);
    customTrackName = null;
    startBtn.disabled = false;
    stopBtn.disabled = true;
  }
};

// --- Rendering all tracks (local and remote) ---
// With subscribeToTracksAutomatically (default), no subscription setup needed.

call.on('track-started', ({ participant, track, type }) => {
  if (type !== 'spinning-box') return;

  const existing = document.getElementById(`custom-${participant.session_id}`);
  if (existing) return;

  const video = document.createElement('video');
  video.id = `custom-${participant.session_id}`;
  video.srcObject = new MediaStream([track]);
  video.autoplay = true;
  document.getElementById('custom-tracks').appendChild(video);
});

call.on('track-stopped', ({ participant, type }) => {
  if (type === 'spinning-box') {
    document
      .getElementById(`custom-${participant?.session_id}`)
      ?.remove();
  }
});