From 0fce0c9525091458371bc7b7ae6d55524716c1e6 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Mon, 14 Sep 2026 14:24:13 +0200 Subject: [PATCH 01/10] Cap video bitrate and report it to the server --- packages/react-client/src/FishjamProvider.tsx | 3 +- packages/react-client/src/types/public.ts | 2 +- .../react-client/src/utils/bandwidth.test.ts | 19 +++ packages/react-client/src/utils/bandwidth.ts | 6 +- packages/webrtc-client/src/bitrate.ts | 63 +++++++- packages/webrtc-client/src/index.ts | 1 + packages/webrtc-client/src/tracks/Local.ts | 34 ++--- packages/webrtc-client/src/tracks/bitrates.ts | 21 +++ packages/webrtc-client/src/types.ts | 5 +- packages/webrtc-client/src/webRTCEndpoint.ts | 12 +- .../tests/methods/addTrackMethod.test.ts | 33 +++++ .../tests/methods/bandwidthLimits.test.ts | 135 ++++++++++++++++++ 12 files changed, 295 insertions(+), 39 deletions(-) create mode 100644 packages/react-client/src/utils/bandwidth.test.ts create mode 100644 packages/webrtc-client/src/tracks/bitrates.ts create mode 100644 packages/webrtc-client/tests/methods/bandwidthLimits.test.ts diff --git a/packages/react-client/src/FishjamProvider.tsx b/packages/react-client/src/FishjamProvider.tsx index f5559afe9..a2c710078 100644 --- a/packages/react-client/src/FishjamProvider.tsx +++ b/packages/react-client/src/FishjamProvider.tsx @@ -42,7 +42,8 @@ export interface FishjamProviderProps extends PropsWithChildren { */ persistLastDevice?: boolean | PersistLastDeviceHandlers; /** - * Adjust max bandwidth limit for a single stream and simulcast. + * Adjust max bandwidth limit (in kbps) for a single stream and each simulcast layer. + * Missing values default to `MAX_BANDWIDTH_LIMITS`; values above the cap are clamped to it. */ bandwidthLimits?: Partial; /** diff --git a/packages/react-client/src/types/public.ts b/packages/react-client/src/types/public.ts index 576558475..123f89514 100644 --- a/packages/react-client/src/types/public.ts +++ b/packages/react-client/src/types/public.ts @@ -70,7 +70,7 @@ export type SimulcastBandwidthLimits = { export type StreamConfig = { sentQualities?: Variant[] | false }; -export type BandwidthLimits = { singleStream: number; simulcast: SimulcastBandwidthLimits }; +export type BandwidthLimits = { singleStream: number; simulcast: Partial }; export type DeviceType = "audio" | "video"; diff --git a/packages/react-client/src/utils/bandwidth.test.ts b/packages/react-client/src/utils/bandwidth.test.ts new file mode 100644 index 000000000..7d923c8ec --- /dev/null +++ b/packages/react-client/src/utils/bandwidth.test.ts @@ -0,0 +1,19 @@ +import { MAX_BANDWIDTH_LIMITS, Variant } from "@fishjam-cloud/ts-client"; +import { expect, it } from "vitest"; + +import { mergeWithDefaultBandwitdthLimits } from "./bandwidth"; + +it("defaults every limit to the cap", () => { + expect(mergeWithDefaultBandwitdthLimits()).toEqual({ + singleStream: MAX_BANDWIDTH_LIMITS.singleStream, + simulcast: MAX_BANDWIDTH_LIMITS.simulcast, + }); +}); + +it("keeps user values and fills the missing simulcast layers with the cap", () => { + const merged = mergeWithDefaultBandwitdthLimits({ singleStream: 800, simulcast: { [Variant.VARIANT_HIGH]: 1000 } }); + + expect(merged.singleStream).toBe(800); + expect(merged.simulcast[Variant.VARIANT_HIGH]).toBe(1000); + expect(merged.simulcast[Variant.VARIANT_LOW]).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); +}); diff --git a/packages/react-client/src/utils/bandwidth.ts b/packages/react-client/src/utils/bandwidth.ts index 6877946e0..5b62dee55 100644 --- a/packages/react-client/src/utils/bandwidth.ts +++ b/packages/react-client/src/utils/bandwidth.ts @@ -1,10 +1,10 @@ -import { Variant } from "@fishjam-cloud/ts-client"; +import { MAX_BANDWIDTH_LIMITS, Variant } from "@fishjam-cloud/ts-client"; import type { BandwidthLimits } from "../types/public"; export const ALL_VARIANTS_SIMULCAST = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; export const mergeWithDefaultBandwitdthLimits = (limits?: Partial): BandwidthLimits => ({ - singleStream: limits?.singleStream ?? 0, - simulcast: limits?.simulcast ?? { [Variant.VARIANT_LOW]: 0, [Variant.VARIANT_MEDIUM]: 0, [Variant.VARIANT_HIGH]: 0 }, + singleStream: limits?.singleStream ?? MAX_BANDWIDTH_LIMITS.singleStream, + simulcast: { ...MAX_BANDWIDTH_LIMITS.simulcast, ...limits?.simulcast }, }); diff --git a/packages/webrtc-client/src/bitrate.ts b/packages/webrtc-client/src/bitrate.ts index 732b031a8..70530e752 100644 --- a/packages/webrtc-client/src/bitrate.ts +++ b/packages/webrtc-client/src/bitrate.ts @@ -1,25 +1,76 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; -import { type BandwidthLimit } from './types'; +import { type BandwidthLimit, type SimulcastBandwidthLimit, type TrackBandwidthLimit } from './types'; export type Bitrate = number; export type Bitrates = Record | Bitrate; +/** + * Hard caps (in kbps) for the bitrate of an outgoing video track. + * A limit passed to the SDK is never allowed to exceed these values, and an unset (0) limit resolves to them. + */ +export const MAX_BANDWIDTH_LIMITS = { + singleStream: 1500 as BandwidthLimit, + simulcast: { + [Variant.VARIANT_LOW]: 150 as BandwidthLimit, + [Variant.VARIANT_MEDIUM]: 500 as BandwidthLimit, + [Variant.VARIANT_HIGH]: 1500 as BandwidthLimit, + }, +} as const; + +const SIMULCAST_VARIANTS = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; + +type SimulcastVariant = (typeof SIMULCAST_VARIANTS)[number]; + +const isSimulcastVariant = (variant: Variant): variant is SimulcastVariant => + (SIMULCAST_VARIANTS as readonly Variant[]).includes(variant); + +const resolveAgainstCap = (limit: BandwidthLimit | undefined, cap: BandwidthLimit, label: string): BandwidthLimit => { + if (!limit || limit <= 0) return cap; + if (limit > cap) { + console.warn(`[Fishjam] ${label} bandwidth limit ${limit} kbps exceeds the cap of ${cap} kbps, using the cap`); + return cap; + } + return limit; +}; + +/** + * Resolves a user-provided video bandwidth limit against {@link MAX_BANDWIDTH_LIMITS}. + * - a number is a single-stream limit; 0 or negative means "use the cap", higher values are clamped to it + * - a Map holds per-variant simulcast limits; each variant is resolved the same way + */ +export const resolveBandwidthLimit = (limit: TrackBandwidthLimit): TrackBandwidthLimit => { + if (typeof limit === 'number') return resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.singleStream, 'single stream'); + + const resolved: SimulcastBandwidthLimit = new Map(); + for (const variant of SIMULCAST_VARIANTS) { + resolved.set(variant, resolveAgainstCap(limit.get(variant), MAX_BANDWIDTH_LIMITS.simulcast[variant], Variant[variant])); + } + return resolved; +}; + +export const resolveVariantBandwidthLimit = (variant: Variant, limit: BandwidthLimit): BandwidthLimit => { + const cap = isSimulcastVariant(variant) ? MAX_BANDWIDTH_LIMITS.simulcast[variant] : MAX_BANDWIDTH_LIMITS.singleStream; + return resolveAgainstCap(limit, cap, Variant[variant]); +}; + +export const kbpsToBps = (kbps: BandwidthLimit): Bitrate => kbps * 1024; + // The suggested bitrate values are based on our internal tests. export const defaultBitrates = { audio: 50_000 as Bitrate, - video: 2_500_000 as Bitrate, + video: kbpsToBps(MAX_BANDWIDTH_LIMITS.singleStream), } as const; export const UNLIMITED_BANDWIDTH: Bitrate = 0 as Bitrate; // The suggested bitrate values are based on our internal tests. export const defaultSimulcastBitrates: { - [key in Variant]: BandwidthLimit; + [key in Variant]: Bitrate; } = { - [Variant.VARIANT_HIGH]: 2_500_000, - [Variant.VARIANT_MEDIUM]: 500_000, - [Variant.VARIANT_LOW]: 150_000, + [Variant.VARIANT_HIGH]: kbpsToBps(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_HIGH]), + [Variant.VARIANT_MEDIUM]: kbpsToBps(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_MEDIUM]), + [Variant.VARIANT_LOW]: kbpsToBps(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]), [Variant.VARIANT_UNSPECIFIED]: 0, [Variant.UNRECOGNIZED]: 0, }; diff --git a/packages/webrtc-client/src/index.ts b/packages/webrtc-client/src/index.ts index 5cb9016ff..e809c1f6e 100644 --- a/packages/webrtc-client/src/index.ts +++ b/packages/webrtc-client/src/index.ts @@ -1,3 +1,4 @@ +export { MAX_BANDWIDTH_LIMITS,resolveBandwidthLimit } from './bitrate'; export { getLogger } from './logger'; export type { MediaEvent, SerializedMediaEvent } from './mediaEvent'; export type { diff --git a/packages/webrtc-client/src/tracks/Local.ts b/packages/webrtc-client/src/tracks/Local.ts index 83a4b488c..6eee0d7d3 100644 --- a/packages/webrtc-client/src/tracks/Local.ts +++ b/packages/webrtc-client/src/tracks/Local.ts @@ -1,4 +1,3 @@ -import type { MediaEvent_VariantBitrate } from '@fishjam-cloud/protobufs/peer'; import { MediaEvent as PeerMediaEvent, MediaEvent_DisableTrackVariant, @@ -10,7 +9,7 @@ import { MediaEvent_UpdateTrackMetadata, } from '@fishjam-cloud/protobufs/peer'; import { type MediaEvent_Track_SimulcastConfig } from '@fishjam-cloud/protobufs/server'; -import { Variant } from '@fishjam-cloud/protobufs/shared'; +import type { Variant } from '@fishjam-cloud/protobufs/shared'; import type { ConnectionManager } from '../ConnectionManager'; import type { EndpointWithTrackContext } from '../internal'; @@ -25,6 +24,7 @@ import type { WebRTCEndpointEvents, } from '../types'; import type { WebRTCEndpoint } from '../webRTCEndpoint'; +import { getVariantBitrates } from './bitrates'; import { LocalTrack } from './LocalTrack'; import type { EndpointId, TrackId } from './TrackCommon'; @@ -156,16 +156,15 @@ export class Local { }; public setTrackBandwidth = async (trackId: string, bandwidth: BandwidthLimit): Promise => { - // FIXME: maxBandwidth in TrackContext is not updated - const trackManager = this.localTracks[trackId]; if (!trackManager) throw new Error(`Cannot find ${trackId}`); await trackManager.setTrackBandwidth(bandwidth); + trackManager.trackContext.maxBandwidth = bandwidth; const trackBitrates = MediaEvent_TrackBitrates.create({ trackId, - variantBitrates: [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: bandwidth }], + variantBitrates: getVariantBitrates(trackManager.trackContext), }); this.sendMediaEvent(PeerMediaEvent.create({ trackBitrates })); this.emit('localTrackBandwidthSet', { @@ -191,22 +190,12 @@ export class Local { await trackManager.setEncodingBandwidth(rid, bandwidth); - const bitrates = trackManager.getTrackBitrates(); - - let variantBitrates: MediaEvent_VariantBitrate[] = []; - - if (typeof bitrates === 'number') { - variantBitrates = [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: bitrates }]; - } else if (bitrates) { - variantBitrates = Object.entries(bitrates).map(([variant, bitrate]) => ({ - variant: Number(variant) as Variant, - bitrate, - })); - } + const { maxBandwidth } = trackManager.trackContext; + if (maxBandwidth instanceof Map) maxBandwidth.set(rid, bandwidth); const trackBitrates = MediaEvent_TrackBitrates.create({ trackId, - variantBitrates, + variantBitrates: getVariantBitrates(trackManager.trackContext), }); this.sendMediaEvent(PeerMediaEvent.create({ trackBitrates })); @@ -298,9 +287,14 @@ export class Local { {}, ); - // TODO add bitrates private getTrackIdToTrackBitrates = (): Record => - Object.values(this.localTracks).reduce((acc, { id }) => ({ ...acc, [id]: { bitrate: 1_500_000 } }), {}); + Object.values(this.localTracks).reduce( + (acc, { id, trackContext }) => ({ + ...acc, + [id]: MediaEvent_TrackBitrates.create({ trackId: id, variantBitrates: getVariantBitrates(trackContext) }), + }), + {}, + ); private getMidToTrackId = (): Record => { if (!this.connection) return {}; diff --git a/packages/webrtc-client/src/tracks/bitrates.ts b/packages/webrtc-client/src/tracks/bitrates.ts new file mode 100644 index 000000000..b341abdbd --- /dev/null +++ b/packages/webrtc-client/src/tracks/bitrates.ts @@ -0,0 +1,21 @@ +import type { MediaEvent_VariantBitrate } from '@fishjam-cloud/protobufs/peer'; +import { Variant } from '@fishjam-cloud/protobufs/shared'; + +import { defaultBitrates, kbpsToBps } from '../bitrate'; +import type { TrackContextImpl } from '../internal'; + +/** + * Builds the per-variant bitrates (in bps) reported to the server for a local track, + * derived from the limits the encoder was configured with. + */ +export const getVariantBitrates = (trackContext: TrackContextImpl): MediaEvent_VariantBitrate[] => { + const kind = trackContext.track?.kind ?? trackContext.trackKind; + if (kind === 'audio') return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: defaultBitrates.audio }]; + + const { maxBandwidth } = trackContext; + if (typeof maxBandwidth === 'number') { + return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: kbpsToBps(maxBandwidth) || defaultBitrates.video }]; + } + + return [...maxBandwidth.entries()].map(([variant, limit]) => ({ variant, bitrate: kbpsToBps(limit) })); +}; diff --git a/packages/webrtc-client/src/types.ts b/packages/webrtc-client/src/types.ts index f2ddec4dc..5b0e593f9 100644 --- a/packages/webrtc-client/src/types.ts +++ b/packages/webrtc-client/src/types.ts @@ -22,14 +22,15 @@ export type TrackKind = 'audio' | 'video'; export type VadStatus = 'speech' | 'silence'; /** - * Type describing maximal bandwidth that can be used, in kbps. 0 is interpreted as unlimited bandwidth. + * Type describing maximal bandwidth that can be used, in kbps. + * Video limits are clamped to `MAX_BANDWIDTH_LIMITS`; 0 is interpreted as "use the cap". */ export type BandwidthLimit = number; /** * Type describing bandwidth limit for simulcast track. * It is a mapping (encoding => BandwidthLimit). - * If encoding isn't present in this mapping, it will be assumed that this particular encoding shouldn't have any bandwidth limit + * If encoding isn't present in this mapping, the cap from `MAX_BANDWIDTH_LIMITS.simulcast` is used for it. */ export type SimulcastBandwidthLimit = Map; diff --git a/packages/webrtc-client/src/webRTCEndpoint.ts b/packages/webrtc-client/src/webRTCEndpoint.ts index 3b889f526..d2d5b2ded 100644 --- a/packages/webrtc-client/src/webRTCEndpoint.ts +++ b/packages/webrtc-client/src/webRTCEndpoint.ts @@ -18,6 +18,7 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { v4 as uuidv4 } from 'uuid'; +import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from './bitrate'; import { CommandsQueue } from './CommandsQueue'; import { ConnectionManager } from './ConnectionManager'; import { DataChannelManager } from './dataChannels/DataChannelManager'; @@ -427,8 +428,7 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter 0 ? maxBandwidth : 0; + const resolvedMaxBandwidth: TrackBandwidthLimit = track.kind === 'video' ? resolveBandwidthLimit(maxBandwidth) : 0; try { if (!stream) trackStream.addTrack(track); @@ -529,13 +529,13 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter} success */ public setTrackBandwidth(trackId: string, bandwidth: BandwidthLimit): Promise { if (!this.connectionManager) throw new Error(`There is no active RTCPeerConnection`); - return this.local.setTrackBandwidth(trackId, bandwidth); + return this.local.setTrackBandwidth(trackId, resolveBandwidthLimit(bandwidth) as BandwidthLimit); } /** @@ -543,13 +543,13 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter { if (!this.connectionManager) throw new Error(`There is no active RTCPeerConnection`); - return await this.local.setEncodingBandwidth(trackId, rid, bandwidth); + return await this.local.setEncodingBandwidth(trackId, rid, resolveVariantBandwidthLimit(rid, bandwidth)); } /** diff --git a/packages/webrtc-client/tests/methods/addTrackMethod.test.ts b/packages/webrtc-client/tests/methods/addTrackMethod.test.ts index e134aa822..8b60a821a 100644 --- a/packages/webrtc-client/tests/methods/addTrackMethod.test.ts +++ b/packages/webrtc-client/tests/methods/addTrackMethod.test.ts @@ -68,6 +68,39 @@ it('Simulcast transceiver config includes the stream', () => { expect(config.streams).toEqual([stream]); }); +it('Adding a simulcast track keeps the per-variant bandwidth limits', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + + const webRTCEndpoint = new WebRTCEndpoint(); + const serializedEvent = serializeServerMediaEvent({ connected: createConnectedEventWithOneEndpoint() }); + await webRTCEndpoint.receiveMediaEvent(serializedEvent); + + const limits = new Map([ + [Variant.VARIANT_LOW, 150], + [Variant.VARIANT_MEDIUM, 500], + [Variant.VARIANT_HIGH, 1500], + ]); + + webRTCEndpoint.addTrack( + mockTrack, + undefined, + { + enabled: true, + enabledVariants: [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH], + disabledVariants: [], + }, + limits, + ); + + const [trackContext] = [...webRTCEndpoint['local'].getTrackIdToTrack().values()]; + expect(trackContext!.maxBandwidth).toEqual(limits); + + const config = createTransceiverConfig(trackContext!); + const high = config.sendEncodings!.find((encoding) => encoding.rid === 'h'); + expect(high?.maxBitrate).toBe(1500 * 1024); +}); + it('Adding track before being accepted by the server throws error', async () => { // Given mockRTCPeerConnection(); diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts new file mode 100644 index 000000000..7ac275a4e --- /dev/null +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -0,0 +1,135 @@ +import { FakeMediaStreamTrack } from 'fake-mediastreamtrack'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { MAX_BANDWIDTH_LIMITS, Variant, WebRTCEndpoint } from '../../src'; +import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from '../../src/bitrate'; +import { deserializePeerMediaEvent, serializeServerMediaEvent } from '../../src/mediaEvent'; +import { createAddLocalTrackSDPOffer, createConnectedEventWithOneEndpoint } from '../fixtures'; +import { mockMediaStream, mockRTCPeerConnection } from '../mocks'; + +const KBPS = 1024; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it('resolveBandwidthLimit resolves 0 to the single-stream cap', () => { + expect(resolveBandwidthLimit(0)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); + expect(resolveBandwidthLimit(-5)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); +}); + +it('resolveBandwidthLimit keeps a single-stream value below the cap', () => { + expect(resolveBandwidthLimit(800)).toBe(800); +}); + +it('resolveBandwidthLimit lowers a single-stream value above the cap and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(resolveBandwidthLimit(8000)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); + expect(warn).toHaveBeenCalledOnce(); +}); + +it('resolveBandwidthLimit fills missing simulcast variants with caps and clamps the rest', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const limits = new Map([ + [Variant.VARIANT_MEDIUM, 300], + [Variant.VARIANT_HIGH, 9000], + ]); + + const resolved = resolveBandwidthLimit(limits) as Map; + + expect(resolved.get(Variant.VARIANT_LOW)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); + expect(resolved.get(Variant.VARIANT_MEDIUM)).toBe(300); + expect(resolved.get(Variant.VARIANT_HIGH)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_HIGH]); +}); + +it('resolveVariantBandwidthLimit uses the cap of the given variant', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(resolveVariantBandwidthLimit(Variant.VARIANT_LOW, 1000)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); + expect(resolveVariantBandwidthLimit(Variant.VARIANT_HIGH, 1000)).toBe(1000); +}); + +const connect = async () => { + const webRTCEndpoint = new WebRTCEndpoint(); + await webRTCEndpoint.receiveMediaEvent( + serializeServerMediaEvent({ connected: createConnectedEventWithOneEndpoint() }), + ); + + const offers: ReturnType['sdpOffer'][] = []; + webRTCEndpoint.on('sendMediaEvent', (mediaEvent) => { + const event = deserializePeerMediaEvent(mediaEvent); + if (event.sdpOffer) offers.push(event.sdpOffer); + }); + + return { webRTCEndpoint, offers }; +}; + +const trackContexts = (webRTCEndpoint: WebRTCEndpoint) => [...webRTCEndpoint['local'].getTrackIdToTrack().values()]; + +it('addTrack without a limit uses the single-stream cap for video and no limit for audio', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint } = await connect(); + + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' })); + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'audio' })); + await webRTCEndpoint.receiveMediaEvent(serializeServerMediaEvent({ offerData: createAddLocalTrackSDPOffer() })); + + const [video, audio] = trackContexts(webRTCEndpoint); + expect(video!.maxBandwidth).toBe(MAX_BANDWIDTH_LIMITS.singleStream); + expect(audio!.maxBandwidth).toBe(0); +}); + +it('addTrack clamps a simulcast limit above the cap', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { webRTCEndpoint } = await connect(); + + webRTCEndpoint.addTrack( + new FakeMediaStreamTrack({ kind: 'video' }), + undefined, + { enabled: true, enabledVariants: [Variant.VARIANT_LOW, Variant.VARIANT_HIGH], disabledVariants: [] }, + new Map([[Variant.VARIANT_HIGH, 6000]]), + ); + + const [video] = trackContexts(webRTCEndpoint); + const limits = video!.maxBandwidth as Map; + expect(limits.get(Variant.VARIANT_HIGH)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_HIGH]); + expect(limits.get(Variant.VARIANT_LOW)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); +}); + +it('SDP offer reports the configured bitrates per variant in bps', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint, offers } = await connect(); + + const simulcastTrack = webRTCEndpoint.addTrack( + new FakeMediaStreamTrack({ kind: 'video' }), + { type: 'camera' }, + { enabled: true, enabledVariants: [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH], disabledVariants: [] }, + new Map([ + [Variant.VARIANT_LOW, 100], + [Variant.VARIANT_MEDIUM, 400], + [Variant.VARIANT_HIGH, 1200], + ]), + ); + const singleTrack = webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' }), { type: 'screen' }, undefined, 900); + const audioTrack = webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'audio' }), { type: 'audio' }); + void simulcastTrack; + void singleTrack; + void audioTrack; + + await webRTCEndpoint.receiveMediaEvent(serializeServerMediaEvent({ offerData: createAddLocalTrackSDPOffer() })); + + expect(offers).toHaveLength(1); + const bitrates = offers[0]!.trackIdToBitrates; + const [simulcastId, singleId, audioId] = trackContexts(webRTCEndpoint).map((t) => t.trackId); + + expect(bitrates[simulcastId!]!.variantBitrates).toEqual([ + { variant: Variant.VARIANT_LOW, bitrate: 100 * KBPS }, + { variant: Variant.VARIANT_MEDIUM, bitrate: 400 * KBPS }, + { variant: Variant.VARIANT_HIGH, bitrate: 1200 * KBPS }, + ]); + expect(bitrates[singleId!]!.variantBitrates).toEqual([{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: 900 * KBPS }]); + expect(bitrates[audioId!]!.variantBitrates).toEqual([{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: 50_000 }]); +}); From d4c346689e15c59eec5533f9c8c53d40d2c65b79 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Mon, 14 Sep 2026 15:33:55 +0200 Subject: [PATCH 02/10] Fix formatting Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011J1hazAiH8ZzPimmmRH9oc --- packages/webrtc-client/src/bitrate.ts | 5 ++++- packages/webrtc-client/src/index.ts | 2 +- .../tests/methods/bandwidthLimits.test.ts | 17 ++++++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/webrtc-client/src/bitrate.ts b/packages/webrtc-client/src/bitrate.ts index 70530e752..801e0cdbf 100644 --- a/packages/webrtc-client/src/bitrate.ts +++ b/packages/webrtc-client/src/bitrate.ts @@ -44,7 +44,10 @@ export const resolveBandwidthLimit = (limit: TrackBandwidthLimit): TrackBandwidt const resolved: SimulcastBandwidthLimit = new Map(); for (const variant of SIMULCAST_VARIANTS) { - resolved.set(variant, resolveAgainstCap(limit.get(variant), MAX_BANDWIDTH_LIMITS.simulcast[variant], Variant[variant])); + resolved.set( + variant, + resolveAgainstCap(limit.get(variant), MAX_BANDWIDTH_LIMITS.simulcast[variant], Variant[variant]), + ); } return resolved; }; diff --git a/packages/webrtc-client/src/index.ts b/packages/webrtc-client/src/index.ts index e809c1f6e..1de0c0a1a 100644 --- a/packages/webrtc-client/src/index.ts +++ b/packages/webrtc-client/src/index.ts @@ -1,4 +1,4 @@ -export { MAX_BANDWIDTH_LIMITS,resolveBandwidthLimit } from './bitrate'; +export { MAX_BANDWIDTH_LIMITS, resolveBandwidthLimit } from './bitrate'; export { getLogger } from './logger'; export type { MediaEvent, SerializedMediaEvent } from './mediaEvent'; export type { diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts index 7ac275a4e..fda0a5162 100644 --- a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -44,7 +44,9 @@ it('resolveBandwidthLimit fills missing simulcast variants with caps and clamps it('resolveVariantBandwidthLimit uses the cap of the given variant', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(resolveVariantBandwidthLimit(Variant.VARIANT_LOW, 1000)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); + expect(resolveVariantBandwidthLimit(Variant.VARIANT_LOW, 1000)).toBe( + MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW], + ); expect(resolveVariantBandwidthLimit(Variant.VARIANT_HIGH, 1000)).toBe(1000); }); @@ -106,14 +108,23 @@ it('SDP offer reports the configured bitrates per variant in bps', async () => { const simulcastTrack = webRTCEndpoint.addTrack( new FakeMediaStreamTrack({ kind: 'video' }), { type: 'camera' }, - { enabled: true, enabledVariants: [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH], disabledVariants: [] }, + { + enabled: true, + enabledVariants: [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH], + disabledVariants: [], + }, new Map([ [Variant.VARIANT_LOW, 100], [Variant.VARIANT_MEDIUM, 400], [Variant.VARIANT_HIGH, 1200], ]), ); - const singleTrack = webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' }), { type: 'screen' }, undefined, 900); + const singleTrack = webRTCEndpoint.addTrack( + new FakeMediaStreamTrack({ kind: 'video' }), + { type: 'screen' }, + undefined, + 900, + ); const audioTrack = webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'audio' }), { type: 'audio' }); void simulcastTrack; void singleTrack; From b1b1a8964cf36cb9aa6498281251583e5ffcb1d1 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Mon, 14 Sep 2026 17:19:40 +0200 Subject: [PATCH 03/10] Requested changes --- AGENTS.md | 4 + .../react-client/src/utils/bandwidth.test.ts | 1 + packages/ts-client/src/FishjamClient.ts | 4 +- packages/webrtc-client/src/bitrate.ts | 31 ++++--- packages/webrtc-client/src/tracks/Local.ts | 28 +++++- .../webrtc-client/src/tracks/LocalTrack.ts | 16 +++- .../webrtc-client/src/tracks/bandwidth.ts | 20 +++++ packages/webrtc-client/src/webRTCEndpoint.ts | 13 +-- .../tests/methods/bandwidthLimits.test.ts | 85 ++++++++++++++++--- packages/webrtc-client/tests/mocks.ts | 1 + 10 files changed, 167 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 807c4734b..df44d1b20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,3 +8,7 @@ Non-obvious setup caveat: - `packages/protobufs/protos` and `packages/react-native-webrtc` are git submodules. They must be initialized before `yarn install`, otherwise install fails during resolution with `Workspace not found (@fishjam-cloud/react-native-webrtc@workspace:*)`. Run `git submodule update --init --recursive` first (the startup script does this). - Validated commands: `yarn build`, `yarn test:unit`, and `yarn lint:check` all pass. `yarn test:e2e` needs Playwright browsers and a running backend. + +## Conventions + +- Log through the SDK logger (`getLogger` in `packages/webrtc-client/src/logger.ts`, threaded as `logger`), never `console.*` directly. It respects the `debug` flag and adds the `[FISHJAM]` prefix. diff --git a/packages/react-client/src/utils/bandwidth.test.ts b/packages/react-client/src/utils/bandwidth.test.ts index 7d923c8ec..acbb5f591 100644 --- a/packages/react-client/src/utils/bandwidth.test.ts +++ b/packages/react-client/src/utils/bandwidth.test.ts @@ -15,5 +15,6 @@ it("keeps user values and fills the missing simulcast layers with the cap", () = expect(merged.singleStream).toBe(800); expect(merged.simulcast[Variant.VARIANT_HIGH]).toBe(1000); + expect(merged.simulcast[Variant.VARIANT_MEDIUM]).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_MEDIUM]); expect(merged.simulcast[Variant.VARIANT_LOW]).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); }); diff --git a/packages/ts-client/src/FishjamClient.ts b/packages/ts-client/src/FishjamClient.ts index 9674d14ab..678ca7f13 100644 --- a/packages/ts-client/src/FishjamClient.ts +++ b/packages/ts-client/src/FishjamClient.ts @@ -665,6 +665,7 @@ export class FishjamClient | Bitrate; @@ -25,10 +25,15 @@ type SimulcastVariant = (typeof SIMULCAST_VARIANTS)[number]; const isSimulcastVariant = (variant: Variant): variant is SimulcastVariant => (SIMULCAST_VARIANTS as readonly Variant[]).includes(variant); -const resolveAgainstCap = (limit: BandwidthLimit | undefined, cap: BandwidthLimit, label: string): BandwidthLimit => { +const resolveAgainstCap = ( + limit: BandwidthLimit | undefined, + cap: BandwidthLimit, + label: string, + logger: Logger, +): BandwidthLimit => { if (!limit || limit <= 0) return cap; if (limit > cap) { - console.warn(`[Fishjam] ${label} bandwidth limit ${limit} kbps exceeds the cap of ${cap} kbps, using the cap`); + logger.warn(`Desired ${label} bandwidth of ${limit} kbps exceeds the cap of ${cap} kbps`); return cap; } return limit; @@ -39,22 +44,26 @@ const resolveAgainstCap = (limit: BandwidthLimit | undefined, cap: BandwidthLimi * - a number is a single-stream limit; 0 or negative means "use the cap", higher values are clamped to it * - a Map holds per-variant simulcast limits; each variant is resolved the same way */ -export const resolveBandwidthLimit = (limit: TrackBandwidthLimit): TrackBandwidthLimit => { - if (typeof limit === 'number') return resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.singleStream, 'single stream'); +export const resolveBandwidthLimit = (limit: TrackBandwidthLimit, logger: Logger): TrackBandwidthLimit => { + if (typeof limit === 'number') { + return resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.singleStream, 'single stream', logger); + } const resolved: SimulcastBandwidthLimit = new Map(); for (const variant of SIMULCAST_VARIANTS) { - resolved.set( - variant, - resolveAgainstCap(limit.get(variant), MAX_BANDWIDTH_LIMITS.simulcast[variant], Variant[variant]), - ); + const cap = MAX_BANDWIDTH_LIMITS.simulcast[variant]; + resolved.set(variant, resolveAgainstCap(limit.get(variant), cap, Variant[variant], logger)); } return resolved; }; -export const resolveVariantBandwidthLimit = (variant: Variant, limit: BandwidthLimit): BandwidthLimit => { +export const resolveVariantBandwidthLimit = ( + variant: Variant, + limit: BandwidthLimit, + logger: Logger, +): BandwidthLimit => { const cap = isSimulcastVariant(variant) ? MAX_BANDWIDTH_LIMITS.simulcast[variant] : MAX_BANDWIDTH_LIMITS.singleStream; - return resolveAgainstCap(limit, cap, Variant[variant]); + return resolveAgainstCap(limit, cap, Variant[variant], logger); }; export const kbpsToBps = (kbps: BandwidthLimit): Bitrate => kbps * 1024; diff --git a/packages/webrtc-client/src/tracks/Local.ts b/packages/webrtc-client/src/tracks/Local.ts index 6eee0d7d3..b850a6fbc 100644 --- a/packages/webrtc-client/src/tracks/Local.ts +++ b/packages/webrtc-client/src/tracks/Local.ts @@ -11,12 +11,14 @@ import { import { type MediaEvent_Track_SimulcastConfig } from '@fishjam-cloud/protobufs/server'; import type { Variant } from '@fishjam-cloud/protobufs/shared'; +import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from '../bitrate'; import type { ConnectionManager } from '../ConnectionManager'; import type { EndpointWithTrackContext } from '../internal'; import { isTrackKind, TrackContextImpl } from '../internal'; import type { BandwidthLimit, LocalTrackId, + Logger, MetadataJson, MLineId, RemoteTrackId, @@ -28,6 +30,9 @@ import { getVariantBitrates } from './bitrates'; import { LocalTrack } from './LocalTrack'; import type { EndpointId, TrackId } from './TrackCommon'; +const isVideoTrack = (trackManager: LocalTrack) => + (trackManager.trackContext.track?.kind ?? trackManager.trackContext.trackKind) === 'video'; + /** * This class encapsulates methods related to handling the list of local tracks and local endpoint. * It stores and mutates part of the client state for this local peer. @@ -51,6 +56,7 @@ export class Local { private readonly sendMediaEvent: (mediaEvent: PeerMediaEvent) => void; private connection: ConnectionManager | null = null; + private readonly logger: Logger; constructor( emit: >( @@ -58,9 +64,11 @@ export class Local { ...args: Parameters[E]> ) => void, sendMediaEvent: (mediaEvent: PeerMediaEvent) => void, + logger: Logger, ) { this.emit = emit; this.sendMediaEvent = sendMediaEvent; + this.logger = logger; } public updateSenders = () => { @@ -155,12 +163,16 @@ export class Local { return this.localEndpoint; }; - public setTrackBandwidth = async (trackId: string, bandwidth: BandwidthLimit): Promise => { + public setTrackBandwidth = async (trackId: string, requestedBandwidth: BandwidthLimit): Promise => { const trackManager = this.localTracks[trackId]; if (!trackManager) throw new Error(`Cannot find ${trackId}`); - await trackManager.setTrackBandwidth(bandwidth); - trackManager.trackContext.maxBandwidth = bandwidth; + // Only video limits are capped; audio values are passed through untouched. + const bandwidth = isVideoTrack(trackManager) + ? (resolveBandwidthLimit(requestedBandwidth, this.logger) as BandwidthLimit) + : requestedBandwidth; + + trackManager.trackContext.maxBandwidth = await trackManager.setTrackBandwidth(bandwidth); const trackBitrates = MediaEvent_TrackBitrates.create({ trackId, @@ -184,10 +196,18 @@ export class Local { this.localEndpoint.id = endpointId; }; - public setEncodingBandwidth = async (trackId: TrackId, rid: Variant, bandwidth: BandwidthLimit): Promise => { + public setEncodingBandwidth = async ( + trackId: TrackId, + rid: Variant, + requestedBandwidth: BandwidthLimit, + ): Promise => { const trackManager = this.localTracks[trackId]; if (!trackManager) throw new Error(`Cannot find ${trackId}`); + const bandwidth = isVideoTrack(trackManager) + ? resolveVariantBandwidthLimit(rid, requestedBandwidth, this.logger) + : requestedBandwidth; + await trackManager.setEncodingBandwidth(rid, bandwidth); const { maxBandwidth } = trackManager.trackContext; diff --git a/packages/webrtc-client/src/tracks/LocalTrack.ts b/packages/webrtc-client/src/tracks/LocalTrack.ts index 0e918bf2e..8aeb27bf1 100644 --- a/packages/webrtc-client/src/tracks/LocalTrack.ts +++ b/packages/webrtc-client/src/tracks/LocalTrack.ts @@ -4,9 +4,17 @@ import type { Bitrate, Bitrates } from '../bitrate'; import { defaultBitrates, defaultSimulcastBitrates, UNLIMITED_BANDWIDTH } from '../bitrate'; import type { ConnectionManager } from '../ConnectionManager'; import type { TrackContextImpl } from '../internal'; -import type { BandwidthLimit, LocalTrackId, MediaStreamTrackId, MLineId, TrackKind } from '../types'; +import type { + BandwidthLimit, + LocalTrackId, + MediaStreamTrackId, + MLineId, + TrackBandwidthLimit, + TrackKind, +} from '../types'; // import { generateCustomEvent } from '../mediaEvent'; import type { WebRTCEndpoint } from '../webRTCEndpoint'; +import { encodingsToBandwidthLimit } from './bandwidth'; import { encodingToVariantMap, getEncodingParameters } from './encodings'; import { emitMutableEvents, getActionType } from './muteTrackUtils'; import type { TrackCommon, TrackEncodings, TrackId } from './TrackCommon'; @@ -161,14 +169,16 @@ export class LocalTrack implements TrackCommon { } }; - public setTrackBandwidth = (bandwidth: BandwidthLimit): Promise => { + public setTrackBandwidth = async (bandwidth: BandwidthLimit): Promise => { if (!this.sender) throw new Error(`RTCRtpSender for track ${this.id} not found`); const parameters = this.sender.getParameters(); parameters.encodings = getEncodingParameters(parameters, bandwidth); - return this.sender.setParameters(parameters); + await this.sender.setParameters(parameters); + + return encodingsToBandwidthLimit(parameters.encodings, bandwidth); }; public setEncodingBandwidth(variant: Variant, bandwidth: BandwidthLimit): Promise { diff --git a/packages/webrtc-client/src/tracks/bandwidth.ts b/packages/webrtc-client/src/tracks/bandwidth.ts index fd476ba03..6f978dc97 100644 --- a/packages/webrtc-client/src/tracks/bandwidth.ts +++ b/packages/webrtc-client/src/tracks/bandwidth.ts @@ -1,3 +1,8 @@ +import { Variant } from '@fishjam-cloud/protobufs/shared'; + +import type { TrackBandwidthLimit } from '../types'; +import { encodingToVariantMap } from './encodings'; + export const splitBandwidth = ( rtcRtpEncodingParameters: RTCRtpEncodingParameters[], maxBandwidth: number, @@ -34,3 +39,18 @@ export const splitBandwidth = ( maxBitrate: x * (firstScaleDownBy / (encoding.scaleResolutionDownBy || 1)) ** 2, })); }; + +export const encodingsToBandwidthLimit = ( + encodings: RTCRtpEncodingParameters[], + fallback: number, +): TrackBandwidthLimit => { + const simulcast = encodings.filter((encoding) => encoding.rid); + if (simulcast.length === 0) return fallback; + + return new Map( + simulcast.map((encoding) => { + const variant = encodingToVariantMap[encoding.rid!] ?? Variant.VARIANT_UNSPECIFIED; + return [variant, encoding.maxBitrate ? Math.round(encoding.maxBitrate / 1024) : 0]; + }), + ); +}; diff --git a/packages/webrtc-client/src/webRTCEndpoint.ts b/packages/webrtc-client/src/webRTCEndpoint.ts index d2d5b2ded..3523e1a37 100644 --- a/packages/webrtc-client/src/webRTCEndpoint.ts +++ b/packages/webrtc-client/src/webRTCEndpoint.ts @@ -18,7 +18,7 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { v4 as uuidv4 } from 'uuid'; -import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from './bitrate'; +import { resolveBandwidthLimit } from './bitrate'; import { CommandsQueue } from './CommandsQueue'; import { ConnectionManager } from './ConnectionManager'; import { DataChannelManager } from './dataChannels/DataChannelManager'; @@ -72,7 +72,7 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter { if (!this.connectionManager) throw new Error(`There is no active RTCPeerConnection`); - return this.local.setTrackBandwidth(trackId, resolveBandwidthLimit(bandwidth) as BandwidthLimit); + return this.local.setTrackBandwidth(trackId, bandwidth); } /** @@ -549,7 +552,7 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter { if (!this.connectionManager) throw new Error(`There is no active RTCPeerConnection`); - return await this.local.setEncodingBandwidth(trackId, rid, resolveVariantBandwidthLimit(rid, bandwidth)); + return await this.local.setEncodingBandwidth(trackId, rid, bandwidth); } /** diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts index fda0a5162..8e3088355 100644 --- a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -4,38 +4,41 @@ import { afterEach, expect, it, vi } from 'vitest'; import { MAX_BANDWIDTH_LIMITS, Variant, WebRTCEndpoint } from '../../src'; import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from '../../src/bitrate'; import { deserializePeerMediaEvent, serializeServerMediaEvent } from '../../src/mediaEvent'; +import { encodingsToBandwidthLimit, splitBandwidth } from '../../src/tracks/bandwidth'; +import { createTransceiverConfig } from '../../src/tracks/transceivers'; import { createAddLocalTrackSDPOffer, createConnectedEventWithOneEndpoint } from '../fixtures'; import { mockMediaStream, mockRTCPeerConnection } from '../mocks'; const KBPS = 1024; +const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + afterEach(() => { vi.restoreAllMocks(); + logger.warn.mockClear(); }); it('resolveBandwidthLimit resolves 0 to the single-stream cap', () => { - expect(resolveBandwidthLimit(0)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); - expect(resolveBandwidthLimit(-5)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); + expect(resolveBandwidthLimit(0, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); + expect(resolveBandwidthLimit(-5, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); }); it('resolveBandwidthLimit keeps a single-stream value below the cap', () => { - expect(resolveBandwidthLimit(800)).toBe(800); + expect(resolveBandwidthLimit(800, logger)).toBe(800); }); it('resolveBandwidthLimit lowers a single-stream value above the cap and warns', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(resolveBandwidthLimit(8000)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); - expect(warn).toHaveBeenCalledOnce(); + expect(resolveBandwidthLimit(8000, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); + expect(logger.warn).toHaveBeenCalledOnce(); }); it('resolveBandwidthLimit fills missing simulcast variants with caps and clamps the rest', () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}); const limits = new Map([ [Variant.VARIANT_MEDIUM, 300], [Variant.VARIANT_HIGH, 9000], ]); - const resolved = resolveBandwidthLimit(limits) as Map; + const resolved = resolveBandwidthLimit(limits, logger) as Map; expect(resolved.get(Variant.VARIANT_LOW)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); expect(resolved.get(Variant.VARIANT_MEDIUM)).toBe(300); @@ -43,11 +46,10 @@ it('resolveBandwidthLimit fills missing simulcast variants with caps and clamps }); it('resolveVariantBandwidthLimit uses the cap of the given variant', () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(resolveVariantBandwidthLimit(Variant.VARIANT_LOW, 1000)).toBe( + expect(resolveVariantBandwidthLimit(Variant.VARIANT_LOW, 1000, logger)).toBe( MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW], ); - expect(resolveVariantBandwidthLimit(Variant.VARIANT_HIGH, 1000)).toBe(1000); + expect(resolveVariantBandwidthLimit(Variant.VARIANT_HIGH, 1000, logger)).toBe(1000); }); const connect = async () => { @@ -81,10 +83,28 @@ it('addTrack without a limit uses the single-stream cap for video and no limit f expect(audio!.maxBandwidth).toBe(0); }); +it('addTrack on a simulcast track without a limit resolves to a Map of the per-variant caps', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint } = await connect(); + + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' }), undefined, { + enabled: true, + enabledVariants: [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH], + disabledVariants: [], + }); + + const [video] = trackContexts(webRTCEndpoint); + const caps = new Map( + Object.entries(MAX_BANDWIDTH_LIMITS.simulcast).map(([variant, limit]) => [Number(variant), limit]), + ); + expect(video!.maxBandwidth).toEqual(caps); + expect(() => createTransceiverConfig(video!)).not.toThrow(); +}); + it('addTrack clamps a simulcast limit above the cap', async () => { mockRTCPeerConnection(); mockMediaStream(); - vi.spyOn(console, 'warn').mockImplementation(() => {}); const { webRTCEndpoint } = await connect(); webRTCEndpoint.addTrack( @@ -144,3 +164,44 @@ it('SDP offer reports the configured bitrates per variant in bps', async () => { expect(bitrates[singleId!]!.variantBitrates).toEqual([{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: 900 * KBPS }]); expect(bitrates[audioId!]!.variantBitrates).toEqual([{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: 50_000 }]); }); + +it('encodingsToBandwidthLimit keeps a per-variant Map for simulcast encodings', () => { + const encodings = splitBandwidth( + [ + { rid: 'l', scaleResolutionDownBy: 4 }, + { rid: 'm', scaleResolutionDownBy: 2 }, + { rid: 'h', scaleResolutionDownBy: 1 }, + ], + 1500, + ); + + const limit = encodingsToBandwidthLimit(encodings, 1500) as Map; + + expect(limit).toBeInstanceOf(Map); + expect([...limit.keys()]).toEqual([Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]); + // 1500 split by pixel count: 1 : 4 : 16 → 71 / 286 / 1143 kbps + expect(limit.get(Variant.VARIANT_LOW)).toBe(71); + expect(limit.get(Variant.VARIANT_MEDIUM)).toBe(286); + expect(limit.get(Variant.VARIANT_HIGH)).toBe(1143); +}); + +it('encodingsToBandwidthLimit returns the number for a single-stream track', () => { + expect(encodingsToBandwidthLimit([{ maxBitrate: 900 * KBPS }], 900)).toBe(900); +}); + +it('setTrackBandwidth passes audio values through and caps video values', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint } = await connect(); + + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'audio' })); + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' })); + await webRTCEndpoint.receiveMediaEvent(serializeServerMediaEvent({ offerData: createAddLocalTrackSDPOffer() })); + + const [audio, video] = trackContexts(webRTCEndpoint); + await webRTCEndpoint.setTrackBandwidth(audio!.trackId, 5000); + await webRTCEndpoint.setTrackBandwidth(video!.trackId, 5000); + + expect(audio!.maxBandwidth).toBe(5000); + expect(video!.maxBandwidth).toBe(MAX_BANDWIDTH_LIMITS.singleStream); +}); diff --git a/packages/webrtc-client/tests/mocks.ts b/packages/webrtc-client/tests/mocks.ts index 9496b0320..bf30ebe86 100644 --- a/packages/webrtc-client/tests/mocks.ts +++ b/packages/webrtc-client/tests/mocks.ts @@ -35,6 +35,7 @@ export const mockRTCPeerConnection = (): { const encodings: RTCRtpEncodingParameters[] = [{}]; return { encodings: encodings } as RTCRtpSendParameters; }; + sender.setParameters = () => Promise.resolve(); if (init?.direction === 'sendonly') { sender.track = typeof trackOrKind !== 'string' ? trackOrKind : { id: 'someTrackId' }; From 9796fc4edbfb85172f42732fbc67445ff7c898d2 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Mon, 14 Sep 2026 17:45:18 +0200 Subject: [PATCH 04/10] Local review result --- packages/mobile-client/src/index.ts | 1 + packages/react-client/src/FishjamProvider.tsx | 8 +- packages/react-client/src/index.ts | 1 + packages/react-client/src/types/public.ts | 7 +- .../react-client/src/utils/bandwidth.test.ts | 6 +- packages/react-client/src/utils/bandwidth.ts | 8 +- packages/react-client/src/utils/track.ts | 7 +- packages/ts-client/src/FishjamClient.ts | 3 +- packages/webrtc-client/src/bitrate.ts | 51 +++++++---- packages/webrtc-client/src/index.ts | 3 +- packages/webrtc-client/src/internal.ts | 4 + packages/webrtc-client/src/tracks/Local.ts | 33 ++++--- .../webrtc-client/src/tracks/LocalTrack.ts | 37 +++++--- packages/webrtc-client/src/tracks/Remote.ts | 8 +- .../webrtc-client/src/tracks/bandwidth.ts | 26 +++++- packages/webrtc-client/src/tracks/bitrates.ts | 6 +- .../webrtc-client/src/tracks/encodings.ts | 6 +- .../webrtc-client/src/tracks/transceivers.ts | 39 ++++----- packages/webrtc-client/src/webRTCEndpoint.ts | 30 +++++-- .../tests/methods/addTrackMethod.test.ts | 8 +- .../tests/methods/bandwidthLimits.test.ts | 87 ++++++++++++++++--- packages/webrtc-client/tests/mocks.ts | 11 +-- 22 files changed, 273 insertions(+), 117 deletions(-) diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 88a1005b6..e04e06051 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -124,6 +124,7 @@ export type { RoomType, UseSandboxProps, BandwidthLimits, + BandwidthLimitsInput, Brand, DeviceError, DeviceItem, diff --git a/packages/react-client/src/FishjamProvider.tsx b/packages/react-client/src/FishjamProvider.tsx index a2c710078..11d4466fb 100644 --- a/packages/react-client/src/FishjamProvider.tsx +++ b/packages/react-client/src/FishjamProvider.tsx @@ -17,8 +17,8 @@ import { useFishjamClientState } from "./hooks/internal/useFishjamClientState"; import { usePeerStatus } from "./hooks/internal/usePeerStatus"; import { useScreenShareManager } from "./hooks/internal/useScreenshareManager"; import { useTrackManager } from "./hooks/internal/useTrackManager"; -import type { BandwidthLimits, PersistLastDeviceHandlers, StreamConfig } from "./types/public"; -import { mergeWithDefaultBandwitdthLimits } from "./utils/bandwidth"; +import type { BandwidthLimitsInput, PersistLastDeviceHandlers, StreamConfig } from "./types/public"; +import { mergeWithDefaultBandwidthLimits } from "./utils/bandwidth"; import { getLastDevice, saveLastDevice } from "./utils/localStorage"; /** @@ -45,7 +45,7 @@ export interface FishjamProviderProps extends PropsWithChildren { * Adjust max bandwidth limit (in kbps) for a single stream and each simulcast layer. * Missing values default to `MAX_BANDWIDTH_LIMITS`; values above the cap are clamped to it. */ - bandwidthLimits?: Partial; + bandwidthLimits?: BandwidthLimitsInput; /** * Configure whether to use video simulcast and which quality layers to send if so. */ @@ -97,7 +97,7 @@ export function FishjamProvider(props: FishjamProviderProps) { const peerStatus = usePeerStatus(fishjamClientRef.current); const mergedBandwidthLimits = useMemo( - () => mergeWithDefaultBandwitdthLimits(props.bandwidthLimits), + () => mergeWithDefaultBandwidthLimits(props.bandwidthLimits), [props.bandwidthLimits], ); diff --git a/packages/react-client/src/index.ts b/packages/react-client/src/index.ts index 5f404ec44..5b3845891 100644 --- a/packages/react-client/src/index.ts +++ b/packages/react-client/src/index.ts @@ -29,6 +29,7 @@ export { useUpdatePeerMetadata } from "./hooks/useUpdatePeerMetadata"; export { useVAD } from "./hooks/useVAD"; export type { BandwidthLimits, + BandwidthLimitsInput, Brand, CustomSource, DeviceError, diff --git a/packages/react-client/src/types/public.ts b/packages/react-client/src/types/public.ts index 123f89514..97fc29098 100644 --- a/packages/react-client/src/types/public.ts +++ b/packages/react-client/src/types/public.ts @@ -70,7 +70,12 @@ export type SimulcastBandwidthLimits = { export type StreamConfig = { sentQualities?: Variant[] | false }; -export type BandwidthLimits = { singleStream: number; simulcast: Partial }; +export type BandwidthLimits = { singleStream: number; simulcast: SimulcastBandwidthLimits }; + +/** + * Bandwidth limits accepted by `FishjamProvider`. Every value is optional; missing ones fall back to `MAX_BANDWIDTH_LIMITS`. + */ +export type BandwidthLimitsInput = { singleStream?: number; simulcast?: Partial }; export type DeviceType = "audio" | "video"; diff --git a/packages/react-client/src/utils/bandwidth.test.ts b/packages/react-client/src/utils/bandwidth.test.ts index acbb5f591..1058f98d4 100644 --- a/packages/react-client/src/utils/bandwidth.test.ts +++ b/packages/react-client/src/utils/bandwidth.test.ts @@ -1,17 +1,17 @@ import { MAX_BANDWIDTH_LIMITS, Variant } from "@fishjam-cloud/ts-client"; import { expect, it } from "vitest"; -import { mergeWithDefaultBandwitdthLimits } from "./bandwidth"; +import { mergeWithDefaultBandwidthLimits } from "./bandwidth"; it("defaults every limit to the cap", () => { - expect(mergeWithDefaultBandwitdthLimits()).toEqual({ + expect(mergeWithDefaultBandwidthLimits()).toEqual({ singleStream: MAX_BANDWIDTH_LIMITS.singleStream, simulcast: MAX_BANDWIDTH_LIMITS.simulcast, }); }); it("keeps user values and fills the missing simulcast layers with the cap", () => { - const merged = mergeWithDefaultBandwitdthLimits({ singleStream: 800, simulcast: { [Variant.VARIANT_HIGH]: 1000 } }); + const merged = mergeWithDefaultBandwidthLimits({ singleStream: 800, simulcast: { [Variant.VARIANT_HIGH]: 1000 } }); expect(merged.singleStream).toBe(800); expect(merged.simulcast[Variant.VARIANT_HIGH]).toBe(1000); diff --git a/packages/react-client/src/utils/bandwidth.ts b/packages/react-client/src/utils/bandwidth.ts index 5b62dee55..6610d80ca 100644 --- a/packages/react-client/src/utils/bandwidth.ts +++ b/packages/react-client/src/utils/bandwidth.ts @@ -1,10 +1,8 @@ -import { MAX_BANDWIDTH_LIMITS, Variant } from "@fishjam-cloud/ts-client"; +import { MAX_BANDWIDTH_LIMITS } from "@fishjam-cloud/ts-client"; -import type { BandwidthLimits } from "../types/public"; +import type { BandwidthLimits, BandwidthLimitsInput } from "../types/public"; -export const ALL_VARIANTS_SIMULCAST = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; - -export const mergeWithDefaultBandwitdthLimits = (limits?: Partial): BandwidthLimits => ({ +export const mergeWithDefaultBandwidthLimits = (limits?: BandwidthLimitsInput): BandwidthLimits => ({ singleStream: limits?.singleStream ?? MAX_BANDWIDTH_LIMITS.singleStream, simulcast: { ...MAX_BANDWIDTH_LIMITS.simulcast, ...limits?.simulcast }, }); diff --git a/packages/react-client/src/utils/track.ts b/packages/react-client/src/utils/track.ts index aaac0485a..ed6fd9866 100644 --- a/packages/react-client/src/utils/track.ts +++ b/packages/react-client/src/utils/track.ts @@ -1,5 +1,5 @@ -import type { FishjamClient, SimulcastConfig, TrackContext, TrackMetadata } from "@fishjam-cloud/ts-client"; -import { Variant } from "@fishjam-cloud/ts-client"; +import type { FishjamClient, SimulcastConfig, TrackContext, TrackMetadata , Variant } from "@fishjam-cloud/ts-client"; +import { SIMULCAST_VARIANTS } from "@fishjam-cloud/ts-client"; import type { BandwidthLimits, Track, TrackId } from "../types/public"; @@ -53,8 +53,7 @@ export function setupOnEndedCallback( } const getDisabledEncodings = (activeEncodings: Variant[] = []) => { - const allEncodings: Variant[] = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]; - return allEncodings.filter((encoding) => !activeEncodings.includes(encoding)); + return SIMULCAST_VARIANTS.filter((encoding) => !activeEncodings.includes(encoding)); }; export const getConfigAndBandwidthFromProps = ( diff --git a/packages/ts-client/src/FishjamClient.ts b/packages/ts-client/src/FishjamClient.ts index 678ca7f13..29cd3f25a 100644 --- a/packages/ts-client/src/FishjamClient.ts +++ b/packages/ts-client/src/FishjamClient.ts @@ -665,7 +665,8 @@ export class FishjamClient | Bitrate; * A limit passed to the SDK is never allowed to exceed these values, and an unset (0) limit resolves to them. */ export const MAX_BANDWIDTH_LIMITS = { - singleStream: 1500 as BandwidthLimit, + singleStream: 1500, simulcast: { - [Variant.VARIANT_LOW]: 150 as BandwidthLimit, - [Variant.VARIANT_MEDIUM]: 500 as BandwidthLimit, - [Variant.VARIANT_HIGH]: 1500 as BandwidthLimit, + [Variant.VARIANT_LOW]: 150, + [Variant.VARIANT_MEDIUM]: 500, + [Variant.VARIANT_HIGH]: 1500, }, -} as const; +} as const satisfies { singleStream: BandwidthLimit; simulcast: Record }; -const SIMULCAST_VARIANTS = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; +/** The variants that have a simulcast layer, ordered from the lowest to the highest resolution. */ +export const SIMULCAST_VARIANTS = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; -type SimulcastVariant = (typeof SIMULCAST_VARIANTS)[number]; +export type SimulcastVariant = (typeof SIMULCAST_VARIANTS)[number]; const isSimulcastVariant = (variant: Variant): variant is SimulcastVariant => (SIMULCAST_VARIANTS as readonly Variant[]).includes(variant); @@ -40,33 +41,49 @@ const resolveAgainstCap = ( }; /** - * Resolves a user-provided video bandwidth limit against {@link MAX_BANDWIDTH_LIMITS}. - * - a number is a single-stream limit; 0 or negative means "use the cap", higher values are clamped to it - * - a Map holds per-variant simulcast limits; each variant is resolved the same way + * Resolves a single-stream video limit against {@link MAX_BANDWIDTH_LIMITS.singleStream}. + * 0 or a negative value means "use the cap"; higher values are clamped to it. */ -export const resolveBandwidthLimit = (limit: TrackBandwidthLimit, logger: Logger): TrackBandwidthLimit => { - if (typeof limit === 'number') { - return resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.singleStream, 'single stream', logger); - } +export const resolveSingleStreamLimit = (limit: BandwidthLimit, logger: Logger): BandwidthLimit => + resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.singleStream, 'single stream', logger); +/** + * Resolves per-variant simulcast limits against {@link MAX_BANDWIDTH_LIMITS.simulcast}. + * Every simulcast variant is present in the result: a missing, 0 or negative entry resolves to that variant's cap, + * and higher values are clamped to it. + */ +export const resolveSimulcastLimits = (limits: SimulcastBandwidthLimit, logger: Logger): SimulcastBandwidthLimit => { const resolved: SimulcastBandwidthLimit = new Map(); for (const variant of SIMULCAST_VARIANTS) { const cap = MAX_BANDWIDTH_LIMITS.simulcast[variant]; - resolved.set(variant, resolveAgainstCap(limit.get(variant), cap, Variant[variant], logger)); + resolved.set(variant, resolveAgainstCap(limits.get(variant), cap, Variant[variant], logger)); } return resolved; }; +/** + * Resolves a user-provided video bandwidth limit against {@link MAX_BANDWIDTH_LIMITS}. + * A number is treated as a single-stream limit, a Map as per-variant simulcast limits. + */ +export const resolveBandwidthLimit = (limit: TrackBandwidthLimit, logger: Logger): TrackBandwidthLimit => + typeof limit === 'number' ? resolveSingleStreamLimit(limit, logger) : resolveSimulcastLimits(limit, logger); + +/** + * Resolves the limit of one simulcast layer against its cap. Throws for a variant that has no layer. + */ export const resolveVariantBandwidthLimit = ( variant: Variant, limit: BandwidthLimit, logger: Logger, ): BandwidthLimit => { - const cap = isSimulcastVariant(variant) ? MAX_BANDWIDTH_LIMITS.simulcast[variant] : MAX_BANDWIDTH_LIMITS.singleStream; - return resolveAgainstCap(limit, cap, Variant[variant], logger); + if (!isSimulcastVariant(variant)) throw new Error(`${Variant[variant]} is not a simulcast variant`); + + return resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.simulcast[variant], Variant[variant], logger); }; +// Throughout the SDK "kbps" means 1024 bps, so that the values match the ones reported by the browser. export const kbpsToBps = (kbps: BandwidthLimit): Bitrate => kbps * 1024; +export const bpsToKbps = (bps: Bitrate): BandwidthLimit => Math.round(bps / 1024); // The suggested bitrate values are based on our internal tests. export const defaultBitrates = { diff --git a/packages/webrtc-client/src/index.ts b/packages/webrtc-client/src/index.ts index 1de0c0a1a..e21082e0d 100644 --- a/packages/webrtc-client/src/index.ts +++ b/packages/webrtc-client/src/index.ts @@ -1,4 +1,5 @@ -export { MAX_BANDWIDTH_LIMITS, resolveBandwidthLimit } from './bitrate'; +export type { SimulcastVariant } from './bitrate'; +export { MAX_BANDWIDTH_LIMITS, resolveBandwidthLimit, SIMULCAST_VARIANTS } from './bitrate'; export { getLogger } from './logger'; export type { MediaEvent, SerializedMediaEvent } from './mediaEvent'; export type { diff --git a/packages/webrtc-client/src/internal.ts b/packages/webrtc-client/src/internal.ts index 2a6de7dab..aa476b1d7 100644 --- a/packages/webrtc-client/src/internal.ts +++ b/packages/webrtc-client/src/internal.ts @@ -61,3 +61,7 @@ export class TrackContextImpl export type EndpointWithTrackContext = Omit & { tracks: Map; }; + +/** Kind of the track, falling back to the kind recorded when the MediaStreamTrack was still attached. */ +export const getTrackKind = (trackContext: TrackContextImpl): TrackKind | null => + (trackContext.track?.kind as TrackKind | undefined) ?? trackContext.trackKind; diff --git a/packages/webrtc-client/src/tracks/Local.ts b/packages/webrtc-client/src/tracks/Local.ts index b850a6fbc..ba88d622d 100644 --- a/packages/webrtc-client/src/tracks/Local.ts +++ b/packages/webrtc-client/src/tracks/Local.ts @@ -11,10 +11,10 @@ import { import { type MediaEvent_Track_SimulcastConfig } from '@fishjam-cloud/protobufs/server'; import type { Variant } from '@fishjam-cloud/protobufs/shared'; -import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from '../bitrate'; +import { resolveSimulcastLimits, resolveSingleStreamLimit, resolveVariantBandwidthLimit } from '../bitrate'; import type { ConnectionManager } from '../ConnectionManager'; import type { EndpointWithTrackContext } from '../internal'; -import { isTrackKind, TrackContextImpl } from '../internal'; +import { getTrackKind, isTrackKind, TrackContextImpl } from '../internal'; import type { BandwidthLimit, LocalTrackId, @@ -30,8 +30,7 @@ import { getVariantBitrates } from './bitrates'; import { LocalTrack } from './LocalTrack'; import type { EndpointId, TrackId } from './TrackCommon'; -const isVideoTrack = (trackManager: LocalTrack) => - (trackManager.trackContext.track?.kind ?? trackManager.trackContext.trackKind) === 'video'; +const isVideoTrack = (trackManager: LocalTrack) => getTrackKind(trackManager.trackContext) === 'video'; /** * This class encapsulates methods related to handling the list of local tracks and local endpoint. @@ -124,7 +123,7 @@ export class Local { this.localEndpoint.tracks.set(trackId, trackContext); - const trackManager = new LocalTrack(connection, trackId, trackContext); + const trackManager = new LocalTrack(connection, trackId, trackContext, this.logger); this.localTracks[trackId] = trackManager; return trackManager; }; @@ -167,10 +166,7 @@ export class Local { const trackManager = this.localTracks[trackId]; if (!trackManager) throw new Error(`Cannot find ${trackId}`); - // Only video limits are capped; audio values are passed through untouched. - const bandwidth = isVideoTrack(trackManager) - ? (resolveBandwidthLimit(requestedBandwidth, this.logger) as BandwidthLimit) - : requestedBandwidth; + const bandwidth = this.resolveTrackBandwidth(trackManager, requestedBandwidth); trackManager.trackContext.maxBandwidth = await trackManager.setTrackBandwidth(bandwidth); @@ -181,7 +177,7 @@ export class Local { this.sendMediaEvent(PeerMediaEvent.create({ trackBitrates })); this.emit('localTrackBandwidthSet', { trackId, - bandwidth, + bandwidth: typeof bandwidth === 'number' ? bandwidth : requestedBandwidth, }); }; @@ -196,6 +192,23 @@ export class Local { this.localEndpoint.id = endpointId; }; + /** + * Applies the video caps to a requested limit: + * - audio: passed through untouched + * - single-stream video: clamped to the single-stream cap, 0 means the cap + * - simulcast video: 0 means every layer at its own cap; a positive number is a total budget that + * {@link LocalTrack.setTrackBandwidth} splits across the layers and clamps per layer + */ + private resolveTrackBandwidth = (trackManager: LocalTrack, requested: BandwidthLimit): TrackBandwidthLimit => { + if (!isVideoTrack(trackManager)) return requested; + + if (!trackManager.trackContext.simulcastConfig?.enabled) { + return resolveSingleStreamLimit(requested, this.logger); + } + + return requested > 0 ? requested : resolveSimulcastLimits(new Map(), this.logger); + }; + public setEncodingBandwidth = async ( trackId: TrackId, rid: Variant, diff --git a/packages/webrtc-client/src/tracks/LocalTrack.ts b/packages/webrtc-client/src/tracks/LocalTrack.ts index 8aeb27bf1..0fa83313f 100644 --- a/packages/webrtc-client/src/tracks/LocalTrack.ts +++ b/packages/webrtc-client/src/tracks/LocalTrack.ts @@ -1,12 +1,13 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; import type { Bitrate, Bitrates } from '../bitrate'; -import { defaultBitrates, defaultSimulcastBitrates, UNLIMITED_BANDWIDTH } from '../bitrate'; +import { defaultBitrates, defaultSimulcastBitrates, kbpsToBps, UNLIMITED_BANDWIDTH } from '../bitrate'; import type { ConnectionManager } from '../ConnectionManager'; import type { TrackContextImpl } from '../internal'; import type { BandwidthLimit, LocalTrackId, + Logger, MediaStreamTrackId, MLineId, TrackBandwidthLimit, @@ -14,11 +15,11 @@ import type { } from '../types'; // import { generateCustomEvent } from '../mediaEvent'; import type { WebRTCEndpoint } from '../webRTCEndpoint'; -import { encodingsToBandwidthLimit } from './bandwidth'; +import { clampSimulcastEncodings, encodingsToBandwidthLimit } from './bandwidth'; import { encodingToVariantMap, getEncodingParameters } from './encodings'; import { emitMutableEvents, getActionType } from './muteTrackUtils'; import type { TrackCommon, TrackEncodings, TrackId } from './TrackCommon'; -import { createTransceiverConfig } from './transceivers'; +import { calculateSimulcastEncodings, createTransceiverConfig } from './transceivers'; /** * This is a wrapper over `TrackContext` that adds additional properties such as: @@ -64,11 +65,18 @@ export class LocalTrack implements TrackCommon { }; public connection: ConnectionManager | undefined; - - constructor(connection: ConnectionManager | undefined, id: LocalTrackId, trackContext: TrackContextImpl) { + private readonly logger: Logger; + + constructor( + connection: ConnectionManager | undefined, + id: LocalTrackId, + trackContext: TrackContextImpl, + logger: Logger, + ) { this.connection = connection; this.id = id; this.trackContext = trackContext; + this.logger = logger; // todo maybe we could remove this object and use sender.getParameters().encodings.encodingParameter.active instead if (trackContext.track?.id) { @@ -107,7 +115,7 @@ export class LocalTrack implements TrackCommon { if (!this.connection) throw new Error(`There is no active RTCPeerConnection`); - const transceiverConfig = createTransceiverConfig(this.trackContext); + const transceiverConfig = createTransceiverConfig(this.trackContext, this.logger); this.updateEncodings(); @@ -169,16 +177,25 @@ export class LocalTrack implements TrackCommon { } }; - public setTrackBandwidth = async (bandwidth: BandwidthLimit): Promise => { + /** + * Applies a bandwidth limit to the sender and returns the limit that ended up on the encodings. + * - a Map sets each simulcast layer to its own limit + * - a number is the total budget: it is split across the layers proportionally to their resolution + * and each layer is then clamped to the cap of its variant + */ + public setTrackBandwidth = async (bandwidth: TrackBandwidthLimit): Promise => { if (!this.sender) throw new Error(`RTCRtpSender for track ${this.id} not found`); const parameters = this.sender.getParameters(); - parameters.encodings = getEncodingParameters(parameters, bandwidth); + parameters.encodings = + typeof bandwidth === 'number' + ? clampSimulcastEncodings(getEncodingParameters(parameters, bandwidth, this.logger), this.logger) + : calculateSimulcastEncodings(parameters.encodings, bandwidth); await this.sender.setParameters(parameters); - return encodingsToBandwidthLimit(parameters.encodings, bandwidth); + return encodingsToBandwidthLimit(parameters.encodings, typeof bandwidth === 'number' ? bandwidth : 0); }; public setEncodingBandwidth(variant: Variant, bandwidth: BandwidthLimit): Promise { @@ -192,7 +209,7 @@ export class LocalTrack implements TrackCommon { } else if (bandwidth === 0) { delete encoding.maxBitrate; } else { - encoding.maxBitrate = bandwidth * 1024; + encoding.maxBitrate = kbpsToBps(bandwidth); } return this.sender.setParameters(parameters); diff --git a/packages/webrtc-client/src/tracks/Remote.ts b/packages/webrtc-client/src/tracks/Remote.ts index 6a67490ad..9b71bac27 100644 --- a/packages/webrtc-client/src/tracks/Remote.ts +++ b/packages/webrtc-client/src/tracks/Remote.ts @@ -8,6 +8,7 @@ import type { EndpointWithTrackContext } from '../internal'; import { TrackContextImpl } from '../internal'; import type { EncodingReason, + Logger, MetadataJson, RemoteTrackId, TrackContext, @@ -26,6 +27,7 @@ export class Remote { ...args: Parameters[E]> ) => void; private readonly sendMediaEvent: (mediaEvent: PeerMediaEvent) => void; + private readonly logger: Logger; constructor( emit: >( @@ -33,9 +35,11 @@ export class Remote { ...args: Parameters[E]> ) => void, sendMediaEvent: (mediaEvent: PeerMediaEvent) => void, + logger: Logger, ) { this.emit = emit; this.sendMediaEvent = sendMediaEvent; + this.logger = logger; } public getTrackByMid = (mid: string): RemoteTrack => { @@ -185,7 +189,7 @@ export class Remote { remoteTrack.trackContext.vadStatus = nextStatus; remoteTrack.trackContext.emit('voiceActivityChanged', remoteTrack.trackContext); } else { - console.warn('Received unknown vad status: ', vadStatus); + this.logger.warn('Received unknown vad status: ', vadStatus); } }; @@ -220,7 +224,7 @@ export class Remote { variant, }); } catch (e) { - console.warn(e); + this.logger.warn(e); } }; diff --git a/packages/webrtc-client/src/tracks/bandwidth.ts b/packages/webrtc-client/src/tracks/bandwidth.ts index 6f978dc97..69c762391 100644 --- a/packages/webrtc-client/src/tracks/bandwidth.ts +++ b/packages/webrtc-client/src/tracks/bandwidth.ts @@ -1,13 +1,15 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; -import type { TrackBandwidthLimit } from '../types'; +import { bpsToKbps, kbpsToBps, resolveVariantBandwidthLimit } from '../bitrate'; +import type { Logger, TrackBandwidthLimit } from '../types'; import { encodingToVariantMap } from './encodings'; export const splitBandwidth = ( rtcRtpEncodingParameters: RTCRtpEncodingParameters[], maxBandwidth: number, + logger: Logger, ): RTCRtpEncodingParameters[] => { - const bandwidth = maxBandwidth * 1024; + const bandwidth = kbpsToBps(maxBandwidth); if (bandwidth === 0) { return rtcRtpEncodingParameters.map((encoding) => ({ @@ -18,7 +20,7 @@ export const splitBandwidth = ( if (rtcRtpEncodingParameters.length === 0) { // This most likely is a race condition. Log an error and prevent catastrophic failure - console.error("Attempted to limit bandwidth of the track that doesn't have any encodings"); + logger.error("Attempted to limit bandwidth of the track that doesn't have any encodings"); return rtcRtpEncodingParameters.map((encoding) => ({ ...encoding })); } if (!rtcRtpEncodingParameters[0]) throw new Error('RTCRtpEncodingParameters is in invalid state'); @@ -50,7 +52,23 @@ export const encodingsToBandwidthLimit = ( return new Map( simulcast.map((encoding) => { const variant = encodingToVariantMap[encoding.rid!] ?? Variant.VARIANT_UNSPECIFIED; - return [variant, encoding.maxBitrate ? Math.round(encoding.maxBitrate / 1024) : 0]; + return [variant, encoding.maxBitrate ? bpsToKbps(encoding.maxBitrate) : 0]; }), ); }; + +/** + * Clamps the `maxBitrate` of every simulcast encoding to the cap of its variant. + * Used after {@link splitBandwidth} so that a large total budget still respects the per-layer caps. + */ +export const clampSimulcastEncodings = ( + encodings: RTCRtpEncodingParameters[], + logger: Logger, +): RTCRtpEncodingParameters[] => + encodings.map((encoding) => { + const variant = encoding.rid ? encodingToVariantMap[encoding.rid] : undefined; + if (variant === undefined || !encoding.maxBitrate) return encoding; + + const limit = resolveVariantBandwidthLimit(variant, bpsToKbps(encoding.maxBitrate), logger); + return { ...encoding, maxBitrate: kbpsToBps(limit) }; + }); diff --git a/packages/webrtc-client/src/tracks/bitrates.ts b/packages/webrtc-client/src/tracks/bitrates.ts index b341abdbd..1754f19a0 100644 --- a/packages/webrtc-client/src/tracks/bitrates.ts +++ b/packages/webrtc-client/src/tracks/bitrates.ts @@ -2,15 +2,15 @@ import type { MediaEvent_VariantBitrate } from '@fishjam-cloud/protobufs/peer'; import { Variant } from '@fishjam-cloud/protobufs/shared'; import { defaultBitrates, kbpsToBps } from '../bitrate'; -import type { TrackContextImpl } from '../internal'; +import { getTrackKind, type TrackContextImpl } from '../internal'; /** * Builds the per-variant bitrates (in bps) reported to the server for a local track, * derived from the limits the encoder was configured with. */ export const getVariantBitrates = (trackContext: TrackContextImpl): MediaEvent_VariantBitrate[] => { - const kind = trackContext.track?.kind ?? trackContext.trackKind; - if (kind === 'audio') return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: defaultBitrates.audio }]; + if (getTrackKind(trackContext) === 'audio') + return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: defaultBitrates.audio }]; const { maxBandwidth } = trackContext; if (typeof maxBandwidth === 'number') { diff --git a/packages/webrtc-client/src/tracks/encodings.ts b/packages/webrtc-client/src/tracks/encodings.ts index d69f31582..7d761b96e 100644 --- a/packages/webrtc-client/src/tracks/encodings.ts +++ b/packages/webrtc-client/src/tracks/encodings.ts @@ -1,14 +1,18 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; +import type { Logger } from '../types'; import { splitBandwidth } from './bandwidth'; export const getEncodingParameters = ( parameters: RTCRtpSendParameters, bandwidth: number, + logger: Logger, ): RTCRtpEncodingParameters[] => { const unlimitedEncodings = [{}]; - return parameters.encodings.length === 0 ? unlimitedEncodings : splitBandwidth(parameters.encodings, bandwidth); + return parameters.encodings.length === 0 + ? unlimitedEncodings + : splitBandwidth(parameters.encodings, bandwidth, logger); }; export const encodingToVariantMap: Record = { diff --git a/packages/webrtc-client/src/tracks/transceivers.ts b/packages/webrtc-client/src/tracks/transceivers.ts index d8267bc8c..bfabbcae6 100644 --- a/packages/webrtc-client/src/tracks/transceivers.ts +++ b/packages/webrtc-client/src/tracks/transceivers.ts @@ -1,18 +1,19 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; +import { kbpsToBps } from '../bitrate'; import type { TrackContextImpl } from '../internal'; -import type { SimulcastBandwidthLimit, TrackBandwidthLimit } from '../types'; +import type { Logger, SimulcastBandwidthLimit, TrackBandwidthLimit } from '../types'; import { splitBandwidth } from './bandwidth'; import { encodingToVariantMap } from './encodings'; -export const createTransceiverConfig = (trackContext: TrackContextImpl): RTCRtpTransceiverInit => { +export const createTransceiverConfig = (trackContext: TrackContextImpl, logger: Logger): RTCRtpTransceiverInit => { if (!trackContext.track) throw new Error(`Cannot create transceiver config for `); if (trackContext.track.kind === 'audio') { return createAudioTransceiverConfig(trackContext.stream); } - return createVideoTransceiverConfig(trackContext, trackContext.maxBandwidth); + return createVideoTransceiverConfig(trackContext, trackContext.maxBandwidth, logger); }; const createAudioTransceiverConfig = (stream: MediaStream | null): RTCRtpTransceiverInit => { @@ -25,29 +26,19 @@ const createAudioTransceiverConfig = (stream: MediaStream | null): RTCRtpTransce const createVideoTransceiverConfig = ( trackContext: TrackContextImpl, maxBandwidth: TrackBandwidthLimit, + logger: Logger, ): RTCRtpTransceiverInit => { if (!trackContext.simulcastConfig) throw new Error(`Simulcast config for track ${trackContext.trackId} not found.`); if (trackContext.simulcastConfig.enabled) { - let simulcastConfig: Map; - - if (maxBandwidth === 0) { - simulcastConfig = new Map([ - [Variant.VARIANT_LOW, 0], - [Variant.VARIANT_MEDIUM, 0], - [Variant.VARIANT_HIGH, 0], - ]); - } else if (typeof maxBandwidth === 'number') { - throw new Error('Invalid bandwidth limit for simulcast track.'); - } else { - simulcastConfig = maxBandwidth; - } - - return createSimulcastTransceiverConfig(trackContext, simulcastConfig); + // `addTrack` resolves every simulcast track to a Map of per-variant limits before it gets here. + if (typeof maxBandwidth === 'number') throw new Error('Invalid bandwidth limit for simulcast track.'); + + return createSimulcastTransceiverConfig(trackContext, maxBandwidth); } if (typeof maxBandwidth === 'number') { - return createNonSimulcastTransceiverConfig(trackContext, maxBandwidth); + return createNonSimulcastTransceiverConfig(trackContext, maxBandwidth, logger); } throw new Error('LocalTrack is in invalid state!'); @@ -56,10 +47,11 @@ const createVideoTransceiverConfig = ( const createNonSimulcastTransceiverConfig = ( trackContext: TrackContextImpl, maxBandwidth: number, + logger: Logger, ): RTCRtpTransceiverInit => { return { direction: 'sendonly', - sendEncodings: splitBandwidth([{ active: true }], maxBandwidth), + sendEncodings: splitBandwidth([{ active: true }], maxBandwidth, logger), streams: trackContext.stream ? [trackContext.stream] : [], }; }; @@ -103,7 +95,10 @@ const createSimulcastTransceiverConfig = ( }; }; -const calculateSimulcastEncodings = (encodings: RTCRtpEncodingParameters[], maxBandwidth: SimulcastBandwidthLimit) => { +export const calculateSimulcastEncodings = ( + encodings: RTCRtpEncodingParameters[], + maxBandwidth: SimulcastBandwidthLimit, +) => { return encodings .filter((encoding) => encoding.rid) .map((encoding) => { @@ -113,7 +108,7 @@ const calculateSimulcastEncodings = (encodings: RTCRtpEncodingParameters[], maxB return { ...encoding, - maxBitrate: limit > 0 ? limit * 1024 : undefined, + maxBitrate: limit > 0 ? kbpsToBps(limit) : undefined, } satisfies RTCRtpEncodingParameters; }); }; diff --git a/packages/webrtc-client/src/webRTCEndpoint.ts b/packages/webrtc-client/src/webRTCEndpoint.ts index 3523e1a37..6e3bd72a0 100644 --- a/packages/webrtc-client/src/webRTCEndpoint.ts +++ b/packages/webrtc-client/src/webRTCEndpoint.ts @@ -18,7 +18,7 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { v4 as uuidv4 } from 'uuid'; -import { resolveBandwidthLimit } from './bitrate'; +import { resolveSimulcastLimits, resolveSingleStreamLimit } from './bitrate'; import { CommandsQueue } from './CommandsQueue'; import { ConnectionManager } from './ConnectionManager'; import { DataChannelManager } from './dataChannels/DataChannelManager'; @@ -71,7 +71,7 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter 0 ? maxBandwidth : resolveSimulcastLimits(new Map(), this.logger); + } + /** * Replaces a track that is being sent to the RTC Engine. * @param trackId - Audio or video track. @@ -532,7 +546,9 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter} success */ public setTrackBandwidth(trackId: string, bandwidth: BandwidthLimit): Promise { diff --git a/packages/webrtc-client/tests/methods/addTrackMethod.test.ts b/packages/webrtc-client/tests/methods/addTrackMethod.test.ts index 8b60a821a..ab64211d6 100644 --- a/packages/webrtc-client/tests/methods/addTrackMethod.test.ts +++ b/packages/webrtc-client/tests/methods/addTrackMethod.test.ts @@ -8,6 +8,8 @@ import { createTransceiverConfig } from '../../src/tracks/transceivers'; import { createConnectedEventWithOneEndpoint, mockTrack } from '../fixtures'; import { mockMediaStream, mockRTCPeerConnection } from '../mocks'; +const logger = { debug: () => {}, warn: () => {}, error: () => {} }; + it('Adding track invokes renegotiation', async () => { const webRTCEndpoint = new WebRTCEndpoint(); mockMediaStream(); @@ -61,9 +63,9 @@ it('Simulcast transceiver config includes the stream', () => { ); trackContext.track = videoTrack; trackContext.stream = stream; - trackContext.maxBandwidth = 0; + trackContext.maxBandwidth = new Map(); - const config = createTransceiverConfig(trackContext); + const config = createTransceiverConfig(trackContext, logger); expect(config.streams).toEqual([stream]); }); @@ -96,7 +98,7 @@ it('Adding a simulcast track keeps the per-variant bandwidth limits', async () = const [trackContext] = [...webRTCEndpoint['local'].getTrackIdToTrack().values()]; expect(trackContext!.maxBandwidth).toEqual(limits); - const config = createTransceiverConfig(trackContext!); + const config = createTransceiverConfig(trackContext!, logger); const high = config.sendEncodings!.find((encoding) => encoding.rid === 'h'); expect(high?.maxBitrate).toBe(1500 * 1024); }); diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts index 8e3088355..418e0bc91 100644 --- a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { MAX_BANDWIDTH_LIMITS, Variant, WebRTCEndpoint } from '../../src'; import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from '../../src/bitrate'; import { deserializePeerMediaEvent, serializeServerMediaEvent } from '../../src/mediaEvent'; -import { encodingsToBandwidthLimit, splitBandwidth } from '../../src/tracks/bandwidth'; +import { clampSimulcastEncodings, encodingsToBandwidthLimit, splitBandwidth } from '../../src/tracks/bandwidth'; import { createTransceiverConfig } from '../../src/tracks/transceivers'; import { createAddLocalTrackSDPOffer, createConnectedEventWithOneEndpoint } from '../fixtures'; import { mockMediaStream, mockRTCPeerConnection } from '../mocks'; @@ -52,6 +52,10 @@ it('resolveVariantBandwidthLimit uses the cap of the given variant', () => { expect(resolveVariantBandwidthLimit(Variant.VARIANT_HIGH, 1000, logger)).toBe(1000); }); +it('resolveVariantBandwidthLimit rejects a variant that has no simulcast layer', () => { + expect(() => resolveVariantBandwidthLimit(Variant.VARIANT_UNSPECIFIED, 1000, logger)).toThrow(); +}); + const connect = async () => { const webRTCEndpoint = new WebRTCEndpoint(); await webRTCEndpoint.receiveMediaEvent( @@ -99,7 +103,7 @@ it('addTrack on a simulcast track without a limit resolves to a Map of the per-v Object.entries(MAX_BANDWIDTH_LIMITS.simulcast).map(([variant, limit]) => [Number(variant), limit]), ); expect(video!.maxBandwidth).toEqual(caps); - expect(() => createTransceiverConfig(video!)).not.toThrow(); + expect(() => createTransceiverConfig(video!, logger)).not.toThrow(); }); it('addTrack clamps a simulcast limit above the cap', async () => { @@ -125,7 +129,7 @@ it('SDP offer reports the configured bitrates per variant in bps', async () => { mockMediaStream(); const { webRTCEndpoint, offers } = await connect(); - const simulcastTrack = webRTCEndpoint.addTrack( + webRTCEndpoint.addTrack( new FakeMediaStreamTrack({ kind: 'video' }), { type: 'camera' }, { @@ -139,17 +143,8 @@ it('SDP offer reports the configured bitrates per variant in bps', async () => { [Variant.VARIANT_HIGH, 1200], ]), ); - const singleTrack = webRTCEndpoint.addTrack( - new FakeMediaStreamTrack({ kind: 'video' }), - { type: 'screen' }, - undefined, - 900, - ); - const audioTrack = webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'audio' }), { type: 'audio' }); - void simulcastTrack; - void singleTrack; - void audioTrack; - + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' }), { type: 'screen' }, undefined, 900); + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'audio' }), { type: 'audio' }); await webRTCEndpoint.receiveMediaEvent(serializeServerMediaEvent({ offerData: createAddLocalTrackSDPOffer() })); expect(offers).toHaveLength(1); @@ -173,6 +168,7 @@ it('encodingsToBandwidthLimit keeps a per-variant Map for simulcast encodings', { rid: 'h', scaleResolutionDownBy: 1 }, ], 1500, + logger, ); const limit = encodingsToBandwidthLimit(encodings, 1500) as Map; @@ -205,3 +201,66 @@ it('setTrackBandwidth passes audio values through and caps video values', async expect(audio!.maxBandwidth).toBe(5000); expect(video!.maxBandwidth).toBe(MAX_BANDWIDTH_LIMITS.singleStream); }); + +it('clampSimulcastEncodings lowers each layer to the cap of its variant', () => { + const encodings = clampSimulcastEncodings( + [ + { rid: 'l', maxBitrate: 1000 * KBPS }, + { rid: 'm', maxBitrate: 400 * KBPS }, + { rid: 'h', maxBitrate: 3000 * KBPS }, + ], + logger, + ); + + expect(encodings.map((encoding) => encoding.maxBitrate)).toEqual([150 * KBPS, 400 * KBPS, 1500 * KBPS]); + expect(logger.warn).toHaveBeenCalledTimes(2); +}); + +const ALL_VARIANTS = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]; + +const connectWithSimulcastTrack = async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint } = await connect(); + + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' }), undefined, { + enabled: true, + enabledVariants: ALL_VARIANTS, + disabledVariants: [], + }); + await webRTCEndpoint.receiveMediaEvent(serializeServerMediaEvent({ offerData: createAddLocalTrackSDPOffer() })); + + const [video] = trackContexts(webRTCEndpoint); + return { webRTCEndpoint, trackId: video!.trackId, limits: () => video!.maxBandwidth as Map }; +}; + +it('setTrackBandwidth(0) on a simulcast track sets every layer to its cap', async () => { + const { webRTCEndpoint, trackId, limits } = await connectWithSimulcastTrack(); + + await webRTCEndpoint.setTrackBandwidth(trackId, 0); + + expect(limits().get(Variant.VARIANT_LOW)).toBe(150); + expect(limits().get(Variant.VARIANT_MEDIUM)).toBe(500); + expect(limits().get(Variant.VARIANT_HIGH)).toBe(1500); +}); + +it('setTrackBandwidth on a simulcast track lets the high layer reach its full cap', async () => { + const { webRTCEndpoint, trackId, limits } = await connectWithSimulcastTrack(); + + // 2100 split 1 : 4 : 16 → 100 / 400 / 1600, then the high layer is clamped to 1500 + await webRTCEndpoint.setTrackBandwidth(trackId, 2100); + + expect(limits().get(Variant.VARIANT_LOW)).toBe(100); + expect(limits().get(Variant.VARIANT_MEDIUM)).toBe(400); + expect(limits().get(Variant.VARIANT_HIGH)).toBe(1500); +}); + +it('setTrackBandwidth on a simulcast track clamps every layer when the budget is huge', async () => { + const { webRTCEndpoint, trackId, limits } = await connectWithSimulcastTrack(); + + await webRTCEndpoint.setTrackBandwidth(trackId, 100_000); + + expect(limits().get(Variant.VARIANT_LOW)).toBe(150); + expect(limits().get(Variant.VARIANT_MEDIUM)).toBe(500); + expect(limits().get(Variant.VARIANT_HIGH)).toBe(1500); +}); diff --git a/packages/webrtc-client/tests/mocks.ts b/packages/webrtc-client/tests/mocks.ts index bf30ebe86..53f7a9b0f 100644 --- a/packages/webrtc-client/tests/mocks.ts +++ b/packages/webrtc-client/tests/mocks.ts @@ -30,12 +30,13 @@ export const mockRTCPeerConnection = (): { addTransceiverCallback(trackOrKind, init); const sender: any = {}; - sender.getParameters = () => { - // @ts-ignore - const encodings: RTCRtpEncodingParameters[] = [{}]; - return { encodings: encodings } as RTCRtpSendParameters; + let encodings: RTCRtpEncodingParameters[] = init?.sendEncodings?.map((encoding) => ({ ...encoding })) ?? [{}]; + sender.getParameters = () => + ({ encodings: encodings.map((encoding) => ({ ...encoding })) }) as RTCRtpSendParameters; + sender.setParameters = (parameters: RTCRtpSendParameters) => { + encodings = parameters.encodings; + return Promise.resolve(); }; - sender.setParameters = () => Promise.resolve(); if (init?.direction === 'sendonly') { sender.track = typeof trackOrKind !== 'string' ? trackOrKind : { id: 'someTrackId' }; From c797764f8e89e3993b20fe387e2a0331942542b9 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Mon, 14 Sep 2026 17:53:53 +0200 Subject: [PATCH 05/10] Fix formatter warnings --- packages/react-client/src/utils/track.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-client/src/utils/track.ts b/packages/react-client/src/utils/track.ts index ed6fd9866..1cf76cfe4 100644 --- a/packages/react-client/src/utils/track.ts +++ b/packages/react-client/src/utils/track.ts @@ -1,4 +1,4 @@ -import type { FishjamClient, SimulcastConfig, TrackContext, TrackMetadata , Variant } from "@fishjam-cloud/ts-client"; +import type { FishjamClient, SimulcastConfig, TrackContext, TrackMetadata, Variant } from "@fishjam-cloud/ts-client"; import { SIMULCAST_VARIANTS } from "@fishjam-cloud/ts-client"; import type { BandwidthLimits, Track, TrackId } from "../types/public"; From 5727760885bc04e286a49e3ec4431a18911c2dd2 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Mon, 14 Sep 2026 18:58:20 +0200 Subject: [PATCH 06/10] Report configured audio bitrate to the server --- packages/webrtc-client/src/tracks/bitrates.ts | 8 ++++---- .../tests/methods/bandwidthLimits.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/webrtc-client/src/tracks/bitrates.ts b/packages/webrtc-client/src/tracks/bitrates.ts index 1754f19a0..4fe05f7c8 100644 --- a/packages/webrtc-client/src/tracks/bitrates.ts +++ b/packages/webrtc-client/src/tracks/bitrates.ts @@ -9,12 +9,12 @@ import { getTrackKind, type TrackContextImpl } from '../internal'; * derived from the limits the encoder was configured with. */ export const getVariantBitrates = (trackContext: TrackContextImpl): MediaEvent_VariantBitrate[] => { - if (getTrackKind(trackContext) === 'audio') - return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: defaultBitrates.audio }]; - const { maxBandwidth } = trackContext; + if (typeof maxBandwidth === 'number') { - return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: kbpsToBps(maxBandwidth) || defaultBitrates.video }]; + // 0 means the limit was never set, so report the default for the track kind. + const fallback = getTrackKind(trackContext) === 'audio' ? defaultBitrates.audio : defaultBitrates.video; + return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: kbpsToBps(maxBandwidth) || fallback }]; } return [...maxBandwidth.entries()].map(([variant, limit]) => ({ variant, bitrate: kbpsToBps(limit) })); diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts index 418e0bc91..64571761b 100644 --- a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -202,6 +202,26 @@ it('setTrackBandwidth passes audio values through and caps video values', async expect(video!.maxBandwidth).toBe(MAX_BANDWIDTH_LIMITS.singleStream); }); +it('setTrackBandwidth on an audio track reports the configured bitrate to the server', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint } = await connect(); + + const reported: number[] = []; + webRTCEndpoint.on('sendMediaEvent', (mediaEvent) => { + const bitrates = deserializePeerMediaEvent(mediaEvent).trackBitrates?.variantBitrates; + if (bitrates) reported.push(...bitrates.map((entry) => entry.bitrate)); + }); + + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'audio' })); + await webRTCEndpoint.receiveMediaEvent(serializeServerMediaEvent({ offerData: createAddLocalTrackSDPOffer() })); + + const [audio] = trackContexts(webRTCEndpoint); + await webRTCEndpoint.setTrackBandwidth(audio!.trackId, 64); + + expect(reported).toEqual([64 * KBPS]); +}); + it('clampSimulcastEncodings lowers each layer to the cap of its variant', () => { const encodings = clampSimulcastEncodings( [ From b7d516b368a5ea56ef4ae189fc527e07d951350f Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Tue, 15 Sep 2026 10:53:05 +0200 Subject: [PATCH 07/10] Refactor bandwidth changing functions --- .vscode/settings.json | 3 + .../fishjam-chat/app/room/[roomName].tsx | 4 +- .../fishjam-chat/app/room/preview.tsx | 4 +- packages/ts-client/src/FishjamClient.ts | 12 +- packages/webrtc-client/src/bitrate.ts | 9 +- packages/webrtc-client/src/index.ts | 3 +- packages/webrtc-client/src/internal.ts | 1 - packages/webrtc-client/src/tracks/Local.ts | 56 ++++------ .../webrtc-client/src/tracks/LocalTrack.ts | 16 +-- .../src/tracks/LocalTrackManager.ts | 10 +- .../webrtc-client/src/tracks/bandwidth.ts | 62 +++++++---- .../webrtc-client/src/tracks/transceivers.ts | 7 +- packages/webrtc-client/src/types.ts | 2 +- packages/webrtc-client/src/webRTCEndpoint.ts | 22 ++-- .../tests/methods/bandwidthLimits.test.ts | 105 ++++++++++-------- 15 files changed, 155 insertions(+), 161 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..cbd7ad7e1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cmake.sourceDirectory": "/Users/konkol/Documents/product/web-client-sdk/packages/react-native-webrtc/android" +} diff --git a/examples/mobile-client/fishjam-chat/app/room/[roomName].tsx b/examples/mobile-client/fishjam-chat/app/room/[roomName].tsx index f81ad8a1f..10778e839 100644 --- a/examples/mobile-client/fishjam-chat/app/room/[roomName].tsx +++ b/examples/mobile-client/fishjam-chat/app/room/[roomName].tsx @@ -155,7 +155,9 @@ export default function RoomScreen() { - setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur).catch(reportBackgroundBlurFailure) + setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur).catch( + reportBackgroundBlurFailure, + ) } /> - setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur).catch(reportBackgroundBlurFailure) + setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur).catch( + reportBackgroundBlurFailure, + ) } /> diff --git a/packages/ts-client/src/FishjamClient.ts b/packages/ts-client/src/FishjamClient.ts index 29cd3f25a..55fb75534 100644 --- a/packages/ts-client/src/FishjamClient.ts +++ b/packages/ts-client/src/FishjamClient.ts @@ -663,16 +663,16 @@ export class FishjamClient} Success */ - public async setTrackBandwidth(trackId: string, bandwidth: BandwidthLimit): Promise { + public async setTrackBandwidth(trackId: string, bandwidth: TrackBandwidthLimit): Promise { if (!this.webrtc) throw this.handleWebRTCNotInitialized(); await this.webrtc.setTrackBandwidth(trackId, bandwidth); return true; diff --git a/packages/webrtc-client/src/bitrate.ts b/packages/webrtc-client/src/bitrate.ts index 0ee9a3f1e..8f5a29959 100644 --- a/packages/webrtc-client/src/bitrate.ts +++ b/packages/webrtc-client/src/bitrate.ts @@ -1,6 +1,6 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; -import { type BandwidthLimit, type Logger, type SimulcastBandwidthLimit, type TrackBandwidthLimit } from './types'; +import { type BandwidthLimit, type Logger, type SimulcastBandwidthLimit } from './types'; export type Bitrate = number; export type Bitrates = Record | Bitrate; @@ -61,13 +61,6 @@ export const resolveSimulcastLimits = (limits: SimulcastBandwidthLimit, logger: return resolved; }; -/** - * Resolves a user-provided video bandwidth limit against {@link MAX_BANDWIDTH_LIMITS}. - * A number is treated as a single-stream limit, a Map as per-variant simulcast limits. - */ -export const resolveBandwidthLimit = (limit: TrackBandwidthLimit, logger: Logger): TrackBandwidthLimit => - typeof limit === 'number' ? resolveSingleStreamLimit(limit, logger) : resolveSimulcastLimits(limit, logger); - /** * Resolves the limit of one simulcast layer against its cap. Throws for a variant that has no layer. */ diff --git a/packages/webrtc-client/src/index.ts b/packages/webrtc-client/src/index.ts index e21082e0d..14b090393 100644 --- a/packages/webrtc-client/src/index.ts +++ b/packages/webrtc-client/src/index.ts @@ -1,7 +1,8 @@ export type { SimulcastVariant } from './bitrate'; -export { MAX_BANDWIDTH_LIMITS, resolveBandwidthLimit, SIMULCAST_VARIANTS } from './bitrate'; +export { MAX_BANDWIDTH_LIMITS, SIMULCAST_VARIANTS } from './bitrate'; export { getLogger } from './logger'; export type { MediaEvent, SerializedMediaEvent } from './mediaEvent'; +export { resolveTrackBandwidthLimit } from './tracks/bandwidth'; export type { BandwidthLimit, DataCallback, diff --git a/packages/webrtc-client/src/internal.ts b/packages/webrtc-client/src/internal.ts index aa476b1d7..0f51318c4 100644 --- a/packages/webrtc-client/src/internal.ts +++ b/packages/webrtc-client/src/internal.ts @@ -62,6 +62,5 @@ export type EndpointWithTrackContext = Omit & { tracks: Map; }; -/** Kind of the track, falling back to the kind recorded when the MediaStreamTrack was still attached. */ export const getTrackKind = (trackContext: TrackContextImpl): TrackKind | null => (trackContext.track?.kind as TrackKind | undefined) ?? trackContext.trackKind; diff --git a/packages/webrtc-client/src/tracks/Local.ts b/packages/webrtc-client/src/tracks/Local.ts index ba88d622d..19c2aef6c 100644 --- a/packages/webrtc-client/src/tracks/Local.ts +++ b/packages/webrtc-client/src/tracks/Local.ts @@ -11,7 +11,7 @@ import { import { type MediaEvent_Track_SimulcastConfig } from '@fishjam-cloud/protobufs/server'; import type { Variant } from '@fishjam-cloud/protobufs/shared'; -import { resolveSimulcastLimits, resolveSingleStreamLimit, resolveVariantBandwidthLimit } from '../bitrate'; +import { resolveVariantBandwidthLimit } from '../bitrate'; import type { ConnectionManager } from '../ConnectionManager'; import type { EndpointWithTrackContext } from '../internal'; import { getTrackKind, isTrackKind, TrackContextImpl } from '../internal'; @@ -26,6 +26,7 @@ import type { WebRTCEndpointEvents, } from '../types'; import type { WebRTCEndpoint } from '../webRTCEndpoint'; +import { resolveTrackBandwidthLimit } from './bandwidth'; import { getVariantBitrates } from './bitrates'; import { LocalTrack } from './LocalTrack'; import type { EndpointId, TrackId } from './TrackCommon'; @@ -162,23 +163,21 @@ export class Local { return this.localEndpoint; }; - public setTrackBandwidth = async (trackId: string, requestedBandwidth: BandwidthLimit): Promise => { + public setTrackBandwidth = async (trackId: string, bandwidth: TrackBandwidthLimit): Promise => { const trackManager = this.localTracks[trackId]; if (!trackManager) throw new Error(`Cannot find ${trackId}`); - const bandwidth = this.resolveTrackBandwidth(trackManager, requestedBandwidth); + const resolvedBandwidth = this.resolveTrackBandwidth(trackManager, bandwidth); - trackManager.trackContext.maxBandwidth = await trackManager.setTrackBandwidth(bandwidth); + await trackManager.setTrackBandwidth(resolvedBandwidth); + trackManager.trackContext.maxBandwidth = resolvedBandwidth; const trackBitrates = MediaEvent_TrackBitrates.create({ trackId, variantBitrates: getVariantBitrates(trackManager.trackContext), }); this.sendMediaEvent(PeerMediaEvent.create({ trackBitrates })); - this.emit('localTrackBandwidthSet', { - trackId, - bandwidth: typeof bandwidth === 'number' ? bandwidth : requestedBandwidth, - }); + this.emit('localTrackBandwidthSet', { trackId, bandwidth: resolvedBandwidth }); }; public getTrackIdToTrack = (): Map => { @@ -192,39 +191,32 @@ export class Local { this.localEndpoint.id = endpointId; }; - /** - * Applies the video caps to a requested limit: - * - audio: passed through untouched - * - single-stream video: clamped to the single-stream cap, 0 means the cap - * - simulcast video: 0 means every layer at its own cap; a positive number is a total budget that - * {@link LocalTrack.setTrackBandwidth} splits across the layers and clamps per layer - */ - private resolveTrackBandwidth = (trackManager: LocalTrack, requested: BandwidthLimit): TrackBandwidthLimit => { - if (!isVideoTrack(trackManager)) return requested; - - if (!trackManager.trackContext.simulcastConfig?.enabled) { - return resolveSingleStreamLimit(requested, this.logger); + /** Audio limits are applied as given; video limits go through {@link resolveTrackBandwidthLimit}. */ + private resolveTrackBandwidth = (trackManager: LocalTrack, requested: TrackBandwidthLimit): TrackBandwidthLimit => { + if (!isVideoTrack(trackManager)) { + if (typeof requested !== 'number') throw new Error('An audio track expects a single bandwidth limit'); + return requested; } - return requested > 0 ? requested : resolveSimulcastLimits(new Map(), this.logger); + return resolveTrackBandwidthLimit( + requested, + trackManager.trackContext.simulcastConfig?.enabled ?? false, + this.logger, + ); }; - public setEncodingBandwidth = async ( - trackId: TrackId, - rid: Variant, - requestedBandwidth: BandwidthLimit, - ): Promise => { + public setEncodingBandwidth = async (trackId: TrackId, rid: Variant, bandwidth: BandwidthLimit): Promise => { const trackManager = this.localTracks[trackId]; if (!trackManager) throw new Error(`Cannot find ${trackId}`); - const bandwidth = isVideoTrack(trackManager) - ? resolveVariantBandwidthLimit(rid, requestedBandwidth, this.logger) - : requestedBandwidth; + const resolvedBandwidth = isVideoTrack(trackManager) + ? resolveVariantBandwidthLimit(rid, bandwidth, this.logger) + : bandwidth; - await trackManager.setEncodingBandwidth(rid, bandwidth); + await trackManager.setEncodingBandwidth(rid, resolvedBandwidth); const { maxBandwidth } = trackManager.trackContext; - if (maxBandwidth instanceof Map) maxBandwidth.set(rid, bandwidth); + if (maxBandwidth instanceof Map) maxBandwidth.set(rid, resolvedBandwidth); const trackBitrates = MediaEvent_TrackBitrates.create({ trackId, @@ -235,7 +227,7 @@ export class Local { this.emit('localTrackEncodingBandwidthSet', { trackId, rid, - bandwidth, + bandwidth: resolvedBandwidth, }); }; diff --git a/packages/webrtc-client/src/tracks/LocalTrack.ts b/packages/webrtc-client/src/tracks/LocalTrack.ts index 0fa83313f..a1b2e17c8 100644 --- a/packages/webrtc-client/src/tracks/LocalTrack.ts +++ b/packages/webrtc-client/src/tracks/LocalTrack.ts @@ -15,7 +15,6 @@ import type { } from '../types'; // import { generateCustomEvent } from '../mediaEvent'; import type { WebRTCEndpoint } from '../webRTCEndpoint'; -import { clampSimulcastEncodings, encodingsToBandwidthLimit } from './bandwidth'; import { encodingToVariantMap, getEncodingParameters } from './encodings'; import { emitMutableEvents, getActionType } from './muteTrackUtils'; import type { TrackCommon, TrackEncodings, TrackId } from './TrackCommon'; @@ -177,25 +176,18 @@ export class LocalTrack implements TrackCommon { } }; - /** - * Applies a bandwidth limit to the sender and returns the limit that ended up on the encodings. - * - a Map sets each simulcast layer to its own limit - * - a number is the total budget: it is split across the layers proportionally to their resolution - * and each layer is then clamped to the cap of its variant - */ - public setTrackBandwidth = async (bandwidth: TrackBandwidthLimit): Promise => { + /** Writes an already resolved limit to the sender: a number for a single stream, a Map for simulcast layers. */ + public setTrackBandwidth = (bandwidth: TrackBandwidthLimit): Promise => { if (!this.sender) throw new Error(`RTCRtpSender for track ${this.id} not found`); const parameters = this.sender.getParameters(); parameters.encodings = typeof bandwidth === 'number' - ? clampSimulcastEncodings(getEncodingParameters(parameters, bandwidth, this.logger), this.logger) + ? getEncodingParameters(parameters, bandwidth, this.logger) : calculateSimulcastEncodings(parameters.encodings, bandwidth); - await this.sender.setParameters(parameters); - - return encodingsToBandwidthLimit(parameters.encodings, typeof bandwidth === 'number' ? bandwidth : 0); + return this.sender.setParameters(parameters); }; public setEncodingBandwidth(variant: Variant, bandwidth: BandwidthLimit): Promise { diff --git a/packages/webrtc-client/src/tracks/LocalTrackManager.ts b/packages/webrtc-client/src/tracks/LocalTrackManager.ts index 74e247380..1a2a47aff 100644 --- a/packages/webrtc-client/src/tracks/LocalTrackManager.ts +++ b/packages/webrtc-client/src/tracks/LocalTrackManager.ts @@ -48,19 +48,11 @@ export class LocalTrackManager { this.ongoingRenegotiation = false; }; - public parseAddTrack = ( - track: MediaStreamTrack, - simulcastConfig: SimulcastConfig, - maxBandwidth: TrackBandwidthLimit, - ) => { + public parseAddTrack = (track: MediaStreamTrack) => { if (this.getEndpointId() === '') { throw new Error('Cannot add tracks before being accepted by the server'); } - if (!simulcastConfig.enabled && !(typeof maxBandwidth === 'number')) { - throw new Error('Invalid type of `maxBandwidth` argument for a non-simulcast track, expected: number'); - } - if (this.connection?.isTrackInUse(track)) { throw new Error("This track was already added to peerConnection, it can't be added again!"); } diff --git a/packages/webrtc-client/src/tracks/bandwidth.ts b/packages/webrtc-client/src/tracks/bandwidth.ts index 69c762391..a7c84df7b 100644 --- a/packages/webrtc-client/src/tracks/bandwidth.ts +++ b/packages/webrtc-client/src/tracks/bandwidth.ts @@ -1,8 +1,20 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; -import { bpsToKbps, kbpsToBps, resolveVariantBandwidthLimit } from '../bitrate'; -import type { Logger, TrackBandwidthLimit } from '../types'; -import { encodingToVariantMap } from './encodings'; +import { + bpsToKbps, + kbpsToBps, + resolveSimulcastLimits, + resolveSingleStreamLimit, + SIMULCAST_VARIANTS, + type SimulcastVariant, +} from '../bitrate'; +import type { Logger, SimulcastBandwidthLimit, TrackBandwidthLimit } from '../types'; + +export const SIMULCAST_LAYER_SCALE: Record = { + [Variant.VARIANT_LOW]: 4, + [Variant.VARIANT_MEDIUM]: 2, + [Variant.VARIANT_HIGH]: 1, +}; export const splitBandwidth = ( rtcRtpEncodingParameters: RTCRtpEncodingParameters[], @@ -42,33 +54,37 @@ export const splitBandwidth = ( })); }; -export const encodingsToBandwidthLimit = ( - encodings: RTCRtpEncodingParameters[], - fallback: number, -): TrackBandwidthLimit => { - const simulcast = encodings.filter((encoding) => encoding.rid); - if (simulcast.length === 0) return fallback; +/** Splits a total budget (in kbps) across the simulcast layers proportionally to their pixel count. */ +export const splitSimulcastBudget = (budget: number, logger: Logger): SimulcastBandwidthLimit => { + const layers = SIMULCAST_VARIANTS.map((variant) => ({ scaleResolutionDownBy: SIMULCAST_LAYER_SCALE[variant] })); + const split = splitBandwidth(layers, budget, logger); return new Map( - simulcast.map((encoding) => { - const variant = encodingToVariantMap[encoding.rid!] ?? Variant.VARIANT_UNSPECIFIED; - return [variant, encoding.maxBitrate ? bpsToKbps(encoding.maxBitrate) : 0]; + SIMULCAST_VARIANTS.map((variant, index) => { + const maxBitrate = split[index]?.maxBitrate; + return [variant, maxBitrate ? bpsToKbps(maxBitrate) : 0]; }), ); }; /** - * Clamps the `maxBitrate` of every simulcast encoding to the cap of its variant. - * Used after {@link splitBandwidth} so that a large total budget still respects the per-layer caps. + * Resolves the limit requested for a video track against {@link MAX_BANDWIDTH_LIMITS}. + * A number is a budget for the whole track, a Map holds per-layer limits; 0 means "use the cap(s)". + * - single stream: the number is clamped to the single-stream cap; a Map is rejected + * - simulcast: a number is split across the layers, then every layer is clamped to its own cap */ -export const clampSimulcastEncodings = ( - encodings: RTCRtpEncodingParameters[], +export const resolveTrackBandwidthLimit = ( + requested: TrackBandwidthLimit, + simulcast: boolean, logger: Logger, -): RTCRtpEncodingParameters[] => - encodings.map((encoding) => { - const variant = encoding.rid ? encodingToVariantMap[encoding.rid] : undefined; - if (variant === undefined || !encoding.maxBitrate) return encoding; +): TrackBandwidthLimit => { + if (!simulcast) { + if (typeof requested !== 'number') throw new Error('A non-simulcast track expects a single bandwidth limit'); + return resolveSingleStreamLimit(requested, logger); + } + + if (typeof requested !== 'number') return resolveSimulcastLimits(requested, logger); - const limit = resolveVariantBandwidthLimit(variant, bpsToKbps(encoding.maxBitrate), logger); - return { ...encoding, maxBitrate: kbpsToBps(limit) }; - }); + const layers = requested > 0 ? splitSimulcastBudget(requested, logger) : new Map(); + return resolveSimulcastLimits(layers, logger); +}; diff --git a/packages/webrtc-client/src/tracks/transceivers.ts b/packages/webrtc-client/src/tracks/transceivers.ts index bfabbcae6..e7665fd5a 100644 --- a/packages/webrtc-client/src/tracks/transceivers.ts +++ b/packages/webrtc-client/src/tracks/transceivers.ts @@ -3,7 +3,7 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; import { kbpsToBps } from '../bitrate'; import type { TrackContextImpl } from '../internal'; import type { Logger, SimulcastBandwidthLimit, TrackBandwidthLimit } from '../types'; -import { splitBandwidth } from './bandwidth'; +import { SIMULCAST_LAYER_SCALE, splitBandwidth } from './bandwidth'; import { encodingToVariantMap } from './encodings'; export const createTransceiverConfig = (trackContext: TrackContextImpl, logger: Logger): RTCRtpTransceiverInit => { @@ -31,7 +31,6 @@ const createVideoTransceiverConfig = ( if (!trackContext.simulcastConfig) throw new Error(`Simulcast config for track ${trackContext.trackId} not found.`); if (trackContext.simulcastConfig.enabled) { - // `addTrack` resolves every simulcast track to a Map of per-variant limits before it gets here. if (typeof maxBandwidth === 'number') throw new Error('Invalid bandwidth limit for simulcast track.'); return createSimulcastTransceiverConfig(trackContext, maxBandwidth); @@ -69,13 +68,13 @@ const createSimulcastTransceiverConfig = ( rid: 'l', active: activeEncodings.includes(Variant.VARIANT_LOW), // maxBitrate: 4_000_000, - scaleResolutionDownBy: 4.0, + scaleResolutionDownBy: SIMULCAST_LAYER_SCALE[Variant.VARIANT_LOW], // scalabilityMode: "L1T" + TEMPORAL_LAYERS_COUNT, }, { rid: 'm', active: activeEncodings.includes(Variant.VARIANT_MEDIUM), - scaleResolutionDownBy: 2.0, + scaleResolutionDownBy: SIMULCAST_LAYER_SCALE[Variant.VARIANT_MEDIUM], }, { rid: 'h', diff --git a/packages/webrtc-client/src/types.ts b/packages/webrtc-client/src/types.ts index 5b0e593f9..065be8617 100644 --- a/packages/webrtc-client/src/types.ts +++ b/packages/webrtc-client/src/types.ts @@ -240,7 +240,7 @@ export interface WebRTCEndpointEvents { localTrackUnmuted: (event: { trackId: string }) => void; - localTrackBandwidthSet: (event: { trackId: string; bandwidth: BandwidthLimit }) => void; + localTrackBandwidthSet: (event: { trackId: string; bandwidth: TrackBandwidthLimit }) => void; localTrackEncodingBandwidthSet: (event: { trackId: string; rid: Variant; bandwidth: BandwidthLimit }) => void; diff --git a/packages/webrtc-client/src/webRTCEndpoint.ts b/packages/webrtc-client/src/webRTCEndpoint.ts index 6e3bd72a0..70e3b322f 100644 --- a/packages/webrtc-client/src/webRTCEndpoint.ts +++ b/packages/webrtc-client/src/webRTCEndpoint.ts @@ -18,7 +18,6 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { v4 as uuidv4 } from 'uuid'; -import { resolveSimulcastLimits, resolveSingleStreamLimit } from './bitrate'; import { CommandsQueue } from './CommandsQueue'; import { ConnectionManager } from './ConnectionManager'; import { DataChannelManager } from './dataChannels/DataChannelManager'; @@ -27,6 +26,7 @@ import type { EndpointWithTrackContext } from './internal'; import { getLogger } from './logger'; import type { SerializedMediaEvent } from './mediaEvent'; import { deserializeServerMediaEvent, serializePeerMediaEvent } from './mediaEvent'; +import { resolveTrackBandwidthLimit } from './tracks/bandwidth'; import { Local } from './tracks/Local'; import { LocalTrackManager } from './tracks/LocalTrackManager'; import { Remote } from './tracks/Remote'; @@ -443,7 +443,7 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter this.localTrackManager.parseAddTrack(track, simulcastConfig, resolvedMaxBandwidth), + parse: () => this.localTrackManager.parseAddTrack(track), resolve: 'after-renegotiation', resolutionNotifier, batchable: true, @@ -464,21 +464,14 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter 0 ? maxBandwidth : resolveSimulcastLimits(new Map(), this.logger); + return resolveTrackBandwidthLimit(maxBandwidth, simulcastConfig.enabled, this.logger); } /** @@ -546,12 +539,11 @@ export class WebRTCEndpoint extends (EventEmitter as new () => TypedEmitter} success */ - public setTrackBandwidth(trackId: string, bandwidth: BandwidthLimit): Promise { + public setTrackBandwidth(trackId: string, bandwidth: TrackBandwidthLimit): Promise { if (!this.connectionManager) throw new Error(`There is no active RTCPeerConnection`); return this.local.setTrackBandwidth(trackId, bandwidth); diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts index 64571761b..396ca0163 100644 --- a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -2,9 +2,9 @@ import { FakeMediaStreamTrack } from 'fake-mediastreamtrack'; import { afterEach, expect, it, vi } from 'vitest'; import { MAX_BANDWIDTH_LIMITS, Variant, WebRTCEndpoint } from '../../src'; -import { resolveBandwidthLimit, resolveVariantBandwidthLimit } from '../../src/bitrate'; +import { resolveVariantBandwidthLimit } from '../../src/bitrate'; import { deserializePeerMediaEvent, serializeServerMediaEvent } from '../../src/mediaEvent'; -import { clampSimulcastEncodings, encodingsToBandwidthLimit, splitBandwidth } from '../../src/tracks/bandwidth'; +import { resolveTrackBandwidthLimit, splitSimulcastBudget } from '../../src/tracks/bandwidth'; import { createTransceiverConfig } from '../../src/tracks/transceivers'; import { createAddLocalTrackSDPOffer, createConnectedEventWithOneEndpoint } from '../fixtures'; import { mockMediaStream, mockRTCPeerConnection } from '../mocks'; @@ -18,27 +18,27 @@ afterEach(() => { logger.warn.mockClear(); }); -it('resolveBandwidthLimit resolves 0 to the single-stream cap', () => { - expect(resolveBandwidthLimit(0, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); - expect(resolveBandwidthLimit(-5, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); +it('resolveTrackBandwidthLimit resolves 0 to the single-stream cap', () => { + expect(resolveTrackBandwidthLimit(0, false, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); + expect(resolveTrackBandwidthLimit(-5, false, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); }); -it('resolveBandwidthLimit keeps a single-stream value below the cap', () => { - expect(resolveBandwidthLimit(800, logger)).toBe(800); +it('resolveTrackBandwidthLimit keeps a single-stream value below the cap', () => { + expect(resolveTrackBandwidthLimit(800, false, logger)).toBe(800); }); -it('resolveBandwidthLimit lowers a single-stream value above the cap and warns', () => { - expect(resolveBandwidthLimit(8000, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); +it('resolveTrackBandwidthLimit lowers a single-stream value above the cap and warns', () => { + expect(resolveTrackBandwidthLimit(8000, false, logger)).toBe(MAX_BANDWIDTH_LIMITS.singleStream); expect(logger.warn).toHaveBeenCalledOnce(); }); -it('resolveBandwidthLimit fills missing simulcast variants with caps and clamps the rest', () => { +it('resolveTrackBandwidthLimit fills missing simulcast variants with caps and clamps the rest', () => { const limits = new Map([ [Variant.VARIANT_MEDIUM, 300], [Variant.VARIANT_HIGH, 9000], ]); - const resolved = resolveBandwidthLimit(limits, logger) as Map; + const resolved = resolveTrackBandwidthLimit(limits, true, logger) as Map; expect(resolved.get(Variant.VARIANT_LOW)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); expect(resolved.get(Variant.VARIANT_MEDIUM)).toBe(300); @@ -160,29 +160,14 @@ it('SDP offer reports the configured bitrates per variant in bps', async () => { expect(bitrates[audioId!]!.variantBitrates).toEqual([{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: 50_000 }]); }); -it('encodingsToBandwidthLimit keeps a per-variant Map for simulcast encodings', () => { - const encodings = splitBandwidth( - [ - { rid: 'l', scaleResolutionDownBy: 4 }, - { rid: 'm', scaleResolutionDownBy: 2 }, - { rid: 'h', scaleResolutionDownBy: 1 }, - ], - 1500, - logger, - ); - - const limit = encodingsToBandwidthLimit(encodings, 1500) as Map; +it('splitSimulcastBudget divides a budget by pixel count', () => { + // 1 : 4 : 16 → 71 / 286 / 1143 kbps + const limits = splitSimulcastBudget(1500, logger); - expect(limit).toBeInstanceOf(Map); - expect([...limit.keys()]).toEqual([Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]); - // 1500 split by pixel count: 1 : 4 : 16 → 71 / 286 / 1143 kbps - expect(limit.get(Variant.VARIANT_LOW)).toBe(71); - expect(limit.get(Variant.VARIANT_MEDIUM)).toBe(286); - expect(limit.get(Variant.VARIANT_HIGH)).toBe(1143); -}); - -it('encodingsToBandwidthLimit returns the number for a single-stream track', () => { - expect(encodingsToBandwidthLimit([{ maxBitrate: 900 * KBPS }], 900)).toBe(900); + expect([...limits.keys()]).toEqual([Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]); + expect(limits.get(Variant.VARIANT_LOW)).toBe(71); + expect(limits.get(Variant.VARIANT_MEDIUM)).toBe(286); + expect(limits.get(Variant.VARIANT_HIGH)).toBe(1143); }); it('setTrackBandwidth passes audio values through and caps video values', async () => { @@ -222,20 +207,6 @@ it('setTrackBandwidth on an audio track reports the configured bitrate to the se expect(reported).toEqual([64 * KBPS]); }); -it('clampSimulcastEncodings lowers each layer to the cap of its variant', () => { - const encodings = clampSimulcastEncodings( - [ - { rid: 'l', maxBitrate: 1000 * KBPS }, - { rid: 'm', maxBitrate: 400 * KBPS }, - { rid: 'h', maxBitrate: 3000 * KBPS }, - ], - logger, - ); - - expect(encodings.map((encoding) => encoding.maxBitrate)).toEqual([150 * KBPS, 400 * KBPS, 1500 * KBPS]); - expect(logger.warn).toHaveBeenCalledTimes(2); -}); - const ALL_VARIANTS = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]; const connectWithSimulcastTrack = async () => { @@ -284,3 +255,43 @@ it('setTrackBandwidth on a simulcast track clamps every layer when the budget is expect(limits().get(Variant.VARIANT_MEDIUM)).toBe(500); expect(limits().get(Variant.VARIANT_HIGH)).toBe(1500); }); + +it('addTrack splits a number on a simulcast track and clamps each layer', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint } = await connect(); + + webRTCEndpoint.addTrack( + new FakeMediaStreamTrack({ kind: 'video' }), + undefined, + { enabled: true, enabledVariants: ALL_VARIANTS, disabledVariants: [] }, + 2100, + ); + + const [video] = trackContexts(webRTCEndpoint); + const limits = video!.maxBandwidth as Map; + expect(limits.get(Variant.VARIANT_LOW)).toBe(100); + expect(limits.get(Variant.VARIANT_MEDIUM)).toBe(400); + expect(limits.get(Variant.VARIANT_HIGH)).toBe(1500); +}); + +it('setTrackBandwidth accepts a Map on a simulcast track', async () => { + const { webRTCEndpoint, trackId, limits } = await connectWithSimulcastTrack(); + + await webRTCEndpoint.setTrackBandwidth(trackId, new Map([[Variant.VARIANT_HIGH, 900]])); + + expect(limits().get(Variant.VARIANT_LOW)).toBe(150); + expect(limits().get(Variant.VARIANT_MEDIUM)).toBe(500); + expect(limits().get(Variant.VARIANT_HIGH)).toBe(900); +}); + +it('addTrack rejects a Map on a non-simulcast track before registering it', async () => { + mockRTCPeerConnection(); + mockMediaStream(); + const { webRTCEndpoint } = await connect(); + + await expect( + webRTCEndpoint.addTrack(new FakeMediaStreamTrack({ kind: 'video' }), undefined, undefined, new Map()), + ).rejects.toThrow('non-simulcast track'); + expect(trackContexts(webRTCEndpoint)).toHaveLength(0); +}); From 2f3ab7895b3f8a9dc84525663cbc7f056687945f Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Tue, 15 Sep 2026 10:54:43 +0200 Subject: [PATCH 08/10] Remove .vscode --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index cbd7ad7e1..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "cmake.sourceDirectory": "/Users/konkol/Documents/product/web-client-sdk/packages/react-native-webrtc/android" -} From cf44eef1c58e63c02bede7a471a6f49a97536677 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Tue, 15 Sep 2026 11:40:33 +0200 Subject: [PATCH 09/10] Requested changes --- packages/tsunami/src/FishjamClient.test.ts | 14 ++++- packages/tsunami/src/FishjamClient.ts | 2 +- .../tests/methods/bandwidthLimits.test.ts | 56 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/tsunami/src/FishjamClient.test.ts b/packages/tsunami/src/FishjamClient.test.ts index af7913739..5a53a23fc 100644 --- a/packages/tsunami/src/FishjamClient.test.ts +++ b/packages/tsunami/src/FishjamClient.test.ts @@ -1,4 +1,4 @@ -import { type ConnectConfig, FishjamClient as TsClient, type GenericMetadata } from "@fishjam-cloud/ts-client"; +import { type ConnectConfig, FishjamClient as TsClient, type GenericMetadata, Variant } from "@fishjam-cloud/ts-client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ClientResourceScope } from "./ClientResourceScope"; @@ -200,6 +200,18 @@ describe("FishjamClient lifecycle", () => { expect(joined).toHaveBeenCalledOnce(); }); + it("forwards per-variant simulcast limits to setTrackBandwidth", async () => { + const setTrackBandwidth = vi.spyOn(TsClient.prototype, "setTrackBandwidth").mockResolvedValue(true); + vi.spyOn(TsClient.prototype, "connect").mockResolvedValue(); + const client = new FishjamClient(); + await client.connect(connectConfig); + const limits = new Map([[Variant.VARIANT_HIGH, 900]]); + + await expect(client.setTrackBandwidth("track-id", limits)).resolves.toBe(true); + + expect(setTrackBandwidth).toHaveBeenCalledWith("track-id", limits); + }); + it("does not take ownership of tracks passed through the signalling seam", async () => { const publication = deferred(); vi.spyOn(TsClient.prototype, "addTrack").mockReturnValue(publication.promise); diff --git a/packages/tsunami/src/FishjamClient.ts b/packages/tsunami/src/FishjamClient.ts index 7729c2e0a..5a7ce3187 100644 --- a/packages/tsunami/src/FishjamClient.ts +++ b/packages/tsunami/src/FishjamClient.ts @@ -119,7 +119,7 @@ export class FishjamClient this.getTsClient().replaceTrack(trackId, newTrack)); } - public async setTrackBandwidth(trackId: string, bandwidth: BandwidthLimit): Promise { + public async setTrackBandwidth(trackId: string, bandwidth: TrackBandwidthLimit): Promise { return this.resources.run(() => this.getTsClient().setTrackBandwidth(trackId, bandwidth)); } diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts index 396ca0163..05e9d445d 100644 --- a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -295,3 +295,59 @@ it('addTrack rejects a Map on a non-simulcast track before registering it', asyn ).rejects.toThrow('non-simulcast track'); expect(trackContexts(webRTCEndpoint)).toHaveLength(0); }); + +const reportedBitrates = (webRTCEndpoint: WebRTCEndpoint) => { + const reported: { variant: Variant; bitrate: number }[][] = []; + webRTCEndpoint.on('sendMediaEvent', (mediaEvent) => { + const bitrates = deserializePeerMediaEvent(mediaEvent).trackBitrates?.variantBitrates; + if (bitrates) reported.push(bitrates.map(({ variant, bitrate }) => ({ variant, bitrate }))); + }); + return reported; +}; + +it('setEncodingBandwidth clamps an over-cap layer, stores it and reports every layer in bps', async () => { + const { webRTCEndpoint, trackId, limits } = await connectWithSimulcastTrack(); + const reported = reportedBitrates(webRTCEndpoint); + + await webRTCEndpoint.setEncodingBandwidth(trackId, Variant.VARIANT_MEDIUM, 9000); + + expect(limits().get(Variant.VARIANT_MEDIUM)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_MEDIUM]); + expect(limits().get(Variant.VARIANT_LOW)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]); + expect(limits().get(Variant.VARIANT_HIGH)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_HIGH]); + expect(reported).toEqual([ + [ + { variant: Variant.VARIANT_LOW, bitrate: 150 * KBPS }, + { variant: Variant.VARIANT_MEDIUM, bitrate: 500 * KBPS }, + { variant: Variant.VARIANT_HIGH, bitrate: 1500 * KBPS }, + ], + ]); +}); + +it('setEncodingBandwidth(0) resolves a layer to its cap and reports it in bps', async () => { + const { webRTCEndpoint, trackId, limits } = await connectWithSimulcastTrack(); + await webRTCEndpoint.setEncodingBandwidth(trackId, Variant.VARIANT_HIGH, 700); + const reported = reportedBitrates(webRTCEndpoint); + + await webRTCEndpoint.setEncodingBandwidth(trackId, Variant.VARIANT_HIGH, 0); + + expect(limits().get(Variant.VARIANT_HIGH)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_HIGH]); + expect(reported).toHaveLength(1); + expect(reported[0]).toContainEqual({ variant: Variant.VARIANT_HIGH, bitrate: 1500 * KBPS }); +}); + +it('setEncodingBandwidth keeps a below-cap layer and reports only that layer changed', async () => { + const { webRTCEndpoint, trackId, limits } = await connectWithSimulcastTrack(); + const reported = reportedBitrates(webRTCEndpoint); + + await webRTCEndpoint.setEncodingBandwidth(trackId, Variant.VARIANT_HIGH, 700); + + expect(limits().get(Variant.VARIANT_HIGH)).toBe(700); + expect(limits().get(Variant.VARIANT_MEDIUM)).toBe(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_MEDIUM]); + expect(reported).toEqual([ + [ + { variant: Variant.VARIANT_LOW, bitrate: 150 * KBPS }, + { variant: Variant.VARIANT_MEDIUM, bitrate: 500 * KBPS }, + { variant: Variant.VARIANT_HIGH, bitrate: 700 * KBPS }, + ], + ]); +}); From 3eb6ba58a858577ca041f01ba12afd547259c057 Mon Sep 17 00:00:00 2001 From: Karol Konkol Date: Thu, 1 Oct 2026 12:09:35 +0200 Subject: [PATCH 10/10] Requested changes --- packages/ts-client/src/FishjamClient.ts | 2 +- packages/webrtc-client/src/bandwidth.ts | 181 ++++++++++++++++++ packages/webrtc-client/src/bitrate.ts | 98 ---------- packages/webrtc-client/src/index.ts | 5 +- packages/webrtc-client/src/tracks/Local.ts | 4 +- .../webrtc-client/src/tracks/LocalTrack.ts | 63 +----- .../webrtc-client/src/tracks/bandwidth.ts | 90 --------- packages/webrtc-client/src/tracks/bitrates.ts | 21 -- .../webrtc-client/src/tracks/encodings.ts | 2 +- .../webrtc-client/src/tracks/transceivers.ts | 3 +- packages/webrtc-client/src/webRTCEndpoint.ts | 2 +- .../tests/methods/bandwidthLimits.test.ts | 3 +- 12 files changed, 191 insertions(+), 283 deletions(-) create mode 100644 packages/webrtc-client/src/bandwidth.ts delete mode 100644 packages/webrtc-client/src/bitrate.ts delete mode 100644 packages/webrtc-client/src/tracks/bandwidth.ts delete mode 100644 packages/webrtc-client/src/tracks/bitrates.ts diff --git a/packages/ts-client/src/FishjamClient.ts b/packages/ts-client/src/FishjamClient.ts index 55fb75534..6c328deaa 100644 --- a/packages/ts-client/src/FishjamClient.ts +++ b/packages/ts-client/src/FishjamClient.ts @@ -588,7 +588,7 @@ export class FishjamClient { if (!this.webrtc) throw this.handleWebRTCNotInitialized(); if (this.isAudioOnlyConnection && track.kind !== 'audio') throw new TrackTypeError(); diff --git a/packages/webrtc-client/src/bandwidth.ts b/packages/webrtc-client/src/bandwidth.ts new file mode 100644 index 000000000..ed861f068 --- /dev/null +++ b/packages/webrtc-client/src/bandwidth.ts @@ -0,0 +1,181 @@ +import type { MediaEvent_VariantBitrate } from '@fishjam-cloud/protobufs/peer'; +import { Variant } from '@fishjam-cloud/protobufs/shared'; + +import { getTrackKind, type TrackContextImpl } from './internal'; +import type { BandwidthLimit, Logger, SimulcastBandwidthLimit, TrackBandwidthLimit } from './types'; + +export type Bitrate = number; + +// Throughout the SDK "kbps" means 1024 bps, so that the values match the ones reported by the browser. +export const kbpsToBps = (kbps: BandwidthLimit): Bitrate => kbps * 1024; +export const bpsToKbps = (bps: Bitrate): BandwidthLimit => Math.round(bps / 1024); + +/** + * Hard caps (in kbps) for the bitrate of an outgoing video track. + * A limit passed to the SDK is never allowed to exceed these values, and an unset (0) limit resolves to them. + */ +export const MAX_BANDWIDTH_LIMITS = { + singleStream: 1500, + simulcast: { + [Variant.VARIANT_LOW]: 150, + [Variant.VARIANT_MEDIUM]: 500, + [Variant.VARIANT_HIGH]: 1500, + }, +} as const satisfies { singleStream: BandwidthLimit; simulcast: Record }; + +/** The variants that have a simulcast layer, ordered from the lowest to the highest resolution. */ +export const SIMULCAST_VARIANTS = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; + +export type SimulcastVariant = (typeof SIMULCAST_VARIANTS)[number]; + +const isSimulcastVariant = (variant: Variant): variant is SimulcastVariant => + (SIMULCAST_VARIANTS as readonly Variant[]).includes(variant); + +const resolveAgainstCap = ( + limit: BandwidthLimit | undefined, + cap: BandwidthLimit, + label: string, + logger: Logger, +): BandwidthLimit => { + if (!limit || limit <= 0) return cap; + if (limit > cap) { + logger.warn(`Desired ${label} bandwidth of ${limit} kbps exceeds the cap of ${cap} kbps`); + return cap; + } + return limit; +}; + +/** + * Resolves a single-stream video limit against {@link MAX_BANDWIDTH_LIMITS.singleStream}. + * 0 or a negative value means "use the cap"; higher values are clamped to it. + */ +export const resolveSingleStreamLimit = (limit: BandwidthLimit, logger: Logger): BandwidthLimit => + resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.singleStream, 'single stream', logger); + +/** + * Resolves per-variant simulcast limits against {@link MAX_BANDWIDTH_LIMITS.simulcast}. + * Every simulcast variant is present in the result: a missing, 0 or negative entry resolves to that variant's cap, + * and higher values are clamped to it. + */ +export const resolveSimulcastLimits = (limits: SimulcastBandwidthLimit, logger: Logger): SimulcastBandwidthLimit => { + const resolved: SimulcastBandwidthLimit = new Map(); + for (const variant of SIMULCAST_VARIANTS) { + const cap = MAX_BANDWIDTH_LIMITS.simulcast[variant]; + resolved.set(variant, resolveAgainstCap(limits.get(variant), cap, Variant[variant], logger)); + } + return resolved; +}; + +/** + * Resolves the limit of one simulcast layer against its cap. Throws for a variant that has no layer. + */ +export const resolveVariantBandwidthLimit = ( + variant: Variant, + limit: BandwidthLimit, + logger: Logger, +): BandwidthLimit => { + if (!isSimulcastVariant(variant)) throw new Error(`${Variant[variant]} is not a simulcast variant`); + + return resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.simulcast[variant], Variant[variant], logger); +}; + +// The suggested bitrate values are based on our internal tests. +export const defaultBitrates = { + audio: 50_000 as Bitrate, + video: kbpsToBps(MAX_BANDWIDTH_LIMITS.singleStream), +}; + +export const SIMULCAST_LAYER_SCALE: Record = { + [Variant.VARIANT_LOW]: 4, + [Variant.VARIANT_MEDIUM]: 2, + [Variant.VARIANT_HIGH]: 1, +}; + +export const splitBandwidth = ( + rtcRtpEncodingParameters: RTCRtpEncodingParameters[], + maxBandwidth: number, + logger: Logger, +): RTCRtpEncodingParameters[] => { + const bandwidth = kbpsToBps(maxBandwidth); + + if (bandwidth === 0) { + return rtcRtpEncodingParameters.map((encoding) => ({ + ...encoding, + maxBitrate: undefined, + })); + } + + if (rtcRtpEncodingParameters.length === 0) { + // This most likely is a race condition. Log an error and prevent catastrophic failure + logger.error("Attempted to limit bandwidth of the track that doesn't have any encodings"); + return rtcRtpEncodingParameters.map((encoding) => ({ ...encoding })); + } + if (!rtcRtpEncodingParameters[0]) throw new Error('RTCRtpEncodingParameters is in invalid state'); + + // We are solving the following equation: + // x + (k0/k1)^2 * x + (k0/k2)^2 * x + ... + (k0/kn)^2 * x = bandwidth + // where x is the bitrate for the first encoding, kn are scaleResolutionDownBy factors + // square is dictated by the fact that k0/kn is a scale factor, but we are interested in the total number of pixels in the image + const firstScaleDownBy = rtcRtpEncodingParameters[0].scaleResolutionDownBy || 1; + const bitrate_parts = rtcRtpEncodingParameters.reduce( + (acc, value) => acc + (firstScaleDownBy / (value.scaleResolutionDownBy || 1)) ** 2, + 0, + ); + const x = bandwidth / bitrate_parts; + + return rtcRtpEncodingParameters.map((encoding) => ({ + ...encoding, + maxBitrate: x * (firstScaleDownBy / (encoding.scaleResolutionDownBy || 1)) ** 2, + })); +}; + +/** Splits a total budget (in kbps) across the simulcast layers proportionally to their pixel count. */ +export const splitSimulcastBudget = (budget: number, logger: Logger): SimulcastBandwidthLimit => { + const layers = SIMULCAST_VARIANTS.map((variant) => ({ scaleResolutionDownBy: SIMULCAST_LAYER_SCALE[variant] })); + const split = splitBandwidth(layers, budget, logger); + + return new Map( + SIMULCAST_VARIANTS.map((variant, index) => { + const maxBitrate = split[index]?.maxBitrate; + return [variant, maxBitrate ? bpsToKbps(maxBitrate) : 0]; + }), + ); +}; + +/** + * Resolves the limit requested for a video track against {@link MAX_BANDWIDTH_LIMITS}. + * A number is a budget for the whole track, a Map holds per-layer limits; 0 means "use the cap(s)". + * - single stream: the number is clamped to the single-stream cap; a Map is rejected + * - simulcast: a number is split across the layers, then every layer is clamped to its own cap + */ +export const resolveTrackBandwidthLimit = ( + requested: TrackBandwidthLimit, + simulcast: boolean, + logger: Logger, +): TrackBandwidthLimit => { + if (!simulcast) { + if (typeof requested !== 'number') throw new Error('A non-simulcast track expects a single bandwidth limit'); + return resolveSingleStreamLimit(requested, logger); + } + + if (typeof requested !== 'number') return resolveSimulcastLimits(requested, logger); + + const layers = requested > 0 ? splitSimulcastBudget(requested, logger) : new Map(); + return resolveSimulcastLimits(layers, logger); +}; + +/** + * Builds the per-variant bitrates (in bps) reported to the server for a local track, + * derived from the limits the encoder was configured with. + */ +export const getVariantBitrates = (trackContext: TrackContextImpl): MediaEvent_VariantBitrate[] => { + const { maxBandwidth } = trackContext; + + if (typeof maxBandwidth === 'number') { + // 0 means the limit was never set, so report the default for the track kind. + const fallback = getTrackKind(trackContext) === 'audio' ? defaultBitrates.audio : defaultBitrates.video; + return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: kbpsToBps(maxBandwidth) || fallback }]; + } + + return [...maxBandwidth.entries()].map(([variant, limit]) => ({ variant, bitrate: kbpsToBps(limit) })); +}; diff --git a/packages/webrtc-client/src/bitrate.ts b/packages/webrtc-client/src/bitrate.ts deleted file mode 100644 index 8f5a29959..000000000 --- a/packages/webrtc-client/src/bitrate.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Variant } from '@fishjam-cloud/protobufs/shared'; - -import { type BandwidthLimit, type Logger, type SimulcastBandwidthLimit } from './types'; - -export type Bitrate = number; -export type Bitrates = Record | Bitrate; - -/** - * Hard caps (in kbps) for the bitrate of an outgoing video track. - * A limit passed to the SDK is never allowed to exceed these values, and an unset (0) limit resolves to them. - */ -export const MAX_BANDWIDTH_LIMITS = { - singleStream: 1500, - simulcast: { - [Variant.VARIANT_LOW]: 150, - [Variant.VARIANT_MEDIUM]: 500, - [Variant.VARIANT_HIGH]: 1500, - }, -} as const satisfies { singleStream: BandwidthLimit; simulcast: Record }; - -/** The variants that have a simulcast layer, ordered from the lowest to the highest resolution. */ -export const SIMULCAST_VARIANTS = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; - -export type SimulcastVariant = (typeof SIMULCAST_VARIANTS)[number]; - -const isSimulcastVariant = (variant: Variant): variant is SimulcastVariant => - (SIMULCAST_VARIANTS as readonly Variant[]).includes(variant); - -const resolveAgainstCap = ( - limit: BandwidthLimit | undefined, - cap: BandwidthLimit, - label: string, - logger: Logger, -): BandwidthLimit => { - if (!limit || limit <= 0) return cap; - if (limit > cap) { - logger.warn(`Desired ${label} bandwidth of ${limit} kbps exceeds the cap of ${cap} kbps`); - return cap; - } - return limit; -}; - -/** - * Resolves a single-stream video limit against {@link MAX_BANDWIDTH_LIMITS.singleStream}. - * 0 or a negative value means "use the cap"; higher values are clamped to it. - */ -export const resolveSingleStreamLimit = (limit: BandwidthLimit, logger: Logger): BandwidthLimit => - resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.singleStream, 'single stream', logger); - -/** - * Resolves per-variant simulcast limits against {@link MAX_BANDWIDTH_LIMITS.simulcast}. - * Every simulcast variant is present in the result: a missing, 0 or negative entry resolves to that variant's cap, - * and higher values are clamped to it. - */ -export const resolveSimulcastLimits = (limits: SimulcastBandwidthLimit, logger: Logger): SimulcastBandwidthLimit => { - const resolved: SimulcastBandwidthLimit = new Map(); - for (const variant of SIMULCAST_VARIANTS) { - const cap = MAX_BANDWIDTH_LIMITS.simulcast[variant]; - resolved.set(variant, resolveAgainstCap(limits.get(variant), cap, Variant[variant], logger)); - } - return resolved; -}; - -/** - * Resolves the limit of one simulcast layer against its cap. Throws for a variant that has no layer. - */ -export const resolveVariantBandwidthLimit = ( - variant: Variant, - limit: BandwidthLimit, - logger: Logger, -): BandwidthLimit => { - if (!isSimulcastVariant(variant)) throw new Error(`${Variant[variant]} is not a simulcast variant`); - - return resolveAgainstCap(limit, MAX_BANDWIDTH_LIMITS.simulcast[variant], Variant[variant], logger); -}; - -// Throughout the SDK "kbps" means 1024 bps, so that the values match the ones reported by the browser. -export const kbpsToBps = (kbps: BandwidthLimit): Bitrate => kbps * 1024; -export const bpsToKbps = (bps: Bitrate): BandwidthLimit => Math.round(bps / 1024); - -// The suggested bitrate values are based on our internal tests. -export const defaultBitrates = { - audio: 50_000 as Bitrate, - video: kbpsToBps(MAX_BANDWIDTH_LIMITS.singleStream), -} as const; - -export const UNLIMITED_BANDWIDTH: Bitrate = 0 as Bitrate; - -// The suggested bitrate values are based on our internal tests. -export const defaultSimulcastBitrates: { - [key in Variant]: Bitrate; -} = { - [Variant.VARIANT_HIGH]: kbpsToBps(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_HIGH]), - [Variant.VARIANT_MEDIUM]: kbpsToBps(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_MEDIUM]), - [Variant.VARIANT_LOW]: kbpsToBps(MAX_BANDWIDTH_LIMITS.simulcast[Variant.VARIANT_LOW]), - [Variant.VARIANT_UNSPECIFIED]: 0, - [Variant.UNRECOGNIZED]: 0, -}; diff --git a/packages/webrtc-client/src/index.ts b/packages/webrtc-client/src/index.ts index 14b090393..77e8706aa 100644 --- a/packages/webrtc-client/src/index.ts +++ b/packages/webrtc-client/src/index.ts @@ -1,8 +1,7 @@ -export type { SimulcastVariant } from './bitrate'; -export { MAX_BANDWIDTH_LIMITS, SIMULCAST_VARIANTS } from './bitrate'; +export type { SimulcastVariant } from './bandwidth'; +export { MAX_BANDWIDTH_LIMITS, resolveTrackBandwidthLimit, SIMULCAST_VARIANTS } from './bandwidth'; export { getLogger } from './logger'; export type { MediaEvent, SerializedMediaEvent } from './mediaEvent'; -export { resolveTrackBandwidthLimit } from './tracks/bandwidth'; export type { BandwidthLimit, DataCallback, diff --git a/packages/webrtc-client/src/tracks/Local.ts b/packages/webrtc-client/src/tracks/Local.ts index 19c2aef6c..279f64ef3 100644 --- a/packages/webrtc-client/src/tracks/Local.ts +++ b/packages/webrtc-client/src/tracks/Local.ts @@ -11,7 +11,7 @@ import { import { type MediaEvent_Track_SimulcastConfig } from '@fishjam-cloud/protobufs/server'; import type { Variant } from '@fishjam-cloud/protobufs/shared'; -import { resolveVariantBandwidthLimit } from '../bitrate'; +import { getVariantBitrates, resolveTrackBandwidthLimit, resolveVariantBandwidthLimit } from '../bandwidth'; import type { ConnectionManager } from '../ConnectionManager'; import type { EndpointWithTrackContext } from '../internal'; import { getTrackKind, isTrackKind, TrackContextImpl } from '../internal'; @@ -26,8 +26,6 @@ import type { WebRTCEndpointEvents, } from '../types'; import type { WebRTCEndpoint } from '../webRTCEndpoint'; -import { resolveTrackBandwidthLimit } from './bandwidth'; -import { getVariantBitrates } from './bitrates'; import { LocalTrack } from './LocalTrack'; import type { EndpointId, TrackId } from './TrackCommon'; diff --git a/packages/webrtc-client/src/tracks/LocalTrack.ts b/packages/webrtc-client/src/tracks/LocalTrack.ts index a1b2e17c8..16548c175 100644 --- a/packages/webrtc-client/src/tracks/LocalTrack.ts +++ b/packages/webrtc-client/src/tracks/LocalTrack.ts @@ -1,19 +1,9 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; -import type { Bitrate, Bitrates } from '../bitrate'; -import { defaultBitrates, defaultSimulcastBitrates, kbpsToBps, UNLIMITED_BANDWIDTH } from '../bitrate'; +import { kbpsToBps } from '../bandwidth'; import type { ConnectionManager } from '../ConnectionManager'; import type { TrackContextImpl } from '../internal'; -import type { - BandwidthLimit, - LocalTrackId, - Logger, - MediaStreamTrackId, - MLineId, - TrackBandwidthLimit, - TrackKind, -} from '../types'; -// import { generateCustomEvent } from '../mediaEvent'; +import type { BandwidthLimit, LocalTrackId, Logger, MediaStreamTrackId, MLineId, TrackBandwidthLimit } from '../types'; import type { WebRTCEndpoint } from '../webRTCEndpoint'; import { encodingToVariantMap, getEncodingParameters } from './encodings'; import { emitMutableEvents, getActionType } from './muteTrackUtils'; @@ -229,44 +219,6 @@ export class LocalTrack implements TrackCommon { this.mLineId = mLineId; }; - private isNotSimulcastTrack = (encodings: RTCRtpEncodingParameters[]): boolean => - encodings.length === 1 && !encodings[0]!.rid; - - public getTrackBitrates = (): Bitrates | undefined => { - const trackContext = this.trackContext; - const kind = this.trackContext.track?.kind as TrackKind | undefined; - - if (!trackContext.track) { - if (!trackContext.trackKind) { - throw new Error('trackContext.trackKind is empty'); - } - - return defaultBitrates[trackContext.trackKind]; - } - - if (!this.sender) return undefined; - - const encodings = this.sender.getParameters().encodings; - - if (this.isNotSimulcastTrack(encodings)) { - return encodings[0]!.maxBitrate || (kind ? defaultBitrates[kind] : UNLIMITED_BANDWIDTH); - } else if (kind === 'audio') { - throw 'Audio track cannot have multiple encodings'; - } - - return encodings - .filter((encoding) => encoding.rid) - .reduce( - (acc, encoding) => { - const variant = encodingToVariantMap[encoding.rid!] ?? Variant.VARIANT_UNSPECIFIED; - - acc[variant] = encoding.maxBitrate || defaultSimulcastBitrates[variant]; - return acc; - }, - {} as Record, - ); - }; - public getAudioLevel = async (): Promise<{ level: number } | null> => { if (!this.sender) return null; @@ -280,15 +232,4 @@ export class LocalTrack implements TrackCommon { return null; } }; - - public createTrackVariantBitratesEvent = () => { - // TODO implement this when simulcast is supported - // return generateCustomEvent({ - // type: 'trackVariantBitrates', - // data: { - // trackId: this.id, - // variantBitrates: this.getTrackBitrates(), - // }, - // }); - }; } diff --git a/packages/webrtc-client/src/tracks/bandwidth.ts b/packages/webrtc-client/src/tracks/bandwidth.ts deleted file mode 100644 index a7c84df7b..000000000 --- a/packages/webrtc-client/src/tracks/bandwidth.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Variant } from '@fishjam-cloud/protobufs/shared'; - -import { - bpsToKbps, - kbpsToBps, - resolveSimulcastLimits, - resolveSingleStreamLimit, - SIMULCAST_VARIANTS, - type SimulcastVariant, -} from '../bitrate'; -import type { Logger, SimulcastBandwidthLimit, TrackBandwidthLimit } from '../types'; - -export const SIMULCAST_LAYER_SCALE: Record = { - [Variant.VARIANT_LOW]: 4, - [Variant.VARIANT_MEDIUM]: 2, - [Variant.VARIANT_HIGH]: 1, -}; - -export const splitBandwidth = ( - rtcRtpEncodingParameters: RTCRtpEncodingParameters[], - maxBandwidth: number, - logger: Logger, -): RTCRtpEncodingParameters[] => { - const bandwidth = kbpsToBps(maxBandwidth); - - if (bandwidth === 0) { - return rtcRtpEncodingParameters.map((encoding) => ({ - ...encoding, - maxBitrate: undefined, - })); - } - - if (rtcRtpEncodingParameters.length === 0) { - // This most likely is a race condition. Log an error and prevent catastrophic failure - logger.error("Attempted to limit bandwidth of the track that doesn't have any encodings"); - return rtcRtpEncodingParameters.map((encoding) => ({ ...encoding })); - } - if (!rtcRtpEncodingParameters[0]) throw new Error('RTCRtpEncodingParameters is in invalid state'); - - // We are solving the following equation: - // x + (k0/k1)^2 * x + (k0/k2)^2 * x + ... + (k0/kn)^2 * x = bandwidth - // where x is the bitrate for the first encoding, kn are scaleResolutionDownBy factors - // square is dictated by the fact that k0/kn is a scale factor, but we are interested in the total number of pixels in the image - const firstScaleDownBy = rtcRtpEncodingParameters[0].scaleResolutionDownBy || 1; - const bitrate_parts = rtcRtpEncodingParameters.reduce( - (acc, value) => acc + (firstScaleDownBy / (value.scaleResolutionDownBy || 1)) ** 2, - 0, - ); - const x = bandwidth / bitrate_parts; - - return rtcRtpEncodingParameters.map((encoding) => ({ - ...encoding, - maxBitrate: x * (firstScaleDownBy / (encoding.scaleResolutionDownBy || 1)) ** 2, - })); -}; - -/** Splits a total budget (in kbps) across the simulcast layers proportionally to their pixel count. */ -export const splitSimulcastBudget = (budget: number, logger: Logger): SimulcastBandwidthLimit => { - const layers = SIMULCAST_VARIANTS.map((variant) => ({ scaleResolutionDownBy: SIMULCAST_LAYER_SCALE[variant] })); - const split = splitBandwidth(layers, budget, logger); - - return new Map( - SIMULCAST_VARIANTS.map((variant, index) => { - const maxBitrate = split[index]?.maxBitrate; - return [variant, maxBitrate ? bpsToKbps(maxBitrate) : 0]; - }), - ); -}; - -/** - * Resolves the limit requested for a video track against {@link MAX_BANDWIDTH_LIMITS}. - * A number is a budget for the whole track, a Map holds per-layer limits; 0 means "use the cap(s)". - * - single stream: the number is clamped to the single-stream cap; a Map is rejected - * - simulcast: a number is split across the layers, then every layer is clamped to its own cap - */ -export const resolveTrackBandwidthLimit = ( - requested: TrackBandwidthLimit, - simulcast: boolean, - logger: Logger, -): TrackBandwidthLimit => { - if (!simulcast) { - if (typeof requested !== 'number') throw new Error('A non-simulcast track expects a single bandwidth limit'); - return resolveSingleStreamLimit(requested, logger); - } - - if (typeof requested !== 'number') return resolveSimulcastLimits(requested, logger); - - const layers = requested > 0 ? splitSimulcastBudget(requested, logger) : new Map(); - return resolveSimulcastLimits(layers, logger); -}; diff --git a/packages/webrtc-client/src/tracks/bitrates.ts b/packages/webrtc-client/src/tracks/bitrates.ts deleted file mode 100644 index 4fe05f7c8..000000000 --- a/packages/webrtc-client/src/tracks/bitrates.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { MediaEvent_VariantBitrate } from '@fishjam-cloud/protobufs/peer'; -import { Variant } from '@fishjam-cloud/protobufs/shared'; - -import { defaultBitrates, kbpsToBps } from '../bitrate'; -import { getTrackKind, type TrackContextImpl } from '../internal'; - -/** - * Builds the per-variant bitrates (in bps) reported to the server for a local track, - * derived from the limits the encoder was configured with. - */ -export const getVariantBitrates = (trackContext: TrackContextImpl): MediaEvent_VariantBitrate[] => { - const { maxBandwidth } = trackContext; - - if (typeof maxBandwidth === 'number') { - // 0 means the limit was never set, so report the default for the track kind. - const fallback = getTrackKind(trackContext) === 'audio' ? defaultBitrates.audio : defaultBitrates.video; - return [{ variant: Variant.VARIANT_UNSPECIFIED, bitrate: kbpsToBps(maxBandwidth) || fallback }]; - } - - return [...maxBandwidth.entries()].map(([variant, limit]) => ({ variant, bitrate: kbpsToBps(limit) })); -}; diff --git a/packages/webrtc-client/src/tracks/encodings.ts b/packages/webrtc-client/src/tracks/encodings.ts index 7d761b96e..2f4a4914d 100644 --- a/packages/webrtc-client/src/tracks/encodings.ts +++ b/packages/webrtc-client/src/tracks/encodings.ts @@ -1,7 +1,7 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; +import { splitBandwidth } from '../bandwidth'; import type { Logger } from '../types'; -import { splitBandwidth } from './bandwidth'; export const getEncodingParameters = ( parameters: RTCRtpSendParameters, diff --git a/packages/webrtc-client/src/tracks/transceivers.ts b/packages/webrtc-client/src/tracks/transceivers.ts index e7665fd5a..11d3cfd55 100644 --- a/packages/webrtc-client/src/tracks/transceivers.ts +++ b/packages/webrtc-client/src/tracks/transceivers.ts @@ -1,9 +1,8 @@ import { Variant } from '@fishjam-cloud/protobufs/shared'; -import { kbpsToBps } from '../bitrate'; +import { kbpsToBps, SIMULCAST_LAYER_SCALE, splitBandwidth } from '../bandwidth'; import type { TrackContextImpl } from '../internal'; import type { Logger, SimulcastBandwidthLimit, TrackBandwidthLimit } from '../types'; -import { SIMULCAST_LAYER_SCALE, splitBandwidth } from './bandwidth'; import { encodingToVariantMap } from './encodings'; export const createTransceiverConfig = (trackContext: TrackContextImpl, logger: Logger): RTCRtpTransceiverInit => { diff --git a/packages/webrtc-client/src/webRTCEndpoint.ts b/packages/webrtc-client/src/webRTCEndpoint.ts index 70e3b322f..07144a6f6 100644 --- a/packages/webrtc-client/src/webRTCEndpoint.ts +++ b/packages/webrtc-client/src/webRTCEndpoint.ts @@ -18,6 +18,7 @@ import EventEmitter from 'events'; import type TypedEmitter from 'typed-emitter'; import { v4 as uuidv4 } from 'uuid'; +import { resolveTrackBandwidthLimit } from './bandwidth'; import { CommandsQueue } from './CommandsQueue'; import { ConnectionManager } from './ConnectionManager'; import { DataChannelManager } from './dataChannels/DataChannelManager'; @@ -26,7 +27,6 @@ import type { EndpointWithTrackContext } from './internal'; import { getLogger } from './logger'; import type { SerializedMediaEvent } from './mediaEvent'; import { deserializeServerMediaEvent, serializePeerMediaEvent } from './mediaEvent'; -import { resolveTrackBandwidthLimit } from './tracks/bandwidth'; import { Local } from './tracks/Local'; import { LocalTrackManager } from './tracks/LocalTrackManager'; import { Remote } from './tracks/Remote'; diff --git a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts index 05e9d445d..495547da1 100644 --- a/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts +++ b/packages/webrtc-client/tests/methods/bandwidthLimits.test.ts @@ -2,9 +2,8 @@ import { FakeMediaStreamTrack } from 'fake-mediastreamtrack'; import { afterEach, expect, it, vi } from 'vitest'; import { MAX_BANDWIDTH_LIMITS, Variant, WebRTCEndpoint } from '../../src'; -import { resolveVariantBandwidthLimit } from '../../src/bitrate'; +import { resolveTrackBandwidthLimit, resolveVariantBandwidthLimit, splitSimulcastBudget } from '../../src/bandwidth'; import { deserializePeerMediaEvent, serializeServerMediaEvent } from '../../src/mediaEvent'; -import { resolveTrackBandwidthLimit, splitSimulcastBudget } from '../../src/tracks/bandwidth'; import { createTransceiverConfig } from '../../src/tracks/transceivers'; import { createAddLocalTrackSDPOffer, createConnectedEventWithOneEndpoint } from '../fixtures'; import { mockMediaStream, mockRTCPeerConnection } from '../mocks';