@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.
@@ -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
+ }