@webex/internal-plugin-voicea 3.12.0-auth-prejoin-fetch.1 → 3.12.0-llmrefactor.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.js +4 -10
- package/dist/constants.js.map +1 -1
- package/dist/index.js +13 -4
- package/dist/index.js.map +1 -1
- package/dist/types/constants.d.ts +2 -1
- package/dist/types/index.d.ts +6 -3
- package/dist/types/voicea-plugin.d.ts +23 -0
- package/dist/types/voicea.d.ts +82 -31
- package/dist/types/voicea.types.d.ts +19 -1
- package/dist/voicea-plugin.js +59 -0
- package/dist/voicea-plugin.js.map +1 -0
- package/dist/voicea.js +257 -156
- package/dist/voicea.js.map +1 -1
- package/dist/voicea.types.js.map +1 -1
- package/package.json +9 -9
- package/src/constants.ts +2 -2
- package/src/index.ts +7 -5
- package/src/voicea-plugin.ts +33 -0
- package/src/voicea.ts +250 -159
- package/src/voicea.types.ts +22 -0
- package/test/unit/spec/voicea-plugin.js +83 -0
- package/test/unit/spec/voicea.js +837 -1126
package/test/unit/spec/voicea.js
CHANGED
|
@@ -4,75 +4,90 @@ import MockWebSocket from '@webex/test-helper-mock-web-socket';
|
|
|
4
4
|
import {assert, expect} from '@webex/test-helper-chai';
|
|
5
5
|
import sinon from 'sinon';
|
|
6
6
|
import Mercury from '@webex/internal-plugin-mercury';
|
|
7
|
-
import LLMChannel from '@webex/internal-plugin-llm';
|
|
8
7
|
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
import {VoiceaChannel} from '../../../src/voicea';
|
|
9
|
+
import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants';
|
|
10
|
+
|
|
11
|
+
const flushPromises = () => new Promise(setImmediate);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a mock LLM channel for testing
|
|
15
|
+
* @param {Object} [options] - Options for the mock channel
|
|
16
|
+
* @param {boolean} [options.isConnected=true] - Whether the channel is connected
|
|
17
|
+
* @param {string} [options.locusUrl] - The locus URL
|
|
18
|
+
* @returns {Object} Mock channel
|
|
19
|
+
*/
|
|
20
|
+
function createMockLLMChannel(options = {}) {
|
|
21
|
+
const mockWebSocket = new MockWebSocket();
|
|
22
|
+
const {isConnected = true, locusUrl = 'locusUrl'} = options;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
isConnected: sinon.stub().returns(isConnected),
|
|
26
|
+
getSocket: sinon.stub().returns(mockWebSocket),
|
|
27
|
+
getBinding: sinon.stub().returns('binding'),
|
|
28
|
+
getDatachannelUrl: sinon.stub().returns('datachannelUrl'),
|
|
29
|
+
getLocusUrl: sinon.stub().returns(locusUrl),
|
|
30
|
+
isDataChannelTokenEnabled: sinon.stub().resolves(true),
|
|
31
|
+
on: sinon.stub(),
|
|
32
|
+
off: sinon.stub(),
|
|
33
|
+
once: sinon.stub(),
|
|
34
|
+
socket: mockWebSocket,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
15
37
|
|
|
16
38
|
describe('plugin-voicea', () => {
|
|
17
39
|
const locusUrl = 'locusUrl';
|
|
18
40
|
|
|
19
|
-
describe('
|
|
20
|
-
let webex,
|
|
41
|
+
describe('VoiceaChannel', () => {
|
|
42
|
+
let webex, voiceaChannel, mockLLMChannel, requestStub;
|
|
21
43
|
|
|
22
44
|
beforeEach(() => {
|
|
23
45
|
webex = new MockWebex({
|
|
24
46
|
children: {
|
|
25
47
|
mercury: Mercury,
|
|
26
|
-
llm: LLMChannel,
|
|
27
|
-
voicea: VoiceaService,
|
|
28
48
|
},
|
|
29
49
|
});
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
voiceaService.connect = sinon.stub().resolves(true);
|
|
33
|
-
voiceaService.webex.internal.llm.isConnected = sinon.stub().returns(true);
|
|
34
|
-
voiceaService.webex.internal.llm.getBinding = sinon.stub().returns(undefined);
|
|
35
|
-
voiceaService.webex.internal.llm.getSocket = sinon.stub().returns(undefined);
|
|
36
|
-
voiceaService.webex.internal.llm.getLocusUrl = sinon.stub().returns(locusUrl);
|
|
37
|
-
|
|
38
|
-
voiceaService.request = sinon.stub().resolves({
|
|
51
|
+
requestStub = sinon.stub().resolves({
|
|
39
52
|
headers: {},
|
|
40
53
|
body: '',
|
|
41
54
|
});
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
webSocketUrl: 'url',
|
|
46
|
-
},
|
|
47
|
-
});
|
|
55
|
+
|
|
56
|
+
mockLLMChannel = createMockLLMChannel({locusUrl});
|
|
57
|
+
voiceaChannel = new VoiceaChannel(mockLLMChannel, requestStub);
|
|
48
58
|
});
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
voiceaChannel.deregisterEvents();
|
|
62
|
+
sinon.restore();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('#constructor', () => {
|
|
51
66
|
it('should init status', () => {
|
|
52
|
-
assert.equal(
|
|
53
|
-
assert.equal(
|
|
67
|
+
assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
|
|
68
|
+
assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
|
|
54
69
|
});
|
|
55
|
-
});
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
it('should subscribe to relay events when llmChannel is provided', () => {
|
|
72
|
+
assert.calledWith(mockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
73
|
+
});
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
it('should not subscribe to events when llmChannel is undefined', () => {
|
|
76
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
77
|
+
// No llmChannel means no subscription, just verify it doesn't throw
|
|
78
|
+
assert.equal(channelWithoutLLM.getAnnounceStatus(), 'idle');
|
|
63
79
|
});
|
|
80
|
+
});
|
|
64
81
|
|
|
82
|
+
describe('#sendAnnouncement', () => {
|
|
65
83
|
it("sends announcement if voicea hasn't joined", () => {
|
|
66
|
-
|
|
84
|
+
voiceaChannel.sendAnnouncement();
|
|
85
|
+
assert.equal(voiceaChannel.getAnnounceStatus(), 'joining');
|
|
67
86
|
|
|
68
|
-
|
|
69
|
-
assert.equal(voiceaService.announceStatus, 'joining');
|
|
70
|
-
assert.calledOnce(spy);
|
|
71
|
-
|
|
72
|
-
assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
|
|
87
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
73
88
|
id: '1',
|
|
74
89
|
type: 'publishRequest',
|
|
75
|
-
recipients: [{route:
|
|
90
|
+
recipients: [{route: 'binding'}],
|
|
76
91
|
headers: {},
|
|
77
92
|
data: {
|
|
78
93
|
clientPayload: {
|
|
@@ -85,31 +100,15 @@ describe('plugin-voicea', () => {
|
|
|
85
100
|
});
|
|
86
101
|
});
|
|
87
102
|
|
|
88
|
-
it('listens to events once', () => {
|
|
89
|
-
const spy = sinon.spy(webex.internal.llm, 'on');
|
|
90
|
-
|
|
91
|
-
voiceaService.sendAnnouncement();
|
|
92
|
-
|
|
93
|
-
voiceaService.sendAnnouncement();
|
|
94
|
-
|
|
95
|
-
assert.calledTwice(spy);
|
|
96
|
-
assert.calledWith(spy, 'event:relay.event', sinon.match.func);
|
|
97
|
-
assert.calledWith(spy, `event:relay.event:${LLM_PRACTICE_SESSION}`, sinon.match.func);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
103
|
it('includes captionServiceId in headers when set', () => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
104
|
-
voiceaService.announceStatus = 'idle';
|
|
105
|
-
voiceaService.captionServiceId = 'svc-123';
|
|
104
|
+
voiceaChannel.captionServiceId = 'svc-123';
|
|
106
105
|
|
|
107
|
-
|
|
106
|
+
voiceaChannel.sendAnnouncement();
|
|
108
107
|
|
|
109
|
-
assert.calledOnceWithExactly(
|
|
108
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
110
109
|
id: '1',
|
|
111
110
|
type: 'publishRequest',
|
|
112
|
-
recipients: [{route:
|
|
111
|
+
recipients: [{route: 'binding'}],
|
|
113
112
|
headers: {to: 'svc-123'},
|
|
114
113
|
data: {
|
|
115
114
|
clientPayload: {
|
|
@@ -124,49 +123,38 @@ describe('plugin-voicea', () => {
|
|
|
124
123
|
});
|
|
125
124
|
|
|
126
125
|
describe('#sendManualClosedCaption', () => {
|
|
127
|
-
beforeEach(async () => {
|
|
128
|
-
const mockWebSocket = new MockWebSocket();
|
|
129
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
130
|
-
voiceaService.seqNum = 1;
|
|
131
|
-
});
|
|
132
|
-
|
|
133
126
|
it('sends interim manual closed caption when connected', () => {
|
|
134
127
|
const text = 'Test interim caption';
|
|
135
128
|
const timeStamp = 1234567890;
|
|
136
129
|
const csis = [123456];
|
|
137
130
|
const isFinal = false;
|
|
138
131
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
assert.calledOnceWithExactly(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
],
|
|
162
|
-
transcript_id: sinon.match.string,
|
|
163
|
-
},
|
|
132
|
+
voiceaChannel.sendManualClosedCaption(text, timeStamp, csis, isFinal);
|
|
133
|
+
|
|
134
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
135
|
+
id: '1',
|
|
136
|
+
type: 'publishRequest',
|
|
137
|
+
recipients: [{route: 'binding'}],
|
|
138
|
+
headers: {},
|
|
139
|
+
data: {
|
|
140
|
+
eventType: 'relay.event',
|
|
141
|
+
relayType: 'client.manual_transcription',
|
|
142
|
+
transcriptPayload: {
|
|
143
|
+
type: 'manual_caption_interim_result',
|
|
144
|
+
id: sinon.match.string,
|
|
145
|
+
transcripts: [
|
|
146
|
+
{
|
|
147
|
+
text: 'Test interim caption',
|
|
148
|
+
start_millis: 1234567890,
|
|
149
|
+
end_millis: 1234567890,
|
|
150
|
+
csis: [123456],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
transcript_id: sinon.match.string,
|
|
164
154
|
},
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
);
|
|
168
|
-
// seqNum should increment
|
|
169
|
-
assert.equal(voiceaService.seqNum, 2);
|
|
155
|
+
},
|
|
156
|
+
trackingId: sinon.match.string,
|
|
157
|
+
});
|
|
170
158
|
});
|
|
171
159
|
|
|
172
160
|
it('sends final manual closed caption when connected', () => {
|
|
@@ -175,137 +163,87 @@ describe('plugin-voicea', () => {
|
|
|
175
163
|
const csis = [654321];
|
|
176
164
|
const isFinal = true;
|
|
177
165
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
assert.calledOnceWithExactly(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
],
|
|
201
|
-
transcript_id: sinon.match.string,
|
|
202
|
-
},
|
|
166
|
+
voiceaChannel.sendManualClosedCaption(text, timeStamp, csis, isFinal);
|
|
167
|
+
|
|
168
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
169
|
+
id: '1',
|
|
170
|
+
type: 'publishRequest',
|
|
171
|
+
recipients: [{route: 'binding'}],
|
|
172
|
+
headers: {},
|
|
173
|
+
data: {
|
|
174
|
+
eventType: 'relay.event',
|
|
175
|
+
relayType: 'client.manual_transcription',
|
|
176
|
+
transcriptPayload: {
|
|
177
|
+
type: 'manual_caption_final_result',
|
|
178
|
+
id: sinon.match.string,
|
|
179
|
+
transcripts: [
|
|
180
|
+
{
|
|
181
|
+
text: 'Test final caption',
|
|
182
|
+
start_millis: 9876543210,
|
|
183
|
+
end_millis: 9876543210,
|
|
184
|
+
csis: [654321],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
transcript_id: sinon.match.string,
|
|
203
188
|
},
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
207
|
-
// seqNum should increment
|
|
208
|
-
assert.equal(voiceaService.seqNum, 2);
|
|
189
|
+
},
|
|
190
|
+
trackingId: sinon.match.string,
|
|
191
|
+
});
|
|
209
192
|
});
|
|
210
193
|
|
|
211
194
|
it('does not send if not connected', () => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const text = 'Should not send';
|
|
215
|
-
const timeStamp = 111;
|
|
216
|
-
const csis = [1];
|
|
217
|
-
const isFinal = true;
|
|
195
|
+
const disconnectedChannel = createMockLLMChannel({isConnected: false});
|
|
196
|
+
const channel = new VoiceaChannel(disconnectedChannel, requestStub);
|
|
218
197
|
|
|
219
|
-
|
|
198
|
+
channel.sendManualClosedCaption('Should not send', 111, [1], true);
|
|
220
199
|
|
|
221
|
-
assert.notCalled(
|
|
200
|
+
assert.notCalled(disconnectedChannel.socket.send);
|
|
222
201
|
});
|
|
223
202
|
});
|
|
203
|
+
|
|
224
204
|
describe('#deregisterEvents', () => {
|
|
225
205
|
beforeEach(async () => {
|
|
226
|
-
|
|
227
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
228
|
-
voiceaService.keepTranscriptionSubscribed = true;
|
|
206
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
229
207
|
});
|
|
230
208
|
|
|
231
|
-
it('
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
headers: {from: 'ws'},
|
|
237
|
-
data: {relayType: 'voicea.annc', voiceaPayload: {}},
|
|
238
|
-
});
|
|
209
|
+
it('works when llmChannel is undefined', () => {
|
|
210
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
211
|
+
channelWithoutLLM.areCaptionsEnabled = true;
|
|
212
|
+
channelWithoutLLM.keepTranscriptionSubscribed = true;
|
|
213
|
+
channelWithoutLLM.hasSubscribedToEvents = true;
|
|
239
214
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
assert.equal(voiceaService.keepTranscriptionSubscribed, true);
|
|
215
|
+
// Should not throw
|
|
216
|
+
channelWithoutLLM.deregisterEvents();
|
|
243
217
|
|
|
244
|
-
|
|
245
|
-
assert.equal(
|
|
246
|
-
assert.equal(
|
|
247
|
-
assert.equal(voiceaService.announceStatus, 'idle');
|
|
248
|
-
assert.equal(voiceaService.captionStatus, 'idle');
|
|
249
|
-
assert.equal(voiceaService.keepTranscriptionSubscribed, false);
|
|
218
|
+
assert.equal(channelWithoutLLM.areCaptionsEnabled, false);
|
|
219
|
+
assert.equal(channelWithoutLLM.keepTranscriptionSubscribed, false);
|
|
220
|
+
assert.equal(channelWithoutLLM.hasSubscribedToEvents, false);
|
|
250
221
|
});
|
|
251
|
-
});
|
|
252
|
-
describe('#processAnnouncementMessage', () => {
|
|
253
|
-
it('works on non-empty payload', async () => {
|
|
254
|
-
const voiceaPayload = {
|
|
255
|
-
translation: {
|
|
256
|
-
allowed_languages: ['af', 'am'],
|
|
257
|
-
max_languages: 5,
|
|
258
|
-
},
|
|
259
|
-
ASR: {
|
|
260
|
-
spoken_languages: ['en'],
|
|
261
|
-
},
|
|
262
|
-
|
|
263
|
-
version: 'v2',
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const spy = sinon.spy();
|
|
267
222
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
captionLanguages: ['af', 'am'],
|
|
273
|
-
spokenLanguages: ['en'],
|
|
274
|
-
maxLanguages: 5,
|
|
275
|
-
currentSpokenLanguage: 'en',
|
|
276
|
-
});
|
|
277
|
-
});
|
|
223
|
+
it('deregisters voicea channel and resets state', () => {
|
|
224
|
+
voiceaChannel.areCaptionsEnabled = true;
|
|
225
|
+
voiceaChannel.captionServiceId = 'ws';
|
|
226
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
278
227
|
|
|
279
|
-
|
|
280
|
-
const spy = sinon.spy();
|
|
228
|
+
voiceaChannel.deregisterEvents();
|
|
281
229
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
assert.
|
|
287
|
-
|
|
288
|
-
spokenLanguages: [],
|
|
289
|
-
maxLanguages: 0,
|
|
290
|
-
currentSpokenLanguage: 'fr',
|
|
291
|
-
});
|
|
230
|
+
assert.equal(voiceaChannel.areCaptionsEnabled, false);
|
|
231
|
+
assert.equal(voiceaChannel.captionServiceId, undefined);
|
|
232
|
+
assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
|
|
233
|
+
assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
|
|
234
|
+
assert.equal(voiceaChannel.keepTranscriptionSubscribed, false);
|
|
235
|
+
assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
|
|
292
236
|
});
|
|
293
237
|
});
|
|
294
238
|
|
|
295
239
|
describe('#requestLanguage', () => {
|
|
296
|
-
beforeEach(async () => {
|
|
297
|
-
const mockWebSocket = new MockWebSocket();
|
|
298
|
-
|
|
299
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
300
|
-
});
|
|
301
|
-
|
|
302
240
|
it('requests caption language', () => {
|
|
303
|
-
|
|
241
|
+
voiceaChannel.requestLanguage('en');
|
|
304
242
|
|
|
305
|
-
assert.calledOnceWithExactly(
|
|
243
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
306
244
|
id: '1',
|
|
307
245
|
type: 'publishRequest',
|
|
308
|
-
recipients: [{route:
|
|
246
|
+
recipients: [{route: 'binding'}],
|
|
309
247
|
headers: {to: undefined},
|
|
310
248
|
data: {
|
|
311
249
|
clientPayload: {
|
|
@@ -320,14 +258,14 @@ describe('plugin-voicea', () => {
|
|
|
320
258
|
});
|
|
321
259
|
|
|
322
260
|
it('uses captionServiceId as "to" header when set', () => {
|
|
323
|
-
|
|
261
|
+
voiceaChannel.captionServiceId = 'svc-456';
|
|
324
262
|
|
|
325
|
-
|
|
263
|
+
voiceaChannel.requestLanguage('fr');
|
|
326
264
|
|
|
327
|
-
assert.calledOnceWithExactly(
|
|
265
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
328
266
|
id: '1',
|
|
329
267
|
type: 'publishRequest',
|
|
330
|
-
recipients: [{route:
|
|
268
|
+
recipients: [{route: 'binding'}],
|
|
331
269
|
headers: {to: 'svc-456'},
|
|
332
270
|
data: {
|
|
333
271
|
clientPayload: {
|
|
@@ -340,6 +278,15 @@ describe('plugin-voicea', () => {
|
|
|
340
278
|
trackingId: sinon.match.string,
|
|
341
279
|
});
|
|
342
280
|
});
|
|
281
|
+
|
|
282
|
+
it('does not send when not connected', () => {
|
|
283
|
+
const disconnectedChannel = createMockLLMChannel({isConnected: false});
|
|
284
|
+
const channel = new VoiceaChannel(disconnectedChannel, requestStub);
|
|
285
|
+
|
|
286
|
+
channel.requestLanguage('en');
|
|
287
|
+
|
|
288
|
+
assert.notCalled(disconnectedChannel.socket.send);
|
|
289
|
+
});
|
|
343
290
|
});
|
|
344
291
|
|
|
345
292
|
describe('#setSpokenLanguage', () => {
|
|
@@ -347,38 +294,33 @@ describe('plugin-voicea', () => {
|
|
|
347
294
|
const languageCode = 'en';
|
|
348
295
|
const triggerSpy = sinon.spy();
|
|
349
296
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
await voiceaService.setSpokenLanguage(languageCode);
|
|
297
|
+
voiceaChannel.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
|
|
298
|
+
await voiceaChannel.setSpokenLanguage(languageCode);
|
|
353
299
|
|
|
354
300
|
assert.calledOnceWithExactly(triggerSpy, {languageCode});
|
|
355
301
|
|
|
356
302
|
sinon.assert.calledWith(
|
|
357
|
-
|
|
303
|
+
requestStub,
|
|
358
304
|
sinon.match({
|
|
359
305
|
method: 'PUT',
|
|
360
306
|
url: `${locusUrl}/controls/`,
|
|
361
307
|
body: {
|
|
362
308
|
transcribe: {
|
|
363
309
|
spokenLanguage: languageCode,
|
|
364
|
-
}
|
|
310
|
+
},
|
|
365
311
|
},
|
|
366
312
|
})
|
|
367
313
|
);
|
|
368
314
|
});
|
|
315
|
+
|
|
369
316
|
it('sets spoken language with language assignment', async () => {
|
|
370
317
|
const languageCode = 'zh';
|
|
371
318
|
const languageAssignment = 'DEFAULT';
|
|
372
|
-
const triggerSpy = sinon.spy();
|
|
373
|
-
|
|
374
|
-
voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
|
|
375
|
-
voiceaService.listenToEvents();
|
|
376
|
-
await voiceaService.setSpokenLanguage(languageCode, languageAssignment);
|
|
377
319
|
|
|
378
|
-
|
|
320
|
+
await voiceaChannel.setSpokenLanguage(languageCode, languageAssignment);
|
|
379
321
|
|
|
380
322
|
sinon.assert.calledWith(
|
|
381
|
-
|
|
323
|
+
requestStub,
|
|
382
324
|
sinon.match({
|
|
383
325
|
method: 'PUT',
|
|
384
326
|
url: `${locusUrl}/controls/`,
|
|
@@ -386,1197 +328,966 @@ describe('plugin-voicea', () => {
|
|
|
386
328
|
transcribe: {
|
|
387
329
|
spokenLanguage: languageCode,
|
|
388
330
|
languageAssignment,
|
|
389
|
-
}
|
|
331
|
+
},
|
|
390
332
|
},
|
|
391
333
|
})
|
|
392
334
|
);
|
|
393
335
|
});
|
|
394
|
-
|
|
395
336
|
});
|
|
396
337
|
|
|
397
|
-
describe('#
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
402
|
-
voiceaService.captionStatus = 'idle';
|
|
338
|
+
describe('#isLLMConnected', () => {
|
|
339
|
+
it('returns true when the LLM channel is connected', () => {
|
|
340
|
+
assert.equal(voiceaChannel.isLLMConnected(), true);
|
|
403
341
|
});
|
|
404
342
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
it('turns on captions', async () => {
|
|
410
|
-
const announcementSpy = sinon.spy(voiceaService, 'announce');
|
|
411
|
-
const updateSubchannelSubscriptionsAndSyncCaptionStateSpy = sinon.spy(voiceaService, 'updateSubchannelSubscriptionsAndSyncCaptionState');
|
|
412
|
-
|
|
413
|
-
const triggerSpy = sinon.spy();
|
|
414
|
-
|
|
415
|
-
voiceaService.on(EVENT_TRIGGERS.CAPTIONS_TURNED_ON, triggerSpy);
|
|
416
|
-
voiceaService.listenToEvents();
|
|
417
|
-
|
|
418
|
-
await voiceaService.requestTurnOnCaptions();
|
|
419
|
-
assert.equal(voiceaService.captionStatus, 'enabled');
|
|
420
|
-
sinon.assert.calledWith(
|
|
421
|
-
voiceaService.request,
|
|
422
|
-
sinon.match({
|
|
423
|
-
method: 'PUT',
|
|
424
|
-
url: `${locusUrl}/controls/`,
|
|
425
|
-
body: {transcribe: {caption: true}},
|
|
426
|
-
})
|
|
427
|
-
);
|
|
428
|
-
|
|
429
|
-
assert.calledOnceWithExactly(triggerSpy);
|
|
430
|
-
|
|
431
|
-
assert.calledOnce(announcementSpy);
|
|
432
|
-
assert.calledOnceWithExactly(
|
|
433
|
-
updateSubchannelSubscriptionsAndSyncCaptionStateSpy,
|
|
434
|
-
{ subscribe: ['transcription'] },
|
|
435
|
-
true
|
|
436
|
-
);
|
|
343
|
+
it('returns false when the LLM channel is not connected', () => {
|
|
344
|
+
mockLLMChannel.isConnected.returns(false);
|
|
345
|
+
assert.equal(voiceaChannel.isLLMConnected(), false);
|
|
437
346
|
});
|
|
438
347
|
|
|
439
|
-
it(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
try {
|
|
444
|
-
await voiceaService.requestTurnOnCaptions();
|
|
445
|
-
} catch (error) {
|
|
446
|
-
expect(error.message).to.include('turn on captions fail');
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
assert.equal(voiceaService.captionStatus, 'idle');
|
|
348
|
+
it('returns false when the LLM channel is undefined', () => {
|
|
349
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
350
|
+
assert.equal(channelWithoutLLM.isLLMConnected(), false);
|
|
450
351
|
});
|
|
451
352
|
});
|
|
452
353
|
|
|
453
|
-
describe(
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
['joining', 'joined'].forEach((status) => {
|
|
459
|
-
it(`should return true when status is ${status}`, () => {
|
|
460
|
-
voiceaService.announceStatus = status;
|
|
461
|
-
assert.equal(voiceaService.isAnnounceProcessing(), true);
|
|
462
|
-
});
|
|
354
|
+
describe('#getKeepTranscriptionSubscribed', () => {
|
|
355
|
+
it('returns false when keepTranscriptionSubscribed is false', () => {
|
|
356
|
+
voiceaChannel.keepTranscriptionSubscribed = false;
|
|
357
|
+
assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), false);
|
|
463
358
|
});
|
|
464
359
|
|
|
465
|
-
it('
|
|
466
|
-
|
|
467
|
-
|
|
360
|
+
it('returns true when keepTranscriptionSubscribed is true', () => {
|
|
361
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
362
|
+
assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
|
|
468
363
|
});
|
|
469
364
|
});
|
|
470
365
|
|
|
471
|
-
describe('#
|
|
472
|
-
it('
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
assert.equal(voiceaService.isLLMConnected(), true);
|
|
366
|
+
describe('#announce', () => {
|
|
367
|
+
it('announce to llm data channel', () => {
|
|
368
|
+
const sendAnnouncementSpy = sinon.spy(voiceaChannel, 'sendAnnouncement');
|
|
369
|
+
voiceaChannel.announce();
|
|
370
|
+
assert.calledOnce(sendAnnouncementSpy);
|
|
478
371
|
});
|
|
479
372
|
|
|
480
|
-
it('
|
|
481
|
-
|
|
482
|
-
|
|
373
|
+
it('throws when llm is not connected', () => {
|
|
374
|
+
mockLLMChannel.isConnected.returns(false);
|
|
375
|
+
assert.throws(
|
|
376
|
+
() => voiceaChannel.announce(),
|
|
377
|
+
'voicea can not announce before llm connected'
|
|
483
378
|
);
|
|
484
|
-
|
|
485
|
-
assert.equal(voiceaService.isLLMConnected(), true);
|
|
486
379
|
});
|
|
487
380
|
|
|
488
|
-
it('
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
381
|
+
it('should not announce duplicate when already processed', () => {
|
|
382
|
+
voiceaChannel.announceStatus = 'joined';
|
|
383
|
+
const sendAnnouncementSpy = sinon.spy(voiceaChannel, 'sendAnnouncement');
|
|
384
|
+
voiceaChannel.announce();
|
|
385
|
+
assert.notCalled(sendAnnouncementSpy);
|
|
492
386
|
});
|
|
493
387
|
});
|
|
494
388
|
|
|
495
|
-
describe('#
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
it('returns false when captions are disabled', () => {
|
|
501
|
-
voiceaService.keepTranscriptionSubscribed = false;
|
|
502
|
-
|
|
503
|
-
const result = voiceaService.getKeepTranscriptionSubscribed();
|
|
504
|
-
|
|
505
|
-
assert.equal(result, false);
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('returns true when captions are enabled', () => {
|
|
509
|
-
voiceaService.keepTranscriptionSubscribed = true;
|
|
510
|
-
|
|
511
|
-
const result = voiceaService.getKeepTranscriptionSubscribed();
|
|
512
|
-
|
|
513
|
-
assert.equal(result, true);
|
|
514
|
-
});
|
|
515
|
-
});
|
|
389
|
+
describe('#turnOnCaptions', () => {
|
|
390
|
+
it('turns on captions', async () => {
|
|
391
|
+
const announceSpy = sinon.spy(voiceaChannel, 'announce');
|
|
392
|
+
const triggerSpy = sinon.spy();
|
|
516
393
|
|
|
517
|
-
|
|
518
|
-
let isAnnounceProcessed, sendAnnouncement;
|
|
519
|
-
beforeEach(() => {
|
|
520
|
-
sendAnnouncement = sinon.stub(voiceaService, 'sendAnnouncement');
|
|
521
|
-
isAnnounceProcessed = sinon.stub(voiceaService, 'isAnnounceProcessed').returns(false)
|
|
522
|
-
});
|
|
394
|
+
voiceaChannel.on(EVENT_TRIGGERS.CAPTIONS_TURNED_ON, triggerSpy);
|
|
523
395
|
|
|
524
|
-
|
|
525
|
-
isAnnounceProcessed.restore();
|
|
526
|
-
sendAnnouncement.restore();
|
|
527
|
-
});
|
|
396
|
+
await voiceaChannel.turnOnCaptions();
|
|
528
397
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
assert.calledOnce(
|
|
398
|
+
assert.equal(voiceaChannel.getCaptionStatus(), 'enabled');
|
|
399
|
+
assert.calledOnce(announceSpy);
|
|
400
|
+
assert.calledOnce(triggerSpy);
|
|
532
401
|
});
|
|
533
402
|
|
|
534
|
-
it('
|
|
535
|
-
|
|
536
|
-
assert.throws(() => voiceaService.announce(), "voicea can not announce before llm connected");
|
|
537
|
-
assert.notCalled(sendAnnouncement);
|
|
538
|
-
});
|
|
403
|
+
it('throws when llm is not connected', async () => {
|
|
404
|
+
mockLLMChannel.isConnected.returns(false);
|
|
539
405
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
406
|
+
await assert.isRejected(
|
|
407
|
+
voiceaChannel.turnOnCaptions(),
|
|
408
|
+
'can not turn on captions before llm connected'
|
|
543
409
|
);
|
|
544
|
-
|
|
545
|
-
voiceaService.announce();
|
|
546
|
-
|
|
547
|
-
assert.calledOnce(sendAnnouncement);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
it('should not announce duplicate', () => {
|
|
551
|
-
isAnnounceProcessed.returns(true);
|
|
552
|
-
voiceaService.announce();
|
|
553
|
-
assert.notCalled(sendAnnouncement);
|
|
554
|
-
})
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
describe("#isCaptionProcessing", () => {
|
|
558
|
-
afterEach(() => {
|
|
559
|
-
voiceaService.captionStatus = 'idle';
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
['sending', 'enabled'].forEach((status) => {
|
|
563
|
-
it(`should return true when status is ${status}`, () => {
|
|
564
|
-
voiceaService.captionStatus = status;
|
|
565
|
-
assert.equal(voiceaService.isCaptionProcessing(), true);
|
|
566
|
-
});
|
|
567
410
|
});
|
|
568
411
|
|
|
569
|
-
it('
|
|
570
|
-
|
|
571
|
-
|
|
412
|
+
it('returns undefined when already sending', async () => {
|
|
413
|
+
voiceaChannel.captionStatus = 'sending';
|
|
414
|
+
const result = await voiceaChannel.turnOnCaptions();
|
|
415
|
+
assert.equal(result, undefined);
|
|
572
416
|
});
|
|
573
|
-
});
|
|
574
417
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
beforeEach(() => {
|
|
578
|
-
requestTurnOnCaptions = sinon.stub(voiceaService, 'requestTurnOnCaptions');
|
|
579
|
-
voiceaService.captionStatus = 'idle';
|
|
580
|
-
});
|
|
418
|
+
it('returns undefined when already enabled', async () => {
|
|
419
|
+
voiceaChannel.captionStatus = 'enabled';
|
|
581
420
|
|
|
582
|
-
|
|
583
|
-
requestTurnOnCaptions.restore();
|
|
584
|
-
voiceaService.captionStatus = 'idle';
|
|
585
|
-
});
|
|
421
|
+
const result = await voiceaChannel.turnOnCaptions();
|
|
586
422
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
voiceaService.turnOnCaptions();
|
|
590
|
-
assert.calledOnce(requestTurnOnCaptions);
|
|
423
|
+
assert.equal(result, undefined);
|
|
424
|
+
assert.notCalled(requestStub);
|
|
591
425
|
});
|
|
592
426
|
|
|
593
|
-
it('throws
|
|
594
|
-
|
|
595
|
-
|
|
427
|
+
it('throws error on request failure', async () => {
|
|
428
|
+
const requestError = new Error('Request failed');
|
|
429
|
+
requestStub.rejects(requestError);
|
|
596
430
|
|
|
597
|
-
await assert.isRejected(
|
|
598
|
-
|
|
599
|
-
'
|
|
431
|
+
const error = await assert.isRejected(
|
|
432
|
+
voiceaChannel.turnOnCaptions(),
|
|
433
|
+
'turn on captions fail'
|
|
600
434
|
);
|
|
601
|
-
assert.notCalled(requestTurnOnCaptions);
|
|
602
|
-
});
|
|
603
435
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
channel === LLM_PRACTICE_SESSION
|
|
607
|
-
);
|
|
436
|
+
assert.equal(error.cause, requestError);
|
|
437
|
+
});
|
|
608
438
|
|
|
609
|
-
|
|
439
|
+
it('resets caption status to idle on error', async () => {
|
|
440
|
+
requestStub.rejects(new Error('Request failed'));
|
|
610
441
|
|
|
611
|
-
|
|
612
|
-
|
|
442
|
+
try {
|
|
443
|
+
await voiceaChannel.turnOnCaptions();
|
|
444
|
+
} catch {
|
|
445
|
+
// expected
|
|
446
|
+
}
|
|
613
447
|
|
|
614
|
-
|
|
615
|
-
voiceaService.captionStatus = 'sending';
|
|
616
|
-
voiceaService.turnOnCaptions();
|
|
617
|
-
assert.notCalled(voiceaService.requestTurnOnCaptions);
|
|
448
|
+
assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
|
|
618
449
|
});
|
|
619
450
|
});
|
|
620
451
|
|
|
621
452
|
describe('#toggleTranscribing', () => {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
it('turns on transcribing with CC enabled', async () => {
|
|
629
|
-
// Turn on captions
|
|
630
|
-
await voiceaService.turnOnCaptions();
|
|
631
|
-
const announcementSpy = sinon.spy(voiceaService, 'sendAnnouncement');
|
|
632
|
-
|
|
633
|
-
// eslint-disable-next-line no-underscore-dangle
|
|
634
|
-
voiceaService.webex.internal.llm._emit('event:relay.event', {
|
|
635
|
-
headers: {from: 'ws'},
|
|
636
|
-
data: {relayType: 'voicea.annc', voiceaPayload: {}},
|
|
637
|
-
});
|
|
453
|
+
it('turns on transcribing', async () => {
|
|
454
|
+
await voiceaChannel.toggleTranscribing(true);
|
|
638
455
|
|
|
639
|
-
voiceaService.listenToEvents();
|
|
640
|
-
|
|
641
|
-
await voiceaService.toggleTranscribing(true);
|
|
642
456
|
sinon.assert.calledWith(
|
|
643
|
-
|
|
457
|
+
requestStub,
|
|
644
458
|
sinon.match({
|
|
645
459
|
method: 'PUT',
|
|
646
460
|
url: `${locusUrl}/controls/`,
|
|
647
461
|
body: {transcribe: {transcribing: true}},
|
|
648
462
|
})
|
|
649
463
|
);
|
|
650
|
-
|
|
651
|
-
assert.notCalled(announcementSpy);
|
|
652
464
|
});
|
|
653
465
|
|
|
654
|
-
it('turns
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
voiceaService.listenToEvents();
|
|
466
|
+
it('turns off transcribing', async () => {
|
|
467
|
+
await voiceaChannel.toggleTranscribing(false);
|
|
658
468
|
|
|
659
|
-
await voiceaService.toggleTranscribing(true);
|
|
660
469
|
sinon.assert.calledWith(
|
|
661
|
-
|
|
470
|
+
requestStub,
|
|
662
471
|
sinon.match({
|
|
663
472
|
method: 'PUT',
|
|
664
473
|
url: `${locusUrl}/controls/`,
|
|
665
|
-
body: {transcribe: {transcribing:
|
|
474
|
+
body: {transcribe: {transcribing: false}},
|
|
666
475
|
})
|
|
667
476
|
);
|
|
668
|
-
|
|
669
|
-
assert.calledOnce(announcementSpy);
|
|
670
477
|
});
|
|
671
478
|
|
|
672
|
-
it('
|
|
673
|
-
|
|
479
|
+
it('calls turnOnCaptions when activating and captions not enabled', async () => {
|
|
480
|
+
voiceaChannel.areCaptionsEnabled = false;
|
|
481
|
+
const turnOnCaptionsSpy = sinon.spy(voiceaChannel, 'turnOnCaptions');
|
|
482
|
+
|
|
483
|
+
await voiceaChannel.toggleTranscribing(true, 'en');
|
|
674
484
|
|
|
675
|
-
|
|
485
|
+
assert.calledOnceWithExactly(turnOnCaptionsSpy, 'en');
|
|
486
|
+
});
|
|
676
487
|
|
|
677
|
-
|
|
488
|
+
it('does not call turnOnCaptions when captions already enabled', async () => {
|
|
489
|
+
voiceaChannel.areCaptionsEnabled = true;
|
|
490
|
+
const turnOnCaptionsSpy = sinon.spy(voiceaChannel, 'turnOnCaptions');
|
|
678
491
|
|
|
679
|
-
await
|
|
680
|
-
sinon.assert.calledWith(
|
|
681
|
-
voiceaService.request,
|
|
682
|
-
sinon.match({
|
|
683
|
-
method: 'PUT',
|
|
684
|
-
url: `${locusUrl}/controls/`,
|
|
685
|
-
body: {transcribe: {transcribing: true}},
|
|
686
|
-
})
|
|
687
|
-
);
|
|
492
|
+
await voiceaChannel.toggleTranscribing(true);
|
|
688
493
|
|
|
689
|
-
assert.notCalled(
|
|
494
|
+
assert.notCalled(turnOnCaptionsSpy);
|
|
690
495
|
});
|
|
691
496
|
});
|
|
692
497
|
|
|
693
498
|
describe('#toggleManualCaption', () => {
|
|
694
|
-
beforeEach(async () => {
|
|
695
|
-
const mockWebSocket = new MockWebSocket();
|
|
696
|
-
|
|
697
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
698
|
-
voiceaService.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE;
|
|
699
|
-
});
|
|
700
|
-
|
|
701
499
|
it('turns on manual caption', async () => {
|
|
702
|
-
await
|
|
500
|
+
await voiceaChannel.toggleManualCaption(true);
|
|
501
|
+
|
|
703
502
|
sinon.assert.calledWith(
|
|
704
|
-
|
|
503
|
+
requestStub,
|
|
705
504
|
sinon.match({
|
|
706
505
|
method: 'PUT',
|
|
707
506
|
url: `${locusUrl}/controls/`,
|
|
708
507
|
body: {manualCaption: {enable: true}},
|
|
709
508
|
})
|
|
710
509
|
);
|
|
711
|
-
|
|
712
510
|
});
|
|
713
511
|
|
|
714
|
-
|
|
715
512
|
it('turns off manual caption', async () => {
|
|
716
|
-
await
|
|
513
|
+
await voiceaChannel.toggleManualCaption(false);
|
|
514
|
+
|
|
717
515
|
sinon.assert.calledWith(
|
|
718
|
-
|
|
516
|
+
requestStub,
|
|
719
517
|
sinon.match({
|
|
720
518
|
method: 'PUT',
|
|
721
519
|
url: `${locusUrl}/controls/`,
|
|
722
520
|
body: {manualCaption: {enable: false}},
|
|
723
521
|
})
|
|
724
522
|
);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('ignores when already sending', async () => {
|
|
526
|
+
voiceaChannel.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING;
|
|
527
|
+
await voiceaChannel.toggleManualCaption(true);
|
|
528
|
+
sinon.assert.notCalled(requestStub);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('throws error on request failure', async () => {
|
|
532
|
+
requestStub.rejects(new Error('Request failed'));
|
|
725
533
|
|
|
534
|
+
await assert.isRejected(
|
|
535
|
+
voiceaChannel.toggleManualCaption(true),
|
|
536
|
+
'toggle manual captions fail'
|
|
537
|
+
);
|
|
726
538
|
});
|
|
727
539
|
|
|
728
|
-
it('
|
|
729
|
-
|
|
730
|
-
|
|
540
|
+
it('resets status to idle on error', async () => {
|
|
541
|
+
requestStub.rejects(new Error('Request failed'));
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
await voiceaChannel.toggleManualCaption(true);
|
|
545
|
+
} catch {
|
|
546
|
+
// expected
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
assert.equal(voiceaChannel.toggleManualCaptionStatus, TOGGLE_MANUAL_CAPTION_STATUS.IDLE);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
731
552
|
|
|
732
|
-
|
|
553
|
+
describe('#getCaptionStatus', () => {
|
|
554
|
+
it('returns current caption status', () => {
|
|
555
|
+
voiceaChannel.captionStatus = 'enabled';
|
|
556
|
+
assert.equal(voiceaChannel.getCaptionStatus(), 'enabled');
|
|
557
|
+
});
|
|
558
|
+
});
|
|
733
559
|
|
|
560
|
+
describe('#getAnnounceStatus', () => {
|
|
561
|
+
it('returns current announce status', () => {
|
|
562
|
+
voiceaChannel.announceStatus = 'joined';
|
|
563
|
+
assert.equal(voiceaChannel.getAnnounceStatus(), 'joined');
|
|
734
564
|
});
|
|
735
565
|
});
|
|
736
566
|
|
|
737
|
-
describe('#
|
|
738
|
-
it('
|
|
567
|
+
describe('#onSpokenLanguageUpdate', () => {
|
|
568
|
+
it('should trigger SPOKEN_LANGUAGE_UPDATE event', () => {
|
|
739
569
|
const triggerSpy = sinon.spy();
|
|
740
|
-
|
|
570
|
+
voiceaChannel.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
|
|
741
571
|
|
|
742
|
-
|
|
743
|
-
voiceaService.listenToEvents();
|
|
572
|
+
voiceaChannel.onSpokenLanguageUpdate('fr', '123');
|
|
744
573
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
relayType: 'voicea.transl.rsp',
|
|
750
|
-
voiceaPayload: {
|
|
751
|
-
statusCode: 200,
|
|
752
|
-
},
|
|
753
|
-
},
|
|
754
|
-
});
|
|
574
|
+
assert.equal(voiceaChannel.currentSpokenLanguage, 'fr');
|
|
575
|
+
assert.calledOnceWithExactly(triggerSpy, {languageCode: 'fr', meetingId: '123'});
|
|
576
|
+
});
|
|
577
|
+
});
|
|
755
578
|
|
|
756
|
-
|
|
757
|
-
|
|
579
|
+
describe('#onCaptionServiceIdUpdate', () => {
|
|
580
|
+
it('does nothing when serviceId is falsy', () => {
|
|
581
|
+
voiceaChannel.captionServiceId = 'existing-id';
|
|
582
|
+
voiceaChannel.onCaptionServiceIdUpdate(undefined);
|
|
583
|
+
voiceaChannel.onCaptionServiceIdUpdate('');
|
|
584
|
+
assert.equal(voiceaChannel.captionServiceId, 'existing-id');
|
|
758
585
|
});
|
|
759
586
|
|
|
760
|
-
it('
|
|
761
|
-
|
|
762
|
-
|
|
587
|
+
it('sets captionServiceId when no currentCaptionLanguage', () => {
|
|
588
|
+
voiceaChannel.captionServiceId = undefined;
|
|
589
|
+
voiceaChannel.currentCaptionLanguage = undefined;
|
|
590
|
+
voiceaChannel.onCaptionServiceIdUpdate('svc-new');
|
|
591
|
+
assert.equal(voiceaChannel.captionServiceId, 'svc-new');
|
|
592
|
+
});
|
|
763
593
|
|
|
764
|
-
|
|
765
|
-
|
|
594
|
+
it('re-sends language when serviceId changes and currentCaptionLanguage is set', () => {
|
|
595
|
+
voiceaChannel.captionServiceId = 'old-svc';
|
|
596
|
+
voiceaChannel.currentCaptionLanguage = 'es';
|
|
766
597
|
|
|
767
|
-
|
|
768
|
-
errorCode: 300,
|
|
769
|
-
message: 'error text',
|
|
770
|
-
};
|
|
598
|
+
voiceaChannel.onCaptionServiceIdUpdate('new-svc');
|
|
771
599
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
headers: {from: 'ws'},
|
|
775
|
-
data: {relayType: 'voicea.transl.rsp', voiceaPayload: payload},
|
|
776
|
-
});
|
|
777
|
-
assert.calledOnce(functionSpy);
|
|
778
|
-
assert.calledOnceWithExactly(triggerSpy, {statusCode: 300, errorMessage: 'error text'});
|
|
600
|
+
assert.equal(voiceaChannel.captionServiceId, 'new-svc');
|
|
601
|
+
assert.calledOnce(mockLLMChannel.socket.send);
|
|
779
602
|
});
|
|
780
603
|
});
|
|
781
604
|
|
|
782
|
-
describe('#
|
|
783
|
-
|
|
605
|
+
describe('#updateSubchannelSubscriptions', () => {
|
|
606
|
+
it('sends subchannelSubscriptionRequest', async () => {
|
|
607
|
+
await voiceaChannel.updateSubchannelSubscriptions({
|
|
608
|
+
subscribe: ['transcription'],
|
|
609
|
+
unsubscribe: ['polls'],
|
|
610
|
+
});
|
|
784
611
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
voiceaService.on(EVENT_TRIGGERS.NEW_CAPTION, triggerSpy);
|
|
793
|
-
const transcripts = [
|
|
794
|
-
{
|
|
795
|
-
text: 'Hello.',
|
|
796
|
-
csis: [3556942592],
|
|
797
|
-
transcript_language_code: 'en',
|
|
798
|
-
translations: {
|
|
799
|
-
fr: 'Bonjour.',
|
|
800
|
-
},
|
|
801
|
-
},
|
|
802
|
-
{
|
|
803
|
-
text: 'This is Webex',
|
|
804
|
-
csis: [3556942593],
|
|
805
|
-
transcript_language_code: 'en',
|
|
806
|
-
translations: {
|
|
807
|
-
fr: "C'est Webex",
|
|
808
|
-
},
|
|
612
|
+
sinon.assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
613
|
+
id: '1',
|
|
614
|
+
type: 'subchannelSubscriptionRequest',
|
|
615
|
+
data: {
|
|
616
|
+
datachannelUri: 'datachannelUrl',
|
|
617
|
+
subscribe: ['transcription'],
|
|
618
|
+
unsubscribe: ['polls'],
|
|
809
619
|
},
|
|
810
|
-
|
|
811
|
-
const voiceaPayload = {
|
|
812
|
-
audio_received_millis: 0,
|
|
813
|
-
command_response: '',
|
|
814
|
-
csis: [3556942592],
|
|
815
|
-
data: 'Hello.',
|
|
816
|
-
id: '38093ff5-f6a8-581c-9e59-035ec027994b',
|
|
817
|
-
meeting: '61d4e269-8419-42ab-9e56-3917974cda01',
|
|
818
|
-
transcript_id: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
|
|
819
|
-
ts: 1611653204.3147924,
|
|
820
|
-
type: 'transcript_interim_results',
|
|
821
|
-
|
|
822
|
-
transcripts,
|
|
823
|
-
};
|
|
824
|
-
|
|
825
|
-
// eslint-disable-next-line no-underscore-dangle
|
|
826
|
-
await voiceaService.webex.internal.llm._emit('event:relay.event', {
|
|
827
|
-
headers: {from: 'ws'},
|
|
828
|
-
data: {relayType: 'voicea.transcription', voiceaPayload},
|
|
620
|
+
trackingId: sinon.match.string,
|
|
829
621
|
});
|
|
622
|
+
});
|
|
830
623
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
624
|
+
it('does nothing when LLM is not connected', async () => {
|
|
625
|
+
mockLLMChannel.isConnected.returns(false);
|
|
626
|
+
|
|
627
|
+
await voiceaChannel.updateSubchannelSubscriptions({subscribe: ['transcription']});
|
|
628
|
+
|
|
629
|
+
sinon.assert.notCalled(mockLLMChannel.socket.send);
|
|
837
630
|
});
|
|
838
631
|
|
|
839
|
-
it('
|
|
840
|
-
|
|
632
|
+
it('does nothing when dataChannelToken is not enabled', async () => {
|
|
633
|
+
mockLLMChannel.isDataChannelTokenEnabled.resolves(false);
|
|
841
634
|
|
|
842
|
-
|
|
843
|
-
audio_received_millis: 0,
|
|
844
|
-
command_response: '',
|
|
845
|
-
csis: [3556942592],
|
|
846
|
-
data: 'Hello. This is Webex',
|
|
847
|
-
id: '38093ff5-f6a8-581c-9e59-035ec027994b',
|
|
848
|
-
meeting: '61d4e269-8419-42ab-9e56-3917974cda01',
|
|
849
|
-
transcript_id: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
|
|
850
|
-
ts: 1611653204.3147924,
|
|
851
|
-
type: 'transcript_final_result',
|
|
852
|
-
translations: {
|
|
853
|
-
en: "Hello?",
|
|
854
|
-
},
|
|
855
|
-
transcript: {
|
|
856
|
-
alignments: [
|
|
857
|
-
{
|
|
858
|
-
end_millis: 12474,
|
|
859
|
-
start_millis: 12204,
|
|
860
|
-
word: 'Hello?',
|
|
861
|
-
},
|
|
862
|
-
],
|
|
863
|
-
csis: [3556942592],
|
|
864
|
-
end_millis: 13044,
|
|
865
|
-
last_packet_timestamp_ms: 1611653206784,
|
|
866
|
-
start_millis: 12204,
|
|
867
|
-
text: 'Hello?',
|
|
868
|
-
transcript_language_code: 'en',
|
|
869
|
-
timestamp: '0:13'
|
|
870
|
-
},
|
|
871
|
-
transcripts: [
|
|
872
|
-
{
|
|
873
|
-
start_millis: 12204,
|
|
874
|
-
end_millis: 13044,
|
|
875
|
-
text: 'Hello.',
|
|
876
|
-
csis: [3556942592],
|
|
877
|
-
transcript_language_code: 'en',
|
|
878
|
-
translations: {
|
|
879
|
-
fr: 'Bonjour.',
|
|
880
|
-
},
|
|
881
|
-
timestamp: '0:13'
|
|
882
|
-
},
|
|
883
|
-
{
|
|
884
|
-
start_millis: 12204,
|
|
885
|
-
end_millis: 13044,
|
|
886
|
-
text: 'This is Webex',
|
|
887
|
-
csis: [3556942593],
|
|
888
|
-
transcript_language_code: 'en',
|
|
889
|
-
translations: {
|
|
890
|
-
fr: "C'est Webex",
|
|
891
|
-
},
|
|
892
|
-
timestamp: '0:13'
|
|
893
|
-
},
|
|
894
|
-
],
|
|
895
|
-
};
|
|
635
|
+
await voiceaChannel.updateSubchannelSubscriptions({subscribe: ['transcription']});
|
|
896
636
|
|
|
897
|
-
|
|
898
|
-
await voiceaService.webex.internal.llm._emit('event:relay.event', {
|
|
899
|
-
headers: {from: 'ws'},
|
|
900
|
-
data: {relayType: 'voicea.transcription', voiceaPayload},
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
assert.calledOnceWithExactly(functionSpy, voiceaPayload);
|
|
904
|
-
assert.calledOnceWithExactly(triggerSpy, {
|
|
905
|
-
isFinal: true,
|
|
906
|
-
transcriptId: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
|
|
907
|
-
transcripts: voiceaPayload.transcripts,
|
|
908
|
-
});
|
|
637
|
+
sinon.assert.notCalled(mockLLMChannel.socket.send);
|
|
909
638
|
});
|
|
639
|
+
});
|
|
910
640
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const voiceaPayload = {
|
|
915
|
-
audio_received_millis: 1616137504810,
|
|
916
|
-
command_response: '',
|
|
917
|
-
id: '31fb2f81-fb55-4257-32a0-f421ef8ba4b0',
|
|
918
|
-
meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
|
|
919
|
-
trigger: {
|
|
920
|
-
detected_at: '2021-03-19T07:05:04.810669662Z',
|
|
921
|
-
ews_confidence: 0.99497044086456299,
|
|
922
|
-
ews_keyphrase: 'OkayWebEx',
|
|
923
|
-
model_version: 'WebEx',
|
|
924
|
-
offset_seconds: 2336.5900000000001,
|
|
925
|
-
recording_file_name:
|
|
926
|
-
'OkayWebEx_fd5bd0fc-06fb-4fd1-982b-554c4368f101_47900f3f-8579-25eb-3f6a-74d81a3c66a4_2335.8900000000003_2336.79.raw',
|
|
927
|
-
type: 'live-hotword',
|
|
928
|
-
},
|
|
929
|
-
ts: 1616137504.8107769,
|
|
930
|
-
type: 'eva_wake',
|
|
931
|
-
};
|
|
641
|
+
describe('#updateSubchannelSubscriptionsAndSyncCaptionState', () => {
|
|
642
|
+
it('updates caption intent and forwards to updateSubchannelSubscriptions', async () => {
|
|
643
|
+
const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
|
|
932
644
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
});
|
|
645
|
+
await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
|
|
646
|
+
{subscribe: ['transcription']},
|
|
647
|
+
true
|
|
648
|
+
);
|
|
938
649
|
|
|
939
|
-
assert.
|
|
940
|
-
assert.calledOnceWithExactly(
|
|
941
|
-
isListening: true,
|
|
942
|
-
});
|
|
650
|
+
assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
|
|
651
|
+
assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
|
|
943
652
|
});
|
|
944
653
|
|
|
945
|
-
it('
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
const voiceaPayload = {
|
|
949
|
-
audio_received_millis: 0,
|
|
950
|
-
command_response: 'OK! Decision created.',
|
|
951
|
-
id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
|
|
952
|
-
intent: 'decision',
|
|
953
|
-
meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
|
|
954
|
-
ts: 1616135828.2552843,
|
|
955
|
-
type: 'eva_thanks',
|
|
956
|
-
};
|
|
654
|
+
it('sets caption intent to false when isCCBoxOpen is false', async () => {
|
|
655
|
+
const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
|
|
957
656
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
});
|
|
657
|
+
await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
|
|
658
|
+
{subscribe: ['transcription']},
|
|
659
|
+
false
|
|
660
|
+
);
|
|
963
661
|
|
|
964
|
-
assert.
|
|
965
|
-
assert.calledOnceWithExactly(
|
|
966
|
-
isListening: false,
|
|
967
|
-
text: 'OK! Decision created.',
|
|
968
|
-
});
|
|
662
|
+
assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), false);
|
|
663
|
+
assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
|
|
969
664
|
});
|
|
970
665
|
|
|
971
|
-
it('
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const voiceaPayload = {
|
|
975
|
-
audio_received_millis: 0,
|
|
976
|
-
command_response: '',
|
|
977
|
-
id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
|
|
978
|
-
intent: 'decision',
|
|
979
|
-
meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
|
|
980
|
-
ts: 1616135828.2552843,
|
|
981
|
-
type: 'eva_cancel',
|
|
982
|
-
};
|
|
983
|
-
|
|
984
|
-
// eslint-disable-next-line no-underscore-dangle
|
|
985
|
-
await voiceaService.webex.internal.llm._emit('event:relay.event', {
|
|
986
|
-
headers: {from: 'ws'},
|
|
987
|
-
data: {relayType: 'voicea.transcription', voiceaPayload},
|
|
988
|
-
});
|
|
666
|
+
it('defaults subscribe/unsubscribe to empty arrays when options is empty', async () => {
|
|
667
|
+
const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
|
|
989
668
|
|
|
990
|
-
|
|
669
|
+
await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState({}, true);
|
|
991
670
|
|
|
992
|
-
assert.
|
|
993
|
-
|
|
994
|
-
});
|
|
671
|
+
assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
|
|
672
|
+
assert.calledOnceWithExactly(updateSpy, {});
|
|
995
673
|
});
|
|
996
674
|
|
|
997
|
-
it('
|
|
998
|
-
|
|
999
|
-
const
|
|
1000
|
-
audio_received_millis: 0,
|
|
1001
|
-
command_response: '',
|
|
1002
|
-
highlight: {
|
|
1003
|
-
created_by_email: '',
|
|
1004
|
-
csis: [3932881920],
|
|
1005
|
-
end_millis: 660160,
|
|
1006
|
-
highlight_id: '219af4b1-1579-5106-53ab-f621094a0c5a',
|
|
1007
|
-
highlight_label: 'Decision',
|
|
1008
|
-
highlight_source: 'voice-command',
|
|
1009
|
-
start_millis: 652756,
|
|
1010
|
-
transcript: 'Create a decision to move ahead with the last proposal.',
|
|
1011
|
-
trigger_info: {type: 'live-hotword'},
|
|
1012
|
-
},
|
|
1013
|
-
id: 'e6df0262-6289-db2e-581a-d44bb41b1c9c',
|
|
1014
|
-
meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
|
|
1015
|
-
ts: 1616135858.5349569,
|
|
1016
|
-
type: 'highlight_created',
|
|
1017
|
-
};
|
|
675
|
+
it('still updates caption intent even if updateSubchannelSubscriptions does nothing (e.g., LLM not connected)', async () => {
|
|
676
|
+
mockLLMChannel.isConnected.returns(false);
|
|
677
|
+
const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
|
|
1018
678
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
});
|
|
679
|
+
await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
|
|
680
|
+
{subscribe: ['transcription']},
|
|
681
|
+
true
|
|
682
|
+
);
|
|
1024
683
|
|
|
1025
|
-
assert.
|
|
1026
|
-
assert.calledOnceWithExactly(
|
|
1027
|
-
csis: [3932881920],
|
|
1028
|
-
highlightId: '219af4b1-1579-5106-53ab-f621094a0c5a',
|
|
1029
|
-
text: 'Create a decision to move ahead with the last proposal.',
|
|
1030
|
-
highlightLabel: 'Decision',
|
|
1031
|
-
highlightSource: 'voice-command',
|
|
1032
|
-
timestamp: '11:00',
|
|
1033
|
-
});
|
|
684
|
+
assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
|
|
685
|
+
assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
|
|
1034
686
|
});
|
|
687
|
+
});
|
|
1035
688
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
const voiceaPayload = {
|
|
1040
|
-
id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
|
|
1041
|
-
meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
|
|
1042
|
-
type: 'language_detected',
|
|
1043
|
-
language: 'en',
|
|
1044
|
-
translation: {
|
|
1045
|
-
allowed_languages: ['af', 'am'],
|
|
1046
|
-
max_languages: 5,
|
|
1047
|
-
},
|
|
1048
|
-
ASR: {
|
|
1049
|
-
spoken_languages: ['en', 'pl'],
|
|
1050
|
-
},
|
|
1051
|
-
|
|
1052
|
-
version: 'v2',
|
|
1053
|
-
};
|
|
689
|
+
describe('event processor', () => {
|
|
690
|
+
let eventHandler;
|
|
1054
691
|
|
|
1055
|
-
|
|
692
|
+
beforeEach(() => {
|
|
693
|
+
// Get the registered event handler
|
|
694
|
+
const onCall = mockLLMChannel.on
|
|
695
|
+
.getCalls()
|
|
696
|
+
.find((call) => call.args[0] === 'event:relay.event');
|
|
697
|
+
eventHandler = onCall?.args[1];
|
|
698
|
+
});
|
|
1056
699
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
700
|
+
it('processes voicea announcement events', () => {
|
|
701
|
+
const spy = sinon.spy();
|
|
702
|
+
voiceaChannel.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
|
|
1060
703
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
headers: {from: 'ws'},
|
|
1064
|
-
data: {
|
|
704
|
+
eventHandler({
|
|
705
|
+
sequenceNumber: 1,
|
|
706
|
+
headers: {from: 'ws-service'},
|
|
707
|
+
data: {
|
|
708
|
+
relayType: 'voicea.annc',
|
|
709
|
+
voiceaPayload: {
|
|
710
|
+
translation: {allowed_languages: ['en', 'es'], max_languages: 3},
|
|
711
|
+
ASR: {spoken_languages: ['en']},
|
|
712
|
+
},
|
|
713
|
+
},
|
|
1065
714
|
});
|
|
1066
715
|
|
|
1067
|
-
assert.
|
|
1068
|
-
assert.calledOnceWithExactly(
|
|
1069
|
-
|
|
716
|
+
assert.equal(voiceaChannel.getAnnounceStatus(), 'joined');
|
|
717
|
+
assert.calledOnceWithExactly(spy, {
|
|
718
|
+
captionLanguages: ['en', 'es'],
|
|
719
|
+
maxLanguages: 3,
|
|
720
|
+
spokenLanguages: ['en'],
|
|
721
|
+
currentSpokenLanguage: 'en',
|
|
1070
722
|
});
|
|
1071
723
|
});
|
|
1072
724
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
let triggerSpy, functionSpy;
|
|
1077
|
-
|
|
1078
|
-
beforeEach(() => {
|
|
1079
|
-
triggerSpy = sinon.spy();
|
|
1080
|
-
functionSpy = sinon.spy(voiceaService, 'processManualTranscription');
|
|
1081
|
-
voiceaService.listenToEvents();
|
|
1082
|
-
});
|
|
1083
|
-
|
|
1084
|
-
it('processes interim manual transcription from aibridge', async () => {
|
|
1085
|
-
voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
|
|
1086
|
-
|
|
1087
|
-
const transcriptPayload = {
|
|
1088
|
-
id: "747d711d-3414-fd69-7081-e842649f2d28",
|
|
1089
|
-
transcripts: [
|
|
1090
|
-
{
|
|
1091
|
-
text: "Good",
|
|
1092
|
-
}
|
|
1093
|
-
],
|
|
1094
|
-
type: "manual_caption_interim_result",
|
|
1095
|
-
};
|
|
725
|
+
it('processes voicea announcement with empty payload', () => {
|
|
726
|
+
const spy = sinon.spy();
|
|
727
|
+
voiceaChannel.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
|
|
1096
728
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
headers: {from: 'ws'},
|
|
1100
|
-
data: {
|
|
729
|
+
eventHandler({
|
|
730
|
+
sequenceNumber: 1,
|
|
731
|
+
headers: {from: 'ws-service'},
|
|
732
|
+
data: {
|
|
733
|
+
relayType: 'voicea.annc',
|
|
734
|
+
voiceaPayload: {},
|
|
735
|
+
},
|
|
1101
736
|
});
|
|
1102
737
|
|
|
1103
|
-
assert.calledOnceWithExactly(
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
sender: 'ws',
|
|
1109
|
-
source: 'aibridge.manual_transcription'
|
|
738
|
+
assert.calledOnceWithExactly(spy, {
|
|
739
|
+
captionLanguages: [],
|
|
740
|
+
maxLanguages: 0,
|
|
741
|
+
spokenLanguages: [],
|
|
742
|
+
currentSpokenLanguage: 'en',
|
|
1110
743
|
});
|
|
1111
744
|
});
|
|
1112
745
|
|
|
1113
|
-
it('processes
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
transcripts: [
|
|
746
|
+
it('processes speaker name updates and advances the message sequence', () => {
|
|
747
|
+
const spy = sinon.spy();
|
|
748
|
+
const voiceaPayload = {
|
|
749
|
+
id: 'speaker-name-update-1',
|
|
750
|
+
taggedSpeakers: [
|
|
1119
751
|
{
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}
|
|
752
|
+
csiId: 3556942592,
|
|
753
|
+
speakerId: '1',
|
|
754
|
+
newName: 'Alice',
|
|
755
|
+
},
|
|
1124
756
|
],
|
|
1125
|
-
type: "manual_caption_final_result",
|
|
1126
757
|
};
|
|
1127
758
|
|
|
1128
|
-
|
|
1129
|
-
|
|
759
|
+
voiceaChannel.on(EVENT_TRIGGERS.SPEAKER_NAME_UPDATED, spy);
|
|
760
|
+
|
|
761
|
+
eventHandler({
|
|
762
|
+
sequenceNumber: 23,
|
|
1130
763
|
headers: {from: 'ws'},
|
|
1131
|
-
data: {
|
|
764
|
+
data: {
|
|
765
|
+
relayType: 'voicea.update_speakername',
|
|
766
|
+
voiceaPayload,
|
|
767
|
+
},
|
|
1132
768
|
});
|
|
1133
769
|
|
|
1134
|
-
assert.calledOnceWithExactly(
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
770
|
+
assert.calledOnceWithExactly(spy, voiceaPayload);
|
|
771
|
+
|
|
772
|
+
voiceaChannel.sendAnnouncement();
|
|
773
|
+
|
|
774
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
775
|
+
id: '24',
|
|
776
|
+
type: 'publishRequest',
|
|
777
|
+
recipients: [{route: 'binding'}],
|
|
778
|
+
headers: {},
|
|
779
|
+
data: {
|
|
780
|
+
clientPayload: {
|
|
781
|
+
version: 'v2',
|
|
782
|
+
},
|
|
783
|
+
eventType: 'relay.event',
|
|
784
|
+
relayType: 'client.annc',
|
|
785
|
+
},
|
|
786
|
+
trackingId: sinon.match.string,
|
|
1141
787
|
});
|
|
1142
788
|
});
|
|
1143
789
|
|
|
1144
|
-
it('processes
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
const transcriptPayload = {
|
|
1148
|
-
id: "747d711d-3414-fd69-7081-e842649f2d28",
|
|
1149
|
-
transcripts: [
|
|
1150
|
-
{
|
|
1151
|
-
text: "Good",
|
|
1152
|
-
}
|
|
1153
|
-
],
|
|
1154
|
-
type: "manual_caption_interim_result",
|
|
1155
|
-
};
|
|
790
|
+
it('processes transcription interim results', () => {
|
|
791
|
+
const spy = sinon.spy();
|
|
792
|
+
voiceaChannel.on(EVENT_TRIGGERS.NEW_CAPTION, spy);
|
|
1156
793
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
headers: {
|
|
1160
|
-
data: {
|
|
794
|
+
eventHandler({
|
|
795
|
+
sequenceNumber: 1,
|
|
796
|
+
headers: {},
|
|
797
|
+
data: {
|
|
798
|
+
relayType: 'voicea.transcription',
|
|
799
|
+
voiceaPayload: {
|
|
800
|
+
type: 'transcript_interim_results',
|
|
801
|
+
transcript_id: 'tid-1',
|
|
802
|
+
transcripts: [{text: 'Hello'}],
|
|
803
|
+
},
|
|
804
|
+
},
|
|
1161
805
|
});
|
|
1162
806
|
|
|
1163
|
-
assert.calledOnceWithExactly(
|
|
1164
|
-
assert.calledOnceWithExactly(triggerSpy, {
|
|
807
|
+
assert.calledOnceWithExactly(spy, {
|
|
1165
808
|
isFinal: false,
|
|
1166
|
-
transcriptId: '
|
|
1167
|
-
transcripts:
|
|
1168
|
-
sender: '654321',
|
|
1169
|
-
source: 'client.manual_transcription'
|
|
809
|
+
transcriptId: 'tid-1',
|
|
810
|
+
transcripts: [{text: 'Hello'}],
|
|
1170
811
|
});
|
|
1171
812
|
});
|
|
1172
813
|
|
|
1173
|
-
it('processes
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
const transcriptPayload = {
|
|
1177
|
-
id: "8d226d31-044a-8d11-cc39-cedbde183154",
|
|
1178
|
-
transcripts: [
|
|
1179
|
-
{
|
|
1180
|
-
text: "Good Morning",
|
|
1181
|
-
start_millis: 10420,
|
|
1182
|
-
end_millis: 11380,
|
|
1183
|
-
}
|
|
1184
|
-
],
|
|
1185
|
-
type: "manual_caption_final_result",
|
|
1186
|
-
};
|
|
814
|
+
it('processes transcription final results', () => {
|
|
815
|
+
const spy = sinon.spy();
|
|
816
|
+
voiceaChannel.on(EVENT_TRIGGERS.NEW_CAPTION, spy);
|
|
1187
817
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
headers: {
|
|
1191
|
-
data: {
|
|
818
|
+
eventHandler({
|
|
819
|
+
sequenceNumber: 1,
|
|
820
|
+
headers: {},
|
|
821
|
+
data: {
|
|
822
|
+
relayType: 'voicea.transcription',
|
|
823
|
+
voiceaPayload: {
|
|
824
|
+
type: 'transcript_final_result',
|
|
825
|
+
transcript_id: 'tid-2',
|
|
826
|
+
transcripts: [{text: 'Hello world', end_millis: 60000}],
|
|
827
|
+
},
|
|
828
|
+
},
|
|
1192
829
|
});
|
|
1193
830
|
|
|
1194
|
-
assert.
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
transcripts: transcriptPayload.transcripts,
|
|
1199
|
-
sender: '654321',
|
|
1200
|
-
source: 'client.manual_transcription'
|
|
1201
|
-
});
|
|
831
|
+
assert.calledOnce(spy);
|
|
832
|
+
const call = spy.getCall(0);
|
|
833
|
+
assert.equal(call.args[0].isFinal, true);
|
|
834
|
+
assert.equal(call.args[0].transcriptId, 'tid-2');
|
|
1202
835
|
});
|
|
1203
|
-
});
|
|
1204
836
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
assert.equal(voiceaService.getCaptionStatus(), "enabled");
|
|
1209
|
-
});
|
|
1210
|
-
});
|
|
837
|
+
it('processes caption language response success', () => {
|
|
838
|
+
const spy = sinon.spy();
|
|
839
|
+
voiceaChannel.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, spy);
|
|
1211
840
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
841
|
+
eventHandler({
|
|
842
|
+
sequenceNumber: 1,
|
|
843
|
+
headers: {},
|
|
844
|
+
data: {
|
|
845
|
+
relayType: 'voicea.transl.rsp',
|
|
846
|
+
voiceaPayload: {statusCode: 200},
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
assert.calledOnceWithExactly(spy, {statusCode: 200});
|
|
1216
851
|
});
|
|
1217
|
-
});
|
|
1218
852
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
|
|
853
|
+
it('processes caption language response error', () => {
|
|
854
|
+
const spy = sinon.spy();
|
|
855
|
+
voiceaChannel.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, spy);
|
|
1223
856
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
857
|
+
eventHandler({
|
|
858
|
+
sequenceNumber: 1,
|
|
859
|
+
headers: {},
|
|
860
|
+
data: {
|
|
861
|
+
relayType: 'voicea.transl.rsp',
|
|
862
|
+
voiceaPayload: {statusCode: 400, errorCode: 400, message: 'Bad request'},
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
assert.calledOnceWithExactly(spy, {statusCode: 400, errorMessage: 'Bad request'});
|
|
1228
867
|
});
|
|
1229
|
-
});
|
|
1230
868
|
|
|
1231
|
-
|
|
1232
|
-
|
|
869
|
+
it('processes manual transcription events', () => {
|
|
870
|
+
const spy = sinon.spy();
|
|
871
|
+
voiceaChannel.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, spy);
|
|
1233
872
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
873
|
+
eventHandler({
|
|
874
|
+
sequenceNumber: 1,
|
|
875
|
+
headers: {from: 'user-1'},
|
|
876
|
+
data: {
|
|
877
|
+
relayType: 'client.manual_transcription',
|
|
878
|
+
transcriptPayload: {
|
|
879
|
+
type: 'manual_caption_final_result',
|
|
880
|
+
id: 'manual-id',
|
|
881
|
+
transcripts: [{text: 'Manual caption'}],
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
assert.calledOnce(spy);
|
|
887
|
+
const call = spy.getCall(0);
|
|
888
|
+
assert.equal(call.args[0].isFinal, true);
|
|
889
|
+
assert.equal(call.args[0].transcriptId, 'manual-id');
|
|
1239
890
|
});
|
|
1240
891
|
|
|
1241
|
-
it('
|
|
1242
|
-
|
|
1243
|
-
|
|
892
|
+
it('processes highlight created events', () => {
|
|
893
|
+
const spy = sinon.spy();
|
|
894
|
+
voiceaChannel.on(EVENT_TRIGGERS.HIGHLIGHT_CREATED, spy);
|
|
1244
895
|
|
|
1245
|
-
|
|
1246
|
-
|
|
896
|
+
eventHandler({
|
|
897
|
+
sequenceNumber: 1,
|
|
898
|
+
headers: {},
|
|
899
|
+
data: {
|
|
900
|
+
relayType: 'voicea.transcription',
|
|
901
|
+
voiceaPayload: {
|
|
902
|
+
type: 'highlight_created',
|
|
903
|
+
highlight: {
|
|
904
|
+
csis: [123],
|
|
905
|
+
highlight_id: 'h-1',
|
|
906
|
+
transcript: 'Highlighted text',
|
|
907
|
+
highlight_label: 'important',
|
|
908
|
+
highlight_source: 'user',
|
|
909
|
+
end_millis: 30000,
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
});
|
|
1247
914
|
|
|
1248
|
-
assert.
|
|
1249
|
-
assert.notCalled(voiceaService.webex.internal.llm.socket.send);
|
|
915
|
+
assert.calledOnce(spy);
|
|
1250
916
|
});
|
|
1251
917
|
|
|
1252
|
-
it('
|
|
1253
|
-
|
|
1254
|
-
|
|
918
|
+
it('processes eva wake events', () => {
|
|
919
|
+
const spy = sinon.spy();
|
|
920
|
+
voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
|
|
1255
921
|
|
|
1256
|
-
|
|
922
|
+
eventHandler({
|
|
923
|
+
sequenceNumber: 1,
|
|
924
|
+
headers: {},
|
|
925
|
+
data: {
|
|
926
|
+
relayType: 'voicea.transcription',
|
|
927
|
+
voiceaPayload: {type: 'eva_wake'},
|
|
928
|
+
},
|
|
929
|
+
});
|
|
1257
930
|
|
|
1258
|
-
assert.
|
|
1259
|
-
assert.notCalled(voiceaService.webex.internal.llm.socket.send);
|
|
931
|
+
assert.calledOnceWithExactly(spy, {isListening: true});
|
|
1260
932
|
});
|
|
1261
933
|
|
|
1262
|
-
it('
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
voiceaService.onCaptionServiceIdUpdate('new-svc');
|
|
934
|
+
it('processes eva cancel events', () => {
|
|
935
|
+
const spy = sinon.spy();
|
|
936
|
+
voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
|
|
1267
937
|
|
|
1268
|
-
|
|
1269
|
-
|
|
938
|
+
eventHandler({
|
|
939
|
+
sequenceNumber: 1,
|
|
940
|
+
headers: {},
|
|
941
|
+
data: {
|
|
942
|
+
relayType: 'voicea.transcription',
|
|
943
|
+
voiceaPayload: {type: 'eva_cancel'},
|
|
944
|
+
},
|
|
945
|
+
});
|
|
1270
946
|
|
|
1271
|
-
|
|
1272
|
-
expect(callArgs).to.have.nested.property('headers.to', 'new-svc');
|
|
1273
|
-
expect(callArgs).to.have.nested.property('data.clientPayload.translationLanguage', 'es');
|
|
947
|
+
assert.calledOnceWithExactly(spy, {isListening: false});
|
|
1274
948
|
});
|
|
1275
949
|
|
|
1276
|
-
it('
|
|
1277
|
-
|
|
1278
|
-
|
|
950
|
+
it('processes eva thanks events', () => {
|
|
951
|
+
const spy = sinon.spy();
|
|
952
|
+
voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
|
|
1279
953
|
|
|
1280
|
-
|
|
954
|
+
eventHandler({
|
|
955
|
+
sequenceNumber: 1,
|
|
956
|
+
headers: {},
|
|
957
|
+
data: {
|
|
958
|
+
relayType: 'voicea.transcription',
|
|
959
|
+
voiceaPayload: {type: 'eva_thanks', command_response: 'OK, noted'},
|
|
960
|
+
},
|
|
961
|
+
});
|
|
1281
962
|
|
|
1282
|
-
assert.
|
|
1283
|
-
assert.notCalled(voiceaService.webex.internal.llm.socket.send);
|
|
963
|
+
assert.calledOnceWithExactly(spy, {isListening: false, text: 'OK, noted'});
|
|
1284
964
|
});
|
|
1285
|
-
});
|
|
1286
965
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
966
|
+
it('processes language detected when in spoken languages', () => {
|
|
967
|
+
const spy = sinon.spy();
|
|
968
|
+
voiceaChannel.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, spy);
|
|
1290
969
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
970
|
+
// First set up spoken languages via announcement
|
|
971
|
+
eventHandler({
|
|
972
|
+
sequenceNumber: 1,
|
|
973
|
+
headers: {from: 'ws-service'},
|
|
974
|
+
data: {
|
|
975
|
+
relayType: 'voicea.annc',
|
|
976
|
+
voiceaPayload: {
|
|
977
|
+
ASR: {spoken_languages: ['en', 'es', 'fr']},
|
|
978
|
+
},
|
|
979
|
+
},
|
|
1294
980
|
});
|
|
1295
981
|
|
|
1296
|
-
|
|
982
|
+
// Then emit language detected
|
|
983
|
+
eventHandler({
|
|
984
|
+
sequenceNumber: 2,
|
|
985
|
+
headers: {},
|
|
986
|
+
data: {
|
|
987
|
+
relayType: 'voicea.transcription',
|
|
988
|
+
voiceaPayload: {type: 'language_detected', language: 'es'},
|
|
989
|
+
},
|
|
990
|
+
});
|
|
1297
991
|
|
|
1298
|
-
|
|
1299
|
-
voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(true);
|
|
992
|
+
assert.calledOnceWithExactly(spy, {languageCode: 'es'});
|
|
1300
993
|
});
|
|
1301
994
|
|
|
1302
|
-
it('
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
unsubscribe: ['polls'],
|
|
1306
|
-
});
|
|
995
|
+
it('does not emit language detected when not in spoken languages', () => {
|
|
996
|
+
const spy = sinon.spy();
|
|
997
|
+
voiceaChannel.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, spy);
|
|
1307
998
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
{
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
datachannelUri: 'mock-datachannel-uri',
|
|
1317
|
-
subscribe: ['transcription'],
|
|
1318
|
-
unsubscribe: ['polls'],
|
|
999
|
+
// First set up spoken languages via announcement
|
|
1000
|
+
eventHandler({
|
|
1001
|
+
sequenceNumber: 1,
|
|
1002
|
+
headers: {from: 'ws-service'},
|
|
1003
|
+
data: {
|
|
1004
|
+
relayType: 'voicea.annc',
|
|
1005
|
+
voiceaPayload: {
|
|
1006
|
+
ASR: {spoken_languages: ['en', 'es']},
|
|
1319
1007
|
},
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
);
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1323
1010
|
|
|
1324
|
-
|
|
1011
|
+
// Then emit language detected for unsupported language
|
|
1012
|
+
eventHandler({
|
|
1013
|
+
sequenceNumber: 2,
|
|
1014
|
+
headers: {},
|
|
1015
|
+
data: {
|
|
1016
|
+
relayType: 'voicea.transcription',
|
|
1017
|
+
voiceaPayload: {type: 'language_detected', language: 'de'},
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
assert.notCalled(spy);
|
|
1325
1022
|
});
|
|
1023
|
+
});
|
|
1326
1024
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1025
|
+
describe('#switchLLMChannel', () => {
|
|
1026
|
+
it('switches to a new LLM channel and preserves caption state', async () => {
|
|
1027
|
+
// First enable captions
|
|
1028
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1029
|
+
voiceaChannel.currentSpokenLanguage = 'es';
|
|
1329
1030
|
|
|
1330
|
-
const
|
|
1031
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1331
1032
|
|
|
1332
|
-
|
|
1333
|
-
socket.send,
|
|
1334
|
-
{
|
|
1335
|
-
id: '1',
|
|
1336
|
-
type: 'subchannelSubscriptionRequest',
|
|
1337
|
-
data: {
|
|
1338
|
-
datachannelUri: 'mock-datachannel-uri',
|
|
1339
|
-
subscribe: [],
|
|
1340
|
-
unsubscribe: [],
|
|
1341
|
-
},
|
|
1342
|
-
trackingId: sinon.match.string,
|
|
1343
|
-
}
|
|
1344
|
-
);
|
|
1033
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1345
1034
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1035
|
+
// Should have unsubscribed from old channel
|
|
1036
|
+
assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
|
|
1348
1037
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1038
|
+
// Should have subscribed to new channel
|
|
1039
|
+
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1351
1040
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
});
|
|
1041
|
+
// Should have reset announcement state to joining (since captions were on and it re-announced)
|
|
1042
|
+
assert.equal(voiceaChannel.getAnnounceStatus(), 'joining');
|
|
1355
1043
|
|
|
1356
|
-
|
|
1044
|
+
// turnOnCaptions sends both an announcement and a subchannel subscription
|
|
1045
|
+
assert.calledTwice(newMockLLMChannel.socket.send);
|
|
1357
1046
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1047
|
+
// First call should be the announcement
|
|
1048
|
+
assert.calledWithExactly(newMockLLMChannel.socket.send.getCall(0), {
|
|
1049
|
+
id: '1',
|
|
1050
|
+
type: 'publishRequest',
|
|
1051
|
+
recipients: [{route: 'binding'}],
|
|
1052
|
+
headers: {},
|
|
1053
|
+
data: {
|
|
1054
|
+
clientPayload: {
|
|
1055
|
+
version: 'v2',
|
|
1056
|
+
},
|
|
1057
|
+
eventType: 'relay.event',
|
|
1058
|
+
relayType: 'client.annc',
|
|
1059
|
+
},
|
|
1060
|
+
trackingId: sinon.match.string,
|
|
1061
|
+
});
|
|
1360
1062
|
});
|
|
1361
1063
|
|
|
1362
|
-
it('
|
|
1363
|
-
|
|
1064
|
+
it('resolves after caption restoration completes', async () => {
|
|
1065
|
+
let resolveRequest;
|
|
1066
|
+
requestStub.returns(
|
|
1067
|
+
new Promise((resolve) => {
|
|
1068
|
+
resolveRequest = resolve;
|
|
1069
|
+
})
|
|
1070
|
+
);
|
|
1071
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1364
1072
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1073
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1074
|
+
const switchPromise = voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1075
|
+
let hasSwitchResolved = false;
|
|
1076
|
+
switchPromise.then(() => {
|
|
1077
|
+
hasSwitchResolved = true;
|
|
1367
1078
|
});
|
|
1368
1079
|
|
|
1369
|
-
|
|
1080
|
+
await flushPromises();
|
|
1370
1081
|
|
|
1371
|
-
|
|
1372
|
-
sinon.assert.match(voiceaService.seqNum, 1);
|
|
1373
|
-
});
|
|
1374
|
-
});
|
|
1082
|
+
assert.isFalse(hasSwitchResolved);
|
|
1375
1083
|
|
|
1084
|
+
resolveRequest();
|
|
1085
|
+
await switchPromise;
|
|
1376
1086
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
const mockWebSocket = new MockWebSocket();
|
|
1380
|
-
voiceaService.webex.internal.llm.socket = mockWebSocket;
|
|
1087
|
+
assert.isTrue(hasSwitchResolved);
|
|
1088
|
+
});
|
|
1381
1089
|
|
|
1382
|
-
|
|
1090
|
+
it('skips switch when already on the same channel', async () => {
|
|
1091
|
+
// Enable captions to verify they are NOT re-enabled on no-op switch
|
|
1092
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1093
|
+
voiceaChannel.currentSpokenLanguage = 'es';
|
|
1383
1094
|
|
|
1384
|
-
|
|
1095
|
+
// Switch to the same channel that voiceaChannel is already using
|
|
1096
|
+
await voiceaChannel.switchLLMChannel(mockLLMChannel);
|
|
1385
1097
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1098
|
+
// Should NOT have unsubscribed (no-op)
|
|
1099
|
+
assert.notCalled(mockLLMChannel.off);
|
|
1388
1100
|
|
|
1389
|
-
|
|
1101
|
+
// Should NOT have re-subscribed
|
|
1102
|
+
// The 'on' was already called in the constructor, but no NEW calls should happen
|
|
1103
|
+
// Since constructor already called 'on', we check it wasn't called again after construction
|
|
1104
|
+
// The mock was created fresh, so we just verify no additional sends
|
|
1105
|
+
assert.notCalled(mockLLMChannel.socket.send);
|
|
1390
1106
|
});
|
|
1391
1107
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1108
|
+
it('does not turn on captions if they were not on before switching', async () => {
|
|
1109
|
+
// Captions are off
|
|
1110
|
+
voiceaChannel.keepTranscriptionSubscribed = false;
|
|
1395
1111
|
|
|
1396
|
-
|
|
1397
|
-
await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
|
|
1398
|
-
{
|
|
1399
|
-
subscribe: ['transcription'],
|
|
1400
|
-
unsubscribe: ['polls'],
|
|
1401
|
-
},
|
|
1402
|
-
true
|
|
1403
|
-
);
|
|
1112
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1404
1113
|
|
|
1405
|
-
|
|
1114
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1406
1115
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
{
|
|
1410
|
-
subscribe: ['transcription'],
|
|
1411
|
-
unsubscribe: ['polls'],
|
|
1412
|
-
}
|
|
1413
|
-
);
|
|
1414
|
-
});
|
|
1116
|
+
// Should have unsubscribed from old channel
|
|
1117
|
+
assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
|
|
1415
1118
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
{ subscribe: ['transcription'] },
|
|
1419
|
-
false
|
|
1420
|
-
);
|
|
1119
|
+
// Should have subscribed to new channel
|
|
1120
|
+
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1421
1121
|
|
|
1422
|
-
|
|
1122
|
+
// Should NOT have sent any messages (no announcement for captions)
|
|
1123
|
+
assert.notCalled(newMockLLMChannel.socket.send);
|
|
1423
1124
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
{ subscribe: ['transcription'] }
|
|
1427
|
-
);
|
|
1125
|
+
// State should be idle since captions weren't re-enabled
|
|
1126
|
+
assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
|
|
1428
1127
|
});
|
|
1429
1128
|
|
|
1430
|
-
it('
|
|
1431
|
-
|
|
1129
|
+
it('handles case when not previously subscribed to events', async () => {
|
|
1130
|
+
// Create a new channel that hasn't subscribed to events
|
|
1131
|
+
const freshVoiceaChannel = new VoiceaChannel(mockLLMChannel, requestStub);
|
|
1132
|
+
freshVoiceaChannel.hasSubscribedToEvents = false;
|
|
1432
1133
|
|
|
1433
|
-
|
|
1134
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1434
1135
|
|
|
1435
|
-
|
|
1436
|
-
voiceaService.updateSubchannelSubscriptions,
|
|
1437
|
-
{}
|
|
1438
|
-
);
|
|
1439
|
-
});
|
|
1136
|
+
await freshVoiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1440
1137
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1138
|
+
// Should NOT have tried to unsubscribe from old channel (wasn't subscribed)
|
|
1139
|
+
assert.neverCalledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
|
|
1443
1140
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
);
|
|
1141
|
+
// Should have subscribed to new channel
|
|
1142
|
+
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1143
|
+
});
|
|
1448
1144
|
|
|
1449
|
-
|
|
1145
|
+
it('switches from undefined llmChannel to a valid one', async () => {
|
|
1146
|
+
// Create a channel without llmChannel
|
|
1147
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
1450
1148
|
|
|
1451
|
-
|
|
1452
|
-
voiceaService.updateSubchannelSubscriptions,
|
|
1453
|
-
{ subscribe: ['transcription'] }
|
|
1454
|
-
);
|
|
1455
|
-
});
|
|
1456
|
-
});
|
|
1149
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1457
1150
|
|
|
1458
|
-
|
|
1459
|
-
let defaultSocket;
|
|
1460
|
-
let practiceSocket;
|
|
1461
|
-
let isPracticeSessionConnected;
|
|
1151
|
+
await channelWithoutLLM.switchLLMChannel(newMockLLMChannel);
|
|
1462
1152
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
practiceSocket = new MockWebSocket();
|
|
1466
|
-
isPracticeSessionConnected = true;
|
|
1153
|
+
// Should have subscribed to new channel
|
|
1154
|
+
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1467
1155
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
channel === LLM_PRACTICE_SESSION ? isPracticeSessionConnected : true
|
|
1471
|
-
);
|
|
1472
|
-
voiceaService.webex.internal.llm.getSocket.callsFake((channel) =>
|
|
1473
|
-
channel === LLM_PRACTICE_SESSION ? practiceSocket : undefined
|
|
1474
|
-
);
|
|
1475
|
-
voiceaService.webex.internal.llm.getBinding.callsFake((channel) =>
|
|
1476
|
-
channel === LLM_PRACTICE_SESSION ? 'practice-binding' : 'default-binding'
|
|
1477
|
-
);
|
|
1478
|
-
voiceaService.seqNum = 1;
|
|
1156
|
+
// isLLMConnected should now return true
|
|
1157
|
+
assert.equal(channelWithoutLLM.isLLMConnected(), true);
|
|
1479
1158
|
});
|
|
1480
1159
|
|
|
1481
|
-
it('
|
|
1482
|
-
|
|
1160
|
+
it('defers caption restoration when new channel is not connected', async () => {
|
|
1161
|
+
// Enable captions
|
|
1162
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1163
|
+
voiceaChannel.currentSpokenLanguage = 'fr';
|
|
1483
1164
|
|
|
1484
|
-
|
|
1165
|
+
// Create a new channel that is NOT connected yet
|
|
1166
|
+
const newMockLLMChannel = createMockLLMChannel({
|
|
1167
|
+
isConnected: false,
|
|
1168
|
+
locusUrl: 'newLocusUrl',
|
|
1169
|
+
});
|
|
1485
1170
|
|
|
1486
|
-
|
|
1487
|
-
assert.notCalled(defaultSocket.send);
|
|
1171
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1488
1172
|
|
|
1489
|
-
|
|
1490
|
-
|
|
1173
|
+
// Should have subscribed to new channel events
|
|
1174
|
+
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1175
|
+
|
|
1176
|
+
// Should have registered a 'once' listener for 'online' event
|
|
1177
|
+
assert.calledWith(newMockLLMChannel.once, 'online', sinon.match.func);
|
|
1178
|
+
|
|
1179
|
+
// Should NOT have sent any messages yet (waiting for connection)
|
|
1180
|
+
assert.notCalled(newMockLLMChannel.socket.send);
|
|
1491
1181
|
});
|
|
1492
1182
|
|
|
1493
|
-
it('
|
|
1494
|
-
|
|
1495
|
-
|
|
1183
|
+
it('restores captions when deferred channel comes online', async () => {
|
|
1184
|
+
// Enable captions
|
|
1185
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1186
|
+
voiceaChannel.currentSpokenLanguage = 'de';
|
|
1496
1187
|
|
|
1497
|
-
|
|
1188
|
+
// Create a new channel that is NOT connected yet
|
|
1189
|
+
const newMockLLMChannel = createMockLLMChannel({
|
|
1190
|
+
isConnected: false,
|
|
1191
|
+
locusUrl: 'newLocusUrl',
|
|
1192
|
+
});
|
|
1498
1193
|
|
|
1499
|
-
|
|
1500
|
-
assert.notCalled(practiceSocket.send);
|
|
1194
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1501
1195
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
});
|
|
1196
|
+
// Get the 'online' listener that was registered
|
|
1197
|
+
const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
|
|
1505
1198
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1199
|
+
// Simulate channel coming online
|
|
1200
|
+
newMockLLMChannel.isConnected.returns(true);
|
|
1201
|
+
onlineListener();
|
|
1508
1202
|
|
|
1509
|
-
|
|
1510
|
-
|
|
1203
|
+
// Wait for async turnOnCaptions to complete
|
|
1204
|
+
await flushPromises();
|
|
1511
1205
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr');
|
|
1206
|
+
// Now it should have sent messages (announcement + subchannel subscription)
|
|
1207
|
+
assert.calledTwice(newMockLLMChannel.socket.send);
|
|
1515
1208
|
});
|
|
1516
1209
|
|
|
1517
|
-
it('
|
|
1518
|
-
|
|
1210
|
+
it('removes pending online listener when deregisterEvents is called', async () => {
|
|
1211
|
+
// Enable captions
|
|
1212
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1519
1213
|
|
|
1520
|
-
|
|
1214
|
+
// Create a new channel that is NOT connected yet
|
|
1215
|
+
const newMockLLMChannel = createMockLLMChannel({
|
|
1216
|
+
isConnected: false,
|
|
1217
|
+
locusUrl: 'newLocusUrl',
|
|
1218
|
+
});
|
|
1521
1219
|
|
|
1522
|
-
|
|
1523
|
-
assert.notCalled(practiceSocket.send);
|
|
1220
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1524
1221
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1222
|
+
// Get the 'online' listener that was registered
|
|
1223
|
+
const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
|
|
1224
|
+
|
|
1225
|
+
// Now deregister events
|
|
1226
|
+
voiceaChannel.deregisterEvents();
|
|
1227
|
+
|
|
1228
|
+
// Should have removed the 'online' listener
|
|
1229
|
+
assert.calledWith(newMockLLMChannel.off, 'online', onlineListener);
|
|
1528
1230
|
});
|
|
1529
1231
|
|
|
1530
|
-
it('
|
|
1531
|
-
|
|
1232
|
+
it('removes old pending online listener when switching channels again', async () => {
|
|
1233
|
+
// Enable captions
|
|
1234
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1532
1235
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1236
|
+
// Create first new channel that is NOT connected
|
|
1237
|
+
const firstNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'firstUrl'});
|
|
1535
1238
|
|
|
1536
|
-
|
|
1537
|
-
expect(sent).to.have.nested.property('recipients[0].route', 'practice-binding');
|
|
1538
|
-
expect(sent).to.have.nested.property(
|
|
1539
|
-
'data.transcriptPayload.type',
|
|
1540
|
-
'manual_caption_final_result'
|
|
1541
|
-
);
|
|
1542
|
-
});
|
|
1239
|
+
await voiceaChannel.switchLLMChannel(firstNewChannel);
|
|
1543
1240
|
|
|
1544
|
-
|
|
1545
|
-
|
|
1241
|
+
// Get the 'online' listener registered on first channel
|
|
1242
|
+
const firstOnlineListener = firstNewChannel.once.getCall(0).args[1];
|
|
1546
1243
|
|
|
1547
|
-
|
|
1244
|
+
// Now switch to another channel (also not connected)
|
|
1245
|
+
const secondNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'secondUrl'});
|
|
1548
1246
|
|
|
1549
|
-
|
|
1550
|
-
assert.notCalled(practiceSocket.send);
|
|
1247
|
+
await voiceaChannel.switchLLMChannel(secondNewChannel);
|
|
1551
1248
|
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
);
|
|
1249
|
+
// Should have removed the 'online' listener from first channel
|
|
1250
|
+
assert.calledWith(firstNewChannel.off, 'online', firstOnlineListener);
|
|
1251
|
+
|
|
1252
|
+
// Should have registered a new 'online' listener on second channel
|
|
1253
|
+
assert.calledWith(secondNewChannel.once, 'online', sinon.match.func);
|
|
1558
1254
|
});
|
|
1559
1255
|
|
|
1560
|
-
it('
|
|
1561
|
-
|
|
1256
|
+
it('does not duplicate caption HTTP requests on concurrent same-channel switches', async () => {
|
|
1257
|
+
// Enable captions
|
|
1258
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1259
|
+
voiceaChannel.currentSpokenLanguage = 'en';
|
|
1562
1260
|
|
|
1563
|
-
|
|
1261
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1564
1262
|
|
|
1565
|
-
//
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1263
|
+
// Simulate concurrent switches to the same channel
|
|
1264
|
+
const switch1 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1265
|
+
const switch2 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1266
|
+
|
|
1267
|
+
await Promise.all([switch1, switch2]);
|
|
1268
|
+
|
|
1269
|
+
// Wait for fire-and-forget turnOnCaptions to complete
|
|
1270
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1271
|
+
|
|
1272
|
+
// Should only have sent 2 messages total (announcement + subchannel subscription)
|
|
1273
|
+
// NOT 4 messages (which would indicate duplicate switches)
|
|
1274
|
+
assert.calledTwice(newMockLLMChannel.socket.send);
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
describe('#requireLLMChannel', () => {
|
|
1279
|
+
it('throws when llmChannel is undefined', () => {
|
|
1280
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
1281
|
+
|
|
1282
|
+
assert.throws(
|
|
1283
|
+
() => channelWithoutLLM.sendAnnouncement(),
|
|
1284
|
+
'VoiceaChannel: LLM channel not available'
|
|
1285
|
+
);
|
|
1286
|
+
});
|
|
1577
1287
|
|
|
1578
|
-
|
|
1579
|
-
|
|
1288
|
+
it('does not throw when llmChannel is defined', () => {
|
|
1289
|
+
// voiceaChannel has a mockLLMChannel, so this should not throw
|
|
1290
|
+
assert.doesNotThrow(() => voiceaChannel.sendAnnouncement());
|
|
1580
1291
|
});
|
|
1581
1292
|
});
|
|
1582
1293
|
});
|