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

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