From fb76d7a6e4f1092418e35967be441482ee99d0cc Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 9 Apr 2026 16:07:31 +0200 Subject: [PATCH 1/4] fix(webrtc): Calculate maxBitrates based on HPBs maxstreambits Signed-off-by: Joas Schilling --- src/utils/signaling.js | 8 +++++ src/utils/webrtc/simplewebrtc/simplewebrtc.js | 30 +++++++++++++++---- src/utils/webrtc/simplewebrtc/webrtc.js | 1 + 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/utils/signaling.js b/src/utils/signaling.js index cbd039a4373..a76eba3b86d 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -71,6 +71,7 @@ function Base(settings) { this.signalingConnectionTimeout = null this.signalingConnectionWarning = null this.signalingConnectionError = null + this.maxStreamBits = 1048576 } Signaling.Base = Base @@ -1297,6 +1298,13 @@ Signaling.Standalone.prototype.joinResponseReceived = function(data, token) { } this._rejoinRoomAfterInvalidSession = null + + // Apply room-specific max bitrate + const totalBps = data.room?.bandwidth?.maxstreambitrate + if (totalBps && typeof totalBps === 'number' && totalBps > 0) { + this.maxStreamBits = totalBps + } + this.signalingRoomJoined = token if (this.pendingJoinCall && token === this.pendingJoinCall.token) { const pendingJoinCallResolve = this.pendingJoinCall.resolve diff --git a/src/utils/webrtc/simplewebrtc/simplewebrtc.js b/src/utils/webrtc/simplewebrtc/simplewebrtc.js index 9af8778cdf6..7bab20793db 100644 --- a/src/utils/webrtc/simplewebrtc/simplewebrtc.js +++ b/src/utils/webrtc/simplewebrtc/simplewebrtc.js @@ -8,6 +8,30 @@ import webrtcSupport from 'webrtcsupport' import WildEmitter from 'wildemitter' import WebRTC from './webrtc.js' +/** + * Split totalBps into integer low/medium/high parts with ratio 1:4:16 (total 21 parts). + * Returned values are integers. Any remainder from integer division is omitted. + * + * @param {number} totalBps - Total bandwidth in bps (non-negative number). + * @returns {{ low: number, medium: number, high: number }} + */ +function splitBandwidthIntegersOmitRemainder(totalBps = 1048576) { + if (typeof totalBps !== 'number' || totalBps < 0) { + totalBps = 1048576 + } + + // Divide into 21 parts and distribute them in 2^2 way across the quality levels + // Additionally we divide and multiple with 100 but round down in between + // to create some safety bits + const partValue = Math.floor(totalBps / 21 / 100) * 100 + + return { + low: partValue, + medium: partValue * 4, + high: partValue * 16, + } +} + /** * @param {object} opts the options object. */ @@ -19,11 +43,7 @@ export default function SimpleWebRTC(opts) { debug: false, enableDataChannels: true, enableSimulcast: false, - maxBitrates: { - high: 900000, - medium: 300000, - low: 100000, - }, + maxBitrates: splitBandwidthIntegersOmitRemainder(opts.connection?.maxStreamBits), autoRequestMedia: false, receiveMedia: { offerToReceiveAudio: 1, diff --git a/src/utils/webrtc/simplewebrtc/webrtc.js b/src/utils/webrtc/simplewebrtc/webrtc.js index 3b443fc33d1..bb6a8fd3938 100644 --- a/src/utils/webrtc/simplewebrtc/webrtc.js +++ b/src/utils/webrtc/simplewebrtc/webrtc.js @@ -27,6 +27,7 @@ export default function WebRTC(opts) { }, enableDataChannels: true, enableSimulcast: false, + // Overwritten by simplewebrtc.js maxBitrates: { high: 900000, medium: 300000, From 43577e6ac1b0b9452c6f2c0efc9b357a571973cd Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 20 Jul 2026 11:11:18 +0200 Subject: [PATCH 2/4] fixup! fall back to default value Signed-off-by: Maksim Sukharev --- src/utils/signaling.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/utils/signaling.js b/src/utils/signaling.js index a76eba3b86d..b139a6e0c58 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -28,6 +28,11 @@ import { messagePleaseTryToReload } from './talkDesktopUtils.ts' const actorStore = useActorStore(pinia) +/** + * Default maximum bitrate (in bits per second) assumed for a published stream + */ +const DEFAULT_MAX_STREAM_BITS = 1048576 + const Signaling = { Base: {}, Internal: {}, @@ -71,7 +76,7 @@ function Base(settings) { this.signalingConnectionTimeout = null this.signalingConnectionWarning = null this.signalingConnectionError = null - this.maxStreamBits = 1048576 + this.maxStreamBits = DEFAULT_MAX_STREAM_BITS } Signaling.Base = Base @@ -1299,10 +1304,13 @@ Signaling.Standalone.prototype.joinResponseReceived = function(data, token) { this._rejoinRoomAfterInvalidSession = null - // Apply room-specific max bitrate + // Apply the room-specific max bitrate provided in server response + // Falls back to the default value when a room has no configured limit const totalBps = data.room?.bandwidth?.maxstreambitrate - if (totalBps && typeof totalBps === 'number' && totalBps > 0) { + if (typeof totalBps === 'number' && totalBps > 0) { this.maxStreamBits = totalBps + } else { + this.maxStreamBits = DEFAULT_MAX_STREAM_BITS } this.signalingRoomJoined = token From 829612798f2adaf4d7cdb39005dd636e12b3597d Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 20 Jul 2026 11:24:19 +0200 Subject: [PATCH 3/4] fixup! extract to util, reuse for Peer#getOffer() Signed-off-by: Maksim Sukharev --- src/utils/signaling.js | 6 +-- src/utils/webrtc/simplewebrtc/peer.js | 19 ++++++-- src/utils/webrtc/simplewebrtc/simplewebrtc.js | 27 +----------- .../webrtc/simplewebrtc/simulcastBitrates.ts | 44 +++++++++++++++++++ 4 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 src/utils/webrtc/simplewebrtc/simulcastBitrates.ts diff --git a/src/utils/signaling.js b/src/utils/signaling.js index b139a6e0c58..b9d5a369ac9 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -25,14 +25,10 @@ import CancelableRequest from './CancelableRequest.ts' import Encryption from './e2ee/encryption.js' import { convertToUnix } from './formattedTime.ts' import { messagePleaseTryToReload } from './talkDesktopUtils.ts' +import { DEFAULT_MAX_STREAM_BITS } from './webrtc/simplewebrtc/simulcastBitrates.ts' const actorStore = useActorStore(pinia) -/** - * Default maximum bitrate (in bits per second) assumed for a published stream - */ -const DEFAULT_MAX_STREAM_BITS = 1048576 - const Signaling = { Base: {}, Internal: {}, diff --git a/src/utils/webrtc/simplewebrtc/peer.js b/src/utils/webrtc/simplewebrtc/peer.js index 2f592176c44..7fa0b2f2446 100644 --- a/src/utils/webrtc/simplewebrtc/peer.js +++ b/src/utils/webrtc/simplewebrtc/peer.js @@ -8,6 +8,7 @@ import adapter from 'webrtc-adapter' import webrtcSupport from 'webrtcsupport' import WildEmitter from 'wildemitter' import { isSafari } from '../../browserCheck.ts' +import { getSimulcastMaxBitrates } from './simulcastBitrates.ts' /** * @param {object} stream the stream object. @@ -356,6 +357,17 @@ function mungeSdpForSimulcasting(sdp) { } /* eslint-enable */ +Peer.prototype._getMaxBitrates = function() { + // Read the live per-stream limit from the signaling connection so a room + // switch or reconnect is reflected, instead of the value frozen at + // SimpleWebRTC construction time. + const maxStreamBits = this.parent?.config?.connection?.maxStreamBits + if (maxStreamBits) { + return getSimulcastMaxBitrates(maxStreamBits) + } + return this.maxBitrates +} + Peer.prototype.offer = function(options) { const sendVideo = this.sendVideoIfAvailable && this.type !== 'screen' if (sendVideo && this.enableSimulcast && adapter.browserDetails.browser === 'firefox') { @@ -368,22 +380,23 @@ Peer.prototype.offer = function(options) { if (!parameters) { parameters = {} } + const maxBitrates = this._getMaxBitrates() parameters.encodings = [ { rid: 'h', active: true, - maxBitrate: this.maxBitrates.high, + maxBitrate: maxBitrates.high, }, { rid: 'm', active: true, - maxBitrate: this.maxBitrates.medium, + maxBitrate: maxBitrates.medium, scaleResolutionDownBy: 2, }, { rid: 'l', active: true, - maxBitrate: this.maxBitrates.low, + maxBitrate: maxBitrates.low, scaleResolutionDownBy: 4, }, ] diff --git a/src/utils/webrtc/simplewebrtc/simplewebrtc.js b/src/utils/webrtc/simplewebrtc/simplewebrtc.js index 7bab20793db..11710329604 100644 --- a/src/utils/webrtc/simplewebrtc/simplewebrtc.js +++ b/src/utils/webrtc/simplewebrtc/simplewebrtc.js @@ -6,32 +6,9 @@ import mockconsole from 'mockconsole' import webrtcSupport from 'webrtcsupport' import WildEmitter from 'wildemitter' +import { getSimulcastMaxBitrates } from './simulcastBitrates.ts' import WebRTC from './webrtc.js' -/** - * Split totalBps into integer low/medium/high parts with ratio 1:4:16 (total 21 parts). - * Returned values are integers. Any remainder from integer division is omitted. - * - * @param {number} totalBps - Total bandwidth in bps (non-negative number). - * @returns {{ low: number, medium: number, high: number }} - */ -function splitBandwidthIntegersOmitRemainder(totalBps = 1048576) { - if (typeof totalBps !== 'number' || totalBps < 0) { - totalBps = 1048576 - } - - // Divide into 21 parts and distribute them in 2^2 way across the quality levels - // Additionally we divide and multiple with 100 but round down in between - // to create some safety bits - const partValue = Math.floor(totalBps / 21 / 100) * 100 - - return { - low: partValue, - medium: partValue * 4, - high: partValue * 16, - } -} - /** * @param {object} opts the options object. */ @@ -43,7 +20,7 @@ export default function SimpleWebRTC(opts) { debug: false, enableDataChannels: true, enableSimulcast: false, - maxBitrates: splitBandwidthIntegersOmitRemainder(opts.connection?.maxStreamBits), + maxBitrates: getSimulcastMaxBitrates(opts.connection?.maxStreamBits), autoRequestMedia: false, receiveMedia: { offerToReceiveAudio: 1, diff --git a/src/utils/webrtc/simplewebrtc/simulcastBitrates.ts b/src/utils/webrtc/simplewebrtc/simulcastBitrates.ts new file mode 100644 index 00000000000..6913d976d7e --- /dev/null +++ b/src/utils/webrtc/simplewebrtc/simulcastBitrates.ts @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Default maximum bitrate for a single published stream + * when the signaling server does not advertise a limit. (1 Mbps) + */ +export const DEFAULT_MAX_STREAM_BITS = 1_048_576 +/** + * Expected maximum bitrate for a hi-quality stream (1920x1080, 30fps) + * Over-provisioning for a minimum "high" resolution (1280x720, 30fps) + */ +export const HIGH_MAX_STREAM_BITS = 2_764_800 +/** + * Expected maximum bitrate for a mid-quality stream (640x360, 30fps) + */ +export const MEDIUM_MAX_STREAM_BITS = 300_000 +/** + * Expected maximum bitrate for a low-quality stream (320x180, 30fps) + */ +export const LOW_MAX_STREAM_BITS = 100_000 + +/** + * Compute per-layer simulcast maxBitrate ceilings from the publisher's total + * bandwidth limit. Low and medium layers use fixed, resolution-appropriate + * caps; only the high layer scales with the available budget. + * + * Firefox only (applied via RTCRtpSender.setParameters()); + * Chromium/Safari use SDP munging and let REMB distribute the bitrate. + * + * @param totalBps - Total bandwidth in bps. + */ +export function getSimulcastMaxBitrates(totalBps: number = DEFAULT_MAX_STREAM_BITS) { + if (typeof totalBps !== 'number' || totalBps <= 0) { + totalBps = DEFAULT_MAX_STREAM_BITS + } + return { + high: Math.min(Math.round(0.9 * totalBps), HIGH_MAX_STREAM_BITS), + medium: MEDIUM_MAX_STREAM_BITS, + low: LOW_MAX_STREAM_BITS, + } +} From c2d2fe7216fcc337f112ff8cbc2622726be4793c Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 20 Jul 2026 14:51:10 +0200 Subject: [PATCH 4/4] [wip] add debugger util `window.OCA.Talk.debugPublisherStats()` to check sent kbps by each layer Signed-off-by: Maksim Sukharev --- src/utils/webrtc/webrtc.js | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/utils/webrtc/webrtc.js b/src/utils/webrtc/webrtc.js index 91869c50964..364e98b6a64 100644 --- a/src/utils/webrtc/webrtc.js +++ b/src/utils/webrtc/webrtc.js @@ -628,6 +628,84 @@ export function initWebRtc(signaling, _callParticipantCollection, _localCallPart } window.OCA.Talk.SimpleWebRTC = webrtc + // debug helper to inspect publisher simulcast layers + // (configured ceilings + live per-layer sent bitrate/res). + // Call OCA.Talk.debugPublisherStats() to start, again to stop. + window.OCA.Talk.debugPublisherStats = function() { + if (window.__pubStats) { + clearInterval(window.__pubStats) + window.__pubStats = null + console.log('[pub] stopped') + return + } + const rtc = window.OCA?.Talk?.SimpleWebRTC?.webrtc + if (!rtc) { + console.warn('No SimpleWebRTC instance') + return + } + const publishers = rtc.peers.filter((p) => p.pc && p.pc.getSenders().some((s) => s.track && s.track.kind === 'video')) + if (!publishers.length) { + console.warn('No publishing video peer (are you sending video?)') + return + } + publishers.forEach((p) => p.pc.getSenders().forEach((s) => { + if (s.track && s.track.kind === 'video') { + console.log( + '[pub]', + p.id, + 'configured:', + (s.getParameters().encodings || []).map((e) => ({ rid: e.rid, maxBitrate: e.maxBitrate })), + ) + } + })) + const prev = new Map() + const history = new Map() // key -> rolling window of recent kbps samples + const WINDOW = 10 // ticks to average over (~20s at the 2s interval) + const avg = (arr) => (arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : '…') + window.__pubStats = setInterval(async () => { + for (const p of publishers) { + const rows = [] + let totalCur = 0 + let totalAvg = 0 + ;(await p.pc.getStats()).forEach((r) => { + if (r.type === 'outbound-rtp' && r.kind === 'video') { + const key = p.id + ':' + (r.rid ?? r.ssrc) + const p0 = prev.get(key) + const kbps = (p0 && r.timestamp > p0.ts) + ? Math.round(8 * (r.bytesSent - p0.bytes) / (r.timestamp - p0.ts)) + : null + prev.set(key, { bytes: r.bytesSent, ts: r.timestamp }) + const h = history.get(key) || [] + if (kbps !== null) { + h.push(kbps) + while (h.length > WINDOW) { + h.shift() + } + history.set(key, h) + totalCur += kbps + totalAvg += avg(h) + } + rows.push({ + rid: r.rid ?? '-', + res: (r.frameWidth || '?') + 'x' + (r.frameHeight || '?'), + fps: r.framesPerSecond ?? '-', + cur_kbps: kbps ?? '…', + avg_kbps: avg(h), + active: r.active ?? '-', + limit: r.qualityLimitationReason ?? '-', + }) + } + }) + if (rows.length) { + console.log('[pub]', p.id, new Date().toLocaleTimeString(), + `— TOTAL cur ${totalCur} kbps / avg ${totalAvg} kbps (window ${WINDOW})`) + console.table(rows) + } + } + }, 2000) + console.log('[pub] polling every 2s, averaging last', WINDOW, 'ticks — call OCA.Talk.debugPublisherStats() again to stop') + } + signaling.on('pullMessagesStoppedOnFail', function() { // Force leaving the call in WebRTC; when pulling messages stops due // to failures the room is left, and leaving the room indirectly