@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.
@@ -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 or typed array view
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 or typed array
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
+ });