@volley/recognition-client-sdk 0.1.200 → 0.1.210
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/README.md +115 -15
- package/dist/{browser-CDQ_TzeH.d.ts → browser-C4ZssGoU.d.ts} +4 -3
- package/dist/index.d.ts +9 -5
- package/dist/index.js +24 -7
- package/dist/index.js.map +1 -1
- package/dist/recog-client-sdk.browser.d.ts +1 -1
- package/dist/recog-client-sdk.browser.js +24 -7
- package/dist/recog-client-sdk.browser.js.map +1 -1
- package/package.json +16 -16
- package/src/config-builder.spec.ts +265 -0
- package/src/factory.spec.ts +215 -0
- package/src/factory.ts +4 -0
- package/src/recognition-client.spec.ts +179 -0
- package/src/recognition-client.ts +44 -1
- package/src/recognition-client.types.ts +2 -2
- package/src/simplified-vgf-recognition-client.spec.ts +6 -0
- package/src/simplified-vgf-recognition-client.ts +3 -3
- package/src/utils/message-handler.spec.ts +311 -0
- package/src/utils/url-builder.spec.ts +203 -0
|
@@ -451,6 +451,185 @@ describe('RealTimeTwoWayWebSocketRecognitionClient', () => {
|
|
|
451
451
|
});
|
|
452
452
|
});
|
|
453
453
|
|
|
454
|
+
describe('Blob Audio Handling', () => {
|
|
455
|
+
it('should accept Blob as audio input', async () => {
|
|
456
|
+
const audioData = new Uint8Array([1, 2, 3, 4]);
|
|
457
|
+
const blob = new Blob([audioData], { type: 'audio/raw' });
|
|
458
|
+
|
|
459
|
+
// Should not throw
|
|
460
|
+
expect(() => client.sendAudio(blob)).not.toThrow();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should convert Blob to ArrayBuffer before buffering', async () => {
|
|
464
|
+
const audioData = new Uint8Array([1, 2, 3, 4]);
|
|
465
|
+
const blob = new Blob([audioData], { type: 'audio/raw' });
|
|
466
|
+
|
|
467
|
+
client.sendAudio(blob);
|
|
468
|
+
|
|
469
|
+
// Wait for async conversion
|
|
470
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
471
|
+
|
|
472
|
+
const stats = client.getStats();
|
|
473
|
+
// Should have buffered the converted data
|
|
474
|
+
expect(stats.currentBufferedChunks).toBeGreaterThan(0);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should handle empty Blob', async () => {
|
|
478
|
+
const blob = new Blob([], { type: 'audio/raw' });
|
|
479
|
+
|
|
480
|
+
client.sendAudio(blob);
|
|
481
|
+
|
|
482
|
+
// Wait for async conversion
|
|
483
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
484
|
+
|
|
485
|
+
const stats = client.getStats();
|
|
486
|
+
// Empty blob should not be buffered
|
|
487
|
+
expect(stats.currentBufferedChunks).toBe(0);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('should handle large Blob', async () => {
|
|
491
|
+
const largeData = new Uint8Array(1024 * 1024); // 1MB
|
|
492
|
+
for (let i = 0; i < largeData.length; i++) {
|
|
493
|
+
largeData[i] = i % 256;
|
|
494
|
+
}
|
|
495
|
+
const blob = new Blob([largeData], { type: 'audio/raw' });
|
|
496
|
+
|
|
497
|
+
client.sendAudio(blob);
|
|
498
|
+
|
|
499
|
+
// Wait for async conversion
|
|
500
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
501
|
+
|
|
502
|
+
const stats = client.getStats();
|
|
503
|
+
expect(stats.currentBufferedChunks).toBe(1);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should handle multiple Blobs in sequence', async () => {
|
|
507
|
+
const blob1 = new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/raw' });
|
|
508
|
+
const blob2 = new Blob([new Uint8Array([5, 6, 7, 8])], { type: 'audio/raw' });
|
|
509
|
+
const blob3 = new Blob([new Uint8Array([9, 10, 11, 12])], { type: 'audio/raw' });
|
|
510
|
+
|
|
511
|
+
client.sendAudio(blob1);
|
|
512
|
+
client.sendAudio(blob2);
|
|
513
|
+
client.sendAudio(blob3);
|
|
514
|
+
|
|
515
|
+
// Wait for all async conversions
|
|
516
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
517
|
+
|
|
518
|
+
const stats = client.getStats();
|
|
519
|
+
expect(stats.currentBufferedChunks).toBe(3);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should handle mixed Blob and ArrayBuffer inputs', async () => {
|
|
523
|
+
const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/raw' });
|
|
524
|
+
const arrayBuffer = new ArrayBuffer(4);
|
|
525
|
+
const view = new Uint8Array(arrayBuffer);
|
|
526
|
+
view.set([5, 6, 7, 8]);
|
|
527
|
+
|
|
528
|
+
client.sendAudio(blob);
|
|
529
|
+
client.sendAudio(arrayBuffer);
|
|
530
|
+
|
|
531
|
+
// Wait for Blob conversion
|
|
532
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
533
|
+
|
|
534
|
+
const stats = client.getStats();
|
|
535
|
+
expect(stats.currentBufferedChunks).toBe(2);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('should log error if Blob conversion fails', async () => {
|
|
539
|
+
const mockLogger = jest.fn();
|
|
540
|
+
const testClient = new RealTimeTwoWayWebSocketRecognitionClient({
|
|
541
|
+
url: 'ws://test.example.com/recognize',
|
|
542
|
+
logger: mockLogger
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Create a mock Blob that throws on arrayBuffer()
|
|
546
|
+
const badBlob: any = {
|
|
547
|
+
arrayBuffer: jest.fn().mockRejectedValue(new Error('Conversion failed'))
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
testClient.sendAudio(badBlob as Blob);
|
|
551
|
+
|
|
552
|
+
// Wait for error handling
|
|
553
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
554
|
+
|
|
555
|
+
// Should have logged an error
|
|
556
|
+
const errorCalls = mockLogger.mock.calls.filter(call => call[0] === 'error');
|
|
557
|
+
expect(errorCalls.length).toBeGreaterThan(0);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Note: The blobToArrayBuffer() function has dual-path support:
|
|
561
|
+
// - Modern browsers: Uses blob.arrayBuffer() [Chrome 76+, Safari 14+]
|
|
562
|
+
// - Older Smart TVs: Falls back to FileReader [Tizen 2018-2019, webOS 3.0-4.x]
|
|
563
|
+
|
|
564
|
+
it('should use blob.arrayBuffer() when available (modern path)', async () => {
|
|
565
|
+
const audioData = new Uint8Array([1, 2, 3, 4]);
|
|
566
|
+
const blob = new Blob([audioData], { type: 'audio/raw' });
|
|
567
|
+
|
|
568
|
+
// Spy on blob.arrayBuffer to verify it's called
|
|
569
|
+
const arrayBufferSpy = jest.spyOn(blob, 'arrayBuffer');
|
|
570
|
+
|
|
571
|
+
client.sendAudio(blob);
|
|
572
|
+
|
|
573
|
+
// Wait for async conversion
|
|
574
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
575
|
+
|
|
576
|
+
// Should have used modern blob.arrayBuffer()
|
|
577
|
+
expect(arrayBufferSpy).toHaveBeenCalled();
|
|
578
|
+
|
|
579
|
+
// Should have buffered successfully
|
|
580
|
+
const stats = client.getStats();
|
|
581
|
+
expect(stats.currentBufferedChunks).toBe(1);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should use FileReader fallback when blob.arrayBuffer not available (Smart TV path)', async () => {
|
|
585
|
+
const audioData = new Uint8Array([1, 2, 3, 4]);
|
|
586
|
+
|
|
587
|
+
// Create a mock Blob-like object without arrayBuffer (simulates old Smart TV)
|
|
588
|
+
const blobLike = {
|
|
589
|
+
size: audioData.length,
|
|
590
|
+
type: 'audio/raw',
|
|
591
|
+
// No arrayBuffer method - will trigger FileReader path
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// Mock FileReader
|
|
595
|
+
const mockReadAsArrayBuffer = jest.fn();
|
|
596
|
+
const originalFileReader = (global as any).FileReader;
|
|
597
|
+
|
|
598
|
+
(global as any).FileReader = jest.fn().mockImplementation(() => ({
|
|
599
|
+
readAsArrayBuffer: mockReadAsArrayBuffer,
|
|
600
|
+
onload: null,
|
|
601
|
+
onerror: null,
|
|
602
|
+
result: audioData.buffer
|
|
603
|
+
}));
|
|
604
|
+
|
|
605
|
+
// Trigger FileReader path by simulating onload after a delay
|
|
606
|
+
mockReadAsArrayBuffer.mockImplementation(function(this: any) {
|
|
607
|
+
setTimeout(() => {
|
|
608
|
+
if (this.onload) {
|
|
609
|
+
this.result = audioData.buffer;
|
|
610
|
+
this.onload();
|
|
611
|
+
}
|
|
612
|
+
}, 10);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
client.sendAudio(blobLike as Blob);
|
|
616
|
+
|
|
617
|
+
// Wait for FileReader async conversion
|
|
618
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
619
|
+
|
|
620
|
+
// Should have used FileReader
|
|
621
|
+
expect((global as any).FileReader).toHaveBeenCalled();
|
|
622
|
+
expect(mockReadAsArrayBuffer).toHaveBeenCalledWith(blobLike);
|
|
623
|
+
|
|
624
|
+
// Should have buffered successfully
|
|
625
|
+
const stats = client.getStats();
|
|
626
|
+
expect(stats.currentBufferedChunks).toBe(1);
|
|
627
|
+
|
|
628
|
+
// Cleanup
|
|
629
|
+
(global as any).FileReader = originalFileReader;
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
454
633
|
describe('Debug Logging', () => {
|
|
455
634
|
it('should not log debug messages when debug logging is disabled (default)', () => {
|
|
456
635
|
const mockLogger = jest.fn();
|
|
@@ -75,6 +75,32 @@ export function isNormalDisconnection(code: number): boolean {
|
|
|
75
75
|
return code === 1000; // 1000 is the only "normal" close code
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Convert Blob to ArrayBuffer with Smart TV compatibility
|
|
80
|
+
*
|
|
81
|
+
* Browser Compatibility:
|
|
82
|
+
* - blob.arrayBuffer(): Newer TV
|
|
83
|
+
* - FileReader: All browsers, including older Smart TVs
|
|
84
|
+
*
|
|
85
|
+
* @see https://developer.samsung.com/smarttv/develop/specifications/web-engine-specifications.html
|
|
86
|
+
* @param blob - Blob to convert
|
|
87
|
+
* @returns Promise resolving to ArrayBuffer
|
|
88
|
+
*/
|
|
89
|
+
async function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
|
90
|
+
// Modern approach (Chrome 76+, Safari 14+, Tizen 2020+, webOS 5.0+)
|
|
91
|
+
if (typeof blob.arrayBuffer === 'function') {
|
|
92
|
+
return await blob.arrayBuffer();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fallback for older Smart TVs (Tizen 2018-2019, webOS 3.0-4.x)
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const reader = new FileReader();
|
|
98
|
+
reader.onload = (): void => resolve(reader.result as ArrayBuffer);
|
|
99
|
+
reader.onerror = (): void => reject(reader.error);
|
|
100
|
+
reader.readAsArrayBuffer(blob);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
// ============================================================================
|
|
79
105
|
// TYPE DEFINITIONS
|
|
80
106
|
// ============================================================================
|
|
@@ -315,7 +341,24 @@ export class RealTimeTwoWayWebSocketRecognitionClient
|
|
|
315
341
|
return this.connectionPromise;
|
|
316
342
|
}
|
|
317
343
|
|
|
318
|
-
override sendAudio(audioData: ArrayBuffer | ArrayBufferView): void {
|
|
344
|
+
override sendAudio(audioData: ArrayBuffer | ArrayBufferView | Blob): void {
|
|
345
|
+
// Handle Blob by converting to ArrayBuffer asynchronously
|
|
346
|
+
if (audioData instanceof Blob) {
|
|
347
|
+
blobToArrayBuffer(audioData)
|
|
348
|
+
.then((arrayBuffer) => {
|
|
349
|
+
this.sendAudioInternal(arrayBuffer);
|
|
350
|
+
})
|
|
351
|
+
.catch((error) => {
|
|
352
|
+
this.log('error', 'Failed to convert Blob to ArrayBuffer', error);
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Handle ArrayBuffer and ArrayBufferView synchronously
|
|
358
|
+
this.sendAudioInternal(audioData);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private sendAudioInternal(audioData: ArrayBuffer | ArrayBufferView): void {
|
|
319
362
|
const bytes = ArrayBuffer.isView(audioData) ? audioData.byteLength : audioData.byteLength;
|
|
320
363
|
if (bytes === 0) return;
|
|
321
364
|
|
|
@@ -164,9 +164,9 @@ export interface IRecognitionClient {
|
|
|
164
164
|
/**
|
|
165
165
|
* Send audio data to the recognition service
|
|
166
166
|
* Audio is buffered locally and sent when connection is ready.
|
|
167
|
-
* @param audioData - PCM audio data as ArrayBuffer
|
|
167
|
+
* @param audioData - PCM audio data as ArrayBuffer, typed array view, or Blob
|
|
168
168
|
*/
|
|
169
|
-
sendAudio(audioData: ArrayBuffer | ArrayBufferView): void;
|
|
169
|
+
sendAudio(audioData: ArrayBuffer | ArrayBufferView | Blob): void;
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
172
|
* Stop recording and wait for final transcript
|
|
@@ -288,6 +288,12 @@ describe('SimplifiedVGFRecognitionClient', () => {
|
|
|
288
288
|
expect(mockClient.sendAudio).toHaveBeenCalledWith(audioData);
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
it('should delegate sendAudio() with Blob to underlying client', () => {
|
|
292
|
+
const blob = new Blob([new Uint8Array([1, 2, 3, 4])]);
|
|
293
|
+
simplifiedClient.sendAudio(blob);
|
|
294
|
+
expect(mockClient.sendAudio).toHaveBeenCalledWith(blob);
|
|
295
|
+
});
|
|
296
|
+
|
|
291
297
|
it('should delegate stopRecording() to underlying client', async () => {
|
|
292
298
|
await simplifiedClient.stopRecording();
|
|
293
299
|
expect(mockClient.stopRecording).toHaveBeenCalled();
|
|
@@ -57,9 +57,9 @@ export interface ISimplifiedVGFRecognitionClient {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Send audio data for transcription
|
|
60
|
-
* @param audioData - PCM audio data as ArrayBuffer
|
|
60
|
+
* @param audioData - PCM audio data as ArrayBuffer, typed array, or Blob
|
|
61
61
|
*/
|
|
62
|
-
sendAudio(audioData: ArrayBuffer | ArrayBufferView): void;
|
|
62
|
+
sendAudio(audioData: ArrayBuffer | ArrayBufferView | Blob): void;
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* Stop recording and wait for final transcription
|
|
@@ -228,7 +228,7 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
|
|
|
228
228
|
// State will be updated via onConnected callback
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
sendAudio(audioData: ArrayBuffer | ArrayBufferView): void {
|
|
231
|
+
sendAudio(audioData: ArrayBuffer | ArrayBufferView | Blob): void {
|
|
232
232
|
// Track recording for state updates
|
|
233
233
|
if (!this.isRecordingAudio) {
|
|
234
234
|
this.isRecordingAudio = true;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for MessageHandler
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { MessageHandler, MessageHandlerCallbacks } from './message-handler.js';
|
|
6
|
+
import { RecognitionResultTypeV1, ClientControlActionV1 } from '@recog/shared-types';
|
|
7
|
+
|
|
8
|
+
describe('MessageHandler', () => {
|
|
9
|
+
let callbacks: MessageHandlerCallbacks;
|
|
10
|
+
let handler: MessageHandler;
|
|
11
|
+
let mockLogger: jest.Mock;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockLogger = jest.fn();
|
|
15
|
+
callbacks = {
|
|
16
|
+
onTranscript: jest.fn(),
|
|
17
|
+
onFunctionCall: jest.fn(),
|
|
18
|
+
onMetadata: jest.fn(),
|
|
19
|
+
onError: jest.fn(),
|
|
20
|
+
onControlMessage: jest.fn(),
|
|
21
|
+
logger: mockLogger
|
|
22
|
+
};
|
|
23
|
+
handler = new MessageHandler(callbacks);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('handleMessage', () => {
|
|
27
|
+
it('should handle transcription message', () => {
|
|
28
|
+
const msg = {
|
|
29
|
+
v: 1,
|
|
30
|
+
type: 'recognition_result',
|
|
31
|
+
data: {
|
|
32
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
33
|
+
transcript: 'hello world',
|
|
34
|
+
isFinal: true
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
handler.handleMessage(msg);
|
|
39
|
+
expect(callbacks.onTranscript).toHaveBeenCalledWith(msg.data);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should handle function call message', () => {
|
|
43
|
+
const msg = {
|
|
44
|
+
v: 1,
|
|
45
|
+
type: 'recognition_result',
|
|
46
|
+
data: {
|
|
47
|
+
type: RecognitionResultTypeV1.FUNCTION_CALL,
|
|
48
|
+
functionName: 'testFunction',
|
|
49
|
+
arguments: { arg1: 'value1' }
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
handler.handleMessage(msg);
|
|
54
|
+
expect(callbacks.onFunctionCall).toHaveBeenCalledWith(msg.data);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should handle metadata message', () => {
|
|
58
|
+
const msg = {
|
|
59
|
+
v: 1,
|
|
60
|
+
type: 'recognition_result',
|
|
61
|
+
data: {
|
|
62
|
+
type: RecognitionResultTypeV1.METADATA,
|
|
63
|
+
metadata: { key: 'value' }
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
handler.handleMessage(msg);
|
|
68
|
+
expect(callbacks.onMetadata).toHaveBeenCalledWith(msg.data);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle error message', () => {
|
|
72
|
+
const msg = {
|
|
73
|
+
v: 1,
|
|
74
|
+
type: 'recognition_result',
|
|
75
|
+
data: {
|
|
76
|
+
type: RecognitionResultTypeV1.ERROR,
|
|
77
|
+
error: 'test error',
|
|
78
|
+
code: 'TEST_ERROR'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
handler.handleMessage(msg);
|
|
83
|
+
expect(callbacks.onError).toHaveBeenCalledWith(msg.data);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should handle client control message', () => {
|
|
87
|
+
const msg = {
|
|
88
|
+
v: 1,
|
|
89
|
+
type: 'recognition_result',
|
|
90
|
+
data: {
|
|
91
|
+
type: RecognitionResultTypeV1.CLIENT_CONTROL_MESSAGE,
|
|
92
|
+
action: ClientControlActionV1.STOP_RECORDING,
|
|
93
|
+
audioUtteranceId: 'test-utterance'
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
handler.handleMessage(msg);
|
|
98
|
+
expect(callbacks.onControlMessage).toHaveBeenCalledWith(msg.data);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should handle unknown message type', () => {
|
|
102
|
+
const msg = {
|
|
103
|
+
v: 1,
|
|
104
|
+
type: 'unknown_type',
|
|
105
|
+
data: {
|
|
106
|
+
type: 'unknown',
|
|
107
|
+
content: 'test'
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
handler.handleMessage(msg);
|
|
112
|
+
expect(mockLogger).toHaveBeenCalledWith(
|
|
113
|
+
'debug',
|
|
114
|
+
'Unknown message type',
|
|
115
|
+
expect.objectContaining({ type: 'unknown' })
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should log all incoming messages', () => {
|
|
120
|
+
const msg = {
|
|
121
|
+
v: 1,
|
|
122
|
+
type: 'recognition_result',
|
|
123
|
+
data: {
|
|
124
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
125
|
+
transcript: 'test'
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
handler.handleMessage(msg);
|
|
130
|
+
expect(mockLogger).toHaveBeenCalledWith(
|
|
131
|
+
'debug',
|
|
132
|
+
'Received WebSocket message',
|
|
133
|
+
expect.objectContaining({
|
|
134
|
+
msgType: 'recognition_result',
|
|
135
|
+
msgDataType: RecognitionResultTypeV1.TRANSCRIPTION
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should handle primitive msg.data', () => {
|
|
141
|
+
const msg = {
|
|
142
|
+
v: 1,
|
|
143
|
+
type: 'recognition_result',
|
|
144
|
+
data: 'primitive string'
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
handler.handleMessage(msg);
|
|
148
|
+
expect(mockLogger).toHaveBeenCalledWith(
|
|
149
|
+
'error',
|
|
150
|
+
'Received primitive msg.data from server',
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
dataType: 'string',
|
|
153
|
+
data: 'primitive string'
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should handle message without data field', () => {
|
|
159
|
+
const msg = {
|
|
160
|
+
v: 1,
|
|
161
|
+
type: RecognitionResultTypeV1.METADATA,
|
|
162
|
+
data: {
|
|
163
|
+
type: RecognitionResultTypeV1.METADATA,
|
|
164
|
+
metadata: { key: 'value' }
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
handler.handleMessage(msg);
|
|
169
|
+
expect(callbacks.onMetadata).toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should work without logger', () => {
|
|
173
|
+
const callbacksNoLogger = {
|
|
174
|
+
onTranscript: jest.fn(),
|
|
175
|
+
onFunctionCall: jest.fn(),
|
|
176
|
+
onMetadata: jest.fn(),
|
|
177
|
+
onError: jest.fn(),
|
|
178
|
+
onControlMessage: jest.fn()
|
|
179
|
+
};
|
|
180
|
+
const handlerNoLogger = new MessageHandler(callbacksNoLogger);
|
|
181
|
+
|
|
182
|
+
const msg = {
|
|
183
|
+
v: 1,
|
|
184
|
+
type: 'recognition_result',
|
|
185
|
+
data: {
|
|
186
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
187
|
+
transcript: 'test'
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
expect(() => handlerNoLogger.handleMessage(msg)).not.toThrow();
|
|
192
|
+
expect(callbacksNoLogger.onTranscript).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('setSessionStartTime', () => {
|
|
197
|
+
it('should set session start time', () => {
|
|
198
|
+
const startTime = Date.now();
|
|
199
|
+
handler.setSessionStartTime(startTime);
|
|
200
|
+
|
|
201
|
+
const metrics = handler.getMetrics();
|
|
202
|
+
expect(metrics.sessionStartTime).toBe(startTime);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('getMetrics', () => {
|
|
207
|
+
it('should return initial metrics', () => {
|
|
208
|
+
const metrics = handler.getMetrics();
|
|
209
|
+
expect(metrics).toEqual({
|
|
210
|
+
sessionStartTime: null,
|
|
211
|
+
firstTranscriptTime: null,
|
|
212
|
+
timeToFirstTranscript: null
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should track time to first transcript', () => {
|
|
217
|
+
const startTime = Date.now();
|
|
218
|
+
handler.setSessionStartTime(startTime);
|
|
219
|
+
|
|
220
|
+
// Simulate delay before first transcript
|
|
221
|
+
const transcriptMsg = {
|
|
222
|
+
v: 1,
|
|
223
|
+
type: 'recognition_result',
|
|
224
|
+
data: {
|
|
225
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
226
|
+
transcript: 'first transcript',
|
|
227
|
+
isFinal: false
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
handler.handleMessage(transcriptMsg);
|
|
232
|
+
|
|
233
|
+
const metrics = handler.getMetrics();
|
|
234
|
+
expect(metrics.sessionStartTime).toBe(startTime);
|
|
235
|
+
expect(metrics.firstTranscriptTime).toBeGreaterThanOrEqual(startTime);
|
|
236
|
+
expect(metrics.timeToFirstTranscript).toBeGreaterThanOrEqual(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should log time to first transcript', () => {
|
|
240
|
+
const startTime = Date.now();
|
|
241
|
+
handler.setSessionStartTime(startTime);
|
|
242
|
+
|
|
243
|
+
const transcriptMsg = {
|
|
244
|
+
v: 1,
|
|
245
|
+
type: 'recognition_result',
|
|
246
|
+
data: {
|
|
247
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
248
|
+
transcript: 'first transcript'
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
handler.handleMessage(transcriptMsg);
|
|
253
|
+
|
|
254
|
+
expect(mockLogger).toHaveBeenCalledWith(
|
|
255
|
+
'debug',
|
|
256
|
+
'First transcript received',
|
|
257
|
+
expect.objectContaining({
|
|
258
|
+
timeToFirstTranscriptMs: expect.any(Number)
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should only track first transcript time once', () => {
|
|
264
|
+
const startTime = Date.now();
|
|
265
|
+
handler.setSessionStartTime(startTime);
|
|
266
|
+
|
|
267
|
+
// First transcript
|
|
268
|
+
handler.handleMessage({
|
|
269
|
+
v: 1,
|
|
270
|
+
type: 'recognition_result',
|
|
271
|
+
data: {
|
|
272
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
273
|
+
transcript: 'first'
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const firstMetrics = handler.getMetrics();
|
|
278
|
+
const firstTranscriptTime = firstMetrics.firstTranscriptTime;
|
|
279
|
+
|
|
280
|
+
// Second transcript
|
|
281
|
+
handler.handleMessage({
|
|
282
|
+
v: 1,
|
|
283
|
+
type: 'recognition_result',
|
|
284
|
+
data: {
|
|
285
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
286
|
+
transcript: 'second'
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const secondMetrics = handler.getMetrics();
|
|
291
|
+
expect(secondMetrics.firstTranscriptTime).toBe(firstTranscriptTime);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should not track transcript time without session start', () => {
|
|
295
|
+
const transcriptMsg = {
|
|
296
|
+
v: 1,
|
|
297
|
+
type: 'recognition_result',
|
|
298
|
+
data: {
|
|
299
|
+
type: RecognitionResultTypeV1.TRANSCRIPTION,
|
|
300
|
+
transcript: 'test'
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
handler.handleMessage(transcriptMsg);
|
|
305
|
+
|
|
306
|
+
const metrics = handler.getMetrics();
|
|
307
|
+
expect(metrics.firstTranscriptTime).toBeNull();
|
|
308
|
+
expect(metrics.timeToFirstTranscript).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|