@volley/recognition-client-sdk 0.1.384 → 0.1.417

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.
@@ -5,7 +5,7 @@
5
5
  import { SimplifiedVGFRecognitionClient, createSimplifiedVGFClient } from './simplified-vgf-recognition-client.js';
6
6
  import { RealTimeTwoWayWebSocketRecognitionClient } from './recognition-client.js';
7
7
  import { ClientState } from './recognition-client.types.js';
8
- import { AudioEncoding, RecognitionContextTypeV1 } from '@recog/shared-types';
8
+ import { AudioEncoding, RecognitionContextTypeV1, RecognitionResultTypeV1 } from '@recog/shared-types';
9
9
  import {
10
10
  RecordingStatus,
11
11
  TranscriptionStatus,
@@ -287,21 +287,43 @@ describe('SimplifiedVGFRecognitionClient', () => {
287
287
  expect(updatedState.finalTranscriptionTimestamp).toBeDefined();
288
288
  });
289
289
 
290
- it('should handle metadata and mark recording as finished', () => {
290
+ it('should pass metadata to callback without updating VGF state', () => {
291
291
  // Get the actual UUID from the client
292
292
  const actualUuid = simplifiedClient.getVGFState().audioUtteranceId;
293
+ const originalOnMetadata = jest.fn();
293
294
 
295
+ // Create new client with onMetadata callback
296
+ const clientWithMetadata = new SimplifiedVGFRecognitionClient({
297
+ asrRequestConfig: {
298
+ provider: 'deepgram',
299
+ language: 'en',
300
+ sampleRate: 16000,
301
+ encoding: AudioEncoding.LINEAR16
302
+ },
303
+ onStateChange: stateChangeCallback,
304
+ onMetadata: originalOnMetadata
305
+ });
306
+
307
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
308
+ const latestConfig = constructorCalls[constructorCalls.length - 1]?.[0];
309
+ const metadataCallback = latestConfig?.onMetadata;
310
+
311
+ const clientUuid = clientWithMetadata.getVGFState().audioUtteranceId;
294
312
  const metadata = {
295
- audioUtteranceId: actualUuid,
296
- duration: 5000
313
+ type: RecognitionResultTypeV1.METADATA as const,
314
+ audioUtteranceId: clientUuid
297
315
  };
298
316
 
299
- onMetadataCallback(metadata);
317
+ // Clear previous state changes from client creation
318
+ stateChangeCallback.mockClear();
300
319
 
301
- expect(stateChangeCallback).toHaveBeenCalled();
302
- const updatedState = stateChangeCallback.mock.calls[0][0];
303
- expect(updatedState.startRecordingStatus).toBe(RecordingStatus.FINISHED);
304
- expect(updatedState.finalRecordingTimestamp).toBeDefined();
320
+ metadataCallback?.(metadata);
321
+
322
+ // Metadata should NOT trigger state change (state management simplified)
323
+ expect(stateChangeCallback).not.toHaveBeenCalled();
324
+
325
+ // But original callback should still be called
326
+ expect(originalOnMetadata).toHaveBeenCalledWith(metadata);
305
327
  });
306
328
 
307
329
  it('should handle errors and update state', () => {
@@ -325,13 +347,18 @@ describe('SimplifiedVGFRecognitionClient', () => {
325
347
  // Then error occurs
326
348
  onErrorCallback({ message: 'Error' });
327
349
 
328
- // Send audio again should restart recording
350
+ // After terminal status (ERROR), no more state changes should be emitted
351
+ // The session is considered over, so sendAudio should not trigger callbacks
329
352
  stateChangeCallback.mockClear();
330
353
  simplifiedClient.sendAudio(Buffer.from([4, 5, 6]));
331
354
 
332
- const updatedState = stateChangeCallback.mock.calls[0][0];
333
- expect(updatedState.startRecordingStatus).toBe(RecordingStatus.RECORDING);
334
- expect(updatedState.startRecordingTimestamp).toBeDefined();
355
+ // No callback should be triggered since we're in terminal state
356
+ expect(stateChangeCallback).not.toHaveBeenCalled();
357
+
358
+ // However, the internal isRecordingAudio flag should still be reset
359
+ // (verified by the fact that the flag was set during the first sendAudio)
360
+ const state = simplifiedClient.getVGFState();
361
+ expect(state.transcriptionStatus).toBe(TranscriptionStatus.ERROR);
335
362
  });
336
363
  });
337
364
 
@@ -1331,4 +1358,163 @@ describe('SimplifiedVGFRecognitionClient', () => {
1331
1358
  expect(stateChangeCallback).not.toHaveBeenCalled();
1332
1359
  });
1333
1360
  });
1361
+
1362
+ describe('Terminal Status Protection', () => {
1363
+ let stateChangeCallback: jest.Mock;
1364
+ let onTranscriptCallback: (result: any) => void;
1365
+ let onErrorCallback: (error: any) => void;
1366
+ let simplifiedClient: SimplifiedVGFRecognitionClient;
1367
+ let clientUuid: string;
1368
+
1369
+ beforeEach(() => {
1370
+ stateChangeCallback = jest.fn();
1371
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
1372
+ asrRequestConfig: {
1373
+ provider: 'deepgram',
1374
+ language: 'en',
1375
+ sampleRate: 16000,
1376
+ encoding: AudioEncoding.LINEAR16
1377
+ },
1378
+ onStateChange: stateChangeCallback
1379
+ });
1380
+
1381
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
1382
+ const latestConfig = constructorCalls[constructorCalls.length - 1]?.[0];
1383
+ onTranscriptCallback = latestConfig?.onTranscript ?? jest.fn();
1384
+ onErrorCallback = latestConfig?.onError ?? jest.fn();
1385
+ clientUuid = simplifiedClient.getVGFState().audioUtteranceId;
1386
+ });
1387
+
1388
+ it('should allow first terminal transcript callback', () => {
1389
+ onTranscriptCallback({
1390
+ type: 'Transcription',
1391
+ audioUtteranceId: clientUuid,
1392
+ finalTranscript: 'hello',
1393
+ finalTranscriptConfidence: 0.9,
1394
+ is_finished: true
1395
+ });
1396
+
1397
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
1398
+ expect(stateChangeCallback.mock.calls[0][0].transcriptionStatus).toBe(TranscriptionStatus.FINALIZED);
1399
+ });
1400
+
1401
+ it('should block second terminal transcript callback with same UUID', () => {
1402
+ // First terminal
1403
+ onTranscriptCallback({
1404
+ type: 'Transcription',
1405
+ audioUtteranceId: clientUuid,
1406
+ finalTranscript: 'first',
1407
+ finalTranscriptConfidence: 0.9,
1408
+ is_finished: true
1409
+ });
1410
+
1411
+ stateChangeCallback.mockClear();
1412
+
1413
+ // Second terminal - should be blocked
1414
+ onTranscriptCallback({
1415
+ type: 'Transcription',
1416
+ audioUtteranceId: clientUuid,
1417
+ finalTranscript: 'second',
1418
+ finalTranscriptConfidence: 0.95,
1419
+ is_finished: true
1420
+ });
1421
+
1422
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1423
+ });
1424
+
1425
+ it('should allow first error callback', () => {
1426
+ onErrorCallback({
1427
+ audioUtteranceId: clientUuid,
1428
+ message: 'Error occurred'
1429
+ });
1430
+
1431
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
1432
+ expect(stateChangeCallback.mock.calls[0][0].transcriptionStatus).toBe(TranscriptionStatus.ERROR);
1433
+ });
1434
+
1435
+ it('should block second error callback with same UUID', () => {
1436
+ // First error
1437
+ onErrorCallback({
1438
+ audioUtteranceId: clientUuid,
1439
+ message: 'First error'
1440
+ });
1441
+
1442
+ stateChangeCallback.mockClear();
1443
+
1444
+ // Second error - should be blocked
1445
+ onErrorCallback({
1446
+ audioUtteranceId: clientUuid,
1447
+ message: 'Second error'
1448
+ });
1449
+
1450
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1451
+ });
1452
+
1453
+ it('should block transcript after error', () => {
1454
+ // Error first
1455
+ onErrorCallback({
1456
+ audioUtteranceId: clientUuid,
1457
+ message: 'Error'
1458
+ });
1459
+
1460
+ stateChangeCallback.mockClear();
1461
+
1462
+ // Transcript after error - should be blocked
1463
+ onTranscriptCallback({
1464
+ type: 'Transcription',
1465
+ audioUtteranceId: clientUuid,
1466
+ finalTranscript: 'late',
1467
+ finalTranscriptConfidence: 0.9,
1468
+ is_finished: true
1469
+ });
1470
+
1471
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1472
+ });
1473
+
1474
+ it('should block error after terminal transcript', () => {
1475
+ // Terminal transcript first
1476
+ onTranscriptCallback({
1477
+ type: 'Transcription',
1478
+ audioUtteranceId: clientUuid,
1479
+ finalTranscript: 'done',
1480
+ finalTranscriptConfidence: 0.9,
1481
+ is_finished: true
1482
+ });
1483
+
1484
+ stateChangeCallback.mockClear();
1485
+
1486
+ // Error after terminal - should be blocked
1487
+ onErrorCallback({
1488
+ audioUtteranceId: clientUuid,
1489
+ message: 'Late error'
1490
+ });
1491
+
1492
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1493
+ });
1494
+
1495
+ it('should preserve original state after blocking duplicate', () => {
1496
+ // First terminal with specific transcript
1497
+ onTranscriptCallback({
1498
+ type: 'Transcription',
1499
+ audioUtteranceId: clientUuid,
1500
+ finalTranscript: 'original',
1501
+ finalTranscriptConfidence: 0.85,
1502
+ is_finished: true
1503
+ });
1504
+
1505
+ // Attempt second terminal with different transcript
1506
+ onTranscriptCallback({
1507
+ type: 'Transcription',
1508
+ audioUtteranceId: clientUuid,
1509
+ finalTranscript: 'different',
1510
+ finalTranscriptConfidence: 0.99,
1511
+ is_finished: true
1512
+ });
1513
+
1514
+ // State should still have original values
1515
+ const state = simplifiedClient.getVGFState();
1516
+ expect(state.finalTranscript).toBe('original');
1517
+ expect(state.finalConfidence).toBe(0.85);
1518
+ });
1519
+ });
1334
1520
  });
