Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/sdk/src/realtime/browser/prepare-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const prepareBrowserConnection: PrepareConnection = ({
createMediaChannel: (config) =>
createLiveKitMediaChannel({
...config,
publishFps: fps,
createFrameMetadataWorker: frameTiming ? takeFrameMetadataWorker : undefined,
}),
dispose: () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk/src/realtime/config-realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ export const REALTIME_CONFIG = {
defaultMaxVideoBitrateBps: 3_500_000,
vp9MaxVideoBitrateBps: 3_000_000,
defaultPublishFps: 30,
/**
* Low-fps uplink bitrate scaling (measured 2026-07-28, api repo
* experiments/2026-07-28-livekit-size-vs-interval-jb). The encoder spends
* its per-SECOND budget regardless of cadence, so a 10 fps publisher packs
* ~3x the bytes into every frame; the frames' wire-arrival span then
* inflates the server's ingest jitter buffer by ~35-100 ms. Scaling the
* cap to ~200 kbit per frame restores 30 fps-parity ingest latency at
* equal-or-better QP (measured: 10 fps hold 129.7 -> 28.4 ms, e2e
* 660 -> 557 ms from us-east4). At >=15 fps the natural rate is already
* optimal and a binding cap measurably hurts (rate-controller churn), so
* scaling only engages BELOW the threshold; the floor keeps the encoder
* far from the simulcast layer-starvation cliff.
*/
lowFpsBitrateScaleBelowFps: 15,
lowFpsBitsPerFrame: 200_000,
lowFpsMinBitrateBps: 1_000_000,
},
observability: {
stallFpsThreshold: 0.5,
Expand Down
20 changes: 18 additions & 2 deletions packages/sdk/src/realtime/media-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,26 @@ export function getDefaultVideoPublishOptions(
source: TrackPublishOptions["source"],
videoCodec?: VideoCodec,
frameMetadata = false,
publishFps?: number,
): TrackPublishOptions {
const resolvedCodec = videoCodec ?? REALTIME_CONFIG.livekit.defaultVideoCodec;
const maxBitrate =
const naturalBitrate =
resolvedCodec === "vp9"
? REALTIME_CONFIG.livekit.vp9MaxVideoBitrateBps
: REALTIME_CONFIG.livekit.defaultMaxVideoBitrateBps;
// The encoder spends its per-second budget regardless of cadence, so at low
// publish rates a fixed cap packs into oversized frames whose wire-arrival
// span inflates the server's ingest jitter buffer. Scale the cap to
// ~constant bits-per-frame below the threshold; at >=15 fps the natural
// rate is optimal and a binding cap hurts, so leave it untouched.
const fps = publishFps ?? REALTIME_CONFIG.livekit.defaultPublishFps;
const maxBitrate =
fps < REALTIME_CONFIG.livekit.lowFpsBitrateScaleBelowFps
? Math.max(
REALTIME_CONFIG.livekit.lowFpsMinBitrateBps,
Math.min(naturalBitrate, Math.round(fps * REALTIME_CONFIG.livekit.lowFpsBitsPerFrame)),
)
: naturalBitrate;

return {
source,
Expand All @@ -48,6 +62,8 @@ export interface MediaChannelConfig {
localStream: MediaStream | null;
logger?: Logger;
videoCodec?: VideoCodec;
/** Intended publish cadence (fps) — scales the uplink maxBitrate below 15 fps. */
publishFps?: number;
createFrameMetadataWorker?: () => Worker;
}

Expand Down Expand Up @@ -192,7 +208,7 @@ export class LiveKitMediaChannel implements MediaChannel {
if (!this.cameraTrackSource) throw new Error("Cannot publish video track: media channel is not connected");
await this.room.localParticipant.publishTrack(
track,
getDefaultVideoPublishOptions(this.cameraTrackSource, this.config.videoCodec, this.frameMetadataEnabled),
getDefaultVideoPublishOptions(this.cameraTrackSource, this.config.videoCodec, this.frameMetadataEnabled, this.config.publishFps),
);
} else {
await this.room.localParticipant.publishTrack(track);
Expand Down
39 changes: 39 additions & 0 deletions packages/sdk/tests/publish-options.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";

import { REALTIME_CONFIG } from "../src/realtime/config-realtime";
import { getDefaultVideoPublishOptions } from "../src/realtime/media-channel";

const NATURAL_H264 = REALTIME_CONFIG.livekit.defaultMaxVideoBitrateBps;
const NATURAL_VP9 = REALTIME_CONFIG.livekit.vp9MaxVideoBitrateBps;

describe("getDefaultVideoPublishOptions low-fps bitrate scaling", () => {
it("keeps the natural bitrate at the default 30 fps", () => {
const opts = getDefaultVideoPublishOptions("camera", "h264", false, 30);
expect(opts.videoEncoding?.maxBitrate).toBe(NATURAL_H264);
});

it("keeps the natural bitrate when fps is omitted (registry models)", () => {
const opts = getDefaultVideoPublishOptions("camera", "h264", false);
expect(opts.videoEncoding?.maxBitrate).toBe(NATURAL_H264);
});

it("does NOT engage at 15 fps (binding caps measurably hurt at >=15 fps)", () => {
const opts = getDefaultVideoPublishOptions("camera", "h264", false, 15);
expect(opts.videoEncoding?.maxBitrate).toBe(NATURAL_H264);
});

it("scales to ~200 kbit/frame at 10 fps", () => {
const opts = getDefaultVideoPublishOptions("camera", "h264", false, 10);
expect(opts.videoEncoding?.maxBitrate).toBe(10 * REALTIME_CONFIG.livekit.lowFpsBitsPerFrame);
});

it("never drops below the viability floor at very low fps", () => {
const opts = getDefaultVideoPublishOptions("camera", "h264", false, 2);
expect(opts.videoEncoding?.maxBitrate).toBe(REALTIME_CONFIG.livekit.lowFpsMinBitrateBps);
});

it("never exceeds the codec's natural bitrate", () => {
const opts = getDefaultVideoPublishOptions("camera", "vp9", false, 14.9);
expect(opts.videoEncoding?.maxBitrate).toBeLessThanOrEqual(NATURAL_VP9);
});
});
Loading