Переглянути джерело

Collect dominant speaker times

dev1
Leonard Kim 8 роки тому
джерело
коміт
e709caaa7c

+ 15
- 0
JitsiConference.js Переглянути файл

@@ -24,8 +24,11 @@ import TalkMutedDetection from './modules/TalkMutedDetection';
24 24
 import Transcriber from './modules/transcription/transcriber';
25 25
 import VideoType from './service/RTC/VideoType';
26 26
 
27
+import SpeakerStatsCollector from './modules/statistics/SpeakerStatsCollector';
28
+
27 29
 const logger = getLogger(__filename);
28 30
 
31
+
29 32
 /**
30 33
  * Creates a JitsiConference object with the given name and properties.
31 34
  * Note: this constructor is not a part of the public API (objects should be
@@ -93,6 +96,10 @@ function JitsiConference(options) {
93 96
      */
94 97
     this.connectionIsInterrupted = false;
95 98
 
99
+    /**
100
+     * The object which tracks active speaker times
101
+     */
102
+    this.speakerStatsCollector = new SpeakerStatsCollector(this);
96 103
 }
97 104
 
98 105
 /**
@@ -1626,4 +1633,12 @@ JitsiConference.prototype.isConnectionInterrupted = function() {
1626 1633
     return this.connectionIsInterrupted;
1627 1634
 };
1628 1635
 
1636
+/**
1637
+ * Get a summary of how long current participants have been the dominant speaker
1638
+ * @returns {object}
1639
+ */
1640
+JitsiConference.prototype.getSpeakerStats = function() {
1641
+    return this.speakerStatsCollector.getStats();
1642
+};
1643
+
1629 1644
 module.exports = JitsiConference;

+ 132
- 0
modules/statistics/SpeakerStats.js Переглянути файл

@@ -0,0 +1,132 @@
1
+/**
2
+ * A model for keeping track of each user's total
3
+ * time as a dominant speaker. The model also
4
+ * keeps track of the user's last known name
5
+ * in case the user has left the meeting,
6
+ * which is also tracked.
7
+ */
8
+class SpeakerStats {
9
+    /**
10
+     * Initializes a new SpeakerStats instance.
11
+     *
12
+     * @constructor
13
+     * @param {string} userId - The id of the user being tracked.
14
+     * @param {string} displayName - The name of the user being tracked.
15
+     * @param {boolean} isLocalStats - True if the stats model tracks
16
+     * the local user.
17
+     * @returns {void}
18
+     */
19
+    constructor(userId, displayName, isLocalStats) {
20
+        this._userId = userId;
21
+        this.setDisplayName(displayName);
22
+        this._isLocalStats = isLocalStats || false;
23
+        this.setIsDominantSpeaker(false);
24
+        this.totalDominantSpeakerTime = 0;
25
+        this._dominantSpeakerStart = null;
26
+        this._hasLeft = false;
27
+    }
28
+
29
+    /**
30
+     * Get the user id being tracked.
31
+     *
32
+     * @returns {string} The user id.
33
+     */
34
+    getUserId() {
35
+        return this._userId;
36
+    }
37
+
38
+    /**
39
+     * Get the name of the user being tracked.
40
+     *
41
+     * @returns {string} The user name.
42
+     */
43
+    getDisplayName() {
44
+        return this.displayName;
45
+    }
46
+
47
+    /**
48
+     * Updates the last known name of the user being tracked.
49
+     *
50
+     * @param {string} - The user name.
51
+     * @returns {void}
52
+     */
53
+    setDisplayName(newName) {
54
+        this.displayName = newName;
55
+    }
56
+
57
+    /**
58
+     * Returns true if the stats are tracking the local user.
59
+     *
60
+     * @returns {boolean}
61
+     */
62
+    isLocalStats() {
63
+        return this._isLocalStats;
64
+    }
65
+
66
+    /**
67
+     * Returns true if the tracked user is currently a dominant speaker.
68
+     *
69
+     * @returns {boolean}
70
+     */
71
+    isDominantSpeaker() {
72
+        return this._isDominantSpeaker;
73
+    }
74
+
75
+    /**
76
+     * Returns true if the tracked user is currently a dominant speaker.
77
+     *
78
+     * @param {boolean} - If true, the user will being accumulating time
79
+     * as dominant speaker. If false, the user will not accumulate time
80
+     * and will record any time accumulated since starting as dominant speaker.
81
+     * @returns {void}
82
+     */
83
+    setIsDominantSpeaker(isNowDominantSpeaker) {
84
+        if (!this._isDominantSpeaker && isNowDominantSpeaker) {
85
+            this._dominantSpeakerStart = Date.now();
86
+        } else if (this._isDominantSpeaker && !isNowDominantSpeaker) {
87
+            const now = Date.now();
88
+            const timeElapsed = now - (this._dominantSpeakerStart || 0);
89
+
90
+            this.totalDominantSpeakerTime += timeElapsed;
91
+            this._dominantSpeakerStart = null;
92
+        }
93
+
94
+        this._isDominantSpeaker = isNowDominantSpeaker;
95
+    }
96
+
97
+    /**
98
+     * Get how long the tracked user has been dominant speaker.
99
+     *
100
+     * @returns {number} - The speaker time in milliseconds.
101
+     */
102
+    getTotalDominantSpeakerTime() {
103
+        let total = this.totalDominantSpeakerTime;
104
+
105
+        if (this._isDominantSpeaker) {
106
+            total += Date.now() - this._dominantSpeakerStart;
107
+        }
108
+
109
+        return total;
110
+    }
111
+
112
+    /**
113
+     * Get whether or not the user is still in the meeting.
114
+     *
115
+     * @returns {boolean} True if the user is no longer in the meeting.
116
+     */
117
+    hasLeft() {
118
+        return this._hasLeft;
119
+    }
120
+
121
+    /**
122
+     * Set the user as having left the meeting.
123
+     *
124
+     * @returns {void}
125
+     */
126
+    markAsHasLeft() {
127
+        this._hasLeft = true;
128
+        this.setIsDominantSpeaker(false);
129
+    }
130
+}
131
+
132
+module.exports = SpeakerStats;

