diff --git a/Makefile b/Makefile index e86136866..44a3e22f3 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ BUILD_DIR = build CLEANCSS = ./node_modules/.bin/cleancss DEPLOY_DIR = libs LIBJITSIMEET_DIR = node_modules/lib-jitsi-meet/ +LIBFLAC_DIR = node_modules/libflacjs/dist/min/ NODE_SASS = ./node_modules/.bin/node-sass NPM = npm OUTPUT_DIR = . @@ -19,7 +20,7 @@ compile: clean: rm -fr $(BUILD_DIR) -deploy: deploy-init deploy-appbundle deploy-lib-jitsi-meet deploy-css deploy-local +deploy: deploy-init deploy-appbundle deploy-lib-jitsi-meet deploy-libflac deploy-css deploy-local deploy-init: rm -fr $(DEPLOY_DIR) @@ -33,6 +34,8 @@ deploy-appbundle: $(BUILD_DIR)/do_external_connect.min.map \ $(BUILD_DIR)/external_api.min.js \ $(BUILD_DIR)/external_api.min.map \ + $(BUILD_DIR)/flacEncodeWorker.min.js \ + $(BUILD_DIR)/flacEncodeWorker.min.map \ $(BUILD_DIR)/device_selection_popup_bundle.min.js \ $(BUILD_DIR)/device_selection_popup_bundle.min.map \ $(BUILD_DIR)/dial_in_info_bundle.min.js \ @@ -50,6 +53,12 @@ deploy-lib-jitsi-meet: $(LIBJITSIMEET_DIR)/modules/browser/capabilities.json \ $(DEPLOY_DIR) +deploy-libflac: + cp \ + $(LIBFLAC_DIR)/libflac4-1.3.2.min.js \ + $(LIBFLAC_DIR)/libflac4-1.3.2.min.js.mem \ + $(DEPLOY_DIR) + deploy-css: $(NODE_SASS) $(STYLES_MAIN) $(STYLES_BUNDLE) && \ $(CLEANCSS) $(STYLES_BUNDLE) > $(STYLES_DESTINATION) ; \ @@ -58,7 +67,7 @@ deploy-css: deploy-local: ([ ! -x deploy-local.sh ] || ./deploy-local.sh) -dev: deploy-init deploy-css deploy-lib-jitsi-meet +dev: deploy-init deploy-css deploy-lib-jitsi-meet deploy-libflac $(WEBPACK_DEV_SERVER) source-package: diff --git a/config.js b/config.js index 3c94dbccb..ec907da95 100644 --- a/config.js +++ b/config.js @@ -347,6 +347,24 @@ var config = { // userRegion: "asia" } + // Local Recording + // + + // localRecording: { + // Enables local recording. + // Additionally, 'localrecording' (all lowercase) needs to be added to + // TOOLBAR_BUTTONS in interface_config.js for the Local Recording + // button to show up on the toolbar. + // + // enabled: true, + // + + // The recording format, can be one of 'ogg', 'flac' or 'wav'. + // format: 'flac' + // + + // } + // Options related to end-to-end (participant to participant) ping. // e2eping: { // // The interval in milliseconds at which pings will be sent. @@ -408,6 +426,7 @@ var config = { nick startBitrate */ + }; /* eslint-enable no-unused-vars, no-var */ diff --git a/css/main.scss b/css/main.scss index aecc17e84..bbe497d36 100644 --- a/css/main.scss +++ b/css/main.scss @@ -45,6 +45,7 @@ @import 'modals/settings/settings'; @import 'modals/speaker_stats/speaker_stats'; @import 'modals/video-quality/video-quality'; +@import 'modals/local-recording/local-recording'; @import 'videolayout_default'; @import 'notice'; @import 'popup_menu'; diff --git a/css/modals/local-recording/_local-recording.scss b/css/modals/local-recording/_local-recording.scss new file mode 100644 index 000000000..e8a979f96 --- /dev/null +++ b/css/modals/local-recording/_local-recording.scss @@ -0,0 +1,92 @@ +.localrec-participant-stats { + list-style: none; + padding: 0; + width: 100%; + font-weight: 500; + + .localrec-participant-stats-item__status-dot { + position: relative; + display: block; + width: 9px; + height: 9px; + border-radius: 50%; + margin: 0 auto; + + &.status-on { + background: green; + } + + &.status-off { + background: gray; + } + + &.status-unknown { + background: darkgoldenrod; + } + + &.status-error { + background: darkred; + } + } + + .localrec-participant-stats-item__status, + .localrec-participant-stats-item__name, + .localrec-participant-stats-item__sessionid { + display: inline-block; + margin: 5px 0; + vertical-align: middle; + } + .localrec-participant-stats-item__status { + width: 5%; + } + .localrec-participant-stats-item__name { + width: 40%; + } + .localrec-participant-stats-item__sessionid { + width: 55%; + } + + .localrec-participant-stats-item__name, + .localrec-participant-stats-item__sessionid { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.localrec-control-info-label { + font-weight: bold; +} + +.localrec-control-info-label:after { + content: ' '; +} + +.localrec-control-action-link { + display: inline-block; + line-height: 1.5em; + + a { + cursor: pointer; + vertical-align: middle; + } +} + +.localrec-control-action-link:before { + color: $linkFontColor; + content: '\2022'; + font-size: 1.5em; + padding: 0 10px; + vertical-align: middle; +} + +.localrec-control-action-link:first-child:before { + content: ''; + padding: 0; +} + +.localrec-control-action-links { + font-weight: bold; + margin-top: 10px; + white-space: nowrap; +} \ No newline at end of file diff --git a/css/modals/video-quality/_video-quality.scss b/css/modals/video-quality/_video-quality.scss index e5255c745..f251779c7 100644 --- a/css/modals/video-quality/_video-quality.scss +++ b/css/modals/video-quality/_video-quality.scss @@ -168,6 +168,10 @@ background: #FF5630; } + .circular-label.local-rec { + background: #FF5630; + } + .circular-label.stream { background: #0065FF; } diff --git a/lang/main.json b/lang/main.json index 46c7da6ff..0a7fde50c 100644 --- a/lang/main.json +++ b/lang/main.json @@ -43,7 +43,8 @@ "mute": "Mute or unmute your microphone", "fullScreen": "View or exit full screen", "videoMute": "Start or stop your camera", - "showSpeakerStats": "Show speaker stats" + "showSpeakerStats": "Show speaker stats", + "localRecording": "Show or hide local recording controls" }, "welcomepage":{ "accessibilityLabel": { @@ -87,6 +88,7 @@ "fullScreen": "Toggle full screen", "hangup": "Leave the call", "invite": "Invite people", + "localRecording": "Toggle local recording controls", "lockRoom": "Toggle room lock", "moreActions": "Toggle more actions menu", "moreActionsMenu": "More actions menu", @@ -668,5 +670,34 @@ "decline": "Dismiss", "productLabel": "from Jitsi Meet", "videoCallTitle": "Incoming video call" + }, + "localRecording": { + "localRecording": "Local Recording", + "dialogTitle": "Local Recording Controls", + "start": "Start Recording", + "stop": "Stop Recording", + "moderator": "Moderator", + "me": "Me", + "duration": "Duration", + "durationNA": "N/A", + "encoding": "Encoding", + "participantStats": "Participant Stats", + "participant": "Participant", + "sessionToken": "Session Token", + "clientState": { + "on": "On", + "off": "Off", + "unknown": "Unknown" + }, + "messages": { + "engaged": "Local recording engaged.", + "finished": "Recording session __token__ finished. Please send the recorded file to the moderator.", + "finishedModerator": "Recording session __token__ finished. The recording of the local track has been saved. Please ask the other participants to submit their recordings.", + "notModerator": "You are not the moderator. You cannot start or stop local recording." + }, + "yes": "Yes", + "no": "No", + "label": "LOR", + "labelToolTip": "Local recording is engaged" } } diff --git a/package-lock.json b/package-lock.json index f7df06232..f2a025ef2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9736,6 +9736,10 @@ "yaeti": "1.0.1" } }, + "libflacjs": { + "version": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d", + "from": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d" + }, "load-json-file": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", diff --git a/package.json b/package.json index 9484bc1c7..19a03a5d7 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "jsc-android": "224109.1.0", "jwt-decode": "2.2.0", "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#4a28a196160411d657518022de8bded7c02ad679", + "libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d", "lodash": "4.17.4", "moment": "2.19.4", "moment-duration-format": "2.2.2", diff --git a/react/features/large-video/components/AbstractLabels.js b/react/features/large-video/components/AbstractLabels.js index b666f60a2..8d127fe85 100644 --- a/react/features/large-video/components/AbstractLabels.js +++ b/react/features/large-video/components/AbstractLabels.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { isFilmstripVisible } from '../../filmstrip'; +import { LocalRecordingLabel } from '../../local-recording'; import { RecordingLabel } from '../../recording'; import { shouldDisplayTileView } from '../../video-layout'; import { VideoQualityLabel } from '../../video-quality'; @@ -69,6 +70,18 @@ export default class AbstractLabels extends Component { ); } + + /** + * Renders the {@code LocalRecordingLabel}. + * + * @returns {React$Element} + * @protected + */ + _renderLocalRecordingLabel() { + return ( + + ); + } } /** diff --git a/react/features/large-video/components/Labels.web.js b/react/features/large-video/components/Labels.web.js index 41df7a7cb..a9b70aaae 100644 --- a/react/features/large-video/components/Labels.web.js +++ b/react/features/large-video/components/Labels.web.js @@ -85,6 +85,9 @@ class Labels extends AbstractLabels { this._renderRecordingLabel( JitsiRecordingConstants.mode.STREAM) } + { + this._renderLocalRecordingLabel() + } { this._renderTranscribingLabel() } @@ -101,6 +104,8 @@ class Labels extends AbstractLabels { _renderVideoQualityLabel: () => React$Element<*> _renderTranscribingLabel: () => React$Element<*> + + _renderLocalRecordingLabel: () => React$Element<*> } export default connect(_mapStateToProps)(Labels); diff --git a/react/features/local-recording/actionTypes.js b/react/features/local-recording/actionTypes.js new file mode 100644 index 000000000..fffcac5a2 --- /dev/null +++ b/react/features/local-recording/actionTypes.js @@ -0,0 +1,32 @@ +/** + * Action to signal that the local client has started to perform recording, + * (as in: {@code RecordingAdapter} is actively collecting audio data). + * + * { + * type: LOCAL_RECORDING_ENGAGED, + * recordingEngagedAt: Date + * } + */ +export const LOCAL_RECORDING_ENGAGED = Symbol('LOCAL_RECORDING_ENGAGED'); + +/** + * Action to signal that the local client has stopped recording, + * (as in: {@code RecordingAdapter} is no longer collecting audio data). + * + * { + * type: LOCAL_RECORDING_UNENGAGED + * } + */ +export const LOCAL_RECORDING_UNENGAGED = Symbol('LOCAL_RECORDING_UNENGAGED'); + +/** + * Action to update {@code LocalRecordingInfoDialog} with stats from all + * clients. + * + * { + * type: LOCAL_RECORDING_STATS_UPDATE, + * stats: Object + * } + */ +export const LOCAL_RECORDING_STATS_UPDATE + = Symbol('LOCAL_RECORDING_STATS_UPDATE'); diff --git a/react/features/local-recording/actions.js b/react/features/local-recording/actions.js new file mode 100644 index 000000000..eee9f509b --- /dev/null +++ b/react/features/local-recording/actions.js @@ -0,0 +1,59 @@ +/* @flow */ + +import { + LOCAL_RECORDING_ENGAGED, + LOCAL_RECORDING_UNENGAGED, + LOCAL_RECORDING_STATS_UPDATE +} from './actionTypes'; + +// The following two actions signal state changes in local recording engagement. +// In other words, the events of the local WebWorker / MediaRecorder starting to +// record and finishing recording. +// Note that this is not the event fired when the users tries to start the +// recording in the UI. + +/** + * Signals that local recording has been engaged. + * + * @param {Date} startTime - Time when the recording is engaged. + * @returns {{ + * type: LOCAL_RECORDING_ENGAGED, + * recordingEngagedAt: Date + * }} + */ +export function localRecordingEngaged(startTime: Date) { + return { + type: LOCAL_RECORDING_ENGAGED, + recordingEngagedAt: startTime + }; +} + +/** + * Signals that local recording has finished. + * + * @returns {{ + * type: LOCAL_RECORDING_UNENGAGED + * }} + */ +export function localRecordingUnengaged() { + return { + type: LOCAL_RECORDING_UNENGAGED + }; +} + +/** + * Updates the the local recording stats from each client, + * to be displayed on {@code LocalRecordingInfoDialog}. + * + * @param {*} stats - The stats object. + * @returns {{ + * type: LOCAL_RECORDING_STATS_UPDATE, + * stats: Object + * }} + */ +export function statsUpdate(stats: Object) { + return { + type: LOCAL_RECORDING_STATS_UPDATE, + stats + }; +} diff --git a/react/features/local-recording/components/LocalRecordingButton.js b/react/features/local-recording/components/LocalRecordingButton.js new file mode 100644 index 000000000..7d8a18577 --- /dev/null +++ b/react/features/local-recording/components/LocalRecordingButton.js @@ -0,0 +1,86 @@ +/* @flow */ + +import React, { Component } from 'react'; + +import { translate } from '../../base/i18n'; +import { ToolbarButton } from '../../toolbox'; + +/** + * The type of the React {@code Component} state of + * {@link LocalRecordingButton}. + */ +type Props = { + + /** + * Whether or not {@link LocalRecordingInfoDialog} should be displayed. + */ + isDialogShown: boolean, + + /** + * Callback function called when {@link LocalRecordingButton} is clicked. + */ + onClick: Function, + + /** + * Invoked to obtain translated strings. + */ + t: Function +} + +/** + * A React {@code Component} for opening or closing the + * {@code LocalRecordingInfoDialog}. + * + * @extends Component + */ +class LocalRecordingButton extends Component { + + /** + * Initializes a new {@code LocalRecordingButton} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props: Props) { + super(props); + + // Bind event handlers so they are only bound once per instance. + this._onClick = this._onClick.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { isDialogShown, t } = this.props; + const iconClasses + = `icon-thumb-menu ${isDialogShown + ? 'icon-rec toggled' : 'icon-rec'}`; + + return ( + + ); + } + + _onClick: () => void; + + /** + * Callback invoked when the Toolbar button is clicked. + * + * @private + * @returns {void} + */ + _onClick() { + this.props.onClick(); + } +} + +export default translate(LocalRecordingButton); diff --git a/react/features/local-recording/components/LocalRecordingInfoDialog.js b/react/features/local-recording/components/LocalRecordingInfoDialog.js new file mode 100644 index 000000000..1794fceca --- /dev/null +++ b/react/features/local-recording/components/LocalRecordingInfoDialog.js @@ -0,0 +1,403 @@ +/* @flow */ + +import moment from 'moment'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { Dialog } from '../../base/dialog'; +import { translate } from '../../base/i18n'; +import { + PARTICIPANT_ROLE, + getLocalParticipant +} from '../../base/participants'; + +import { statsUpdate } from '../actions'; +import { recordingController } from '../controller'; + + +/** + * The type of the React {@code Component} props of + * {@link LocalRecordingInfoDialog}. + */ +type Props = { + + /** + * Redux store dispatch function. + */ + dispatch: Dispatch<*>, + + /** + * Current encoding format. + */ + encodingFormat: string, + + /** + * Whether the local user is the moderator. + */ + isModerator: boolean, + + /** + * Whether local recording is engaged. + */ + isEngaged: boolean, + + /** + * The start time of the current local recording session. + * Used to calculate the duration of recording. + */ + recordingEngagedAt: Date, + + /** + * Stats of all the participant. + */ + stats: Object, + + /** + * Invoked to obtain translated strings. + */ + t: Function +} + +/** + * The type of the React {@code Component} state of + * {@link LocalRecordingInfoDialog}. + */ +type State = { + + /** + * The recording duration string to be displayed on the UI. + */ + durationString: string +} + +/** + * A React Component with the contents for a dialog that shows information about + * local recording. For users with moderator rights, this is also the "control + * panel" for starting/stopping local recording on all clients. + * + * @extends Component + */ +class LocalRecordingInfoDialog extends Component { + + /** + * Saves a handle to the timer for UI updates, + * so that it can be cancelled when the component unmounts. + */ + _timer: ?IntervalID; + + /** + * Initializes a new {@code LocalRecordingInfoDialog} instance. + * + * @param {Props} props - The React {@code Component} props to initialize + * the new {@code LocalRecordingInfoDialog} instance with. + */ + constructor(props: Props) { + super(props); + this.state = { + durationString: '' + }; + } + + /** + * Implements React's {@link Component#componentDidMount()}. + * + * @returns {void} + */ + componentDidMount() { + this._timer = setInterval( + () => { + this.setState((_prevState, props) => { + const nowTime = new Date(); + + return { + durationString: this._getDuration(nowTime, + props.recordingEngagedAt) + }; + }); + try { + this.props.dispatch( + statsUpdate(recordingController + .getParticipantsStats())); + } catch (e) { + // do nothing + } + }, + 1000 + ); + } + + /** + * Implements React's {@link Component#componentWillUnmount()}. + * + * @returns {void} + */ + componentWillUnmount() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { isModerator, t } = this.props; + + return ( + +
+ + {`${t('localRecording.moderator')}:`} + + + { isModerator + ? t('localRecording.yes') + : t('localRecording.no') } + +
+ { this._renderModeratorControls() } + { this._renderDurationAndFormat() } +
+ ); + } + + /** + * Renders the recording duration and encoding format. Only shown if local + * recording is engaged. + * + * @private + * @returns {ReactElement|null} + */ + _renderDurationAndFormat() { + const { encodingFormat, isEngaged, t } = this.props; + const { durationString } = this.state; + + if (!isEngaged) { + return null; + } + + return ( +
+
+ + {`${t('localRecording.duration')}:`} + + + { durationString === '' + ? t('localRecording.durationNA') + : durationString } + +
+
+ + {`${t('localRecording.encoding')}:`} + + + { encodingFormat } + +
+
+ ); + } + + /** + * Returns React elements for displaying the local recording stats of + * each participant. + * + * @private + * @returns {ReactElement|null} + */ + _renderStats() { + const { stats } = this.props; + + if (stats === undefined) { + return null; + } + const ids = Object.keys(stats); + + return ( +
+ { this._renderStatsHeader() } + { ids.map((id, i) => this._renderStatsLine(i, id)) } +
+ ); + } + + /** + * Renders the stats for one participant. + * + * @private + * @param {*} lineKey - The key required by React for elements in lists. + * @param {*} id - The ID of the participant. + * @returns {ReactElement} + */ + _renderStatsLine(lineKey, id) { + const { stats } = this.props; + let statusClass = 'localrec-participant-stats-item__status-dot '; + + statusClass += stats[id].recordingStats + ? stats[id].recordingStats.isRecording + ? 'status-on' + : 'status-off' + : 'status-unknown'; + + return ( +
+
+ +
+
+ { stats[id].displayName || id } +
+
+ { stats[id].recordingStats.currentSessionToken } +
+
+ ); + } + + /** + * Renders the participant stats header line. + * + * @private + * @returns {ReactElement} + */ + _renderStatsHeader() { + const { t } = this.props; + + return ( +
+
+
+ { t('localRecording.participant') } +
+
+ { t('localRecording.sessionToken') } +
+
+ ); + } + + /** + * Renders the moderator-only controls, i.e. stats of all users and the + * action links. + * + * @private + * @returns {ReactElement|null} + */ + _renderModeratorControls() { + const { isModerator, isEngaged, t } = this.props; + + if (!isModerator) { + return null; + } + + return ( +
+ +
+ + {`${t('localRecording.participantStats')}:`} + +
+ { this._renderStats() } +
+ ); + } + + /** + * Creates a duration string "HH:MM:SS" from two Date objects. + * + * @param {Date} now - Current time. + * @param {Date} prev - Previous time, the time to be subtracted. + * @returns {string} + */ + _getDuration(now, prev) { + if (prev === null || prev === undefined) { + return ''; + } + + // Still a hack, as moment.js does not support formatting of duration + // (i.e. TimeDelta). Only works if total duration < 24 hours. + // But who is going to have a 24-hour long conference? + return moment(now - prev).utc() + .format('HH:mm:ss'); + } + + /** + * Callback function for the Start UI action. + * + * @private + * @returns {void} + */ + _onStart() { + recordingController.startRecording(); + } + + /** + * Callback function for the Stop UI action. + * + * @private + * @returns {void} + */ + _onStop() { + recordingController.stopRecording(); + } + +} + +/** + * Maps (parts of) the Redux state to the associated props for the + * {@code LocalRecordingInfoDialog} component. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * encodingFormat: string, + * isModerator: boolean, + * isEngaged: boolean, + * recordingEngagedAt: Date, + * stats: Object + * }} + */ +function _mapStateToProps(state) { + const { + encodingFormat, + isEngaged, + recordingEngagedAt, + stats + } = state['features/local-recording']; + const isModerator + = getLocalParticipant(state).role === PARTICIPANT_ROLE.MODERATOR; + + return { + encodingFormat, + isModerator, + isEngaged, + recordingEngagedAt, + stats + }; +} + +export default translate(connect(_mapStateToProps)(LocalRecordingInfoDialog)); diff --git a/react/features/local-recording/components/LocalRecordingLabel.native.js b/react/features/local-recording/components/LocalRecordingLabel.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/local-recording/components/LocalRecordingLabel.web.js b/react/features/local-recording/components/LocalRecordingLabel.web.js new file mode 100644 index 000000000..ef7c023f6 --- /dev/null +++ b/react/features/local-recording/components/LocalRecordingLabel.web.js @@ -0,0 +1,75 @@ +// @flow + +import Tooltip from '@atlaskit/tooltip'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '../../base/i18n/index'; +import { CircularLabel } from '../../base/label/index'; + + +/** + * The type of the React {@code Component} props of {@link LocalRecordingLabel}. + */ +type Props = { + + /** + * Invoked to obtain translated strings. + */ + t: Function, + + /** + * Whether local recording is engaged or not. + */ + isEngaged: boolean +}; + +/** + * React Component for displaying a label when local recording is engaged. + * + * @extends Component + */ +class LocalRecordingLabel extends Component { + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + if (!this.props.isEngaged) { + return null; + } + + return ( + + + + ); + } + +} + +/** + * Maps (parts of) the Redux state to the associated props for the + * {@code LocalRecordingLabel} component. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * }} + */ +function _mapStateToProps(state) { + const { isEngaged } = state['features/local-recording']; + + return { + isEngaged + }; +} + +export default translate(connect(_mapStateToProps)(LocalRecordingLabel)); diff --git a/react/features/local-recording/components/index.js b/react/features/local-recording/components/index.js new file mode 100644 index 000000000..d8c8b1211 --- /dev/null +++ b/react/features/local-recording/components/index.js @@ -0,0 +1,5 @@ +export { default as LocalRecordingButton } from './LocalRecordingButton'; +export { default as LocalRecordingLabel } from './LocalRecordingLabel'; +export { + default as LocalRecordingInfoDialog +} from './LocalRecordingInfoDialog'; diff --git a/react/features/local-recording/controller/RecordingController.js b/react/features/local-recording/controller/RecordingController.js new file mode 100644 index 000000000..cd2d80d53 --- /dev/null +++ b/react/features/local-recording/controller/RecordingController.js @@ -0,0 +1,687 @@ +/* @flow */ + +import { i18next } from '../../base/i18n'; + +import { + FlacAdapter, + OggAdapter, + WavAdapter, + downloadBlob +} from '../recording'; +import { sessionManager } from '../session'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * 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 - {@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: + JSON.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; + + /** + * Stops the recording locally. + * + * @private + * @returns {Promise} + */ + _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); + // 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(); diff --git a/react/features/local-recording/controller/index.js b/react/features/local-recording/controller/index.js new file mode 100644 index 000000000..16f5b3605 --- /dev/null +++ b/react/features/local-recording/controller/index.js @@ -0,0 +1 @@ +export * from './RecordingController'; diff --git a/react/features/local-recording/index.js b/react/features/local-recording/index.js new file mode 100644 index 000000000..049bb678d --- /dev/null +++ b/react/features/local-recording/index.js @@ -0,0 +1,7 @@ +export * from './actions'; +export * from './actionTypes'; +export * from './components'; +export * from './controller'; + +import './middleware'; +import './reducer'; diff --git a/react/features/local-recording/middleware.js b/react/features/local-recording/middleware.js new file mode 100644 index 000000000..3f834c26a --- /dev/null +++ b/react/features/local-recording/middleware.js @@ -0,0 +1,92 @@ +/* @flow */ + +import { createShortcutEvent, sendAnalytics } from '../analytics'; +import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; +import { CONFERENCE_JOINED } from '../base/conference'; +import { toggleDialog } from '../base/dialog'; +import { i18next } from '../base/i18n'; +import { SET_AUDIO_MUTED } from '../base/media'; +import { MiddlewareRegistry } from '../base/redux'; +import { SETTINGS_UPDATED } from '../base/settings/actionTypes'; +import { showNotification } from '../notifications'; + +import { localRecordingEngaged, localRecordingUnengaged } from './actions'; +import { LocalRecordingInfoDialog } from './components'; +import { recordingController } from './controller'; + +declare var APP: Object; +declare var config: Object; + +const isFeatureEnabled = typeof config === 'object' && config.localRecording + && config.localRecording.enabled === true; + +isFeatureEnabled +&& MiddlewareRegistry.register(({ getState, dispatch }) => next => action => { + const result = next(action); + + switch (action.type) { + case CONFERENCE_JOINED: { + const { conference } = getState()['features/base/conference']; + const { localRecording } = getState()['features/base/config']; + + if (localRecording && localRecording.format) { + recordingController.switchFormat(localRecording.format); + } + + recordingController.registerEvents(conference); + break; + } + case APP_WILL_MOUNT: + + // realize the delegates on recordingController, allowing the UI to + // react to state changes in recordingController. + recordingController.onStateChanged = isEngaged => { + if (isEngaged) { + const nowTime = new Date(); + + dispatch(localRecordingEngaged(nowTime)); + } else { + dispatch(localRecordingUnengaged()); + } + }; + + recordingController.onWarning = (messageKey, messageParams) => { + dispatch(showNotification({ + title: i18next.t('localRecording.localRecording'), + description: i18next.t(messageKey, messageParams) + }, 10000)); + }; + + recordingController.onNotify = (messageKey, messageParams) => { + dispatch(showNotification({ + title: i18next.t('localRecording.localRecording'), + description: i18next.t(messageKey, messageParams) + }, 10000)); + }; + + typeof APP === 'object' && typeof APP.keyboardshortcut === 'object' + && APP.keyboardshortcut.registerShortcut('L', null, () => { + sendAnalytics(createShortcutEvent('local.recording')); + dispatch(toggleDialog(LocalRecordingInfoDialog)); + }, 'keyboardShortcuts.localRecording'); + break; + case APP_WILL_UNMOUNT: + recordingController.onStateChanged = null; + recordingController.onNotify = null; + recordingController.onWarning = null; + break; + case SET_AUDIO_MUTED: + recordingController.setMuted(action.muted); + break; + case SETTINGS_UPDATED: { + const { micDeviceId } = getState()['features/base/settings']; + + if (micDeviceId) { + recordingController.setMicDevice(micDeviceId); + } + break; + } + } + + return result; +}); diff --git a/react/features/local-recording/recording/AbstractAudioContextAdapter.js b/react/features/local-recording/recording/AbstractAudioContextAdapter.js new file mode 100644 index 000000000..26cf40c11 --- /dev/null +++ b/react/features/local-recording/recording/AbstractAudioContextAdapter.js @@ -0,0 +1,129 @@ +import { RecordingAdapter } from './RecordingAdapter'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Base class for {@code AudioContext}-based recording adapters. + */ +export class AbstractAudioContextAdapter extends RecordingAdapter { + /** + * The {@code AudioContext} instance. + */ + _audioContext = null; + + /** + * The {@code ScriptProcessorNode} instance. + */ + _audioProcessingNode = null; + + /** + * The {@code MediaStreamAudioSourceNode} instance. + */ + _audioSource = null; + + /** + * The {@code MediaStream} instance, representing the current audio device. + */ + _stream = null; + + /** + * Sample rate. + */ + _sampleRate = 44100; + + /** + * Constructor. + */ + constructor() { + super(); + + // sampleRate is browser and OS dependent. + // Setting sampleRate explicitly is in the specs but not implemented + // by browsers. + // See: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/ + // AudioContext#Browser_compatibility + // And https://bugs.chromium.org/p/chromium/issues/detail?id=432248 + + this._audioContext = new AudioContext(); + this._sampleRate = this._audioContext.sampleRate; + logger.log(`Current sampleRate ${this._sampleRate}.`); + } + + /** + * Sets up the audio graph in the AudioContext. + * + * @protected + * @param {string} micDeviceId - The current microphone device ID. + * @param {Function} callback - Callback function to + * handle AudioProcessingEvents. + * @returns {Promise} + */ + _initializeAudioContext(micDeviceId, callback) { + if (typeof callback !== 'function') { + return Promise.reject('a callback function is required.'); + } + + return this._getAudioStream(micDeviceId) + .then(stream => { + this._stream = stream; + this._audioSource + = this._audioContext.createMediaStreamSource(stream); + this._audioProcessingNode + = this._audioContext.createScriptProcessor(4096, 1, 1); + this._audioProcessingNode.onaudioprocess = callback; + logger.debug('AudioContext is set up.'); + }) + .catch(err => { + logger.error(`Error calling getUserMedia(): ${err}`); + + return Promise.reject(err); + }); + } + + /** + * Connects the nodes in the {@code AudioContext} to start the flow of + * audio data. + * + * @protected + * @returns {void} + */ + _connectAudioGraph() { + this._audioSource.connect(this._audioProcessingNode); + this._audioProcessingNode.connect(this._audioContext.destination); + } + + /** + * Disconnects the nodes in the {@code AudioContext}. + * + * @protected + * @returns {void} + */ + _disconnectAudioGraph() { + this._audioProcessingNode.onaudioprocess = undefined; + this._audioProcessingNode.disconnect(); + this._audioSource.disconnect(); + } + + /** + * Replaces the current microphone MediaStream. + * + * @protected + * @param {string} micDeviceId - New microphone ID. + * @returns {Promise} + */ + _replaceMic(micDeviceId) { + if (this._audioContext && this._audioProcessingNode) { + return this._getAudioStream(micDeviceId).then(newStream => { + const newSource = this._audioContext + .createMediaStreamSource(newStream); + + this._audioSource.disconnect(); + newSource.connect(this._audioProcessingNode); + this._stream = newStream; + this._audioSource = newSource; + }); + } + + return Promise.resolve(); + } +} diff --git a/react/features/local-recording/recording/OggAdapter.js b/react/features/local-recording/recording/OggAdapter.js new file mode 100644 index 000000000..fdb03e8c6 --- /dev/null +++ b/react/features/local-recording/recording/OggAdapter.js @@ -0,0 +1,143 @@ +import { RecordingAdapter } from './RecordingAdapter'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Recording adapter that uses {@code MediaRecorder} (default browser encoding + * with Opus codec). + */ +export class OggAdapter extends RecordingAdapter { + + /** + * Instance of MediaRecorder. + * @private + */ + _mediaRecorder = null; + + /** + * Initialization promise. + * @private + */ + _initPromise = null; + + /** + * The recorded audio file. + * @private + */ + _recordedData = null; + + /** + * Implements {@link RecordingAdapter#start()}. + * + * @inheritdoc + */ + start(micDeviceId) { + if (!this._initPromise) { + this._initPromise = this._initialize(micDeviceId); + } + + return this._initPromise.then(() => + new Promise(resolve => { + this._mediaRecorder.start(); + resolve(); + }) + ); + } + + /** + * Implements {@link RecordingAdapter#stop()}. + * + * @inheritdoc + */ + stop() { + return new Promise( + resolve => { + this._mediaRecorder.onstop = () => resolve(); + this._mediaRecorder.stop(); + } + ); + } + + /** + * Implements {@link RecordingAdapter#exportRecordedData()}. + * + * @inheritdoc + */ + exportRecordedData() { + if (this._recordedData !== null) { + return Promise.resolve({ + data: this._recordedData, + format: 'ogg' + }); + } + + return Promise.reject('No audio data recorded.'); + } + + /** + * Implements {@link RecordingAdapter#setMuted()}. + * + * @inheritdoc + */ + setMuted(muted) { + const shouldEnable = !muted; + + if (!this._stream) { + return Promise.resolve(); + } + + const track = this._stream.getAudioTracks()[0]; + + if (!track) { + logger.error('Cannot mute/unmute. Track not found!'); + + return Promise.resolve(); + } + + if (track.enabled !== shouldEnable) { + track.enabled = shouldEnable; + logger.log(muted ? 'Mute' : 'Unmute'); + } + + return Promise.resolve(); + } + + /** + * Initialize the adapter. + * + * @private + * @param {string} micDeviceId - The current microphone device ID. + * @returns {Promise} + */ + _initialize(micDeviceId) { + if (this._mediaRecorder) { + return Promise.resolve(); + } + + return new Promise((resolve, error) => { + this._getAudioStream(micDeviceId) + .then(stream => { + this._stream = stream; + this._mediaRecorder = new MediaRecorder(stream); + this._mediaRecorder.ondataavailable + = e => this._saveMediaData(e.data); + resolve(); + }) + .catch(err => { + logger.error(`Error calling getUserMedia(): ${err}`); + error(); + }); + }); + } + + /** + * Callback for storing the encoded data. + * + * @private + * @param {Blob} data - Encoded data. + * @returns {void} + */ + _saveMediaData(data) { + this._recordedData = data; + } +} diff --git a/react/features/local-recording/recording/RecordingAdapter.js b/react/features/local-recording/recording/RecordingAdapter.js new file mode 100644 index 000000000..514c9ddc1 --- /dev/null +++ b/react/features/local-recording/recording/RecordingAdapter.js @@ -0,0 +1,85 @@ +import JitsiMeetJS from '../../base/lib-jitsi-meet'; + +/** + * Base class for recording backends. + */ +export class RecordingAdapter { + + /** + * Starts recording. + * + * @param {string} micDeviceId - The microphone to record on. + * @returns {Promise} + */ + start(/* eslint-disable no-unused-vars */ + micDeviceId/* eslint-enable no-unused-vars */) { + throw new Error('Not implemented'); + } + + /** + * Stops recording. + * + * @returns {Promise} + */ + stop() { + throw new Error('Not implemented'); + } + + /** + * Export the recorded and encoded audio file. + * + * @returns {Promise} + */ + exportRecordedData() { + throw new Error('Not implemented'); + } + + /** + * Mutes or unmutes the current recording. + * + * @param {boolean} muted - Whether to mute or to unmute. + * @returns {Promise} + */ + setMuted(/* eslint-disable no-unused-vars */ + muted/* eslint-enable no-unused-vars */) { + throw new Error('Not implemented'); + } + + /** + * Changes the current microphone. + * + * @param {string} micDeviceId - The new microphone device ID. + * @returns {Promise} + */ + setMicDevice(/* eslint-disable no-unused-vars */ + micDeviceId/* eslint-enable no-unused-vars */) { + throw new Error('Not implemented'); + } + + /** + * Helper method for getting an audio {@code MediaStream}. Use this instead + * of calling browser APIs directly. + * + * @protected + * @param {number} micDeviceId - The ID of the current audio device. + * @returns {Promise} + */ + _getAudioStream(micDeviceId) { + return JitsiMeetJS.createLocalTracks({ + devices: [ 'audio' ], + micDeviceId + }).then(result => { + if (result.length !== 1) { + throw new Error('Unexpected number of streams ' + + 'from createLocalTracks.'); + } + const mediaStream = result[0].stream; + + if (mediaStream === undefined) { + throw new Error('Failed to create local track.'); + } + + return mediaStream; + }); + } +} diff --git a/react/features/local-recording/recording/Utils.js b/react/features/local-recording/recording/Utils.js new file mode 100644 index 000000000..322b5e6a4 --- /dev/null +++ b/react/features/local-recording/recording/Utils.js @@ -0,0 +1,20 @@ +/** + * Force download of Blob in browser by faking an tag. + * + * @param {Blob} blob - Base64 URL. + * @param {string} fileName - The filename to appear in the download dialog. + * @returns {void} + */ +export function downloadBlob(blob, fileName = 'recording.ogg') { + const base64Url = window.URL.createObjectURL(blob); + + // fake a anchor tag + const a = document.createElement('a'); + + a.style = 'display: none'; + a.href = base64Url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} diff --git a/react/features/local-recording/recording/WavAdapter.js b/react/features/local-recording/recording/WavAdapter.js new file mode 100644 index 000000000..4e36d5c5d --- /dev/null +++ b/react/features/local-recording/recording/WavAdapter.js @@ -0,0 +1,290 @@ +import { AbstractAudioContextAdapter } from './AbstractAudioContextAdapter'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +const WAV_BITS_PER_SAMPLE = 16; + +/** + * Recording adapter for raw WAVE format. + */ +export class WavAdapter extends AbstractAudioContextAdapter { + + /** + * Length of the WAVE file, in number of samples. + */ + _wavLength = 0; + + /** + * The {@code ArrayBuffer}s that stores the PCM bits. + */ + _wavBuffers = []; + + /** + * Whether or not the {@code WavAdapter} is in a ready state. + */ + _isInitialized = false; + + /** + * Initialization promise. + */ + _initPromise = null; + + /** + * Constructor. + */ + constructor() { + super(); + this._onAudioProcess = this._onAudioProcess.bind(this); + } + + /** + * Implements {@link RecordingAdapter#start()}. + * + * @inheritdoc + */ + start(micDeviceId) { + if (!this._initPromise) { + this._initPromise = this._initialize(micDeviceId); + } + + return this._initPromise.then(() => { + this._wavBuffers = []; + this._wavLength = 0; + + this._connectAudioGraph(); + }); + } + + /** + * Implements {@link RecordingAdapter#stop()}. + * + * @inheritdoc + */ + stop() { + this._disconnectAudioGraph(); + this._data = this._exportMonoWAV(this._wavBuffers, this._wavLength); + this._audioProcessingNode = null; + this._audioSource = null; + this._isInitialized = false; + + return Promise.resolve(); + } + + /** + * Implements {@link RecordingAdapter#exportRecordedData()}. + * + * @inheritdoc + */ + exportRecordedData() { + if (this._data !== null) { + return Promise.resolve({ + data: this._data, + format: 'wav' + }); + } + + return Promise.reject('No audio data recorded.'); + } + + /** + * Implements {@link RecordingAdapter#setMuted()}. + * + * @inheritdoc + */ + setMuted(muted) { + const shouldEnable = !muted; + + if (!this._stream) { + return Promise.resolve(); + } + + const track = this._stream.getAudioTracks()[0]; + + if (!track) { + logger.error('Cannot mute/unmute. Track not found!'); + + return Promise.resolve(); + } + + if (track.enabled !== shouldEnable) { + track.enabled = shouldEnable; + logger.log(muted ? 'Mute' : 'Unmute'); + } + + return Promise.resolve(); + } + + /** + * Implements {@link RecordingAdapter#setMicDevice()}. + * + * @inheritdoc + */ + setMicDevice(micDeviceId) { + return this._replaceMic(micDeviceId); + } + + /** + * Creates a WAVE file header. + * + * @private + * @param {number} dataLength - Length of the payload (PCM data), in bytes. + * @returns {Uint8Array} + */ + _createWavHeader(dataLength) { + // adapted from + // https://github.com/mmig/speech-to-flac/blob/master/encoder.js + + // ref: http://soundfile.sapp.org/doc/WaveFormat/ + + // create our WAVE file header + const buffer = new ArrayBuffer(44); + const view = new DataView(buffer); + + // RIFF chunk descriptor + writeUTFBytes(view, 0, 'RIFF'); + + // set file size at the end + writeUTFBytes(view, 8, 'WAVE'); + + // FMT sub-chunk + writeUTFBytes(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + + // NumChannels + view.setUint16(22, 1, true); + + // SampleRate + view.setUint32(24, this._sampleRate, true); + + // ByteRate + view.setUint32(28, + Number(this._sampleRate) * 1 * WAV_BITS_PER_SAMPLE / 8, true); + + // BlockAlign + view.setUint16(32, 1 * Number(WAV_BITS_PER_SAMPLE) / 8, true); + + view.setUint16(34, WAV_BITS_PER_SAMPLE, true); + + // data sub-chunk + writeUTFBytes(view, 36, 'data'); + + // file length + view.setUint32(4, 32 + dataLength, true); + + // data chunk length + view.setUint32(40, dataLength, true); + + return new Uint8Array(buffer); + } + + /** + * Initialize the adapter. + * + * @private + * @param {string} micDeviceId - The current microphone device ID. + * @returns {Promise} + */ + _initialize(micDeviceId) { + if (this._isInitialized) { + return Promise.resolve(); + } + + return this._initializeAudioContext(micDeviceId, this._onAudioProcess) + .then(() => { + this._isInitialized = true; + }); + } + + /** + * Callback function for handling AudioProcessingEvents. + * + * @private + * @param {AudioProcessingEvent} e - The event containing the raw PCM. + * @returns {void} + */ + _onAudioProcess(e) { + // See: https://developer.mozilla.org/en-US/docs/Web/API/ + // AudioBuffer/getChannelData + // The returned value is an Float32Array. + const channelLeft = e.inputBuffer.getChannelData(0); + + // Need to copy the Float32Array: + // unlike passing to WebWorker, this data is passed by reference, + // so we need to copy it, otherwise the resulting audio file will be + // just repeating the last segment. + this._wavBuffers.push(new Float32Array(channelLeft)); + this._wavLength += channelLeft.length; + } + + /** + * Combines buffers and export to a wav file. + * + * @private + * @param {Float32Array[]} buffers - The stored buffers. + * @param {number} length - Total length (number of samples). + * @returns {Blob} + */ + _exportMonoWAV(buffers, length) { + const dataLength = length * 2; // each sample = 16 bit = 2 bytes + const buffer = new ArrayBuffer(44 + dataLength); + const view = new DataView(buffer); + + // copy WAV header data into the array buffer + const header = this._createWavHeader(dataLength); + const len = header.length; + + for (let i = 0; i < len; ++i) { + view.setUint8(i, header[i]); + } + + // write audio data + floatTo16BitPCM(view, 44, buffers); + + return new Blob([ view ], { type: 'audio/wav' }); + } +} + + +/** + * Helper function. Writes a UTF string to memory + * using big endianness. Required by WAVE headers. + * + * @param {ArrayBuffer} view - The view to memory. + * @param {number} offset - Offset. + * @param {string} string - The string to be written. + * @returns {void} + */ +function writeUTFBytes(view, offset, string) { + const lng = string.length; + + // convert to big endianness + for (let i = 0; i < lng; ++i) { + view.setUint8(offset + i, string.charCodeAt(i)); + } +} + +/** + * Helper function for converting Float32Array to Int16Array. + * + * @param {DataView} output - View to the output buffer. + * @param {number} offset - The offset in output buffer to write from. + * @param {Float32Array[]} inputBuffers - The input buffers. + * @returns {void} + */ +function floatTo16BitPCM(output, offset, inputBuffers) { + + let i, j; + let input, s, sampleCount; + const bufferCount = inputBuffers.length; + let o = offset; + + for (i = 0; i < bufferCount; ++i) { + input = inputBuffers[i]; + sampleCount = input.length; + for (j = 0; j < sampleCount; ++j, o += 2) { + s = Math.max(-1, Math.min(1, input[j])); + output.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true); + } + } +} diff --git a/react/features/local-recording/recording/flac/FlacAdapter.js b/react/features/local-recording/recording/flac/FlacAdapter.js new file mode 100644 index 000000000..32f0dd881 --- /dev/null +++ b/react/features/local-recording/recording/flac/FlacAdapter.js @@ -0,0 +1,262 @@ +import { + DEBUG, + MAIN_THREAD_FINISH, + MAIN_THREAD_INIT, + MAIN_THREAD_NEW_DATA_ARRIVED, + WORKER_BLOB_READY, + WORKER_LIBFLAC_READY +} from './messageTypes'; + +import { AbstractAudioContextAdapter } from '../AbstractAudioContextAdapter'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Recording adapter that uses libflac.js in the background. + */ +export class FlacAdapter extends AbstractAudioContextAdapter { + + /** + * Instance of WebWorker (flacEncodeWorker). + */ + _encoder = null; + + /** + * Resolve function of the Promise returned by {@code stop()}. + * This is called after the WebWorker sends back {@code WORKER_BLOB_READY}. + */ + _stopPromiseResolver = null; + + /** + * Resolve function of the Promise that initializes the flacEncodeWorker. + */ + _initWorkerPromiseResolver = null; + + /** + * Initialization promise. + */ + _initPromise = null; + + /** + * Constructor. + */ + constructor() { + super(); + this._onAudioProcess = this._onAudioProcess.bind(this); + this._onWorkerMessage = this._onWorkerMessage.bind(this); + } + + /** + * Implements {@link RecordingAdapter#start()}. + * + * @inheritdoc + */ + start(micDeviceId) { + if (!this._initPromise) { + this._initPromise = this._initialize(micDeviceId); + } + + return this._initPromise.then(() => { + this._connectAudioGraph(); + }); + } + + /** + * Implements {@link RecordingAdapter#stop()}. + * + * @inheritdoc + */ + stop() { + if (!this._encoder) { + logger.error('Attempting to stop but has nothing to stop.'); + + return Promise.reject(); + } + + return new Promise(resolve => { + this._initPromise = null; + this._disconnectAudioGraph(); + this._stopPromiseResolver = resolve; + this._encoder.postMessage({ + command: MAIN_THREAD_FINISH + }); + }); + } + + /** + * Implements {@link RecordingAdapter#exportRecordedData()}. + * + * @inheritdoc + */ + exportRecordedData() { + if (this._data !== null) { + return Promise.resolve({ + data: this._data, + format: 'flac' + }); + } + + return Promise.reject('No audio data recorded.'); + } + + /** + * Implements {@link RecordingAdapter#setMuted()}. + * + * @inheritdoc + */ + setMuted(muted) { + const shouldEnable = !muted; + + if (!this._stream) { + return Promise.resolve(); + } + + const track = this._stream.getAudioTracks()[0]; + + if (!track) { + logger.error('Cannot mute/unmute. Track not found!'); + + return Promise.resolve(); + } + + if (track.enabled !== shouldEnable) { + track.enabled = shouldEnable; + logger.log(muted ? 'Mute' : 'Unmute'); + } + + return Promise.resolve(); + } + + /** + * Implements {@link RecordingAdapter#setMicDevice()}. + * + * @inheritdoc + */ + setMicDevice(micDeviceId) { + return this._replaceMic(micDeviceId); + } + + /** + * Initialize the adapter. + * + * @private + * @param {string} micDeviceId - The current microphone device ID. + * @returns {Promise} + */ + _initialize(micDeviceId) { + if (this._encoder !== null) { + return Promise.resolve(); + } + + const promiseInitWorker = new Promise((resolve, reject) => { + try { + this._loadWebWorker(); + } catch (e) { + reject(); + } + + // Save the Promise's resolver to resolve it later. + // This Promise is only resolved in _onWorkerMessage when we + // receive WORKER_LIBFLAC_READY from the WebWorker. + this._initWorkerPromiseResolver = resolve; + + // set up listener for messages from the WebWorker + this._encoder.onmessage = this._onWorkerMessage; + + this._encoder.postMessage({ + command: MAIN_THREAD_INIT, + config: { + sampleRate: this._sampleRate, + bps: 16 + } + }); + }); + + // Arrow function is used here because we want AudioContext to be + // initialized only **after** promiseInitWorker is resolved. + return promiseInitWorker + .then(() => + this._initializeAudioContext( + micDeviceId, + this._onAudioProcess + )); + } + + /** + * Callback function for handling AudioProcessingEvents. + * + * @private + * @param {AudioProcessingEvent} e - The event containing the raw PCM. + * @returns {void} + */ + _onAudioProcess(e) { + // Delegates to the WebWorker to do the encoding. + // The return of getChannelData() is a Float32Array, + // each element representing one sample. + const channelLeft = e.inputBuffer.getChannelData(0); + + this._encoder.postMessage({ + command: MAIN_THREAD_NEW_DATA_ARRIVED, + buf: channelLeft + }); + } + + /** + * Handler for messages from flacEncodeWorker. + * + * @private + * @param {MessageEvent} e - The event sent by the WebWorker. + * @returns {void} + */ + _onWorkerMessage(e) { + switch (e.data.command) { + case WORKER_BLOB_READY: + // Received a Blob representing an encoded FLAC file. + this._data = e.data.buf; + if (this._stopPromiseResolver !== null) { + this._stopPromiseResolver(); + this._stopPromiseResolver = null; + this._encoder.terminate(); + this._encoder = null; + } + break; + case DEBUG: + logger.log(e.data); + break; + case WORKER_LIBFLAC_READY: + logger.log('libflac is ready.'); + this._initWorkerPromiseResolver(); + break; + default: + logger.error( + `Unknown event + from encoder (WebWorker): "${e.data.command}"!`); + break; + } + } + + /** + * Loads the WebWorker. + * + * @private + * @returns {void} + */ + _loadWebWorker() { + // FIXME: Workaround for different file names in development/ + // production environments. + // We cannot import flacEncodeWorker as a webpack module, + // because it is in a different bundle and should be lazy-loaded + // only when flac recording is in use. + try { + // try load the minified version first + this._encoder = new Worker('/libs/flacEncodeWorker.min.js'); + } catch (exception1) { + // if failed, try unminified version + try { + this._encoder = new Worker('/libs/flacEncodeWorker.js'); + } catch (exception2) { + throw new Error('Failed to load flacEncodeWorker.'); + } + } + } +} diff --git a/react/features/local-recording/recording/flac/flacEncodeWorker.js b/react/features/local-recording/recording/flac/flacEncodeWorker.js new file mode 100644 index 000000000..eff9d5c5d --- /dev/null +++ b/react/features/local-recording/recording/flac/flacEncodeWorker.js @@ -0,0 +1,397 @@ +import { + MAIN_THREAD_FINISH, + MAIN_THREAD_INIT, + MAIN_THREAD_NEW_DATA_ARRIVED, + WORKER_BLOB_READY, + WORKER_LIBFLAC_READY +} from './messageTypes'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * WebWorker that does FLAC encoding using libflac.js + */ + +self.FLAC_SCRIPT_LOCATION = '/libs/'; +/* eslint-disable */ +importScripts('/libs/libflac4-1.3.2.min.js'); +/* eslint-enable */ + +// There is a number of API calls to libflac.js, which does not conform +// to the camalCase naming convention, but we cannot change it. +// So we disable the ESLint rule `new-cap` in this file. +/* eslint-disable new-cap */ + +// Flow will complain about the number keys in `FLAC_ERRORS`, +// ESLint will complain about the `declare` statement. +// As the current workaround, add an exception for eslint. +/* eslint-disable flowtype/no-types-missing-file-annotation */ +declare var Flac: Object; + +const FLAC_ERRORS = { + // The encoder is in the normal OK state and samples can be processed. + 0: 'FLAC__STREAM_ENCODER_OK', + + // The encoder is in the uninitialized state one of the + // FLAC__stream_encoder_init_*() functions must be called before samples can + // be processed. + 1: 'FLAC__STREAM_ENCODER_UNINITIALIZED', + + // An error occurred in the underlying Ogg layer. + 2: 'FLAC__STREAM_ENCODER_OGG_ERROR', + + // An error occurred in the underlying verify stream decoder; check + // FLAC__stream_encoder_get_verify_decoder_state(). + 3: 'FLAC__STREAM_ENCODER_VERIFY_DECODER_ERROR', + + // The verify decoder detected a mismatch between the original audio signal + // and the decoded audio signal. + 4: 'FLAC__STREAM_ENCODER_VERIFY_MISMATCH_IN_AUDIO_DATA', + + // One of the callbacks returned a fatal error. + 5: 'FLAC__STREAM_ENCODER_CLIENT_ERROR', + + // An I/O error occurred while opening/reading/writing a file. Check errno. + 6: 'FLAC__STREAM_ENCODER_IO_ERROR', + + // An error occurred while writing the stream; usually, the write_callback + // returned an error. + 7: 'FLAC__STREAM_ENCODER_FRAMING_ERROR', + + // Memory allocation failed. + 8: 'FLAC__STREAM_ENCODER_MEMORY_ALLOCATION_ERROR' +}; + +/** + * States of the {@code Encoder}. + */ +const EncoderState = Object.freeze({ + /** + * Initial state, when libflac.js is not initialized. + */ + UNINTIALIZED: Symbol('uninitialized'), + + /** + * Actively encoding new audio bits. + */ + WORKING: Symbol('working'), + + /** + * Encoding has finished and encoded bits are available. + */ + FINISHED: Symbol('finished') +}); + +/** + * Default FLAC compression level. + */ +const FLAC_COMPRESSION_LEVEL = 5; + +/** + * Concat multiple Uint8Arrays into one. + * + * @param {Uint8Array[]} arrays - Array of Uint8 arrays. + * @param {number} totalLength - Total length of all Uint8Arrays. + * @returns {Uint8Array} + */ +function mergeUint8Arrays(arrays, totalLength) { + const result = new Uint8Array(totalLength); + let offset = 0; + const len = arrays.length; + + for (let i = 0; i < len; i++) { + const buffer = arrays[i]; + + result.set(buffer, offset); + offset += buffer.length; + } + + return result; +} + +/** + * Wrapper class around libflac API. + */ +class Encoder { + + /** + * Flac encoder instance ID. (As per libflac.js API). + * @private + */ + _encoderId = 0; + + /** + * Sample rate. + * @private + */ + _sampleRate; + + /** + * Bit depth (bits per sample). + * @private + */ + _bitDepth; + + /** + * Buffer size. + * @private + */ + _bufferSize; + + /** + * Buffers to store encoded bits temporarily. + */ + _flacBuffers = []; + + /** + * Length of encoded FLAC bits. + */ + _flacLength = 0; + + /** + * The current state of the {@code Encoder}. + */ + _state = EncoderState.UNINTIALIZED; + + /** + * The ready-for-grab downloadable Blob. + */ + _data = null; + + + /** + * Constructor. + * Note: only create instance when Flac.isReady() returns true. + * + * @param {number} sampleRate - Sample rate of the raw audio data. + * @param {number} bitDepth - Bit depth (bit per sample). + * @param {number} bufferSize - The size of each batch. + */ + constructor(sampleRate, bitDepth = 16, bufferSize = 4096) { + if (!Flac.isReady()) { + throw new Error('libflac is not ready yet!'); + } + + this._sampleRate = sampleRate; + this._bitDepth = bitDepth; + this._bufferSize = bufferSize; + + // create the encoder + this._encoderId = Flac.init_libflac_encoder( + this._sampleRate, + + // Mono channel + 1, + this._bitDepth, + + FLAC_COMPRESSION_LEVEL, + + // Pass 0 in becuase of unknown total samples, + 0, + + // checksum, FIXME: double-check whether this is necessary + true, + + // Auto-determine block size (samples per frame) + 0 + ); + + if (this._encoderId === 0) { + throw new Error('Failed to create libflac encoder.'); + } + + // initialize the encoder + const initResult = Flac.init_encoder_stream( + this._encoderId, + this._onEncodedData.bind(this), + this._onMetadataAvailable.bind(this) + ); + + if (initResult !== 0) { + throw new Error('Failed to initalise libflac encoder.'); + } + + this._state = EncoderState.WORKING; + } + + /** + * Receive and encode new data. + * + * @param {Float32Array} audioData - Raw audio data. + * @returns {void} + */ + encode(audioData) { + if (this._state !== EncoderState.WORKING) { + throw new Error('Encoder is not ready or has finished.'); + } + + if (!Flac.isReady()) { + throw new Error('Flac not ready'); + } + const bufferLength = audioData.length; + + // Convert sample to signed 32-bit integers. + // According to libflac documentation: + // each sample in the buffers should be a signed integer, + // right-justified to the resolution set by + // FLAC__stream_encoder_set_bits_per_sample(). + + // Here we are using 16 bits per sample, the samples should all be in + // the range [-32768,32767]. This is achieved by multipling Float32 + // numbers with 0x7FFF. + + const bufferI32 = new Int32Array(bufferLength); + const view = new DataView(bufferI32.buffer); + const volume = 1; + let index = 0; + + for (let i = 0; i < bufferLength; i++) { + view.setInt32(index, audioData[i] * (0x7FFF * volume), true); + index += 4; // 4 bytes (32-bit) + } + + // pass it to libflac + const status = Flac.FLAC__stream_encoder_process_interleaved( + this._encoderId, + bufferI32, + bufferI32.length + ); + + if (status !== 1) { + // gets error number + + const errorNo + = Flac.FLAC__stream_encoder_get_state(this._encoderId); + + logger.error('Error during encoding', FLAC_ERRORS[errorNo]); + } + } + + /** + * Signals the termination of encoding. + * + * @returns {void} + */ + finish() { + if (this._state === EncoderState.WORKING) { + this._state = EncoderState.FINISHED; + + const status = Flac.FLAC__stream_encoder_finish(this._encoderId); + + logger.log('Flac encoding finished: ', status); + + // free up resources + Flac.FLAC__stream_encoder_delete(this._encoderId); + + this._data = this._exportFlacBlob(); + } + } + + /** + * Gets the encoded flac file. + * + * @returns {Blob} - The encoded flac file. + */ + getBlob() { + if (this._state === EncoderState.FINISHED) { + return this._data; + } + + return null; + } + + /** + * Converts flac buffer to a Blob. + * + * @private + * @returns {void} + */ + _exportFlacBlob() { + const samples = mergeUint8Arrays(this._flacBuffers, this._flacLength); + + const blob = new Blob([ samples ], { type: 'audio/flac' }); + + return blob; + } + + /* eslint-disable no-unused-vars */ + /** + * Callback function for saving encoded Flac data. + * This is invoked by libflac. + * + * @private + * @param {Uint8Array} buffer - The encoded Flac data. + * @param {number} bytes - Number of bytes in the data. + * @returns {void} + */ + _onEncodedData(buffer, bytes) { + this._flacBuffers.push(buffer); + this._flacLength += buffer.byteLength; + } + /* eslint-enable no-unused-vars */ + + /** + * Callback function for receiving metadata. + * + * @private + * @returns {void} + */ + _onMetadataAvailable = () => { + // reserved for future use + } +} + + +let encoder = null; + +self.onmessage = function(e) { + + switch (e.data.command) { + case MAIN_THREAD_INIT: + { + const bps = e.data.config.bps; + const sampleRate = e.data.config.sampleRate; + + if (Flac.isReady()) { + encoder = new Encoder(sampleRate, bps); + self.postMessage({ + command: WORKER_LIBFLAC_READY + }); + } else { + Flac.onready = function() { + setTimeout(() => { + encoder = new Encoder(sampleRate, bps); + self.postMessage({ + command: WORKER_LIBFLAC_READY + }); + }, 0); + }; + } + break; + } + + case MAIN_THREAD_NEW_DATA_ARRIVED: + if (encoder === null) { + logger.error('flacEncoderWorker received data when the encoder is' + + 'not ready.'); + } else { + encoder.encode(e.data.buf); + } + break; + + case MAIN_THREAD_FINISH: + if (encoder !== null) { + encoder.finish(); + const data = encoder.getBlob(); + + self.postMessage( + { + command: WORKER_BLOB_READY, + buf: data + } + ); + encoder = null; + } + break; + } +}; diff --git a/react/features/local-recording/recording/flac/index.js b/react/features/local-recording/recording/flac/index.js new file mode 100644 index 000000000..fc91cbd05 --- /dev/null +++ b/react/features/local-recording/recording/flac/index.js @@ -0,0 +1 @@ +export * from './FlacAdapter'; diff --git a/react/features/local-recording/recording/flac/messageTypes.js b/react/features/local-recording/recording/flac/messageTypes.js new file mode 100644 index 000000000..431cdead8 --- /dev/null +++ b/react/features/local-recording/recording/flac/messageTypes.js @@ -0,0 +1,44 @@ +/** + * Types of messages that are passed between the main thread and the WebWorker + * ({@code flacEncodeWorker}) + */ + +// Messages sent by the main thread + +/** + * Message type that signals the termination of encoding, + * after which no new audio bits should be sent to the + * WebWorker. + */ +export const MAIN_THREAD_FINISH = 'MAIN_THREAD_FINISH'; + +/** + * Message type that carries initial parameters for + * the WebWorker. + */ +export const MAIN_THREAD_INIT = 'MAIN_THREAD_INIT'; + +/** + * Message type that carries the newly received raw audio bits + * for the WebWorker to encode. + */ +export const MAIN_THREAD_NEW_DATA_ARRIVED = 'MAIN_THREAD_NEW_DATA_ARRIVED'; + +// Messages sent by the WebWorker + +/** + * Message type that signals libflac is ready to receive audio bits. + */ +export const WORKER_LIBFLAC_READY = 'WORKER_LIBFLAC_READY'; + +/** + * Message type that carries the encoded FLAC file as a Blob. + */ +export const WORKER_BLOB_READY = 'WORKER_BLOB_READY'; + +// Messages sent by either the main thread or the WebWorker + +/** + * Debug messages. + */ +export const DEBUG = 'DEBUG'; diff --git a/react/features/local-recording/recording/index.js b/react/features/local-recording/recording/index.js new file mode 100644 index 000000000..7764a6109 --- /dev/null +++ b/react/features/local-recording/recording/index.js @@ -0,0 +1,5 @@ +export * from './OggAdapter'; +export * from './RecordingAdapter'; +export * from './Utils'; +export * from './WavAdapter'; +export * from './flac'; diff --git a/react/features/local-recording/reducer.js b/react/features/local-recording/reducer.js new file mode 100644 index 000000000..c6447c7bb --- /dev/null +++ b/react/features/local-recording/reducer.js @@ -0,0 +1,35 @@ +/* @flow */ + +import { ReducerRegistry } from '../base/redux'; +import { + LOCAL_RECORDING_ENGAGED, + LOCAL_RECORDING_STATS_UPDATE, + LOCAL_RECORDING_UNENGAGED +} from './actionTypes'; +import { recordingController } from './controller'; + +ReducerRegistry.register('features/local-recording', (state = {}, action) => { + switch (action.type) { + case LOCAL_RECORDING_ENGAGED: { + return { + ...state, + isEngaged: true, + recordingEngagedAt: action.recordingEngagedAt, + encodingFormat: recordingController._format + }; + } + case LOCAL_RECORDING_UNENGAGED: + return { + ...state, + isEngaged: false, + recordingEngagedAt: null + }; + case LOCAL_RECORDING_STATS_UPDATE: + return { + ...state, + stats: action.stats + }; + default: + return state; + } +}); diff --git a/react/features/local-recording/session/SessionManager.js b/react/features/local-recording/session/SessionManager.js new file mode 100644 index 000000000..e43ac7a05 --- /dev/null +++ b/react/features/local-recording/session/SessionManager.js @@ -0,0 +1,439 @@ +/* @flow */ + +import jitsiLocalStorage from '../../../../modules/util/JitsiLocalStorage'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Gets high precision system time. + * + * @returns {number} + */ +function highPrecisionTime(): number { + return window.performance + && window.performance.now + && window.performance.timing + && window.performance.timing.navigationStart + ? window.performance.now() + window.performance.timing.navigationStart + : Date.now(); +} + +// Have to use string literal here, instead of Symbols, +// because these values need to be JSON-serializible. + +/** + * Types of SessionEvents. + */ +const SessionEventType = Object.freeze({ + /** + * Start of local recording session. This is recorded when the + * {@code RecordingController} receives the signal to start local recording, + * before the actual adapter is engaged. + */ + SESSION_STARTED: 'SESSION_STARTED', + + /** + * Start of a continuous segment. This is recorded when the adapter is + * engaged. Can happen multiple times in a local recording session, + * due to browser reloads or switching of recording device. + */ + SEGMENT_STARTED: 'SEGMENT_STARTED', + + /** + * End of a continuous segment. This is recorded when the adapter unengages. + */ + SEGMENT_ENDED: 'SEGMENT_ENDED' +}); + +/** + * Represents an event during a local recording session. + * The event can be either that the adapter started recording, or stopped + * recording. + */ +type SessionEvent = { + + /** + * The type of the event. + * Should be one of the values in {@code SessionEventType}. + */ + type: string, + + /** + * The timestamp of the event. + */ + timestamp: number +}; + +/** + * Representation of the metadata of a segment. + */ +type SegmentInfo = { + + /** + * The length of gap before this segment, in milliseconds. + * mull if unknown. + */ + gapBefore?: ?number, + + /** + * The duration of this segment, in milliseconds. + * null if unknown or the segment is not finished. + */ + duration?: ?number, + + /** + * The start time, in milliseconds. + */ + start?: ?number, + + /** + * The end time, in milliseconds. + * null if unknown, the segment is not finished, or the recording is + * interrupted (e.g. browser reload). + */ + end?: ?number +}; + +/** + * Representation of metadata of a local recording session. + */ +type SessionInfo = { + + /** + * The session token. + */ + sessionToken: string, + + /** + * The start time of the session. + */ + start: ?number, + + /** + * The recording format. + */ + format: string, + + /** + * Array of segments in the session. + */ + segments: SegmentInfo[] +} + +/** + * {@code localStorage} key. + */ +const LOCAL_STORAGE_KEY = 'localRecordingMetadataVersion1'; + +/** + * SessionManager manages the metadata of each segment during each local + * recording session. + * + * A segment is a continous portion of recording done using the same adapter + * on the same microphone device. + * + * Browser refreshes, switching of microphone will cause new segments to be + * created. + * + * A recording session can consist of one or more segments. + */ +class SessionManager { + + /** + * The metadata. + */ + _sessionsMetadata = { + }; + + /** + * Constructor. + */ + constructor() { + this._loadMetadata(); + } + + /** + * Loads metadata from localStorage. + * + * @private + * @returns {void} + */ + _loadMetadata() { + const dataStr = jitsiLocalStorage.getItem(LOCAL_STORAGE_KEY); + + if (dataStr !== null) { + try { + const dataObject = JSON.parse(dataStr); + + this._sessionsMetadata = dataObject; + } catch (e) { + logger.warn('Failed to parse localStorage item.'); + + return; + } + } + } + + /** + * Persists metadata to localStorage. + * + * @private + * @returns {void} + */ + _saveMetadata() { + jitsiLocalStorage.setItem(LOCAL_STORAGE_KEY, + JSON.stringify(this._sessionsMetadata)); + } + + /** + * Creates a session if not exists. + * + * @param {string} sessionToken - The local recording session token. + * @param {string} format - The local recording format. + * @returns {void} + */ + createSession(sessionToken: string, format: string) { + if (this._sessionsMetadata[sessionToken] === undefined) { + this._sessionsMetadata[sessionToken] = { + format, + events: [] + }; + this._sessionsMetadata[sessionToken].events.push({ + type: SessionEventType.SESSION_STARTED, + timestamp: highPrecisionTime() + }); + this._saveMetadata(); + } else { + logger.warn(`Session ${sessionToken} already exists`); + } + } + + /** + * Gets all the Sessions. + * + * @returns {SessionInfo[]} + */ + getSessions(): SessionInfo[] { + const sessionTokens = Object.keys(this._sessionsMetadata); + const output = []; + + for (let i = 0; i < sessionTokens.length; ++i) { + const thisSession = this._sessionsMetadata[sessionTokens[i]]; + const newSessionInfo : SessionInfo = { + start: thisSession.events[0].timestamp, + format: thisSession.format, + sessionToken: sessionTokens[i], + segments: this.getSegments(sessionTokens[i]) + }; + + output.push(newSessionInfo); + } + + output.sort((a, b) => (a.start || 0) - (b.start || 0)); + + return output; + } + + /** + * Removes session metadata. + * + * @param {string} sessionToken - The session token. + * @returns {void} + */ + removeSession(sessionToken: string) { + delete this._sessionsMetadata[sessionToken]; + this._saveMetadata(); + } + + /** + * Get segments of a given Session. + * + * @param {string} sessionToken - The session token. + * @returns {SegmentInfo[]} + */ + getSegments(sessionToken: string): SegmentInfo[] { + const thisSession = this._sessionsMetadata[sessionToken]; + + if (thisSession) { + return this._constructSegments(thisSession.events); + } + + return []; + } + + /** + * Marks the start of a new segment. + * This should be invoked by {@code RecordingAdapter}s when they need to + * start asynchronous operations (such as switching tracks) that interrupts + * recording. + * + * @param {string} sessionToken - The token of the session to start a new + * segment in. + * @returns {number} - Current segment index. + */ + beginSegment(sessionToken: string): number { + if (this._sessionsMetadata[sessionToken] === undefined) { + logger.warn('Attempting to add segments to nonexistent' + + ` session ${sessionToken}`); + + return -1; + } + this._sessionsMetadata[sessionToken].events.push({ + type: SessionEventType.SEGMENT_STARTED, + timestamp: highPrecisionTime() + }); + this._saveMetadata(); + + return this.getSegments(sessionToken).length - 1; + } + + /** + * Gets the current segment index. Starting from 0 for the first + * segment. + * + * @param {string} sessionToken - The session token. + * @returns {number} + */ + getCurrentSegmentIndex(sessionToken: string): number { + if (this._sessionsMetadata[sessionToken] === undefined) { + return -1; + } + const segments = this.getSegments(sessionToken); + + if (segments.length === 0) { + return -1; + } + + const lastSegment = segments[segments.length - 1]; + + if (lastSegment.end) { + // last segment is already ended + return -1; + } + + return segments.length - 1; + } + + /** + * Marks the end of the last segment in a session. + * + * @param {string} sessionToken - The session token. + * @returns {void} + */ + endSegment(sessionToken: string) { + if (this._sessionsMetadata[sessionToken] === undefined) { + logger.warn('Attempting to end a segment in nonexistent' + + ` session ${sessionToken}`); + } else { + this._sessionsMetadata[sessionToken].events.push({ + type: SessionEventType.SEGMENT_ENDED, + timestamp: highPrecisionTime() + }); + this._saveMetadata(); + } + } + + /** + * Constructs an array of {@code SegmentInfo} from an array of + * {@code SessionEvent}s. + * + * @private + * @param {SessionEvent[]} events - The array of {@code SessionEvent}s. + * @returns {SegmentInfo[]} + */ + _constructSegments(events: SessionEvent[]): SegmentInfo[] { + if (events.length === 0) { + return []; + } + + const output = []; + let sessionStartTime = null; + let currentSegment : SegmentInfo = { + }; + + /** + * Helper function for adding a new {@code SegmentInfo} object to the + * output. + * + * @returns {void} + */ + function commit() { + if (currentSegment.gapBefore === undefined + || currentSegment.gapBefore === null) { + if (output.length > 0 && output[output.length - 1].end) { + const lastSegment = output[output.length - 1]; + + if (currentSegment.start && lastSegment.end) { + currentSegment.gapBefore = currentSegment.start + - lastSegment.end; + } else { + currentSegment.gapBefore = null; + } + } else if (sessionStartTime !== null && output.length === 0) { + currentSegment.gapBefore = currentSegment.start + ? currentSegment.start - sessionStartTime + : null; + } else { + currentSegment.gapBefore = null; + } + } + currentSegment.duration = currentSegment.end && currentSegment.start + ? currentSegment.end - currentSegment.start + : null; + output.push(currentSegment); + currentSegment = {}; + } + + for (let i = 0; i < events.length; ++i) { + const currentEvent = events[i]; + + switch (currentEvent.type) { + case SessionEventType.SESSION_STARTED: + if (sessionStartTime === null) { + sessionStartTime = currentEvent.timestamp; + } else { + logger.warn('Unexpected SESSION_STARTED event.' + , currentEvent); + } + break; + case SessionEventType.SEGMENT_STARTED: + if (currentSegment.start === undefined + || currentSegment.start === null) { + currentSegment.start = currentEvent.timestamp; + } else { + commit(); + currentSegment.start = currentEvent.timestamp; + } + break; + + case SessionEventType.SEGMENT_ENDED: + if (currentSegment.start === undefined + || currentSegment.start === null) { + logger.warn('Unexpected SEGMENT_ENDED event', currentEvent); + } else { + currentSegment.end = currentEvent.timestamp; + commit(); + } + break; + + default: + logger.warn('Unexpected error during _constructSegments'); + break; + } + } + if (currentSegment.start) { + commit(); + } + + return output; + } + +} + +/** + * Global singleton of {@code SessionManager}. + */ +export const sessionManager = new SessionManager(); + +// For debug only. To remove later. +window.sessionManager = sessionManager; diff --git a/react/features/local-recording/session/index.js b/react/features/local-recording/session/index.js new file mode 100644 index 000000000..2f0d24585 --- /dev/null +++ b/react/features/local-recording/session/index.js @@ -0,0 +1 @@ +export * from './SessionManager'; diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index a9854c07b..147f9ef8c 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -28,6 +28,10 @@ import { isDialOutEnabled } from '../../../invite'; import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts'; +import { + LocalRecordingButton, + LocalRecordingInfoDialog +} from '../../../local-recording'; import { LiveStreamButton, RecordButton @@ -129,6 +133,11 @@ type Props = { */ _localParticipantID: String, + /** + * The subsection of Redux state for local recording + */ + _localRecState: Object, + /** * Whether or not the overflow menu is visible. */ @@ -159,6 +168,7 @@ type Props = { */ _visible: boolean, + /** * Set with the buttons which this Toolbox should display. */ @@ -228,6 +238,8 @@ class Toolbox extends Component { = this._onToolbarToggleScreenshare.bind(this); this._onToolbarToggleSharedVideo = this._onToolbarToggleSharedVideo.bind(this); + this._onToolbarOpenLocalRecordingInfoDialog + = this._onToolbarOpenLocalRecordingInfoDialog.bind(this); } /** @@ -370,6 +382,12 @@ class Toolbox extends Component { visible = { this._shouldShowButton('camera') } />
+ { this._shouldShowButton('localrecording') + && + } { this._shouldShowButton('tileview') && } { this._shouldShowButton('invite') @@ -842,6 +860,20 @@ class Toolbox extends Component { this._doToggleSharedVideo(); } + _onToolbarOpenLocalRecordingInfoDialog: () => void; + + /** + * Opens the {@code LocalRecordingInfoDialog}. + * + * @private + * @returns {void} + */ + _onToolbarOpenLocalRecordingInfoDialog() { + sendAnalytics(createToolbarEvent('local.recording')); + + this.props.dispatch(openDialog(LocalRecordingInfoDialog)); + } + /** * Renders a button for toggleing screen sharing. * @@ -984,7 +1016,7 @@ class Toolbox extends Component { * Returns if a button name has been explicitly configured to be displayed. * * @param {string} buttonName - The name of the button, as expected in - * {@link intefaceConfig}. + * {@link interfaceConfig}. * @private * @returns {boolean} True if the button should be displayed. */ @@ -1021,6 +1053,7 @@ function _mapStateToProps(state) { visible } = state['features/toolbox']; const localParticipant = getLocalParticipant(state); + const localRecordingStates = state['features/local-recording']; const localVideo = getLocalVideoTrack(state['features/base/tracks']); const addPeopleEnabled = isAddPeopleEnabled(state); const dialOutEnabled = isDialOutEnabled(state); @@ -1061,6 +1094,7 @@ function _mapStateToProps(state) { _isGuest: state['features/base/jwt'].isGuest, _fullScreen: fullScreen, _localParticipantID: localParticipant.id, + _localRecState: localRecordingStates, _overflowMenuVisible: overflowMenuVisible, _raisedHand: localParticipant.raisedHand, _screensharing: localVideo && localVideo.videoType === 'desktop', diff --git a/webpack.config.js b/webpack.config.js index cccd8d679..0f05a1c0f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -149,7 +149,11 @@ module.exports = [ ], 'do_external_connect': - './connection_optimization/do_external_connect.js' + './connection_optimization/do_external_connect.js', + + 'flacEncodeWorker': + './react/features/local-recording/' + + 'recording/flac/flacEncodeWorker.js' } }),