@webex/internal-plugin-voicea 3.11.0 → 3.12.0

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.
@@ -7,7 +7,11 @@ import Mercury from '@webex/internal-plugin-mercury';
7
7
  import LLMChannel from '@webex/internal-plugin-llm';
8
8
 
9
9
  import VoiceaService from '../../../src/index';
10
- import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants';
10
+ import {
11
+ EVENT_TRIGGERS,
12
+ LLM_PRACTICE_SESSION,
13
+ TOGGLE_MANUAL_CAPTION_STATUS,
14
+ } from '../../../src/constants';
11
15
 
12
16
  describe('plugin-voicea', () => {
13
17
  const locusUrl = 'locusUrl';
@@ -28,6 +32,7 @@ describe('plugin-voicea', () => {
28
32
  voiceaService.connect = sinon.stub().resolves(true);
29
33
  voiceaService.webex.internal.llm.isConnected = sinon.stub().returns(true);
30
34
  voiceaService.webex.internal.llm.getBinding = sinon.stub().returns(undefined);
35
+ voiceaService.webex.internal.llm.getSocket = sinon.stub().returns(undefined);
31
36
  voiceaService.webex.internal.llm.getLocusUrl = sinon.stub().returns(locusUrl);
32
37
 
33
38
  voiceaService.request = sinon.stub().resolves({
@@ -87,7 +92,9 @@ describe('plugin-voicea', () => {
87
92
 
88
93
  voiceaService.sendAnnouncement();
89
94
 
90
- assert.calledOnceWithExactly(spy, 'event:relay.event', sinon.match.func);
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);
91
98
  });
92
99
 
93
100
  it('includes captionServiceId in headers when set', () => {
@@ -214,19 +221,17 @@ describe('plugin-voicea', () => {
214
221
  assert.notCalled(voiceaService.webex.internal.llm.socket.send);
215
222
  });
216
223
  });
217
-
218
224
  describe('#deregisterEvents', () => {
219
225
  beforeEach(async () => {
220
226
  const mockWebSocket = new MockWebSocket();
221
-
222
227
  voiceaService.webex.internal.llm.socket = mockWebSocket;
228
+ voiceaService.isCaptionBoxOn = true;
223
229
  });
224
230
 
225
- it('deregisters voicea service', async () => {
231
+ it('deregisters voicea service and resets caption state', async () => {
226
232
  voiceaService.listenToEvents();
227
233
  await voiceaService.toggleTranscribing(true);
228
234
 
229
- // eslint-disable-next-line no-underscore-dangle
230
235
  voiceaService.webex.internal.llm._emit('event:relay.event', {
231
236
  headers: {from: 'ws'},
232
237
  data: {relayType: 'voicea.annc', voiceaPayload: {}},
@@ -234,12 +239,14 @@ describe('plugin-voicea', () => {
234
239
 
235
240
  assert.equal(voiceaService.areCaptionsEnabled, true);
236
241
  assert.equal(voiceaService.captionServiceId, 'ws');
242
+ assert.equal(voiceaService.isCaptionBoxOn, true);
237
243
 
238
244
  voiceaService.deregisterEvents();
239
245
  assert.equal(voiceaService.areCaptionsEnabled, false);
240
246
  assert.equal(voiceaService.captionServiceId, undefined);
241
247
  assert.equal(voiceaService.announceStatus, 'idle');
242
248
  assert.equal(voiceaService.captionStatus, 'idle');
249
+ assert.equal(voiceaService.isCaptionBoxOn, false);
243
250
  });
244
251
  });
245
252
  describe('#processAnnouncementMessage', () => {
@@ -269,7 +276,7 @@ describe('plugin-voicea', () => {
269
276
  });
270
277
  });
271
278
 
272
- it('works on non-empty payload', async () => {
279
+ it('works on empty payload', async () => {
273
280
  const spy = sinon.spy();
274
281
 
275
282
  voiceaService.on(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, spy);
@@ -401,6 +408,7 @@ describe('plugin-voicea', () => {
401
408
 
402
409
  it('turns on captions', async () => {
403
410
  const announcementSpy = sinon.spy(voiceaService, 'announce');
411
+ const updateSubchannelSubscriptionsAndSyncCaptionStateSpy = sinon.spy(voiceaService, 'updateSubchannelSubscriptionsAndSyncCaptionState');
404
412
 
405
413
  const triggerSpy = sinon.spy();
406
414
 
@@ -421,6 +429,11 @@ describe('plugin-voicea', () => {
421
429
  assert.calledOnceWithExactly(triggerSpy);
422
430
 
423
431
  assert.calledOnce(announcementSpy);
432
+ assert.calledOnceWithExactly(
433
+ updateSubchannelSubscriptionsAndSyncCaptionStateSpy,
434
+ { subscribe: ['transcription'] },
435
+ true
436
+ );
424
437
  });
425
438
 
426
439
  it("should handle request fail", async () => {
@@ -455,17 +468,61 @@ describe('plugin-voicea', () => {
455
468
  });
456
469
  });
457
470
 
471
+ describe('#isLLMConnected', () => {
472
+ it('returns true when the default llm connection is connected', () => {
473
+ voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
474
+ channel === LLM_PRACTICE_SESSION ? false : true
475
+ );
476
+
477
+ assert.equal(voiceaService.isLLMConnected(), true);
478
+ });
479
+
480
+ it('returns true when only the practice session llm connection is connected', () => {
481
+ voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
482
+ channel === LLM_PRACTICE_SESSION
483
+ );
484
+
485
+ assert.equal(voiceaService.isLLMConnected(), true);
486
+ });
487
+
488
+ it('returns false when neither llm connection is connected', () => {
489
+ voiceaService.webex.internal.llm.isConnected.returns(false);
490
+
491
+ assert.equal(voiceaService.isLLMConnected(), false);
492
+ });
493
+ });
494
+
495
+ describe('#getIsCaptionBoxOn', () => {
496
+ beforeEach(() => {
497
+ voiceaService.isCaptionBoxOn = false;
498
+ });
499
+
500
+ it('returns false when captions are disabled', () => {
501
+ voiceaService.isCaptionBoxOn = false;
502
+
503
+ const result = voiceaService.getIsCaptionBoxOn();
504
+
505
+ assert.equal(result, false);
506
+ });
507
+
508
+ it('returns true when captions are enabled', () => {
509
+ voiceaService.isCaptionBoxOn = true;
510
+
511
+ const result = voiceaService.getIsCaptionBoxOn();
512
+
513
+ assert.equal(result, true);
514
+ });
515
+ });
516
+
458
517
  describe("#announce", () => {
459
- let isAnnounceProcessing, sendAnnouncement;
518
+ let isAnnounceProcessed, sendAnnouncement;
460
519
  beforeEach(() => {
461
- voiceaService.webex.internal.llm.isConnected.returns(true);
462
520
  sendAnnouncement = sinon.stub(voiceaService, 'sendAnnouncement');
463
- isAnnounceProcessing = sinon.stub(voiceaService, 'isAnnounceProcessing').returns(false)
521
+ isAnnounceProcessed = sinon.stub(voiceaService, 'isAnnounceProcessed').returns(false)
464
522
  });
465
523
 
466
524
  afterEach(() => {
467
- voiceaService.webex.internal.llm.isConnected.returns(true);
468
- isAnnounceProcessing.restore();
525
+ isAnnounceProcessed.restore();
469
526
  sendAnnouncement.restore();
470
527
  });
471
528
 
@@ -480,8 +537,18 @@ describe('plugin-voicea', () => {
480
537
  assert.notCalled(sendAnnouncement);
481
538
  });
482
539
 
540
+ it('announce to llm data channel when only practice session is connected', ()=> {
541
+ voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
542
+ channel === LLM_PRACTICE_SESSION
543
+ );
544
+
545
+ voiceaService.announce();
546
+
547
+ assert.calledOnce(sendAnnouncement);
548
+ });
549
+
483
550
  it('should not announce duplicate', () => {
484
- isAnnounceProcessing.returns(true);
551
+ isAnnounceProcessed.returns(true);
485
552
  voiceaService.announce();
486
553
  assert.notCalled(sendAnnouncement);
487
554
  })
@@ -510,13 +577,11 @@ describe('plugin-voicea', () => {
510
577
  beforeEach(() => {
511
578
  requestTurnOnCaptions = sinon.stub(voiceaService, 'requestTurnOnCaptions');
512
579
  voiceaService.captionStatus = 'idle';
513
- voiceaService.webex.internal.llm.isConnected.returns(true);
514
580
  });
515
581
 
516
582
  afterEach(() => {
517
583
  requestTurnOnCaptions.restore();
518
584
  voiceaService.captionStatus = 'idle';
519
- voiceaService.webex.internal.llm.isConnected.returns(true);
520
585
  });
521
586
 
522
587
  it('call request turn on captions', () => {
@@ -525,13 +590,27 @@ describe('plugin-voicea', () => {
525
590
  assert.calledOnce(requestTurnOnCaptions);
526
591
  });
527
592
 
528
- it("turns on captions before llm connected", () => {
593
+ it('throws before turning on captions when llm is not connected', async () => {
529
594
  voiceaService.captionStatus = 'idle';
530
- voiceaService.webex.internal.llm.isConnected.returns(true);
531
- // assert.throws(() => voiceaService.turnOnCaptions(), "can not turn on captions before llm connected");
595
+ voiceaService.webex.internal.llm.isConnected.returns(false);
596
+
597
+ await assert.isRejected(
598
+ voiceaService.turnOnCaptions(),
599
+ 'can not turn on captions before llm connected'
600
+ );
532
601
  assert.notCalled(requestTurnOnCaptions);
533
602
  });
534
603
 
604
+ it('turns on captions when only the practice session llm connection is connected', () => {
605
+ voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
606
+ channel === LLM_PRACTICE_SESSION
607
+ );
608
+
609
+ voiceaService.turnOnCaptions();
610
+
611
+ assert.calledOnce(requestTurnOnCaptions);
612
+ });
613
+
535
614
  it('should not turn on duplicate when processing', () => {
536
615
  voiceaService.captionStatus = 'sending';
537
616
  voiceaService.turnOnCaptions();
@@ -1205,5 +1284,300 @@ describe('plugin-voicea', () => {
1205
1284
  });
1206
1285
  });
1207
1286
 
1287
+ describe('#updateSubchannelSubscriptions', () => {
1288
+ beforeEach(() => {
1289
+ const mockWebSocket = new MockWebSocket();
1290
+
1291
+ sinon.stub(voiceaService, 'getPublishTransport').returns({
1292
+ socket: mockWebSocket,
1293
+ datachannelUrl: 'mock-datachannel-uri',
1294
+ });
1295
+
1296
+ voiceaService.seqNum = 1;
1297
+
1298
+ voiceaService.isLLMConnected = sinon.stub().returns(true);
1299
+ voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(true);
1300
+ });
1301
+
1302
+ it('sends subchannelSubscriptionRequest with subscribe and unsubscribe lists', async () => {
1303
+ await voiceaService.updateSubchannelSubscriptions({
1304
+ subscribe: ['transcription'],
1305
+ unsubscribe: ['polls'],
1306
+ });
1307
+
1308
+ const socket = voiceaService.getPublishTransport().socket;
1309
+
1310
+ sinon.assert.calledOnceWithExactly(
1311
+ socket.send,
1312
+ {
1313
+ id: '1',
1314
+ type: 'subchannelSubscriptionRequest',
1315
+ data: {
1316
+ datachannelUri: 'mock-datachannel-uri',
1317
+ subscribe: ['transcription'],
1318
+ unsubscribe: ['polls'],
1319
+ },
1320
+ trackingId: sinon.match.string,
1321
+ }
1322
+ );
1323
+
1324
+ sinon.assert.match(voiceaService.seqNum, 2);
1325
+ });
1326
+
1327
+ it('sends empty arrays when no subscribe/unsubscribe provided', async () => {
1328
+ await voiceaService.updateSubchannelSubscriptions({});
1329
+
1330
+ const socket = voiceaService.getPublishTransport().socket;
1331
+
1332
+ sinon.assert.calledOnceWithExactly(
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
+ );
1345
+
1346
+ sinon.assert.match(voiceaService.seqNum, 2);
1347
+ });
1348
+
1349
+ it('does nothing when LLM is not connected', async () => {
1350
+ voiceaService.isLLMConnected = sinon.stub().returns(false);
1351
+
1352
+ await voiceaService.updateSubchannelSubscriptions({
1353
+ subscribe: ['transcription'],
1354
+ });
1355
+
1356
+ const socket = voiceaService.getPublishTransport().socket;
1357
+
1358
+ sinon.assert.notCalled(socket.send);
1359
+ sinon.assert.match(voiceaService.seqNum, 1);
1360
+ });
1361
+
1362
+ it('does nothing when dataChannelToken is not enabled', async () => {
1363
+ voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
1364
+
1365
+ await voiceaService.updateSubchannelSubscriptions({
1366
+ subscribe: ['transcription'],
1367
+ });
1368
+
1369
+ const socket = voiceaService.getPublishTransport().socket;
1370
+
1371
+ sinon.assert.notCalled(socket.send);
1372
+ sinon.assert.match(voiceaService.seqNum, 1);
1373
+ });
1374
+ });
1375
+
1376
+
1377
+ describe('#updateSubchannelSubscriptionsAndSyncCaptionState', () => {
1378
+ beforeEach(() => {
1379
+ const mockWebSocket = new MockWebSocket();
1380
+ voiceaService.webex.internal.llm.socket = mockWebSocket;
1381
+
1382
+ voiceaService.webex.internal.llm.getDatachannelUrl = sinon.stub().returns('mock-datachannel-uri');
1383
+
1384
+ voiceaService.seqNum = 1;
1385
+
1386
+ voiceaService.isLLMConnected = sinon.stub().returns(true);
1387
+ voiceaService.webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(true);
1388
+
1389
+ sinon.spy(voiceaService, 'updateSubchannelSubscriptions');
1390
+ });
1391
+
1392
+ afterEach(() => {
1393
+ sinon.restore();
1394
+ });
1395
+
1396
+ it('updates caption intent and forwards subscribe/unsubscribe to updateSubchannelSubscriptions', async () => {
1397
+ await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1398
+ {
1399
+ subscribe: ['transcription'],
1400
+ unsubscribe: ['polls'],
1401
+ },
1402
+ true
1403
+ );
1404
+
1405
+ assert.equal(voiceaService.isCaptionBoxOn, true);
1406
+
1407
+ assert.calledOnceWithExactly(
1408
+ voiceaService.updateSubchannelSubscriptions,
1409
+ {
1410
+ subscribe: ['transcription'],
1411
+ unsubscribe: ['polls'],
1412
+ }
1413
+ );
1414
+ });
1415
+
1416
+ it('sets caption intent to false when isCCBoxOpen is false', async () => {
1417
+ await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1418
+ { subscribe: ['transcription'] },
1419
+ false
1420
+ );
1421
+
1422
+ assert.equal(voiceaService.isCaptionBoxOn, false);
1423
+
1424
+ assert.calledOnceWithExactly(
1425
+ voiceaService.updateSubchannelSubscriptions,
1426
+ { subscribe: ['transcription'] }
1427
+ );
1428
+ });
1429
+
1430
+ it('defaults subscribe/unsubscribe to empty arrays when options is empty', async () => {
1431
+ await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState({}, true);
1432
+
1433
+ assert.equal(voiceaService.isCaptionBoxOn, true);
1434
+
1435
+ assert.calledOnceWithExactly(
1436
+ voiceaService.updateSubchannelSubscriptions,
1437
+ {}
1438
+ );
1439
+ });
1440
+
1441
+ it('still updates caption intent even if updateSubchannelSubscriptions does nothing (e.g., LLM not connected)', async () => {
1442
+ voiceaService.isLLMConnected = sinon.stub().returns(false);
1443
+
1444
+ await voiceaService.updateSubchannelSubscriptionsAndSyncCaptionState(
1445
+ { subscribe: ['transcription'] },
1446
+ true
1447
+ );
1448
+
1449
+ assert.equal(voiceaService.isCaptionBoxOn, true);
1450
+
1451
+ assert.calledOnceWithExactly(
1452
+ voiceaService.updateSubchannelSubscriptions,
1453
+ { subscribe: ['transcription'] }
1454
+ );
1455
+ });
1456
+ });
1457
+
1458
+ describe('#multiple llm connections', () => {
1459
+ let defaultSocket;
1460
+ let practiceSocket;
1461
+ let isPracticeSessionConnected;
1462
+
1463
+ beforeEach(() => {
1464
+ defaultSocket = new MockWebSocket();
1465
+ practiceSocket = new MockWebSocket();
1466
+ isPracticeSessionConnected = true;
1467
+
1468
+ voiceaService.webex.internal.llm.socket = defaultSocket;
1469
+ voiceaService.webex.internal.llm.isConnected.callsFake((channel) =>
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;
1479
+ });
1480
+
1481
+ it('sendAnnouncement uses the practice session socket and binding when available', () => {
1482
+ voiceaService.announceStatus = 'idle';
1483
+
1484
+ voiceaService.sendAnnouncement();
1485
+
1486
+ assert.calledOnce(practiceSocket.send);
1487
+ assert.notCalled(defaultSocket.send);
1488
+
1489
+ const sent = practiceSocket.send.getCall(0).args[0];
1490
+ expect(sent).to.have.nested.property('recipients.route', 'practice-binding');
1491
+ });
1492
+
1493
+ it('sendAnnouncement falls back to the default socket and binding when the practice session is not connected', () => {
1494
+ voiceaService.announceStatus = 'idle';
1495
+ isPracticeSessionConnected = false;
1496
+
1497
+ voiceaService.sendAnnouncement();
1498
+
1499
+ assert.calledOnce(defaultSocket.send);
1500
+ assert.notCalled(practiceSocket.send);
1501
+
1502
+ const sent = defaultSocket.send.getCall(0).args[0];
1503
+ expect(sent).to.have.nested.property('recipients.route', 'default-binding');
1504
+ });
1505
+
1506
+ it('requestLanguage uses the practice session socket and binding when available', () => {
1507
+ voiceaService.requestLanguage('fr');
1508
+
1509
+ assert.calledOnce(practiceSocket.send);
1510
+ assert.notCalled(defaultSocket.send);
1511
+
1512
+ const sent = practiceSocket.send.getCall(0).args[0];
1513
+ expect(sent).to.have.nested.property('recipients.route', 'practice-binding');
1514
+ expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr');
1515
+ });
1516
+
1517
+ it('requestLanguage falls back to the default socket and binding when the practice session is not connected', () => {
1518
+ isPracticeSessionConnected = false;
1519
+
1520
+ voiceaService.requestLanguage('fr');
1521
+
1522
+ assert.calledOnce(defaultSocket.send);
1523
+ assert.notCalled(practiceSocket.send);
1524
+
1525
+ const sent = defaultSocket.send.getCall(0).args[0];
1526
+ expect(sent).to.have.nested.property('recipients.route', 'default-binding');
1527
+ expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr');
1528
+ });
1529
+
1530
+ it('sendManualClosedCaption uses the practice session socket and binding when available', () => {
1531
+ voiceaService.sendManualClosedCaption('caption', 123, [456], true);
1532
+
1533
+ assert.calledOnce(practiceSocket.send);
1534
+ assert.notCalled(defaultSocket.send);
1535
+
1536
+ const sent = practiceSocket.send.getCall(0).args[0];
1537
+ expect(sent).to.have.nested.property('recipients.route', 'practice-binding');
1538
+ expect(sent).to.have.nested.property(
1539
+ 'data.transcriptPayload.type',
1540
+ 'manual_caption_final_result'
1541
+ );
1542
+ });
1543
+
1544
+ it('sendManualClosedCaption falls back to the default socket and binding when the practice session is not connected', () => {
1545
+ isPracticeSessionConnected = false;
1546
+
1547
+ voiceaService.sendManualClosedCaption('caption', 123, [456], false);
1548
+
1549
+ assert.calledOnce(defaultSocket.send);
1550
+ assert.notCalled(practiceSocket.send);
1551
+
1552
+ const sent = defaultSocket.send.getCall(0).args[0];
1553
+ expect(sent).to.have.nested.property('recipients.route', 'default-binding');
1554
+ expect(sent).to.have.nested.property(
1555
+ 'data.transcriptPayload.type',
1556
+ 'manual_caption_interim_result'
1557
+ );
1558
+ });
1559
+
1560
+ it('processes relay events from the practice session channel', async () => {
1561
+ const announcementSpy = sinon.spy(voiceaService, 'processAnnouncementMessage');
1562
+
1563
+ voiceaService.listenToEvents();
1564
+
1565
+ // eslint-disable-next-line no-underscore-dangle
1566
+ await voiceaService.webex.internal.llm._emit(`event:relay.event:${LLM_PRACTICE_SESSION}`, {
1567
+ headers: {from: 'svc-practice'},
1568
+ data: {
1569
+ relayType: 'voicea.annc',
1570
+ voiceaPayload: {
1571
+ translation: {allowed_languages: ['en'], max_languages: 1},
1572
+ ASR: {spoken_languages: ['en']},
1573
+ },
1574
+ },
1575
+ sequenceNumber: 10,
1576
+ });
1577
+
1578
+ assert.calledOnce(announcementSpy);
1579
+ assert.equal(voiceaService.captionServiceId, 'svc-practice');
1580
+ });
1581
+ });
1208
1582
  });
1209
1583
  });