+ 60
- 0
modules/statistics/SpeakerStats.spec.js Переглянути файл

@@ -0,0 +1,60 @@
1
+import SpeakerStats from './SpeakerStats';
2
+
3
+describe('SpeakerStats', () => {
4
+    const mockUserId = 1;
5
+    const mockUserName = 'foo';
6
+    let speakerStats;
7
+
8
+    beforeEach(() => {
9
+        speakerStats = new SpeakerStats(mockUserId, mockUserName);
10
+    });
11
+
12
+    describe('markAsHasLeft', () => {
13
+        it('sets the user state as having left the meeting', () => {
14
+            speakerStats.markAsHasLeft();
15
+            expect(speakerStats.hasLeft()).toBe(true);
16
+        });
17
+
18
+        it('removes the user as a dominant speaker', () => {
19
+            speakerStats.setIsDominantSpeaker(true);
20
+            speakerStats.markAsHasLeft();
21
+            expect(speakerStats.isDominantSpeaker()).toBe(false);
22
+        });
23
+    });
24
+
25
+    describe('setDisplayName', () => {
26
+        it('updates the username', () => {
27
+            const newName = `new-${mockUserName}`;
28
+
29
+            speakerStats.setDisplayName(newName);
30
+            expect(speakerStats.getDisplayName()).toBe(newName);
31
+        });
32
+    });
33
+
34
+    describe('getTotalDominantSpeakerTime', () => {
35
+        const mockDate = new Date(2017, 1, 1);
36
+
37
+        beforeEach(() => {
38
+            jasmine.clock().install();
39
+            jasmine.clock().mockDate(mockDate);
40
+        });
41
+
42
+        afterEach(() => {
43
+            jasmine.clock().uninstall();
44
+        });
45
+
46
+        it('returns the total dominant speaker time', () => {
47
+            const domaintSpeakerEvents = 3;
48
+            const domaintSpeakerTime = 100;
49
+
50
+            for (let i = 0; i < domaintSpeakerEvents; i++) {
51
+                speakerStats.setIsDominantSpeaker(true);
52
+                jasmine.clock().tick(domaintSpeakerTime);
53
+                speakerStats.setIsDominantSpeaker(false);
54
+            }
55
+
56
+            expect(speakerStats.getTotalDominantSpeakerTime())
57
+              .toBe(domaintSpeakerTime * domaintSpeakerEvents);
58
+        });
59
+    });
60
+});

+ 123
- 0
modules/statistics/SpeakerStatsCollector.js Переглянути файл

