Selaa lähdekoodia

Implement local recording

index.js of local recording

local-recording(ui): recording button

local-recording(encoding): flac support with libflac.js

Fixes in RecordingController; integration with UI

local-recording(controller): coordinate recording on different clients

local-recording(controller): allow recording on remote participants

local-recording(controller): global singleton

local-recording(controller): use middleware to init LocalRecording

cleanup and documentation in RecordingController

local-recording(refactor): "Delegate" -> "Adapter"

code style

stop eslint and flow from complaining

temp save: client status

fix linter issues

fix some docs; remove global LocalRecording instance

use node.js packaging for libflac.js; remove vendor/ folder

code style: flacEncodeWorker.js

use moment.js to do time diff

remove the use of console.log

code style: flac related files

remove excessive empty lines; and more docs

remove the use of clockTick for UI updates

initalize flacEncodeWorker properly, to avoid premature audio data transmission

move the realization of recordingController events
from LocalRecordingButton to middleware

i18n strings

minor markup changes in LocalRecordingInfoDialog

fix documentation
j8
Radium Zheng 6 vuotta sitten
vanhempi
commit
07bc70c2f5

+ 11
- 2
Makefile Näytä tiedosto

@@ -2,6 +2,7 @@ BUILD_DIR = build
2 2
 CLEANCSS = ./node_modules/.bin/cleancss
3 3
 DEPLOY_DIR = libs
4 4
 LIBJITSIMEET_DIR = node_modules/lib-jitsi-meet/
5
+LIBFLAC_DIR = node_modules/libflac/dist/
5 6
 NODE_SASS = ./node_modules/.bin/node-sass
6 7
 NPM = npm
7 8
 OUTPUT_DIR = .
@@ -19,7 +20,7 @@ compile:
19 20
 clean:
20 21
 	rm -fr $(BUILD_DIR)
21 22
 
22
-deploy: deploy-init deploy-appbundle deploy-lib-jitsi-meet deploy-css deploy-local
23
+deploy: deploy-init deploy-appbundle deploy-lib-jitsi-meet deploy-libflac deploy-css deploy-local
23 24
 
24 25
 deploy-init:
25 26
 	rm -fr $(DEPLOY_DIR)
@@ -33,6 +34,8 @@ deploy-appbundle:
33 34
 		$(BUILD_DIR)/do_external_connect.min.map \
34 35
 		$(BUILD_DIR)/external_api.min.js \
35 36
 		$(BUILD_DIR)/external_api.min.map \
37
+		$(BUILD_DIR)/flacEncodeWorker.min.js \
38
+		$(BUILD_DIR)/flacEncodeWorker.min.map \
36 39
 		$(BUILD_DIR)/device_selection_popup_bundle.min.js \
37 40
 		$(BUILD_DIR)/device_selection_popup_bundle.min.map \
38 41
 		$(BUILD_DIR)/dial_in_info_bundle.min.js \
@@ -50,6 +53,12 @@ deploy-lib-jitsi-meet:
50 53
 		$(LIBJITSIMEET_DIR)/modules/browser/capabilities.json \
51 54
 		$(DEPLOY_DIR)
52 55
 
56
+deploy-libflac:
57
+	cp \
58
+		$(LIBFLAC_DIR)/libflac3-1.3.2.min.js \
59
+		$(LIBFLAC_DIR)/libflac3-1.3.2.min.js.mem \
60
+		$(DEPLOY_DIR)
61
+
53 62
 deploy-css:
54 63
 	$(NODE_SASS) $(STYLES_MAIN) $(STYLES_BUNDLE) && \
55 64
 	$(CLEANCSS) $(STYLES_BUNDLE) > $(STYLES_DESTINATION) ; \
@@ -58,7 +67,7 @@ deploy-css:
58 67
 deploy-local:
59 68
 	([ ! -x deploy-local.sh ] || ./deploy-local.sh)
60 69
 
61
-dev: deploy-init deploy-css deploy-lib-jitsi-meet
70
+dev: deploy-init deploy-css deploy-lib-jitsi-meet deploy-libflac
62 71
 	$(WEBPACK_DEV_SERVER)
63 72
 
64 73
 source-package:

+ 23
- 0
lang/main.json Näytä tiedosto

@@ -666,5 +666,28 @@
666 666
         "decline": "Dismiss",
667 667
         "productLabel": "from Jitsi Meet",
668 668
         "videoCallTitle": "Incoming video call"
669
+    },
670
+    "localRecording": {
671
+        "localRecording": "Local Recording",
672
+        "dialogTitle": "Local Recording Controls",
673
+        "start": "Start",
674
+        "stop": "Stop",
675
+        "moderator": "Moderator",
676
+        "localUser": "Local user",
677
+        "duration": "Duration",
678
+        "encoding": "Encoding",
679
+        "participantStats": "Participant Stats",
680
+        "clientState": {
681
+            "on": "On",
682
+            "off": "Off",
683
+            "unknown": "Unknown"
684
+        },
685
+        "messages": {
686
+            "engaged": "Local recording engaged.",
687
+            "finished": "Recording session __token__ finished. Please send the recorded file to the moderator.",
688
+            "notModerator": "You are not the moderator. You cannot start or stop local recording."
689
+        },
690
+        "yes": "Yes",
691
+        "no": "No"
669 692
     }
670 693
 }

+ 4
- 0
package-lock.json Näytä tiedosto

@@ -9736,6 +9736,10 @@
9736 9736
         "yaeti": "1.0.1"
9737 9737
       }
9738 9738
     },
