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.

RecordingController.js 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. /* @flow */
  2. import Bourne from '@hapi/bourne';
  3. import { i18next } from '../../base/i18n';
  4. import logger from '../logger';
  5. import {
  6. FlacAdapter,
  7. OggAdapter,
  8. WavAdapter,
  9. downloadBlob
  10. } from '../recording';
  11. import { sessionManager } from '../session';
  12. /**
  13. * XMPP command for signaling the start of local recording to all clients.
  14. * Should be sent by the moderator only.
  15. */
  16. const COMMAND_START = 'localRecStart';
  17. /**
  18. * XMPP command for signaling the stop of local recording to all clients.
  19. * Should be sent by the moderator only.
  20. */
  21. const COMMAND_STOP = 'localRecStop';
  22. /**
  23. * One-time command used to trigger the moderator to resend the commands.
  24. * This is a workaround for newly-joined clients to receive remote presence.
  25. */
  26. const COMMAND_PING = 'localRecPing';
  27. /**
  28. * One-time command sent upon receiving a {@code COMMAND_PING}.
  29. * Only the moderator sends this command.
  30. * This command does not carry any information itself, but rather forces the
  31. * XMPP server to resend the remote presence.
  32. */
  33. const COMMAND_PONG = 'localRecPong';
  34. /**
  35. * Participant property key for local recording stats.
  36. */
  37. const PROPERTY_STATS = 'localRecStats';
  38. /**
  39. * Supported recording formats.
  40. */
  41. const RECORDING_FORMATS = new Set([ 'flac', 'wav', 'ogg' ]);
  42. /**
  43. * Default recording format.
  44. */
  45. const DEFAULT_RECORDING_FORMAT = 'flac';
  46. /**
  47. * States of the {@code RecordingController}.
  48. */
  49. const ControllerState = Object.freeze({
  50. /**
  51. * Idle (not recording).
  52. */
  53. IDLE: Symbol('IDLE'),
  54. /**
  55. * Starting.
  56. */
  57. STARTING: Symbol('STARTING'),
  58. /**
  59. * Engaged (recording).
  60. */
  61. RECORDING: Symbol('RECORDING'),
  62. /**
  63. * Stopping.
  64. */
  65. STOPPING: Symbol('STOPPING'),
  66. /**
  67. * Failed, due to error during starting / stopping process.
  68. */
  69. FAILED: Symbol('FAILED')
  70. });
  71. /**
  72. * Type of the stats reported by each participant (client).
  73. */
  74. type RecordingStats = {
  75. /**
  76. * Current local recording session token used by the participant.
  77. */
  78. currentSessionToken: number,
  79. /**
  80. * Whether local recording is engaged on the participant's device.
  81. */
  82. isRecording: boolean,
  83. /**
  84. * Total recorded bytes. (Reserved for future use.)
  85. */
  86. recordedBytes: number,
  87. /**
  88. * Total recording duration. (Reserved for future use.)
  89. */
  90. recordedLength: number
  91. }
  92. /**
  93. * The component responsible for the coordination of local recording, across
  94. * multiple participants.
  95. * Current implementation requires that there is only one moderator in a room.
  96. */
  97. class RecordingController {
  98. /**
  99. * For each recording session, there is a separate @{code RecordingAdapter}
  100. * instance so that encoded bits from the previous sessions can still be
  101. * retrieved after they ended.
  102. *
  103. * @private
  104. */
  105. _adapters = {};
  106. /**
  107. * The {@code JitsiConference} instance.
  108. *
  109. * @private
  110. */
  111. _conference: * = null;
  112. /**
  113. * Current recording session token.
  114. * Session token is a number generated by the moderator, to ensure every
  115. * client is in the same recording state.
  116. *
  117. * @private
  118. */
  119. _currentSessionToken: number = -1;
  120. /**
  121. * Current state of {@code RecordingController}.
  122. *
  123. * @private
  124. */
  125. _state = ControllerState.IDLE;
  126. /**
  127. * Whether or not the audio is muted in the UI. This is stored as internal
  128. * state of {@code RecordingController} because we might have recording
  129. * sessions that start muted.
  130. */
  131. _isMuted = false;
  132. /**
  133. * The ID of the active microphone.
  134. *
  135. * @private
  136. */
  137. _micDeviceId = 'default';
  138. /**
  139. * Current recording format. This will be in effect from the next
  140. * recording session, i.e., if this value is changed during an on-going
  141. * recording session, that on-going session will not use the new format.
  142. *
  143. * @private
  144. */
  145. _format = DEFAULT_RECORDING_FORMAT;
  146. /**
  147. * Whether or not the {@code RecordingController} has registered for
  148. * XMPP events. Prevents initialization from happening multiple times.
  149. *
  150. * @private
  151. */
  152. _registered = false;
  153. /**
  154. * FIXME: callback function for the {@code RecordingController} to notify
  155. * UI it wants to display a notice. Keeps {@code RecordingController}
  156. * decoupled from UI.
  157. */
  158. _onNotify: ?(messageKey: string, messageParams?: Object) => void;
  159. /**
  160. * FIXME: callback function for the {@code RecordingController} to notify
  161. * UI it wants to display a warning. Keeps {@code RecordingController}
  162. * decoupled from UI.
  163. */
  164. _onWarning: ?(messageKey: string, messageParams?: Object) => void;
  165. /**
  166. * FIXME: callback function for the {@code RecordingController} to notify
  167. * UI that the local recording state has changed.
  168. */
  169. _onStateChanged: ?(boolean) => void;
  170. /**
  171. * Constructor.
  172. *
  173. * @returns {void}
  174. */
  175. constructor() {
  176. this.registerEvents = this.registerEvents.bind(this);
  177. this.getParticipantsStats = this.getParticipantsStats.bind(this);
  178. this._onStartCommand = this._onStartCommand.bind(this);
  179. this._onStopCommand = this._onStopCommand.bind(this);
  180. this._onPingCommand = this._onPingCommand.bind(this);
  181. this._doStartRecording = this._doStartRecording.bind(this);
  182. this._doStopRecording = this._doStopRecording.bind(this);
  183. this._updateStats = this._updateStats.bind(this);
  184. this._switchToNewSession = this._switchToNewSession.bind(this);
  185. }
  186. registerEvents: () => void;
  187. /**
  188. * Registers listeners for XMPP events.
  189. *
  190. * @param {JitsiConference} conference - A {@code JitsiConference} instance.
  191. * @returns {void}
  192. */
  193. registerEvents(conference: Object) {
  194. if (!this._registered) {
  195. this._conference = conference;
  196. if (this._conference) {
  197. this._conference
  198. .addCommandListener(COMMAND_STOP, this._onStopCommand);
  199. this._conference
  200. .addCommandListener(COMMAND_START, this._onStartCommand);
  201. this._conference
  202. .addCommandListener(COMMAND_PING, this._onPingCommand);
  203. this._registered = true;
  204. }
  205. if (!this._conference.isModerator()) {
  206. this._conference.sendCommandOnce(COMMAND_PING, {});
  207. }
  208. }
  209. }
  210. /**
  211. * Sets the event handler for {@code onStateChanged}.
  212. *
  213. * @param {Function} delegate - The event handler.
  214. * @returns {void}
  215. */
  216. set onStateChanged(delegate: Function) {
  217. this._onStateChanged = delegate;
  218. }
  219. /**
  220. * Sets the event handler for {@code onNotify}.
  221. *
  222. * @param {Function} delegate - The event handler.
  223. * @returns {void}
  224. */
  225. set onNotify(delegate: Function) {
  226. this._onNotify = delegate;
  227. }
  228. /**
  229. * Sets the event handler for {@code onWarning}.
  230. *
  231. * @param {Function} delegate - The event handler.
  232. * @returns {void}
  233. */
  234. set onWarning(delegate: Function) {
  235. this._onWarning = delegate;
  236. }
  237. /**
  238. * Signals the participants to start local recording.
  239. *
  240. * @returns {void}
  241. */
  242. startRecording() {
  243. this.registerEvents();
  244. if (this._conference && this._conference.isModerator()) {
  245. this._conference.removeCommand(COMMAND_STOP);
  246. this._conference.sendCommand(COMMAND_START, {
  247. attributes: {
  248. sessionToken: this._getRandomToken(),
  249. format: this._format
  250. }
  251. });
  252. } else if (this._onWarning) {
  253. this._onWarning('localRecording.messages.notModerator');
  254. }
  255. }
  256. /**
  257. * Signals the participants to stop local recording.
  258. *
  259. * @returns {void}
  260. */
  261. stopRecording() {
  262. if (this._conference) {
  263. if (this._conference.isModerator()) {
  264. this._conference.removeCommand(COMMAND_START);
  265. this._conference.sendCommand(COMMAND_STOP, {
  266. attributes: {
  267. sessionToken: this._currentSessionToken
  268. }
  269. });
  270. } else if (this._onWarning) {
  271. this._onWarning('localRecording.messages.notModerator');
  272. }
  273. }
  274. }
  275. /**
  276. * Triggers the download of recorded data.
  277. * Browser only.
  278. *
  279. * @param {number} sessionToken - The token of the session to download.
  280. * @returns {void}
  281. */
  282. downloadRecordedData(sessionToken: number) {
  283. if (this._adapters[sessionToken]) {
  284. this._adapters[sessionToken].exportRecordedData()
  285. .then(args => {
  286. const { data, format } = args;
  287. const filename = `session_${sessionToken}`
  288. + `_${this._conference.myUserId()}.${format}`;
  289. downloadBlob(data, filename);
  290. })
  291. .catch(error => {
  292. logger.error('Failed to download audio for'
  293. + ` session ${sessionToken}. Error: ${error}`);
  294. });
  295. } else {
  296. logger.error(`Invalid session token for download ${sessionToken}`);
  297. }
  298. }
  299. /**
  300. * Changes the current microphone.
  301. *
  302. * @param {string} micDeviceId - The new microphone device ID.
  303. * @returns {void}
  304. */
  305. setMicDevice(micDeviceId: string) {
  306. if (micDeviceId !== this._micDeviceId) {
  307. this._micDeviceId = String(micDeviceId);
  308. if (this._state === ControllerState.RECORDING) {
  309. // sessionManager.endSegment(this._currentSessionToken);
  310. logger.log('Before switching microphone...');
  311. this._adapters[this._currentSessionToken]
  312. .setMicDevice(this._micDeviceId)
  313. .then(() => {
  314. logger.log('Finished switching microphone.');
  315. // sessionManager.beginSegment(this._currentSesoken);
  316. })
  317. .catch(() => {
  318. logger.error('Failed to switch microphone');
  319. });
  320. }
  321. logger.log(`Switch microphone to ${this._micDeviceId}`);
  322. }
  323. }
  324. /**
  325. * Mute or unmute audio. When muted, the ongoing local recording should
  326. * produce silence.
  327. *
  328. * @param {boolean} muted - If the audio should be muted.
  329. * @returns {void}
  330. */
  331. setMuted(muted: boolean) {
  332. this._isMuted = Boolean(muted);
  333. if (this._state === ControllerState.RECORDING) {
  334. this._adapters[this._currentSessionToken].setMuted(this._isMuted);
  335. }
  336. }
  337. /**
  338. * Switches the recording format.
  339. *
  340. * @param {string} newFormat - The new format.
  341. * @returns {void}
  342. */
  343. switchFormat(newFormat: string) {
  344. if (!RECORDING_FORMATS.has(newFormat)) {
  345. logger.log(`Unknown format ${newFormat}. Ignoring...`);
  346. return;
  347. }
  348. this._format = newFormat;
  349. logger.log(`Recording format switched to ${newFormat}`);
  350. // the new format will be used in the next recording session
  351. }
  352. /**
  353. * Returns the local recording stats.
  354. *
  355. * @returns {RecordingStats}
  356. */
  357. getLocalStats(): RecordingStats {
  358. return {
  359. currentSessionToken: this._currentSessionToken,
  360. isRecording: this._state === ControllerState.RECORDING,
  361. recordedBytes: 0,
  362. recordedLength: 0
  363. };
  364. }
  365. getParticipantsStats: () => *;
  366. /**
  367. * Returns the remote participants' local recording stats.
  368. *
  369. * @returns {*}
  370. */
  371. getParticipantsStats() {
  372. const members
  373. = this._conference.getParticipants()
  374. .map(member => {
  375. return {
  376. id: member.getId(),
  377. displayName: member.getDisplayName(),
  378. recordingStats:
  379. Bourne.parse(member.getProperty(PROPERTY_STATS) || '{}'),
  380. isSelf: false
  381. };
  382. });
  383. // transform into a dictionary for consistent ordering
  384. const result = {};
  385. for (let i = 0; i < members.length; ++i) {
  386. result[members[i].id] = members[i];
  387. }
  388. const localId = this._conference.myUserId();
  389. result[localId] = {
  390. id: localId,
  391. displayName: i18next.t('localRecording.me'),
  392. recordingStats: this.getLocalStats(),
  393. isSelf: true
  394. };
  395. return result;
  396. }
  397. _changeState: (Symbol) => void;
  398. /**
  399. * Changes the current state of {@code RecordingController}.
  400. *
  401. * @private
  402. * @param {Symbol} newState - The new state.
  403. * @returns {void}
  404. */
  405. _changeState(newState: Symbol) {
  406. if (this._state !== newState) {
  407. logger.log(`state change: ${this._state.toString()} -> `
  408. + `${newState.toString()}`);
  409. this._state = newState;
  410. }
  411. }
  412. _updateStats: () => void;
  413. /**
  414. * Sends out updates about the local recording stats via XMPP.
  415. *
  416. * @private
  417. * @returns {void}
  418. */
  419. _updateStats() {
  420. if (this._conference) {
  421. this._conference.setLocalParticipantProperty(PROPERTY_STATS,
  422. JSON.stringify(this.getLocalStats()));
  423. }
  424. }
  425. _onStartCommand: (*) => void;
  426. /**
  427. * Callback function for XMPP event.
  428. *
  429. * @private
  430. * @param {*} value - The event args.
  431. * @returns {void}
  432. */
  433. _onStartCommand(value) {
  434. const { sessionToken, format } = value.attributes;
  435. if (this._state === ControllerState.IDLE) {
  436. this._changeState(ControllerState.STARTING);
  437. this._switchToNewSession(sessionToken, format);
  438. this._doStartRecording();
  439. } else if (this._state === ControllerState.RECORDING
  440. && this._currentSessionToken !== sessionToken) {
  441. // There is local recording going on, but not for the same session.
  442. // This means the current state might be out-of-sync with the
  443. // moderator's, so we need to restart the recording.
  444. this._changeState(ControllerState.STOPPING);
  445. this._doStopRecording().then(() => {
  446. this._changeState(ControllerState.STARTING);
  447. this._switchToNewSession(sessionToken, format);
  448. this._doStartRecording();
  449. });
  450. }
  451. }
  452. _onStopCommand: (*) => void;
  453. /**
  454. * Callback function for XMPP event.
  455. *
  456. * @private
  457. * @param {*} value - The event args.
  458. * @returns {void}
  459. */
  460. _onStopCommand(value) {
  461. if (this._state === ControllerState.RECORDING
  462. && this._currentSessionToken === value.attributes.sessionToken) {
  463. this._changeState(ControllerState.STOPPING);
  464. this._doStopRecording();
  465. }
  466. }
  467. _onPingCommand: () => void;
  468. /**
  469. * Callback function for XMPP event.
  470. *
  471. * @private
  472. * @returns {void}
  473. */
  474. _onPingCommand() {
  475. if (this._conference.isModerator()) {
  476. logger.log('Received ping, sending pong.');
  477. this._conference.sendCommandOnce(COMMAND_PONG, {});
  478. }
  479. }
  480. /**
  481. * Generates a token that can be used to distinguish each local recording
  482. * session.
  483. *
  484. * @returns {number}
  485. */
  486. _getRandomToken() {
  487. return Math.floor(Math.random() * 100000000) + 1;
  488. }
  489. _doStartRecording: () => void;
  490. /**
  491. * Starts the recording locally.
  492. *
  493. * @private
  494. * @returns {void}
  495. */
  496. _doStartRecording() {
  497. if (this._state === ControllerState.STARTING) {
  498. const delegate = this._adapters[this._currentSessionToken];
  499. delegate.start(this._micDeviceId)
  500. .then(() => {
  501. this._changeState(ControllerState.RECORDING);
  502. sessionManager.beginSegment(this._currentSessionToken);
  503. logger.log('Local recording engaged.');
  504. if (this._onNotify) {
  505. this._onNotify('localRecording.messages.engaged');
  506. }
  507. if (this._onStateChanged) {
  508. this._onStateChanged(true);
  509. }
  510. delegate.setMuted(this._isMuted);
  511. this._updateStats();
  512. })
  513. .catch(err => {
  514. logger.error('Failed to start local recording.', err);
  515. });
  516. }
  517. }
  518. _doStopRecording: () => Promise<void>;
  519. /**
  520. * Stops the recording locally.
  521. *
  522. * @private
  523. * @returns {Promise<void>}
  524. */
  525. _doStopRecording() {
  526. if (this._state === ControllerState.STOPPING) {
  527. const token = this._currentSessionToken;
  528. return this._adapters[this._currentSessionToken]
  529. .stop()
  530. .then(() => {
  531. this._changeState(ControllerState.IDLE);
  532. sessionManager.endSegment(this._currentSessionToken);
  533. logger.log('Local recording unengaged.');
  534. this.downloadRecordedData(token);
  535. const messageKey
  536. = this._conference.isModerator()
  537. ? 'localRecording.messages.finishedModerator'
  538. : 'localRecording.messages.finished';
  539. const messageParams = {
  540. token
  541. };
  542. if (this._onNotify) {
  543. this._onNotify(messageKey, messageParams);
  544. }
  545. if (this._onStateChanged) {
  546. this._onStateChanged(false);
  547. }
  548. this._updateStats();
  549. })
  550. .catch(err => {
  551. logger.error('Failed to stop local recording.', err);
  552. });
  553. }
  554. /* eslint-disable */
  555. return (Promise.resolve(): Promise<void>);
  556. // FIXME: better ways to satisfy flow and ESLint at the same time?
  557. /* eslint-enable */
  558. }
  559. _switchToNewSession: (string, string) => void;
  560. /**
  561. * Switches to a new local recording session.
  562. *
  563. * @param {string} sessionToken - The session Token.
  564. * @param {string} format - The recording format for the session.
  565. * @returns {void}
  566. */
  567. _switchToNewSession(sessionToken, format) {
  568. this._format = format;
  569. this._currentSessionToken = sessionToken;
  570. logger.log(`New session: ${this._currentSessionToken}, `
  571. + `format: ${this._format}`);
  572. this._adapters[sessionToken]
  573. = this._createRecordingAdapter();
  574. sessionManager.createSession(sessionToken, this._format);
  575. }
  576. /**
  577. * Creates a recording adapter according to the current recording format.
  578. *
  579. * @private
  580. * @returns {RecordingAdapter}
  581. */
  582. _createRecordingAdapter() {
  583. logger.debug('[RecordingController] creating recording'
  584. + ` adapter for ${this._format} format.`);
  585. switch (this._format) {
  586. case 'ogg':
  587. return new OggAdapter();
  588. case 'flac':
  589. return new FlacAdapter();
  590. case 'wav':
  591. return new WavAdapter();
  592. default:
  593. throw new Error(`Unknown format: ${this._format}`);
  594. }
  595. }
  596. }
  597. /**
  598. * Global singleton of {@code RecordingController}.
  599. */
  600. export const recordingController = new RecordingController();