@@ -23,7 +23,6 @@ import { RealTimeTwoWayWebSocketRecognitionClient } from './recognition-client.j
23
23
  import {
24
24
  createVGFStateFromConfig,
25
25
  mapTranscriptionResultToState,
26
- mapMetadataToState,
27
26
  mapErrorToState,
28
27
  updateStateOnStop
29
28
  } from './vgf-recognition-mapper.js';
@@ -154,6 +153,7 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
154
153
  private stateChangeCallback: ((state: RecognitionState) => void) | undefined;
155
154
  private expectedUuid: string;
156
155
  private logger: IRecognitionClientConfig['logger'];
156
+ private lastSentTerminalUuid: string | null = null;
157
157
 
158
158
  constructor(config: SimplifiedVGFClientConfig) {
159
159
  const { onStateChange, initialState, ...clientConfig } = config;
@@ -193,6 +193,9 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
193
193
  // Use new UUID in client config
194
194
  clientConfig.audioUtteranceId = newUUID;
195
195
 
196
+ // Reset terminal status tracking for new session
197
+ this.lastSentTerminalUuid = null;
198
+
196
199
  // Notify state change immediately so app can update
197
200
  if (onStateChange) {
198
201
  onStateChange(this.state);
@@ -246,12 +249,19 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
246
249
  if (result.audioUtteranceId && result.audioUtteranceId !== this.expectedUuid) {
247
250
  if (this.logger) {
248
251
  this.logger('warn',
249
- `[VGF] Skipping transcript update: UUID mismatch (expected: ${this.expectedUuid}, got: ${result.audioUtteranceId})`
252
+ `[RecogSDK:VGF] Skipping transcript update: UUID mismatch (expected: ${this.expectedUuid}, got: ${result.audioUtteranceId})`
250
253
  );
251
254
  }
252
- // Still call original callback if provided
253
- if (clientConfig.onTranscript) {
254
- clientConfig.onTranscript(result);
255
+ return;
256
+ }
257
+
258
+ // Skip if terminal status already sent for THIS session
259
+ // (If lastSentTerminalUuid exists but doesn't match expectedUuid, treat as new session)
260
+ if (this.lastSentTerminalUuid === this.expectedUuid) {
261
+ if (this.logger) {
262
+ this.logger('info',
263
+ `[RecogSDK:VGF] Duplicate terminal status suppressed (lastSentTerminalUuid: ${this.lastSentTerminalUuid})`
264
+ );
255
265
  }
256
266
  return;
257
267
  }
@@ -271,19 +281,12 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
271
281
  if (metadata.audioUtteranceId && metadata.audioUtteranceId !== this.expectedUuid) {
272
282
  if (this.logger) {
273
283
  this.logger('warn',
274
- `[VGF] Skipping metadata update: UUID mismatch (expected: ${this.expectedUuid}, got: ${metadata.audioUtteranceId})`
284
+ `[RecogSDK:VGF] Skipping metadata update: UUID mismatch (expected: ${this.expectedUuid}, got: ${metadata.audioUtteranceId})`
275
285
  );
276
286
  }
277
- // Still call original callback if provided
278
- if (clientConfig.onMetadata) {
279
- clientConfig.onMetadata(metadata);
280
- }
281
287
  return;
282
288
  }
283
289
 
284
- this.state = mapMetadataToState(this.state, metadata);
285
- this.notifyStateChange();
286
-
287
290
  if (clientConfig.onMetadata) {
288
291
  clientConfig.onMetadata(metadata);
289
292
  }
@@ -301,13 +304,14 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
301
304
  if (error.audioUtteranceId && error.audioUtteranceId !== this.expectedUuid) {
302
305
  if (this.logger) {
303
306
  this.logger('warn',
304
- `[VGF] Skipping error update: UUID mismatch (expected: ${this.expectedUuid}, got: ${error.audioUtteranceId})`
307
+ `[RecogSDK:VGF] Skipping error update: UUID mismatch (expected: ${this.expectedUuid}, got: ${error.audioUtteranceId})`
305
308
  );
306
309
  }
307
- // Still call original callback if provided
308
- if (clientConfig.onError) {
309
- clientConfig.onError(error);
310
- }
310
+ return;
311
+ }
312
+
313
+ // Skip if terminal status already sent for THIS session
314
+ if (this.lastSentTerminalUuid === this.expectedUuid) {
311
315
  return;
312
316
  }
313
317
 
@@ -362,6 +366,27 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
362
366
  this.isRecordingAudio = false;
363
367
  this.state = updateStateOnStop(this.state);
364
368
  this.notifyStateChange();
369
+
370
+ // Early termination: If transcription never started (NOT_STARTED), emit synthetic finalization immediately
371
+ // This prevents games from getting stuck waiting for a server response that may never come
372
+ if (this.state.transcriptionStatus === TranscriptionStatus.NOT_STARTED) {
373
+ if (this.logger) {
374
+ this.logger('info',
375
+ `[RecogSDK:VGF] Early termination detected (transcriptionStatus: NOT_STARTED) - emitting synthetic finalization`
376
+ );
377
+ }
378
+ this.state = {
379
+ ...this.state,
380
+ transcriptionStatus: TranscriptionStatus.FINALIZED,
381
+ finalTranscript: '',
382
+ finalConfidence: 0,
383
+ pendingTranscript: '',
384
+ pendingConfidence: undefined,
385
+ finalTranscriptionTimestamp: new Date().toISOString()
386
+ };
387
+ this.notifyStateChange();
388
+ }
389
+
365
390
  await this.client.stopRecording();
366
391
  }
367
392
 
@@ -436,11 +461,34 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
436
461
  return { ...this.state };
437
462
  }
438
463
 
464
+ private isTerminalStatus(status: string | undefined): boolean {
465
+ return status === TranscriptionStatus.FINALIZED ||
466
+ status === TranscriptionStatus.ABORTED ||
467
+ status === TranscriptionStatus.ERROR;
468
+ }
469
+
439
470
  private notifyStateChange(): void {
440
- // State has already been validated for correct UUID before this is called
441
- if (this.stateChangeCallback) {
442
- this.stateChangeCallback({ ...this.state });
471
+
472
+ // Block duplicate terminal status emissions for THIS session
473
+ if (this.isTerminalStatus(this.state.transcriptionStatus)) {
474
+ if (this.lastSentTerminalUuid === this.expectedUuid) {
475
+ // Already sent a terminal status for this session - suppress duplicate
476
+ if (this.logger) {
477
+ this.logger('info',
478
+ `[RecogSDK:VGF] Duplicate terminal status suppressed (lastSentTerminalUuid: ${this.lastSentTerminalUuid})`
479
+ );
480
+ }
481
+ return;
482
+ }
483
+ // First terminal status for this session - record it
484
+ this.lastSentTerminalUuid = this.expectedUuid;
443
485
  }
486
+
487
+ if (!this.stateChangeCallback) {
488
+ return;
489
+ }
490
+
491
+ this.stateChangeCallback({ ...this.state });
444
492
  }
445
493
  }
446
494
 
@@ -489,4 +537,4 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
489
537
  */
490
538
  export function createSimplifiedVGFClient(config: SimplifiedVGFClientConfig): ISimplifiedVGFRecognitionClient {
491
539
  return new SimplifiedVGFRecognitionClient(config);
492
- }
540
+ }
@@ -55,7 +55,7 @@ export class AudioRingBuffer {
55
55
 
56
56
  // Log buffer overflow event
57
57
  if (this.logger) {
58
- this.logger('debug', 'Buffer overflow detected', {
58
+ this.logger('debug', '[RecogSDK] Buffer overflow detected', {
59
59
  bufferSize: this.bufferSize,
60
60
  totalOverflows: this.overflowCount,
61
61
  droppedChunk: this.buffer[this.readIndex]?.timestamp
@@ -151,7 +151,7 @@ export class AudioRingBuffer {
151
151
  this.totalBufferedBytes = 0;
152
152
 
153
153
  if (this.logger) {
154
- this.logger('debug', 'Audio buffer cleared');
154
+ this.logger('debug', '[RecogSDK] Audio buffer cleared');
155
155
  }
156
156
  }
157
157
 
@@ -44,7 +44,7 @@ export class MessageHandler {
44
44
  handleMessage(msg: { v: number; type: string; data: any }): void {
45
45
  // Log ALL incoming messages for debugging
46
46
  if (this.callbacks.logger) {
47
- this.callbacks.logger('debug', 'Received WebSocket message', {
47
+ this.callbacks.logger('debug', '[RecogSDK] Received WebSocket message', {
48
48
  msgType: msg.type,
49
49
  msgDataType: msg.data && typeof msg.data === 'object' && 'type' in msg.data ? msg.data.type : 'N/A',
50
50
  fullMessage: msg
@@ -55,7 +55,7 @@ export class MessageHandler {
55
55
  // Log error if we receive primitive data (indicates server issue)
56
56
  if (msg.data && typeof msg.data !== 'object') {
57
57
  if (this.callbacks.logger) {
58
- this.callbacks.logger('error', 'Received primitive msg.data from server', {
58
+ this.callbacks.logger('error', '[RecogSDK] Received primitive msg.data from server', {
59
59
  dataType: typeof msg.data,
60
60
  data: msg.data,
61
61
  fullMessage: msg
@@ -90,7 +90,7 @@ export class MessageHandler {
90
90
  default:
91
91
  // Unknown message type - log if logger available
92
92
  if (this.callbacks.logger) {
93
- this.callbacks.logger('debug', 'Unknown message type', { type: msgType });
93
+ this.callbacks.logger('debug', '[RecogSDK] Unknown message type', { type: msgType });
94
94
  }
95
95
  }
96
96
  }
@@ -106,7 +106,7 @@ export class MessageHandler {
106
106
  const timeToFirstTranscript = this.firstTranscriptTime - this.sessionStartTime;
107
107
 
108
108
  if (this.callbacks.logger) {
109
- this.callbacks.logger('debug', 'First transcript received', {
109
+ this.callbacks.logger('debug', '[RecogSDK] First transcript received', {
110
110
  timeToFirstTranscriptMs: timeToFirstTranscript
111
111
  });
112
112
  }
@@ -19,6 +19,8 @@ export interface UrlBuilderConfig {
19
19
  questionAnswerId?: string;
20
20
  platform?: string;
21
21
  gameContext?: GameContextV1;
22
+ /** Standalone gameId - takes precedence over gameContext.gameId if both provided */
23
+ gameId?: string;
22
24
  }
23
25
 
24
26
  /**
@@ -75,9 +77,14 @@ export function buildWebSocketUrl(config: UrlBuilderConfig): string {
75
77
  url.searchParams.set('platform', config.platform);
76
78
  }
77
79
 
78
- // Add gameId and gamePhase if provided from gameContext
79
- if (config.gameContext) {
80
- url.searchParams.set('gameId', config.gameContext.gameId);
80
+ // Add gameId - standalone gameId takes precedence over gameContext.gameId
81
+ const gameId = config.gameId ?? config.gameContext?.gameId;
82
+ if (gameId) {
83
+ url.searchParams.set('gameId', gameId);
84
+ }
85
+
86
+ // Add gamePhase from gameContext if provided
87
+ if (config.gameContext?.gamePhase) {
81
88
  url.searchParams.set('gamePhase', config.gameContext.gamePhase);
82
89
  }
83
90
 
@@ -17,9 +17,7 @@ import {
17
17
  } from './recognition-client.types.js';
18
18
  import {
19
19
  TranscriptionResultV1,
20
- MetadataResultV1,
21
- ErrorResultV1,
22
- ASRRequestConfig
20
+ ErrorResultV1
23
21
  } from '@recog/shared-types';
24
22
 
25
23
  /**
@@ -103,26 +101,6 @@ export function mapTranscriptionResultToState(
103
101
  return newState;
104
102
  }
105
103
 
106
- /**
107
- * Maps metadata result to update state timestamps
108
- */
109
- export function mapMetadataToState(
110
- currentState: RecognitionState,
111
- metadata: MetadataResultV1
112
- ): RecognitionState {
113
- const newState = { ...currentState };
114
-
115
- // Update final recording timestamp when metadata arrives
116
- if (!newState.finalRecordingTimestamp) {
117
- newState.finalRecordingTimestamp = new Date().toISOString();
118
- }
119
-
120
- // Recording is finished when metadata arrives
121
- newState.startRecordingStatus = RecordingStatus.FINISHED;
122
-
123
- return newState;
124
- }
125
-
126
104
  /**
127
105
  * Maps error to state
128
106
  */