9739
+    "libflac": {
9740
+      "version": "git+https://github.com/ztl8702/libflac.git#31368097eaf9dcb5ef59365ef60b259cb7b97f07",
9741
+      "from": "git+https://github.com/ztl8702/libflac.git#31368097eaf9dcb5ef59365ef60b259cb7b97f07"
9742
+    },
9739 9743
     "load-json-file": {
9740 9744
       "version": "2.0.0",
9741 9745
       "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",

+ 1
- 0
package.json Näytä tiedosto

@@ -48,6 +48,7 @@
48 48
     "jsc-android": "224109.1.0",
49 49
     "jwt-decode": "2.2.0",
50 50
     "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#e097a1189ed99838605d90b959e129155bc0e50a",
51
+    "libflac": "git+https://github.com/ztl8702/libflac.git#31368097eaf9dcb5ef59365ef60b259cb7b97f07",
51 52
     "lodash": "4.17.4",
52 53
     "moment": "2.19.4",
53 54
     "moment-duration-format": "2.2.2",

+ 40
- 0
react/features/local-recording/actionTypes.js Näytä tiedosto

@@ -0,0 +1,40 @@
1
+/**
2
+ * Action to signal that the local client has started to perform recording,
3
+ * (as in: {@code RecordingAdapter} is actively collecting audio data).
4
+ *
5
+ * {
6
+ *     type: LOCAL_RECORDING_ENGAGED
7
+ * }
8
+ */
9
+export const LOCAL_RECORDING_ENGAGED = Symbol('LOCAL_RECORDING_ENGAGED');
10
+
11
+/**
12
+ * Action to signal that the local client has stopped recording,
13
+ * (as in: {@code RecordingAdapter} is no longer collecting audio data).
14
+ *
15
+ * {
16
+ *     type: LOCAL_RECORDING_UNENGAGED
17
+ * }
18
+ */
19
+export const LOCAL_RECORDING_UNENGAGED = Symbol('LOCAL_RECORDING_UNENGAGED');
20
+
21
+/**
22
+ * Action to show/hide {@code LocalRecordingInfoDialog}.
23
+ *
24
+ * {
25
+ *     type: LOCAL_RECORDING_TOGGLE_DIALOG
26
+ * }
27
+ */
28
+export const LOCAL_RECORDING_TOGGLE_DIALOG
29
+    = Symbol('LOCAL_RECORDING_TOGGLE_DIALOG');
30
+
31
+/**
32
+ * Action to update {@code LocalRecordingInfoDialog} with stats
33
+ * from all clients.
34
+ *
35
+ * {
36
+ *     type: LOCAL_RECORDING_STATS_UPDATE
37
+ * }
38
+ */
39
+export const LOCAL_RECORDING_STATS_UPDATE
40
+    = Symbol('LOCAL_RECORDING_STATS_UPDATE');

+ 59
- 0
react/features/local-recording/actions.js Näytä tiedosto

@@ -0,0 +1,59 @@
1
+/* @flow */
2
+
3
+import {
4
+    LOCAL_RECORDING_ENGAGED,
5
+    LOCAL_RECORDING_UNENGAGED,
6
+    LOCAL_RECORDING_TOGGLE_DIALOG,
7
+    LOCAL_RECORDING_STATS_UPDATE
8
+} from './actionTypes';
9
+
10
+/**
11
+ * Signals state change in local recording engagement.
12
+ * In other words, the events of the local WebWorker / MediaRecorder
13
+ * starting to record and finishing recording.
14
+ *
15
+ * Note that this is not the event fired when the users tries to start
16
+ * the recording in the UI.
17
+ *
18
+ * @param {bool} isEngaged - Whether local recording is engaged or not.
19
+ * @returns {{
20
+ *     type: LOCAL_RECORDING_ENGAGED
21
+ * }|{
22
+ *     type: LOCAL_RECORDING_UNENGAGED
23
+ * }}
24
+ */
25
+export function signalLocalRecordingEngagement(isEngaged: boolean) {
26
+    return {
27
+        type: isEngaged ? LOCAL_RECORDING_ENGAGED : LOCAL_RECORDING_UNENGAGED
28
+    };
29
+}
30
+
31
+/**
32
+ * Toggles the open/close state of {@code LocalRecordingInfoDialog}.
33
+ *
34
+ * @returns {{
35
+ *     type: LOCAL_RECORDING_TOGGLE_DIALOG
36
+ * }}
37
+ */
38
+export function toggleLocalRecordingInfoDialog() {
39
+    return {
40
+        type: LOCAL_RECORDING_TOGGLE_DIALOG
41
+    };
42
+}
43
+
44
+/**
45
+ * Updates the the local recording stats from each client,
46
+ * to be displayed on {@code LocalRecordingInfoDialog}.
47
+ *
48
+ * @param {*} stats - The stats object.
49
+ * @returns {{
50
+ *     type: LOCAL_RECORDING_STATS_UPDATE,
51
+ *     stats: Object
52
+ * }}
53
+ */
54
+export function statsUpdate(stats: Object) {
55
+    return {
56
+        type: LOCAL_RECORDING_STATS_UPDATE,
57
+        stats
58
+    };
59
+}

+ 111
- 0
react/features/local-recording/components/LocalRecordingButton.js Näytä tiedosto

@@ -0,0 +1,111 @@
1
+/* @flow */
2
+
3
+import InlineDialog from '@atlaskit/inline-dialog';
4
+import React, { Component } from 'react';
5
+
6
+import { translate } from '../../base/i18n';
7
+import { ToolbarButton } from '../../toolbox';
8
+
9
+import LocalRecordingInfoDialog from './LocalRecordingInfoDialog';
10
+
11
+/**
12
+ * The type of the React {@code Component} state of
13
+ * {@link LocalRecordingButton}.
14
+ */
15
+type Props = {
16
+
17
+    /**
18
+     * Whether or not {@link LocalRecordingInfoDialog} should be displayed.
19
+     */
20
+    isDialogShown: boolean,
21
+
22
+    /**
23
+     * Callback function called when {@link LocalRecordingButton} is clicked.
24
+     */
25
+    onClick: Function,
26
+
27
+    /**
28
+     * Invoked to obtain translated strings.
29
+     */
30
+    t: Function
31
+}
32
+
33
+/**
34
+ * A React {@code Component} for opening or closing the
35
+ * {@code LocalRecordingInfoDialog}.
36
+ *
37
+ * @extends Component
38
+ */
39
+class LocalRecordingButton extends Component<Props> {
40
+
41
+    /**
42
+     * Initializes a new {@code LocalRecordingButton} instance.
43
+     *
44
+     * @param {Object} props - The read-only properties with which the new
45
+     * instance is to be initialized.
46
+     */
47
+    constructor(props: Props) {
48
+        super(props);
49
+
50
+        // Bind event handlers so they are only bound once per instance.
51
+        this._onClick = this._onClick.bind(this);
52
+    }
53
+
54
+    /**
55
+     * Implements React's {@link Component#render()}.
56
+     *
57
+     * @inheritdoc
58
+     * @returns {ReactElement}
59
+     */
60
+    render() {
61
+        const { isDialogShown, t } = this.props;
62
+        const iconClasses
63
+            = `icon-thumb-menu ${isDialogShown
64
+                ? 'icon-rec toggled' : 'icon-rec'}`;
65
+
66
+        return (
67
+            <div className = 'toolbox-button-wth-dialog'>
68
+                <InlineDialog
69
+                    content = {
70
+                        <LocalRecordingInfoDialog />
71
+                    }
72
+                    isOpen = { isDialogShown }
73
+                    onClose = { this._onCloseDialog }
74
+                    position = { 'top right' }>
75
+                    <ToolbarButton
76
+                        iconName = { iconClasses }
77
+                        onClick = { this._onClick }
78
+                        tooltip = { t('localRecording.dialogTitle') } />
79
+                </InlineDialog>
80
+            </div>
81
+        );
82
+    }
83
+
84
+    _onClick: () => void;
85
+
86
+    /**
87
+     * Callback invoked when the Toolbar button is clicked.
88
+     *
89
+     * @private
90
+     * @returns {void}
91
+     */
92
+    _onClick() {
93
+        this.props.onClick();
94
+    }
95
+
96
+    _onCloseDialog: () => void;
97
+
98
+    /**
99
+     * Callback invoked when {@code InlineDialog} signals that it should be
100
+     * close.
101
+     *
102
+     * @returns {void}
103
+     */
104
+    _onCloseDialog() {
105
+        // Do nothing for now, because we want the dialog to stay open
106
+        // after certain time, otherwise the moderator might need to repeatly
107
+        // open the dialog to see the stats.
108
+    }
109
+}
110
+
111
+export default translate(LocalRecordingButton);

+ 332
- 0
react/features/local-recording/components/LocalRecordingInfoDialog.js Näytä tiedosto

@@ -0,0 +1,332 @@
1
+/* @flow */
2
+
3
+import moment from 'moment';
4
+import React, { Component } from 'react';
5
+import { connect } from 'react-redux';
6
+
7
+import { translate } from '../../base/i18n';
8
+import {
9
+    PARTICIPANT_ROLE,
10
+    getLocalParticipant
11
+} from '../../base/participants';
12
+
13
+import { statsUpdate } from '../actions';
14
+import { recordingController } from '../controller';
15
+
16
+
17
+/**
18
+ * The type of the React {@code Component} props of
19
+ * {@link LocalRecordingInfoDialog}.
20
+ */
21
+type Props = {
22
+
23
+    /**
24
+     * Redux store dispatch function.
25
+     */
26
+    dispatch: Dispatch<*>,
27
+
28
+    /**
29
+     * Current encoding format.
30
+     */
31
+    encodingFormat: string,
32
+
33
+    /**
34
+     * Whether the local user is the moderator.
35
+     */
36
+    isModerator: boolean,
37
+
38
+    /**
39
+     * Whether local recording is engaged.
40
+     */
41
+    isOn: boolean,
42
+
43
+    /**
44
+     * The start time of the current local recording session.
45
+     * Used to calculate the duration of recording.
46
+     */
47
+    recordingStartedAt: Date,
48
+
49
+    /**
50
+     * Stats of all the participant.
51
+     */
52
+    stats: Object,
53
+
54
+    /**
55
+     * Invoked to obtain translated strings.
56
+     */
57
+    t: Function
58
+}
59
+
60
+/**
61
+ * The type of the React {@code Component} state of
62
+ * {@link LocalRecordingInfoDialog}.
63
+ */
64
+type State = {
65
+
66
+    /**
67
+     * The recording duration string to be displayed on the UI.
68
+     */
69
+    durationString: string
70
+}
71
+
72
+/**
73
+ * A React Component with the contents for a dialog that shows information about
74
+ * local recording. For users with moderator rights, this is also the "control
75
+ * panel" for starting/stopping local recording on all clients.
76
+ *
77
+ * @extends Component
78
+ */
79
+class LocalRecordingInfoDialog extends Component<Props, State> {
80
+
81
+    /**
82
+     * Saves a handle to the timer for UI updates,
83
+     * so that it can be cancelled when the component unmounts.
84
+     */
85
+    _timer: ?IntervalID;
86
+
87
+    /**
88
+     * Constructor.
89
+     */
90
+    constructor() {
91
+        super();
92
+        this.state = {
93
+            durationString: 'N/A'
94
+        };
95
+    }
96
+
97
+    /**
98
+     * Implements React's {@link Component#componentWillMount()}.
99
+     *
100
+     * @returns {void}
101
+     */
102
+    componentWillMount() {
103
+        this._timer = setInterval(
104
+            () => {
105
+                this.setState((_prevState, props) => {
106
+                    const nowTime = new Date(Date.now());
107
+
108
+                    return {
109
+                        durationString: this._getDuration(nowTime,
110
+                            props.recordingStartedAt)
111
+                    };
112
+                });
113
+                try {
114
+                    this.props.dispatch(
115
+                        statsUpdate(recordingController
116
+                            .getParticipantsStats()));
117
+                } catch (e) {
118
+                    // do nothing
119
+                }
120
+            },
121
+            1000
122
+        );
123
+    }
124
+
125
+    /**
126
+     * Implements React's {@link Component#componentWillUnmount()}.
127
+     *
128
+     * @returns {void}
129
+     */
130
+    componentWillUnmount() {
131
+        if (this._timer) {
132
+            clearInterval(this._timer);
133
+            this._timer = null;
134
+        }
135
+    }
136
+
137
+
138
+    /**
139
+     * Returns React elements for displaying the local recording stats of
140
+     * each participant.
141
+     *
142
+     * @returns {ReactElement}
143
+     */
144
+    renderStats() {
145
+        const { stats, t } = this.props;
146
+
147
+        if (stats === undefined) {
148
+            return <ul />;
149
+        }
150
+        const ids = Object.keys(stats);
151
+
152
+        return (
153
+            <ul>
154
+                {ids.map((id, i) =>
155
+
156
+                    // FIXME: a workaround, as arrow functions without `return`
157
+                    // keyword need to be wrapped in parenthesis.
158
+                    /* eslint-disable no-extra-parens */
159
+                    (<li key = { i }>
160
+                        <span>{stats[id].displayName || id}: </span>
161
+                        <span>{stats[id].recordingStats
162
+                            ? `${stats[id].recordingStats.isRecording
163
+                                ? t('localRecording.clientState.on')
164
+                                : t('localRecording.clientState.off')} `
165
+                            + `(${stats[id]
166
+                                .recordingStats.currentSessionToken})`
167
+                            : t('localRecording.clientState.unknown')}</span>
168
+                    </li>)
169
+                    /* eslint-enable no-extra-parens */
170
+                )}
171
+            </ul>
172
+        );
173
+    }
174
+
175
+    /**
176
+     * Implements React's {@link Component#render()}.
177
+     *
178
+     * @inheritdoc
179
+     * @returns {ReactElement}
180
+     */
181
+    render() {
182
+        const { isModerator, encodingFormat, isOn, t } = this.props;
183
+        const { durationString } = this.state;
184
+
185
+        return (
186
+            <div
187
+                className = 'info-dialog' >
188
+                <div className = 'info-dialog-column'>
189
+                    <h4 className = 'info-dialog-icon'>
190
+                        <i className = 'icon-info' />
191
+                    </h4>
192
+                </div>
193
+                <div className = 'info-dialog-column'>
194
+                    <div className = 'info-dialog-title'>
195
+                        { t('localRecording.localRecording') }
196
+                    </div>
197
+                    <div>
198
+                        <span className = 'info-label'>
199
+                            {`${t('localRecording.moderator')}:`}
200
+                        </span>
201
+                        <span className = 'spacer'>&nbsp;</span>
202
+                        <span className = 'info-value'>
203
+                            { isModerator
204
+                                ? t('localRecording.yes')
205
+                                : t('localRecording.no') }
206
+                        </span>
207
+                    </div>
208
+                    { isOn && <div>
209
+                        <span className = 'info-label'>
210
+                            {`${t('localRecording.duration')}:`}
211
+                        </span>
212
+                        <span className = 'spacer'>&nbsp;</span>
213
+                        <span className = 'info-value'>
214
+                            { durationString }
215
+                        </span>
216
+                    </div>
217
+                    }
218
+                    {isOn
219
+                    && <div>
220
+                        <span className = 'info-label'>
221
+                            {`${t('localRecording.encoding')}:`}
222
+                        </span>
223
+                        <span className = 'spacer'>&nbsp;</span>
224
+                        <span className = 'info-value'>
225
+                            { encodingFormat }
226
+                        </span>
227
+                    </div>
228
+                    }
229
+                    {
230
+                        isModerator
231
+                        && <div>
232
+                            <div>
233
+                                <span className = 'info-label'>
234
+                                    {`${t('localRecording.participantStats')}:`}
235
+                                </span>
236
+                            </div>
237
+                            { this.renderStats() }
238
+                        </div>
239
+                    }
240
+                    {
241
+                        isModerator
242
+                            && <div className = 'info-dialog-action-links'>
243
+                                <div className = 'info-dialog-action-link'>
244
+                                    {isOn ? <a
245
+                                        onClick = { this._onStop }>
246
+                                        { t('localRecording.stop') }
247
+                                    </a>
248
+                                        : <a
249
+                                            onClick = { this._onStart }>
250
+                                            { t('localRecording.start') }
251
+                                        </a>
252
+
253
+                                    }
254
+                                </div>
255
+                            </div>
256
+                    }
257
+                </div>
258
+            </div>
259
+        );
260
+    }
261
+
262
+    /**
263
+     * Creates a duration string "HH:MM:SS" from two Date objects.
264
+     *
265
+     * @param {Date} now - Current time.
266
+     * @param {Date} prev - Previous time, the time to be subtracted.
267
+     * @returns {string}
268
+     */
269
+    _getDuration(now, prev) {
270
+        // Still a hack, as moment.js does not support formatting of duration
271
+        // (i.e. TimeDelta). Only works if total duration < 24 hours.
272
+        // But who is going to have a 24-hour long conference?
273
+        return moment(now - prev).utc()
274
+            .format('HH:mm:ss');
275
+    }
276
+
277
+    /**
278
+     * Callback function for the Start UI action.
279
+     *
280
+     * @private
281
+     * @returns {void}
282
+     */
283
+    _onStart() {
284
+        recordingController.startRecording();
285
+    }
286
+
287
+    /**
288
+     * Callback function for the Stop UI action.
289
+     *
290
+     * @private
291
+     * @returns {void}
292
+     */
293
+    _onStop() {
294
+        recordingController.stopRecording();
295
+    }
296
+
297
+}
298
+
299
+/**
300
+ * Maps (parts of) the Redux state to the associated props for the
301
+ * {@code LocalRecordingInfoDialog} component.
302
+ *
303
+ * @param {Object} state - The Redux state.
304
+ * @private
305
+ * @returns {{
306
+ *     encodingFormat: string,
307
+ *     isModerator: boolean,
308
+ *     isOn: boolean,
309
+ *     recordingStartedAt: Date,
310
+ *     stats: Object
311
+ * }}
312
+ */
313
+function _mapStateToProps(state) {
314
+    const {
315
+        encodingFormat,
316
+        isEngaged: isOn,
317
+        recordingStartedAt,
318
+        stats
319
+    } = state['features/local-recording'];
320
+    const isModerator
321
+        = getLocalParticipant(state).role === PARTICIPANT_ROLE.MODERATOR;
322
+
323
+    return {
324
+        encodingFormat,
325
+        isModerator,
326
+        isOn,
327
+        recordingStartedAt,
328
+        stats
329
+    };
330
+}
331
+
332
+export default translate(connect(_mapStateToProps)(LocalRecordingInfoDialog));

+ 1
- 0
react/features/local-recording/components/index.js Näytä tiedosto

@@ -0,0 +1 @@
1
+export { default as LocalRecordingButton } from './LocalRecordingButton';

+ 493
- 0
react/features/local-recording/controller/RecordingController.js Näytä tiedosto

@@ -0,0 +1,493 @@
1
+/* @flow */
2
+
3
+import { i18next } from '../../base/i18n';
4
+import {
5
+    FlacAdapter,
6
+    OggAdapter,
7
+    WavAdapter
8
+} from '../recording';
9
+
10
+const logger = require('jitsi-meet-logger').getLogger(__filename);
11
+
12
+/**
13
+ * XMPP command for signaling the start of local recording to all clients.
14
+ * Should be sent by the moderator only.
15
+ */
16
+const COMMAND_START = 'localRecStart';
17
+
18
+/**
19
+ * XMPP command for signaling the stop of local recording to all clients.
20
+ * Should be sent by the moderator only.
21
+ */
22
+const COMMAND_STOP = 'localRecStop';
23
+
24
+/**
25
+ * Participant property key for local recording stats.
26
+ */
27
+const PROPERTY_STATS = 'localRecStats';
28
+
29
+/**
30
+ * Default recording format.
31
+ */
32
+const DEFAULT_RECORDING_FORMAT = 'flac';
33
+
34
+/**
35
+ * States of the {@code RecordingController}.
36
+ */
37
+const ControllerState = Object.freeze({
38
+    /**
39
+     * Idle (not recording).
40
+     */
41
+    IDLE: Symbol('IDLE'),
42
+
43
+    /**
44
+     * Engaged (recording).
45
+     */
46
+    RECORDING: Symbol('RECORDING')
47
+});
48
+
49
+/**
50
+ * Type of the stats reported by each participant (client).
51
+ */
52
+type RecordingStats = {
53
+
54
+    /**
55
+     * Current local recording session token used by the participant.
56
+     */
57
+    currentSessionToken: number,
58
+
59
+    /**
60
+     * Whether local recording is engaged on the participant's device.
61
+     */
62
+    isRecording: boolean,
63
+
64
+    /**
65
+     * Total recorded bytes. (Reserved for future use.)
66
+     */
67
+    recordedBytes: number,
68
+
69
+    /**
70
+     * Total recording duration. (Reserved for future use.)
71
+     */
72
+    recordedLength: number
73
+}
74
+
75
+/**
76
+ * The component responsible for the coordination of local recording, across
77
+ * multiple participants.
78
+ * Current implementation requires that there is only one moderator in a room.
79
+ */
80
+class RecordingController {
81
+
82
+    /**
83
+     * For each recording session, there is a separate @{code RecordingAdapter}
84
+     * instance so that encoded bits from the previous sessions can still be
85
+     * retrieved after they ended.
86
+     *
87
+     * @private
88
+     */
89
+    _adapters = {};
90
+
91
+    /**
92
+     * The {@code JitsiConference} instance.
93
+     *
94
+     * @private
95
+     */
96
+    _conference: * = null;
97
+
98
+    /**
99
+     * Current recording session token.
100
+     * Session token is a number generated by the moderator, to ensure every
101
+     * client is in the same recording state.
102
+     *
103
+     * @private
104
+     */
105
+    _currentSessionToken: number = -1;
106
+
107
+    /**
108
+     * Current state of {@code RecordingController}.
109
+     *
110
+     * @private
111
+     */
112
+    _state = ControllerState.IDLE;
113
+
114
+    /**
115
+     * Current recording format. This will be in effect from the next
116
+     * recording session, i.e., if this value is changed during an on-going
117
+     * recording session, that on-going session will not use the new format.
118
+     *
119
+     * @private
120
+     */
121
+    _format = DEFAULT_RECORDING_FORMAT;
122
+
123
+    /**
124
+     * Whether or not the {@code RecordingController} has registered for
125
+     * XMPP events. Prevents initialization from happening multiple times.
126
+     *
127
+     * @private
128
+     */
129
+    _registered = false;
130
+
131
+    /**
132
+     * FIXME: callback function for the {@code RecordingController} to notify
133
+     * UI it wants to display a notice. Keeps {@code RecordingController}
134
+     * decoupled from UI.
135
+     */
136
+    onNotify: ?(string) => void;
137
+
138
+    /**
139
+     * FIXME: callback function for the {@code RecordingController} to notify
140
+     * UI it wants to display a warning. Keeps {@code RecordingController}
141
+     * decoupled from UI.
142
+     */
143
+    onWarning: ?(string) => void;
144
+
145
+    /**
146
+     * FIXME: callback function for the {@code RecordingController} to notify
147
+     * UI that the local recording state has changed.
148
+     */
149
+    onStateChanged: ?(boolean) => void;
150
+
151
+    /**
152
+     * Constructor.
153
+     *
154
+     * @returns {void}
155
+     */
156
+    constructor() {
157
+        this._updateStats = this._updateStats.bind(this);
158
+        this._onStartCommand = this._onStartCommand.bind(this);
159
+        this._onStopCommand = this._onStopCommand.bind(this);
160
+        this._doStartRecording = this._doStartRecording.bind(this);
161
+        this._doStopRecording = this._doStopRecording.bind(this);
162
+        this.registerEvents = this.registerEvents.bind(this);
163
+        this.getParticipantsStats = this.getParticipantsStats.bind(this);
164
+    }
165
+
166
+    registerEvents: () => void;
167
+
168
+    /**
169
+     * Registers listeners for XMPP events.
170
+     *
171
+     * @param {JitsiConference} conference - {@code JitsiConference} instance.
172
+     * @returns {void}
173
+     */
174
+    registerEvents(conference: Object) {
175
+        if (!this._registered) {
176
+            this._conference = conference;
177
+            if (this._conference) {
178
+                this._conference
179
+                    .addCommandListener(COMMAND_STOP, this._onStopCommand);
180
+                this._conference
181
+                    .addCommandListener(COMMAND_START, this._onStartCommand);
182
+                this._registered = true;
183
+            }
184
+        }
185
+    }
186
+
187
+    /**
188
+     * Signals the participants to start local recording.
189
+     *
190
+     * @returns {void}
191
+     */
192
+    startRecording() {
193
+        this.registerEvents();
194
+        if (this._conference && this._conference.isModerator()) {
195
+            this._conference.removeCommand(COMMAND_STOP);
196
+            this._conference.sendCommand(COMMAND_START, {
197
+                attributes: {
198
+                    sessionToken: this._getRandomToken(),
199
+                    format: this._format
200
+                }
201
+            });
202
+        } else {
203
+            const message = i18next.t('localRecording.messages.notModerator');
204
+
205
+            if (this.onWarning) {
206
+                this.onWarning(message);
207
+            }
208
+        }
209
+    }
210
+
211
+    /**
212
+     * Signals the participants to stop local recording.
213
+     *
214
+     * @returns {void}
215
+     */
216
+    stopRecording() {
217
+        if (this._conference) {
218
+            if (this._conference.isModerator) {
219
+                this._conference.removeCommand(COMMAND_START);
220
+                this._conference.sendCommand(COMMAND_STOP, {
221
+                    attributes: {
222
+                        sessionToken: this._currentSessionToken
223
+                    }
224
+                });
225
+            } else {
226
+                const message
227
+                    = i18next.t('localRecording.messages.notModerator');
228
+
229
+                if (this.onWarning) {
230
+                    this.onWarning(message);
231
+                }
232
+            }
233
+        }
234
+    }
235
+
236
+    /**
237
+     * Triggers the download of recorded data.
238
+     * Browser only.
239
+     *
240
+     * @param {number} sessionToken - The token of the session to download.
241
+     * @returns {void}
242
+     */
243
+    downloadRecordedData(sessionToken: number) {
244
+        if (this._adapters[sessionToken]) {
245
+            this._adapters[sessionToken].download();
246
+        } else {
247
+            logger.error(`Invalid session token for download ${sessionToken}`);
248
+        }
249
+    }
250
+
251
+    /**
252
+     * Switches the recording format.
253
+     *
254
+     * @param {string} newFormat - The new format.
255
+     * @returns {void}
256
+     */
257
+    switchFormat(newFormat: string) {
258
+        this._format = newFormat;
259
+        logger.log(`Recording format switched to ${newFormat}`);
260
+
261
+        // will be used next time
262
+    }
263
+
264
+    /**
265
+     * Returns the local recording stats.
266
+     *
267
+     * @returns {RecordingStats}
268
+     */
269
+    getLocalStats(): RecordingStats {
270
+        return {
271
+            currentSessionToken: this._currentSessionToken,
272
+            isRecording: this._state === ControllerState.RECORDING,
273
+            recordedBytes: 0,
274
+            recordedLength: 0
275
+        };
276
+    }
277
+
278
+    getParticipantsStats: () => *;
279
+
280
+    /**
281
+     * Returns the remote participants' local recording stats.
282
+     *
283
+     * @returns {*}
284
+     */
285
+    getParticipantsStats() {
286
+        const members
287
+            = this._conference.getParticipants()
288
+            .map(member => {
289
+                return {
290
+                    id: member.getId(),
291
+                    displayName: member.getDisplayName(),
292
+                    recordingStats:
293
+                        JSON.parse(member.getProperty(PROPERTY_STATS) || '{}'),
294
+                    isSelf: false
295
+                };
296
+            });
297
+
298
+        // transform into a dictionary,
299
+        // for consistent ordering
300
+        const result = {};
301
+
302
+        for (let i = 0; i < members.length; ++i) {
303
+            result[members[i].id] = members[i];
304
+        }
305
+        const localId = this._conference.myUserId();
306
+
307
+        result[localId] = {
308
+            id: localId,
309
+            displayName: i18next.t('localRecording.localUser'),
310
+            recordingStats: this.getLocalStats(),
311
+            isSelf: true
312
+        };
313
+
314
+        return result;
315
+    }
316
+
317
+    _updateStats: () => void;
318
+
319
+    /**
320
+     * Sends out updates about the local recording stats via XMPP.
321
+     *
322
+     * @private
323
+     * @returns {void}
324
+     */
325
+    _updateStats() {
326
+        if (this._conference) {
327
+            this._conference.setLocalParticipantProperty(PROPERTY_STATS,
328
+                JSON.stringify(this.getLocalStats()));
329
+        }
330
+    }
331
+
332
+    _onStartCommand: (*) => void;
333
+
334
+    /**
335
+     * Callback function for XMPP event.
336
+     *
337
+     * @private
338
+     * @param {*} value - The event args.
339
+     * @returns {void}
340
+     */
341
+    _onStartCommand(value) {
342
+        const { sessionToken, format } = value.attributes;
343
+
344
+        if (this._state === ControllerState.IDLE) {
345
+            this._format = format;
346
+            this._currentSessionToken = sessionToken;
347
+            this._adapters[sessionToken]
348
+                 = this._createRecordingAdapter();
349
+            this._doStartRecording();
350
+        } else if (this._currentSessionToken !== sessionToken) {
351
+            // we need to restart the recording
352
+            this._doStopRecording().then(() => {
353
+                this._format = format;
354
+                this._currentSessionToken = sessionToken;
355
+                this._adapters[sessionToken]
356
+                    = this._createRecordingAdapter();
357
+                this._doStartRecording();
358
+            });
359
+        }
360
+    }
361
+
362
+    _onStopCommand: (*) => void;
363
+
364
+    /**
365
+     * Callback function for XMPP event.
366
+     *
367
+     * @private
368
+     * @param {*} value - The event args.
369
+     * @returns {void}
370
+     */
371
+    _onStopCommand(value) {
372
+        if (this._state === ControllerState.RECORDING
373
+            && this._currentSessionToken === value.attributes.sessionToken) {
374
+            this._doStopRecording();
375
+        }
376
+    }
377
+
378
+    /**
379
+     * Generates a token that can be used to distinguish each
380
+     * recording session.
381
+     *
382
+     * @returns {number}
383
+     */
384
+    _getRandomToken() {
385
+        return Math.floor(Math.random() * 10000) + 1;
386
+    }
387
+
388
+    _doStartRecording: () => void;
389
+
390
+    /**
391
+     * Starts the recording locally.
392
+     *
393
+     * @private
394
+     * @returns {void}
395
+     */
396
+    _doStartRecording() {
397
+        if (this._state === ControllerState.IDLE) {
398
+            this._state = ControllerState.RECORDING;
399
+            const delegate = this._adapters[this._currentSessionToken];
400
+
401
+            delegate.ensureInitialized()
402
+            .then(() => delegate.start())
403
+            .then(() => {
404
+                logger.log('Local recording engaged.');
405
+                const message = i18next.t('localRecording.messages.engaged');
406
+
407
+                if (this.onNotify) {
408
+                    this.onNotify(message);
409
+                }
410
+                if (this.onStateChanged) {
411
+                    this.onStateChanged(true);
412
+                }
413
+                this._updateStats();
414
+            })
415
+            .catch(err => {
416
+                logger.error('Failed to start local recording.', err);
417
+            });
418
+        }
419
+
420
+    }
421
+
422
+    _doStopRecording: () => Promise<void>;
423
+
424
+    /**
425
+     * Stops the recording locally.
426
+     *
427
+     * @private
428
+     * @returns {Promise<void>}
429
+     */
430
+    _doStopRecording() {
431
+        if (this._state === ControllerState.RECORDING) {
432
+            const token = this._currentSessionToken;
433
+
434
+            return this._adapters[this._currentSessionToken]
435
+                .stop()
436
+                .then(() => {
437
+                    this._state = ControllerState.IDLE;
438
+                    logger.log('Local recording unengaged.');
439
+                    this.downloadRecordedData(token);
440
+
441
+                    const message
442
+                        = i18next.t('localRecording.messages.finished',
443
+                            {
444
+                                token
445
+                            });
446
+
447
+                    if (this.onNotify) {
448
+                        this.onNotify(message);
449
+                    }
450
+                    if (this.onStateChanged) {
451
+                        this.onStateChanged(false);
452
+                    }
453
+                    this._updateStats();
454
+                })
455
+                .catch(err => {
456
+                    logger.error('Failed to stop local recording.', err);
457
+                });
458
+        }
459
+
460
+        /* eslint-disable */
461
+        return (Promise.resolve(): Promise<void>); 
462
+        // FIXME: better ways to satisfy flow and ESLint at the same time?
463
+        /* eslint-enable */
464
+
465
+    }
466
+
467
+    /**
468
+     * Creates a recording adapter according to the current recording format.
469
+     *
470
+     * @private
471
+     * @returns {RecordingAdapter}
472
+     */
473
+    _createRecordingAdapter() {
474
+        logger.debug('[RecordingController] creating recording'
475
+            + ` adapter for ${this._format} format.`);
476
+
477
+        switch (this._format) {
478
+        case 'ogg':
479
+            return new OggAdapter();
480
+        case 'flac':
481
+            return new FlacAdapter();
482
+        case 'wav':
483
+            return new WavAdapter();
484
+        default:
485
+            throw new Error(`Unknown format: ${this._format}`);
486
+        }
487
+    }
488
+}
489
+
490
+/**
491
+ * Global singleton of {@code RecordingController}.
492
+ */
493
+export const recordingController = new RecordingController();

+ 1
- 0
react/features/local-recording/controller/index.js Näytä tiedosto

@@ -0,0 +1 @@
1
+export * from './RecordingController';

+ 7
- 0
react/features/local-recording/index.js Näytä tiedosto

@@ -0,0 +1,7 @@
1
+export * from './actions';
2
+export * from './actionTypes';
3
+export * from './components';
4
+export * from './controller';
5
+
6
+import './middleware';
7
+import './reducer';

+ 52
- 0
react/features/local-recording/middleware.js Näytä tiedosto

@@ -0,0 +1,52 @@
1
+/* @flow */
2
+
3
+import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
4
+import { CONFERENCE_JOINED } from '../base/conference';
5
+import { i18next } from '../base/i18n';
6
+import { MiddlewareRegistry } from '../base/redux';
7
+import { showNotification } from '../notifications';
8
+
9
+import { recordingController } from './controller';
10
+import { signalLocalRecordingEngagement } from './actions';
11
+
12
+MiddlewareRegistry.register(({ getState, dispatch }) => next => action => {
13
+    const result = next(action);
14
+
15
+    switch (action.type) {
16
+    case CONFERENCE_JOINED: {
17
+        // the Conference object is ready
18
+        const { conference } = getState()['features/base/conference'];
19
+
20
+        recordingController.registerEvents(conference);
21
+        break;
22
+    }
23
+    case APP_WILL_MOUNT:
24
+        // realize the delegates on recordingController,
25
+        // providing UI reactions.
26
+        recordingController.onStateChanged = function(state) {
27
+            dispatch(signalLocalRecordingEngagement(state));
28
+        };
29
+
30
+        recordingController.onWarning = function(message) {
31
+            dispatch(showNotification({
32
+                title: i18next.t('localRecording.localRecording'),
33
+                description: message
34
+            }, 10000));
35
+        };
36
+
37
+        recordingController.onNotify = function(message) {
38
+            dispatch(showNotification({
39
+                title: i18next.t('localRecording.localRecording'),
40
+                description: message
41
+            }, 10000));
42
+        };
43
+        break;
44
+    case APP_WILL_UNMOUNT:
45
+        recordingController.onStateChanged = null;
46
+        recordingController.onNotify = null;
47
+        recordingController.onWarning = null;
48
+        break;
49
+    }
50
+
51
+    return result;
52
+});

+ 107
- 0
react/features/local-recording/recording/OggAdapter.js Näytä tiedosto

@@ -0,0 +1,107 @@
1
+import { RecordingAdapter } from './RecordingAdapter';
2
+import { downloadBlob, timestampString } from './Utils';
3
+
4
+const logger = require('jitsi-meet-logger').getLogger(__filename);
5
+
6
+/**
7
+ * RecordingAdapter implementation that uses MediaRecorder
8
+ * (default browser encoding with Opus codec)
9
+ */
10
+export class OggAdapter extends RecordingAdapter {
11
+
12
+    _mediaRecorder = null;
13
+
14
+    /**
15
+     * Implements {@link RecordingAdapter#ensureInitialized()}.
16
+     *
17
+     * @inheritdoc
18
+     */
19
+    ensureInitialized() {
20
+        let p = null;
21
+
22
+        if (this._mediaRecorder === null) {
23
+            p = new Promise((resolve, error) => {
24
+                navigator.getUserMedia(
25
+
26
+                    // constraints, only audio needed
27
+                    {
28
+                        audioBitsPerSecond: 44100, // 44 kbps
29
+                        audio: true,
30
+                        mimeType: 'application/ogg'
31
+                    },
32
+
33
+                    // success callback
34
+                    stream => {
35
+                        this._mediaRecorder = new MediaRecorder(stream);
36
+                        this._mediaRecorder.ondataavailable
37
+                            = e => this._saveMediaData(e.data);
38
+                        resolve();
39
+                    },
40
+
41
+                    // Error callback
42
+                    err => {
43
+                        logger.error(`Error calling getUserMedia(): ${err}`);
44
+                        error();
45
+                    }
46
+                );
47
+            });
48
+        } else {
49
+            p = new Promise(resolve => {
50
+                resolve();
51
+            });
52
+        }
53
+
54
+        return p;
55
+    }
56
+
57
+    /**
58
+     * Implements {@link RecordingAdapter#start()}.
59
+     *
60
+     * @inheritdoc
61
+     */
62
+    start() {
63
+        return new Promise(resolve => {
64
+            this._mediaRecorder.start();
65
+            resolve();
66
+        });
67
+    }
68
+
69
+    /**
70
+     * Implements {@link RecordingAdapter#stop()}.
71
+     *
72
+     * @inheritdoc
73
+     */
74
+    stop() {
75
+        return new Promise(
76
+            resolve => {
77
+                this._mediaRecorder.onstop = () => resolve();
78
+                this._mediaRecorder.stop();
79
+            }
80
+        );
81
+    }
82
+
83
+    /**
84
+     * Implements {@link RecordingAdapter#download()}.
85
+     *
86
+     * @inheritdoc
87
+     */
88
+    download() {
89
+        if (this._recordedData !== null) {
90
+            const audioURL = window.URL.createObjectURL(this._recordedData);
91
+
92
+            downloadBlob(audioURL, `recording${timestampString()}.ogg`);
93
+        }
94
+
95
+    }
96
+
97
+    /**
98
+     * Callback for encoded data.
99
+     *
100
+     * @private
101
+     * @param {*} data - Encoded data.
102
+     * @returns {void}
103
+     */
104
+    _saveMediaData(data) {
105
+        this._recordedData = data;
106
+    }
107
+}

+ 41
- 0
react/features/local-recording/recording/RecordingAdapter.js Näytä tiedosto

@@ -0,0 +1,41 @@
1
+/**
2
+ * Common interface for recording mechanisms
3
+ */
4
+export class RecordingAdapter {
5
+
6
+    /**
7
+     * Initialize the recording backend.
8
+     *
9
+     * @returns {Promise}
10
+     */
11
+    ensureInitialized() {
12
+        throw new Error('Not implemented');
13
+    }
14
+
15
+    /**
16
+     * Starts recording.
17
+     *
18
+     * @returns {Promise}
19
+     */
20
+    start() {
21
+        throw new Error('Not implemented');
22
+    }
23
+
24
+    /**
25
+     * Stops recording.
26
+     *
27
+     * @returns {Promise}
28
+     */
29
+    stop() {
30
+        throw new Error('Not implemented');
31
+    }
32
+
33
+    /**
34
+     * Initiates download of the recorded and encoded audio file.
35
+     *
36
+     * @returns {void}
37
+     */
38
+    download() {
39
+        throw new Error('Not implemented');
40
+    }
41
+}

+ 34
- 0
react/features/local-recording/recording/Utils.js Näytä tiedosto

@@ -0,0 +1,34 @@
1
+/**
2
+ * Force download of Blob in browser by faking an <a> tag.
3
+ *
4
+ * @param {string} blob - Base64 URL.
5
+ * @param {string} fileName - The filename to appear in the download dialog.
6
+ * @returns {void}
7
+ */
8
+export function downloadBlob(blob, fileName = 'recording.ogg') {
9
+    // fake a anchor tag
10
+    const a = document.createElement('a');
11
+
12
+    document.body.appendChild(a);
13
+    a.style = 'display: none';
14
+    a.href = blob;
15
+    a.download = fileName;
16
+    a.click();
17
+}
18
+
19
+/**
20
+ * Obtains a timestamp of now.
21
+ * Used in filenames.
22
+ *
23
+ * @returns {string}
24
+ */
25
+export function timestampString() {
26
+    const timeStampInMs = window.performance
27
+        && window.performance.now
28
+        && window.performance.timing
29
+        && window.performance.timing.navigationStart
30
+        ? window.performance.now() + window.performance.timing.navigationStart
31
+        : Date.now();
32
+
33
+    return timeStampInMs.toString();
34
+}

+ 284
- 0
react/features/local-recording/recording/WavAdapter.js Näytä tiedosto

@@ -0,0 +1,284 @@
1
+import { RecordingAdapter } from './RecordingAdapter';
2
+import { downloadBlob, timestampString } from './Utils';
3
+
4
+const logger = require('jitsi-meet-logger').getLogger(__filename);
5
+
6
+const WAV_BITS_PER_SAMPLE = 16;
7
+const WAV_SAMPLE_RATE = 44100;
8
+
9
+/**
10
+ * Recording adapter for raw WAVE format.
11
+ */
12
+export class WavAdapter extends RecordingAdapter {
13
+
14
+    _audioContext = null;
15
+    _audioProcessingNode = null;
16
+    _audioSource = null;
17
+
18
+    _wavLength = 0;
19
+    _wavBuffers = [];
20
+    _isInitialized = false;
21
+
22
+    /**
23
+     * Constructor.
24
+     *
25
+     */
26
+    constructor() {
27
+        super();
28
+
29
+        this._saveWavPCM = this._saveWavPCM.bind(this);
30
+    }
31
+
32
+    /**
33
+     * Implements {@link RecordingAdapter#ensureInitialized()}.
34
+     *
35
+     * @inheritdoc
36
+     */
37
+    ensureInitialized() {
38
+        if (this._isInitialized) {
39
+            return Promise.resolve();
40
+        }
41
+
42
+        const p = new Promise((resolve, reject) => {
43
+            navigator.getUserMedia(
44
+
45
+                // constraints - only audio needed for this app
46
+                {
47
+                    audioBitsPerSecond: WAV_SAMPLE_RATE * WAV_BITS_PER_SAMPLE,
48
+                    audio: true,
49
+                    mimeType: 'application/ogg' // useless?
50
+                },
51
+
52
+                // Success callback
53
+                stream => {
54
+                    this._audioContext = new AudioContext();
55
+                    this._audioSource
56
+                     = this._audioContext.createMediaStreamSource(stream);
57
+                    this._audioProcessingNode
58
+                      = this._audioContext.createScriptProcessor(4096, 1, 1);
59
+                    this._audioProcessingNode.onaudioprocess = e => {
60
+                        const channelLeft = e.inputBuffer.getChannelData(0);
61
+
62
+                        // https://developer.mozilla.org/en-US/docs/
63
+                        // Web/API/AudioBuffer/getChannelData
64
+                        // the returned value is an Float32Array
65
+                        this._saveWavPCM(channelLeft);
66
+                    };
67
+                    this._isInitialized = true;
68
+                    resolve();
69
+                },
70
+
71
+                // Error callback
72
+                err => {
73
+                    logger.error(`Error calling getUserMedia(): ${err}`);
74
+                    reject();
75
+                }
76
+            );
77
+        });
78
+
79
+        return p;
80
+    }
81
+
82
+    /**
83
+     * Implements {@link RecordingAdapter#start()}.
84
+     *
85
+     * @inheritdoc
86
+     */
87
+    start() {
88
+        return new Promise(
89
+            (resolve, /* eslint-disable */_reject/* eslint-enable */) => {
90
+                this._wavBuffers = [];
91
+                this._wavLength = 0;
92
+                this._wavBuffers.push(this._createWavHeader());
93
+
94
+                this._audioSource.connect(this._audioProcessingNode);
95
+                this._audioProcessingNode
96
+                    .connect(this._audioContext.destination);
97
+                resolve();
98
+            });
99
+    }
100
+
101
+    /**
102
+     * Implements {@link RecordingAdapter#stop()}.
103
+     *
104
+     * @inheritdoc
105
+     */
106
+    stop() {
107
+        this._audioProcessingNode.disconnect();
108
+        this._audioSource.disconnect();
109
+        this._data = this._exportMonoWAV(this._wavBuffers, this._wavLength);
110
+
111
+        return Promise.resolve();
112
+    }
113
+
114
+    /**
115
+     * Implements {@link RecordingAdapter#download()}.
116
+     *
117
+     * @inheritdoc
118
+     */
119
+    download() {
120
+        if (this._data !== null) {
121
+            const audioURL = window.URL.createObjectURL(this._data);
122
+
123
+            downloadBlob(audioURL, `recording${timestampString()}.wav`);
124
+        }
125
+
126
+    }
127
+
128
+    /**
129
+     * Creates a WAVE file header.
130
+     *
131
+     * @private
132
+     * @returns {Uint8Array}
133
+     */
134
+    _createWavHeader() {
135
+        // adapted from
136
+        // https://github.com/mmig/speech-to-flac/blob/master/encoder.js
137
+
138
+        // ref: http://soundfile.sapp.org/doc/WaveFormat/
139
+
140
+        // create our WAVE file header
141
+        const buffer = new ArrayBuffer(44);
142
+        const view = new DataView(buffer);
143
+
144
+        // RIFF chunk descriptor
145
+        writeUTFBytes(view, 0, 'RIFF');
146
+
147
+        // set file size at the end
148
+        writeUTFBytes(view, 8, 'WAVE');
149
+
150
+        // FMT sub-chunk
151
+        writeUTFBytes(view, 12, 'fmt ');
152
+        view.setUint32(16, 16, true);
153
+        view.setUint16(20, 1, true);
154
+
155
+        // NumChannels
156
+        view.setUint16(22, 1, true);
157
+
158
+        // SampleRate
159
+        view.setUint32(24, WAV_SAMPLE_RATE, true);
160
+
161
+        // ByteRate
162
+        view.setUint32(28,
163
+            Number(WAV_SAMPLE_RATE) * 1 * WAV_BITS_PER_SAMPLE / 8, true);
164
+
165
+        // BlockAlign
166
+        view.setUint16(32, 1 * Number(WAV_BITS_PER_SAMPLE) / 8, true);
167
+
168
+        view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
169
+
170
+        // data sub-chunk
171
+        writeUTFBytes(view, 36, 'data');
172
+
173
+        // DUMMY file length (set real value on export)
174
+        view.setUint32(4, 10, true);
175
+
176
+        // DUMMY data chunk length (set real value on export)
177
+        view.setUint32(40, 10, true);
178
+
179
+        return new Uint8Array(buffer);
180
+    }
181
+
182
+
183
+    /**
184
+     * Callback function that saves the PCM bits.
185
+     *
186
+     * @private
187
+     * @param {Float32Array} data - The audio PCM data.
188
+     * @returns {void}
189
+     */
190
+    _saveWavPCM(data) {
191
+        // need to copy the Float32Array,
192
+        // unlike passing to WebWorker,
193
+        // this data is passed by reference,
194
+        // so we need to copy it, otherwise the
195
+        // audio file will be just repeating the last
196
+        // segment.
197
+        this._wavBuffers.push(new Float32Array(data));
198
+        this._wavLength += data.length;
199
+    }
200
+
201
+    /**
202
+     * Combines buffers and export to a wav file.
203
+     *
204
+     * @private
205
+     * @param {*} buffers - The stored buffers.
206
+     * @param {*} length - Total length (in bytes).
207
+     * @returns {Blob}
208
+     */
209
+    _exportMonoWAV(buffers, length) {
210
+        // buffers: array with
211
+        //  buffers[0] = header information (with missing length information)
212
+        //  buffers[1] = Float32Array object (audio data)
213
+        //  ...
214
+        //  buffers[n] = Float32Array object (audio data)
215
+
216
+        const dataLength = length * 2; // why multiply by 2 here?
217
+        const buffer = new ArrayBuffer(44 + dataLength);
218
+        const view = new DataView(buffer);
219
+
220
+        // copy WAV header data into the array buffer
221
+        const header = buffers[0];
222
+        const len = header.length;
223
+
224
+        for (let i = 0; i < len; ++i) {
225
+            view.setUint8(i, header[i]);
226
+        }
227
+
228
+        // add file length in header
229
+        view.setUint32(4, 32 + dataLength, true);
230
+
231
+        // add data chunk length in header
232
+        view.setUint32(40, dataLength, true);
233
+
234
+        // write audio data
235
+        floatTo16BitPCM(view, 44, buffers);
236
+
237
+        return new Blob([ view ], { type: 'audio/wav' });
238
+    }
239
+}
240
+
241
+
242
+/**
243
+ * Helper function. Writes a UTF string to memory
244
+ * using big endianness. Required by WAVE headers.
245
+ *
246
+ * @param {ArrayBuffer} view - The view to memory.
247
+ * @param {*} offset - Offset.
248
+ * @param {*} string - The string to be written.
249
+ * @returns {void}
250
+ */
251
+function writeUTFBytes(view, offset, string) {
252
+    const lng = string.length;
253
+
254
+    // convert to big endianness
255
+    for (let i = 0; i < lng; ++i) {
256
+        view.setUint8(offset + i, string.charCodeAt(i));
257
+    }
258
+}
259
+
260
+/**
261
+ * Helper function for converting Float32Array to Int16Array.
262
+ *
263
+ * @param {*} output - The output buffer.
264
+ * @param {*} offset - The offset in output buffer to write from.
265
+ * @param {*} inputBuffers - The input buffers.
266
+ * @returns {void}
267
+ */
268
+function floatTo16BitPCM(output, offset, inputBuffers) {
269
+
270
+    let i, input, isize, s;
271
+    const jsize = inputBuffers.length;
272
+    let o = offset;
273
+
274
+    // first entry is header information (already used in exportMonoWAV),
275
+    // rest is Float32Array-entries -> ignore header entry
276
+    for (let j = 1; j < jsize; ++j) {
277
+        input = inputBuffers[j];
278
+        isize = input.length;
279
+        for (i = 0; i < isize; ++i, o += 2) {
280
+            s = Math.max(-1, Math.min(1, input[i]));
281
+            output.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
282
+        }
283
+    }
284
+}

+ 170
- 0
react/features/local-recording/recording/flac/FlacAdapter.js Näytä tiedosto

@@ -0,0 +1,170 @@
1
+import { RecordingAdapter } from '../RecordingAdapter';
2
+import { downloadBlob, timestampString } from '../Utils';
3
+import {
4
+    DEBUG,
5
+    MAIN_THREAD_FINISH,
6
+    MAIN_THREAD_INIT,
7
+    MAIN_THREAD_NEW_DATA_ARRIVED,
8
+    WORKER_BLOB_READY,
9
+    WORKER_LIBFLAC_READY
10
+} from './messageTypes';
11
+
12
+const logger = require('jitsi-meet-logger').getLogger(__filename);
13
+
14
+/**
15
+ * Recording adapter that uses libflac in the background
16
+ */
17
+export class FlacAdapter extends RecordingAdapter {
18
+
19
+    _encoder = null;
20
+    _audioContext = null;
21
+    _audioProcessingNode = null;
22
+    _audioSource = null;
23
+
24
+    _stopPromiseResolver = null;
25
+
26
+    /**
27
+     * Implements {@link RecordingAdapter#ensureInitialized}.
28
+     *
29
+     * @inheritdoc
30
+     */
31
+    ensureInitialized() {
32
+        if (this._encoder !== null) {
33
+            return Promise.resolve();
34
+        }
35
+
36
+        const promiseInitWorker = new Promise((resolve, reject) => {
37
+            // FIXME: workaround for different file names in development/
38
+            // production environments.
39
+            // We cannot import flacEncodeWorker as a webpack module,
40
+            // because it is in a different bundle and should be lazy-loaded
41
+            // only when flac recording is in use.
42
+            try {
43
+                // try load the minified version first
44
+                this._encoder = new Worker('/libs/flacEncodeWorker.min.js');
45
+            } catch (exception1) {
46
+                // if failed, try un minified version
47
+                try {
48
+                    this._encoder = new Worker('/libs/flacEncodeWorker.js');
49
+                } catch (exception2) {
50
+                    logger.error('Failed to load flacEncodeWorker.');
51
+                    reject();
52
+                }
53
+            }
54
+
55
+            // set up listen for messages from the WebWorker
56
+            this._encoder.onmessage = e => {
57
+                if (e.data.command === WORKER_BLOB_READY) {
58
+                    // receiving blob
59
+                    this._data = e.data.buf;
60
+                    if (this._stopPromiseResolver !== null) {
61
+                        this._stopPromiseResolver();
62
+                        this._stopPromiseResolver = null;
63
+                    }
64
+                } else if (e.data.command === DEBUG) {
65
+                    logger.log(e.data);
66
+                } else if (e.data.command === WORKER_LIBFLAC_READY) {
67
+                    logger.debug('libflac is ready.');
68
+                    resolve();
69
+                } else {
70
+                    logger.error(
71
+                        `Unknown event
72
+                        from encoder (WebWorker): "${e.data.command}"!`);
73
+                }
74
+            };
75
+
76
+            this._encoder.postMessage({
77
+                command: MAIN_THREAD_INIT,
78
+                config: {
79
+                    sampleRate: 44100,
80
+                    bps: 16
81
+                }
82
+            });
83
+        });
84
+
85
+        const callbackInitAudioContext = (resolve, reject) => {
86
+            navigator.getUserMedia(
87
+
88
+                // constraints - only audio needed for this app
89
+                {
90
+                    audioBitsPerSecond: 44100, // 44 kbps
91
+                    audio: true,
92
+                    mimeType: 'application/ogg' // useless?
93
+                },
94
+
95
+                // Success callback
96
+                stream => {
97
+                    this._audioContext = new AudioContext();
98
+                    this._audioSource
99
+                     = this._audioContext.createMediaStreamSource(stream);
100
+                    this._audioProcessingNode
101
+                      = this._audioContext.createScriptProcessor(4096, 1, 1);
102
+                    this._audioProcessingNode.onaudioprocess = e => {
103
+                        // delegate to the WebWorker to do the encoding
104
+                        const channelLeft = e.inputBuffer.getChannelData(0);
105
+
106
+                        this._encoder.postMessage({
107
+                            command: MAIN_THREAD_NEW_DATA_ARRIVED,
108
+                            buf: channelLeft
109
+                        });
110
+                    };
111
+                    logger.debug('AudioContext is set up.');
112
+                    resolve();
113
+                },
114
+
115
+                // Error callback
116
+                err => {
117
+                    logger.error(`Error calling getUserMedia(): ${err}`);
118
+                    reject();
119
+                }
120
+            );
121
+        };
122
+
123
+        // FIXME: because Promise constructor immediately executes the executor
124
+        // function. This is undesirable, we want callbackInitAudioContext to be
125
+        // executed only **after** promiseInitWorker is resolved.
126
+        return promiseInitWorker
127
+            .then(() => new Promise(callbackInitAudioContext));
128
+    }
129
+
130
+    /**
131
+     * Implements {@link RecordingAdapter#start()}.
132
+     *
133
+     * @inheritdoc
134
+     */
135
+    start() {
136
+        this._audioSource.connect(this._audioProcessingNode);
137
+        this._audioProcessingNode.connect(this._audioContext.destination);
138
+    }
139
+
140
+    /**
141
+     * Implements {@link RecordingAdapter#stop()}.
142
+     *
143
+     * @inheritdoc
144
+     */
145
+    stop() {
146
+        return new Promise(resolve => {
147
+            this._audioProcessingNode.onaudioprocess = undefined;
148
+            this._audioProcessingNode.disconnect();
149
+            this._audioSource.disconnect();
150
+            this._stopPromiseResolver = resolve;
151
+            this._encoder.postMessage({
152
+                command: MAIN_THREAD_FINISH
153
+            });
154
+        });
155
+    }
156
+
157
+    /**
158
+     * Implements {@link RecordingAdapter#download()}.
159
+     *
160
+     * @inheritdoc
161
+     */
162
+    download() {
163
+        if (this._data !== null) {
164
+            const audioURL = window.URL.createObjectURL(this._data);
165
+
166
+            downloadBlob(audioURL, `recording${timestampString()}.flac`);
167
+        }
168
+
169
+    }
170
+}

+ 416
- 0
react/features/local-recording/recording/flac/flacEncodeWorker.js Näytä tiedosto

@@ -0,0 +1,416 @@
1
+import {
2
+    MAIN_THREAD_FINISH,
3
+    MAIN_THREAD_INIT,
4
+    MAIN_THREAD_NEW_DATA_ARRIVED,
5
+    WORKER_BLOB_READY,
6
+    WORKER_LIBFLAC_READY
7
+} from './messageTypes';
8
+
9
+/**
10
+ * WebWorker that does FLAC encoding using libflac.js
11
+ */
12
+
13
+/* eslint-disable */
14
+importScripts('/libs/libflac3-1.3.2.min.js');
15
+/* eslint-enable */
16
+
17
+// There is a number of API calls to libflac.js, which does not conform
18
+// to the camalCase naming convention, but we cannot change it.
19
+// So we disable the ESLint rule `new-cap` in this file.
20
+/* eslint-disable new-cap */
21
+
22
+// Flow will complain about the number keys in `FLAC_ERRORS,
23
+// ESLint will complain about the `declare` statement.
24
+// As the current workaround, add an exception for eslint.
25
+/* eslint-disable flowtype/no-types-missing-file-annotation*/
26
+declare var Flac: Object;
27
+
28
+const FLAC_ERRORS = {
29
+    // The encoder is in the normal OK state and
30
+    // samples can be processed.
31
+    0: 'FLAC__STREAM_ENCODER_OK',
32
+
33
+    // The encoder is in the
34
+    // uninitialized state one of the FLAC__stream_encoder_init_*() functions
35
+    // must be called before samples can be processed.
36
+    1: 'FLAC__STREAM_ENCODER_UNINITIALIZED',
37
+
38
+    // An error occurred in the underlying Ogg layer.
39
+    2: 'FLAC__STREAM_ENCODER_OGG_ERROR',
40
+
41
+    // An error occurred in the
42
+    // underlying verify stream decoder; check
43
+    // FLAC__stream_encoder_get_verify_decoder_state().
44
+    3: 'FLAC__STREAM_ENCODER_VERIFY_DECODER_ERROR',
45
+
46
+    // The verify decoder detected a mismatch between the
47
+    // original audio signal and the decoded audio signal.
48
+
49
+    4: 'FLAC__STREAM_ENCODER_VERIFY_MISMATCH_IN_AUDIO_DATA',
50
+
51
+    // One of the callbacks returned
52
+    // a fatal error.
53
+    5: 'FLAC__STREAM_ENCODER_CLIENT_ERROR',
54
+
55
+    // An I/O error occurred while
56
+    // opening/reading/writing a file. Check errno.
57
+
58
+    6: 'FLAC__STREAM_ENCODER_IO_ERROR',
59
+
60
+    // An error occurred while writing
61
+    // the stream; usually, the write_callback returned an error.
62
+    7: 'FLAC__STREAM_ENCODER_FRAMING_ERROR',
63
+
64
+    // Memory allocation failed.
65
+    8: 'FLAC__STREAM_ENCODER_MEMORY_ALLOCATION_ERROR'
66
+};
67
+
68
+/**
69
+ * States of the {@code Encoder}.
70
+ */
71
+const EncoderState = Object.freeze({
72
+    /**
73
+     * Initial state, when libflac.js is not initialized.
74
+     */
75
+    UNINTIALIZED: Symbol('uninitialized'),
76
+
77
+    /**
78
+     * Actively encoding new audio bits.
79
+     */
80
+    WORKING: Symbol('working'),
81
+
82
+    /**
83
+     * Encoding has finished and encoded bits are available.
84
+     */
85
+    FINISHED: Symbol('finished')
86
+});
87
+
88
+/**
89
+ * Default compression level.
90
+ */
91
+const FLAC_COMPRESSION_LEVEL = 5;
92
+
93
+/**
94
+ * Concat multiple Uint8Arrays into one.
95
+ *
96
+ * @param {Array} arrays - Array of Uint8 arrays.
97
+ * @param {*} totalLength - Total length of all Uint8Arrays.
98
+ * @returns {Uint8Array}
99
+ */
100
+function mergeUint8Arrays(arrays, totalLength) {
101
+    const result = new Uint8Array(totalLength);
102
+    let offset = 0;
103
+    const len = arrays.length;
104
+
105
+    for (let i = 0; i < len; i++) {
106
+        const buffer = arrays[i];
107
+
108
+        result.set(buffer, offset);
109
+        offset += buffer.length;
110
+    }
111
+
112
+    return result;
113
+}
114
+
115
+/**
116
+ * Wrapper class around libflac API.
117
+ */
118
+class Encoder {
119
+
120
+    /**
121
+     * Flac encoder instance ID. (As per libflac.js API).
122
+     * @private
123
+     */
124
+    _encoderId = 0;
125
+
126
+    /**
127
+     * Sample rate.
128
+     * @private
129
+     */
130
+    _sampleRate;
131
+
132
+    /**
133
+     * Bit depth (bits per sample).
134
+     * @private
135
+     */
136
+    _bitDepth;
137
+
138
+    /**
139
+     * Buffer size.
140
+     * @private
141
+     */
142
+    _bufferSize;
143
+
144
+    /**
145
+     * Buffers to store encoded bits temporarily.
146
+     */
147
+    _flacBuffers = [];
148
+
149
+    /**
150
+     * Length of encoded FLAC bits.
151
+     */
152
+    _flacLength = 0;
153
+
154
+    /**
155
+     * The current state of the {@code Encoder}.
156
+     */
157
+    _state = EncoderState.UNINTIALIZED;
158
+
159
+    /**
160
+     * The ready-for-grab downloadable blob.
161
+     */
162
+    _data = null;
163
+
164
+
165
+    /**
166
+     * Constructor.
167
+     * Note: only create instance when Flac.isReady() returns true.
168
+     *
169
+     * @param {number} sampleRate - Sample rate of the raw audio data.
170
+     * @param {number} bitDepth - Bit depth (bit per sample).
171
+     * @param {number} bufferSize - The size of each batch.
172
+     */
173
+    constructor(sampleRate, bitDepth = 16, bufferSize = 4096) {
174
+        if (!Flac.isReady()) {
175
+            throw new Error('libflac is not ready yet!');
176
+        }
177
+
178
+        this._sampleRate = sampleRate;
179
+        this._bitDepth = bitDepth;
180
+        this._bufferSize = bufferSize;
181
+
182
+        // create the encoder
183
+        this._encoderId = Flac.init_libflac_encoder(
184
+            this._sampleRate,
185
+
186
+            // Mono channel
187
+            1,
188
+            this._bitDepth,
189
+
190
+            FLAC_COMPRESSION_LEVEL,
191
+
192
+            // Pass 0 in becuase of unknown total samples,
193
+            0,
194
+
195
+            // checksum, FIXME: double-check whether this is necessary
196
+            true,
197
+
198
+            // Auto-determine block size (samples per frame)
199
+            0
200
+        );
201
+
202
+        if (this._encoderId === 0) {
203
+            throw new Error('Failed to create libflac encoder.');
204
+        }
205
+
206
+        // initialize the encoder
207
+        const initResult = Flac.init_encoder_stream(
208
+            this._encoderId,
209
+            this._onEncodedData.bind(this),
210
+            this._onMetadataAvailable.bind(this)
211
+        );
212
+
213
+        if (initResult !== 0) {
214
+            throw new Error('Failed to initalise libflac encoder.');
215
+        }
216
+
217
+        this._state = EncoderState.WORKING;
218
+    }
219
+
220
+    /**
221
+     * Receive and encode new data.
222
+     *
223
+     * @param {*} audioData - Raw audio data.
224
+     * @returns {void}
225
+     */
226
+    encode(audioData) {
227
+        if (this._state !== EncoderState.WORKING) {
228
+            throw new Error('Encoder is not ready or has finished.');
229
+        }
230
+
231
+        if (!Flac.isReady()) {
232
+            throw new Error('Flac not ready');
233
+        }
234
+        const bufferLength = audioData.length;
235
+
236
+        // convert to Uint32,
237
+        // appearantly libflac requires 32-bit signed integer input
238
+        // FIXME: why unsigned 32bit array?
239
+        const bufferI32 = new Int32Array(bufferLength);
240
+        const view = new DataView(bufferI32.buffer);
241
+        const volume = 1;
242
+        let index = 0;
243
+
244
+        for (let i = 0; i < bufferLength; i++) {
245
+            view.setInt32(index, audioData[i] * (0x7FFF * volume), true);
246
+            index += 4; // 4 bytes (32bit)
247
+        }
248
+
249
+        // pass it to libflac
250
+        const status = Flac.FLAC__stream_encoder_process_interleaved(
251
+            this._encoderId,
252
+            bufferI32,
253
+            bufferI32.length
254
+        );
255
+
256
+        if (status !== 1) {
257
+            // get error
258
+
259
+            const errorNo
260
+                = Flac.FLAC__stream_encoder_get_state(this._encoderId);
261
+
262
+            console.error('Error during encoding', FLAC_ERRORS[errorNo]);
263
+        }
264
+    }
265
+
266
+    /**
267
+     * Signals the termination of encoding.
268
+     *
269
+     * @returns {void}
270
+     */
271
+    finish() {
272
+        if (this._state === EncoderState.WORKING) {
273
+            this._state = EncoderState.FINISHED;
274
+
275
+            const status = Flac.FLAC__stream_encoder_finish(this._encoderId);
276
+
277
+            console.log('flac encoding finish: ', status);
278
+
279
+            // free up resources
280
+            Flac.FLAC__stream_encoder_delete(this._encoderId);
281
+
282
+            this._data = this._exportFlacBlob();
283
+        }
284
+    }
285
+
286
+    /**
287
+     * Gets the stats.
288
+     *
289
+     * @returns {Object}
290
+     */
291
+    getStats() {
292
+        return {
293
+            'samplesEncoded': this._bufferSize
294
+        };
295
+    }
296
+
297
+    /**
298
+     * Gets the encoded flac file.
299
+     *
300
+     * @returns {Blob} - The encoded flac file.
301
+     */
302
+    getBlob() {
303
+        if (this._state === EncoderState.FINISHED) {
304
+            return this._data;
305
+        }
306
+
307
+        return null;
308
+    }
309
+
310
+    /**
311
+     * Converts flac buffer to a Blob.
312
+     *
313
+     * @private
314
+     * @returns {void}
315
+     */
316
+    _exportFlacBlob() {
317
+        const samples = mergeUint8Arrays(this._flacBuffers, this._flacLength);
318
+
319
+        const blob = new Blob([ samples ], { type: 'audio/flac' });
320
+
321
+        return blob;
322
+    }
323
+
324
+    /* eslint-disable no-unused-vars */
325
+    /**
326
+     * Callback function for saving encoded Flac data.
327
+     * This is invoked by libflac.
328
+     *
329
+     * @private
330
+     * @param {*} buffer - The encoded Flac data.
331
+     * @param {*} bytes - Number of bytes in the data.
332
+     * @returns {void}
333
+     */
334
+    _onEncodedData(buffer, bytes) {
335
+        this._flacBuffers.push(buffer);
336
+        this._flacLength += buffer.byteLength;
337
+    }
338
+    /* eslint-enable no-unused-vars */
339
+
340
+    /**
341
+     * Callback function for receiving metadata.
342
+     *
343
+     * @private
344
+     * @returns {void}
345
+     */
346
+    _onMetadataAvailable = () => {
347
+        // reserved for future use
348
+    }
349
+}
350
+
351
+
352
+let encoder = null;
353
+
354
+self.onmessage = function(e) {
355
+
356
+    switch (e.data.command) {
357
+    case MAIN_THREAD_INIT:
358
+    {
359
+        const bps = e.data.config.bps;
360
+        const sampleRate = e.data.config.sampleRate;
361
+
362
+        if (Flac.isReady()) {
363
+            encoder = new Encoder(sampleRate, bps);
364
+            self.postMessage({
365
+                command: WORKER_LIBFLAC_READY
366
+            });
367
+        } else {
368
+            Flac.onready = function() {
369
+                setTimeout(() => {
370
+                    encoder = new Encoder(sampleRate, bps);
371
+                    self.postMessage({
372
+                        command: WORKER_LIBFLAC_READY
373
+                    });
374
+                }, 0);
375
+            };
376
+        }
377
+        break;
378
+    }
379
+
380
+    case MAIN_THREAD_NEW_DATA_ARRIVED:
381
+        if (encoder === null) {
382
+            console
383
+                .error('flacEncoderWorker:'
384
+                + 'received data when the encoder is not ready.');
385
+        } else {
386
+            encoder.encode(e.data.buf);
387
+        }
388
+        break;
389
+
390
+    case MAIN_THREAD_FINISH:
391
+        if (encoder !== null) {
392
+            encoder.finish();
393
+            const data = encoder.getBlob();
394
+
395
+            self.postMessage(
396
+                {
397
+                    command: WORKER_BLOB_READY,
398
+                    buf: data
399
+                }
400
+            );
401
+            encoder = null;
402
+        }
403
+        break;
404
+    }
405
+};
406
+
407
+/**
408
+ * if(wavBuffers.length > 0){
409
+        //if there is buffered audio: encode buffered first (and clear buffer)
410
+        var len = wavBuffers.length;
411
+        var buffered = wavBuffers.splice(0, len);
412
+        for(var i=0; i < len; ++i){
413
+            doEncodeFlac(buffered[i]);
414
+        }
415
+    }
416
+ */

+ 1
- 0
react/features/local-recording/recording/flac/index.js Näytä tiedosto

@@ -0,0 +1 @@
1
+export * from './FlacAdapter';

+ 44
- 0
react/features/local-recording/recording/flac/messageTypes.js Näytä tiedosto

@@ -0,0 +1,44 @@
1
+/**
2
+ * Types of messages that are passed between the main thread and the WebWorker
3
+ * ({@code flacEncodeWorker})
4
+ */
5
+
6
+// Messages sent by the main thread
7
+
8
+/**
9
+ * Message type that signals the termination of encoding,
10
+ * after which no new audio bits should be sent to the
11
+ * WebWorker.
12
+ */
13
+export const MAIN_THREAD_FINISH = 'MAIN_THREAD_FINISH';
14
+
15
+/**
16
+ * Message type that carries initial parameters for
17
+ * the WebWorker.
18
+ */
19
+export const MAIN_THREAD_INIT = 'MAIN_THREAD_INIT';
20
+
21
+/**
22
+ * Message type that carries the newly received raw audio bits
23
+ * for the WebWorker to encode.
24
+ */
25
+export const MAIN_THREAD_NEW_DATA_ARRIVED = 'MAIN_THREAD_NEW_DATA_ARRIVED';
26
+
27
+// Messages sent by the WebWorker
28
+
29
+/**
30
+ * Message type that signals libflac is ready to receive audio bits.
31
+ */
32
+export const WORKER_LIBFLAC_READY = 'WORKER_LIBFLAC_READY';
33
+
34
+/**
35
+ * Message type that carries the encoded FLAC file as a Blob.
36
+ */
37
+export const WORKER_BLOB_READY = 'WORKER_BLOB_READY';
38
+
39
+// Messages sent by either the main thread or the WebWorker
40
+
41
+/**
42
+ * Debug messages.
43
+ */
44
+export const DEBUG = 'DEBUG';

+ 4
- 0
react/features/local-recording/recording/index.js Näytä tiedosto

@@ -0,0 +1,4 @@
1
+export * from './RecordingAdapter';
2
+export * from './flac';
3
+export * from './OggAdapter';
4
+export * from './WavAdapter';

+ 46
- 0
react/features/local-recording/reducer.js Näytä tiedosto

@@ -0,0 +1,46 @@
1
+/* @flow */
2
+
3
+import { ReducerRegistry } from '../base/redux';
4
+import {
5
+    LOCAL_RECORDING_ENGAGED,
6
+    LOCAL_RECORDING_STATS_UPDATE,
7
+    LOCAL_RECORDING_TOGGLE_DIALOG,
8
+    LOCAL_RECORDING_UNENGAGED
9
+} from './actionTypes';
10
+import { recordingController } from './controller';
11
+
12
+const logger = require('jitsi-meet-logger').getLogger(__filename);
13
+
14
+ReducerRegistry.register('features/local-recording', (state = {}, action) => {
15
+    logger.debug(`Redux state (features/local-recording):\n ${
16
+        JSON.stringify(state)}`);
17
+    switch (action.type) {
18
+    case LOCAL_RECORDING_ENGAGED: {
19
+        return {
20
+            ...state,
21
+            isEngaged: true,
22
+            recordingStartedAt: new Date(Date.now()),
23
+            encodingFormat: recordingController._format
24
+        };
25
+    }
26
+    case LOCAL_RECORDING_UNENGAGED:
27
+        return {
28
+            ...state,
29
+            isEngaged: false,
30
+            recordingStartedAt: null
31
+        };
32
+    case LOCAL_RECORDING_TOGGLE_DIALOG:
33
+        return {
34
+            ...state,
35
+            showDialog: state.showDialog === undefined
36
+                || state.showDialog === false
37
+        };
38
+    case LOCAL_RECORDING_STATS_UPDATE:
39
+        return {
40
+            ...state,
41
+            stats: action.stats
42
+        };
43
+    default:
44
+        return state;
45
+    }
46
+});

+ 29
- 0
react/features/toolbox/components/web/Toolbox.js Näytä tiedosto

@@ -28,6 +28,10 @@ import {
28 28
     isDialOutEnabled
29 29
 } from '../../../invite';
30 30
 import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts';
31
+import {
32
+    toggleLocalRecordingInfoDialog,
33
+    LocalRecordingButton
34
+} from '../../../local-recording';
31 35
 import {
32 36
     LiveStreamButton,
33 37
     RecordButton
@@ -148,6 +152,8 @@ type Props = {
148 152
      */
149 153
     _sharingVideo: boolean,
150 154
 
155
+    _localRecState: Object,
156
+
151 157
     /**
152 158
      * Whether or not transcribing is enabled.
153 159
      */
@@ -158,6 +164,8 @@ type Props = {
158 164
      */
159 165
     _visible: boolean,
160 166
 
167
+    _localRecState: any,
168
+
161 169
     /**
162 170
      * Set with the buttons which this Toolbox should display.
163 171
      */
@@ -227,6 +235,8 @@ class Toolbox extends Component<Props> {
227 235
             = this._onToolbarToggleScreenshare.bind(this);
228 236
         this._onToolbarToggleSharedVideo
229 237
             = this._onToolbarToggleSharedVideo.bind(this);
238
+        this._onToolbarToggleLocalRecordingInfoDialog
239
+            = this._onToolbarToggleLocalRecordingInfoDialog.bind(this);
230 240
     }
231 241
 
232 242
     /**
@@ -369,6 +379,11 @@ class Toolbox extends Component<Props> {
369 379
                         visible = { this._shouldShowButton('camera') } />
370 380
                 </div>
371 381
                 <div className = 'button-group-right'>
382
+                    <LocalRecordingButton
383
+                        isDialogShown = { this.props._localRecState.showDialog }
384
+                        onClick = {
385
+                            this._onToolbarToggleLocalRecordingInfoDialog
386
+                        } />
372 387
                     { this._shouldShowButton('invite')
373 388
                         && !_hideInviteButton
374 389
                         && <ToolbarButton
@@ -839,6 +854,18 @@ class Toolbox extends Component<Props> {
839 854
         this._doToggleSharedVideo();
840 855
     }
841 856
 
857
+    _onToolbarToggleLocalRecordingInfoDialog: () => void;
858
+
859
+    /**
860
+     * Switches local recording on or off.
861
+     *
862
+     * @private
863
+     * @returns {void}
864
+     */
865
+    _onToolbarToggleLocalRecordingInfoDialog() {
866
+        this.props.dispatch(toggleLocalRecordingInfoDialog());
867
+    }
868
+
842 869
     /**
843 870
      * Renders a button for toggleing screen sharing.
844 871
      *
@@ -1021,6 +1048,7 @@ function _mapStateToProps(state) {
1021 1048
     const localVideo = getLocalVideoTrack(state['features/base/tracks']);
1022 1049
     const addPeopleEnabled = isAddPeopleEnabled(state);
1023 1050
     const dialOutEnabled = isDialOutEnabled(state);
1051
+    const localRecordingStates = state['features/local-recording'];
1024 1052
 
1025 1053
     let desktopSharingDisabledTooltipKey;
1026 1054
 
@@ -1059,6 +1087,7 @@ function _mapStateToProps(state) {
1059 1087
         _fullScreen: fullScreen,
1060 1088
         _localParticipantID: localParticipant.id,
1061 1089
         _overflowMenuVisible: overflowMenuVisible,
1090
+        _localRecState: localRecordingStates,
1062 1091
         _raisedHand: localParticipant.raisedHand,
1063 1092
         _screensharing: localVideo && localVideo.videoType === 'desktop',
1064 1093
         _transcribingEnabled: transcribingEnabled,

+ 5
- 1
webpack.config.js Näytä tiedosto

@@ -149,7 +149,11 @@ module.exports = [
149 149
             ],
150 150
 
151 151
             'do_external_connect':
152
-                './connection_optimization/do_external_connect.js'
152
+                './connection_optimization/do_external_connect.js',
153
+
154
+            'flacEncodeWorker':
155
+                './react/features/local-recording/'
156
+                    + 'recording/flac/flacEncodeWorker.js'
153 157
         }
154 158
     }),
155 159
 

Loading…
Peruuta
Tallenna