@volley/recognition-client-sdk 0.1.255 → 0.1.294

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.
Files changed (42) hide show
  1. package/dist/browser.d.ts +10 -0
  2. package/dist/browser.d.ts.map +1 -0
  3. package/dist/config-builder.d.ts +129 -0
  4. package/dist/config-builder.d.ts.map +1 -0
  5. package/dist/errors.d.ts +41 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/factory.d.ts +36 -0
  8. package/dist/factory.d.ts.map +1 -0
  9. package/dist/index.d.ts +15 -1079
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +4293 -645
  12. package/dist/index.js.map +7 -1
  13. package/dist/recog-client-sdk.browser.d.ts +10 -2
  14. package/dist/recog-client-sdk.browser.d.ts.map +1 -0
  15. package/dist/recog-client-sdk.browser.js +4127 -525
  16. package/dist/recog-client-sdk.browser.js.map +7 -1
  17. package/dist/recognition-client.d.ts +120 -0
  18. package/dist/recognition-client.d.ts.map +1 -0
  19. package/dist/recognition-client.types.d.ts +265 -0
  20. package/dist/recognition-client.types.d.ts.map +1 -0
  21. package/dist/simplified-vgf-recognition-client.d.ts +174 -0
  22. package/dist/simplified-vgf-recognition-client.d.ts.map +1 -0
  23. package/dist/utils/audio-ring-buffer.d.ts +69 -0
  24. package/dist/utils/audio-ring-buffer.d.ts.map +1 -0
  25. package/dist/utils/message-handler.d.ts +45 -0
  26. package/dist/utils/message-handler.d.ts.map +1 -0
  27. package/dist/utils/url-builder.d.ts +26 -0
  28. package/dist/utils/url-builder.d.ts.map +1 -0
  29. package/dist/vgf-recognition-mapper.d.ts +53 -0
  30. package/dist/vgf-recognition-mapper.d.ts.map +1 -0
  31. package/dist/vgf-recognition-state.d.ts +82 -0
  32. package/dist/vgf-recognition-state.d.ts.map +1 -0
  33. package/package.json +7 -8
  34. package/src/index.ts +4 -0
  35. package/src/recognition-client.spec.ts +147 -14
  36. package/src/recognition-client.ts +27 -0
  37. package/src/recognition-client.types.ts +19 -0
  38. package/src/simplified-vgf-recognition-client.spec.ts +246 -0
  39. package/src/simplified-vgf-recognition-client.ts +58 -1
  40. package/src/utils/url-builder.spec.ts +5 -3
  41. package/src/vgf-recognition-state.ts +2 -1
  42. package/dist/browser-BZs4BL_w.d.ts +0 -1118
@@ -29,6 +29,7 @@ describe('SimplifiedVGFRecognitionClient', () => {
29
29
  connect: jest.fn().mockResolvedValue(undefined),
30
30
  sendAudio: jest.fn(),
31
31
  stopRecording: jest.fn().mockResolvedValue(undefined),
32
+ stopAbnormally: jest.fn(),
32
33
  getAudioUtteranceId: jest.fn().mockReturnValue('test-uuid'),
33
34
  getState: jest.fn().mockReturnValue(ClientState.INITIAL),
34
35
  isConnected: jest.fn().mockReturnValue(false),
@@ -674,4 +675,249 @@ describe('SimplifiedVGFRecognitionClient', () => {
674
675
  expect(callbackState).not.toBe(currentState); // Different references
675
676
  });
676
677
  });