@@ -0,0 +1,123 @@
1
+import * as JitsiConferenceEvents from '../../JitsiConferenceEvents';
2
+import SpeakerStats from './SpeakerStats';
3
+
4
+/**
5
+ * A collection for tracking speaker stats. Attaches listeners
6
+ * to the conference to automatically update on tracked events.
7
+ */
8
+class SpeakerStatsCollector {
9
+    /**
10
+     * Initializes a new SpeakerStatsCollector instance.
11
+     *
12
+     * @constructor
13
+     * @param {JitsiConference} conference - The conference to track.
14
+     * @returns {void}
15
+     */
16
+    constructor(conference) {
17
+        this.stats = {
18
+            users: {
19
+
20
+                // userId: SpeakerStats
21
+            },
22
+            dominantSpeakerId: null
23
+        };
24
+
25
+        const userId = conference.myUserId();
26
+
27
+        this.stats.users[userId] = new SpeakerStats(userId, null, true);
28
+
29
+        conference.addEventListener(
30
+            JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED,
31
+            this._onDominantSpeaker.bind(this));
32
+        conference.addEventListener(
33
+            JitsiConferenceEvents.USER_JOINED,
34
+            this._onUserJoin.bind(this));
35
+        conference.addEventListener(
36
+            JitsiConferenceEvents.USER_LEFT,
37
+            this._onUserLeave.bind(this));
38
+        conference.addEventListener(
39
+            JitsiConferenceEvents.DISPLAY_NAME_CHANGED,
40
+            this._onDisplayNameChange.bind(this));
41
+    }
42
+
43
+    /**
44
+     * Reacts to dominant speaker change events by changing its speaker stats
45
+     * models to reflect the current dominant speaker.
46
+     *
47
+     * @param {string} dominantSpeakerId - The user id of the new
48
+     * dominant speaker.
49
+     * @returns {void}
50
+     * @private
51
+     */
52
+    _onDominantSpeaker(dominantSpeakerId) {
53
+        const oldDominantSpeaker
54
+            = this.stats.users[this.stats.dominantSpeakerId];
55
+        const newDominantSpaker = this.stats.users[dominantSpeakerId];
56
+
57
+        oldDominantSpeaker && oldDominantSpeaker.setIsDominantSpeaker(false);
58
+        newDominantSpaker && newDominantSpaker.setIsDominantSpeaker(true);
59
+        this.stats.dominantSpeakerId = dominantSpeakerId;
60
+    }
61
+
62
+    /**
63
+     * Reacts to user join events by creating a new SpeakerStats model.
64
+     *
65
+     * @param {string} userId - The user id of the new user.
66
+     * @param {JitsiParticipant} - The JitsiParticipant model for the new user.
67
+     * @returns {void}
68
+     * @private
69
+     */
70
+    _onUserJoin(userId, participant) {
71
+        let savedUser = this.stats.users[userId];
72
+
73
+        if (!savedUser) {
74
+            savedUser = this.stats.users[userId]
75
+                = new SpeakerStats(userId, participant.getDisplayName());
76
+        }
77
+    }
78
+
79
+    /**
80
+     * Reacts to user leave events by updating the associated user's
81
+     * SpeakerStats model.
82
+     *
83
+     * @param {string} userId - The user id of the user that left.
84
+     * @returns {void}
85
+     * @private
86
+     */
87
+    _onUserLeave(userId) {
88
+        const savedUser = this.stats.users[userId];
89
+
90
+        if (savedUser) {
91
+            savedUser.markAsHasLeft();
92
+        }
93
+    }
94
+
95
+    /**
96
+     * Reacts to user name change events by updating the last known name
97
+     * tracked in the associated SpeakerStats model.
98
+     *
99
+     * @param {string} userId - The user id of the user that left.
100
+     * @returns {void}
101
+     * @private
102
+     */
103
+    _onDisplayNameChange(userId, newName) {
104
+        const savedUser = this.stats.users[userId];
105
+
106
+        if (savedUser) {
107
+            savedUser.setDisplayName(newName);
108
+        }
109
+    }
110
+
111
+    /**
112
+     * Return a copy of the tracked SpeakerStats models.
113
+     *
114
+     * @returns {Object} The keys are the user ids and the values are the
115
+     * associated user's SpeakerStats model.
116
+     * @private
117
+     */
118
+    getStats() {
119
+        return this.stats.users;
120
+    }
121
+}
122
+
123
+module.exports = SpeakerStatsCollector;

+ 126
- 0
modules/statistics/SpeakerStatsCollector.spec.js Переглянути файл

