123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687 |
- /* @flow */
-
- import Bourne from '@hapi/bourne';
-
- import { i18next } from '../../base/i18n';
- import logger from '../logger';
- import {
- FlacAdapter,
- OggAdapter,
- WavAdapter,
- downloadBlob
- } from '../recording';
- import { sessionManager } from '../session';
-
- /**
- * XMPP command for signaling the start of local recording to all clients.
- * Should be sent by the moderator only.
- */
- const COMMAND_START = 'localRecStart';
-
- /**
- * XMPP command for signaling the stop of local recording to all clients.
- * Should be sent by the moderator only.
- */
- const COMMAND_STOP = 'localRecStop';
-
- /**
- * One-time command used to trigger the moderator to resend the commands.
- * This is a workaround for newly-joined clients to receive remote presence.
- */
- const COMMAND_PING = 'localRecPing';
-
- /**
- * One-time command sent upon receiving a {@code COMMAND_PING}.
- * Only the moderator sends this command.
- * This command does not carry any information itself, but rather forces the
- * XMPP server to resend the remote presence.
- */
- const COMMAND_PONG = 'localRecPong';
-
- /**
- * Participant property key for local recording stats.
- */
- const PROPERTY_STATS = 'localRecStats';
-
- /**
- * Supported recording formats.
- */
- const RECORDING_FORMATS = new Set([ 'flac', 'wav', 'ogg' ]);
-
- /**
- * Default recording format.
- */
- const DEFAULT_RECORDING_FORMAT = 'flac';
-
- /**
- * States of the {@code RecordingController}.
- */
- const ControllerState = Object.freeze({
- /**
- * Idle (not recording).
- */
- IDLE: Symbol('IDLE'),
-
- /**
- * Starting.
- */
- STARTING: Symbol('STARTING'),
-
- /**
- * Engaged (recording).
- */
- RECORDING: Symbol('RECORDING'),
-
- /**
- * Stopping.
- */
- STOPPING: Symbol('STOPPING'),
-
- /**
- * Failed, due to error during starting / stopping process.
- */
- FAILED: Symbol('FAILED')
- });
-
- /**
- * Type of the stats reported by each participant (client).
- */
- type RecordingStats = {
-
- /**
- * Current local recording session token used by the participant.
- */
- currentSessionToken: number,
-
- /**
- * Whether local recording is engaged on the participant's device.
- */
- isRecording: boolean,
-
- /**
- * Total recorded bytes. (Reserved for future use.)
- */
- recordedBytes: number,
-
- /**
- * Total recording duration. (Reserved for future use.)
- */
- recordedLength: number
- }
-
- /**
- * The component responsible for the coordination of local recording, across
- * multiple participants.
- * Current implementation requires that there is only one moderator in a room.
- */
- class RecordingController {
-
- /**
- * For each recording session, there is a separate @{code RecordingAdapter}
- * instance so that encoded bits from the previous sessions can still be
- * retrieved after they ended.
- *
- * @private
- */
- _adapters = {};
-
- /**
- * The {@code JitsiConference} instance.
- *
- * @private
- */
- _conference: * = null;
-
- /**
- * Current recording session token.
- * Session token is a number generated by the moderator, to ensure every
- * client is in the same recording state.
- *
- * @private
- */
- _currentSessionToken: number = -1;
-
- /**
- * Current state of {@code RecordingController}.
- *
- * @private
- */
- _state = ControllerState.IDLE;
-
- /**
- * Whether or not the audio is muted in the UI. This is stored as internal
- * state of {@code RecordingController} because we might have recording
- * sessions that start muted.
- */
- _isMuted = false;
-
- /**
- * The ID of the active microphone.
- *
- * @private
- */
- _micDeviceId = 'default';
-
- /**
- * Current recording format. This will be in effect from the next
- * recording session, i.e., if this value is changed during an on-going
- * recording session, that on-going session will not use the new format.
- *
- * @private
- */
- _format = DEFAULT_RECORDING_FORMAT;
-
- /**
- * Whether or not the {@code RecordingController} has registered for
- * XMPP events. Prevents initialization from happening multiple times.
- *
- * @private
- */
- _registered = false;
-
- /**
- * FIXME: callback function for the {@code RecordingController} to notify
- * UI it wants to display a notice. Keeps {@code RecordingController}
- * decoupled from UI.
- */
- _onNotify: ?(messageKey: string, messageParams?: Object) => void;
-
- /**
- * FIXME: callback function for the {@code RecordingController} to notify
- * UI it wants to display a warning. Keeps {@code RecordingController}
- * decoupled from UI.
- */
- _onWarning: ?(messageKey: string, messageParams?: Object) => void;
-
- /**
- * FIXME: callback function for the {@code RecordingController} to notify
- * UI that the local recording state has changed.
- */
- _onStateChanged: ?(boolean) => void;
-
- /**
- * Constructor.
- *
- * @returns {void}
- */
- constructor() {
- this.registerEvents = this.registerEvents.bind(this);
- this.getParticipantsStats = this.getParticipantsStats.bind(this);
- this._onStartCommand = this._onStartCommand.bind(this);
- this._onStopCommand = this._onStopCommand.bind(this);
- this._onPingCommand = this._onPingCommand.bind(this);
- this._doStartRecording = this._doStartRecording.bind(this);
- this._doStopRecording = this._doStopRecording.bind(this);
- this._updateStats = this._updateStats.bind(this);
- this._switchToNewSession = this._switchToNewSession.bind(this);
- }
-
- registerEvents: () => void;
-
- /**
- * Registers listeners for XMPP events.
- *
- * @param {JitsiConference} conference - A {@code JitsiConference} instance.
- * @returns {void}
- */
- registerEvents(conference: Object) {
- if (!this._registered) {
- this._conference = conference;
- if (this._conference) {
- this._conference
- .addCommandListener(COMMAND_STOP, this._onStopCommand);
- this._conference
- .addCommandListener(COMMAND_START, this._onStartCommand);
- this._conference
- .addCommandListener(COMMAND_PING, this._onPingCommand);
- this._registered = true;
- }
- if (!this._conference.isModerator()) {
- this._conference.sendCommandOnce(COMMAND_PING, {});
- }
- }
- }
-
- /**
- * Sets the event handler for {@code onStateChanged}.
- *
- * @param {Function} delegate - The event handler.
- * @returns {void}
- */
- set onStateChanged(delegate: Function) {
- this._onStateChanged = delegate;
- }
-
- /**
- * Sets the event handler for {@code onNotify}.
- *
- * @param {Function} delegate - The event handler.
- * @returns {void}
- */
- set onNotify(delegate: Function) {
- this._onNotify = delegate;
- }
-
- /**
- * Sets the event handler for {@code onWarning}.
- *
- * @param {Function} delegate - The event handler.
- * @returns {void}
- */
- set onWarning(delegate: Function) {
- this._onWarning = delegate;
- }
-
- /**
- * Signals the participants to start local recording.
- *
- * @returns {void}
- */
- startRecording() {
- this.registerEvents();
- if (this._conference && this._conference.isModerator()) {
- this._conference.removeCommand(COMMAND_STOP);
- this._conference.sendCommand(COMMAND_START, {
- attributes: {
- sessionToken: this._getRandomToken(),
- format: this._format
- }
- });
- } else if (this._onWarning) {
- this._onWarning('localRecording.messages.notModerator');
- }
- }
-
- /**
- * Signals the participants to stop local recording.
- *
- * @returns {void}
- */
- stopRecording() {
- if (this._conference) {
- if (this._conference.isModerator()) {
- this._conference.removeCommand(COMMAND_START);
- this._conference.sendCommand(COMMAND_STOP, {
- attributes: {
- sessionToken: this._currentSessionToken
- }
- });
- } else if (this._onWarning) {
- this._onWarning('localRecording.messages.notModerator');
- }
- }
- }
-
- /**
- * Triggers the download of recorded data.
- * Browser only.
- *
- * @param {number} sessionToken - The token of the session to download.
- * @returns {void}
- */
- downloadRecordedData(sessionToken: number) {
- if (this._adapters[sessionToken]) {
- this._adapters[sessionToken].exportRecordedData()
- .then(args => {
- const { data, format } = args;
-
- const filename = `session_${sessionToken}`
- + `_${this._conference.myUserId()}.${format}`;
-
- downloadBlob(data, filename);
- })
- .catch(error => {
- logger.error('Failed to download audio for'
- + ` session ${sessionToken}. Error: ${error}`);
- });
- } else {
- logger.error(`Invalid session token for download ${sessionToken}`);
- }
- }
-
- /**
- * Changes the current microphone.
- *
- * @param {string} micDeviceId - The new microphone device ID.
- * @returns {void}
- */
- setMicDevice(micDeviceId: string) {
- if (micDeviceId !== this._micDeviceId) {
- this._micDeviceId = String(micDeviceId);
-
- if (this._state === ControllerState.RECORDING) {
- // sessionManager.endSegment(this._currentSessionToken);
- logger.log('Before switching microphone...');
- this._adapters[this._currentSessionToken]
- .setMicDevice(this._micDeviceId)
- .then(() => {
- logger.log('Finished switching microphone.');
-
- // sessionManager.beginSegment(this._currentSesoken);
- })
- .catch(() => {
- logger.error('Failed to switch microphone');
- });
- }
- logger.log(`Switch microphone to ${this._micDeviceId}`);
- }
- }
-
- /**
- * Mute or unmute audio. When muted, the ongoing local recording should
- * produce silence.
- *
- * @param {boolean} muted - If the audio should be muted.
- * @returns {void}
- */
- setMuted(muted: boolean) {
- this._isMuted = Boolean(muted);
-
- if (this._state === ControllerState.RECORDING) {
- this._adapters[this._currentSessionToken].setMuted(this._isMuted);
- }
- }
-
- /**
- * Switches the recording format.
- *
- * @param {string} newFormat - The new format.
- * @returns {void}
- */
- switchFormat(newFormat: string) {
- if (!RECORDING_FORMATS.has(newFormat)) {
- logger.log(`Unknown format ${newFormat}. Ignoring...`);
-
- return;
- }
- this._format = newFormat;
- logger.log(`Recording format switched to ${newFormat}`);
-
- // the new format will be used in the next recording session
- }
-
- /**
- * Returns the local recording stats.
- *
- * @returns {RecordingStats}
- */
- getLocalStats(): RecordingStats {
- return {
- currentSessionToken: this._currentSessionToken,
- isRecording: this._state === ControllerState.RECORDING,
- recordedBytes: 0,
- recordedLength: 0
- };
- }
-
- getParticipantsStats: () => *;
-
- /**
- * Returns the remote participants' local recording stats.
- *
- * @returns {*}
- */
- getParticipantsStats() {
- const members
- = this._conference.getParticipants()
- .map(member => {
- return {
- id: member.getId(),
- displayName: member.getDisplayName(),
- recordingStats:
- Bourne.parse(member.getProperty(PROPERTY_STATS) || '{}'),
- isSelf: false
- };
- });
-
- // transform into a dictionary for consistent ordering
- const result = {};
-
- for (let i = 0; i < members.length; ++i) {
- result[members[i].id] = members[i];
- }
- const localId = this._conference.myUserId();
-
- result[localId] = {
- id: localId,
- displayName: i18next.t('localRecording.me'),
- recordingStats: this.getLocalStats(),
- isSelf: true
- };
-
- return result;
- }
-
- _changeState: (Symbol) => void;
-
- /**
- * Changes the current state of {@code RecordingController}.
- *
- * @private
- * @param {Symbol} newState - The new state.
- * @returns {void}
- */
- _changeState(newState: Symbol) {
- if (this._state !== newState) {
- logger.log(`state change: ${this._state.toString()} -> `
- + `${newState.toString()}`);
- this._state = newState;
- }
- }
-
- _updateStats: () => void;
-
- /**
- * Sends out updates about the local recording stats via XMPP.
- *
- * @private
- * @returns {void}
- */
- _updateStats() {
- if (this._conference) {
- this._conference.setLocalParticipantProperty(PROPERTY_STATS,
- JSON.stringify(this.getLocalStats()));
- }
- }
-
- _onStartCommand: (*) => void;
-
- /**
- * Callback function for XMPP event.
- *
- * @private
- * @param {*} value - The event args.
- * @returns {void}
- */
- _onStartCommand(value) {
- const { sessionToken, format } = value.attributes;
-
- if (this._state === ControllerState.IDLE) {
- this._changeState(ControllerState.STARTING);
- this._switchToNewSession(sessionToken, format);
- this._doStartRecording();
- } else if (this._state === ControllerState.RECORDING
- && this._currentSessionToken !== sessionToken) {
- // There is local recording going on, but not for the same session.
- // This means the current state might be out-of-sync with the
- // moderator's, so we need to restart the recording.
- this._changeState(ControllerState.STOPPING);
- this._doStopRecording().then(() => {
- this._changeState(ControllerState.STARTING);
- this._switchToNewSession(sessionToken, format);
- this._doStartRecording();
- });
- }
- }
-
- _onStopCommand: (*) => void;
-
- /**
- * Callback function for XMPP event.
- *
- * @private
- * @param {*} value - The event args.
- * @returns {void}
- */
- _onStopCommand(value) {
- if (this._state === ControllerState.RECORDING
- && this._currentSessionToken === value.attributes.sessionToken) {
- this._changeState(ControllerState.STOPPING);
- this._doStopRecording();
- }
- }
-
- _onPingCommand: () => void;
-
- /**
- * Callback function for XMPP event.
- *
- * @private
- * @returns {void}
- */
- _onPingCommand() {
- if (this._conference.isModerator()) {
- logger.log('Received ping, sending pong.');
- this._conference.sendCommandOnce(COMMAND_PONG, {});
- }
- }
-
- /**
- * Generates a token that can be used to distinguish each local recording
- * session.
- *
- * @returns {number}
- */
- _getRandomToken() {
- return Math.floor(Math.random() * 100000000) + 1;
- }
-
- _doStartRecording: () => void;
-
- /**
- * Starts the recording locally.
- *
- * @private
- * @returns {void}
- */
- _doStartRecording() {
- if (this._state === ControllerState.STARTING) {
- const delegate = this._adapters[this._currentSessionToken];
-
- delegate.start(this._micDeviceId)
- .then(() => {
- this._changeState(ControllerState.RECORDING);
- sessionManager.beginSegment(this._currentSessionToken);
- logger.log('Local recording engaged.');
-
- if (this._onNotify) {
- this._onNotify('localRecording.messages.engaged');
- }
- if (this._onStateChanged) {
- this._onStateChanged(true);
- }
-
- delegate.setMuted(this._isMuted);
- this._updateStats();
- })
- .catch(err => {
- logger.error('Failed to start local recording.', err);
- });
- }
-
- }
-
- _doStopRecording: () => Promise<void>;
-
- /**
- * Stops the recording locally.
- *
- * @private
- * @returns {Promise<void>}
- */
- _doStopRecording() {
- if (this._state === ControllerState.STOPPING) {
- const token = this._currentSessionToken;
-
- return this._adapters[this._currentSessionToken]
- .stop()
- .then(() => {
- this._changeState(ControllerState.IDLE);
- sessionManager.endSegment(this._currentSessionToken);
- logger.log('Local recording unengaged.');
- this.downloadRecordedData(token);
-
- const messageKey
- = this._conference.isModerator()
- ? 'localRecording.messages.finishedModerator'
- : 'localRecording.messages.finished';
- const messageParams = {
- token
- };
-
- if (this._onNotify) {
- this._onNotify(messageKey, messageParams);
- }
- if (this._onStateChanged) {
- this._onStateChanged(false);
- }
- this._updateStats();
- })
- .catch(err => {
- logger.error('Failed to stop local recording.', err);
- });
- }
-
- /* eslint-disable */
- return (Promise.resolve(): Promise<void>);
- // FIXME: better ways to satisfy flow and ESLint at the same time?
- /* eslint-enable */
-
- }
-
- _switchToNewSession: (string, string) => void;
-
- /**
- * Switches to a new local recording session.
- *
- * @param {string} sessionToken - The session Token.
- * @param {string} format - The recording format for the session.
- * @returns {void}
- */
- _switchToNewSession(sessionToken, format) {
- this._format = format;
- this._currentSessionToken = sessionToken;
- logger.log(`New session: ${this._currentSessionToken}, `
- + `format: ${this._format}`);
- this._adapters[sessionToken]
- = this._createRecordingAdapter();
- sessionManager.createSession(sessionToken, this._format);
- }
-
- /**
- * Creates a recording adapter according to the current recording format.
- *
- * @private
- * @returns {RecordingAdapter}
- */
- _createRecordingAdapter() {
- logger.debug('[RecordingController] creating recording'
- + ` adapter for ${this._format} format.`);
-
- switch (this._format) {
- case 'ogg':
- return new OggAdapter();
- case 'flac':
- return new FlacAdapter();
- case 'wav':
- return new WavAdapter();
- default:
- throw new Error(`Unknown format: ${this._format}`);
- }
- }
- }
-
- /**
- * Global singleton of {@code RecordingController}.
- */
- export const recordingController = new RecordingController();
|