@volley/recognition-client-sdk 0.1.424 → 0.1.622

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.
@@ -27,7 +27,7 @@ import {
27
27
  updateStateOnStop,
28
28
  resetRecognitionVGFState
29
29
  } from './vgf-recognition-mapper.js';
30
- import { RecognitionContextTypeV1 } from '@recog/shared-types';
30
+ import { RecognitionContextTypeV1, type GameContextV1 } from '@recog/shared-types';
31
31
 
32
32
  /**
33
33
  * Configuration for SimplifiedVGFRecognitionClient
@@ -124,6 +124,23 @@ export interface ISimplifiedVGFRecognitionClient {
124
124
  */
125
125
  isBufferOverflowing(): boolean;
126
126
 
127
+ // ============= Preconnect Methods =============
128
+ /**
129
+ * Send game context after connection is established (for preconnect flow).
130
+ *
131
+ * Preconnect flow: Create client with asrRequestConfig (useContext: true) but
132
+ * WITHOUT gameContext → call connect() → later call sendGameContext() with slotMap.
133
+ *
134
+ * @param context - Game context including slotMap for keyword boosting
135
+ */
136
+ sendGameContext(context: GameContextV1): void;
137
+
138
+ /**
139
+ * Check if server has sent READY signal (provider connected, ready for audio).
140
+ * In preconnect flow, this becomes true after sendGameContext() triggers provider attachment.
141
+ */
142
+ isServerReady(): boolean;
143
+
127
144
  // ============= Utility Methods =============
