|
|
/** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */
/** * Talkbuchet is the helper tool for load/stress testing of Nextcloud Talk. * * Talkbuchet is a JavaScript script (Talkbuchet.js), and it is run using a web * browser. A Python script (Talkbuchet-cli.py) is provided to launch a web * browser, load Talkbuchet and control it from a command line interface (which * requires Selenium and certain Python packages to be available in the system). * A Bash script (Talkbuchet-run.sh) is provided to set up a Docker container * with Selenium, a web browser and all the needed Python dependencies for * Talkbuchet-cli.py. * * Please refer to the documentation in Talkbuchet-cli.py and Talkbuchet-run.sh * for information on how to control Talkbuchet and easily run it. * * A High Performance Backend (HPB) server must be configured in Nextcloud Talk * to use Talkbuchet. * * HOW TO SETUP (without using the CLI): * ----------------------------------------------------------------------------- * - In the browser, log in the Nextcloud server (with the same user as in this * script). * - Copy and paste the full script in the console of the browser. * * * * ----------------------------------------------------------------------------- * SIEGE MODE * ----------------------------------------------------------------------------- * * Siege mode is used for load testing of the Janus gateway running in the HPB * server. In siege mode Talkbuchet creates M publishers and N subscribers for * each publisher for a total of M+M*N connections with the Janus gateway. * Therefore, it can be used to verify that the server will be able to withstand * certain number of media connections (audio, video or both audio and video). * * In a real call every participant will subscribe to the publishers of the * other participants. That is, for X participants, Y of them publishers, there * will be (X-1)*Y subscribers. However, note that some participants may not be * publishers, for example, if the do not have publishing permissions, or if * they never had a microphone nor a camera selected (although they will be * publishers if the microphone and camera are selected but disabled, or if the * microphone or camera is no longer selected but was selected at some point * during the call). * * For example, in a normal call between 10 participants there will be 10 * publishers and (10-1)*10=90 subscribers for a total of 100 connections. * However, if only two participants have publishing permissions then there will * be 2 publishers and (10-1)*2=18 subscribers for a total of 20 connections. * * To use the siege mode the signaling server of the HPB must be configured to * allow subscribing any stream: * https://github.com/strukturag/nextcloud-spreed-signaling/blob/a663dd43f90b0876630250012bb716136920fcd3/server.conf.in#L32-L35
* * HOW TO RUN: * ----------------------------------------------------------------------------- * - Set the user and appToken (a user must be used; guests do not work; * generate an apptoken at index.php/settings/user/security) by calling * "setCredentials(user, appToken)" in the console. * - If HPB clustering is enabled, set the token of a conversation (otherwise * leave empty) by calling "setToken(token)" in the console. * - If media other than just audio should be used, start it by calling * "startMedia(audio, video)" in the console. * - Set the desired numbers of publishers and subscribers per publisher (in a * regular call with N participants you would have N publishers and N-1 * subscribers) by calling "setPublishersAndSubscribersCount(publishersCount, * subscribersPerPublisherCount)" in the console. * - Once all the needed parameters are set execute "siege()" in the console. * - To run it again execute "siege()" again in the console; if any parameter * needs to be changed it is recommended to first stop the previous siege by * calling "closeConnections()" in the console before changing the parameters. * * HOW TO ENABLE AND DISABLE THE MEDIA DURING A TEST: * ----------------------------------------------------------------------------- * You can manually enable and disable the media during a test by running the * following commands in the browser console: * - For audio: * setAudioEnabled(TRUE_OR_FALSE) * - For video: * setVideoEnabled(TRUE_OR_FALSE) * * Note that you can only enable and disable the original media specified in the * "getUserMedia" call. * * Additionally, you can also enable and disable the sent media streams during * a test by running the following commands in the browser console: * - For audio: * setSentAudioStreamEnabled(TRUE_OR_FALSE) * - For video: * setSentVideoStreamEnabled(TRUE_OR_FALSE) * * Currently Firefox behaviour is the same whether the media is disabled or the * sent media stream is disabled, so this makes no difference. Chromium, on the * other hand, sends some media data when the media is disabled, but stops it * when the sent media stream is disabled. In any case, please note that some * data will be always sent as long as there is a connection open, even if no * media is being sent. * * HOW TO CALIBRATE: * ----------------------------------------------------------------------------- * The script starts as many publishers and subscribers for each publisher as * specified, each one being a real and independent peer connection to Janus. * Therefore, how many peer connections can be established with a single script * run depends on the client machine, both its CPU and its network. * * You should first "calibrate" the client by running it with different numbers * of publishers and subscribers to check how many peer connections are * supported before doing the actual stress test on Janus. * * It is most likely that the CPU or network of a single client will be * saturated before Janus is. The script will write to the console when a peer * could not be connected or if it was disconnected after being connected; if * there is a large number of those messages or the CPU consumption is very high * the client has probably reached its limit. * * Besides the messages written by the script itself you can manually check the * connection state by running the following commands in the browser console: * - For the publishers: * checkPublishersConnections() * - For the subscribers: * checkSubscribersConnections() * * DISCLAIMER: * ----------------------------------------------------------------------------- * Talk performs some optimizations during calls, like reducing the video * quality based on the number of participants. This script does not take into * account those things; it is meant to be used for stress tests of Janus rather * than to accurately simulate Talk behaviour. * * Independently of that, please note that an accurate simulation would not be * possible given that a single client has to behave as several different * clients. In a real call each client could have a different behaviour (not * only due to sending different media streams, but also from having different * CPU and network conditions), and that might even also affect Janus and the * server regarding very low level things (but relevant for performance on * highly loaded systems) like caches. * * Nevertheless, if those things are kept in mind the script can be anyway used * as a rough performance test of specific call scenarios. Just remember that in * regular calls peer connections increase quadratically with the number of * participants; specifically, publishers increase linearly while subscribers * increase quadratically. * * For example a call between 10 participants has 10 publishers and 90 * subscribers (9 for each publisher) for a total of 100 peer connections, while * a call between 30 participants has 30 publishers and 870 subscribers (29 for * each publisher) for a total of 900 peer connections.
* Due to this, if you have several clients that can only withstand ~100 peer * connections each in order to simulate a 30 participants call you could run * the script with 3 publishers and 29 subscribers per publisher on 10 clients * at the same time. * * * * ----------------------------------------------------------------------------- * VIRTUAL PARTICIPANT MODE * ----------------------------------------------------------------------------- * * Virtual participant mode is used for load testing of clients. From the point * of view of the clients in a call the virtual participants are real * participants (they can be just listeners, but they can publish audio and/or * video), so several virtual participants can be added to a call to verify if * a client running on a specific system will be able to withstand certain * number of participants in a call. * * The reason to use virtual participants instead of real participants is that * virtual participants are either just in the call or publishing media, but * they will not subscribe to any other participant (which does not affect the * other clients, only the HPB server). Due to this they need much less * resources than real participants and therefore the system launching the test * would be able to add a much higher number of virtual participants than of * real participants. * * However, note that each web browser session can execute a single virtual * participant. Due to this it is recommended to use Talkbuchet CLI instead to * easily start several web browser sessions, each one with its own virtual * participant, and control the virtual participants from the CLI. * * HOW TO RUN: * ----------------------------------------------------------------------------- * - Set the user and appToken (generate an apptoken at * index.php/settings/user/security) by calling * "setCredentials(user, appToken)" in the console. If credentials are not set * a guest user will be used instead. * - Set the token of the conversation by calling "setToken(token)" in the * console. * - Start the desired media by calling "startMedia(audio, video)" in the * console. If not called (or both parameters are false) no media will be * used. * - Once all the needed parameters are set execute "startVirtualParticipant()" * in the console. * - To run it again execute "stopVirtualParticipant()" and then * "startVirtualParticipant()" again in the console; if any parameter * needs to be changed do it before starting the virtual participant again. * * TRIGGERING ACTIONS BY THE VIRTUAL PARTICIPANT: * ----------------------------------------------------------------------------- * In general, for load testing of clients it is enough to start the virtual * participant and that is it. However, for development purposes it can be * useful to simulate certain actions by the virtual participant. * * Currently all the actions are simulated through data channel messages; the * equivalent signaling messages are not sent. * * The nick of the virtual participant can be set with: * sendNickThroughDataChannel(NICK) * * Note that sending the nick is not limited to guests; even if the virtual * participant is a registered user a nick can be sent. Moreover, clients may * not show any name at all for the virtual participant of a registered user * until a nick is sent, even if the user name is known by the client. * * If the virtual participant is a publisher the media can be enabled and * disabled as described in the siege mode. However, that does not notify other * participants in the call that the media state has changed. That can be done * instead with: * sendMediaEnabledStateThroughDataChannel(AUDIO_OR_VIDEO, TRUE_OR_FALSE) * * Similarly, when audio is enabled the message to notify other participants * when the virtual participant started or stopped speaking can be sent with: * sendSpeakingStateThroughDataChannel(TRUE_OR_FALSE) * * Note that the message is independent of the actual audio being sent; a * "speaking" event can be sent when audio is silent, and a "stoppedSpeaking" * event can be sent when audio is at full volume. * * DISCLAIMER: * ----------------------------------------------------------------------------- * Like in siege mode, virtual participants mode does not take into account the * optimizations performed by Talk during calls, like reducing the video * quality based on the number of participants. This script is not meant to * accurately simulate Talk behaviour, but to provide a tool to perform rough * performance tests of specific call scenarios. * * Therefore, it should be kept in mind that in a real call with several video * participants the performance is very likely to be better than with several * virtual participants with video, as in that case the video quality will not * be adjusted based on the number of participants. Nevertheless, this script * could still be used to simulate a worst case scenario. */
// Sieges with guest users do not currently work, as they must join the
// conversation to not be kicked out from the signaling server. However, joining
// the conversation a second time causes the first guest to be unregistered.
// Regular users do not need to join the conversation, so the same user can be
// connected several times to the HPB.
let user = '' let appToken = ''
// The conversation token is only strictly needed for guests or if HPB
// clustering is enabled.
let token = ''
// Number of streams to send
let publishersCount = 5 // Number of streams to receive
let subscribersPerPublisherCount = 40
const mediaConstraints = { audio: true, video: false, }
let connectionWarningTimeout = 5000
/* * End of configuration section */
// To run the script the current page in the browser must be a page of the
// target Nextcloud instance, as cross-doman requests are not allowed, so the
// host is directly got from the current location.
const host = 'https://' + window.location.host
const capabitiliesUrl = host + '/ocs/v1.php/cloud/capabilities'
async function getCapabilities() { const fetchOptions = { headers: { 'OCS-ApiRequest': true, 'Accept': 'json', }, }
const capabilitiesResponse = await fetch(capabitiliesUrl, fetchOptions) const capabilities = await capabilitiesResponse.json()
return capabilities.ocs.data }
const capabilities = await getCapabilities()
function extractFeatureVersion(feature) { const talkFeatures = capabilities?.capabilities?.spreed?.features if (!talkFeatures) { console.error('Talk features not found', capabilities) throw new Error() }
for (const talkFeature of talkFeatures) { if (talkFeature.startsWith(feature + '-v')) { return talkFeature.substring(feature.length + 2) } }
console.error('Failed to get feature version for ' + feature, talkFeatures) throw new Error() }
const signalingApiVersion = extractFeatureVersion('signaling') const conversationApiVersion = extractFeatureVersion('conversation')
const talkOcsApiUrl = host + '/ocs/v2.php/apps/spreed/api/' const signalingSettingsUrl = talkOcsApiUrl + 'v' + signalingApiVersion + '/signaling/settings' const signalingBackendUrl = talkOcsApiUrl + 'v' + signalingApiVersion + '/signaling/backend' let joinLeaveRoomUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/room/' + token + '/participants/active' let joinLeaveCallUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/call/' + token
const publishers = [] const subscribers = []
let virtualParticipant
let stream
async function getSignalingSettings(user, appToken, token) { const fetchOptions = { headers: { 'OCS-ApiRequest': true, 'Accept': 'json', }, }
if (user) { fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken) }
const signalingSettingsResponse = await fetch(signalingSettingsUrl + '?token=' + token, fetchOptions) const signalingSettings = await signalingSettingsResponse.json()
return signalingSettings.ocs.data }
/** * Helper class to interact with the signaling server. * * A new signaling session is started when a Signaling object is created. * Messages can be sent using the sendXXX methods. Received messages are emitted * using events, so a listener for the type of received message can be set to * listen to them; the message data is provided in the "detail" attribute of the * event. */ class Signaling extends EventTarget { constructor(user, signalingSettings) { super();
this.user = user this.signalingTicket = signalingSettings.ticket
let resolveSessionId this.sessionId = new Promise((resolve, reject) => { resolveSessionId = resolve })
this.messageId = 1 this.resolveMessageId = []
const signalingUrl = this.sanitizeSignalingUrl(signalingSettings.server)
this.socket = new WebSocket(signalingUrl) this.socket.onopen = () => { this.sendHello() } this.socket.onmessage = event => { const data = JSON.parse(event.data)
if (data.id && this.resolveMessageId[data.id]) { this.resolveMessageId[data.id]() delete this.resolveMessageId[data.id] }
this.dispatchEvent(new CustomEvent(data.type, { detail: data[data.type] })) } this.socket.onclose = () => { console.warn('Socket closed') }
this.addEventListener('hello', async event => { const hello = event.detail const sessionId = hello.sessionid
this.sessionId = sessionId resolveSessionId(sessionId) })
this.addEventListener('error', event => { const error = event.detail
console.warn(error)
if (error.code === 'not_allowed') { console.info('Is "allowsubscribeany = true" set in the signaling server configuration?') } }) }
sanitizeSignalingUrl(url) { if (url.startsWith('https://')) { url = 'wss://' + url.slice(8) } else if (url.startsWith('http://')) { url = 'ws://' + url.slice(7) } if (url.endsWith('/')) { url = url.slice(0, -1) }
return url + '/spreed' }
/** * Returns the session ID. * * It can return either the actual session ID or a promise that is fulfilled * once the session ID is available. Therefore this should be called with * something like "sessionId = await signaling.getSessionId()". */ async getSessionId() { return this.sessionId }
send(message) { this.socket.send(JSON.stringify(message)) }
async sendAndWaitForResponse(message) { return new Promise((resolve, reject) => { message.id = String(this.messageId++) this.resolveMessageId[message.id] = resolve
this.send(message) }) }
sendHello() { this.send({ 'type': 'hello', 'hello': { 'version': '1.0', 'auth': { 'url': signalingBackendUrl, 'params': { 'userid': this.user, 'ticket': this.signalingTicket, }, }, }, }) }
sendMessage(data) { this.send({ 'type': 'message', 'message': { 'recipient': { 'type': 'session', 'sessionid': data.to, }, 'data': data } }) }
sendRequestOffer(publisherSessionId) { this.send({ 'type': 'message', 'message': { 'recipient': { 'type': 'session', 'sessionid': publisherSessionId, }, 'data': { 'type': 'requestoffer', 'roomType': 'video', }, }, }) }
async joinRoom() { const fetchOptions = { headers: { 'OCS-ApiRequest': true, 'Accept': 'json', }, method: 'POST', }
if (user) { fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken) }
const joinRoomResponse = await fetch(joinLeaveRoomUrl, fetchOptions) const joinRoomResult = await joinRoomResponse.json() const nextcloudSessionId = joinRoomResult.ocs.data.sessionId
await this.sendAndWaitForResponse({ 'type': 'room', 'room': { 'roomid': token, 'sessionid': nextcloudSessionId, }, }) }
async joinCall(flags) { const fetchOptions = { headers: { 'OCS-ApiRequest': true, 'Accept': 'json', }, method: 'POST', body: new URLSearchParams({ flags, }), }
if (user) { fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken) }
await fetch(joinLeaveCallUrl, fetchOptions) }
async leaveCall() { const fetchOptions = { headers: { 'OCS-ApiRequest': true, 'Accept': 'json', }, method: 'DELETE', }
if (user) { fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken) }
await fetch(joinLeaveCallUrl, fetchOptions) }
async leaveRoom() { const fetchOptions = { headers: { 'OCS-ApiRequest': true, 'Accept': 'json', }, method: 'DELETE', }
if (user) { fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken) }
await fetch(joinLeaveRoomUrl, fetchOptions)
this.send({ 'type': 'room', 'room': { 'roomid': '', }, }) } }
/** * Base class for publishers and subscribers. * * After a Peer is created it must be explicitly connected to the HPB by calling * "connect()". This method returns a promise that is fulfilled once the peer * has connected, or rejected if the peer has not connected yet after some time. * "connect()" must be called once the signaling is already connected; this can * be done by waiting for "signaling.getSessionId()". * * Subclasses must set the "sessionId" attribute. */ class Peer { constructor(user, signalingSettings, signaling) { this.signaling = signaling
let iceServers = signalingSettings.stunservers iceServers = iceServers.concat(signalingSettings.turnservers)
this.peerConnection = new RTCPeerConnection({ iceServers: iceServers }) this.peerConnection.onicecandidate = async event => { const candidate = event.candidate
if (candidate) { this.send('candidate', candidate) } }
this.signaling.addEventListener('message', event => { const message = event.detail
if (message.data.type === 'candidate' && message.data.from === this.sessionId) { const candidate = message.data.payload this.peerConnection.addIceCandidate(candidate.candidate) } })
this.connectedPromiseResolve = undefined this.connectedPromiseReject = undefined this.connectedPromise = new Promise((resolve, reject) => { this.connectedPromiseResolve = resolve this.connectedPromiseReject = reject }) }
async connect() { this.peerConnection.addEventListener('iceconnectionstatechange', () => { if (this.peerConnection.iceConnectionState === 'connected' || this.peerConnection.iceConnectionState === 'completed') { this.connectedPromiseResolve() this.connected = true } })
setTimeout(() => { if (!this.connected) { this.connectedPromiseReject('Peer has not connected in ' + connectionWarningTimeout + ' seconds') } }, connectionWarningTimeout)
return this.connectedPromise }
send(type, data) { this.signaling.sendMessage({ to: this.sessionId, roomType: 'video', type: type, payload: data }) } }
/** * Helper class for publishers. * * A single publisher can be used on each signaling session. * * A publisher is connected to the HPB by sending an offer and handling the * returned answer. */ class Publisher extends Peer { constructor(user, signalingSettings, signaling, stream) { super(user, signalingSettings, signaling)
stream.getTracks().forEach(track => { this.peerConnection.addTrack(track, stream) })
this.signaling.addEventListener('message', event => { const message = event.detail
if (message.data.type === 'answer') { const answer = message.data.payload this.peerConnection.setRemoteDescription(answer) } }) }
async connect() { this.sessionId = await this.signaling.getSessionId()
const offer = await this.peerConnection.createOffer({ offerToReceiveAudio: 0, offerToReceiveVideo: 0 }) await this.peerConnection.setLocalDescription(offer)
this.send('offer', offer)
return super.connect() } }
async function newPublisher(signalingSettings, signaling, stream) { const publisher = new Publisher(user, signalingSettings, signaling, stream) const sessionId = await publisher.signaling.getSessionId()
return [sessionId, publisher] }
/** * Helper class for subscribers. * * Several subscribers can be used on a single signaling session provided that * each subscriber subscribes to a different publisher. * * A subscriber is connected to the HPB by requesting an offer, handling the * returned offer and sending back an answer. */ class Subscriber extends Peer { constructor(user, signalingSettings, signaling, publisherSessionId) { super(user, signalingSettings, signaling)
this.sessionId = publisherSessionId
this.signaling.addEventListener('message', async event => { const message = event.detail
if (message.data.type === 'offer' && message.data.from === this.sessionId) { const offer = message.data.payload await this.peerConnection.setRemoteDescription(offer)
const answer = await this.peerConnection.createAnswer() await this.peerConnection.setLocalDescription(answer)
this.send('answer', answer) } }) }
async connect() { this.signaling.sendRequestOffer(this.sessionId)
return super.connect() } }
function listenToPublisherConnectionChanges() { Object.values(publishers).forEach(publisher => { publisher.peerConnection.addEventListener('iceconnectionstatechange', event => { if (publisher.peerConnection.iceConnectionState === 'connected' || publisher.peerConnection.iceConnectionState === 'completed') { clearTimeout(publisher.connectionWarning) publisher.connectionWarning = null
return }
if (publisher.peerConnection.iceConnectionState === 'disconnected') { // Brief disconnections are normal and expected; they are only
// relevant if the connection has not been restored after some
// seconds.
publisher.connectionWarning = setTimeout(() => { console.warn('Publisher disconnected', publisher.sessionId) }, connectionWarningTimeout) } else if (publisher.peerConnection.iceConnectionState === 'failed') { console.warn('Publisher connection failed', publisher.sessionId) } }) }) }
async function initPublishers() { for (let i = 0; i < publishersCount; i++) { const signalingSettings = await getSignalingSettings(user, appToken, token) let signaling = null try { signaling = new Signaling(user, signalingSettings) } catch (exception) { console.error('Publisher ' + i + ' init error: ' + exception) continue }
const [publisherSessionId, publisher] = await newPublisher(signalingSettings, signaling, stream)
try { await publisher.connect()
if ((i + 1) % 5 === 0 && (i + 1) < publishersCount) { console.info('Publisher started (' + (i + 1) + '/' + publishersCount + ')') } } catch (exception) { console.warn('Publisher ' + i + ' error: ' + exception) }
publishers[publisherSessionId] = publisher }
console.info('Publishers started the siege')
listenToPublisherConnectionChanges() }
function listenToSubscriberConnectionChanges() { subscribers.forEach(subscriber => { subscriber.peerConnection.addEventListener('iceconnectionstatechange', event => { if (subscriber.peerConnection.iceConnectionState === 'connected' || subscriber.peerConnection.iceConnectionState === 'completed') { clearTimeout(subscriber.connectionWarning) subscriber.connectionWarning = null
return }
if (subscriber.peerConnection.iceConnectionState === 'disconnected') { // Brief disconnections are normal and expected; they are only
// relevant if the connection has not been restored after some
// seconds.
subscriber.connectionWarning = setTimeout(() => { console.warn('Subscriber disconnected', subscriber.sessionId) }, connectionWarningTimeout) } else if (subscriber.peerConnection.iceConnectionState === 'failed') { console.warn('Subscriber connection failed', subscriber.sessionId) } }) }) }
async function initSubscribers() { for (let i = 0; i < subscribersPerPublisherCount; i++) { // The same signaling session can be shared between subscribers to
// different publishers.
const signalingSettings = await getSignalingSettings(user, appToken, token) let signaling = null try { signaling = new Signaling(user, signalingSettings) } catch (exception) { console.error('Subscriber ' + i + ' init error: ' + exception) continue }
await signaling.getSessionId()
Object.keys(publishers).forEach(async publisherSessionId => { const subscriber = new Subscriber(user, signalingSettings, signaling, publisherSessionId)
subscribers.push(subscriber) }) }
for (let i = 0; i < subscribers.length; i++) { try { await subscribers[i].connect()
if ((i + 1) % 5 === 0 && (i + 1) < subscribers.length) { console.info('Subscriber started (' + (i + 1) + '/' + subscribers.length + ')') } } catch (exception) { console.warn('Subscriber ' + i + ' error: ' + exception) } }
console.info('Subscribers started the siege')
listenToSubscriberConnectionChanges() }
// Expose publishers to CLI.
const getPublishers = function() { return publishers }
// Expose subscribers to CLI.
const getSubscribers = function() { return subscribers }
const closeConnections = function() { subscribers.forEach(subscriber => { subscriber.peerConnection.close() }) subscribers.splice(0)
Object.values(publishers).forEach(publisher => { publisher.peerConnection.close() }) Object.keys(publishers).forEach(publisherSessionId => { delete publishers[publisherSessionId] })
if (stream) { stream.getTracks().forEach(track => { track.stop() }) stream = null } }
const setAudioEnabled = function(enabled) { if (!stream || !stream.getAudioTracks().length) { console.error('Audio was not initialized')
return }
// There will be at most a single audio track.
stream.getAudioTracks()[0].enabled = enabled }
const setVideoEnabled = function(enabled) { if (!stream || !stream.getVideoTracks().length) { console.error('Video was not initialized')
return }
// There will be at most a single video track.
stream.getVideoTracks()[0].enabled = enabled }
const setSentAudioStreamEnabled = function(enabled) { if (!stream || !stream.getAudioTracks().length) { console.error('Audio was not initialized')
return }
Object.values(publishers).forEach(publisher => { // For simplicity it is assumed that if audio is enabled the audio
// sender will always be the first one.
const audioSender = publisher.peerConnection.getSenders()[0] if (enabled) { audioSender.replaceTrack(stream.getAudioTracks()[0]) } else { audioSender.replaceTrack(null) } }) }
const setSentVideoStreamEnabled = function(enabled) { if (!stream || !stream.getVideoTracks().length) { console.error('Video was not initialized')
return }
Object.values(publishers).forEach(publisher => { // For simplicity it is assumed that if audio is not enabled the video
// sender will always be the first one, otherwise the second one.
let videoIndex = 0 if (stream.getAudioTracks().length) { videoIndex = 1 }
const videoSender = publisher.peerConnection.getSenders()[videoIndex] if (enabled) { videoSender.replaceTrack(stream.getVideoTracks()[0]) } else { videoSender.replaceTrack(null) } }) }
const checkPublishersConnections = function() { const iceConnectionStateCount = {}
Object.keys(publishers).forEach(publisherSessionId => { publisher = publishers[publisherSessionId]
console.info(publisherSessionId + ': ' + publisher.peerConnection.iceConnectionState)
if (iceConnectionStateCount[publisher.peerConnection.iceConnectionState] === undefined) { iceConnectionStateCount[publisher.peerConnection.iceConnectionState] = 1 } else { iceConnectionStateCount[publisher.peerConnection.iceConnectionState]++ } })
console.info('Summary:') console.info(' - New: ' + (iceConnectionStateCount['new'] ?? 0)) console.info(' - Connected: ' + ((iceConnectionStateCount['connected'] ?? 0) + (iceConnectionStateCount['completed'] ?? 0))) console.info(' - Disconnected: ' + (iceConnectionStateCount['disconnected'] ?? 0)) console.info(' - Failed: ' + (iceConnectionStateCount['failed'] ?? 0)) }
const checkSubscribersConnections = function() { const iceConnectionStateCount = {}
i = 0
subscribers.forEach(subscriber => { console.info(i + ': ' + subscriber.peerConnection.iceConnectionState) i++
if (iceConnectionStateCount[subscriber.peerConnection.iceConnectionState] === undefined) { iceConnectionStateCount[subscriber.peerConnection.iceConnectionState] = 1 } else { iceConnectionStateCount[subscriber.peerConnection.iceConnectionState]++ } })
console.info('Summary:') console.info(' - New: ' + (iceConnectionStateCount['new'] ?? 0)) console.info(' - Connected: ' + ((iceConnectionStateCount['connected'] ?? 0) + (iceConnectionStateCount['completed'] ?? 0))) console.info(' - Disconnected: ' + (iceConnectionStateCount['disconnected'] ?? 0)) console.info(' - Failed: ' + (iceConnectionStateCount['failed'] ?? 0)) }
const printPublisherStats = async function(publisherSessionId, stringify = false) { if (!(publisherSessionId in publishers)) { console.error('Invalid publisher session ID')
return }
stats = await publishers[publisherSessionId].peerConnection.getStats()
for (stat of stats.values()) { if (stringify) { console.info(JSON.stringify(stat)) } else { console.info(stat) } } }
const printSubscriberStats = async function(index, stringify = false) { if (!(index in subscribers)) { console.error('Index out of range')
return }
stats = await subscribers[index].peerConnection.getStats()
for (stat of stats.values()) { if (stringify) { console.info(JSON.stringify(stat)) } else { console.info(stat) } } }
const setCredentials = function(userToSet, appTokenToSet) { user = userToSet appToken = appTokenToSet }
const setToken = function(tokenToSet) { token = tokenToSet
joinLeaveRoomUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/room/' + token + '/participants/active' joinLeaveCallUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/call/' + token }
const setPublishersAndSubscribersCount = function(publishersCountToSet, subscribersPerPublisherCountToSet) { publishersCount = publishersCountToSet subscribersPerPublisherCount = subscribersPerPublisherCountToSet }
const startMedia = async function(audio, video) { if (stream) { stream.getTracks().forEach(track => { track.stop() })
stream = null }
if (audio !== undefined) { mediaConstraints.audio = audio } if (video !== undefined) { mediaConstraints.video = video }
stream = await navigator.mediaDevices.getUserMedia(mediaConstraints) }
const setConnectionWarningTimeout = function(connectionWarningTimeoutToSet) { connectionWarningTimeout = connectionWarningTimeoutToSet }
const siege = async function() { if (!user || !appToken) { console.error('Credentials (user and appToken) are not set')
return }
closeConnections()
if (!stream) { await startMedia() }
console.info('Preparing to siege')
await initPublishers() await initSubscribers() }
// Expose virtual participant to CLI.
const getVirtualParticipant = function() { return virtualParticipant }
const startVirtualParticipant = async function() { if (!token) { console.error('Conversation token is not set')
return }
const signalingSettings = await getSignalingSettings(user, appToken, token) let signaling = null try { signaling = new Signaling(user, signalingSettings) } catch (exception) { console.error('Virtual participant init error: ' + exception) return }
let flags = 1
let publisherSessionId let publisher
if (stream) { [publisherSessionId, publisher] = await newPublisher(signalingSettings, signaling, stream)
if (stream.getAudioTracks().length > 0) { flags |= 2 }
if (stream.getVideoTracks().length > 0) { flags |= 4 } } else { await signaling.getSessionId() }
await signaling.joinRoom() await signaling.joinCall(flags)
virtualParticipant = { signaling }
if (stream) { try { // Data channels are expected to be available for call participants.
virtualParticipant.dataChannel = publisher.peerConnection.createDataChannel('status')
await publisher.connect()
publishers[publisherSessionId] = publisher virtualParticipant.publisherSessionId = publisherSessionId } catch (exception) { console.warn('Virtual participant publisher error: ' + exception) } } }
const stopVirtualParticipant = async function() { if (!virtualParticipant) { return }
if (virtualParticipant.publisherSessionId) { publishers[virtualParticipant.publisherSessionId].peerConnection.close() delete publishers[virtualParticipant.publisherSessionId] }
await virtualParticipant.signaling.leaveCall() virtualParticipant.signaling.leaveRoom()
virtualParticipant = null }
function isVirtualParticipantAndDataChannelAvailable() { if (!virtualParticipant) { console.error('Virtual participant not started')
return false }
if (!virtualParticipant.dataChannel) { console.error('Data channel not open for virtual participant (was media enabled when virtual participant was started?)')
return false }
return true }
const sendMediaEnabledStateThroughDataChannel = function(mediaType, enabled) { if (!isVirtualParticipantAndDataChannelAvailable()) { return }
let messageType if (mediaType === 'audio' && enabled) { messageType = 'audioOn' } else if (mediaType === 'audio' && !enabled) { messageType = 'audioOff' } else if (mediaType === 'video' && enabled) { messageType = 'videoOn' } else if (mediaType === 'video' && !enabled) { messageType = 'videoOff' } else { console.error('Wrong parameters, expected "audio" or "video" and a boolean: ', mediaType, enabled)
return }
virtualParticipant.dataChannel.send(JSON.stringify({ type: messageType })) }
const sendSpeakingStateThroughDataChannel = function(speaking) { if (!isVirtualParticipantAndDataChannelAvailable()) { return }
let messageType if (speaking) { messageType = 'speaking' } else { messageType = 'stoppedSpeaking' }
virtualParticipant.dataChannel.send(JSON.stringify({ type: messageType })) }
const sendNickThroughDataChannel = function(nick) { if (!isVirtualParticipantAndDataChannelAvailable()) { return }
if (!virtualParticipant.signaling.user) { payload = nick } else { payload = { name: nick, userid: virtualParticipant.signaling.user, } }
virtualParticipant.dataChannel.send(JSON.stringify({ type: 'nickChanged', payload, })) }
|