@volley/recognition-client-sdk 0.1.200
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 +168 -0
- package/dist/browser-CDQ_TzeH.d.ts +1039 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +2332 -0
- package/dist/index.js.map +1 -0
- package/dist/recog-client-sdk.browser.d.ts +2 -0
- package/dist/recog-client-sdk.browser.js +1843 -0
- package/dist/recog-client-sdk.browser.js.map +1 -0
- package/package.json +73 -0
- package/src/browser.ts +24 -0
- package/src/config-builder.ts +213 -0
- package/src/factory.ts +43 -0
- package/src/index.ts +86 -0
- package/src/recognition-client.spec.ts +551 -0
- package/src/recognition-client.ts +595 -0
- package/src/recognition-client.types.ts +260 -0
- package/src/simplified-vgf-recognition-client.spec.ts +671 -0
- package/src/simplified-vgf-recognition-client.ts +339 -0
- package/src/utils/audio-ring-buffer.ts +170 -0
- package/src/utils/message-handler.ts +131 -0
- package/src/utils/url-builder.ts +70 -0
- package/src/vgf-recognition-mapper.ts +225 -0
- package/src/vgf-recognition-state.ts +89 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simplified VGF Recognition Client
|
|
3
|
+
*
|
|
4
|
+
* A thin wrapper around RealTimeTwoWayWebSocketRecognitionClient that maintains
|
|
5
|
+
* a VGF RecognitionState as a pure sink/output of recognition events.
|
|
6
|
+
*
|
|
7
|
+
* The VGF state is updated based on events but never influences client behavior.
|
|
8
|
+
* All functionality is delegated to the underlying client.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { RecognitionState } from './vgf-recognition-state.js';
|
|
12
|
+
import {
|
|
13
|
+
IRecognitionClient,
|
|
14
|
+
IRecognitionClientConfig,
|
|
15
|
+
ClientState
|
|
16
|
+
} from './recognition-client.types.js';
|
|
17
|
+
import { RealTimeTwoWayWebSocketRecognitionClient } from './recognition-client.js';
|
|
18
|
+
import {
|
|
19
|
+
createVGFStateFromConfig,
|
|
20
|
+
mapTranscriptionResultToState,
|
|
21
|
+
mapMetadataToState,
|
|
22
|
+
mapErrorToState,
|
|
23
|
+
updateStateOnStop
|
|
24
|
+
} from './vgf-recognition-mapper.js';
|
|
25
|
+
import { RecognitionContextTypeV1 } from '@recog/shared-types';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration for SimplifiedVGFRecognitionClient
|
|
29
|
+
*/
|
|
30
|
+
export interface SimplifiedVGFClientConfig extends IRecognitionClientConfig {
|
|
31
|
+
/**
|
|
32
|
+
* Callback invoked whenever the VGF state changes
|
|
33
|
+
* Use this to update your UI or React state
|
|
34
|
+
*/
|
|
35
|
+
onStateChange?: (state: RecognitionState) => void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Optional initial state to restore from a previous session
|
|
39
|
+
* If provided, audioUtteranceId will be extracted and used
|
|
40
|
+
*/
|
|
41
|
+
initialState?: RecognitionState;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Interface for SimplifiedVGFRecognitionClient
|
|
46
|
+
*
|
|
47
|
+
* A simplified client that maintains VGF state for game developers.
|
|
48
|
+
* All methods from the underlying client are available, plus VGF state management.
|
|
49
|
+
*/
|
|
50
|
+
export interface ISimplifiedVGFRecognitionClient {
|
|
51
|
+
// ============= Core Connection Methods =============
|
|
52
|
+
/**
|
|
53
|
+
* Connect to the recognition service WebSocket
|
|
54
|
+
* @returns Promise that resolves when connected and ready
|
|
55
|
+
*/
|
|
56
|
+
connect(): Promise<void>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Send audio data for transcription
|
|
60
|
+
* @param audioData - PCM audio data as ArrayBuffer or typed array
|
|
61
|
+
*/
|
|
62
|
+
sendAudio(audioData: ArrayBuffer | ArrayBufferView): void;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stop recording and wait for final transcription
|
|
66
|
+
* @returns Promise that resolves when transcription is complete
|
|
67
|
+
*/
|
|
68
|
+
stopRecording(): Promise<void>;
|
|
69
|
+
|
|
70
|
+
// ============= VGF State Methods =============
|
|
71
|
+
/**
|
|
72
|
+
* Get the current VGF recognition state
|
|
73
|
+
* @returns Current RecognitionState with all transcription data
|
|
74
|
+
*/
|
|
75
|
+
getVGFState(): RecognitionState;
|
|
76
|
+
|
|
77
|
+
// ============= Status Check Methods =============
|
|
78
|
+
/**
|
|
79
|
+
* Check if connected to the WebSocket
|
|
80
|
+
*/
|
|
81
|
+
isConnected(): boolean;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if currently connecting
|
|
85
|
+
*/
|
|
86
|
+
isConnecting(): boolean;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if currently stopping
|
|
90
|
+
*/
|
|
91
|
+
isStopping(): boolean;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if transcription has finished
|
|
95
|
+
*/
|
|
96
|
+
isTranscriptionFinished(): boolean;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if the audio buffer has overflowed
|
|
100
|
+
*/
|
|
101
|
+
isBufferOverflowing(): boolean;
|
|
102
|
+
|
|
103
|
+
// ============= Utility Methods =============
|
|
104
|
+
/**
|
|
105
|
+
* Get the audio utterance ID for this session
|
|
106
|
+
*/
|
|
107
|
+
getAudioUtteranceId(): string;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get the underlying client state (for advanced usage)
|
|
111
|
+
*/
|
|
112
|
+
getState(): ClientState;
|
|
113
|
+
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* This wrapper ONLY maintains VGF state as a sink.
|
|
118
|
+
* All actual functionality is delegated to the underlying client.
|
|
119
|
+
*/
|
|
120
|
+
export class SimplifiedVGFRecognitionClient implements ISimplifiedVGFRecognitionClient {
|
|
121
|
+
private client: IRecognitionClient;
|
|
122
|
+
private state: RecognitionState;
|
|
123
|
+
private isRecordingAudio: boolean = false;
|
|
124
|
+
private stateChangeCallback: ((state: RecognitionState) => void) | undefined;
|
|
125
|
+
|
|
126
|
+
constructor(config: SimplifiedVGFClientConfig) {
|
|
127
|
+
const { onStateChange, initialState, ...clientConfig } = config;
|
|
128
|
+
this.stateChangeCallback = onStateChange;
|
|
129
|
+
|
|
130
|
+
// Use provided initial state or create from config
|
|
131
|
+
if (initialState) {
|
|
132
|
+
this.state = initialState;
|
|
133
|
+
// Override audioUtteranceId in config if state has one
|
|
134
|
+
if (initialState.audioUtteranceId && !clientConfig.audioUtteranceId) {
|
|
135
|
+
clientConfig.audioUtteranceId = initialState.audioUtteranceId;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Initialize VGF state from config
|
|
139
|
+
this.state = createVGFStateFromConfig(clientConfig);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Client is immediately ready to accept audio (will buffer if not connected)
|
|
143
|
+
this.state = { ...this.state, startRecordingStatus: 'READY' };
|
|
144
|
+
|
|
145
|
+
// If VGF state has promptSlotMap, configure gameContext to use it
|
|
146
|
+
if (this.state.promptSlotMap) {
|
|
147
|
+
// Set useContext=true in ASR config to enable context processing
|
|
148
|
+
if (clientConfig.asrRequestConfig) {
|
|
149
|
+
clientConfig.asrRequestConfig.useContext = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Add promptSlotMap to gameContext
|
|
153
|
+
if (!clientConfig.gameContext) {
|
|
154
|
+
// Only create gameContext if we have gameId and gamePhase
|
|
155
|
+
// These should come from the game's configuration
|
|
156
|
+
if (clientConfig.logger) {
|
|
157
|
+
clientConfig.logger('warn', '[VGF] promptSlotMap found but no gameContext provided. SlotMap will not be sent.');
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// Merge promptSlotMap into existing gameContext
|
|
161
|
+
clientConfig.gameContext.slotMap = this.state.promptSlotMap;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Create underlying client with callbacks that ONLY update VGF state
|
|
166
|
+
this.client = new RealTimeTwoWayWebSocketRecognitionClient({
|
|
167
|
+
...clientConfig,
|
|
168
|
+
|
|
169
|
+
// These callbacks ONLY update the VGF state sink
|
|
170
|
+
onTranscript: (result) => {
|
|
171
|
+
// Update VGF state based on transcript
|
|
172
|
+
this.state = mapTranscriptionResultToState(this.state, result, this.isRecordingAudio);
|
|
173
|
+
this.notifyStateChange();
|
|
174
|
+
|
|
175
|
+
// Call original callback if provided
|
|
176
|
+
if (clientConfig.onTranscript) {
|
|
177
|
+
clientConfig.onTranscript(result);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
onMetadata: (metadata) => {
|
|
182
|
+
this.state = mapMetadataToState(this.state, metadata);
|
|
183
|
+
this.notifyStateChange();
|
|
184
|
+
|
|
185
|
+
if (clientConfig.onMetadata) {
|
|
186
|
+
clientConfig.onMetadata(metadata);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
onFunctionCall: (result) => {
|
|
191
|
+
// Pass through function call - no VGF state changes needed for P2 feature
|
|
192
|
+
if (clientConfig.onFunctionCall) {
|
|
193
|
+
clientConfig.onFunctionCall(result);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
onError: (error) => {
|
|
198
|
+
this.isRecordingAudio = false; // Reset on error
|
|
199
|
+
this.state = mapErrorToState(this.state, error);
|
|
200
|
+
this.notifyStateChange();
|
|
201
|
+
|
|
202
|
+
if (clientConfig.onError) {
|
|
203
|
+
clientConfig.onError(error);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
onConnected: () => {
|
|
208
|
+
// Don't update READY here - client can accept audio before connection
|
|
209
|
+
if (clientConfig.onConnected) {
|
|
210
|
+
clientConfig.onConnected();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
onDisconnected: (code, reason) => {
|
|
215
|
+
this.isRecordingAudio = false; // Reset on disconnect
|
|
216
|
+
if (clientConfig.onDisconnected) {
|
|
217
|
+
clientConfig.onDisconnected(code, reason);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// DELEGATE ALL METHODS TO UNDERLYING CLIENT
|
|
224
|
+
// The wrapper ONLY updates VGF state, doesn't use it for decisions
|
|
225
|
+
|
|
226
|
+
async connect(): Promise<void> {
|
|
227
|
+
await this.client.connect();
|
|
228
|
+
// State will be updated via onConnected callback
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
sendAudio(audioData: ArrayBuffer | ArrayBufferView): void {
|
|
232
|
+
// Track recording for state updates
|
|
233
|
+
if (!this.isRecordingAudio) {
|
|
234
|
+
this.isRecordingAudio = true;
|
|
235
|
+
this.state = {
|
|
236
|
+
...this.state,
|
|
237
|
+
startRecordingStatus: 'RECORDING',
|
|
238
|
+
startRecordingTimestamp: new Date().toISOString()
|
|
239
|
+
};
|
|
240
|
+
this.notifyStateChange();
|
|
241
|
+
}
|
|
242
|
+
this.client.sendAudio(audioData);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async stopRecording(): Promise<void> {
|
|
246
|
+
this.isRecordingAudio = false;
|
|
247
|
+
this.state = updateStateOnStop(this.state);
|
|
248
|
+
this.notifyStateChange();
|
|
249
|
+
await this.client.stopRecording();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Pure delegation methods - no state logic
|
|
253
|
+
getAudioUtteranceId(): string {
|
|
254
|
+
return this.client.getAudioUtteranceId();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
getState(): ClientState {
|
|
258
|
+
return this.client.getState();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
isConnected(): boolean {
|
|
262
|
+
return this.client.isConnected();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
isConnecting(): boolean {
|
|
266
|
+
return this.client.isConnecting();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
isStopping(): boolean {
|
|
270
|
+
return this.client.isStopping();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
isTranscriptionFinished(): boolean {
|
|
274
|
+
return this.client.isTranscriptionFinished();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
isBufferOverflowing(): boolean {
|
|
278
|
+
return this.client.isBufferOverflowing();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
// VGF State access (read-only for consumers)
|
|
283
|
+
getVGFState(): RecognitionState {
|
|
284
|
+
return { ...this.state };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private notifyStateChange(): void {
|
|
288
|
+
if (this.stateChangeCallback) {
|
|
289
|
+
this.stateChangeCallback({ ...this.state });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Factory function for creating simplified client
|
|
296
|
+
* Usage examples:
|
|
297
|
+
*
|
|
298
|
+
* // Basic usage
|
|
299
|
+
* const client = createSimplifiedVGFClient({
|
|
300
|
+
* asrRequestConfig: { provider: 'deepgram', language: 'en' },
|
|
301
|
+
* onStateChange: (state) => {
|
|
302
|
+
* console.log('VGF State updated:', state);
|
|
303
|
+
* // Update React state, game UI, etc.
|
|
304
|
+
* }
|
|
305
|
+
* });
|
|
306
|
+
*
|
|
307
|
+
* // With initial state (e.g., restoring from previous session)
|
|
308
|
+
* const client = createSimplifiedVGFClient({
|
|
309
|
+
* asrRequestConfig: { provider: 'deepgram', language: 'en' },
|
|
310
|
+
* initialState: previousState, // Will use audioUtteranceId from state
|
|
311
|
+
* onStateChange: (state) => setVGFState(state)
|
|
312
|
+
* });
|
|
313
|
+
*
|
|
314
|
+
* // With initial state containing promptSlotMap for enhanced recognition
|
|
315
|
+
* const stateWithSlots: RecognitionState = {
|
|
316
|
+
* audioUtteranceId: 'session-123',
|
|
317
|
+
* promptSlotMap: {
|
|
318
|
+
* 'song_title': ['one time', 'baby'],
|
|
319
|
+
* 'artists': ['justin bieber']
|
|
320
|
+
* }
|
|
321
|
+
* };
|
|
322
|
+
* const client = createSimplifiedVGFClient({
|
|
323
|
+
* asrRequestConfig: { provider: 'deepgram', language: 'en' },
|
|
324
|
+
* gameContext: {
|
|
325
|
+
* type: RecognitionContextTypeV1.GAME_CONTEXT,
|
|
326
|
+
* gameId: 'music-quiz', // Your game's ID
|
|
327
|
+
* gamePhase: 'song-guessing' // Current game phase
|
|
328
|
+
* },
|
|
329
|
+
* initialState: stateWithSlots, // promptSlotMap will be added to gameContext
|
|
330
|
+
* onStateChange: (state) => setVGFState(state)
|
|
331
|
+
* });
|
|
332
|
+
*
|
|
333
|
+
* await client.connect();
|
|
334
|
+
* client.sendAudio(audioData);
|
|
335
|
+
* // VGF state automatically updates based on transcription results
|
|
336
|
+
*/
|
|
337
|
+
export function createSimplifiedVGFClient(config: SimplifiedVGFClientConfig): ISimplifiedVGFRecognitionClient {
|
|
338
|
+
return new SimplifiedVGFRecognitionClient(config);
|
|
339
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Ring Buffer
|
|
3
|
+
* Manages circular buffer for audio data with overflow detection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface BufferedAudio {
|
|
7
|
+
data: ArrayBuffer | ArrayBufferView;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AudioRingBufferConfig {
|
|
12
|
+
maxBufferDurationSec: number;
|
|
13
|
+
chunksPerSecond: number;
|
|
14
|
+
logger?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class AudioRingBuffer {
|
|
18
|
+
private buffer: BufferedAudio[] = [];
|
|
19
|
+
private bufferSize: number;
|
|
20
|
+
private writeIndex = 0;
|
|
21
|
+
private readIndex = 0;
|
|
22
|
+
private hasWrapped = false;
|
|
23
|
+
private totalBufferedBytes = 0;
|
|
24
|
+
private overflowCount = 0;
|
|
25
|
+
private chunksBuffered = 0;
|
|
26
|
+
private logger?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any) => void;
|
|
27
|
+
|
|
28
|
+
constructor(config: AudioRingBufferConfig) {
|
|
29
|
+
this.bufferSize = config.maxBufferDurationSec * config.chunksPerSecond;
|
|
30
|
+
this.buffer = new Array(this.bufferSize);
|
|
31
|
+
if (config.logger) {
|
|
32
|
+
this.logger = config.logger;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Write audio chunk to ring buffer with overflow detection
|
|
38
|
+
*/
|
|
39
|
+
write(audioData: ArrayBuffer | ArrayBufferView): void {
|
|
40
|
+
const bytes = ArrayBuffer.isView(audioData) ? audioData.byteLength : audioData.byteLength;
|
|
41
|
+
|
|
42
|
+
// Write to current position
|
|
43
|
+
this.buffer[this.writeIndex] = {
|
|
44
|
+
data: audioData,
|
|
45
|
+
timestamp: Date.now()
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Advance write pointer
|
|
49
|
+
const nextWriteIndex = (this.writeIndex + 1) % this.bufferSize;
|
|
50
|
+
|
|
51
|
+
// Detect overflow: write caught up to read
|
|
52
|
+
if (nextWriteIndex === this.readIndex && this.writeIndex !== this.readIndex) {
|
|
53
|
+
this.hasWrapped = true;
|
|
54
|
+
this.overflowCount++;
|
|
55
|
+
|
|
56
|
+
// Log buffer overflow event
|
|
57
|
+
if (this.logger) {
|
|
58
|
+
this.logger('debug', 'Buffer overflow detected', {
|
|
59
|
+
bufferSize: this.bufferSize,
|
|
60
|
+
totalOverflows: this.overflowCount,
|
|
61
|
+
droppedChunk: this.buffer[this.readIndex]?.timestamp
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Move read pointer forward to make room (drop oldest)
|
|
66
|
+
this.readIndex = (this.readIndex + 1) % this.bufferSize;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.writeIndex = nextWriteIndex;
|
|
70
|
+
this.chunksBuffered++;
|
|
71
|
+
this.totalBufferedBytes += bytes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read next chunk from buffer
|
|
76
|
+
*/
|
|
77
|
+
read(): BufferedAudio | null {
|
|
78
|
+
if (this.isEmpty()) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const chunk = this.buffer[this.readIndex];
|
|
83
|
+
this.readIndex = (this.readIndex + 1) % this.bufferSize;
|
|
84
|
+
return chunk || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read all buffered chunks without removing them
|
|
89
|
+
*/
|
|
90
|
+
readAll(): BufferedAudio[] {
|
|
91
|
+
const chunks: BufferedAudio[] = [];
|
|
92
|
+
let index = this.readIndex;
|
|
93
|
+
|
|
94
|
+
while (index !== this.writeIndex) {
|
|
95
|
+
const chunk = this.buffer[index];
|
|
96
|
+
if (chunk) {
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
}
|
|
99
|
+
index = (index + 1) % this.bufferSize;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return chunks;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Flush all buffered data and advance read pointer
|
|
107
|
+
*/
|
|
108
|
+
flush(): BufferedAudio[] {
|
|
109
|
+
const chunks = this.readAll();
|
|
110
|
+
this.readIndex = this.writeIndex;
|
|
111
|
+
return chunks;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get count of buffered chunks
|
|
116
|
+
*/
|
|
117
|
+
getBufferedCount(): number {
|
|
118
|
+
if (this.writeIndex >= this.readIndex) {
|
|
119
|
+
return this.writeIndex - this.readIndex;
|
|
120
|
+
} else {
|
|
121
|
+
// Wrapped around
|
|
122
|
+
return this.bufferSize - this.readIndex + this.writeIndex;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if buffer is empty
|
|
128
|
+
*/
|
|
129
|
+
isEmpty(): boolean {
|
|
130
|
+
return this.readIndex === this.writeIndex;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if buffer has overflowed
|
|
135
|
+
*/
|
|
136
|
+
isOverflowing(): boolean {
|
|
137
|
+
return this.hasWrapped;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Clear the buffer and reset all counters
|
|
142
|
+
* Frees memory by releasing all stored audio chunks
|
|
143
|
+
*/
|
|
144
|
+
clear(): void {
|
|
145
|
+
this.buffer = [];
|
|
146
|
+
this.writeIndex = 0;
|
|
147
|
+
this.readIndex = 0;
|
|
148
|
+
this.hasWrapped = false;
|
|
149
|
+
this.overflowCount = 0;
|
|
150
|
+
this.chunksBuffered = 0;
|
|
151
|
+
this.totalBufferedBytes = 0;
|
|
152
|
+
|
|
153
|
+
if (this.logger) {
|
|
154
|
+
this.logger('debug', 'Audio buffer cleared');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get buffer statistics
|
|
160
|
+
*/
|
|
161
|
+
getStats() {
|
|
162
|
+
return {
|
|
163
|
+
chunksBuffered: this.chunksBuffered,
|
|
164
|
+
currentBufferedChunks: this.getBufferedCount(),
|
|
165
|
+
overflowCount: this.overflowCount,
|
|
166
|
+
hasWrapped: this.hasWrapped,
|
|
167
|
+
totalBufferedBytes: this.totalBufferedBytes
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Handler for Recognition Client
|
|
3
|
+
* Routes incoming WebSocket messages to appropriate callbacks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
RecognitionResultTypeV1,
|
|
8
|
+
ClientControlActionV1,
|
|
9
|
+
type TranscriptionResultV1,
|
|
10
|
+
type FunctionCallResultV1,
|
|
11
|
+
type MetadataResultV1,
|
|
12
|
+
type ErrorResultV1,
|
|
13
|
+
type ClientControlMessageV1
|
|
14
|
+
} from '@recog/shared-types';
|
|
15
|
+
|
|
16
|
+
export interface MessageHandlerCallbacks {
|
|
17
|
+
onTranscript: (result: TranscriptionResultV1) => void;
|
|
18
|
+
onFunctionCall: (result: FunctionCallResultV1) => void;
|
|
19
|
+
onMetadata: (metadata: MetadataResultV1) => void;
|
|
20
|
+
onError: (error: ErrorResultV1) => void;
|
|
21
|
+
onControlMessage: (msg: ClientControlMessageV1) => void;
|
|
22
|
+
logger?: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class MessageHandler {
|
|
26
|
+
private firstTranscriptTime: number | null = null;
|
|
27
|
+
private sessionStartTime: number | null = null;
|
|
28
|
+
private callbacks: MessageHandlerCallbacks;
|
|
29
|
+
|
|
30
|
+
constructor(callbacks: MessageHandlerCallbacks) {
|
|
31
|
+
this.callbacks = callbacks;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Set session start time for performance tracking
|
|
36
|
+
*/
|
|
37
|
+
setSessionStartTime(time: number): void {
|
|
38
|
+
this.sessionStartTime = time;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Handle incoming WebSocket message
|
|
43
|
+
*/
|
|
44
|
+
handleMessage(msg: { v: number; type: string; data: any }): void {
|
|
45
|
+
// Log ALL incoming messages for debugging
|
|
46
|
+
if (this.callbacks.logger) {
|
|
47
|
+
this.callbacks.logger('debug', 'Received WebSocket message', {
|
|
48
|
+
msgType: msg.type,
|
|
49
|
+
msgDataType: msg.data && typeof msg.data === 'object' && 'type' in msg.data ? msg.data.type : 'N/A',
|
|
50
|
+
fullMessage: msg
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Safely check for type in msg.data - guard against primitives
|
|
55
|
+
// Log error if we receive primitive data (indicates server issue)
|
|
56
|
+
if (msg.data && typeof msg.data !== 'object') {
|
|
57
|
+
if (this.callbacks.logger) {
|
|
58
|
+
this.callbacks.logger('error', 'Received primitive msg.data from server', {
|
|
59
|
+
dataType: typeof msg.data,
|
|
60
|
+
data: msg.data,
|
|
61
|
+
fullMessage: msg
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const msgType = (msg.data && typeof msg.data === 'object' && 'type' in msg.data ? msg.data.type : undefined) || msg.type;
|
|
67
|
+
const msgData = msg.data || msg;
|
|
68
|
+
|
|
69
|
+
switch (msgType) {
|
|
70
|
+
case RecognitionResultTypeV1.TRANSCRIPTION:
|
|
71
|
+
this.handleTranscription(msgData as TranscriptionResultV1);
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case RecognitionResultTypeV1.FUNCTION_CALL:
|
|
75
|
+
this.callbacks.onFunctionCall(msgData as FunctionCallResultV1);
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
case RecognitionResultTypeV1.METADATA:
|
|
79
|
+
this.callbacks.onMetadata(msgData as MetadataResultV1);
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case RecognitionResultTypeV1.ERROR:
|
|
83
|
+
this.callbacks.onError(msgData as ErrorResultV1);
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case RecognitionResultTypeV1.CLIENT_CONTROL_MESSAGE:
|
|
87
|
+
this.callbacks.onControlMessage(msgData as ClientControlMessageV1);
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
default:
|
|
91
|
+
// Unknown message type - log if logger available
|
|
92
|
+
if (this.callbacks.logger) {
|
|
93
|
+
this.callbacks.logger('debug', 'Unknown message type', { type: msgType });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Handle transcript message and track performance metrics
|
|
100
|
+
* @param result - The transcription result from the server
|
|
101
|
+
*/
|
|
102
|
+
private handleTranscription(result: TranscriptionResultV1): void {
|
|
103
|
+
// Track time to first transcript
|
|
104
|
+
if (!this.firstTranscriptTime && this.sessionStartTime) {
|
|
105
|
+
this.firstTranscriptTime = Date.now();
|
|
106
|
+
const timeToFirstTranscript = this.firstTranscriptTime - this.sessionStartTime;
|
|
107
|
+
|
|
108
|
+
if (this.callbacks.logger) {
|
|
109
|
+
this.callbacks.logger('debug', 'First transcript received', {
|
|
110
|
+
timeToFirstTranscriptMs: timeToFirstTranscript
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.callbacks.onTranscript(result);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get performance metrics
|
|
120
|
+
*/
|
|
121
|
+
getMetrics() {
|
|
122
|
+
return {
|
|
123
|
+
sessionStartTime: this.sessionStartTime,
|
|
124
|
+
firstTranscriptTime: this.firstTranscriptTime,
|
|
125
|
+
timeToFirstTranscript:
|
|
126
|
+
this.firstTranscriptTime && this.sessionStartTime
|
|
127
|
+
? this.firstTranscriptTime - this.sessionStartTime
|
|
128
|
+
: null
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL Builder for Recognition Client
|
|
3
|
+
* Handles WebSocket URL construction with query parameters
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getRecognitionServiceBase } from '@recog/shared-config';
|
|
7
|
+
import type { GameContextV1 } from '@recog/shared-types';
|
|
8
|
+
import type { RecognitionCallbackUrl } from '../recognition-client.types.js';
|
|
9
|
+
|
|
10
|
+
export interface UrlBuilderConfig {
|
|
11
|
+
url?: string;
|
|
12
|
+
audioUtteranceId: string;
|
|
13
|
+
callbackUrls?: RecognitionCallbackUrl[];
|
|
14
|
+
userId?: string;
|
|
15
|
+
gameSessionId?: string;
|
|
16
|
+
deviceId?: string;
|
|
17
|
+
accountId?: string;
|
|
18
|
+
questionAnswerId?: string;
|
|
19
|
+
platform?: string;
|
|
20
|
+
gameContext?: GameContextV1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build WebSocket URL with all query parameters
|
|
25
|
+
*/
|
|
26
|
+
export function buildWebSocketUrl(config: UrlBuilderConfig): string {
|
|
27
|
+
// Use default URL if not provided (production recognition service)
|
|
28
|
+
const defaultBase = getRecognitionServiceBase('production');
|
|
29
|
+
const baseUrl = config.url || `${defaultBase.wsBase}/ws/v1/recognize`;
|
|
30
|
+
|
|
31
|
+
// Build URL - add all optional identification parameters
|
|
32
|
+
const url = new URL(baseUrl);
|
|
33
|
+
|
|
34
|
+
// Add audioUtteranceId as query parameter (required for server to recognize it)
|
|
35
|
+
url.searchParams.set('audioUtteranceId', config.audioUtteranceId);
|
|
36
|
+
|
|
37
|
+
// Add callback URLs if provided (for server-side notifications)
|
|
38
|
+
if (config.callbackUrls && config.callbackUrls.length > 0) {
|
|
39
|
+
// Serialize as JSON for complex structure
|
|
40
|
+
url.searchParams.set('callbackUrls', JSON.stringify(config.callbackUrls));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Add user/session/device/account identification if provided
|
|
44
|
+
if (config.userId) {
|
|
45
|
+
url.searchParams.set('userId', config.userId);
|
|
46
|
+
}
|
|
47
|
+
if (config.gameSessionId) {
|
|
48
|
+
url.searchParams.set('gameSessionId', config.gameSessionId);
|
|
49
|
+
}
|
|
50
|
+
if (config.deviceId) {
|
|
51
|
+
url.searchParams.set('deviceId', config.deviceId);
|
|
52
|
+
}
|
|
53
|
+
if (config.accountId) {
|
|
54
|
+
url.searchParams.set('accountId', config.accountId);
|
|
55
|
+
}
|
|
56
|
+
if (config.questionAnswerId) {
|
|
57
|
+
url.searchParams.set('questionAnswerId', config.questionAnswerId);
|
|
58
|
+
}
|
|
59
|
+
if (config.platform) {
|
|
60
|
+
url.searchParams.set('platform', config.platform);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Add gameId and gamePhase if provided from gameContext
|
|
64
|
+
if (config.gameContext) {
|
|
65
|
+
url.searchParams.set('gameId', config.gameContext.gameId);
|
|
66
|
+
url.searchParams.set('gamePhase', config.gameContext.gamePhase);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return url.toString();
|
|
70
|
+
}
|