678
+
679
+ describe('stopAbnormally', () => {
680
+ beforeEach(() => {
681
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
682
+ asrRequestConfig: {
683
+ provider: 'deepgram',
684
+ language: 'en',
685
+ sampleRate: 16000,
686
+ encoding: AudioEncoding.LINEAR16
687
+ },
688
+ onStateChange: stateChangeCallback
689
+ });
690
+ });
691
+
692
+ it('should immediately set state to ABORTED with empty transcript', () => {
693
+ // Start recording first
694
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
695
+ jest.clearAllMocks();
696
+
697
+ // Call stopAbnormally
698
+ simplifiedClient.stopAbnormally();
699
+
700
+ // Verify state was updated to ABORTED (not FINALIZED)
701
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
702
+ const finalState = stateChangeCallback.mock.calls[0][0];
703
+
704
+ expect(finalState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
705
+ expect(finalState.finalTranscript).toBe('');
706
+ expect(finalState.startRecordingStatus).toBe(RecordingStatus.FINISHED);
707
+ expect(finalState.finalRecordingTimestamp).toBeDefined();
708
+ expect(finalState.finalTranscriptionTimestamp).toBeDefined();
709
+ });
710
+
711
+ it('should stop recording audio flag', () => {
712
+ // Start recording
713
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
714
+
715
+ // Call stopAbnormally
716
+ simplifiedClient.stopAbnormally();
717
+
718
+ // Send more audio - should not update recording status again
719
+ jest.clearAllMocks();
720
+ simplifiedClient.sendAudio(Buffer.from([4, 5, 6]));
721
+
722
+ // Verify recording status was set in sendAudio
723
+ const state = simplifiedClient.getVGFState();
724
+ expect(state.startRecordingStatus).toBe(RecordingStatus.RECORDING);
725
+ });
726
+
727
+ it('should be idempotent - calling twice does not change state again', () => {
728
+ // Start recording
729
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
730
+ jest.clearAllMocks();
731
+
732
+ // Call stopAbnormally first time
733
+ simplifiedClient.stopAbnormally();
734
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
735
+
736
+ const firstCallState = stateChangeCallback.mock.calls[0][0];
737
+ jest.clearAllMocks();
738
+
739
+ // Call stopAbnormally second time
740
+ simplifiedClient.stopAbnormally();
741
+
742
+ // Should not trigger state change callback again (already aborted)
743
+ expect(stateChangeCallback).toHaveBeenCalledTimes(0);
744
+
745
+ const currentState = simplifiedClient.getVGFState();
746
+ expect(currentState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
747
+ expect(currentState.finalTranscript).toBe('');
748
+ });
749
+
750
+ it('should work even if called before any recording', () => {
751
+ // Call stopAbnormally without ever recording
752
+ simplifiedClient.stopAbnormally();
753
+
754
+ const state = simplifiedClient.getVGFState();
755
+ expect(state.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
756
+ expect(state.finalTranscript).toBe('');
757
+ expect(state.startRecordingStatus).toBe(RecordingStatus.FINISHED);
758
+ });
759
+
760
+ it('should preserve existing state fields except for overridden ones', () => {
761
+ // Set up some initial state by sending audio
762
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
763
+
764
+ const initialState = simplifiedClient.getVGFState();
765
+ const audioUtteranceId = initialState.audioUtteranceId;
766
+
767
+ // Call stopAbnormally
768
+ simplifiedClient.stopAbnormally();
769
+
770
+ const finalState = simplifiedClient.getVGFState();
771
+
772
+ // Should preserve audioUtteranceId and other non-overridden fields
773
+ expect(finalState.audioUtteranceId).toBe(audioUtteranceId);
774
+
775
+ // Should override these fields
776
+ expect(finalState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
777
+ expect(finalState.finalTranscript).toBe('');
778
+ expect(finalState.startRecordingStatus).toBe(RecordingStatus.FINISHED);
779
+ });
780
+
781
+ it('should set both recording and transcription timestamps', () => {
782
+ const beforeTime = new Date().toISOString();
783
+
784
+ simplifiedClient.stopAbnormally();
785
+
786
+ const state = simplifiedClient.getVGFState();
787
+ const afterTime = new Date().toISOString();
788
+
789
+ // Timestamps should be set and within reasonable range
790
+ expect(state.finalRecordingTimestamp).toBeDefined();
791
+ expect(state.finalTranscriptionTimestamp).toBeDefined();
792
+
793
+ // Basic sanity check that timestamps are ISO strings
794
+ expect(state.finalRecordingTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
795
+ expect(state.finalTranscriptionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
796
+
797
+ // Timestamps should be close to current time
798
+ if (state.finalRecordingTimestamp) {
799
+ expect(state.finalRecordingTimestamp >= beforeTime).toBe(true);
800
+ expect(state.finalRecordingTimestamp <= afterTime).toBe(true);
801
+ }
802
+ });
803
+
804
+ it('should call underlying client stopAbnormally for cleanup', () => {
805
+ simplifiedClient.stopAbnormally();
806
+
807
+ // stopAbnormally on underlying client SHOULD be called for WebSocket cleanup
808
+ expect(mockClient.stopAbnormally).toHaveBeenCalled();
809
+
810
+ // stopRecording on underlying client should NOT be called
811
+ expect(mockClient.stopRecording).not.toHaveBeenCalled();
812
+ });
813
+
814
+ it('should differ from stopRecording behavior', async () => {
815
+ // Test that stopAbnormally and stopRecording behave differently
816
+ jest.clearAllMocks();
817
+
818
+ // Use the existing simplifiedClient for testing
819
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
820
+
821
+ // Test stopAbnormally - should NOT call underlying client
822
+ simplifiedClient.stopAbnormally();
823
+ expect(mockClient.stopRecording).not.toHaveBeenCalled();
824
+
825
+ // Create new client to test stopRecording
826
+ const client2 = new SimplifiedVGFRecognitionClient({
827
+ asrRequestConfig: {
828
+ provider: 'deepgram',
829
+ language: 'en',
830
+ sampleRate: 16000,
831
+ encoding: AudioEncoding.LINEAR16
832
+ },
833
+ onStateChange: jest.fn()
834
+ });
835
+
836
+ // Clear mocks to isolate client2's behavior
837
+ jest.clearAllMocks();
838
+
839
+ // Test stopRecording - SHOULD call underlying client
840
+ await client2.stopRecording();
841
+ expect(mockClient.stopRecording).toHaveBeenCalled();
842
+ });
843
+
844
+ it('should use ABORTED status to distinguish from normal completion', () => {
845
+ // Test that stopAbnormally uses ABORTED, not FINALIZED
846
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
847
+
848
+ // Abnormal stop - should set to ABORTED
849
+ simplifiedClient.stopAbnormally();
850
+ const abortedState = simplifiedClient.getVGFState();
851
+
852
+ // Verify ABORTED is used (not FINALIZED)
853
+ expect(abortedState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
854
+ expect(abortedState.transcriptionStatus).not.toBe(TranscriptionStatus.FINALIZED);
855
+ expect(abortedState.finalTranscript).toBe(''); // Empty because cancelled
856
+
857
+ // ABORTED clearly indicates user cancelled, vs FINALIZED which means completed normally
858
+ });
859
+
860
+ describe('state guards', () => {
861
+ it('should do nothing if already fully stopped', () => {
862
+ // Setup: finalize state and mark underlying client as stopped
863
+ mockClient.getState.mockReturnValue(ClientState.STOPPED);
864
+ simplifiedClient.stopAbnormally();
865
+
866
+ // Clear mocks to test second call
867
+ jest.clearAllMocks();
868
+
869
+ // Call again - should return early and not call anything
870
+ simplifiedClient.stopAbnormally();
871
+
872
+ expect(stateChangeCallback).not.toHaveBeenCalled();
873
+ expect(mockClient.stopAbnormally).not.toHaveBeenCalled();
874
+ });
875
+
876
+ it('should not call underlying client if already in STOPPED state', () => {
877
+ // Mock underlying client as already stopped
878
+ mockClient.getState.mockReturnValue(ClientState.STOPPED);
879
+
880
+ // But VGF state not finalized yet
881
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
882
+ jest.clearAllMocks();
883
+
884
+ simplifiedClient.stopAbnormally();
885
+
886
+ // Should finalize VGF state
887
+ expect(stateChangeCallback).toHaveBeenCalled();
888
+ // But should NOT call underlying client
889
+ expect(mockClient.stopAbnormally).not.toHaveBeenCalled();
890
+ });
891
+
892
+ it('should not call underlying client if already in FAILED state', () => {
893
+ // Mock underlying client as failed
894
+ mockClient.getState.mockReturnValue(ClientState.FAILED);
895
+
896
+ simplifiedClient.stopAbnormally();
897
+
898
+ // Should handle VGF state update
899
+ expect(stateChangeCallback).toHaveBeenCalled();
900
+ // But should NOT call underlying client (already in terminal state)
901
+ expect(mockClient.stopAbnormally).not.toHaveBeenCalled();
902
+ });
903
+
904
+ it('should only update VGF state if already finalized but client not stopped', () => {
905
+ // First call - fully stop
906
+ simplifiedClient.stopAbnormally();
907
+ const firstCallCount = stateChangeCallback.mock.calls.length;
908
+
909
+ // Mock underlying client reconnects (edge case)
910
+ mockClient.getState.mockReturnValue(ClientState.READY);
911
+ jest.clearAllMocks();
912
+
913
+ // Second call - VGF already finalized but client not stopped
914
+ simplifiedClient.stopAbnormally();
915
+
916
+ // Should NOT update VGF state (already finalized)
917
+ expect(stateChangeCallback).not.toHaveBeenCalled();
918
+ // But SHOULD call underlying client (not stopped)
919
+ expect(mockClient.stopAbnormally).toHaveBeenCalled();
920
+ });
921
+ });
922
+ });
677
923
  });
@@ -8,7 +8,11 @@
8
8
  * All functionality is delegated to the underlying client.
9
9
  */
10
10
 
11
- import { RecognitionState } from './vgf-recognition-state.js';
11
+ import {
12
+ RecognitionState,
13
+ TranscriptionStatus,
14
+ RecordingStatus
15
+ } from './vgf-recognition-state.js';
12
16
  import {
13
17
  IRecognitionClient,
14
18
  IRecognitionClientConfig,
@@ -67,6 +71,25 @@ export interface ISimplifiedVGFRecognitionClient {
67
71
  */
68
72
  stopRecording(): Promise<void>;
69
73
 
74
+ /**
75
+ * Force stop and immediately close connection without waiting for server
76
+ *
77
+ * WARNING: This is an abnormal shutdown that bypasses the graceful stop flow:
78
+ * - Does NOT wait for server to process remaining audio
79
+ * - Does NOT receive final transcript from server (VGF state set to empty)
80
+ * - Immediately closes WebSocket connection
81
+ * - Cleans up resources (buffers, listeners)
82
+ *
83
+ * Use Cases:
84
+ * - User explicitly cancels/abandons the session
85
+ * - Timeout scenarios where waiting is not acceptable
86
+ * - Need immediate cleanup and can't wait for server
87
+ *
88
+ * RECOMMENDED: Use stopRecording() for normal shutdown.
89
+ * Only use this when immediate disconnection is required.
90
+ */
91
+ stopAbnormally(): void;
92
+
70
93
  // ============= VGF State Methods =============
71
94
  /**
72
95
  * Get the current VGF recognition state
@@ -254,6 +277,40 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
254
277
  await this.client.stopRecording();
255
278
  }
256
279
 
280
+ stopAbnormally(): void {
281
+ // Guard: If already aborted/finalized and stopped, do nothing
282
+ if ((this.state.transcriptionStatus === TranscriptionStatus.ABORTED ||
283
+ this.state.transcriptionStatus === TranscriptionStatus.FINALIZED) &&
284
+ this.client.getState() === ClientState.STOPPED) {
285
+ // Already fully stopped - nothing to do
286
+ return;
287
+ }
288
+
289
+ this.isRecordingAudio = false;
290
+
291
+ // Set state to ABORTED with empty transcript
292
+ // This clearly indicates the session was cancelled/abandoned by user
293
+ if (this.state.transcriptionStatus !== TranscriptionStatus.ABORTED &&
294
+ this.state.transcriptionStatus !== TranscriptionStatus.FINALIZED) {
295
+ this.state = {
296
+ ...this.state,
297
+ transcriptionStatus: TranscriptionStatus.ABORTED,
298
+ finalTranscript: '',
299
+ startRecordingStatus: RecordingStatus.FINISHED,
300
+ finalRecordingTimestamp: new Date().toISOString(),
301
+ finalTranscriptionTimestamp: new Date().toISOString()
302
+ };
303
+ this.notifyStateChange();
304
+ }
305
+
306
+ // Delegate to underlying client for actual WebSocket cleanup
307
+ // Only if client is not already in a terminal state
308
+ const clientState = this.client.getState();
309
+ if (clientState !== ClientState.STOPPED && clientState !== ClientState.FAILED) {
310
+ this.client.stopAbnormally();
311
+ }
312
+ }
313
+
257
314
  // Pure delegation methods - no state logic
258
315
  getAudioUtteranceId(): string {
259
316
  return this.client.getAudioUtteranceId();
@@ -2,22 +2,24 @@
2
2
  * Unit tests for URL Builder
3
3
  */
4
4
 
5
- import { buildWebSocketUrl, UrlBuilderConfig } from './url-builder.js';
6
5
  import { RecognitionContextTypeV1, STAGES } from '@recog/shared-types';
7
6
 
8
- // Mock the shared-config module
7
+ // Mock the shared-config module BEFORE importing the module under test
9
8
  const mockGetRecognitionServiceBase = jest.fn();
10
9
  jest.mock('@recog/shared-config', () => ({
11
10
  getRecognitionServiceBase: mockGetRecognitionServiceBase
12
11
  }));
13
12
 
13
+ import { buildWebSocketUrl, UrlBuilderConfig } from './url-builder.js';
14
+
14
15
  describe('buildWebSocketUrl', () => {
15
16
  const baseConfig: UrlBuilderConfig = {
16
17
  audioUtteranceId: 'test-utterance-123'
17
18
  };
18
19
 
19
20
  beforeEach(() => {
20
- // Reset mock before each test
21
+ // Clear and reset mock before each test
22
+ mockGetRecognitionServiceBase.mockClear();
21
23
  mockGetRecognitionServiceBase.mockReturnValue({
22
24
  wsBase: 'wss://recognition.volley.com'
23
25
  });
@@ -18,7 +18,7 @@ export const RecognitionVGFStateSchema = z.object({
18
18
  audioUtteranceId: z.string(),
19
19
  startRecordingStatus: z.string().optional(), // "NOT_READY", "READY", "RECORDING", "FINISHED". States follow this order.
20
20
  // Streaming should only start when "READY". Other states control mic UI and recording.
21
- transcriptionStatus: z.string().optional(), // "NOT_STARTED", "IN_PROGRESS", "FINALIZED", "ERROR"
21
+ transcriptionStatus: z.string().optional(), // "NOT_STARTED", "IN_PROGRESS", "FINALIZED", "ABORTED", "ERROR"
22
22
  finalTranscript: z.string().optional(), // Full finalized transcript for the utterance. Will not change.
23
23
  finalConfidence: z.number().optional(),
24
24
 
@@ -57,6 +57,7 @@ export const TranscriptionStatus = {
57
57
  NOT_STARTED: "NOT_STARTED",
58
58
  IN_PROGRESS: "IN_PROGRESS",
59
59
  FINALIZED: "FINALIZED",
60
+ ABORTED: "ABORTED", // Session was cancelled/abandoned by user
60
61
  ERROR: "ERROR",
61
62
  } as const
62
63