Files
element-call-Github/sdk
fkwpandClaude Opus 5.5 ef68e1347e Attach background effects through one switchable pipeline
- One pipeline, shared by the pre-join preview and the call, attached the
  first time an effect is wanted and never detached after; turning blur
  off switches it to a disabled mode in place. Main dropped the processor
  whenever blur went off, and a pipeline taken off the camera is destroyed
  and re-primed, so each time blur came back one unprocessed frame went
  out. Built once, the priming frame is spent once.
- Still driven by the existing blur setting: nothing a user sees changes.
- Its logic is BackgroundEffects in src/state/, which the provider only
  builds, so the tests drive it without rendering anything.
- Disabled drops the blur radius too. The SDK's disabled mode keeps it,
  and with a radius every frame is still segmented and thrown away; with
  neither a radius nor a picture, frames pass untouched.
- Switches run one at a time and skip any overtaken, so an earlier,
  slower switch can't land after a later choice.
- Builds, rebuilds and teardowns of the pipeline run one at a time
  (OneStepPipeline). The preview's and the call's tracks attach and stop
  it independently, and the SDK's wrapper holds one track's streams: two
  builds at once overwrite each other's, and a stop during a build does
  nothing.
- Every camera track starts with the pipeline on: the room's capture
  defaults follow it, and a camera turned off before the first effect is
  replaced on turning it on, since a stopped track can't take one. One
  attached after publishing lets the room through while it is built.
- BlurBackgroundTransformer becomes BackgroundEffectTransformer: it also
  sits disabled, and its init applies that state as the SDK's would.

Rebased onto main, where src/livekit moved to src/tracks and publishing
moved into the SDK:
- Publisher.ts is gone. Starting each camera track with the pipeline is now
  the processor on the camera publish request, at the join and on turning
  the camera on (CallViewModel). Replacing a camera turned off before the
  first effect is done in the camera's mute handler, which unpublishes and
  publishes it anew; its tests moved to CallViewModel.test.ts. The SDK's
  publisher, starting the microphone and camera in one call, takes the
  requests' options into the room's capture defaults, since a camera turned
  on before the start would otherwise come up without the processor.
- The SDK gives one VideoProcessor one LiveKit adapter, and setProcessor
  leaves a track alone that already runs it, so a track made with the
  pipeline isn't torn down when the call sets it again.
- BackgroundEffects wraps the pipeline once with fromLivekitProcessor, so the
  call and preview see one VideoProcessor throughout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 18:19:44 +01:00
..

MatrixRTC SDK (EXPERIMENTAL)

@element-hq/matrixrtc-sdk is the call model under Element Call, on its own: it joins a MatrixRTC slot in a Matrix room, writes and watches the memberships, connects to the media transport (LiveKit today), publishes the local media and exposes every member and their tracks as observables. It has no UI. Element Call's own CallViewModel is one consumer of it; any host can build a different view on top. The design is in SdkArchitecture.md, what moved out of Element Call's src/ in SdkMigration.md.

Status: first implementation. createRTCSlot and slot.join() join the session, connect to the transport, publish the local media and expose every member's media; the development harness and its e2e tests in playwright/sdk drive it, and Element Call's own CallViewModel is built on it. It depends on nothing in Element Call's src/.

Using it

import {
  createRTCSlot,
  E2eeType,
  MatrixRTCMode,
  ObservableScope,
  trackBySource$,
} from "@element-hq/matrixrtc-sdk";

const scope = new ObservableScope();
const slot = createRTCSlot(
  scope,
  client, // a matrix-js-sdk MatrixClient, logged in and syncing
  room, // the matrix-js-sdk Room, from client.getRoom() once the join has synced
  {
    encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
    matrixRTCMode: MatrixRTCMode.Compatibility,
  },
);
const participation = slot.join({
  publish: [{ source: "microphone" }, { source: "camera" }],
});

// A <video> for each remote member's camera and an <audio> for its microphone.
// A track plays on an element once attached to it; detach before the element
// leaves the DOM. (`tileFor`, `setName` and `removeTilesExcept` are the host's.)
participation.remoteMembers$.subscribe((members) => {
  for (const member of members) {
    const { video, audio } = tileFor(member.memberId);
    member.displayName$.subscribe((name) => setName(member.memberId, name));
    trackBySource$(scope, member.tracks$, "camera").subscribe((track) =>
      track?.attach(video),
    );
    trackBySource$(scope, member.tracks$, "microphone").subscribe((track) =>
      track?.attach(audio),
    );
  }
  removeTilesExcept(members.map((m) => m.memberId));
});

// later
participation.leave();
scope.end();

participation.localMember$ is one member of the same shape, null until our own membership is in the room; its microphone would only echo.

dev/main.ts is this example as a working page.

Reading the code

Three files define the surface. Read them in this order.

  • src/api.ts: the MatrixRTC side. An RTCSlot is the namespace in a room that memberships join into; join() returns an RTCParticipation, which lasts until its leave() and exposes state$, localMember$ and remoteMembers$. Members, transports and the participation state machine live here. createRTCSlot in RTCSlot.ts is the only way to get an implementation.
  • src/media-api.ts: media as a host sees it, on remoteMembers$ and localMember$. PublishRequest says what to publish (microphone, camera, screen share); MediaTrack is how it arrives. The underlying tracks stay hidden: the host hands an element to attach and the SDK plays into it. The local variants carry the controls (setEnabled, setDevice, setProcessor).
  • src/media-backend/api.ts: internal, the seam between the session and whatever carries the media. The session speaks MatrixRTC; a MediaBackend speaks one media protocol and hands back the MemberMedia of each member through mediaFor(). A host rarely touches it; it matters when working on the LiveKit backend under src/media-backend/livekit/, the only place that imports livekit-client, or when writing a new backend.

Development

Everything runs from the repository root. This directory is a pnpm project of its own only so that a host can install it as a git dependency (see pnpm-workspace.yaml).

pnpm dev:sdk              # the harness, https://localhost:3002
pnpm build:sdk            # dist/matrixrtc-sdk.js and dist/types
pnpm test:playwright playwright/sdk   # needs `pnpm backend`

Installing

pnpm add github:element-hq/element-call#<ref>&path:/sdk

The host supplies matrix-js-sdk, livekit-client and rxjs; the build leaves them external.