@webex/internal-plugin-voicea 3.12.0-llmrefactor.1 → 3.12.0-llmrefactor.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types/constants.d.ts +2 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/voicea-plugin.d.ts +3 -3
- package/dist/types/voicea.d.ts +51 -7
- package/dist/types/voicea.types.d.ts +18 -1
- package/dist/voicea-plugin.js +2 -2
- package/dist/voicea-plugin.js.map +1 -1
- package/dist/voicea.js +169 -70
- package/dist/voicea.js.map +1 -1
- package/dist/voicea.types.js.map +1 -1
- package/package.json +8 -8
- package/src/constants.ts +2 -0
- package/src/index.ts +2 -2
- package/src/voicea-plugin.ts +3 -3
- package/src/voicea.ts +156 -50
- package/src/voicea.types.ts +21 -0
- package/test/unit/spec/voicea-plugin.js +12 -0
- package/test/unit/spec/voicea.js +282 -4
package/test/unit/spec/voicea.js
CHANGED
|
@@ -8,6 +8,8 @@ import Mercury from '@webex/internal-plugin-mercury';
|
|
|
8
8
|
import {VoiceaChannel} from '../../../src/voicea';
|
|
9
9
|
import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants';
|
|
10
10
|
|
|
11
|
+
const flushPromises = () => new Promise(setImmediate);
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Creates a mock LLM channel for testing
|
|
13
15
|
* @param {Object} [options] - Options for the mock channel
|
|
@@ -28,6 +30,7 @@ function createMockLLMChannel(options = {}) {
|
|
|
28
30
|
isDataChannelTokenEnabled: sinon.stub().resolves(true),
|
|
29
31
|
on: sinon.stub(),
|
|
30
32
|
off: sinon.stub(),
|
|
33
|
+
once: sinon.stub(),
|
|
31
34
|
socket: mockWebSocket,
|
|
32
35
|
};
|
|
33
36
|
}
|
|
@@ -65,9 +68,15 @@ describe('plugin-voicea', () => {
|
|
|
65
68
|
assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
|
|
66
69
|
});
|
|
67
70
|
|
|
68
|
-
it('should subscribe to relay events', () => {
|
|
71
|
+
it('should subscribe to relay events when llmChannel is provided', () => {
|
|
69
72
|
assert.calledWith(mockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
70
73
|
});
|
|
74
|
+
|
|
75
|
+
it('should not subscribe to events when llmChannel is undefined', () => {
|
|
76
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
77
|
+
// No llmChannel means no subscription, just verify it doesn't throw
|
|
78
|
+
assert.equal(channelWithoutLLM.getAnnounceStatus(), 'idle');
|
|
79
|
+
});
|
|
71
80
|
});
|
|
72
81
|
|
|
73
82
|
describe('#sendAnnouncement', () => {
|
|
@@ -193,11 +202,24 @@ describe('plugin-voicea', () => {
|
|
|
193
202
|
});
|
|
194
203
|
|
|
195
204
|
describe('#deregisterEvents', () => {
|
|
196
|
-
|
|
197
205
|
beforeEach(async () => {
|
|
198
206
|
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
199
207
|
});
|
|
200
208
|
|
|
209
|
+
it('works when llmChannel is undefined', () => {
|
|
210
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
211
|
+
channelWithoutLLM.areCaptionsEnabled = true;
|
|
212
|
+
channelWithoutLLM.keepTranscriptionSubscribed = true;
|
|
213
|
+
channelWithoutLLM.hasSubscribedToEvents = true;
|
|
214
|
+
|
|
215
|
+
// Should not throw
|
|
216
|
+
channelWithoutLLM.deregisterEvents();
|
|
217
|
+
|
|
218
|
+
assert.equal(channelWithoutLLM.areCaptionsEnabled, false);
|
|
219
|
+
assert.equal(channelWithoutLLM.keepTranscriptionSubscribed, false);
|
|
220
|
+
assert.equal(channelWithoutLLM.hasSubscribedToEvents, false);
|
|
221
|
+
});
|
|
222
|
+
|
|
201
223
|
it('deregisters voicea channel and resets state', () => {
|
|
202
224
|
voiceaChannel.areCaptionsEnabled = true;
|
|
203
225
|
voiceaChannel.captionServiceId = 'ws';
|
|
@@ -322,6 +344,11 @@ describe('plugin-voicea', () => {
|
|
|
322
344
|
mockLLMChannel.isConnected.returns(false);
|
|
323
345
|
assert.equal(voiceaChannel.isLLMConnected(), false);
|
|
324
346
|
});
|
|
347
|
+
|
|
348
|
+
it('returns false when the LLM channel is undefined', () => {
|
|
349
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
350
|
+
assert.equal(channelWithoutLLM.isLLMConnected(), false);
|
|
351
|
+
});
|
|
325
352
|
});
|
|
326
353
|
|
|
327
354
|
describe('#getKeepTranscriptionSubscribed', () => {
|
|
@@ -388,10 +415,25 @@ describe('plugin-voicea', () => {
|
|
|
388
415
|
assert.equal(result, undefined);
|
|
389
416
|
});
|
|
390
417
|
|
|
418
|
+
it('returns undefined when already enabled', async () => {
|
|
419
|
+
voiceaChannel.captionStatus = 'enabled';
|
|
420
|
+
|
|
421
|
+
const result = await voiceaChannel.turnOnCaptions();
|
|
422
|
+
|
|
423
|
+
assert.equal(result, undefined);
|
|
424
|
+
assert.notCalled(requestStub);
|
|
425
|
+
});
|
|
426
|
+
|
|
391
427
|
it('throws error on request failure', async () => {
|
|
392
|
-
|
|
428
|
+
const requestError = new Error('Request failed');
|
|
429
|
+
requestStub.rejects(requestError);
|
|
393
430
|
|
|
394
|
-
await assert.isRejected(
|
|
431
|
+
const error = await assert.isRejected(
|
|
432
|
+
voiceaChannel.turnOnCaptions(),
|
|
433
|
+
'turn on captions fail'
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
assert.equal(error.cause, requestError);
|
|
395
437
|
});
|
|
396
438
|
|
|
397
439
|
it('resets caption status to idle on error', async () => {
|
|
@@ -701,6 +743,50 @@ describe('plugin-voicea', () => {
|
|
|
701
743
|
});
|
|
702
744
|
});
|
|
703
745
|
|
|
746
|
+
it('processes speaker name updates and advances the message sequence', () => {
|
|
747
|
+
const spy = sinon.spy();
|
|
748
|
+
const voiceaPayload = {
|
|
749
|
+
id: 'speaker-name-update-1',
|
|
750
|
+
taggedSpeakers: [
|
|
751
|
+
{
|
|
752
|
+
csiId: 3556942592,
|
|
753
|
+
speakerId: '1',
|
|
754
|
+
newName: 'Alice',
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
voiceaChannel.on(EVENT_TRIGGERS.SPEAKER_NAME_UPDATED, spy);
|
|
760
|
+
|
|
761
|
+
eventHandler({
|
|
762
|
+
sequenceNumber: 23,
|
|
763
|
+
headers: {from: 'ws'},
|
|
764
|
+
data: {
|
|
765
|
+
relayType: 'voicea.update_speakername',
|
|
766
|
+
voiceaPayload,
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
assert.calledOnceWithExactly(spy, voiceaPayload);
|
|
771
|
+
|
|
772
|
+
voiceaChannel.sendAnnouncement();
|
|
773
|
+
|
|
774
|
+
assert.calledOnceWithExactly(mockLLMChannel.socket.send, {
|
|
775
|
+
id: '24',
|
|
776
|
+
type: 'publishRequest',
|
|
777
|
+
recipients: [{route: 'binding'}],
|
|
778
|
+
headers: {},
|
|
779
|
+
data: {
|
|
780
|
+
clientPayload: {
|
|
781
|
+
version: 'v2',
|
|
782
|
+
},
|
|
783
|
+
eventType: 'relay.event',
|
|
784
|
+
relayType: 'client.annc',
|
|
785
|
+
},
|
|
786
|
+
trackingId: sinon.match.string,
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
704
790
|
it('processes transcription interim results', () => {
|
|
705
791
|
const spy = sinon.spy();
|
|
706
792
|
voiceaChannel.on(EVENT_TRIGGERS.NEW_CAPTION, spy);
|
|
@@ -975,6 +1061,50 @@ describe('plugin-voicea', () => {
|
|
|
975
1061
|
});
|
|
976
1062
|
});
|
|
977
1063
|
|
|
1064
|
+
it('resolves after caption restoration completes', async () => {
|
|
1065
|
+
let resolveRequest;
|
|
1066
|
+
requestStub.returns(
|
|
1067
|
+
new Promise((resolve) => {
|
|
1068
|
+
resolveRequest = resolve;
|
|
1069
|
+
})
|
|
1070
|
+
);
|
|
1071
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1072
|
+
|
|
1073
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1074
|
+
const switchPromise = voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1075
|
+
let hasSwitchResolved = false;
|
|
1076
|
+
switchPromise.then(() => {
|
|
1077
|
+
hasSwitchResolved = true;
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
await flushPromises();
|
|
1081
|
+
|
|
1082
|
+
assert.isFalse(hasSwitchResolved);
|
|
1083
|
+
|
|
1084
|
+
resolveRequest();
|
|
1085
|
+
await switchPromise;
|
|
1086
|
+
|
|
1087
|
+
assert.isTrue(hasSwitchResolved);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it('skips switch when already on the same channel', async () => {
|
|
1091
|
+
// Enable captions to verify they are NOT re-enabled on no-op switch
|
|
1092
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1093
|
+
voiceaChannel.currentSpokenLanguage = 'es';
|
|
1094
|
+
|
|
1095
|
+
// Switch to the same channel that voiceaChannel is already using
|
|
1096
|
+
await voiceaChannel.switchLLMChannel(mockLLMChannel);
|
|
1097
|
+
|
|
1098
|
+
// Should NOT have unsubscribed (no-op)
|
|
1099
|
+
assert.notCalled(mockLLMChannel.off);
|
|
1100
|
+
|
|
1101
|
+
// Should NOT have re-subscribed
|
|
1102
|
+
// The 'on' was already called in the constructor, but no NEW calls should happen
|
|
1103
|
+
// Since constructor already called 'on', we check it wasn't called again after construction
|
|
1104
|
+
// The mock was created fresh, so we just verify no additional sends
|
|
1105
|
+
assert.notCalled(mockLLMChannel.socket.send);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
978
1108
|
it('does not turn on captions if they were not on before switching', async () => {
|
|
979
1109
|
// Captions are off
|
|
980
1110
|
voiceaChannel.keepTranscriptionSubscribed = false;
|
|
@@ -1011,6 +1141,154 @@ describe('plugin-voicea', () => {
|
|
|
1011
1141
|
// Should have subscribed to new channel
|
|
1012
1142
|
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1013
1143
|
});
|
|
1144
|
+
|
|
1145
|
+
it('switches from undefined llmChannel to a valid one', async () => {
|
|
1146
|
+
// Create a channel without llmChannel
|
|
1147
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
1148
|
+
|
|
1149
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1150
|
+
|
|
1151
|
+
await channelWithoutLLM.switchLLMChannel(newMockLLMChannel);
|
|
1152
|
+
|
|
1153
|
+
// Should have subscribed to new channel
|
|
1154
|
+
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1155
|
+
|
|
1156
|
+
// isLLMConnected should now return true
|
|
1157
|
+
assert.equal(channelWithoutLLM.isLLMConnected(), true);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('defers caption restoration when new channel is not connected', async () => {
|
|
1161
|
+
// Enable captions
|
|
1162
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1163
|
+
voiceaChannel.currentSpokenLanguage = 'fr';
|
|
1164
|
+
|
|
1165
|
+
// Create a new channel that is NOT connected yet
|
|
1166
|
+
const newMockLLMChannel = createMockLLMChannel({
|
|
1167
|
+
isConnected: false,
|
|
1168
|
+
locusUrl: 'newLocusUrl',
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1172
|
+
|
|
1173
|
+
// Should have subscribed to new channel events
|
|
1174
|
+
assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
|
|
1175
|
+
|
|
1176
|
+
// Should have registered a 'once' listener for 'online' event
|
|
1177
|
+
assert.calledWith(newMockLLMChannel.once, 'online', sinon.match.func);
|
|
1178
|
+
|
|
1179
|
+
// Should NOT have sent any messages yet (waiting for connection)
|
|
1180
|
+
assert.notCalled(newMockLLMChannel.socket.send);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it('restores captions when deferred channel comes online', async () => {
|
|
1184
|
+
// Enable captions
|
|
1185
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1186
|
+
voiceaChannel.currentSpokenLanguage = 'de';
|
|
1187
|
+
|
|
1188
|
+
// Create a new channel that is NOT connected yet
|
|
1189
|
+
const newMockLLMChannel = createMockLLMChannel({
|
|
1190
|
+
isConnected: false,
|
|
1191
|
+
locusUrl: 'newLocusUrl',
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1195
|
+
|
|
1196
|
+
// Get the 'online' listener that was registered
|
|
1197
|
+
const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
|
|
1198
|
+
|
|
1199
|
+
// Simulate channel coming online
|
|
1200
|
+
newMockLLMChannel.isConnected.returns(true);
|
|
1201
|
+
onlineListener();
|
|
1202
|
+
|
|
1203
|
+
// Wait for async turnOnCaptions to complete
|
|
1204
|
+
await flushPromises();
|
|
1205
|
+
|
|
1206
|
+
// Now it should have sent messages (announcement + subchannel subscription)
|
|
1207
|
+
assert.calledTwice(newMockLLMChannel.socket.send);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('removes pending online listener when deregisterEvents is called', async () => {
|
|
1211
|
+
// Enable captions
|
|
1212
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1213
|
+
|
|
1214
|
+
// Create a new channel that is NOT connected yet
|
|
1215
|
+
const newMockLLMChannel = createMockLLMChannel({
|
|
1216
|
+
isConnected: false,
|
|
1217
|
+
locusUrl: 'newLocusUrl',
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
await voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1221
|
+
|
|
1222
|
+
// Get the 'online' listener that was registered
|
|
1223
|
+
const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
|
|
1224
|
+
|
|
1225
|
+
// Now deregister events
|
|
1226
|
+
voiceaChannel.deregisterEvents();
|
|
1227
|
+
|
|
1228
|
+
// Should have removed the 'online' listener
|
|
1229
|
+
assert.calledWith(newMockLLMChannel.off, 'online', onlineListener);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
it('removes old pending online listener when switching channels again', async () => {
|
|
1233
|
+
// Enable captions
|
|
1234
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1235
|
+
|
|
1236
|
+
// Create first new channel that is NOT connected
|
|
1237
|
+
const firstNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'firstUrl'});
|
|
1238
|
+
|
|
1239
|
+
await voiceaChannel.switchLLMChannel(firstNewChannel);
|
|
1240
|
+
|
|
1241
|
+
// Get the 'online' listener registered on first channel
|
|
1242
|
+
const firstOnlineListener = firstNewChannel.once.getCall(0).args[1];
|
|
1243
|
+
|
|
1244
|
+
// Now switch to another channel (also not connected)
|
|
1245
|
+
const secondNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'secondUrl'});
|
|
1246
|
+
|
|
1247
|
+
await voiceaChannel.switchLLMChannel(secondNewChannel);
|
|
1248
|
+
|
|
1249
|
+
// Should have removed the 'online' listener from first channel
|
|
1250
|
+
assert.calledWith(firstNewChannel.off, 'online', firstOnlineListener);
|
|
1251
|
+
|
|
1252
|
+
// Should have registered a new 'online' listener on second channel
|
|
1253
|
+
assert.calledWith(secondNewChannel.once, 'online', sinon.match.func);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it('does not duplicate caption HTTP requests on concurrent same-channel switches', async () => {
|
|
1257
|
+
// Enable captions
|
|
1258
|
+
voiceaChannel.keepTranscriptionSubscribed = true;
|
|
1259
|
+
voiceaChannel.currentSpokenLanguage = 'en';
|
|
1260
|
+
|
|
1261
|
+
const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
|
|
1262
|
+
|
|
1263
|
+
// Simulate concurrent switches to the same channel
|
|
1264
|
+
const switch1 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1265
|
+
const switch2 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
|
|
1266
|
+
|
|
1267
|
+
await Promise.all([switch1, switch2]);
|
|
1268
|
+
|
|
1269
|
+
// Wait for fire-and-forget turnOnCaptions to complete
|
|
1270
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1271
|
+
|
|
1272
|
+
// Should only have sent 2 messages total (announcement + subchannel subscription)
|
|
1273
|
+
// NOT 4 messages (which would indicate duplicate switches)
|
|
1274
|
+
assert.calledTwice(newMockLLMChannel.socket.send);
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
describe('#requireLLMChannel', () => {
|
|
1279
|
+
it('throws when llmChannel is undefined', () => {
|
|
1280
|
+
const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
|
|
1281
|
+
|
|
1282
|
+
assert.throws(
|
|
1283
|
+
() => channelWithoutLLM.sendAnnouncement(),
|
|
1284
|
+
'VoiceaChannel: LLM channel not available'
|
|
1285
|
+
);
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it('does not throw when llmChannel is defined', () => {
|
|
1289
|
+
// voiceaChannel has a mockLLMChannel, so this should not throw
|
|
1290
|
+
assert.doesNotThrow(() => voiceaChannel.sendAnnouncement());
|
|
1291
|
+
});
|
|
1014
1292
|
});
|
|
1015
1293
|
});
|
|
1016
1294
|
});
|