128
145
  /**
129
146
  * Get the audio utterance ID for this session
@@ -202,6 +219,8 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
202
219
  } else {
203
220
  // Initialize VGF state from config
204
221
  this.state = createVGFStateFromConfig(clientConfig);
222
+ // Ensure clientConfig uses the same UUID as VGF state
223
+ clientConfig.audioUtteranceId = this.state.audioUtteranceId;
205
224
  }
206
225
 
207
226
  // Client is immediately ready to accept audio (will buffer if not connected)
@@ -430,6 +449,14 @@ export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognition
430
449
  return this.client.isBufferOverflowing();
431
450
  }
432
451
 
452
+ sendGameContext(context: GameContextV1): void {
453
+ this.client.sendGameContext(context);
454
+ }
455
+
456
+ isServerReady(): boolean {
457
+ return this.client.isServerReady();
458
+ }
459
+
433
460
 
434
461
  // VGF State access (read-only for consumers)
435
462
  getVGFState(): RecognitionState {
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Audio Ring Buffer Tests
3
+ */
4
+
5
+ import { AudioRingBuffer } from './audio-ring-buffer.js';
6
+
7
+ describe('AudioRingBuffer', () => {
8
+ describe('constructor', () => {
9
+ it('should initialize with correct buffer size', () => {
10
+ const buffer = new AudioRingBuffer({
11
+ maxBufferDurationSec: 10,
12
+ chunksPerSecond: 100
13
+ });
14
+
15
+ expect(buffer.isEmpty()).toBe(true);
16
+ expect(buffer.getBufferedCount()).toBe(0);
17
+ expect(buffer.isOverflowing()).toBe(false);
18
+ });
19
+
20
+ it('should accept logger function', () => {
21
+ const logger = jest.fn();
22
+ const buffer = new AudioRingBuffer({
23
+ maxBufferDurationSec: 1,
24
+ chunksPerSecond: 10,
25
+ logger
26
+ });
27
+
28
+ expect(buffer).toBeDefined();
29
+ });
30
+ });
31
+
32
+ describe('write', () => {
33
+ it('should write ArrayBuffer data', () => {
34
+ const buffer = new AudioRingBuffer({
35
+ maxBufferDurationSec: 1,
36
+ chunksPerSecond: 10
37
+ });
38
+
39
+ const data = new ArrayBuffer(100);
40
+ buffer.write(data);
41
+
42
+ expect(buffer.isEmpty()).toBe(false);
43
+ expect(buffer.getBufferedCount()).toBe(1);
44
+ });
45
+
46
+ it('should write ArrayBufferView data', () => {
47
+ const buffer = new AudioRingBuffer({
48
+ maxBufferDurationSec: 1,
49
+ chunksPerSecond: 10
50
+ });
51
+
52
+ const data = new Uint8Array(100);
53
+ buffer.write(data);
54
+
55
+ expect(buffer.isEmpty()).toBe(false);
56
+ expect(buffer.getBufferedCount()).toBe(1);
57
+ });
58
+
59
+ it('should track total buffered bytes', () => {
60
+ const buffer = new AudioRingBuffer({
61
+ maxBufferDurationSec: 1,
62
+ chunksPerSecond: 10
63
+ });
64
+
65
+ buffer.write(new ArrayBuffer(100));
66
+ buffer.write(new ArrayBuffer(200));
67
+
68
+ const stats = buffer.getStats();
69
+ expect(stats.totalBufferedBytes).toBe(300);
70
+ expect(stats.chunksBuffered).toBe(2);
71
+ });
72
+
73
+ it('should handle multiple writes', () => {
74
+ const buffer = new AudioRingBuffer({
75
+ maxBufferDurationSec: 1,
76
+ chunksPerSecond: 100
77
+ });
78
+
79
+ for (let i = 0; i < 50; i++) {
80
+ buffer.write(new ArrayBuffer(10));
81
+ }
82
+
83
+ expect(buffer.getBufferedCount()).toBe(50);
84
+ expect(buffer.isOverflowing()).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe('overflow detection', () => {
89
+ it('should detect overflow when buffer is full', () => {
90
+ const buffer = new AudioRingBuffer({
91
+ maxBufferDurationSec: 1,
92
+ chunksPerSecond: 5 // Small buffer of 5 chunks
93
+ });
94
+
95
+ // Write more than buffer can hold
96
+ for (let i = 0; i < 10; i++) {
97
+ buffer.write(new ArrayBuffer(10));
98
+ }
99
+
100
+ expect(buffer.isOverflowing()).toBe(true);
101
+ const stats = buffer.getStats();
102
+ expect(stats.hasWrapped).toBe(true);
103
+ expect(stats.overflowCount).toBeGreaterThan(0);
104
+ });
105
+
106
+ it('should call logger on overflow', () => {
107
+ const logger = jest.fn();
108
+ const buffer = new AudioRingBuffer({
109
+ maxBufferDurationSec: 1,
110
+ chunksPerSecond: 3, // Small buffer
111
+ logger
112
+ });
113
+
114
+ // Fill buffer to overflow
115
+ for (let i = 0; i < 10; i++) {
116
+ buffer.write(new ArrayBuffer(10));
117
+ }
118
+
119
+ // Logger should have been called with overflow message
120
+ expect(logger).toHaveBeenCalledWith(
121
+ 'debug',
122
+ expect.stringContaining('overflow'),
123
+ expect.any(Object)
124
+ );
125
+ });
126
+ });
127
+
128
+ describe('read', () => {
129
+ it('should return null when buffer is empty', () => {
130
+ const buffer = new AudioRingBuffer({
131
+ maxBufferDurationSec: 1,
132
+ chunksPerSecond: 10
133
+ });
134
+
135
+ expect(buffer.read()).toBeNull();
136
+ });
137
+
138
+ it('should return data in FIFO order', () => {
139
+ const buffer = new AudioRingBuffer({
140
+ maxBufferDurationSec: 1,
141
+ chunksPerSecond: 10
142
+ });
143
+
144
+ const data1 = new Uint8Array([1, 2, 3]);
145
+ const data2 = new Uint8Array([4, 5, 6]);
146
+
147
+ buffer.write(data1);
148
+ buffer.write(data2);
149
+
150
+ const chunk1 = buffer.read();
151
+ expect(chunk1).not.toBeNull();
152
+ expect(chunk1!.data).toBe(data1);
153
+
154
+ const chunk2 = buffer.read();
155
+ expect(chunk2).not.toBeNull();
156
+ expect(chunk2!.data).toBe(data2);
157
+ });
158
+
159
+ it('should decrement buffered count after read', () => {
160
+ const buffer = new AudioRingBuffer({
161
+ maxBufferDurationSec: 1,
162
+ chunksPerSecond: 10
163
+ });
164
+
165
+ buffer.write(new ArrayBuffer(10));
166
+ buffer.write(new ArrayBuffer(10));
167
+
168
+ expect(buffer.getBufferedCount()).toBe(2);
169
+
170
+ buffer.read();
171
+ expect(buffer.getBufferedCount()).toBe(1);
172
+
173
+ buffer.read();
174
+ expect(buffer.getBufferedCount()).toBe(0);
175
+ expect(buffer.isEmpty()).toBe(true);
176
+ });
177
+ });
178
+
179
+ describe('readAll', () => {
180
+ it('should return empty array when buffer is empty', () => {
181
+ const buffer = new AudioRingBuffer({
182
+ maxBufferDurationSec: 1,
183
+ chunksPerSecond: 10
184
+ });
185
+
186
+ expect(buffer.readAll()).toEqual([]);
187
+ });
188
+
189
+ it('should return all chunks without removing them', () => {
190
+ const buffer = new AudioRingBuffer({
191
+ maxBufferDurationSec: 1,
192
+ chunksPerSecond: 10
193
+ });
194
+
195
+ buffer.write(new ArrayBuffer(10));
196
+ buffer.write(new ArrayBuffer(20));
197
+ buffer.write(new ArrayBuffer(30));
198
+
199
+ const chunks = buffer.readAll();
200
+ expect(chunks).toHaveLength(3);
201
+
202
+ // Should still have all chunks
203
+ expect(buffer.getBufferedCount()).toBe(3);
204
+ });
205
+ });
206
+
207
+ describe('flush', () => {
208
+ it('should return all chunks and clear buffer', () => {
209
+ const buffer = new AudioRingBuffer({
210
+ maxBufferDurationSec: 1,
211
+ chunksPerSecond: 10
212
+ });
213
+
214
+ buffer.write(new ArrayBuffer(10));
215
+ buffer.write(new ArrayBuffer(20));
216
+
217
+ const chunks = buffer.flush();
218
+ expect(chunks).toHaveLength(2);
219
+ expect(buffer.isEmpty()).toBe(true);
220
+ expect(buffer.getBufferedCount()).toBe(0);
221
+ });
222
+ });
223
+
224
+ describe('clear', () => {
225
+ it('should reset all state', () => {
226
+ const buffer = new AudioRingBuffer({
227
+ maxBufferDurationSec: 1,
228
+ chunksPerSecond: 5
229
+ });
230
+
231
+ // Fill and overflow
232
+ for (let i = 0; i < 10; i++) {
233
+ buffer.write(new ArrayBuffer(100));
234
+ }
235
+
236
+ expect(buffer.isOverflowing()).toBe(true);
237
+
238
+ buffer.clear();
239
+
240
+ expect(buffer.isEmpty()).toBe(true);
241
+ expect(buffer.getBufferedCount()).toBe(0);
242
+ expect(buffer.isOverflowing()).toBe(false);
243
+
244
+ const stats = buffer.getStats();
245
+ expect(stats.chunksBuffered).toBe(0);
246
+ expect(stats.totalBufferedBytes).toBe(0);
247
+ expect(stats.overflowCount).toBe(0);
248
+ expect(stats.hasWrapped).toBe(false);
249
+ });
250
+
251
+ it('should call logger when clearing', () => {
252
+ const logger = jest.fn();
253
+ const buffer = new AudioRingBuffer({
254
+ maxBufferDurationSec: 1,
255
+ chunksPerSecond: 10,
256
+ logger
257
+ });
258
+
259
+ buffer.write(new ArrayBuffer(10));
260
+ buffer.clear();
261
+
262
+ expect(logger).toHaveBeenCalledWith(
263
+ 'debug',
264
+ expect.stringContaining('cleared')
265
+ );
266
+ });
267
+ });
268
+
269
+ describe('getStats', () => {
270
+ it('should return correct statistics', () => {
271
+ const buffer = new AudioRingBuffer({
272
+ maxBufferDurationSec: 1,
273
+ chunksPerSecond: 10
274
+ });
275
+
276
+ buffer.write(new ArrayBuffer(100));
277
+ buffer.write(new ArrayBuffer(200));
278
+
279
+ const stats = buffer.getStats();
280
+
281
+ expect(stats.chunksBuffered).toBe(2);
282
+ expect(stats.currentBufferedChunks).toBe(2);
283
+ expect(stats.totalBufferedBytes).toBe(300);
284
+ expect(stats.hasWrapped).toBe(false);
285
+ expect(stats.overflowCount).toBe(0);
286
+ });
287
+ });
288
+
289
+ describe('getBufferedCount with wraparound', () => {
290
+ it('should correctly count after partial read and more writes', () => {
291
+ const buffer = new AudioRingBuffer({
292
+ maxBufferDurationSec: 1,
293
+ chunksPerSecond: 10 // Buffer size of 10
294
+ });
295
+
296
+ // Write 5 chunks
297
+ for (let i = 0; i < 5; i++) {
298
+ buffer.write(new ArrayBuffer(10));
299
+ }
300
+ expect(buffer.getBufferedCount()).toBe(5);
301
+
302
+ // Read 3 chunks
303
+ buffer.read();
304
+ buffer.read();
305
+ buffer.read();
306
+ expect(buffer.getBufferedCount()).toBe(2);
307
+
308
+ // Write 4 more (wraps around in buffer)
309
+ for (let i = 0; i < 4; i++) {
310
+ buffer.write(new ArrayBuffer(10));
311
+ }
312
+
313
+ // Should have 2 + 4 = 6 chunks
314
+ expect(buffer.getBufferedCount()).toBe(6);
315
+ expect(buffer.isOverflowing()).toBe(false);
316
+ });
317
+
318
+ it('should handle buffer overflow correctly', () => {
319
+ const buffer = new AudioRingBuffer({
320
+ maxBufferDurationSec: 1,
321
+ chunksPerSecond: 3 // Buffer size of 3
322
+ });
323
+
324
+ // Write 5 chunks (more than buffer can hold)
325
+ for (let i = 0; i < 5; i++) {
326
+ buffer.write(new ArrayBuffer(10));
327
+ }
328
+
329
+ // Buffer should be full but not exceed capacity
330
+ // Ring buffer drops oldest when full
331
+ expect(buffer.getBufferedCount()).toBeLessThanOrEqual(3);
332
+ expect(buffer.isOverflowing()).toBe(true);
333
+ });
334
+ });
335
+ });
@@ -86,6 +86,14 @@ export function mapTranscriptionResultToState(
86
86
  newState.finalConfidence = result.finalTranscriptConfidence;
87
87
  }
88
88
  }
89
+
90
+ // Update voice timing on every transcript message
91
+ if (result.voiceEnd !== undefined) {
92
+ newState.voiceEnd = result.voiceEnd;
93
+ }
94
+ if (result.lastNonSilence !== undefined) {
95
+ newState.lastNonSilence = result.lastNonSilence;
96
+ }
89
97
  } else {
90
98
  // Transcription is finished
91
99
  newState.transcriptionStatus = TranscriptionStatus.FINALIZED;
@@ -95,6 +103,14 @@ export function mapTranscriptionResultToState(
95
103
  }
96
104
  newState.finalTranscriptionTimestamp = new Date().toISOString();
97
105
 
106
+ // Update voice timing on final transcript
107
+ if (result.voiceEnd !== undefined) {
108
+ newState.voiceEnd = result.voiceEnd;
109
+ }
110
+ if (result.lastNonSilence !== undefined) {
111
+ newState.lastNonSilence = result.lastNonSilence;
112
+ }
113
+
98
114
  // Clear pending when we have final
99
115
  newState.pendingTranscript = "";
100
116
  newState.pendingConfidence = undefined;
@@ -167,7 +183,9 @@ export function resetRecognitionVGFState(currentState: RecognitionState): Recogn
167
183
  transcriptionStatus: TranscriptionStatus.NOT_STARTED,
168
184
  startRecordingStatus: RecordingStatus.READY,
169
185
  recognitionActionProcessingState: RecognitionActionProcessingState.NOT_STARTED,
170
- finalTranscript: undefined
186
+ finalTranscript: undefined,
187
+ voiceEnd: undefined,
188
+ lastNonSilence: undefined
171
189
  };
172
190
  }
173
191
 
@@ -22,6 +22,10 @@ export const RecognitionVGFStateSchema = z.object({
22
22
  finalTranscript: z.string().optional(), // Full finalized transcript for the utterance. Will not change.
23
23
  finalConfidence: z.number().optional(),
24
24
 
25
+ // Voice timing (ms from stream start, prefix-adjusted)
26
+ voiceEnd: z.number().optional(), // voice end time identified by ASR
27
+ lastNonSilence: z.number().optional(), // last non-silence sample time from PCM analysis
28
+
25
29
  // Tracking-only metadata
26
30
  asrConfig: z.string().optional(), // Json format of the ASR config
27
31
  startRecordingTimestamp: z.string().optional(), // Start of recording. Immutable after set.