@@ -0,0 +1,126 @@
1
+import EventEmitter from 'events';
2
+import JitsiConference from '../../JitsiConference';
3
+import * as JitsiConferenceEvents from '../../JitsiConferenceEvents';
4
+import JitsiParticipant from '../../JitsiParticipant';
5
+import SpeakerStats from './SpeakerStats';
6
+import SpeakerStatsCollector from './SpeakerStatsCollector';
7
+
8
+const mockMyId = 1;
9
+const mockRemoteUser = {
10
+    id: 2,
11
+    name: 'foo'
12
+};
13
+
14
+/**
15
+ * Mock object to be used in place of a real conference.
16
+ *
17
+ * @constructor
18
+ */
19
+function MockConference() {
20
+    this.eventEmitter = new EventEmitter();
21
+}
22
+MockConference.prototype = Object.create(JitsiConference.prototype);
23
+MockConference.prototype.constructor = JitsiConference;
24
+
25
+/**
26
+ * Mock object to be used in place of a real JitsiParticipant.
27
+ *
28
+ * @constructor
29
+ * @param {string|number} id - An id for the mock user.
30
+ * @param {string} name - A name for the mock user.
31
+ * @returns {void}
32
+ */
33
+function MockJitsiParticipant(id, name) {
34
+    this._jid = id;
35
+    this._displayName = name;
36
+}
37
+MockJitsiParticipant.prototype = Object.create(JitsiParticipant.prototype);
38
+MockJitsiParticipant.prototype.constructor = JitsiParticipant;
39
+
40
+describe('SpeakerStatsCollector', () => {
41
+    let mockConference, speakerStatsCollector;
42
+
43
+    beforeEach(() => {
44
+        mockConference = new MockConference();
45
+        spyOn(mockConference, 'myUserId').and.returnValue(mockMyId);
46
+
47
+        speakerStatsCollector = new SpeakerStatsCollector(mockConference);
48
+
49
+        mockConference.eventEmitter.emit(
50
+            JitsiConferenceEvents.USER_JOINED,
51
+            mockRemoteUser.id,
52
+            new MockJitsiParticipant(mockRemoteUser.id, mockRemoteUser.name)
53
+        );
54
+    });
55
+
56
+    it('automatically adds the current user', () => {
57
+        const stats = speakerStatsCollector.getStats();
58
+        const currentUserStats = stats[mockMyId];
59
+
60
+        expect(currentUserStats instanceof SpeakerStats).toBe(true);
61
+    });
62
+
63
+    it('adds joined users to the stats', () => {
64
+        const stats = speakerStatsCollector.getStats();
65
+        const remoteUserStats = stats[mockRemoteUser.id];
66
+
67
+        expect(remoteUserStats).toBeTruthy();
68
+        expect(remoteUserStats instanceof SpeakerStats).toBe(true);
69
+        expect(remoteUserStats.getDisplayName()).toBe(mockRemoteUser.name);
70
+    });
71
+
72
+    describe('on user name change', () => {
73
+        it('updates the username', () => {
74
+            const newName = `new-${mockRemoteUser.name}`;
75
+
76
+            mockConference.eventEmitter.emit(
77
+                JitsiConferenceEvents.DISPLAY_NAME_CHANGED,
78
+                mockRemoteUser.id,
79
+                newName
80
+            );
81
+
82
+            const stats = speakerStatsCollector.getStats();
83
+            const remoteUserStats = stats[mockRemoteUser.id];
84
+
85
+            expect(remoteUserStats.getDisplayName()).toBe(newName);
86
+        });
87
+    });
88
+
89
+    describe('on user leave', () => {
90
+        it('retains the user stats but marks the user as left', () => {
91
+            mockConference.eventEmitter.emit(
92
+                JitsiConferenceEvents.USER_LEFT,
93
+                mockRemoteUser.id
94
+            );
95
+
96
+            const stats = speakerStatsCollector.getStats();
97
+            const remoteUserStats = stats[mockRemoteUser.id];
98
+
99
+            expect(remoteUserStats.hasLeft()).toBe(true);
100
+        });
101
+    });
102
+
103
+    describe('on dominant speaker change', () => {
104
+        it('updates models to reflect the new dominant speaker', () => {
105
+            const stats = speakerStatsCollector.getStats();
106
+            const remoteUserStats = stats[mockRemoteUser.id];
107
+            const currentUserStats = stats[mockMyId];
108
+
109
+            mockConference.eventEmitter.emit(
110
+                JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED,
111
+                mockRemoteUser.id
112
+            );
113
+
114
+            expect(remoteUserStats.isDominantSpeaker()).toBe(true);
115
+            expect(currentUserStats.isDominantSpeaker()).toBe(false);
116
+
117
+            mockConference.eventEmitter.emit(
118
+                JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED,
119
+                mockMyId
120
+            );
121
+
122
+            expect(remoteUserStats.isDominantSpeaker()).toBe(false);
123
+            expect(currentUserStats.isDominantSpeaker()).toBe(true);
124
+        });
125
+    });
126
+});

Завантаження…
Відмінити
Зберегти