@webex/internal-plugin-voicea 3.11.0 → 3.12.0-llmrefactor.2

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.
@@ -4,70 +4,81 @@ 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 VoiceaService from '../../../src/index';
8
+ import {VoiceaChannel} from '../../../src/voicea';
10
9
  import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants';
11
10
 
11
+ /**
12
+ * Creates a mock LLM channel for testing
13
+ * @param {Object} [options] - Options for the mock channel
14
+ * @param {boolean} [options.isConnected=true] - Whether the channel is connected
15
+ * @param {string} [options.locusUrl] - The locus URL
16
+ * @returns {Object} Mock channel
17
+ */
18
+ function createMockLLMChannel(options = {}) {
19
+ const mockWebSocket = new MockWebSocket();
20
+ const {isConnected = true, locusUrl = 'locusUrl'} = options;
21
+
22
+ return {
23
+ isConnected: sinon.stub().returns(isConnected),
24
+ getSocket: sinon.stub().returns(mockWebSocket),
25
+ getBinding: sinon.stub().returns('binding'),
26
+ getDatachannelUrl: sinon.stub().returns('datachannelUrl'),
27
+ getLocusUrl: sinon.stub().returns(locusUrl),
28
+ isDataChannelTokenEnabled: sinon.stub().resolves(true),
29
+ on: sinon.stub(),
30
+ off: sinon.stub(),
31
+ socket: mockWebSocket,
32
+ };
33
+ }
34
+
12
35
  describe('plugin-voicea', () => {
13
36
  const locusUrl = 'locusUrl';
14
37
 
15
- describe('voicea', () => {
16
- let webex, voiceaService;
38
+ describe('VoiceaChannel', () => {
39
+ let webex, voiceaChannel, mockLLMChannel, requestStub;
17
40
 
18
41
  beforeEach(() => {
19
42
  webex = new MockWebex({
20
43
  children: {
21
44
  mercury: Mercury,
22
- llm: LLMChannel,
23
- voicea: VoiceaService,
24
45
  },
25
46
  });
26
47
 
27
- voiceaService = webex.internal.voicea;
28
- voiceaService.connect = sinon.stub().resolves(true);
29
- voiceaService.webex.internal.llm.isConnected = sinon.stub().returns(true);
30
- voiceaService.webex.internal.llm.getBinding = sinon.stub().returns(undefined);
31
- voiceaService.webex.internal.llm.getLocusUrl = sinon.stub().returns(locusUrl);
32
-
33
- voiceaService.request = sinon.stub().resolves({
48
+ requestStub = sinon.stub().resolves({
34
49
  headers: {},
35
50
  body: '',
36
51
  });
37
- voiceaService.register = sinon.stub().resolves({
38
- body: {
39
- binding: 'binding',
40
- webSocketUrl: 'url',
41
- },
42
- });
52
+
53
+ mockLLMChannel = createMockLLMChannel({locusUrl});
54
+ voiceaChannel = new VoiceaChannel(mockLLMChannel, requestStub);
43
55
  });
44
56
 
45
- describe("#constructor", () => {
46
- it('should init status', () => {
47
- assert.equal(voiceaService.announceStatus, 'idle');
48
- assert.equal(voiceaService.captionStatus, 'idle');
49
- });
57
+ afterEach(() => {
58
+ voiceaChannel.deregisterEvents();
59
+ sinon.restore();
50
60
  });
51
61
 
52
- describe('#sendAnnouncement', () => {
53
- beforeEach(async () => {
54
- const mockWebSocket = new MockWebSocket();
62
+ describe('#constructor', () => {
63
+ it('should init status', () => {
64
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
65
+ assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
66
+ });
55
67
 
56
- voiceaService.webex.internal.llm.socket = mockWebSocket;
57
- voiceaService.announceStatus = "idle";
68
+ it('should subscribe to relay events', () => {
69
+ assert.calledWith(mockLLMChannel.on, 'event:relay.event', sinon.match.func);
58
70
  });
71
+ });
59
72
 
73
+ describe('#sendAnnouncement', () => {
60
74
  it("sends announcement if voicea hasn't joined", () => {
61
- const spy = sinon.spy(voiceaService, 'listenToEvents');
62
-
63
- voiceaService.sendAnnouncement();
64
- assert.equal(voiceaService.announceStatus, 'joining');
65
- assert.calledOnce(spy);
75
+ voiceaChannel.sendAnnouncement();
76
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joining');
66
77
 
67
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
78
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
68
79
  id: '1',
69
80
  type: 'publishRequest',
70
- recipients: {route: undefined},
81
+ recipients: [{route: 'binding'}],
71
82
  headers: {},
72
83
  data: {
73
84
  clientPayload: {
@@ -80,29 +91,15 @@ describe('plugin-voicea', () => {
80
91
  });
81
92
  });
82
93
 
83
- it('listens to events once', () => {
84
- const spy = sinon.spy(webex.internal.llm, 'on');
85
-
86
- voiceaService.sendAnnouncement();
87
-
88
- voiceaService.sendAnnouncement();
89
-
90
- assert.calledOnceWithExactly(spy, 'event:relay.event', sinon.match.func);
91
- });
92
-
93
94
  it('includes captionServiceId in headers when set', () => {
94
- const mockWebSocket = new MockWebSocket();
95
-
96
- voiceaService.webex.internal.llm.socket = mockWebSocket;
97
- voiceaService.announceStatus = 'idle';
98
- voiceaService.captionServiceId = 'svc-123';
95
+ voiceaChannel.captionServiceId = 'svc-123';
99
96
 
100
- voiceaService.sendAnnouncement();
97
+ voiceaChannel.sendAnnouncement();
101
98
 
102
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
99
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
103
100
  id: '1',
104
101
  type: 'publishRequest',
105
- recipients: {route: undefined},
102
+ recipients: [{route: 'binding'}],
106
103
  headers: {to: 'svc-123'},
107
104
  data: {
108
105
  clientPayload: {
@@ -117,49 +114,38 @@ describe('plugin-voicea', () => {
117
114
  });
118
115
 
119
116
  describe('#sendManualClosedCaption', () => {
120
- beforeEach(async () => {
121
- const mockWebSocket = new MockWebSocket();
122
- voiceaService.webex.internal.llm.socket = mockWebSocket;
123
- voiceaService.seqNum = 1;
124
- });
125
-
126
117
  it('sends interim manual closed caption when connected', () => {
127
118
  const text = 'Test interim caption';
128
119
  const timeStamp = 1234567890;
129
120
  const csis = [123456];
130
121
  const isFinal = false;
131
122
 
132
- voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal);
133
-
134
- assert.calledOnceWithExactly(
135
- voiceaService.webex.internal.llm.socket.send,
136
- {
137
- id: '1',
138
- type: 'publishRequest',
139
- recipients: {route: undefined},
140
- headers: {},
141
- data: {
142
- eventType: 'relay.event',
143
- relayType: 'client.manual_transcription',
144
- transcriptPayload: {
145
- type: 'manual_caption_interim_result',
146
- id: sinon.match.string,
147
- transcripts: [
148
- {
149
- text: 'Test interim caption',
150
- start_millis: 1234567890,
151
- end_millis: 1234567890,
152
- csis: [123456],
153
- },
154
- ],
155
- transcript_id: sinon.match.string,
156
- },
123
+ voiceaChannel.sendManualClosedCaption(text, timeStamp, csis, isFinal);
124
+
125
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
126
+ id: '1',
127
+ type: 'publishRequest',
128
+ recipients: [{route: 'binding'}],
129
+ headers: {},
130
+ data: {
131
+ eventType: 'relay.event',
132
+ relayType: 'client.manual_transcription',
133
+ transcriptPayload: {
134
+ type: 'manual_caption_interim_result',
135
+ id: sinon.match.string,
136
+ transcripts: [
137
+ {
138
+ text: 'Test interim caption',
139
+ start_millis: 1234567890,
140
+ end_millis: 1234567890,
141
+ csis: [123456],
142
+ },
143
+ ],
144
+ transcript_id: sinon.match.string,
157
145
  },
158
- trackingId: sinon.match.string,
159
- }
160
- );
161
- // seqNum should increment
162
- assert.equal(voiceaService.seqNum, 2);
146
+ },
147
+ trackingId: sinon.match.string,
148
+ });
163
149
  });
164
150
 
165
151
  it('sends final manual closed caption when connected', () => {
@@ -168,137 +154,74 @@ describe('plugin-voicea', () => {
168
154
  const csis = [654321];
169
155
  const isFinal = true;
170
156
 
171
- voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal);
172
-
173
- assert.calledOnceWithExactly(
174
- voiceaService.webex.internal.llm.socket.send,
175
- {
176
- id: '1',
177
- type: 'publishRequest',
178
- recipients: {route: undefined},
179
- headers: {},
180
- data: {
181
- eventType: 'relay.event',
182
- relayType: 'client.manual_transcription',
183
- transcriptPayload: {
184
- type: 'manual_caption_final_result',
185
- id: sinon.match.string,
186
- transcripts: [
187
- {
188
- text: 'Test final caption',
189
- start_millis: 9876543210,
190
- end_millis: 9876543210,
191
- csis: [654321],
192
- },
193
- ],
194
- transcript_id: sinon.match.string,
195
- },
157
+ voiceaChannel.sendManualClosedCaption(text, timeStamp, csis, isFinal);
158
+
159
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
160
+ id: '1',
161
+ type: 'publishRequest',
162
+ recipients: [{route: 'binding'}],
163
+ headers: {},
164
+ data: {
165
+ eventType: 'relay.event',
166
+ relayType: 'client.manual_transcription',
167
+ transcriptPayload: {
168
+ type: 'manual_caption_final_result',
169
+ id: sinon.match.string,
170
+ transcripts: [
171
+ {
172
+ text: 'Test final caption',
173
+ start_millis: 9876543210,
174
+ end_millis: 9876543210,
175
+ csis: [654321],
176
+ },
177
+ ],
178
+ transcript_id: sinon.match.string,
196
179
  },
197
- trackingId: sinon.match.string,
198
- }
199
- );
200
- // seqNum should increment
201
- assert.equal(voiceaService.seqNum, 2);
180
+ },
181
+ trackingId: sinon.match.string,
182
+ });
202
183
  });
203
184
 
204
185
  it('does not send if not connected', () => {
205
- voiceaService.webex.internal.llm.isConnected.returns(false);
206
-
207
- const text = 'Should not send';
208
- const timeStamp = 111;
209
- const csis = [1];
210
- const isFinal = true;
186
+ const disconnectedChannel = createMockLLMChannel({isConnected: false});
187
+ const channel = new VoiceaChannel(disconnectedChannel, requestStub);
211
188
 
212
- voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal);
189
+ channel.sendManualClosedCaption('Should not send', 111, [1], true);
213
190
 
214
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
191
+ assert.notCalled(disconnectedChannel.socket.send);
215
192
  });
216
193
  });
217
194
 
218
195
  describe('#deregisterEvents', () => {
219
- beforeEach(async () => {
220
- const mockWebSocket = new MockWebSocket();
221
196
 
222
- voiceaService.webex.internal.llm.socket = mockWebSocket;
197
+ beforeEach(async () => {
198
+ voiceaChannel.keepTranscriptionSubscribed = true;
223
199
  });
224
200
 
225
- it('deregisters voicea service', async () => {
226
- voiceaService.listenToEvents();
227
- await voiceaService.toggleTranscribing(true);
228
-
229
- // eslint-disable-next-line no-underscore-dangle
230
- voiceaService.webex.internal.llm._emit('event:relay.event', {
231
- headers: {from: 'ws'},
232
- data: {relayType: 'voicea.annc', voiceaPayload: {}},
233
- });
234
-
235
- assert.equal(voiceaService.areCaptionsEnabled, true);
236
- assert.equal(voiceaService.captionServiceId, 'ws');
237
-
238
- voiceaService.deregisterEvents();
239
- assert.equal(voiceaService.areCaptionsEnabled, false);
240
- assert.equal(voiceaService.captionServiceId, undefined);
241
- assert.equal(voiceaService.announceStatus, 'idle');
242
- assert.equal(voiceaService.captionStatus, 'idle');
243
- });
244
- });
245
- describe('#processAnnouncementMessage', () => {
246
- it('works on non-empty payload', async () => {
247
- const voiceaPayload = {
248
- translation: {
249
- allowed_languages: ['af', 'am'],
250
- max_languages: 5,
251
- },
252
- ASR: {
253
- spoken_languages: ['en'],
254
- },
255
-
256
- version: 'v2',
257
- };
258
-
259
- const spy = sinon.spy();
260
-
261
- voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
262
- voiceaService.listenToEvents();
263
- voiceaService.processAnnouncementMessage(voiceaPayload);
264
- assert.calledOnceWithExactly(spy, {
265
- captionLanguages: ['af', 'am'],
266
- spokenLanguages: ['en'],
267
- maxLanguages: 5,
268
- currentSpokenLanguage: 'en',
269
- });
270
- });
201
+ it('deregisters voicea channel and resets state', () => {
202
+ voiceaChannel.areCaptionsEnabled = true;
203
+ voiceaChannel.captionServiceId = 'ws';
204
+ voiceaChannel.keepTranscriptionSubscribed = true;
271
205
 
272
- it('works on non-empty payload', async () => {
273
- const spy = sinon.spy();
206
+ voiceaChannel.deregisterEvents();
274
207
 
275
- voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
276
- voiceaService.listenToEvents();
277
- voiceaService.currentSpokenLanguage = 'fr';
278
- await voiceaService.processAnnouncementMessage({});
279
- assert.calledOnceWithExactly(spy, {
280
- captionLanguages: [],
281
- spokenLanguages: [],
282
- maxLanguages: 0,
283
- currentSpokenLanguage: 'fr',
284
- });
208
+ assert.equal(voiceaChannel.areCaptionsEnabled, false);
209
+ assert.equal(voiceaChannel.captionServiceId, undefined);
210
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
211
+ assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
212
+ assert.equal(voiceaChannel.keepTranscriptionSubscribed, false);
213
+ assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
285
214
  });
286
215
  });
287
216
 
288
217
  describe('#requestLanguage', () => {
289
- beforeEach(async () => {
290
- const mockWebSocket = new MockWebSocket();
291
-
292
- voiceaService.webex.internal.llm.socket = mockWebSocket;
293
- });
294
-
295
218
  it('requests caption language', () => {
296
- voiceaService.requestLanguage('en');
219
+ voiceaChannel.requestLanguage('en');
297
220
 
298
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
221
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
299
222
  id: '1',
300
223
  type: 'publishRequest',
301
- recipients: {route: undefined},
224
+ recipients: [{route: 'binding'}],
302
225
  headers: {to: undefined},
303
226
  data: {
304
227
  clientPayload: {
@@ -313,14 +236,14 @@ describe('plugin-voicea', () => {
313
236
  });
314
237
 
315
238
  it('uses captionServiceId as "to" header when set', () => {
316
- voiceaService.captionServiceId = 'svc-456';
239
+ voiceaChannel.captionServiceId = 'svc-456';
317
240
 
318
- voiceaService.requestLanguage('fr');
241
+ voiceaChannel.requestLanguage('fr');
319
242
 
320
- assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, {
243
+ assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
321
244
  id: '1',
322
245
  type: 'publishRequest',
323
- recipients: {route: undefined},
246
+ recipients: [{route: 'binding'}],
324
247
  headers: {to: 'svc-456'},
325
248
  data: {
326
249
  clientPayload: {
@@ -333,6 +256,15 @@ describe('plugin-voicea', () => {
333
256
  trackingId: sinon.match.string,
334
257
  });
335
258
  });
259
+
260
+ it('does not send when not connected', () => {
261
+ const disconnectedChannel = createMockLLMChannel({isConnected: false});
262
+ const channel = new VoiceaChannel(disconnectedChannel, requestStub);
263
+
264
+ channel.requestLanguage('en');
265
+
266
+ assert.notCalled(disconnectedChannel.socket.send);
267
+ });
336
268
  });
337
269
 
338
270
  describe('#setSpokenLanguage', () => {
@@ -340,38 +272,33 @@ describe('plugin-voicea', () => {
340
272
  const languageCode = 'en';
341
273
  const triggerSpy = sinon.spy();
342
274
 
343
- voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
344
- voiceaService.listenToEvents();
345
- await voiceaService.setSpokenLanguage(languageCode);
275
+ voiceaChannel.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
276
+ await voiceaChannel.setSpokenLanguage(languageCode);
346
277
 
347
278
  assert.calledOnceWithExactly(triggerSpy, {languageCode});
348
279
 
349
280
  sinon.assert.calledWith(
350
- voiceaService.request,
281
+ requestStub,
351
282
  sinon.match({
352
283
  method: 'PUT',
353
284
  url: `${locusUrl}/controls/`,
354
285
  body: {
355
286
  transcribe: {
356
287
  spokenLanguage: languageCode,
357
- }
288
+ },
358
289
  },
359
290
  })
360
291
  );
361
292
  });
293
+
362
294
  it('sets spoken language with language assignment', async () => {
363
295
  const languageCode = 'zh';
364
296
  const languageAssignment = 'DEFAULT';
365
- const triggerSpy = sinon.spy();
366
297
 
367
- voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
368
- voiceaService.listenToEvents();
369
- await voiceaService.setSpokenLanguage(languageCode, languageAssignment);
370
-
371
- assert.calledOnceWithExactly(triggerSpy, {languageCode});
298
+ await voiceaChannel.setSpokenLanguage(languageCode, languageAssignment);
372
299
 
373
300
  sinon.assert.calledWith(
374
- voiceaService.request,
301
+ requestStub,
375
302
  sinon.match({
376
303
  method: 'PUT',
377
304
  url: `${locusUrl}/controls/`,
@@ -379,831 +306,711 @@ describe('plugin-voicea', () => {
379
306
  transcribe: {
380
307
  spokenLanguage: languageCode,
381
308
  languageAssignment,
382
- }
309
+ },
383
310
  },
384
311
  })
385
312
  );
386
313
  });
387
-
388
314
  });
389
315
 
390
- describe('#requestTurnOnCaptions', () => {
391
- beforeEach(async () => {
392
- const mockWebSocket = new MockWebSocket();
393
-
394
- voiceaService.webex.internal.llm.socket = mockWebSocket;
395
- voiceaService.captionStatus = 'idle';
316
+ describe('#isLLMConnected', () => {
317
+ it('returns true when the LLM channel is connected', () => {
318
+ assert.equal(voiceaChannel.isLLMConnected(), true);
396
319
  });
397
320
 
398
- afterEach( () => {
399
- voiceaService.captionStatus = 'idle';
400
- })
401
-
402
- it('turns on captions', async () => {
403
- const announcementSpy = sinon.spy(voiceaService, 'announce');
404
-
405
- const triggerSpy = sinon.spy();
406
-
407
- voiceaService.on(EVENT_TRIGGERS.CAPTIONS_TURNED_ON, triggerSpy);
408
- voiceaService.listenToEvents();
409
-
410
- await voiceaService.requestTurnOnCaptions();
411
- assert.equal(voiceaService.captionStatus, 'enabled');
412
- sinon.assert.calledWith(
413
- voiceaService.request,
414
- sinon.match({
415
- method: 'PUT',
416
- url: `${locusUrl}/controls/`,
417
- body: {transcribe: {caption: true}},
418
- })
419
- );
420
-
421
- assert.calledOnceWithExactly(triggerSpy);
422
-
423
- assert.calledOnce(announcementSpy);
321
+ it('returns false when the LLM channel is not connected', () => {
322
+ mockLLMChannel.isConnected.returns(false);
323
+ assert.equal(voiceaChannel.isLLMConnected(), false);
424
324
  });
325
+ });
425
326
 
426
- it("should handle request fail", async () => {
427
- voiceaService.captionStatus = 'sending';
428
- voiceaService.request = sinon.stub().rejects();
327
+ describe('#getKeepTranscriptionSubscribed', () => {
328
+ it('returns false when keepTranscriptionSubscribed is false', () => {
329
+ voiceaChannel.keepTranscriptionSubscribed = false;
330
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), false);
331
+ });
429
332
 
430
- try {
431
- await voiceaService.requestTurnOnCaptions();
432
- } catch (error) {
433
- expect(error.message).to.include('turn on captions fail');
434
- return;
435
- }
436
- assert.equal(voiceaService.captionStatus, 'idle');
333
+ it('returns true when keepTranscriptionSubscribed is true', () => {
334
+ voiceaChannel.keepTranscriptionSubscribed = true;
335
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
437
336
  });
438
337
  });
439
338
 
440
- describe("#isAnnounceProcessing", () => {
441
- afterEach(() => {
442
- voiceaService.announceStatus = 'idle';
339
+ describe('#announce', () => {
340
+ it('announce to llm data channel', () => {
341
+ const sendAnnouncementSpy = sinon.spy(voiceaChannel, 'sendAnnouncement');
342
+ voiceaChannel.announce();
343
+ assert.calledOnce(sendAnnouncementSpy);
443
344
  });
444
345
 
445
- ['joining', 'joined'].forEach((status) => {
446
- it(`should return true when status is ${status}`, () => {
447
- voiceaService.announceStatus = status;
448
- assert.equal(voiceaService.isAnnounceProcessing(), true);
449
- });
346
+ it('throws when llm is not connected', () => {
347
+ mockLLMChannel.isConnected.returns(false);
348
+ assert.throws(
349
+ () => voiceaChannel.announce(),
350
+ 'voicea can not announce before llm connected'
351
+ );
450
352
  });
451
353
 
452
- it('should return false when status is not processing status', () => {
453
- voiceaService.announceStatus = 'idle';
454
- assert.equal(voiceaService.isAnnounceProcessing(), false);
354
+ it('should not announce duplicate when already processed', () => {
355
+ voiceaChannel.announceStatus = 'joined';
356
+ const sendAnnouncementSpy = sinon.spy(voiceaChannel, 'sendAnnouncement');
357
+ voiceaChannel.announce();
358
+ assert.notCalled(sendAnnouncementSpy);
455
359
  });
456
360
  });
457
361
 
458
- describe("#announce", () => {
459
- let isAnnounceProcessing, sendAnnouncement;
460
- beforeEach(() => {
461
- voiceaService.webex.internal.llm.isConnected.returns(true);
462
- sendAnnouncement = sinon.stub(voiceaService, 'sendAnnouncement');
463
- isAnnounceProcessing = sinon.stub(voiceaService, 'isAnnounceProcessing').returns(false)
464
- });
362
+ describe('#turnOnCaptions', () => {
363
+ it('turns on captions', async () => {
364
+ const announceSpy = sinon.spy(voiceaChannel, 'announce');
365
+ const triggerSpy = sinon.spy();
465
366
 
466
- afterEach(() => {
467
- voiceaService.webex.internal.llm.isConnected.returns(true);
468
- isAnnounceProcessing.restore();
469
- sendAnnouncement.restore();
470
- });
367
+ voiceaChannel.on(EVENT_TRIGGERS.CAPTIONS_TURNED_ON, triggerSpy);
471
368
 
472
- it('announce to llm data channel', ()=> {
473
- voiceaService.announce();
474
- assert.calledOnce(sendAnnouncement);
475
- });
369
+ await voiceaChannel.turnOnCaptions();
476
370
 
477
- it('announce to llm data channel before llm connected', ()=> {
478
- voiceaService.webex.internal.llm.isConnected.returns(false);
479
- assert.throws(() => voiceaService.announce(), "voicea can not announce before llm connected");
480
- assert.notCalled(sendAnnouncement);
371
+ assert.equal(voiceaChannel.getCaptionStatus(), 'enabled');
372
+ assert.calledOnce(announceSpy);
373
+ assert.calledOnce(triggerSpy);
481
374
  });
482
375
 
483
- it('should not announce duplicate', () => {
484
- isAnnounceProcessing.returns(true);
485
- voiceaService.announce();
486
- assert.notCalled(sendAnnouncement);
487
- })
488
- });
376
+ it('throws when llm is not connected', async () => {
377
+ mockLLMChannel.isConnected.returns(false);
489
378
 
490
- describe("#isCaptionProcessing", () => {
491
- afterEach(() => {
492
- voiceaService.captionStatus = 'idle';
379
+ await assert.isRejected(
380
+ voiceaChannel.turnOnCaptions(),
381
+ 'can not turn on captions before llm connected'
382
+ );
493
383
  });
494
384
 
495
- ['sending', 'enabled'].forEach((status) => {
496
- it(`should return true when status is ${status}`, () => {
497
- voiceaService.captionStatus = status;
498
- assert.equal(voiceaService.isCaptionProcessing(), true);
499
- });
385
+ it('returns undefined when already sending', async () => {
386
+ voiceaChannel.captionStatus = 'sending';
387
+ const result = await voiceaChannel.turnOnCaptions();
388
+ assert.equal(result, undefined);
500
389
  });
501
390
 
502
- it('should return false when status is not processing status', () => {
503
- voiceaService.captionStatus = 'idle';
504
- assert.equal(voiceaService.isCaptionProcessing(), false);
505
- });
506
- });
391
+ it('throws error on request failure', async () => {
392
+ requestStub.rejects(new Error('Request failed'));
507
393
 
508
- describe('#turnOnCaptions', () => {
509
- let requestTurnOnCaptions;
510
- beforeEach(() => {
511
- requestTurnOnCaptions = sinon.stub(voiceaService, 'requestTurnOnCaptions');
512
- voiceaService.captionStatus = 'idle';
513
- voiceaService.webex.internal.llm.isConnected.returns(true);
394
+ await assert.isRejected(voiceaChannel.turnOnCaptions(), 'turn on captions fail');
514
395
  });
515
396
 
516
- afterEach(() => {
517
- requestTurnOnCaptions.restore();
518
- voiceaService.captionStatus = 'idle';
519
- voiceaService.webex.internal.llm.isConnected.returns(true);
520
- });
397
+ it('resets caption status to idle on error', async () => {
398
+ requestStub.rejects(new Error('Request failed'));
521
399
 
522
- it('call request turn on captions', () => {
523
- voiceaService.captionStatus = 'idle';
524
- voiceaService.turnOnCaptions();
525
- assert.calledOnce(requestTurnOnCaptions);
526
- });
527
-
528
- it("turns on captions before llm connected", () => {
529
- voiceaService.captionStatus = 'idle';
530
- voiceaService.webex.internal.llm.isConnected.returns(true);
531
- // assert.throws(() => voiceaService.turnOnCaptions(), "can not turn on captions before llm connected");
532
- assert.notCalled(requestTurnOnCaptions);
533
- });
400
+ try {
401
+ await voiceaChannel.turnOnCaptions();
402
+ } catch {
403
+ // expected
404
+ }
534
405
 
535
- it('should not turn on duplicate when processing', () => {
536
- voiceaService.captionStatus = 'sending';
537
- voiceaService.turnOnCaptions();
538
- assert.notCalled(voiceaService.requestTurnOnCaptions);
406
+ assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
539
407
  });
540
408
  });
541
409
 
542
410
  describe('#toggleTranscribing', () => {
543
- beforeEach(async () => {
544
- const mockWebSocket = new MockWebSocket();
545
-
546
- voiceaService.webex.internal.llm.socket = mockWebSocket;
547
- });
548
-
549
- it('turns on transcribing with CC enabled', async () => {
550
- // Turn on captions
551
- await voiceaService.turnOnCaptions();
552
- const announcementSpy = sinon.spy(voiceaService, 'sendAnnouncement');
553
-
554
- // eslint-disable-next-line no-underscore-dangle
555
- voiceaService.webex.internal.llm._emit('event:relay.event', {
556
- headers: {from: 'ws'},
557
- data: {relayType: 'voicea.annc', voiceaPayload: {}},
558
- });
411
+ it('turns on transcribing', async () => {
412
+ await voiceaChannel.toggleTranscribing(true);
559
413
 
560
- voiceaService.listenToEvents();
561
-
562
- await voiceaService.toggleTranscribing(true);
563
414
  sinon.assert.calledWith(
564
- voiceaService.request,
415
+ requestStub,
565
416
  sinon.match({
566
417
  method: 'PUT',
567
418
  url: `${locusUrl}/controls/`,
568
419
  body: {transcribe: {transcribing: true}},
569
420
  })
570
421
  );
571
-
572
- assert.notCalled(announcementSpy);
573
422
  });
574
423
 
575
- it('turns on transcribing with CC disabled', async () => {
576
- const announcementSpy = sinon.spy(voiceaService, 'sendAnnouncement');
577
-
578
- voiceaService.listenToEvents();
424
+ it('turns off transcribing', async () => {
425
+ await voiceaChannel.toggleTranscribing(false);
579
426
 
580
- await voiceaService.toggleTranscribing(true);
581
427
  sinon.assert.calledWith(
582
- voiceaService.request,
428
+ requestStub,
583
429
  sinon.match({
584
430
  method: 'PUT',
585
431
  url: `${locusUrl}/controls/`,
586
- body: {transcribe: {transcribing: true}},
432
+ body: {transcribe: {transcribing: false}},
587
433
  })
588
434
  );
589
-
590
- assert.calledOnce(announcementSpy);
591
435
  });
592
436
 
593
- it('turns off transcribing', async () => {
594
- await voiceaService.toggleTranscribing(true);
437
+ it('calls turnOnCaptions when activating and captions not enabled', async () => {
438
+ voiceaChannel.areCaptionsEnabled = false;
439
+ const turnOnCaptionsSpy = sinon.spy(voiceaChannel, 'turnOnCaptions');
595
440
 
596
- const announcementSpy = sinon.spy(voiceaService, 'sendAnnouncement');
441
+ await voiceaChannel.toggleTranscribing(true, 'en');
597
442
 
598
- voiceaService.listenToEvents();
443
+ assert.calledOnceWithExactly(turnOnCaptionsSpy, 'en');
444
+ });
599
445
 
600
- await voiceaService.toggleTranscribing(false);
601
- sinon.assert.calledWith(
602
- voiceaService.request,
603
- sinon.match({
604
- method: 'PUT',
605
- url: `${locusUrl}/controls/`,
606
- body: {transcribe: {transcribing: true}},
607
- })
608
- );
446
+ it('does not call turnOnCaptions when captions already enabled', async () => {
447
+ voiceaChannel.areCaptionsEnabled = true;
448
+ const turnOnCaptionsSpy = sinon.spy(voiceaChannel, 'turnOnCaptions');
609
449
 
610
- assert.notCalled(announcementSpy);
450
+ await voiceaChannel.toggleTranscribing(true);
451
+
452
+ assert.notCalled(turnOnCaptionsSpy);
611
453
  });
612
454
  });
613
455
 
614
456
  describe('#toggleManualCaption', () => {
615
- beforeEach(async () => {
616
- const mockWebSocket = new MockWebSocket();
617
-
618
- voiceaService.webex.internal.llm.socket = mockWebSocket;
619
- voiceaService.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE;
620
- });
621
-
622
457
  it('turns on manual caption', async () => {
623
- await voiceaService.toggleManualCaption(true);
458
+ await voiceaChannel.toggleManualCaption(true);
459
+
624
460
  sinon.assert.calledWith(
625
- voiceaService.request,
461
+ requestStub,
626
462
  sinon.match({
627
463
  method: 'PUT',
628
464
  url: `${locusUrl}/controls/`,
629
465
  body: {manualCaption: {enable: true}},
630
466
  })
631
467
  );
632
-
633
468
  });
634
469
 
635
-
636
470
  it('turns off manual caption', async () => {
637
- await voiceaService.toggleManualCaption(false);
471
+ await voiceaChannel.toggleManualCaption(false);
472
+
638
473
  sinon.assert.calledWith(
639
- voiceaService.request,
474
+ requestStub,
640
475
  sinon.match({
641
476
  method: 'PUT',
642
477
  url: `${locusUrl}/controls/`,
643
478
  body: {manualCaption: {enable: false}},
644
479
  })
645
480
  );
481
+ });
482
+
483
+ it('ignores when already sending', async () => {
484
+ voiceaChannel.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING;
485
+ await voiceaChannel.toggleManualCaption(true);
486
+ sinon.assert.notCalled(requestStub);
487
+ });
488
+
489
+ it('throws error on request failure', async () => {
490
+ requestStub.rejects(new Error('Request failed'));
646
491
 
492
+ await assert.isRejected(
493
+ voiceaChannel.toggleManualCaption(true),
494
+ 'toggle manual captions fail'
495
+ );
647
496
  });
648
497
 
649
- it('ignore toggle manual caption', async () => {
650
- voiceaService.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING;
651
- await voiceaService.toggleManualCaption(true);
498
+ it('resets status to idle on error', async () => {
499
+ requestStub.rejects(new Error('Request failed'));
652
500
 
653
- sinon.assert.notCalled(voiceaService.request);
501
+ try {
502
+ await voiceaChannel.toggleManualCaption(true);
503
+ } catch {
504
+ // expected
505
+ }
506
+
507
+ assert.equal(voiceaChannel.toggleManualCaptionStatus, TOGGLE_MANUAL_CAPTION_STATUS.IDLE);
508
+ });
509
+ });
654
510
 
511
+ describe('#getCaptionStatus', () => {
512
+ it('returns current caption status', () => {
513
+ voiceaChannel.captionStatus = 'enabled';
514
+ assert.equal(voiceaChannel.getCaptionStatus(), 'enabled');
655
515
  });
656
516
  });
657
517
 
658
- describe('#processCaptionLanguageResponse', () => {
659
- it('responds to process caption language', async () => {
518
+ describe('#getAnnounceStatus', () => {
519
+ it('returns current announce status', () => {
520
+ voiceaChannel.announceStatus = 'joined';
521
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joined');
522
+ });
523
+ });
524
+
525
+ describe('#onSpokenLanguageUpdate', () => {
526
+ it('should trigger SPOKEN_LANGUAGE_UPDATE event', () => {
660
527
  const triggerSpy = sinon.spy();
661
- const functionSpy = sinon.spy(voiceaService, 'processCaptionLanguageResponse');
528
+ voiceaChannel.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
529
+
530
+ voiceaChannel.onSpokenLanguageUpdate('fr', '123');
531
+
532
+ assert.equal(voiceaChannel.currentSpokenLanguage, 'fr');
533
+ assert.calledOnceWithExactly(triggerSpy, {languageCode: 'fr', meetingId: '123'});
534
+ });
535
+ });
536
+
537
+ describe('#onCaptionServiceIdUpdate', () => {
538
+ it('does nothing when serviceId is falsy', () => {
539
+ voiceaChannel.captionServiceId = 'existing-id';
540
+ voiceaChannel.onCaptionServiceIdUpdate(undefined);
541
+ voiceaChannel.onCaptionServiceIdUpdate('');
542
+ assert.equal(voiceaChannel.captionServiceId, 'existing-id');
543
+ });
662
544
 
663
- voiceaService.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, triggerSpy);
664
- voiceaService.listenToEvents();
545
+ it('sets captionServiceId when no currentCaptionLanguage', () => {
546
+ voiceaChannel.captionServiceId = undefined;
547
+ voiceaChannel.currentCaptionLanguage = undefined;
548
+ voiceaChannel.onCaptionServiceIdUpdate('svc-new');
549
+ assert.equal(voiceaChannel.captionServiceId, 'svc-new');
550
+ });
551
+
552
+ it('re-sends language when serviceId changes and currentCaptionLanguage is set', () => {
553
+ voiceaChannel.captionServiceId = 'old-svc';
554
+ voiceaChannel.currentCaptionLanguage = 'es';
555
+
556
+ voiceaChannel.onCaptionServiceIdUpdate('new-svc');
557
+
558
+ assert.equal(voiceaChannel.captionServiceId, 'new-svc');
559
+ assert.calledOnce(mockLLMChannel.socket.send);
560
+ });
561
+ });
562
+
563
+ describe('#updateSubchannelSubscriptions', () => {
564
+ it('sends subchannelSubscriptionRequest', async () => {
565
+ await voiceaChannel.updateSubchannelSubscriptions({
566
+ subscribe: ['transcription'],
567
+ unsubscribe: ['polls'],
568
+ });
665
569
 
666
- // eslint-disable-next-line no-underscore-dangle
667
- voiceaService.webex.internal.llm._emit('event:relay.event', {
668
- headers: {from: 'ws'},
570
+ sinon.assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
571
+ id: '1',
572
+ type: 'subchannelSubscriptionRequest',
669
573
  data: {
670
- relayType: 'voicea.transl.rsp',
671
- voiceaPayload: {
672
- statusCode: 200,
673
- },
574
+ datachannelUri: 'datachannelUrl',
575
+ subscribe: ['transcription'],
576
+ unsubscribe: ['polls'],
674
577
  },
578
+ trackingId: sinon.match.string,
675
579
  });
580
+ });
581
+
582
+ it('does nothing when LLM is not connected', async () => {
583
+ mockLLMChannel.isConnected.returns(false);
584
+
585
+ await voiceaChannel.updateSubchannelSubscriptions({subscribe: ['transcription']});
676
586
 
677
- assert.calledOnceWithExactly(triggerSpy, {statusCode: 200});
678
- assert.calledOnce(functionSpy);
587
+ sinon.assert.notCalled(mockLLMChannel.socket.send);
679
588
  });
680
589
 
681
- it('responds to process caption language for a failed response', async () => {
682
- const triggerSpy = sinon.spy();
683
- const functionSpy = sinon.spy(voiceaService, 'processCaptionLanguageResponse');
590
+ it('does nothing when dataChannelToken is not enabled', async () => {
591
+ mockLLMChannel.isDataChannelTokenEnabled.resolves(false);
684
592
 
685
- voiceaService.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, triggerSpy);
686
- voiceaService.listenToEvents();
593
+ await voiceaChannel.updateSubchannelSubscriptions({subscribe: ['transcription']});
687
594
 
688
- const payload = {
689
- errorCode: 300,
690
- message: 'error text',
691
- };
595
+ sinon.assert.notCalled(mockLLMChannel.socket.send);
596
+ });
597
+ });
692
598
 
693
- // eslint-disable-next-line no-underscore-dangle
694
- voiceaService.webex.internal.llm._emit('event:relay.event', {
695
- headers: {from: 'ws'},
696
- data: {relayType: 'voicea.transl.rsp', voiceaPayload: payload},
697
- });
698
- assert.calledOnce(functionSpy);
699
- assert.calledOnceWithExactly(triggerSpy, {statusCode: 300, errorMessage: 'error text'});
599
+ describe('#updateSubchannelSubscriptionsAndSyncCaptionState', () => {
600
+ it('updates caption intent and forwards to updateSubchannelSubscriptions', async () => {
601
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
602
+
603
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
604
+ {subscribe: ['transcription']},
605
+ true
606
+ );
607
+
608
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
609
+ assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
610
+ });
611
+
612
+ it('sets caption intent to false when isCCBoxOpen is false', async () => {
613
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
614
+
615
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
616
+ {subscribe: ['transcription']},
617
+ false
618
+ );
619
+
620
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), false);
621
+ assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
622
+ });
623
+
624
+ it('defaults subscribe/unsubscribe to empty arrays when options is empty', async () => {
625
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
626
+
627
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState({}, true);
628
+
629
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
630
+ assert.calledOnceWithExactly(updateSpy, {});
631
+ });
632
+
633
+ it('still updates caption intent even if updateSubchannelSubscriptions does nothing (e.g., LLM not connected)', async () => {
634
+ mockLLMChannel.isConnected.returns(false);
635
+ const updateSpy = sinon.spy(voiceaChannel, 'updateSubchannelSubscriptions');
636
+
637
+ await voiceaChannel.updateSubchannelSubscriptionsAndSyncCaptionState(
638
+ {subscribe: ['transcription']},
639
+ true
640
+ );
641
+
642
+ assert.equal(voiceaChannel.getKeepTranscriptionSubscribed(), true);
643
+ assert.calledOnceWithExactly(updateSpy, {subscribe: ['transcription']});
700
644
  });
701
645
  });
702
646
 
703
- describe('#processTranscription', () => {
704
- let triggerSpy, functionSpy;
647
+ describe('event processor', () => {
648
+ let eventHandler;
705
649
 
706
650
  beforeEach(() => {
707
- triggerSpy = sinon.spy();
708
- functionSpy = sinon.spy(voiceaService, 'processTranscription');
709
- voiceaService.listenToEvents();
710
- });
711
-
712
- it('processes interim transcription', async () => {
713
- voiceaService.on(EVENT_TRIGGERS.NEW_CAPTION, triggerSpy);
714
- const transcripts = [
715
- {
716
- text: 'Hello.',
717
- csis: [3556942592],
718
- transcript_language_code: 'en',
719
- translations: {
720
- fr: 'Bonjour.',
721
- },
722
- },
723
- {
724
- text: 'This is Webex',
725
- csis: [3556942593],
726
- transcript_language_code: 'en',
727
- translations: {
728
- fr: "C'est Webex",
651
+ // Get the registered event handler
652
+ const onCall = mockLLMChannel.on
653
+ .getCalls()
654
+ .find((call) => call.args[0] === 'event:relay.event');
655
+ eventHandler = onCall?.args[1];
656
+ });
657
+
658
+ it('processes voicea announcement events', () => {
659
+ const spy = sinon.spy();
660
+ voiceaChannel.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
661
+
662
+ eventHandler({
663
+ sequenceNumber: 1,
664
+ headers: {from: 'ws-service'},
665
+ data: {
666
+ relayType: 'voicea.annc',
667
+ voiceaPayload: {
668
+ translation: {allowed_languages: ['en', 'es'], max_languages: 3},
669
+ ASR: {spoken_languages: ['en']},
729
670
  },
730
671
  },
731
- ];
732
- const voiceaPayload = {
733
- audio_received_millis: 0,
734
- command_response: '',
735
- csis: [3556942592],
736
- data: 'Hello.',
737
- id: '38093ff5-f6a8-581c-9e59-035ec027994b',
738
- meeting: '61d4e269-8419-42ab-9e56-3917974cda01',
739
- transcript_id: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
740
- ts: 1611653204.3147924,
741
- type: 'transcript_interim_results',
742
-
743
- transcripts,
744
- };
745
-
746
- // eslint-disable-next-line no-underscore-dangle
747
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
748
- headers: {from: 'ws'},
749
- data: {relayType: 'voicea.transcription', voiceaPayload},
750
672
  });
751
673
 
752
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
753
- assert.calledOnceWithExactly(triggerSpy, {
754
- isFinal: false,
755
- transcriptId: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
756
- transcripts,
674
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joined');
675
+ assert.calledOnceWithExactly(spy, {
676
+ captionLanguages: ['en', 'es'],
677
+ maxLanguages: 3,
678
+ spokenLanguages: ['en'],
679
+ currentSpokenLanguage: 'en',
757
680
  });
758
681
  });
759
682
 
760
- it('processes final transcription', async () => {
761
- voiceaService.on(EVENT_TRIGGERS.NEW_CAPTION, triggerSpy);
762
-
763
- const voiceaPayload = {
764
- audio_received_millis: 0,
765
- command_response: '',
766
- csis: [3556942592],
767
- data: 'Hello. This is Webex',
768
- id: '38093ff5-f6a8-581c-9e59-035ec027994b',
769
- meeting: '61d4e269-8419-42ab-9e56-3917974cda01',
770
- transcript_id: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
771
- ts: 1611653204.3147924,
772
- type: 'transcript_final_result',
773
- translations: {
774
- en: "Hello?",
775
- },
776
- transcript: {
777
- alignments: [
778
- {
779
- end_millis: 12474,
780
- start_millis: 12204,
781
- word: 'Hello?',
782
- },
783
- ],
784
- csis: [3556942592],
785
- end_millis: 13044,
786
- last_packet_timestamp_ms: 1611653206784,
787
- start_millis: 12204,
788
- text: 'Hello?',
789
- transcript_language_code: 'en',
790
- timestamp: '0:13'
791
- },
792
- transcripts: [
793
- {
794
- start_millis: 12204,
795
- end_millis: 13044,
796
- text: 'Hello.',
797
- csis: [3556942592],
798
- transcript_language_code: 'en',
799
- translations: {
800
- fr: 'Bonjour.',
801
- },
802
- timestamp: '0:13'
803
- },
804
- {
805
- start_millis: 12204,
806
- end_millis: 13044,
807
- text: 'This is Webex',
808
- csis: [3556942593],
809
- transcript_language_code: 'en',
810
- translations: {
811
- fr: "C'est Webex",
812
- },
813
- timestamp: '0:13'
814
- },
815
- ],
816
- };
683
+ it('processes voicea announcement with empty payload', () => {
684
+ const spy = sinon.spy();
685
+ voiceaChannel.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
817
686
 
818
- // eslint-disable-next-line no-underscore-dangle
819
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
820
- headers: {from: 'ws'},
821
- data: {relayType: 'voicea.transcription', voiceaPayload},
687
+ eventHandler({
688
+ sequenceNumber: 1,
689
+ headers: {from: 'ws-service'},
690
+ data: {
691
+ relayType: 'voicea.annc',
692
+ voiceaPayload: {},
693
+ },
822
694
  });
823
695
 
824
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
825
- assert.calledOnceWithExactly(triggerSpy, {
826
- isFinal: true,
827
- transcriptId: '3ec73890-bffb-f28b-e77f-99dc13caea7e',
828
- transcripts: voiceaPayload.transcripts,
696
+ assert.calledOnceWithExactly(spy, {
697
+ captionLanguages: [],
698
+ maxLanguages: 0,
699
+ spokenLanguages: [],
700
+ currentSpokenLanguage: 'en',
829
701
  });
830
702
  });
831
703
 
832
- it('processes a eva wake up', async () => {
833
- voiceaService.on(EVENT_TRIGGERS.EVA_COMMAND, triggerSpy);
834
-
835
- const voiceaPayload = {
836
- audio_received_millis: 1616137504810,
837
- command_response: '',
838
- id: '31fb2f81-fb55-4257-32a0-f421ef8ba4b0',
839
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
840
- trigger: {
841
- detected_at: '2021-03-19T07:05:04.810669662Z',
842
- ews_confidence: 0.99497044086456299,
843
- ews_keyphrase: 'OkayWebEx',
844
- model_version: 'WebEx',
845
- offset_seconds: 2336.5900000000001,
846
- recording_file_name:
847
- 'OkayWebEx_fd5bd0fc-06fb-4fd1-982b-554c4368f101_47900f3f-8579-25eb-3f6a-74d81a3c66a4_2335.8900000000003_2336.79.raw',
848
- type: 'live-hotword',
704
+ it('processes transcription interim results', () => {
705
+ const spy = sinon.spy();
706
+ voiceaChannel.on(EVENT_TRIGGERS.NEW_CAPTION, spy);
707
+
708
+ eventHandler({
709
+ sequenceNumber: 1,
710
+ headers: {},
711
+ data: {
712
+ relayType: 'voicea.transcription',
713
+ voiceaPayload: {
714
+ type: 'transcript_interim_results',
715
+ transcript_id: 'tid-1',
716
+ transcripts: [{text: 'Hello'}],
717
+ },
849
718
  },
850
- ts: 1616137504.8107769,
851
- type: 'eva_wake',
852
- };
853
-
854
- // eslint-disable-next-line no-underscore-dangle
855
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
856
- headers: {from: 'ws'},
857
- data: {relayType: 'voicea.transcription', voiceaPayload},
858
719
  });
859
720
 
860
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
861
- assert.calledOnceWithExactly(triggerSpy, {
862
- isListening: true,
721
+ assert.calledOnceWithExactly(spy, {
722
+ isFinal: false,
723
+ transcriptId: 'tid-1',
724
+ transcripts: [{text: 'Hello'}],
863
725
  });
864
726
  });
865
727
 
866
- it('processes a eva thanks', async () => {
867
- voiceaService.on(EVENT_TRIGGERS.EVA_COMMAND, triggerSpy);
868
-
869
- const voiceaPayload = {
870
- audio_received_millis: 0,
871
- command_response: 'OK! Decision created.',
872
- id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
873
- intent: 'decision',
874
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
875
- ts: 1616135828.2552843,
876
- type: 'eva_thanks',
877
- };
728
+ it('processes transcription final results', () => {
729
+ const spy = sinon.spy();
730
+ voiceaChannel.on(EVENT_TRIGGERS.NEW_CAPTION, spy);
878
731
 
879
- // eslint-disable-next-line no-underscore-dangle
880
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
881
- headers: {from: 'ws'},
882
- data: {relayType: 'voicea.transcription', voiceaPayload},
732
+ eventHandler({
733
+ sequenceNumber: 1,
734
+ headers: {},
735
+ data: {
736
+ relayType: 'voicea.transcription',
737
+ voiceaPayload: {
738
+ type: 'transcript_final_result',
739
+ transcript_id: 'tid-2',
740
+ transcripts: [{text: 'Hello world', end_millis: 60000}],
741
+ },
742
+ },
883
743
  });
884
744
 
885
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
886
- assert.calledOnceWithExactly(triggerSpy, {
887
- isListening: false,
888
- text: 'OK! Decision created.',
889
- });
745
+ assert.calledOnce(spy);
746
+ const call = spy.getCall(0);
747
+ assert.equal(call.args[0].isFinal, true);
748
+ assert.equal(call.args[0].transcriptId, 'tid-2');
890
749
  });
891
750
 
892
- it('processes a eva cancel', async () => {
893
- voiceaService.on(EVENT_TRIGGERS.EVA_COMMAND, triggerSpy);
894
-
895
- const voiceaPayload = {
896
- audio_received_millis: 0,
897
- command_response: '',
898
- id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
899
- intent: 'decision',
900
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
901
- ts: 1616135828.2552843,
902
- type: 'eva_cancel',
903
- };
751
+ it('processes caption language response success', () => {
752
+ const spy = sinon.spy();
753
+ voiceaChannel.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, spy);
904
754
 
905
- // eslint-disable-next-line no-underscore-dangle
906
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
907
- headers: {from: 'ws'},
908
- data: {relayType: 'voicea.transcription', voiceaPayload},
755
+ eventHandler({
756
+ sequenceNumber: 1,
757
+ headers: {},
758
+ data: {
759
+ relayType: 'voicea.transl.rsp',
760
+ voiceaPayload: {statusCode: 200},
761
+ },
909
762
  });
910
763
 
911
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
764
+ assert.calledOnceWithExactly(spy, {statusCode: 200});
765
+ });
766
+
767
+ it('processes caption language response error', () => {
768
+ const spy = sinon.spy();
769
+ voiceaChannel.on(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, spy);
912
770
 
913
- assert.calledOnceWithExactly(triggerSpy, {
914
- isListening: false,
771
+ eventHandler({
772
+ sequenceNumber: 1,
773
+ headers: {},
774
+ data: {
775
+ relayType: 'voicea.transl.rsp',
776
+ voiceaPayload: {statusCode: 400, errorCode: 400, message: 'Bad request'},
777
+ },
915
778
  });
779
+
780
+ assert.calledOnceWithExactly(spy, {statusCode: 400, errorMessage: 'Bad request'});
916
781
  });
917
782
 
918
- it('processes a highlight', async () => {
919
- voiceaService.on(EVENT_TRIGGERS.HIGHLIGHT_CREATED, triggerSpy);
920
- const voiceaPayload = {
921
- audio_received_millis: 0,
922
- command_response: '',
923
- highlight: {
924
- created_by_email: '',
925
- csis: [3932881920],
926
- end_millis: 660160,
927
- highlight_id: '219af4b1-1579-5106-53ab-f621094a0c5a',
928
- highlight_label: 'Decision',
929
- highlight_source: 'voice-command',
930
- start_millis: 652756,
931
- transcript: 'Create a decision to move ahead with the last proposal.',
932
- trigger_info: {type: 'live-hotword'},
783
+ it('processes manual transcription events', () => {
784
+ const spy = sinon.spy();
785
+ voiceaChannel.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, spy);
786
+
787
+ eventHandler({
788
+ sequenceNumber: 1,
789
+ headers: {from: 'user-1'},
790
+ data: {
791
+ relayType: 'client.manual_transcription',
792
+ transcriptPayload: {
793
+ type: 'manual_caption_final_result',
794
+ id: 'manual-id',
795
+ transcripts: [{text: 'Manual caption'}],
796
+ },
933
797
  },
934
- id: 'e6df0262-6289-db2e-581a-d44bb41b1c9c',
935
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
936
- ts: 1616135858.5349569,
937
- type: 'highlight_created',
938
- };
939
-
940
- // eslint-disable-next-line no-underscore-dangle
941
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
942
- headers: {from: 'ws'},
943
- data: {relayType: 'voicea.transcription', voiceaPayload},
944
798
  });
945
799
 
946
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
947
- assert.calledOnceWithExactly(triggerSpy, {
948
- csis: [3932881920],
949
- highlightId: '219af4b1-1579-5106-53ab-f621094a0c5a',
950
- text: 'Create a decision to move ahead with the last proposal.',
951
- highlightLabel: 'Decision',
952
- highlightSource: 'voice-command',
953
- timestamp: '11:00',
954
- });
800
+ assert.calledOnce(spy);
801
+ const call = spy.getCall(0);
802
+ assert.equal(call.args[0].isFinal, true);
803
+ assert.equal(call.args[0].transcriptId, 'manual-id');
955
804
  });
956
805
 
957
- it('processes a language detected if language is in spoken languages', async () => {
958
- voiceaService.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, triggerSpy);
806
+ it('processes highlight created events', () => {
807
+ const spy = sinon.spy();
808
+ voiceaChannel.on(EVENT_TRIGGERS.HIGHLIGHT_CREATED, spy);
959
809
 
960
- const voiceaPayload = {
961
- id: '9bc51440-1a22-7c81-6add-4b6ff7b59f7c',
962
- meeting: 'fd5bd0fc-06fb-4fd1-982b-554c4368f101',
963
- type: 'language_detected',
964
- language: 'en',
965
- translation: {
966
- allowed_languages: ['af', 'am'],
967
- max_languages: 5,
968
- },
969
- ASR: {
970
- spoken_languages: ['en', 'pl'],
810
+ eventHandler({
811
+ sequenceNumber: 1,
812
+ headers: {},
813
+ data: {
814
+ relayType: 'voicea.transcription',
815
+ voiceaPayload: {
816
+ type: 'highlight_created',
817
+ highlight: {
818
+ csis: [123],
819
+ highlight_id: 'h-1',
820
+ transcript: 'Highlighted text',
821
+ highlight_label: 'important',
822
+ highlight_source: 'user',
823
+ end_millis: 30000,
824
+ },
825
+ },
971
826
  },
827
+ });
972
828
 
973
- version: 'v2',
974
- };
975
-
976
- const spy = sinon.spy();
829
+ assert.calledOnce(spy);
830
+ });
977
831
 
978
- voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
979
- voiceaService.listenToEvents();
980
- voiceaService.processAnnouncementMessage(voiceaPayload);
832
+ it('processes eva wake events', () => {
833
+ const spy = sinon.spy();
834
+ voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
981
835
 
982
- // eslint-disable-next-line no-underscore-dangle
983
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
984
- headers: {from: 'ws'},
985
- data: {relayType: 'voicea.transcription', voiceaPayload},
836
+ eventHandler({
837
+ sequenceNumber: 1,
838
+ headers: {},
839
+ data: {
840
+ relayType: 'voicea.transcription',
841
+ voiceaPayload: {type: 'eva_wake'},
842
+ },
986
843
  });
987
844
 
988
- assert.calledOnceWithExactly(functionSpy, voiceaPayload);
989
- assert.calledOnceWithExactly(triggerSpy, {
990
- languageCode: 'en',
991
- });
845
+ assert.calledOnceWithExactly(spy, {isListening: true});
992
846
  });
993
847
 
994
- });
995
-
996
- describe('#processManualTranscription', () => {
997
- let triggerSpy, functionSpy;
848
+ it('processes eva cancel events', () => {
849
+ const spy = sinon.spy();
850
+ voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
998
851
 
999
- beforeEach(() => {
1000
- triggerSpy = sinon.spy();
1001
- functionSpy = sinon.spy(voiceaService, 'processManualTranscription');
1002
- voiceaService.listenToEvents();
1003
- });
1004
-
1005
- it('processes interim manual transcription from aibridge', async () => {
1006
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
1007
-
1008
- const transcriptPayload = {
1009
- id: "747d711d-3414-fd69-7081-e842649f2d28",
1010
- transcripts: [
1011
- {
1012
- text: "Good",
1013
- }
1014
- ],
1015
- type: "manual_caption_interim_result",
1016
- };
1017
-
1018
- // eslint-disable-next-line no-underscore-dangle
1019
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1020
- headers: {from: 'ws'},
1021
- data: {relayType: 'aibridge.manual_transcription', transcriptPayload},
852
+ eventHandler({
853
+ sequenceNumber: 1,
854
+ headers: {},
855
+ data: {
856
+ relayType: 'voicea.transcription',
857
+ voiceaPayload: {type: 'eva_cancel'},
858
+ },
1022
859
  });
1023
860
 
1024
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: 'ws', data_source: 'aibridge.manual_transcription'});
1025
- assert.calledOnceWithExactly(triggerSpy, {
1026
- isFinal: false,
1027
- transcriptId: '747d711d-3414-fd69-7081-e842649f2d28',
1028
- transcripts: transcriptPayload.transcripts,
1029
- sender: 'ws',
1030
- source: 'aibridge.manual_transcription'
1031
- });
861
+ assert.calledOnceWithExactly(spy, {isListening: false});
1032
862
  });
1033
863
 
1034
- it('processes final manual transcription from aibridge', async () => {
1035
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
1036
-
1037
- const transcriptPayload = {
1038
- id: "8d226d31-044a-8d11-cc39-cedbde183154",
1039
- transcripts: [
1040
- {
1041
- text: "Good Morning",
1042
- start_millis: 10420,
1043
- end_millis: 11380,
1044
- }
1045
- ],
1046
- type: "manual_caption_final_result",
1047
- };
1048
-
1049
- // eslint-disable-next-line no-underscore-dangle
1050
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1051
- headers: {from: 'ws'},
1052
- data: {relayType: 'aibridge.manual_transcription', transcriptPayload},
1053
- });
864
+ it('processes eva thanks events', () => {
865
+ const spy = sinon.spy();
866
+ voiceaChannel.on(EVENT_TRIGGERS.EVA_COMMAND, spy);
1054
867
 
1055
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: 'ws', data_source: 'aibridge.manual_transcription'});
1056
- assert.calledOnceWithExactly(triggerSpy, {
1057
- isFinal: true,
1058
- transcriptId: '8d226d31-044a-8d11-cc39-cedbde183154',
1059
- transcripts: transcriptPayload.transcripts,
1060
- sender: 'ws',
1061
- source: 'aibridge.manual_transcription'
868
+ eventHandler({
869
+ sequenceNumber: 1,
870
+ headers: {},
871
+ data: {
872
+ relayType: 'voicea.transcription',
873
+ voiceaPayload: {type: 'eva_thanks', command_response: 'OK, noted'},
874
+ },
1062
875
  });
1063
- });
1064
876
 
1065
- it('processes interim manual transcription from captioner', async () => {
1066
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
877
+ assert.calledOnceWithExactly(spy, {isListening: false, text: 'OK, noted'});
878
+ });
1067
879
 
1068
- const transcriptPayload = {
1069
- id: "747d711d-3414-fd69-7081-e842649f2d28",
1070
- transcripts: [
1071
- {
1072
- text: "Good",
1073
- }
1074
- ],
1075
- type: "manual_caption_interim_result",
1076
- };
880
+ it('processes language detected when in spoken languages', () => {
881
+ const spy = sinon.spy();
882
+ voiceaChannel.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, spy);
1077
883
 
1078
- // eslint-disable-next-line no-underscore-dangle
1079
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1080
- headers: {from: '654321'},
1081
- data: {relayType: 'client.manual_transcription', transcriptPayload},
884
+ // First set up spoken languages via announcement
885
+ eventHandler({
886
+ sequenceNumber: 1,
887
+ headers: {from: 'ws-service'},
888
+ data: {
889
+ relayType: 'voicea.annc',
890
+ voiceaPayload: {
891
+ ASR: {spoken_languages: ['en', 'es', 'fr']},
892
+ },
893
+ },
1082
894
  });
1083
895
 
1084
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: '654321', data_source: 'client.manual_transcription'});
1085
- assert.calledOnceWithExactly(triggerSpy, {
1086
- isFinal: false,
1087
- transcriptId: '747d711d-3414-fd69-7081-e842649f2d28',
1088
- transcripts: transcriptPayload.transcripts,
1089
- sender: '654321',
1090
- source: 'client.manual_transcription'
896
+ // Then emit language detected
897
+ eventHandler({
898
+ sequenceNumber: 2,
899
+ headers: {},
900
+ data: {
901
+ relayType: 'voicea.transcription',
902
+ voiceaPayload: {type: 'language_detected', language: 'es'},
903
+ },
1091
904
  });
905
+
906
+ assert.calledOnceWithExactly(spy, {languageCode: 'es'});
1092
907
  });
1093
908
 
1094
- it('processes final manual transcription from captioner', async () => {
1095
- voiceaService.on(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, triggerSpy);
1096
-
1097
- const transcriptPayload = {
1098
- id: "8d226d31-044a-8d11-cc39-cedbde183154",
1099
- transcripts: [
1100
- {
1101
- text: "Good Morning",
1102
- start_millis: 10420,
1103
- end_millis: 11380,
1104
- }
1105
- ],
1106
- type: "manual_caption_final_result",
1107
- };
1108
-
1109
- // eslint-disable-next-line no-underscore-dangle
1110
- await voiceaService.webex.internal.llm._emit('event:relay.event', {
1111
- headers: {from: '654321'},
1112
- data: {relayType: 'client.manual_transcription', transcriptPayload},
909
+ it('does not emit language detected when not in spoken languages', () => {
910
+ const spy = sinon.spy();
911
+ voiceaChannel.on(EVENT_TRIGGERS.LANGUAGE_DETECTED, spy);
912
+
913
+ // First set up spoken languages via announcement
914
+ eventHandler({
915
+ sequenceNumber: 1,
916
+ headers: {from: 'ws-service'},
917
+ data: {
918
+ relayType: 'voicea.annc',
919
+ voiceaPayload: {
920
+ ASR: {spoken_languages: ['en', 'es']},
921
+ },
922
+ },
1113
923
  });
1114
924
 
1115
- assert.calledOnceWithExactly(functionSpy, {...transcriptPayload, sender: '654321', data_source: 'client.manual_transcription'});
1116
- assert.calledOnceWithExactly(triggerSpy, {
1117
- isFinal: true,
1118
- transcriptId: '8d226d31-044a-8d11-cc39-cedbde183154',
1119
- transcripts: transcriptPayload.transcripts,
1120
- sender: '654321',
1121
- source: 'client.manual_transcription'
925
+ // Then emit language detected for unsupported language
926
+ eventHandler({
927
+ sequenceNumber: 2,
928
+ headers: {},
929
+ data: {
930
+ relayType: 'voicea.transcription',
931
+ voiceaPayload: {type: 'language_detected', language: 'de'},
932
+ },
1122
933
  });
1123
- });
1124
- });
1125
934
 
1126
- describe("#getCaptionStatus", () => {
1127
- it('works correctly', () => {
1128
- voiceaService.captionStatus = "enabled"
1129
- assert.equal(voiceaService.getCaptionStatus(), "enabled");
935
+ assert.notCalled(spy);
1130
936
  });
1131
937
  });
1132
938
 
1133
- describe("#getAnnounceStatus", () => {
1134
- it('works correctly', () => {
1135
- voiceaService.announceStatus = "joined"
1136
- assert.equal(voiceaService.getAnnounceStatus(), "joined");
1137
- });
1138
- });
939
+ describe('#switchLLMChannel', () => {
940
+ it('switches to a new LLM channel and preserves caption state', async () => {
941
+ // First enable captions
942
+ voiceaChannel.keepTranscriptionSubscribed = true;
943
+ voiceaChannel.currentSpokenLanguage = 'es';
1139
944
 
1140
- describe('#onSpokenLanguageUpdate', () => {
1141
- it('should trigger SPOKEN_LANGUAGE_UPDATE event with correct languageCode', () => {
1142
- const triggerSpy = sinon.spy();
1143
- voiceaService.on(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, triggerSpy);
945
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1144
946
 
1145
- const languageCode = 'fr';
1146
- voiceaService.onSpokenLanguageUpdate(languageCode, '123');
1147
- assert.equal(voiceaService.currentSpokenLanguage, languageCode);
1148
- assert.calledOnceWithExactly(triggerSpy, {languageCode, meetingId: '123'});
1149
- });
1150
- });
947
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1151
948
 
1152
- describe('#onCaptionServiceIdUpdate', () => {
1153
- let mockWebSocket;
949
+ // Should have unsubscribed from old channel
950
+ assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
1154
951
 
1155
- beforeEach(() => {
1156
- mockWebSocket = new MockWebSocket();
1157
- voiceaService.webex.internal.llm.socket = mockWebSocket;
1158
- voiceaService.webex.internal.llm.isConnected.returns(true);
1159
- voiceaService.seqNum = 1;
1160
- });
952
+ // Should have subscribed to new channel
953
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1161
954
 
1162
- it('does nothing when serviceId is falsy', () => {
1163
- voiceaService.captionServiceId = 'existing-id';
1164
- voiceaService.currentCaptionLanguage = 'en';
955
+ // Should have reset announcement state to joining (since captions were on and it re-announced)
956
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'joining');
1165
957
 
1166
- voiceaService.onCaptionServiceIdUpdate(undefined);
1167
- voiceaService.onCaptionServiceIdUpdate('');
958
+ // turnOnCaptions sends both an announcement and a subchannel subscription
959
+ assert.calledTwice(newMockLLMChannel.socket.send);
1168
960
 
1169
- assert.equal(voiceaService.captionServiceId, 'existing-id');
1170
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
961
+ // First call should be the announcement
962
+ assert.calledWithExactly(newMockLLMChannel.socket.send.getCall(0), {
963
+ id: '1',
964
+ type: 'publishRequest',
965
+ recipients: [{route: 'binding'}],
966
+ headers: {},
967
+ data: {
968
+ clientPayload: {
969
+ version: 'v2',
970
+ },
971
+ eventType: 'relay.event',
972
+ relayType: 'client.annc',
973
+ },
974
+ trackingId: sinon.match.string,
975
+ });
1171
976
  });
1172
977
 
1173
- it('sets captionServiceId when no currentCaptionLanguage', () => {
1174
- voiceaService.captionServiceId = undefined;
1175
- voiceaService.currentCaptionLanguage = undefined;
978
+ it('does not turn on captions if they were not on before switching', async () => {
979
+ // Captions are off
980
+ voiceaChannel.keepTranscriptionSubscribed = false;
1176
981
 
1177
- voiceaService.onCaptionServiceIdUpdate('svc-new');
982
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1178
983
 
1179
- assert.equal(voiceaService.captionServiceId, 'svc-new');
1180
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
1181
- });
984
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1182
985
 
1183
- it('re-sends language when serviceId changes and currentCaptionLanguage is set', () => {
1184
- voiceaService.captionServiceId = 'old-svc';
1185
- voiceaService.currentCaptionLanguage = 'es';
986
+ // Should have unsubscribed from old channel
987
+ assert.calledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
1186
988
 
1187
- voiceaService.onCaptionServiceIdUpdate('new-svc');
989
+ // Should have subscribed to new channel
990
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1188
991
 
1189
- assert.equal(voiceaService.captionServiceId, 'new-svc');
1190
- assert.calledOnce(voiceaService.webex.internal.llm.socket.send);
992
+ // Should NOT have sent any messages (no announcement for captions)
993
+ assert.notCalled(newMockLLMChannel.socket.send);
1191
994
 
1192
- const callArgs = voiceaService.webex.internal.llm.socket.send.getCall(0).args[0];
1193
- expect(callArgs).to.have.nested.property('headers.to', 'new-svc');
1194
- expect(callArgs).to.have.nested.property('data.clientPayload.translationLanguage', 'es');
995
+ // State should be idle since captions weren't re-enabled
996
+ assert.equal(voiceaChannel.getAnnounceStatus(), 'idle');
1195
997
  });
1196
998
 
1197
- it('does not re-send language when serviceId is unchanged', () => {
1198
- voiceaService.captionServiceId = 'same-svc';
1199
- voiceaService.currentCaptionLanguage = 'de';
999
+ it('handles case when not previously subscribed to events', async () => {
1000
+ // Create a new channel that hasn't subscribed to events
1001
+ const freshVoiceaChannel = new VoiceaChannel(mockLLMChannel, requestStub);
1002
+ freshVoiceaChannel.hasSubscribedToEvents = false;
1003
+
1004
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1200
1005
 
1201
- voiceaService.onCaptionServiceIdUpdate('same-svc');
1006
+ await freshVoiceaChannel.switchLLMChannel(newMockLLMChannel);
1202
1007
 
1203
- assert.equal(voiceaService.captionServiceId, 'same-svc');
1204
- assert.notCalled(voiceaService.webex.internal.llm.socket.send);
1008
+ // Should NOT have tried to unsubscribe from old channel (wasn't subscribed)
1009
+ assert.neverCalledWith(mockLLMChannel.off, 'event:relay.event', sinon.match.func);
1010
+
1011
+ // Should have subscribed to new channel
1012
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1205
1013
  });
1206
1014
  });
1207
-
1208
1015
  });
1209
1016
  });