@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.
package/src/voicea.ts CHANGED
@@ -1,18 +1,19 @@
1
+ import EventEmitter from 'events';
1
2
  import uuid from 'uuid';
2
- import {WebexPlugin, config} from '@webex/webex-core';
3
+ // @ts-ignore - webex-core types
4
+ import {config} from '@webex/webex-core';
3
5
 
6
+ // @ts-ignore - internal-plugin-llm types
7
+ import type LLMChannel from '@webex/internal-plugin-llm';
4
8
  import {
5
9
  EVENT_TRIGGERS,
6
10
  AIBRIDGE_RELAY_TYPES,
7
11
  TRANSCRIPTION_TYPE,
8
- VOICEA,
9
12
  ANNOUNCE_STATUS,
10
13
  TURN_ON_CAPTION_STATUS,
11
14
  TOGGLE_MANUAL_CAPTION_STATUS,
12
15
  DEFAULT_SPOKEN_LANGUAGE,
13
- LANGUAGE_ASSIGNMENT,
14
16
  } from './constants';
15
- // eslint-disable-next-line no-unused-vars
16
17
  import {
17
18
  AnnouncementPayload,
18
19
  CaptionLanguageResponse,
@@ -22,39 +23,64 @@ import {
22
23
  import {millisToMinutesAndSeconds} from './utils';
23
24
 
24
25
  /**
25
- * @description VoiceaChannel to hold single instance of LLM
26
+ * @description VoiceaChannel handles voicea/transcription functionality for a single LLM connection.
27
+ * Created via `webex.internal.voicea.createChannel(llmChannel)`. The caller owns the
28
+ * channel and is responsible for its lifecycle.
26
29
  * @export
27
30
  * @class VoiceaChannel
28
31
  */
29
- export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
30
- namespace = VOICEA;
32
+ export class VoiceaChannel extends EventEmitter implements IVoiceaChannel {
33
+ private request: (options: {method: string; url: string; body?: object}) => Promise<any>;
34
+ private llmChannel: LLMChannel;
31
35
 
32
36
  private seqNum: number;
33
-
34
37
  private areCaptionsEnabled: boolean;
35
-
36
38
  private hasSubscribedToEvents = false;
37
-
38
39
  private captionServiceId?: string;
39
-
40
40
  private announceStatus: string;
41
-
42
41
  private captionStatus: string;
43
42
 
44
- private toggleManualCaptionStatus: string;
43
+ private keepTranscriptionSubscribed: boolean;
45
44
 
45
+ private toggleManualCaptionStatus: string;
46
46
  private currentSpokenLanguage?: string;
47
-
48
47
  private spokenLanguages: string[] = [];
49
-
50
48
  private currentCaptionLanguage?: string;
51
49
 
52
50
  /**
53
- * @param {Object} e
54
- * @returns {undefined}
51
+ * Creates a VoiceaChannel bound to the given LLMChannel
52
+ * @param {LLMChannel} llmChannel - The LLM channel to use
53
+ * @param {Function} request - The request function for making API calls (typically webex.request bound to webex)
55
54
  */
55
+ constructor(
56
+ llmChannel: LLMChannel,
57
+ request: (options: {method: string; url: string; body?: object}) => Promise<any>
58
+ ) {
59
+ super();
60
+ this.llmChannel = llmChannel;
61
+ this.request = request;
62
+
63
+ this.seqNum = 1;
64
+ this.areCaptionsEnabled = false;
65
+ this.captionServiceId = undefined;
66
+ this.announceStatus = ANNOUNCE_STATUS.IDLE;
67
+ this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE;
68
+ this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE;
69
+ this.currentSpokenLanguage = DEFAULT_SPOKEN_LANGUAGE;
70
+ this.currentCaptionLanguage = undefined;
71
+ this.keepTranscriptionSubscribed = false;
56
72
 
57
- private eventProcessor = (e) => {
73
+ // Subscribe to relay events from the LLM channel
74
+ this.llmChannel.on('event:relay.event', this.eventProcessor);
75
+ this.hasSubscribedToEvents = true;
76
+ }
77
+
78
+ /**
79
+ * Process events from LLM channel
80
+ * @param {Object} e - Event data
81
+ * @returns {void}
82
+ */
83
+ private eventProcessor = (e: any): void => {
58
84
  this.seqNum = e.sequenceNumber + 1;
59
85
  switch (e.data.relayType) {
60
86
  case AIBRIDGE_RELAY_TYPES.VOICEA.ANNOUNCEMENT:
@@ -82,27 +108,19 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
82
108
  };
83
109
 
84
110
  /**
85
- * Listen to websocket messages
86
- * @returns {undefined}
87
- */
88
- private listenToEvents() {
89
- if (!this.hasSubscribedToEvents) {
90
- // @ts-ignore
91
- this.webex.internal.llm.on('event:relay.event', this.eventProcessor);
92
- this.hasSubscribedToEvents = true;
93
- }
94
- }
95
-
96
- /**
97
- * Listen to websocket messages
111
+ * Deregister events and clean up
98
112
  * @returns {void}
99
113
  */
100
- public deregisterEvents() {
114
+ public deregisterEvents(): void {
101
115
  this.areCaptionsEnabled = false;
116
+ this.keepTranscriptionSubscribed = false;
102
117
  this.captionServiceId = undefined;
103
- // @ts-ignore
104
- this.webex.internal.llm.off('event:relay.event', this.eventProcessor);
105
- this.hasSubscribedToEvents = false;
118
+
119
+ if (this.hasSubscribedToEvents) {
120
+ this.llmChannel.off('event:relay.event', this.eventProcessor);
121
+ this.hasSubscribedToEvents = false;
122
+ }
123
+
106
124
  this.announceStatus = ANNOUNCE_STATUS.IDLE;
107
125
  this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE;
108
126
  this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE;
@@ -111,19 +129,42 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
111
129
  }
112
130
 
113
131
  /**
114
- * Initializes Voicea plugin
115
- * @param {any} args
132
+ * Switch to a different LLM channel while preserving caption state.
133
+ * Used when transitioning between main meeting and practice session.
134
+ * - Preserves keepTranscriptionSubscribed and spokenLanguage state
135
+ * - Unsubscribes from old channel, subscribes to new channel
136
+ * - Re-announces and re-enables captions if they were on
137
+ * @param {LLMChannel} newLLMChannel - The new LLM channel to switch to
138
+ * @returns {Promise<void>}
116
139
  */
117
- constructor(...args) {
118
- super(...args);
119
- this.seqNum = 1;
120
- this.areCaptionsEnabled = false;
121
- this.captionServiceId = undefined;
140
+ public async switchLLMChannel(newLLMChannel: LLMChannel): Promise<void> {
141
+ // Save current state
142
+ const captionsWereOn = this.keepTranscriptionSubscribed;
143
+ const spokenLanguage = this.currentSpokenLanguage;
144
+
145
+ // Unsubscribe from old channel
146
+ if (this.hasSubscribedToEvents && this.llmChannel) {
147
+ this.llmChannel.off('event:relay.event', this.eventProcessor);
148
+ this.hasSubscribedToEvents = false;
149
+ }
150
+
151
+ // Switch to new channel
152
+ this.llmChannel = newLLMChannel;
153
+
154
+ // Subscribe to new channel
155
+ this.llmChannel.on('event:relay.event', this.eventProcessor);
156
+ this.hasSubscribedToEvents = true;
157
+
158
+ // Reset announcement state for new connection
122
159
  this.announceStatus = ANNOUNCE_STATUS.IDLE;
123
160
  this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE;
124
- this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE;
125
- this.currentSpokenLanguage = DEFAULT_SPOKEN_LANGUAGE;
126
- this.currentCaptionLanguage = undefined;
161
+ this.captionServiceId = undefined;
162
+ this.areCaptionsEnabled = false;
163
+
164
+ // Re-announce and re-enable captions if they were on
165
+ if (captionsWereOn) {
166
+ await this.turnOnCaptions(spokenLanguage);
167
+ }
127
168
  }
128
169
 
129
170
  /**
@@ -136,8 +177,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
136
177
  transcriptPayload.type === TRANSCRIPTION_TYPE.MANUAL_CAPTION_FINAL_RESULT ||
137
178
  transcriptPayload.type === TRANSCRIPTION_TYPE.MANUAL_CAPTION_INTERIM_RESULT
138
179
  ) {
139
- // @ts-ignore
140
- this.trigger(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, {
180
+ this.emit(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, {
141
181
  isFinal: transcriptPayload.type === TRANSCRIPTION_TYPE.MANUAL_CAPTION_FINAL_RESULT,
142
182
  transcriptId: transcriptPayload.id,
143
183
  transcripts: transcriptPayload.transcripts,
@@ -155,8 +195,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
155
195
  private processTranscription = (voiceaPayload: TranscriptionResponse): void => {
156
196
  switch (voiceaPayload.type) {
157
197
  case TRANSCRIPTION_TYPE.TRANSCRIPT_INTERIM_RESULTS:
158
- // @ts-ignore
159
- this.trigger(EVENT_TRIGGERS.NEW_CAPTION, {
198
+ this.emit(EVENT_TRIGGERS.NEW_CAPTION, {
160
199
  isFinal: false,
161
200
  transcriptId: voiceaPayload.transcript_id,
162
201
  transcripts: voiceaPayload.transcripts,
@@ -164,11 +203,10 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
164
203
  break;
165
204
 
166
205
  case TRANSCRIPTION_TYPE.TRANSCRIPT_FINAL_RESULT:
167
- // @ts-ignore
168
- this.trigger(EVENT_TRIGGERS.NEW_CAPTION, {
206
+ this.emit(EVENT_TRIGGERS.NEW_CAPTION, {
169
207
  isFinal: true,
170
208
  transcriptId: voiceaPayload.transcript_id,
171
- transcripts: voiceaPayload.transcripts.map((transcript) => {
209
+ transcripts: voiceaPayload.transcripts?.map((transcript) => {
172
210
  transcript.timestamp = millisToMinutesAndSeconds(transcript.end_millis);
173
211
 
174
212
  return transcript;
@@ -177,20 +215,18 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
177
215
  break;
178
216
 
179
217
  case TRANSCRIPTION_TYPE.HIGHLIGHT_CREATED:
180
- // @ts-ignore
181
- this.trigger(EVENT_TRIGGERS.HIGHLIGHT_CREATED, {
182
- csis: voiceaPayload.highlight.csis,
183
- highlightId: voiceaPayload.highlight.highlight_id,
184
- text: voiceaPayload.highlight.transcript,
185
- highlightLabel: voiceaPayload.highlight.highlight_label,
186
- highlightSource: voiceaPayload.highlight.highlight_source,
187
- timestamp: millisToMinutesAndSeconds(voiceaPayload.highlight.end_millis),
218
+ this.emit(EVENT_TRIGGERS.HIGHLIGHT_CREATED, {
219
+ csis: voiceaPayload.highlight?.csis,
220
+ highlightId: voiceaPayload.highlight?.highlight_id,
221
+ text: voiceaPayload.highlight?.transcript,
222
+ highlightLabel: voiceaPayload.highlight?.highlight_label,
223
+ highlightSource: voiceaPayload.highlight?.highlight_source,
224
+ timestamp: millisToMinutesAndSeconds(voiceaPayload.highlight?.end_millis ?? 0),
188
225
  });
189
226
  break;
190
227
 
191
228
  case TRANSCRIPTION_TYPE.EVA_THANKS:
192
- // @ts-ignore
193
- this.trigger(EVENT_TRIGGERS.EVA_COMMAND, {
229
+ this.emit(EVENT_TRIGGERS.EVA_COMMAND, {
194
230
  isListening: false,
195
231
  text: voiceaPayload.command_response,
196
232
  });
@@ -198,22 +234,18 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
198
234
 
199
235
  case TRANSCRIPTION_TYPE.EVA_WAKE:
200
236
  case TRANSCRIPTION_TYPE.EVA_CANCEL:
201
- // @ts-ignore
202
- this.trigger(EVENT_TRIGGERS.EVA_COMMAND, {
237
+ this.emit(EVENT_TRIGGERS.EVA_COMMAND, {
203
238
  isListening: voiceaPayload.type === TRANSCRIPTION_TYPE.EVA_WAKE,
204
239
  });
205
240
  break;
206
241
 
207
242
  case TRANSCRIPTION_TYPE.LANGUAGE_DETECTED: {
208
243
  const isInSpokenLanguages = this.spokenLanguages.includes(voiceaPayload.language);
209
-
210
244
  if (isInSpokenLanguages) {
211
- // @ts-ignore
212
- this.trigger(EVENT_TRIGGERS.LANGUAGE_DETECTED, {
245
+ this.emit(EVENT_TRIGGERS.LANGUAGE_DETECTED, {
213
246
  languageCode: voiceaPayload.language,
214
247
  });
215
248
  }
216
-
217
249
  break;
218
250
  }
219
251
  default:
@@ -228,11 +260,9 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
228
260
  */
229
261
  private processCaptionLanguageResponse = (voiceaPayload: CaptionLanguageResponse): void => {
230
262
  if (voiceaPayload.statusCode === 200) {
231
- // @ts-ignore
232
- this.trigger(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, {statusCode: 200});
263
+ this.emit(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, {statusCode: 200});
233
264
  } else {
234
- // @ts-ignore
235
- this.trigger(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, {
265
+ this.emit(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, {
236
266
  statusCode: voiceaPayload.errorCode,
237
267
  errorMessage: voiceaPayload.message,
238
268
  });
@@ -253,25 +283,34 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
253
283
  };
254
284
 
255
285
  this.spokenLanguages = voiceaPayload?.ASR?.spoken_languages ?? [];
256
- // @ts-ignore
257
- this.trigger(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, voiceaLanguageOptions);
286
+ this.emit(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, voiceaLanguageOptions);
258
287
  };
259
288
 
289
+ /**
290
+ * Indicates whether the LLM channel is connected.
291
+ * @returns {boolean}
292
+ */
293
+ public isLLMConnected = (): boolean => this.llmChannel.isConnected();
294
+
295
+ public getKeepTranscriptionSubscribed = (): boolean => this.keepTranscriptionSubscribed;
296
+
260
297
  /**
261
298
  * Sends Announcement to add voicea to the meeting
262
299
  * @returns {void}
263
300
  */
264
- private sendAnnouncement = (): void => {
301
+ public sendAnnouncement = (): void => {
265
302
  this.announceStatus = ANNOUNCE_STATUS.JOINING;
266
- this.listenToEvents();
267
- // @ts-ignore
268
- this.webex.internal.llm.socket.send({
303
+ const socket = this.llmChannel.getSocket();
304
+ const binding = this.llmChannel.getBinding();
305
+
306
+ const payload = {
269
307
  id: `${this.seqNum}`,
270
308
  type: 'publishRequest',
271
- recipients: {
272
- // @ts-ignore
273
- route: this.webex.internal.llm.getBinding(),
274
- },
309
+ recipients: [
310
+ {
311
+ route: binding,
312
+ },
313
+ ],
275
314
  // If captionServiceId exists, send it as the 'to' header; otherwise keep headers empty.
276
315
  headers: this.captionServiceId ? {to: this.captionServiceId} : {},
277
316
  data: {
@@ -282,7 +321,8 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
282
321
  relayType: AIBRIDGE_RELAY_TYPES.VOICEA.CLIENT_ANNOUNCEMENT,
283
322
  },
284
323
  trackingId: `${config.trackingIdPrefix}_${uuid.v4().toString()}`,
285
- });
324
+ };
325
+ socket.send(payload);
286
326
  this.seqNum += 1;
287
327
  };
288
328
 
@@ -296,11 +336,9 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
296
336
  languageCode: string,
297
337
  languageAssignment?: 'DEFAULT' | 'AUTO' | 'MANUAL'
298
338
  ): Promise<void> =>
299
- // @ts-ignore
300
339
  this.request({
301
340
  method: 'PUT',
302
- // @ts-ignore
303
- url: `${this.webex.internal.llm.getLocusUrl()}/controls/`,
341
+ url: `${this.llmChannel.getLocusUrl()}/controls/`,
304
342
  body: {
305
343
  transcribe: {
306
344
  spokenLanguage: languageCode,
@@ -308,8 +346,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
308
346
  },
309
347
  },
310
348
  }).then(() => {
311
- // @ts-ignore
312
- this.trigger(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode});
349
+ this.emit(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode});
313
350
  });
314
351
 
315
352
  /**
@@ -318,16 +355,21 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
318
355
  * @returns {void}
319
356
  */
320
357
  public requestLanguage = (languageCode: string): void => {
321
- // @ts-ignore
322
- if (!this.webex.internal.llm.isConnected()) return;
323
- // @ts-ignore
324
- this.webex.internal.llm.socket.send({
358
+ if (!this.isLLMConnected()) {
359
+ return;
360
+ }
361
+
362
+ const socket = this.llmChannel.getSocket();
363
+ const binding = this.llmChannel.getBinding();
364
+
365
+ socket.send({
325
366
  id: `${this.seqNum}`,
326
367
  type: 'publishRequest',
327
- recipients: {
328
- // @ts-ignore
329
- route: this.webex.internal.llm.getBinding(),
330
- },
368
+ recipients: [
369
+ {
370
+ route: binding,
371
+ },
372
+ ],
331
373
  headers: {
332
374
  to: this.captionServiceId,
333
375
  },
@@ -342,7 +384,6 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
342
384
  trackingId: `${config.trackingIdPrefix}_${uuid.v4().toString()}`,
343
385
  });
344
386
  this.currentCaptionLanguage = languageCode;
345
-
346
387
  this.seqNum += 1;
347
388
  };
348
389
 
@@ -360,17 +401,21 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
360
401
  csis: number[],
361
402
  isFinal: boolean
362
403
  ): void => {
363
- // @ts-ignore
364
- if (!this.webex.internal.llm.isConnected()) return;
404
+ if (!this.isLLMConnected()) {
405
+ return;
406
+ }
407
+
408
+ const socket = this.llmChannel.getSocket();
409
+ const binding = this.llmChannel.getBinding();
365
410
 
366
- // @ts-ignore
367
- this.webex.internal.llm.socket.send({
411
+ socket?.send({
368
412
  id: `${this.seqNum}`,
369
413
  type: 'publishRequest',
370
- recipients: {
371
- // @ts-ignore
372
- route: this.webex.internal.llm.getBinding(),
373
- },
414
+ recipients: [
415
+ {
416
+ route: binding,
417
+ },
418
+ ],
374
419
  headers: {},
375
420
  data: {
376
421
  eventType: 'relay.event',
@@ -398,35 +443,33 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
398
443
 
399
444
  /**
400
445
  * request turn on Captions
401
- * @param {string} [languageCode] - Optional Parameter for spoken language code. Defaults to English
446
+ * @param {string} [languageCode] - Optional Parameter for spoken language code
402
447
  * @returns {Promise}
403
448
  */
404
- private requestTurnOnCaptions = (languageCode?): undefined | Promise<void> => {
449
+ private requestTurnOnCaptions = (languageCode?: string): undefined | Promise<void> => {
405
450
  this.captionStatus = TURN_ON_CAPTION_STATUS.SENDING;
406
451
 
407
- // only set the spoken language if it is provided
452
+ const locusUrl = this.llmChannel.getLocusUrl();
453
+
408
454
  const body = {
409
455
  transcribe: {caption: true},
410
456
  languageCode,
411
457
  };
412
458
 
413
- // @ts-ignore
414
- // eslint-disable-next-line newline-before-return
415
459
  return this.request({
416
460
  method: 'PUT',
417
- // @ts-ignore
418
- url: `${this.webex.internal.llm.getLocusUrl()}/controls/`,
461
+ url: `${locusUrl}/controls/`,
419
462
  body,
420
463
  })
421
464
  .then(() => {
422
- // @ts-ignore
423
- this.trigger(EVENT_TRIGGERS.CAPTIONS_TURNED_ON);
465
+ this.emit(EVENT_TRIGGERS.CAPTIONS_TURNED_ON);
424
466
 
425
467
  this.areCaptionsEnabled = true;
426
468
  this.captionStatus = TURN_ON_CAPTION_STATUS.ENABLED;
427
469
  this.announce();
470
+ this.updateSubchannelSubscriptionsAndSyncCaptionState({subscribe: ['transcription']}, true);
428
471
  })
429
- .catch(() => {
472
+ .catch((error) => {
430
473
  this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE;
431
474
  throw new Error('turn on captions fail');
432
475
  });
@@ -436,17 +479,24 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
436
479
  * is announce processing
437
480
  * @returns {boolean}
438
481
  */
439
- private isAnnounceProcessing = () =>
482
+ private isAnnounceProcessing = (): boolean =>
440
483
  [ANNOUNCE_STATUS.JOINING, ANNOUNCE_STATUS.JOINED].includes(this.announceStatus);
441
484
 
442
485
  /**
443
- * announce to voicea data chanel
486
+ * is announce processed
487
+ * @returns {boolean}
488
+ */
489
+ private isAnnounceProcessed = (): boolean => this.announceStatus === ANNOUNCE_STATUS.JOINED;
490
+
491
+ /**
492
+ * announce to voicea data channel
444
493
  * @returns {void}
445
494
  */
446
- public announce = () => {
447
- if (this.isAnnounceProcessing()) return;
448
- // @ts-ignore
449
- if (!this.webex.internal.llm.isConnected()) {
495
+ public announce = (): void => {
496
+ if (this.isAnnounceProcessed()) {
497
+ return;
498
+ }
499
+ if (!this.isLLMConnected()) {
450
500
  throw new Error('voicea can not announce before llm connected');
451
501
  }
452
502
  this.sendAnnouncement();
@@ -456,7 +506,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
456
506
  * is turn on caption processing
457
507
  * @returns {boolean}
458
508
  */
459
- private isCaptionProcessing = () =>
509
+ private isCaptionProcessing = (): boolean =>
460
510
  [TURN_ON_CAPTION_STATUS.SENDING, TURN_ON_CAPTION_STATUS.ENABLED].includes(this.captionStatus);
461
511
 
462
512
  /**
@@ -464,10 +514,10 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
464
514
  * @param {string} [spokenLanguage] - Optional Spoken language code
465
515
  * @returns {Promise}
466
516
  */
467
- public turnOnCaptions = async (spokenLanguage?): undefined | Promise<void> => {
517
+ public turnOnCaptions = async (spokenLanguage?: string): Promise<void | undefined> => {
468
518
  if (this.captionStatus === TURN_ON_CAPTION_STATUS.SENDING) return undefined;
469
- // @ts-ignore
470
- if (!this.webex.internal.llm.isConnected()) {
519
+
520
+ if (!this.isLLMConnected()) {
471
521
  throw new Error('can not turn on captions before llm connected');
472
522
  }
473
523
 
@@ -484,11 +534,9 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
484
534
  activate: boolean,
485
535
  spokenLanguage?: string
486
536
  ): undefined | Promise<void> => {
487
- // @ts-ignore
488
537
  return this.request({
489
538
  method: 'PUT',
490
- // @ts-ignore
491
- url: `${this.webex.internal.llm.getLocusUrl()}/controls/`,
539
+ url: `${this.llmChannel.getLocusUrl()}/controls/`,
492
540
  body: {
493
541
  transcribe: {
494
542
  transcribing: activate,
@@ -514,11 +562,9 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
514
562
 
515
563
  this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING;
516
564
 
517
- // @ts-ignore
518
565
  return this.request({
519
566
  method: 'PUT',
520
- // @ts-ignore
521
- url: `${this.webex.internal.llm.getLocusUrl()}/controls/`,
567
+ url: `${this.llmChannel.getLocusUrl()}/controls/`,
522
568
  body: {
523
569
  manualCaption: {
524
570
  enable,
@@ -542,9 +588,8 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
542
588
  * @param {string} meetingId
543
589
  * @returns {void}
544
590
  */
545
- public onSpokenLanguageUpdate = (languageCode: string, meetingId): void => {
546
- // @ts-ignore
547
- this.trigger(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode, meetingId});
591
+ public onSpokenLanguageUpdate = (languageCode: string, meetingId: string): void => {
592
+ this.emit(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode, meetingId});
548
593
  this.currentSpokenLanguage = languageCode;
549
594
  };
550
595
 
@@ -559,7 +604,6 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
559
604
  }
560
605
  if (this.captionServiceId !== serviceId) {
561
606
  this.captionServiceId = serviceId;
562
- // if service id value has changed and the translation language has been set, client needs to resend the translator language message to the LLM.
563
607
  if (this.currentCaptionLanguage) {
564
608
  this.requestLanguage(this.currentCaptionLanguage);
565
609
  }
@@ -570,13 +614,73 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel {
570
614
  * get caption status
571
615
  * @returns {string}
572
616
  */
573
- public getCaptionStatus = () => this.captionStatus;
617
+ public getCaptionStatus = (): string => this.captionStatus;
574
618
 
575
619
  /**
576
620
  * get announce status
577
621
  * @returns {string}
578
622
  */
579
- public getAnnounceStatus = () => this.announceStatus;
623
+ public getAnnounceStatus = (): string => this.announceStatus;
624
+
625
+ /**
626
+ * update LLM sub‑channel subscriptions.
627
+ *
628
+ * @param {string[]} options.subscribe Sub‑channels to subscribe to.
629
+ * @param {string[]} options.unsubscribe Sub‑channels to unsubscribe from.
630
+ * @returns {Promise}
631
+ */
632
+ public updateSubchannelSubscriptions = async ({
633
+ subscribe = [],
634
+ unsubscribe = [],
635
+ }: {
636
+ subscribe?: string[];
637
+ unsubscribe?: string[];
638
+ } = {}): Promise<void> => {
639
+ if (!this.isLLMConnected()) return;
640
+
641
+ const isDataChannelTokenEnabled = await this.llmChannel.isDataChannelTokenEnabled();
642
+ if (!isDataChannelTokenEnabled) return;
643
+
644
+ const socket = this.llmChannel.getSocket();
645
+ const datachannelUrl = this.llmChannel.getDatachannelUrl();
646
+
647
+ socket.send({
648
+ id: `${this.seqNum}`,
649
+ type: 'subchannelSubscriptionRequest',
650
+ data: {
651
+ datachannelUri: datachannelUrl,
652
+ subscribe,
653
+ unsubscribe,
654
+ },
655
+ trackingId: `${config.trackingIdPrefix}_${uuid.v4().toString()}`,
656
+ });
657
+
658
+ this.seqNum += 1;
659
+ };
660
+
661
+ /**
662
+ * Updates transcription subchannel subscriptions and records whether the
663
+ * transcription subscription should be kept (and restored on reconnect).
664
+ *
665
+ * @param {Object} [options] - Subscription options.
666
+ * @param {string[]} [options.subscribe] - Subchannels to subscribe to.
667
+ * @param {string[]} [options.unsubscribe] - Subchannels to unsubscribe from.
668
+ * @param {boolean} [keepSubscribed=false] - Whether the transcription
669
+ * subscription should be kept and restored on reconnect.
670
+ *
671
+ * @returns {Promise<void>}
672
+ */
673
+ public updateSubchannelSubscriptionsAndSyncCaptionState = (
674
+ options: {
675
+ subscribe?: string[];
676
+ unsubscribe?: string[];
677
+ } = {},
678
+ keepSubscribed = false
679
+ ): Promise<void> => {
680
+ this.keepTranscriptionSubscribed = keepSubscribed;
681
+
682
+ return this.updateSubchannelSubscriptions(options);
683
+ };
580
684
  }
581
685
 
582
686
  export default VoiceaChannel;
@@ -96,6 +96,7 @@ interface IVoiceaChannel {
96
96
  csis: number[],
97
97
  isFinal: boolean
98
98
  ) => void;
99
+ getKeepTranscriptionSubscribed: () => boolean;
99
100
  }
100
101
 
101
102
  type MeetingTranscripts = {