> ## Documentation Index
> Fetch the complete documentation index at: https://docs.daily.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Tracks

> Send and receive additional MediaStreamTracks beyond camera and microphone — including canvas-based video, pre-processed audio, and multiple simultaneous media streams.

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.

```javascript theme={null}
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`

<ParamField path="track" type="MediaStreamTrack" required>
  The `MediaStreamTrack` to send. Can be audio or video.
</ParamField>

<ParamField path="trackName" type="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.
</ParamField>

<Tip>
  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.
</Tip>

<ParamField path="mode" type="'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.
</ParamField>

<ParamField path="ignoreAudioLevel" type="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.
</ParamField>

### `DailyMicAudioModeSettings`

<ParamField path="bitrate" type="number">
  Target audio bitrate in bits per second.
</ParamField>

<ParamField path="stereo" type="boolean">
  When `true`, the track is encoded as stereo. Default: `false`.
</ParamField>

```javascript theme={null}
// 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

```javascript theme={null}
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.

```javascript theme={null}
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:

```javascript theme={null}
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](/reference/daily-js/instance-methods/start-custom-track) 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:

```javascript theme={null}
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:

```typescript theme={null}
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:

```javascript theme={null}
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

<CodeGroup>
  ```javascript app.js theme={null}
  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();
    }
  });
  ```

  ```html index.html theme={null}
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Tracks Demo</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div class="controls">
      <button id="join">Join</button>
      <button id="leave" disabled>Leave</button>
      <button id="start-custom" disabled>Start custom track</button>
      <button id="stop-custom" disabled>Stop custom track</button>
    </div>
    <div id="custom-tracks" class="track-grid"></div>
    <script src="https://unpkg.com/@daily-co/daily-js"></script>
    <script type="module" src="app.js"></script>
  </body>
  </html>
  ```

  ```css styles.css theme={null}
  body {
    font-family: sans-serif;
    background: #0f0f1a;
    color: #e0e0e0;
    margin: 0;
    padding: 1rem;
  }

  .controls {
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1rem;
  }

  button {
    padding: 0.5rem 1rem;
    background: #e94560;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  button:disabled {
    opacity: 0.4;
    cursor: not-allowed;
  }

  .track-grid {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
  }

  .track-grid video {
    width: 320px;
    height: 240px;
    background: #1a1a2e;
    border-radius: 4px;
  }
  ```
</CodeGroup>
