You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1242 lines
38 KiB

  1. /**
  2. * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
  3. * SPDX-License-Identifier: AGPL-3.0-or-later
  4. */
  5. /**
  6. * Talkbuchet is the helper tool for load/stress testing of Nextcloud Talk.
  7. *
  8. * Talkbuchet is a JavaScript script (Talkbuchet.js), and it is run using a web
  9. * browser. A Python script (Talkbuchet-cli.py) is provided to launch a web
  10. * browser, load Talkbuchet and control it from a command line interface (which
  11. * requires Selenium and certain Python packages to be available in the system).
  12. * A Bash script (Talkbuchet-run.sh) is provided to set up a Docker container
  13. * with Selenium, a web browser and all the needed Python dependencies for
  14. * Talkbuchet-cli.py.
  15. *
  16. * Please refer to the documentation in Talkbuchet-cli.py and Talkbuchet-run.sh
  17. * for information on how to control Talkbuchet and easily run it.
  18. *
  19. * A High Performance Backend (HPB) server must be configured in Nextcloud Talk
  20. * to use Talkbuchet.
  21. *
  22. * HOW TO SETUP (without using the CLI):
  23. * -----------------------------------------------------------------------------
  24. * - In the browser, log in the Nextcloud server (with the same user as in this
  25. * script).
  26. * - Copy and paste the full script in the console of the browser.
  27. *
  28. *
  29. *
  30. * -----------------------------------------------------------------------------
  31. * SIEGE MODE
  32. * -----------------------------------------------------------------------------
  33. *
  34. * Siege mode is used for load testing of the Janus gateway running in the HPB
  35. * server. In siege mode Talkbuchet creates M publishers and N subscribers for
  36. * each publisher for a total of M+M*N connections with the Janus gateway.
  37. * Therefore, it can be used to verify that the server will be able to withstand
  38. * certain number of media connections (audio, video or both audio and video).
  39. *
  40. * In a real call every participant will subscribe to the publishers of the
  41. * other participants. That is, for X participants, Y of them publishers, there
  42. * will be (X-1)*Y subscribers. However, note that some participants may not be
  43. * publishers, for example, if the do not have publishing permissions, or if
  44. * they never had a microphone nor a camera selected (although they will be
  45. * publishers if the microphone and camera are selected but disabled, or if the
  46. * microphone or camera is no longer selected but was selected at some point
  47. * during the call).
  48. *
  49. * For example, in a normal call between 10 participants there will be 10
  50. * publishers and (10-1)*10=90 subscribers for a total of 100 connections.
  51. * However, if only two participants have publishing permissions then there will
  52. * be 2 publishers and (10-1)*2=18 subscribers for a total of 20 connections.
  53. *
  54. * To use the siege mode the signaling server of the HPB must be configured to
  55. * allow subscribing any stream:
  56. * https://github.com/strukturag/nextcloud-spreed-signaling/blob/a663dd43f90b0876630250012bb716136920fcd3/server.conf.in#L32-L35
  57. *
  58. * HOW TO RUN:
  59. * -----------------------------------------------------------------------------
  60. * - Set the user and appToken (a user must be used; guests do not work;
  61. * generate an apptoken at index.php/settings/user/security) by calling
  62. * "setCredentials(user, appToken)" in the console.
  63. * - If HPB clustering is enabled, set the token of a conversation (otherwise
  64. * leave empty) by calling "setToken(token)" in the console.
  65. * - If media other than just audio should be used, start it by calling
  66. * "startMedia(audio, video)" in the console.
  67. * - Set the desired numbers of publishers and subscribers per publisher (in a
  68. * regular call with N participants you would have N publishers and N-1
  69. * subscribers) by calling "setPublishersAndSubscribersCount(publishersCount,
  70. * subscribersPerPublisherCount)" in the console.
  71. * - Once all the needed parameters are set execute "siege()" in the console.
  72. * - To run it again execute "siege()" again in the console; if any parameter
  73. * needs to be changed it is recommended to first stop the previous siege by
  74. * calling "closeConnections()" in the console before changing the parameters.
  75. *
  76. * HOW TO ENABLE AND DISABLE THE MEDIA DURING A TEST:
  77. * -----------------------------------------------------------------------------
  78. * You can manually enable and disable the media during a test by running the
  79. * following commands in the browser console:
  80. * - For audio:
  81. * setAudioEnabled(TRUE_OR_FALSE)
  82. * - For video:
  83. * setVideoEnabled(TRUE_OR_FALSE)
  84. *
  85. * Note that you can only enable and disable the original media specified in the
  86. * "getUserMedia" call.
  87. *
  88. * Additionally, you can also enable and disable the sent media streams during
  89. * a test by running the following commands in the browser console:
  90. * - For audio:
  91. * setSentAudioStreamEnabled(TRUE_OR_FALSE)
  92. * - For video:
  93. * setSentVideoStreamEnabled(TRUE_OR_FALSE)
  94. *
  95. * Currently Firefox behaviour is the same whether the media is disabled or the
  96. * sent media stream is disabled, so this makes no difference. Chromium, on the
  97. * other hand, sends some media data when the media is disabled, but stops it
  98. * when the sent media stream is disabled. In any case, please note that some
  99. * data will be always sent as long as there is a connection open, even if no
  100. * media is being sent.
  101. *
  102. * HOW TO CALIBRATE:
  103. * -----------------------------------------------------------------------------
  104. * The script starts as many publishers and subscribers for each publisher as
  105. * specified, each one being a real and independent peer connection to Janus.
  106. * Therefore, how many peer connections can be established with a single script
  107. * run depends on the client machine, both its CPU and its network.
  108. *
  109. * You should first "calibrate" the client by running it with different numbers
  110. * of publishers and subscribers to check how many peer connections are
  111. * supported before doing the actual stress test on Janus.
  112. *
  113. * It is most likely that the CPU or network of a single client will be
  114. * saturated before Janus is. The script will write to the console when a peer
  115. * could not be connected or if it was disconnected after being connected; if
  116. * there is a large number of those messages or the CPU consumption is very high
  117. * the client has probably reached its limit.
  118. *
  119. * Besides the messages written by the script itself you can manually check the
  120. * connection state by running the following commands in the browser console:
  121. * - For the publishers:
  122. * checkPublishersConnections()
  123. * - For the subscribers:
  124. * checkSubscribersConnections()
  125. *
  126. * DISCLAIMER:
  127. * -----------------------------------------------------------------------------
  128. * Talk performs some optimizations during calls, like reducing the video
  129. * quality based on the number of participants. This script does not take into
  130. * account those things; it is meant to be used for stress tests of Janus rather
  131. * than to accurately simulate Talk behaviour.
  132. *
  133. * Independently of that, please note that an accurate simulation would not be
  134. * possible given that a single client has to behave as several different
  135. * clients. In a real call each client could have a different behaviour (not
  136. * only due to sending different media streams, but also from having different
  137. * CPU and network conditions), and that might even also affect Janus and the
  138. * server regarding very low level things (but relevant for performance on
  139. * highly loaded systems) like caches.
  140. *
  141. * Nevertheless, if those things are kept in mind the script can be anyway used
  142. * as a rough performance test of specific call scenarios. Just remember that in
  143. * regular calls peer connections increase quadratically with the number of
  144. * participants; specifically, publishers increase linearly while subscribers
  145. * increase quadratically.
  146. *
  147. * For example a call between 10 participants has 10 publishers and 90
  148. * subscribers (9 for each publisher) for a total of 100 peer connections, while
  149. * a call between 30 participants has 30 publishers and 870 subscribers (29 for
  150. * each publisher) for a total of 900 peer connections.
  151. * Due to this, if you have several clients that can only withstand ~100 peer
  152. * connections each in order to simulate a 30 participants call you could run
  153. * the script with 3 publishers and 29 subscribers per publisher on 10 clients
  154. * at the same time.
  155. *
  156. *
  157. *
  158. * -----------------------------------------------------------------------------
  159. * VIRTUAL PARTICIPANT MODE
  160. * -----------------------------------------------------------------------------
  161. *
  162. * Virtual participant mode is used for load testing of clients. From the point
  163. * of view of the clients in a call the virtual participants are real
  164. * participants (they can be just listeners, but they can publish audio and/or
  165. * video), so several virtual participants can be added to a call to verify if
  166. * a client running on a specific system will be able to withstand certain
  167. * number of participants in a call.
  168. *
  169. * The reason to use virtual participants instead of real participants is that
  170. * virtual participants are either just in the call or publishing media, but
  171. * they will not subscribe to any other participant (which does not affect the
  172. * other clients, only the HPB server). Due to this they need much less
  173. * resources than real participants and therefore the system launching the test
  174. * would be able to add a much higher number of virtual participants than of
  175. * real participants.
  176. *
  177. * However, note that each web browser session can execute a single virtual
  178. * participant. Due to this it is recommended to use Talkbuchet CLI instead to
  179. * easily start several web browser sessions, each one with its own virtual
  180. * participant, and control the virtual participants from the CLI.
  181. *
  182. * HOW TO RUN:
  183. * -----------------------------------------------------------------------------
  184. * - Set the user and appToken (generate an apptoken at
  185. * index.php/settings/user/security) by calling
  186. * "setCredentials(user, appToken)" in the console. If credentials are not set
  187. * a guest user will be used instead.
  188. * - Set the token of the conversation by calling "setToken(token)" in the
  189. * console.
  190. * - Start the desired media by calling "startMedia(audio, video)" in the
  191. * console. If not called (or both parameters are false) no media will be
  192. * used.
  193. * - Once all the needed parameters are set execute "startVirtualParticipant()"
  194. * in the console.
  195. * - To run it again execute "stopVirtualParticipant()" and then
  196. * "startVirtualParticipant()" again in the console; if any parameter
  197. * needs to be changed do it before starting the virtual participant again.
  198. *
  199. * TRIGGERING ACTIONS BY THE VIRTUAL PARTICIPANT:
  200. * -----------------------------------------------------------------------------
  201. * In general, for load testing of clients it is enough to start the virtual
  202. * participant and that is it. However, for development purposes it can be
  203. * useful to simulate certain actions by the virtual participant.
  204. *
  205. * Currently all the actions are simulated through data channel messages; the
  206. * equivalent signaling messages are not sent.
  207. *
  208. * The nick of the virtual participant can be set with:
  209. * sendNickThroughDataChannel(NICK)
  210. *
  211. * Note that sending the nick is not limited to guests; even if the virtual
  212. * participant is a registered user a nick can be sent. Moreover, clients may
  213. * not show any name at all for the virtual participant of a registered user
  214. * until a nick is sent, even if the user name is known by the client.
  215. *
  216. * If the virtual participant is a publisher the media can be enabled and
  217. * disabled as described in the siege mode. However, that does not notify other
  218. * participants in the call that the media state has changed. That can be done
  219. * instead with:
  220. * sendMediaEnabledStateThroughDataChannel(AUDIO_OR_VIDEO, TRUE_OR_FALSE)
  221. *
  222. * Similarly, when audio is enabled the message to notify other participants
  223. * when the virtual participant started or stopped speaking can be sent with:
  224. * sendSpeakingStateThroughDataChannel(TRUE_OR_FALSE)
  225. *
  226. * Note that the message is independent of the actual audio being sent; a
  227. * "speaking" event can be sent when audio is silent, and a "stoppedSpeaking"
  228. * event can be sent when audio is at full volume.
  229. *
  230. * DISCLAIMER:
  231. * -----------------------------------------------------------------------------
  232. * Like in siege mode, virtual participants mode does not take into account the
  233. * optimizations performed by Talk during calls, like reducing the video
  234. * quality based on the number of participants. This script is not meant to
  235. * accurately simulate Talk behaviour, but to provide a tool to perform rough
  236. * performance tests of specific call scenarios.
  237. *
  238. * Therefore, it should be kept in mind that in a real call with several video
  239. * participants the performance is very likely to be better than with several
  240. * virtual participants with video, as in that case the video quality will not
  241. * be adjusted based on the number of participants. Nevertheless, this script
  242. * could still be used to simulate a worst case scenario.
  243. */
  244. // Sieges with guest users do not currently work, as they must join the
  245. // conversation to not be kicked out from the signaling server. However, joining
  246. // the conversation a second time causes the first guest to be unregistered.
  247. // Regular users do not need to join the conversation, so the same user can be
  248. // connected several times to the HPB.
  249. let user = ''
  250. let appToken = ''
  251. // The conversation token is only strictly needed for guests or if HPB
  252. // clustering is enabled.
  253. let token = ''
  254. // Number of streams to send
  255. let publishersCount = 5
  256. // Number of streams to receive
  257. let subscribersPerPublisherCount = 40
  258. const mediaConstraints = {
  259. audio: true,
  260. video: false,
  261. }
  262. let connectionWarningTimeout = 5000
  263. /*
  264. * End of configuration section
  265. */
  266. // To run the script the current page in the browser must be a page of the
  267. // target Nextcloud instance, as cross-doman requests are not allowed, so the
  268. // host is directly got from the current location.
  269. const host = 'https://' + window.location.host
  270. const capabitiliesUrl = host + '/ocs/v1.php/cloud/capabilities'
  271. async function getCapabilities() {
  272. const fetchOptions = {
  273. headers: {
  274. 'OCS-ApiRequest': true,
  275. 'Accept': 'json',
  276. },
  277. }
  278. const capabilitiesResponse = await fetch(capabitiliesUrl, fetchOptions)
  279. const capabilities = await capabilitiesResponse.json()
  280. return capabilities.ocs.data
  281. }
  282. const capabilities = await getCapabilities()
  283. function extractFeatureVersion(feature) {
  284. const talkFeatures = capabilities?.capabilities?.spreed?.features
  285. if (!talkFeatures) {
  286. console.error('Talk features not found', capabilities)
  287. throw new Error()
  288. }
  289. for (const talkFeature of talkFeatures) {
  290. if (talkFeature.startsWith(feature + '-v')) {
  291. return talkFeature.substring(feature.length + 2)
  292. }
  293. }
  294. console.error('Failed to get feature version for ' + feature, talkFeatures)
  295. throw new Error()
  296. }
  297. const signalingApiVersion = extractFeatureVersion('signaling')
  298. const conversationApiVersion = extractFeatureVersion('conversation')
  299. const talkOcsApiUrl = host + '/ocs/v2.php/apps/spreed/api/'
  300. const signalingSettingsUrl = talkOcsApiUrl + 'v' + signalingApiVersion + '/signaling/settings'
  301. const signalingBackendUrl = talkOcsApiUrl + 'v' + signalingApiVersion + '/signaling/backend'
  302. let joinLeaveRoomUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/room/' + token + '/participants/active'
  303. let joinLeaveCallUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/call/' + token
  304. const publishers = []
  305. const subscribers = []
  306. let virtualParticipant
  307. let stream
  308. async function getSignalingSettings(user, appToken, token) {
  309. const fetchOptions = {
  310. headers: {
  311. 'OCS-ApiRequest': true,
  312. 'Accept': 'json',
  313. },
  314. }
  315. if (user) {
  316. fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken)
  317. }
  318. const signalingSettingsResponse = await fetch(signalingSettingsUrl + '?token=' + token, fetchOptions)
  319. const signalingSettings = await signalingSettingsResponse.json()
  320. return signalingSettings.ocs.data
  321. }
  322. /**
  323. * Helper class to interact with the signaling server.
  324. *
  325. * A new signaling session is started when a Signaling object is created.
  326. * Messages can be sent using the sendXXX methods. Received messages are emitted
  327. * using events, so a listener for the type of received message can be set to
  328. * listen to them; the message data is provided in the "detail" attribute of the
  329. * event.
  330. */
  331. class Signaling extends EventTarget {
  332. constructor(user, signalingSettings) {
  333. super();
  334. this.user = user
  335. this.signalingTicket = signalingSettings.ticket
  336. let resolveSessionId
  337. this.sessionId = new Promise((resolve, reject) => {
  338. resolveSessionId = resolve
  339. })
  340. this.messageId = 1
  341. this.resolveMessageId = []
  342. const signalingUrl = this.sanitizeSignalingUrl(signalingSettings.server)
  343. this.socket = new WebSocket(signalingUrl)
  344. this.socket.onopen = () => {
  345. this.sendHello()
  346. }
  347. this.socket.onmessage = event => {
  348. const data = JSON.parse(event.data)
  349. if (data.id && this.resolveMessageId[data.id]) {
  350. this.resolveMessageId[data.id]()
  351. delete this.resolveMessageId[data.id]
  352. }
  353. this.dispatchEvent(new CustomEvent(data.type, { detail: data[data.type] }))
  354. }
  355. this.socket.onclose = () => {
  356. console.warn('Socket closed')
  357. }
  358. this.addEventListener('hello', async event => {
  359. const hello = event.detail
  360. const sessionId = hello.sessionid
  361. this.sessionId = sessionId
  362. resolveSessionId(sessionId)
  363. })
  364. this.addEventListener('error', event => {
  365. const error = event.detail
  366. console.warn(error)
  367. if (error.code === 'not_allowed') {
  368. console.info('Is "allowsubscribeany = true" set in the signaling server configuration?')
  369. }
  370. })
  371. }
  372. sanitizeSignalingUrl(url) {
  373. if (url.startsWith('https://')) {
  374. url = 'wss://' + url.slice(8)
  375. } else if (url.startsWith('http://')) {
  376. url = 'ws://' + url.slice(7)
  377. }
  378. if (url.endsWith('/')) {
  379. url = url.slice(0, -1)
  380. }
  381. return url + '/spreed'
  382. }
  383. /**
  384. * Returns the session ID.
  385. *
  386. * It can return either the actual session ID or a promise that is fulfilled
  387. * once the session ID is available. Therefore this should be called with
  388. * something like "sessionId = await signaling.getSessionId()".
  389. */
  390. async getSessionId() {
  391. return this.sessionId
  392. }
  393. send(message) {
  394. this.socket.send(JSON.stringify(message))
  395. }
  396. async sendAndWaitForResponse(message) {
  397. return new Promise((resolve, reject) => {
  398. message.id = String(this.messageId++)
  399. this.resolveMessageId[message.id] = resolve
  400. this.send(message)
  401. })
  402. }
  403. sendHello() {
  404. this.send({
  405. 'type': 'hello',
  406. 'hello': {
  407. 'version': '1.0',
  408. 'auth': {
  409. 'url': signalingBackendUrl,
  410. 'params': {
  411. 'userid': this.user,
  412. 'ticket': this.signalingTicket,
  413. },
  414. },
  415. },
  416. })
  417. }
  418. sendMessage(data) {
  419. this.send({
  420. 'type': 'message',
  421. 'message': {
  422. 'recipient': {
  423. 'type': 'session',
  424. 'sessionid': data.to,
  425. },
  426. 'data': data
  427. }
  428. })
  429. }
  430. sendRequestOffer(publisherSessionId) {
  431. this.send({
  432. 'type': 'message',
  433. 'message': {
  434. 'recipient': {
  435. 'type': 'session',
  436. 'sessionid': publisherSessionId,
  437. },
  438. 'data': {
  439. 'type': 'requestoffer',
  440. 'roomType': 'video',
  441. },
  442. },
  443. })
  444. }
  445. async joinRoom() {
  446. const fetchOptions = {
  447. headers: {
  448. 'OCS-ApiRequest': true,
  449. 'Accept': 'json',
  450. },
  451. method: 'POST',
  452. }
  453. if (user) {
  454. fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken)
  455. }
  456. const joinRoomResponse = await fetch(joinLeaveRoomUrl, fetchOptions)
  457. const joinRoomResult = await joinRoomResponse.json()
  458. const nextcloudSessionId = joinRoomResult.ocs.data.sessionId
  459. await this.sendAndWaitForResponse({
  460. 'type': 'room',
  461. 'room': {
  462. 'roomid': token,
  463. 'sessionid': nextcloudSessionId,
  464. },
  465. })
  466. }
  467. async joinCall(flags) {
  468. const fetchOptions = {
  469. headers: {
  470. 'OCS-ApiRequest': true,
  471. 'Accept': 'json',
  472. },
  473. method: 'POST',
  474. body: new URLSearchParams({
  475. flags,
  476. }),
  477. }
  478. if (user) {
  479. fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken)
  480. }
  481. await fetch(joinLeaveCallUrl, fetchOptions)
  482. }
  483. async leaveCall() {
  484. const fetchOptions = {
  485. headers: {
  486. 'OCS-ApiRequest': true,
  487. 'Accept': 'json',
  488. },
  489. method: 'DELETE',
  490. }
  491. if (user) {
  492. fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken)
  493. }
  494. await fetch(joinLeaveCallUrl, fetchOptions)
  495. }
  496. async leaveRoom() {
  497. const fetchOptions = {
  498. headers: {
  499. 'OCS-ApiRequest': true,
  500. 'Accept': 'json',
  501. },
  502. method: 'DELETE',
  503. }
  504. if (user) {
  505. fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + appToken)
  506. }
  507. await fetch(joinLeaveRoomUrl, fetchOptions)
  508. this.send({
  509. 'type': 'room',
  510. 'room': {
  511. 'roomid': '',
  512. },
  513. })
  514. }
  515. }
  516. /**
  517. * Base class for publishers and subscribers.
  518. *
  519. * After a Peer is created it must be explicitly connected to the HPB by calling
  520. * "connect()". This method returns a promise that is fulfilled once the peer
  521. * has connected, or rejected if the peer has not connected yet after some time.
  522. * "connect()" must be called once the signaling is already connected; this can
  523. * be done by waiting for "signaling.getSessionId()".
  524. *
  525. * Subclasses must set the "sessionId" attribute.
  526. */
  527. class Peer {
  528. constructor(user, signalingSettings, signaling) {
  529. this.signaling = signaling
  530. let iceServers = signalingSettings.stunservers
  531. iceServers = iceServers.concat(signalingSettings.turnservers)
  532. this.peerConnection = new RTCPeerConnection({ iceServers: iceServers })
  533. this.peerConnection.onicecandidate = async event => {
  534. const candidate = event.candidate
  535. if (candidate) {
  536. this.send('candidate', candidate)
  537. }
  538. }
  539. this.signaling.addEventListener('message', event => {
  540. const message = event.detail
  541. if (message.data.type === 'candidate' && message.data.from === this.sessionId) {
  542. const candidate = message.data.payload
  543. this.peerConnection.addIceCandidate(candidate.candidate)
  544. }
  545. })
  546. this.connectedPromiseResolve = undefined
  547. this.connectedPromiseReject = undefined
  548. this.connectedPromise = new Promise((resolve, reject) => {
  549. this.connectedPromiseResolve = resolve
  550. this.connectedPromiseReject = reject
  551. })
  552. }
  553. async connect() {
  554. this.peerConnection.addEventListener('iceconnectionstatechange', () => {
  555. if (this.peerConnection.iceConnectionState === 'connected' || this.peerConnection.iceConnectionState === 'completed') {
  556. this.connectedPromiseResolve()
  557. this.connected = true
  558. }
  559. })
  560. setTimeout(() => {
  561. if (!this.connected) {
  562. this.connectedPromiseReject('Peer has not connected in ' + connectionWarningTimeout + ' seconds')
  563. }
  564. }, connectionWarningTimeout)
  565. return this.connectedPromise
  566. }
  567. send(type, data) {
  568. this.signaling.sendMessage({
  569. to: this.sessionId,
  570. roomType: 'video',
  571. type: type,
  572. payload: data
  573. })
  574. }
  575. }
  576. /**
  577. * Helper class for publishers.
  578. *
  579. * A single publisher can be used on each signaling session.
  580. *
  581. * A publisher is connected to the HPB by sending an offer and handling the
  582. * returned answer.
  583. */
  584. class Publisher extends Peer {
  585. constructor(user, signalingSettings, signaling, stream) {
  586. super(user, signalingSettings, signaling)
  587. stream.getTracks().forEach(track => {
  588. this.peerConnection.addTrack(track, stream)
  589. })
  590. this.signaling.addEventListener('message', event => {
  591. const message = event.detail
  592. if (message.data.type === 'answer') {
  593. const answer = message.data.payload
  594. this.peerConnection.setRemoteDescription(answer)
  595. }
  596. })
  597. }
  598. async connect() {
  599. this.sessionId = await this.signaling.getSessionId()
  600. const offer = await this.peerConnection.createOffer({ offerToReceiveAudio: 0, offerToReceiveVideo: 0 })
  601. await this.peerConnection.setLocalDescription(offer)
  602. this.send('offer', offer)
  603. return super.connect()
  604. }
  605. }
  606. async function newPublisher(signalingSettings, signaling, stream) {
  607. const publisher = new Publisher(user, signalingSettings, signaling, stream)
  608. const sessionId = await publisher.signaling.getSessionId()
  609. return [sessionId, publisher]
  610. }
  611. /**
  612. * Helper class for subscribers.
  613. *
  614. * Several subscribers can be used on a single signaling session provided that
  615. * each subscriber subscribes to a different publisher.
  616. *
  617. * A subscriber is connected to the HPB by requesting an offer, handling the
  618. * returned offer and sending back an answer.
  619. */
  620. class Subscriber extends Peer {
  621. constructor(user, signalingSettings, signaling, publisherSessionId) {
  622. super(user, signalingSettings, signaling)
  623. this.sessionId = publisherSessionId
  624. this.signaling.addEventListener('message', async event => {
  625. const message = event.detail
  626. if (message.data.type === 'offer' && message.data.from === this.sessionId) {
  627. const offer = message.data.payload
  628. await this.peerConnection.setRemoteDescription(offer)
  629. const answer = await this.peerConnection.createAnswer()
  630. await this.peerConnection.setLocalDescription(answer)
  631. this.send('answer', answer)
  632. }
  633. })
  634. }
  635. async connect() {
  636. this.signaling.sendRequestOffer(this.sessionId)
  637. return super.connect()
  638. }
  639. }
  640. function listenToPublisherConnectionChanges() {
  641. Object.values(publishers).forEach(publisher => {
  642. publisher.peerConnection.addEventListener('iceconnectionstatechange', event => {
  643. if (publisher.peerConnection.iceConnectionState === 'connected'
  644. || publisher.peerConnection.iceConnectionState === 'completed') {
  645. clearTimeout(publisher.connectionWarning)
  646. publisher.connectionWarning = null
  647. return
  648. }
  649. if (publisher.peerConnection.iceConnectionState === 'disconnected') {
  650. // Brief disconnections are normal and expected; they are only
  651. // relevant if the connection has not been restored after some
  652. // seconds.
  653. publisher.connectionWarning = setTimeout(() => {
  654. console.warn('Publisher disconnected', publisher.sessionId)
  655. }, connectionWarningTimeout)
  656. } else if (publisher.peerConnection.iceConnectionState === 'failed') {
  657. console.warn('Publisher connection failed', publisher.sessionId)
  658. }
  659. })
  660. })
  661. }
  662. async function initPublishers() {
  663. for (let i = 0; i < publishersCount; i++) {
  664. const signalingSettings = await getSignalingSettings(user, appToken, token)
  665. let signaling = null
  666. try {
  667. signaling = new Signaling(user, signalingSettings)
  668. } catch (exception) {
  669. console.error('Publisher ' + i + ' init error: ' + exception)
  670. continue
  671. }
  672. const [publisherSessionId, publisher] = await newPublisher(signalingSettings, signaling, stream)
  673. try {
  674. await publisher.connect()
  675. if ((i + 1) % 5 === 0 && (i + 1) < publishersCount) {
  676. console.info('Publisher started (' + (i + 1) + '/' + publishersCount + ')')
  677. }
  678. } catch (exception) {
  679. console.warn('Publisher ' + i + ' error: ' + exception)
  680. }
  681. publishers[publisherSessionId] = publisher
  682. }
  683. console.info('Publishers started the siege')
  684. listenToPublisherConnectionChanges()
  685. }
  686. function listenToSubscriberConnectionChanges() {
  687. subscribers.forEach(subscriber => {
  688. subscriber.peerConnection.addEventListener('iceconnectionstatechange', event => {
  689. if (subscriber.peerConnection.iceConnectionState === 'connected'
  690. || subscriber.peerConnection.iceConnectionState === 'completed') {
  691. clearTimeout(subscriber.connectionWarning)
  692. subscriber.connectionWarning = null
  693. return
  694. }
  695. if (subscriber.peerConnection.iceConnectionState === 'disconnected') {
  696. // Brief disconnections are normal and expected; they are only
  697. // relevant if the connection has not been restored after some
  698. // seconds.
  699. subscriber.connectionWarning = setTimeout(() => {
  700. console.warn('Subscriber disconnected', subscriber.sessionId)
  701. }, connectionWarningTimeout)
  702. } else if (subscriber.peerConnection.iceConnectionState === 'failed') {
  703. console.warn('Subscriber connection failed', subscriber.sessionId)
  704. }
  705. })
  706. })
  707. }
  708. async function initSubscribers() {
  709. for (let i = 0; i < subscribersPerPublisherCount; i++) {
  710. // The same signaling session can be shared between subscribers to
  711. // different publishers.
  712. const signalingSettings = await getSignalingSettings(user, appToken, token)
  713. let signaling = null
  714. try {
  715. signaling = new Signaling(user, signalingSettings)
  716. } catch (exception) {
  717. console.error('Subscriber ' + i + ' init error: ' + exception)
  718. continue
  719. }
  720. await signaling.getSessionId()
  721. Object.keys(publishers).forEach(async publisherSessionId => {
  722. const subscriber = new Subscriber(user, signalingSettings, signaling, publisherSessionId)
  723. subscribers.push(subscriber)
  724. })
  725. }
  726. for (let i = 0; i < subscribers.length; i++) {
  727. try {
  728. await subscribers[i].connect()
  729. if ((i + 1) % 5 === 0 && (i + 1) < subscribers.length) {
  730. console.info('Subscriber started (' + (i + 1) + '/' + subscribers.length + ')')
  731. }
  732. } catch (exception) {
  733. console.warn('Subscriber ' + i + ' error: ' + exception)
  734. }
  735. }
  736. console.info('Subscribers started the siege')
  737. listenToSubscriberConnectionChanges()
  738. }
  739. // Expose publishers to CLI.
  740. const getPublishers = function() {
  741. return publishers
  742. }
  743. // Expose subscribers to CLI.
  744. const getSubscribers = function() {
  745. return subscribers
  746. }
  747. const closeConnections = function() {
  748. subscribers.forEach(subscriber => {
  749. subscriber.peerConnection.close()
  750. })
  751. subscribers.splice(0)
  752. Object.values(publishers).forEach(publisher => {
  753. publisher.peerConnection.close()
  754. })
  755. Object.keys(publishers).forEach(publisherSessionId => {
  756. delete publishers[publisherSessionId]
  757. })
  758. if (stream) {
  759. stream.getTracks().forEach(track => {
  760. track.stop()
  761. })
  762. stream = null
  763. }
  764. }
  765. const setAudioEnabled = function(enabled) {
  766. if (!stream || !stream.getAudioTracks().length) {
  767. console.error('Audio was not initialized')
  768. return
  769. }
  770. // There will be at most a single audio track.
  771. stream.getAudioTracks()[0].enabled = enabled
  772. }
  773. const setVideoEnabled = function(enabled) {
  774. if (!stream || !stream.getVideoTracks().length) {
  775. console.error('Video was not initialized')
  776. return
  777. }
  778. // There will be at most a single video track.
  779. stream.getVideoTracks()[0].enabled = enabled
  780. }
  781. const setSentAudioStreamEnabled = function(enabled) {
  782. if (!stream || !stream.getAudioTracks().length) {
  783. console.error('Audio was not initialized')
  784. return
  785. }
  786. Object.values(publishers).forEach(publisher => {
  787. // For simplicity it is assumed that if audio is enabled the audio
  788. // sender will always be the first one.
  789. const audioSender = publisher.peerConnection.getSenders()[0]
  790. if (enabled) {
  791. audioSender.replaceTrack(stream.getAudioTracks()[0])
  792. } else {
  793. audioSender.replaceTrack(null)
  794. }
  795. })
  796. }
  797. const setSentVideoStreamEnabled = function(enabled) {
  798. if (!stream || !stream.getVideoTracks().length) {
  799. console.error('Video was not initialized')
  800. return
  801. }
  802. Object.values(publishers).forEach(publisher => {
  803. // For simplicity it is assumed that if audio is not enabled the video
  804. // sender will always be the first one, otherwise the second one.
  805. let videoIndex = 0
  806. if (stream.getAudioTracks().length) {
  807. videoIndex = 1
  808. }
  809. const videoSender = publisher.peerConnection.getSenders()[videoIndex]
  810. if (enabled) {
  811. videoSender.replaceTrack(stream.getVideoTracks()[0])
  812. } else {
  813. videoSender.replaceTrack(null)
  814. }
  815. })
  816. }
  817. const checkPublishersConnections = function() {
  818. const iceConnectionStateCount = {}
  819. Object.keys(publishers).forEach(publisherSessionId => {
  820. publisher = publishers[publisherSessionId]
  821. console.info(publisherSessionId + ': ' + publisher.peerConnection.iceConnectionState)
  822. if (iceConnectionStateCount[publisher.peerConnection.iceConnectionState] === undefined) {
  823. iceConnectionStateCount[publisher.peerConnection.iceConnectionState] = 1
  824. } else {
  825. iceConnectionStateCount[publisher.peerConnection.iceConnectionState]++
  826. }
  827. })
  828. console.info('Summary:')
  829. console.info(' - New: ' + (iceConnectionStateCount['new'] ?? 0))
  830. console.info(' - Connected: ' + ((iceConnectionStateCount['connected'] ?? 0) + (iceConnectionStateCount['completed'] ?? 0)))
  831. console.info(' - Disconnected: ' + (iceConnectionStateCount['disconnected'] ?? 0))
  832. console.info(' - Failed: ' + (iceConnectionStateCount['failed'] ?? 0))
  833. }
  834. const checkSubscribersConnections = function() {
  835. const iceConnectionStateCount = {}
  836. i = 0
  837. subscribers.forEach(subscriber => {
  838. console.info(i + ': ' + subscriber.peerConnection.iceConnectionState)
  839. i++
  840. if (iceConnectionStateCount[subscriber.peerConnection.iceConnectionState] === undefined) {
  841. iceConnectionStateCount[subscriber.peerConnection.iceConnectionState] = 1
  842. } else {
  843. iceConnectionStateCount[subscriber.peerConnection.iceConnectionState]++
  844. }
  845. })
  846. console.info('Summary:')
  847. console.info(' - New: ' + (iceConnectionStateCount['new'] ?? 0))
  848. console.info(' - Connected: ' + ((iceConnectionStateCount['connected'] ?? 0) + (iceConnectionStateCount['completed'] ?? 0)))
  849. console.info(' - Disconnected: ' + (iceConnectionStateCount['disconnected'] ?? 0))
  850. console.info(' - Failed: ' + (iceConnectionStateCount['failed'] ?? 0))
  851. }
  852. const printPublisherStats = async function(publisherSessionId, stringify = false) {
  853. if (!(publisherSessionId in publishers)) {
  854. console.error('Invalid publisher session ID')
  855. return
  856. }
  857. stats = await publishers[publisherSessionId].peerConnection.getStats()
  858. for (stat of stats.values()) {
  859. if (stringify) {
  860. console.info(JSON.stringify(stat))
  861. } else {
  862. console.info(stat)
  863. }
  864. }
  865. }
  866. const printSubscriberStats = async function(index, stringify = false) {
  867. if (!(index in subscribers)) {
  868. console.error('Index out of range')
  869. return
  870. }
  871. stats = await subscribers[index].peerConnection.getStats()
  872. for (stat of stats.values()) {
  873. if (stringify) {
  874. console.info(JSON.stringify(stat))
  875. } else {
  876. console.info(stat)
  877. }
  878. }
  879. }
  880. const setCredentials = function(userToSet, appTokenToSet) {
  881. user = userToSet
  882. appToken = appTokenToSet
  883. }
  884. const setToken = function(tokenToSet) {
  885. token = tokenToSet
  886. joinLeaveRoomUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/room/' + token + '/participants/active'
  887. joinLeaveCallUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/call/' + token
  888. }
  889. const setPublishersAndSubscribersCount = function(publishersCountToSet, subscribersPerPublisherCountToSet) {
  890. publishersCount = publishersCountToSet
  891. subscribersPerPublisherCount = subscribersPerPublisherCountToSet
  892. }
  893. const startMedia = async function(audio, video) {
  894. if (stream) {
  895. stream.getTracks().forEach(track => {
  896. track.stop()
  897. })
  898. stream = null
  899. }
  900. if (audio !== undefined) {
  901. mediaConstraints.audio = audio
  902. }
  903. if (video !== undefined) {
  904. mediaConstraints.video = video
  905. }
  906. stream = await navigator.mediaDevices.getUserMedia(mediaConstraints)
  907. }
  908. const setConnectionWarningTimeout = function(connectionWarningTimeoutToSet) {
  909. connectionWarningTimeout = connectionWarningTimeoutToSet
  910. }
  911. const siege = async function() {
  912. if (!user || !appToken) {
  913. console.error('Credentials (user and appToken) are not set')
  914. return
  915. }
  916. closeConnections()
  917. if (!stream) {
  918. await startMedia()
  919. }
  920. console.info('Preparing to siege')
  921. await initPublishers()
  922. await initSubscribers()
  923. }
  924. // Expose virtual participant to CLI.
  925. const getVirtualParticipant = function() {
  926. return virtualParticipant
  927. }
  928. const startVirtualParticipant = async function() {
  929. if (!token) {
  930. console.error('Conversation token is not set')
  931. return
  932. }
  933. const signalingSettings = await getSignalingSettings(user, appToken, token)
  934. let signaling = null
  935. try {
  936. signaling = new Signaling(user, signalingSettings)
  937. } catch (exception) {
  938. console.error('Virtual participant init error: ' + exception)
  939. return
  940. }
  941. let flags = 1
  942. let publisherSessionId
  943. let publisher
  944. if (stream) {
  945. [publisherSessionId, publisher] = await newPublisher(signalingSettings, signaling, stream)
  946. if (stream.getAudioTracks().length > 0) {
  947. flags |= 2
  948. }
  949. if (stream.getVideoTracks().length > 0) {
  950. flags |= 4
  951. }
  952. } else {
  953. await signaling.getSessionId()
  954. }
  955. await signaling.joinRoom()
  956. await signaling.joinCall(flags)
  957. virtualParticipant = {
  958. signaling
  959. }
  960. if (stream) {
  961. try {
  962. // Data channels are expected to be available for call participants.
  963. virtualParticipant.dataChannel = publisher.peerConnection.createDataChannel('status')
  964. await publisher.connect()
  965. publishers[publisherSessionId] = publisher
  966. virtualParticipant.publisherSessionId = publisherSessionId
  967. } catch (exception) {
  968. console.warn('Virtual participant publisher error: ' + exception)
  969. }
  970. }
  971. }
  972. const stopVirtualParticipant = async function() {
  973. if (!virtualParticipant) {
  974. return
  975. }
  976. if (virtualParticipant.publisherSessionId) {
  977. publishers[virtualParticipant.publisherSessionId].peerConnection.close()
  978. delete publishers[virtualParticipant.publisherSessionId]
  979. }
  980. await virtualParticipant.signaling.leaveCall()
  981. virtualParticipant.signaling.leaveRoom()
  982. virtualParticipant = null
  983. }
  984. function isVirtualParticipantAndDataChannelAvailable() {
  985. if (!virtualParticipant) {
  986. console.error('Virtual participant not started')
  987. return false
  988. }
  989. if (!virtualParticipant.dataChannel) {
  990. console.error('Data channel not open for virtual participant (was media enabled when virtual participant was started?)')
  991. return false
  992. }
  993. return true
  994. }
  995. const sendMediaEnabledStateThroughDataChannel = function(mediaType, enabled) {
  996. if (!isVirtualParticipantAndDataChannelAvailable()) {
  997. return
  998. }
  999. let messageType
  1000. if (mediaType === 'audio' && enabled) {
  1001. messageType = 'audioOn'
  1002. } else if (mediaType === 'audio' && !enabled) {
  1003. messageType = 'audioOff'
  1004. } else if (mediaType === 'video' && enabled) {
  1005. messageType = 'videoOn'
  1006. } else if (mediaType === 'video' && !enabled) {
  1007. messageType = 'videoOff'
  1008. } else {
  1009. console.error('Wrong parameters, expected "audio" or "video" and a boolean: ', mediaType, enabled)
  1010. return
  1011. }
  1012. virtualParticipant.dataChannel.send(JSON.stringify({
  1013. type: messageType
  1014. }))
  1015. }
  1016. const sendSpeakingStateThroughDataChannel = function(speaking) {
  1017. if (!isVirtualParticipantAndDataChannelAvailable()) {
  1018. return
  1019. }
  1020. let messageType
  1021. if (speaking) {
  1022. messageType = 'speaking'
  1023. } else {
  1024. messageType = 'stoppedSpeaking'
  1025. }
  1026. virtualParticipant.dataChannel.send(JSON.stringify({
  1027. type: messageType
  1028. }))
  1029. }
  1030. const sendNickThroughDataChannel = function(nick) {
  1031. if (!isVirtualParticipantAndDataChannelAvailable()) {
  1032. return
  1033. }
  1034. if (!virtualParticipant.signaling.user) {
  1035. payload = nick
  1036. } else {
  1037. payload = {
  1038. name: nick,
  1039. userid: virtualParticipant.signaling.user,
  1040. }
  1041. }
  1042. virtualParticipant.dataChannel.send(JSON.stringify({
  1043. type: 'nickChanged',
  1044. payload,
  1045. }))
  1046. }