UNPKG

expo-edge-speech

Version:

Text-to-speech library for Expo using Microsoft Edge TTS service

934 lines (933 loc) 39.6 kB
"use strict"; /** * Complete EdgeSpeech WebSocket communication service using protocol knowledge * from previous tasks. Handles Edge TTS WebSocket protocol, binary audio processing, * boundary event parsing, timeout/retry logic, and storage service coordination. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.NetworkService = exports.timingConverter = void 0; const Crypto = __importStar(require("expo-crypto")); const types_1 = require("../types"); const constants_1 = require("../constants"); const audioUtils_1 = require("../utils/audioUtils"); const commonUtils_1 = require("../utils/commonUtils"); // WebSocket Ready State Constants // Define these locally to avoid dependency on global WebSocket in test environments const WS_OPEN = 1; // ============================================================================= // Timing Conversion Utilities // ============================================================================= /** * Timing conversion implementation */ exports.timingConverter = { ticksToMs: (ticks) => { return Math.round(ticks / 10000); // 10,000 ticks = 1 millisecond }, msToTicks: (ms) => { return ms * 10000; // 1 millisecond = 10,000 ticks }, compensateOffset: (rawOffset) => { // Apply 8,750,000 ticks (875ms) padding compensation return Math.max(0, rawOffset - 8750000); }, }; // ============================================================================= // Authentication Utilities // ============================================================================= /** * Generate Windows file time for authentication */ function generateWindowsFileTime() { const now = new Date(); const unixTime = Math.floor(now.getTime() / 1000); // Windows file time: 100-nanosecond intervals since 1601-01-01 return (unixTime + constants_1.SEC_MS_GEC_GENERATION.WIN_EPOCH) * 10000000; } /** * Generate SHA-256 hash */ async function generateSecMSGECToken() { const ticks = generateWindowsFileTime(); const hashInput = `${ticks}MSEdgeSpeechTTS`; const token = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, hashInput); return token.toUpperCase(); } // ============================================================================= // NetworkService Class // ============================================================================= /** * Network service for Edge TTS WebSocket communication */ class NetworkService { config; storageService; // <X-RequestId, SynthesisSession> activeSessions = new Map(); connections = new Map(); debugLog = false; constructor(storageService, config = {}) { this.storageService = storageService; this.config = { maxRetries: config.maxRetries ?? constants_1.CONNECTION_LIFECYCLE.RETRY_LIMITS.CONNECTION_ATTEMPTS, baseRetryDelay: config.baseRetryDelay ?? 1000, maxRetryDelay: config.maxRetryDelay ?? 10000, connectionTimeout: config.connectionTimeout ?? constants_1.CONNECTION_LIFECYCLE.TIMEOUTS.CONNECTION_ESTABLISHMENT, gracefulCloseTimeout: config.gracefulCloseTimeout ?? constants_1.CONNECTION_LIFECYCLE.TIMEOUTS.GRACEFUL_CLOSE, enableDebugLogging: config.enableDebugLogging ?? false, }; this.debugLog = this.config.enableDebugLogging || false; } // =========================================================================== // Public API Methods // =========================================================================== /** * Synthesize SSML to speech using Edge TTS */ async synthesizeText(ssml, options, // Add clientSessionId and connectionId parameters clientSessionId, connectionId) { const requestId = clientSessionId; this.log(`Starting synthesis for request ${requestId} using connection ${connectionId}`); // Validate input if (!ssml || ssml.trim().length === 0) { throw new Error("SSML cannot be empty"); } if (ssml.length > constants_1.AUDIO_CONFIG.maxBufferSize) { throw new Error(`SSML length exceeds maximum of ${constants_1.AUDIO_CONFIG.maxBufferSize} characters`); } // Create synthesis request const request = { text: ssml, // Store SSML in text field for compatibility options, requestId, // This is clientSessionId connectionId, // This is the passed-in connectionId }; // Create synthesis session return new Promise((resolve, reject) => { const session = { request, response: { audioChunks: [], boundaries: [], duration: 0, completed: false, }, createdAt: new Date(), promise: { resolve, reject }, }; // Set timeout for total synthesis session.timeoutHandle = setTimeout(() => { this.handleSynthesisTimeout(requestId); }, constants_1.CONNECTION_LIFECYCLE.TIMEOUTS.TOTAL_SYNTHESIS); this.activeSessions.set(requestId, session); // Start synthesis with retry logic this.performSynthesisWithRetry(session, 0).catch((error) => { this.cleanupSession(requestId); reject(error); }); }); } /** * Close all connections and cleanup */ async close() { this.log("Closing network service"); // Clear all timeouts for (const session of this.activeSessions.values()) { if (session.timeoutHandle) { clearTimeout(session.timeoutHandle); } } // Close all connections aggressively const closePromises = []; for (const connection of this.connections.values()) { closePromises.push(this.forceCloseConnection(connection.id)); } await Promise.all(closePromises); // Clear collections this.activeSessions.clear(); this.connections.clear(); } // =========================================================================== // Connection Management // =========================================================================== /** * Create and establish WebSocket connection */ async createConnection(connectionId) { this.log(`Creating connection ${connectionId}`); // Generate authentication token const secMsGecToken = await generateSecMSGECToken(); // Build WebSocket URL with parameters const url = constants_1.EDGE_TTS_WEBSOCKET_URL_TEMPLATE.replace("{secMsGec}", secMsGecToken) .replace("{secMsGecVersion}", constants_1.SEC_MS_GEC_VERSION) .replace("{connectionId}", connectionId); this.log(`WebSocket URL: ${url}`); // Create connection object const connection = { id: connectionId, websocket: null, state: types_1.ConnectionState.Connecting, createdAt: new Date(), lastActivity: new Date(), }; this.connections.set(connectionId, connection); return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { reject(new types_1.WebSocketError(`Connection timeout after ${this.config.connectionTimeout}ms`)); }, this.config.connectionTimeout); try { // Create WebSocket with authentication headers required by Edge TTS protocol // Note: React Native WebSocket headers are handled differently than DOM WebSocket const websocket = new WebSocket(url); websocket.binaryType = "arraybuffer"; // Set up websocket handlers once with state-aware logic connection.websocket = websocket; this.setupWebSocketHandlers(connection, { resolve, reject, timeoutHandle, }); } catch (error) { clearTimeout(timeoutHandle); reject(new types_1.WebSocketError(`Failed to create WebSocket: ${error}`)); } }); } /** * Setup WebSocket event handlers with state-aware logic */ setupWebSocketHandlers(connection, // connection.id is the connectionId, which is also the clientSessionId/requestId for the messages on this WebSocket connectionContext) { if (!connection.websocket) return; // Single onopen handler for connection establishment connection.websocket.onopen = () => { this.log(`Connection ${connection.id} established`); if (connectionContext) { clearTimeout(connectionContext.timeoutHandle); this.setConnectionState(connection, types_1.ConnectionState.Connected); connection.lastActivity = new Date(); connectionContext.resolve(connection); } }; // State-aware onmessage handler connection.websocket.onmessage = (event) => { connection.lastActivity = new Date(); this.handleWebSocketMessage(connection, event); }; // State-aware onerror handler connection.websocket.onerror = (event) => { this.log(`WebSocket error on connection ${connection.id}:`, event); // During connection establishment if (connectionContext && connection.state === types_1.ConnectionState.Connecting) { clearTimeout(connectionContext.timeoutHandle); this.setConnectionState(connection, types_1.ConnectionState.Error); connectionContext.reject(new types_1.WebSocketError(`WebSocket connection error: ${event.message}`)); } else { // During communication phase this.handleConnectionError(connection, new types_1.WebSocketError(`WebSocket error: ${event.message}`)); } }; // State-aware onclose handler connection.websocket.onclose = (event) => { this.log(`WebSocket closed on connection ${connection.id}: ${event.code} ${event.reason}`); // During connection establishment if (connectionContext && connection.state === types_1.ConnectionState.Connecting) { clearTimeout(connectionContext.timeoutHandle); this.setConnectionState(connection, types_1.ConnectionState.Disconnected); if (event.code !== 1000) { connectionContext.reject(new types_1.WebSocketError(`WebSocket closed unexpectedly: ${event.code} ${event.reason}`)); } } else { // During communication phase connection.state = types_1.ConnectionState.Disconnected; if (event.code !== 1000) { this.handleConnectionError(connection, new types_1.WebSocketError(`WebSocket closed unexpectedly: ${event.code} ${event.reason}`)); } } }; } /** * Close a WebSocket connection */ async closeConnection(connectionId) { const connection = this.connections.get(connectionId); if (!connection) return; this.log(`Closing connection ${connectionId}`); if (connection.websocket && connection.websocket.readyState === WS_OPEN) { connection.websocket.close(1000, "Normal closure"); // Wait for graceful close with configurable timeout await new Promise((resolve) => { const timeoutHandle = setTimeout(() => { resolve(); }, this.config.gracefulCloseTimeout); if (connection.websocket) { connection.websocket.onclose = () => { clearTimeout(timeoutHandle); resolve(); }; } }); } connection.state = types_1.ConnectionState.Disconnected; this.connections.delete(connectionId); } /** * Force close a WebSocket connection without waiting for graceful shutdown */ async forceCloseConnection(connectionId) { const connection = this.connections.get(connectionId); if (!connection) return; this.log(`Force closing connection ${connectionId}`); if (connection.websocket) { try { // Clear all event handlers to prevent any callbacks after cleanup connection.websocket.onopen = null; connection.websocket.onmessage = null; connection.websocket.onerror = null; connection.websocket.onclose = null; // Force close the WebSocket immediately if (connection.websocket.readyState === WS_OPEN || connection.websocket.readyState === 0 /* CONNECTING */) { connection.websocket.close(1000, "Force close"); } } catch (error) { // Ignore any errors during force close this.log(`Error during force close: ${error}`); } } connection.state = types_1.ConnectionState.Disconnected; this.connections.delete(connectionId); } /** * Send speech configuration message */ async sendSpeechConfig(connection, requestId) { this.log(`Sending speech config for request ${requestId}`); const headers = { "X-RequestId": requestId, "X-Timestamp": (0, commonUtils_1.generateTimestamp)(), "Content-Type": constants_1.CONTENT_TYPES.JSON, Path: constants_1.MESSAGE_PATHS.SPEECH_CONFIG, }; const configBody = { context: { synthesis: { audio: { metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: true, }, outputFormat: constants_1.AUDIO_CONFIG.defaultFormat, }, }, }, }; const message = this.formatTextMessage(headers, JSON.stringify(configBody)); await this.sendWebSocketMessage(connection, message); } /** * Send SSML synthesis request */ async sendSSMLRequest(connection, session) { this.log(`Sending SSML request for ${session.request.requestId}`); // Use SSML directly from session text field (now contains SSML instead of text) const ssml = session.request.text; const headers = { "X-RequestId": session.request.requestId, "X-Timestamp": (0, commonUtils_1.generateTimestamp)(), "Content-Type": constants_1.CONTENT_TYPES.SSML, Path: constants_1.MESSAGE_PATHS.SSML, }; const message = this.formatTextMessage(headers, ssml); await this.sendWebSocketMessage(connection, message); } /** * Format text message with headers */ formatTextMessage(headers, body) { const headerLines = Object.entries(headers) .map(([key, value]) => `${key}${constants_1.MESSAGE_FORMAT.HEADER_VALUE_SEPARATOR}${value}`) .join(constants_1.MESSAGE_FORMAT.LINE_ENDING); return `${headerLines}${constants_1.MESSAGE_FORMAT.HEADER_SEPARATOR}${body}`; } /** * Send WebSocket message */ async sendWebSocketMessage(connection, message) { if (!connection.websocket || connection.websocket.readyState !== WS_OPEN) { throw new types_1.WebSocketError("WebSocket is not connected"); } try { connection.websocket.send(message); connection.lastActivity = new Date(); this.log(`Sent message on connection ${connection.id}`); } catch (error) { throw new types_1.WebSocketError(`Failed to send WebSocket message: ${error}`); } } // =========================================================================== // Message Handling // =========================================================================== /** * Handle incoming WebSocket message */ handleWebSocketMessage(connection, // This connection object contains the id we need event) { try { if (typeof event.data === "string") { this.handleTextMessage(connection, event.data); } else if (event.data instanceof ArrayBuffer) { this.handleBinaryMessage(connection, event.data); } else { this.log(`Unknown message type from connection ${connection.id}`); } } catch (error) { this.log(`Error handling message from connection ${connection.id}:`, error); this.handleConnectionError(connection, error); } } /** * Handle text (JSON) message */ handleTextMessage(connection, data) { this.log(`Received text message on connection ${connection.id}`); try { // Parse message headers and body const headerEndIndex = data.indexOf(constants_1.MESSAGE_FORMAT.HEADER_SEPARATOR); if (headerEndIndex === -1) { throw new types_1.UnexpectedResponse("Invalid message format: missing header separator"); } const headerSection = data.substring(0, headerEndIndex); const bodySection = data.substring(headerEndIndex + constants_1.MESSAGE_FORMAT.HEADER_SEPARATOR.length); // Parse headers const headers = {}; const headerLines = headerSection.split(constants_1.MESSAGE_FORMAT.LINE_ENDING); for (const line of headerLines) { const separatorIndex = line.indexOf(":"); if (separatorIndex > 0) { const key = line.substring(0, separatorIndex).trim(); const value = line.substring(separatorIndex + 1).trim(); if (key.replace(/-/g, "").toLowerCase() === "xrequestid") { headers["X-RequestId"] = value; } else { headers[key] = value; } } } // Route message based on path const path = headers.Path; const requestId = headers["X-RequestId"]; if (this.debugLog) { console.debug(`[NetworkService] Processing message: path=${path}, requestId=${requestId}`); } switch (path) { case constants_1.MESSAGE_PATHS.TURN_START: this.log(`Received Path:${constants_1.MESSAGE_PATHS.TURN_START} for request (X-RequestId: ${requestId})`); // Note: Connection buffer is created by ConnectionManager // to avoid duplicate buffer creation issues break; case constants_1.MESSAGE_PATHS.AUDIO_METADATA: this.handleAudioMetadata(requestId, connection, bodySection); break; case constants_1.MESSAGE_PATHS.RESPONSE: this.handleResponse(requestId, connection, bodySection); break; case constants_1.MESSAGE_PATHS.TURN_END: this.handleTurnEnd(requestId, connection); break; default: this.log(`Unknown message path: ${path}`); break; } } catch (error) { throw new types_1.UnexpectedResponse(`Failed to parse text message: ${error}`); } } /** * Handle binary (audio) message */ handleBinaryMessage(connection, data) { try { // Parse binary message structure const binaryMessage = (0, audioUtils_1.parseEdgeTTSBinaryMessage)(data); if (!binaryMessage) { throw new types_1.UnexpectedResponse("Failed to parse binary message: invalid format"); } // Extract request ID from headers const requestId = binaryMessage.header["X-RequestId"]; if (!requestId) { throw new types_1.UnexpectedResponse("Binary message missing X-RequestId header"); } // Get session const session = this.activeSessions.get(requestId); if (!session) { this.log(`Received audio for unknown session. X-RequestId: ${requestId}`); return; } this.log(`Received binary message for request ${requestId}, audio data length: ${binaryMessage.audioData.byteLength}`); // Add audio chunk to session const audioChunk = new Uint8Array(binaryMessage.audioData); session.response.audioChunks.push(audioChunk); // Add audio chunk to storage buffer (ConnectionManager creates buffer) this.storageService.addAudioChunk(session.request.connectionId, audioChunk); // this.log( // `Added audio chunk for session ${requestId}, size: ${audioChunk.length}`, // ); } catch (error) { throw new types_1.UnexpectedResponse(`Failed to parse binary message: ${error}`); } } /** * Handle turn start message */ handleTurnStart(requestId, connection) { const session = this.activeSessions.get(requestId); if (!session) { this.log(`No active session for turn.start with X-RequestId ${requestId}`); return; } // console.log("called from handle turn start"); this.log(`Turn started for X-RequestId ${requestId}`); connection.lastActivity = new Date(); // // Initialize storage buffer for this connection // try { // // console.log("called from handle turn start"); // this.storageService.createConnectionBuffer(session.request.connectionId); // } catch (error) { // // Buffer might already exist, which is okay // if (!(error as Error).message.includes("Buffer already exists")) { // throw error; // } // } } /** * Handle audio metadata (boundary events) */ handleAudioMetadata(requestId, connection, bodyData) { this.log(`Audio metadata for X-RequestId ${requestId}`); try { const session = this.activeSessions.get(requestId); if (!session) { this.log(`Received metadata for unknown request. X-RequestId ${requestId}`); return; } connection.lastActivity = new Date(); // Parse metadata JSON const metadata = JSON.parse(bodyData); if (metadata.Metadata && Array.isArray(metadata.Metadata)) { for (const boundaryData of metadata.Metadata) { if (boundaryData.Type === "WordBoundary") { // Initialize boundary position tracking if not set if (session.lastBoundaryPosition === undefined) { session.lastBoundaryPosition = 0; } // Process boundary event with original text and current position const result = this.processBoundaryEvent(boundaryData, session.request.text, session.lastBoundaryPosition); // Extract the boundary and update position tracking const { boundary, nextPosition } = result; session.lastBoundaryPosition = nextPosition; // Add to session boundaries session.response.boundaries.push(boundary); // Call boundary callback if provided if (session.request.options.onBoundary) { session.request.options.onBoundary(boundary); } } } } } catch (error) { this.log(`Error processing audio metadata: ${error}`); } } /** * Handle response message */ handleResponse(requestId, connection, bodyData) { this.log(`Received Path:response for request (X-RequestId: ${requestId})`); try { const session = this.activeSessions.get(requestId); if (!session) { this.log(`Received response for unknown session. X-RequestId: ${requestId}`); return; } connection.lastActivity = new Date(); // Parse response JSON const responseData = JSON.parse(bodyData); // Log the response data for debugging this.log(`Path:response data:`, responseData); // The response message typically contains context and audio stream information // Based on the sample data: {"context":{"serviceTag":"..."},"audio":{"type":"inline","streamId":"..."}} // This is informational and doesn't require special handling beyond logging if (responseData.context?.serviceTag) { this.log(`Path:response Service tag: ${responseData.context.serviceTag}`); } if (responseData.audio?.streamId) { this.log(`Path:response Audio stream ID: ${responseData.audio.streamId}`); } } catch (error) { this.log(`Error processing response message: ${error}`); } } /** * Handle turn end message */ handleTurnEnd(requestId, connection) { this.log(`Turn end for X-RequestId ${requestId}`); const session = this.activeSessions.get(requestId); if (!session) { this.log(`Received turn end for unknown session. X-RequestId: ${requestId}`); return; } connection.lastActivity = new Date(); // Mark synthesis as completed session.response.completed = true; // Calculate total duration session.response.duration = this.calculateAudioDuration(session.response.audioChunks); // Complete the synthesis this.completeSynthesis(requestId); } /** * Process boundary event data */ processBoundaryEvent(boundaryData, originalText, currentPosition = 0) { // Extract word text from boundary data const wordText = boundaryData.Data.text?.Text || ""; const wordLength = boundaryData.Data.text?.Length || wordText.length; // Find the word in the original text starting from current position const charIndex = this.findWordInText(originalText, wordText, currentPosition); // Use the actual word length, but ensure it doesn't exceed text boundaries const charLength = Math.min(wordLength, originalText.length - charIndex); // Calculate next search position (after this word) const nextPosition = charIndex + charLength; return { boundary: { charIndex, charLength, }, nextPosition, }; } /** * Find the next occurrence of a word in text starting from a given position */ findWordInText(text, word, startPosition) { if (!word || startPosition >= text.length) { return startPosition; } // Convert both to lowercase for case-insensitive matching const lowerText = text.toLowerCase(); const lowerWord = word.toLowerCase(); // Find the word starting from the current position const index = lowerText.indexOf(lowerWord, startPosition); if (index !== -1) { return index; } // If exact match fails, try to find the word ignoring punctuation // This handles cases where Edge TTS normalizes text differently for (let i = startPosition; i <= text.length - word.length; i++) { const textSegment = text.slice(i, i + word.length).toLowerCase(); // Remove punctuation and whitespace for comparison const cleanTextSegment = textSegment.replace(/[^\w]/g, ""); const cleanWord = lowerWord.replace(/[^\w]/g, ""); if (cleanTextSegment === cleanWord) { return i; } } // If still no match found, return current position to avoid errors return startPosition; } /** * Calculate total audio duration */ calculateAudioDuration(audioChunks) { // Estimate duration based on audio format (MP3 24kHz) const totalBytes = audioChunks.reduce((sum, chunk) => sum + chunk.length, 0); const bitRate = constants_1.AUDIO_CONFIG.bitRate * 1000; // Convert to bits per second const durationSeconds = (totalBytes * 8) / bitRate; return Math.round(durationSeconds * 1000); // Convert to milliseconds } // =========================================================================== // Synthesis Flow Management // =========================================================================== /** * Perform synthesis with retry logic */ async performSynthesisWithRetry(session, attempt) { const { requestId, connectionId } = session.request; this.log(`Attempt ${attempt + 1} for synthesis request ${requestId} using connection ${connectionId}`); try { let connection = this.connections.get(connectionId); if (!connection || !connection.websocket || connection.websocket.readyState !== WS_OPEN) { if (connection) { this.log(`Connection ${connectionId} exists but is not open (state: ${connection.websocket?.readyState}). Recreating.`); if (connection.websocket) { connection.websocket.onopen = null; connection.websocket.onmessage = null; connection.websocket.onerror = null; connection.websocket.onclose = null; try { connection.websocket.close(); } catch (e) { this.log(`Error closing stale websocket for ${connectionId}: ${e}`); } } this.connections.delete(connectionId); } this.log(`Creating new connection ${connectionId} for request ${requestId}`); connection = await this.createConnection(connectionId); } else { this.log(`Reusing existing open connection ${connectionId} for request ${requestId}`); } // Send speech config message await this.sendSpeechConfig(connection, requestId); // Send SSML request await this.sendSSMLRequest(connection, session); // Set synthesis state connection.state = types_1.ConnectionState.Synthesizing; this.log(`Synthesis started for request ${requestId}`); } catch (error) { // Clean up connection on error await this.closeConnection(requestId); throw error; } } /** * Perform single synthesis attempt */ async performSynthesis(session) { const { request } = session; try { // Create connection const connection = await this.createConnection(request.connectionId); // Send speech configuration await this.sendSpeechConfig(connection, request.requestId); // Send SSML request await this.sendSSMLRequest(connection, session); // Set synthesis state connection.state = types_1.ConnectionState.Synthesizing; this.log(`Synthesis started for request ${request.requestId}`); } catch (error) { // Clean up connection on error await this.closeConnection(request.connectionId); throw error; } } /** * Complete synthesis and resolve promise */ completeSynthesis(requestId) { const session = this.activeSessions.get(requestId); if (!session) return; this.log(`Completing synthesis for request ${requestId}`); // Clear timeout if (session.timeoutHandle) { clearTimeout(session.timeoutHandle); } // Check if we have audio data if (session.response.audioChunks.length === 0) { session.promise.reject(new types_1.NoAudioReceived("No audio data received from Edge TTS service")); this.cleanupSession(requestId); return; } // Complete storage coordination this.storageService.markConnectionCompleted(session.request.connectionId); // Call completion callback if (session.request.options.onDone) { try { session.request.options.onDone(); } catch (error) { this.log(`Error in onDone callback:`, error); } } // Resolve synthesis promise session.promise.resolve(session.response); // Cleanup this.cleanupSession(requestId); } /** * Handle synthesis timeout */ handleSynthesisTimeout(requestId) { this.log(`Synthesis timeout for request ${requestId}`); const session = this.activeSessions.get(requestId); if (!session) return; session.promise.reject(new Error(`Synthesis timeout after ${constants_1.CONNECTION_LIFECYCLE.TIMEOUTS.TOTAL_SYNTHESIS}ms`)); this.cleanupSession(requestId); } /** * Handle connection error */ handleConnectionError(connection, error) { this.log(`Connection error on ${connection.id}:`, error); // Find sessions using this connection for (const [requestId, session] of this.activeSessions.entries()) { if (session.request.connectionId === connection.id) { // Call error callback if (session.request.options.onError) { try { session.request.options.onError(error); } catch (callbackError) { this.log(`Error in onError callback:`, callbackError); } } // Reject synthesis promise session.promise.reject(error); this.cleanupSession(requestId); } } // Close connection this.closeConnection(connection.id).catch((closeError) => { this.log(`Error closing connection ${connection.id}:`, closeError); }); } /** * Cleanup synthesis session */ cleanupSession(requestId) { const session = this.activeSessions.get(requestId); if (!session) return; this.log(`Cleaning up session for request ${requestId}`); // Clear timeout if (session.timeoutHandle) { clearTimeout(session.timeoutHandle); } // Close connection this.closeConnection(session.request.connectionId).catch((error) => { this.log(`Error closing connection during cleanup:`, error); }); // Remove from active sessions this.activeSessions.delete(requestId); } // =========================================================================== // Utility Methods // =========================================================================== /** * Debug logging helper */ log(message, ...args) { if (this.debugLog) { console.log(`[NetworkService] ${message}`, ...args); } } /** * Get service statistics */ getStats() { return { activeSessions: this.activeSessions.size, activeConnections: this.connections.size, connections: Array.from(this.connections.values()).map((conn) => ({ id: conn.id, state: conn.state, createdAt: conn.createdAt, lastActivity: conn.lastActivity, })), }; } // =========================================================================== // StateManager Integration Methods // =========================================================================== /** * Initialize the service (for StateManager integration) */ async initialize() { this.log("NetworkService initialized"); // No specific initialization needed for network service } /** * Cleanup the service (for StateManager integration) */ async cleanup() { this.log("NetworkService cleanup"); await this.close(); } /** * Register callback for connection state changes (for StateManager integration) */ onConnectionStateChange(callback) { this.onConnectionStateChangeCallback = callback; } // Callback for connection state changes onConnectionStateChangeCallback = null; /** * Set connection state and trigger StateManager callback */ setConnectionState(connection, newState) { connection.state = newState; if (this.onConnectionStateChangeCallback) { this.onConnectionStateChangeCallback(connection.id, newState); } } } exports.NetworkService = NetworkService; /** * Default network service instance */ exports.default = NetworkService;