diff --git a/src/utils/signaling.js b/src/utils/signaling.js index cbd039a4373..b9d5a369ac9 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -25,6 +25,7 @@ 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) @@ -71,6 +72,7 @@ function Base(settings) { this.signalingConnectionTimeout = null this.signalingConnectionWarning = null this.signalingConnectionError = null + this.maxStreamBits = DEFAULT_MAX_STREAM_BITS } Signaling.Base = Base @@ -1297,6 +1299,16 @@ Signaling.Standalone.prototype.joinResponseReceived = function(data, token) { } this._rejoinRoomAfterInvalidSession = null + + // 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 (typeof totalBps === 'number' && totalBps > 0) { + this.maxStreamBits = totalBps + } else { + this.maxStreamBits = DEFAULT_MAX_STREAM_BITS + } + this.signalingRoomJoined = token if (this.pendingJoinCall && token === this.pendingJoinCall.token) { const pendingJoinCallResolve = this.pendingJoinCall.resolve 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 9af8778cdf6..11710329604 100644 --- a/src/utils/webrtc/simplewebrtc/simplewebrtc.js +++ b/src/utils/webrtc/simplewebrtc/simplewebrtc.js @@ -6,6 +6,7 @@ import mockconsole from 'mockconsole' import webrtcSupport from 'webrtcsupport' import WildEmitter from 'wildemitter' +import { getSimulcastMaxBitrates } from './simulcastBitrates.ts' import WebRTC from './webrtc.js' /** @@ -19,11 +20,7 @@ export default function SimpleWebRTC(opts) { debug: false, enableDataChannels: true, enableSimulcast: false, - maxBitrates: { - high: 900000, - medium: 300000, - low: 100000, - }, + 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, + } +} 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, 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