expo-edge-speech
Version:
Text-to-speech library for Expo using Microsoft Edge TTS service
726 lines (725 loc) • 31.1 kB
JavaScript
"use strict";
/**
* Coordinates WebSocket connections via Network Service with audio playback via Audio Service.
* Handles connection pooling using Storage Service, processes audio data streams,
* manages concurrent connections using State Management, and implements circuit breaker pattern.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConnectionManager = void 0;
const types_1 = require("../types");
const react_native_1 = require("react-native");
const constants_1 = require("../constants");
// =============================================================================
// Connection Manager Configuration and Types
// =============================================================================
/**
* Circuit breaker state
*/
var CircuitBreakerState;
(function (CircuitBreakerState) {
CircuitBreakerState["Closed"] = "closed";
CircuitBreakerState["Open"] = "open";
CircuitBreakerState["HalfOpen"] = "halfopen";
})(CircuitBreakerState || (CircuitBreakerState = {}));
// =============================================================================
// Connection Manager Implementation
// =============================================================================
/**
* Connection Manager - coordinates all services for speech synthesis
*
* Responsibilities:
* - Coordinate WebSocket connections via Network Service with audio playback via Audio Service
* - Handle connection pooling using Storage Service connection management
* - Implement connection lifecycle management using all service capabilities
* - Process audio data streams coordinating Network Service, Storage Service, and Audio Utilities
* - Handle connection errors and recovery using Network Service error handling
* - Manage concurrent connections using State Management
* - Coordinate real-time streaming between all services
* - Implement circuit breaker pattern using complete error types and constants
*/
class ConnectionManager {
stateManager;
networkService;
audioService;
storageService;
config;
circuitBreakerState;
activeConnections;
activeSessions; // sessionId -> connectionId mapping
connectionQueue;
globalConnectionState = types_1.ConnectionState.Disconnected;
isShuttingDown = false;
// Circuit breaker tracking
failureCount = 0;
lastFailureTime = 0;
successCount = 0;
// App state handler management to prevent memory leaks
appStateSubscription;
appStateHandlerAdded = false;
constructor(stateManager, networkService, audioService, storageService, config) {
this.stateManager = stateManager;
this.networkService = networkService;
this.audioService = audioService;
this.storageService = storageService;
// Initialize configuration with defaults
this.config = {
maxConnections: constants_1.CONNECTION_LIFECYCLE.POOL_MANAGEMENT.MAX_POOL_SIZE,
connectionTimeout: constants_1.CONNECTION_LIFECYCLE.TIMEOUTS.CONNECTION_ESTABLISHMENT,
circuitBreaker: {
failureThreshold: 5,
recoveryTimeout: 30000, // 30 seconds
testRequestLimit: 3,
},
poolingEnabled: false, // Disabled by default to enforce connection limits
...config,
};
this.circuitBreakerState = CircuitBreakerState.Closed;
this.activeConnections = new Map();
this.activeSessions = new Map();
this.connectionQueue = [];
this.setupEventHandlers();
}
// =============================================================================
// Public API - Session-based Methods
// =============================================================================
/**
* Start speech synthesis and return session ID
*/
async startSynthesis(ssml, // Changed from text to ssml
options) {
// Check circuit breaker state
if (!this.isCircuitClosed()) {
throw this.createSpeechError("CircuitBreakerOpen", "Service temporarily unavailable due to repeated failures", "CONNECTION_CIRCUIT_BREAKER_OPEN");
}
// Check connection limits
const maxConnections = this.config.maxConnections;
if (this.activeConnections.size >= maxConnections) {
if (this.config.poolingEnabled) {
// Queue the request
return new Promise((resolve, reject) => {
this.connectionQueue.push({
options: { ...options, ssml }, // Changed from text to ssml
resolve,
reject,
});
});
}
else {
throw this.createSpeechError("ConnectionLimitExceeded", "Maximum concurrent connections reached", "CONNECTION_LIMIT_EXCEEDED");
}
}
try {
const result = await this.createAndManageConnection(ssml, options); // Changed from text to ssml
this.recordSuccess();
return result.sessionId;
}
catch (error) {
this.recordFailure();
throw error;
}
}
/**
* Stop speech synthesis for a specific session
*/
async stopSynthesis(sessionId) {
const connectionId = this.activeSessions.get(sessionId);
if (!connectionId) {
throw this.createSpeechError("SessionNotFound", "Session not found", "SESSION_NOT_FOUND");
}
await this.terminateConnection(connectionId);
this.activeSessions.delete(sessionId);
// Trigger callback
const coordinator = this.activeConnections.get(connectionId);
if (coordinator?.options.onStopped) {
coordinator.options.onStopped();
}
}
/**
* Pause speech synthesis for a specific session
*/
async pauseSynthesis(sessionId) {
const connectionId = this.activeSessions.get(sessionId);
if (!connectionId) {
throw this.createSpeechError("SessionNotFound", "Session not found", "SESSION_NOT_FOUND");
}
await this.audioService.pause();
// State coordination happens through service integration
// Trigger callback
const coordinator = this.activeConnections.get(connectionId);
if (coordinator?.options.onPause) {
coordinator.options.onPause();
}
}
/**
* Resume speech synthesis for a specific session
*/
async resumeSynthesis(sessionId) {
const connectionId = this.activeSessions.get(sessionId);
if (!connectionId) {
throw this.createSpeechError("SessionNotFound", "Session not found", "SESSION_NOT_FOUND");
}
await this.audioService.resume();
// State coordination happens through service integration
// Trigger callback
const coordinator = this.activeConnections.get(connectionId);
if (coordinator?.options.onResume) {
coordinator.options.onResume();
}
}
/**
* Get connection pool status
*/
getConnectionPoolStatus() {
const maxConnections = this.config.maxConnections;
return {
activeConnections: this.activeConnections.size,
maxConnections: maxConnections,
availableConnections: maxConnections - this.activeConnections.size,
circuitBreakerState: this.circuitBreakerState,
};
}
/**
* Shutdown connection manager and clean up all resources
*/
async shutdown() {
this.isShuttingDown = true;
await this.stopAllConnections();
this.activeSessions.clear();
// Cleanup AppState subscription (React Native pattern)
// Following React Native documentation for proper subscription cleanup
if (this.appStateSubscription && this.appStateHandlerAdded) {
try {
// Remove the AppState event listener using NativeEventSubscription.remove()
// This is the documented React Native pattern for cleanup
this.appStateSubscription.remove();
this.appStateSubscription = undefined;
this.appStateHandlerAdded = false;
}
catch (error) {
console.warn("Failed to remove AppState listener:", error);
// Reset subscription state even if removal fails to prevent memory leaks
this.appStateSubscription = undefined;
this.appStateHandlerAdded = false;
}
}
}
/**
* Stop all active connections and clear queue
*/
async stopAllConnections() {
// Stop all active connections
const stopPromises = Array.from(this.activeConnections.values()).map((coordinator) => this.terminateConnection(coordinator.connectionId));
await Promise.allSettled(stopPromises);
// Clear connection queue
this.connectionQueue.forEach(({ reject }) => {
reject(this.createSpeechError("ConnectionStopped", "Connection stopped by user request", "CONNECTION_STOPPED"));
});
this.connectionQueue.length = 0;
// Clear all connections on shutdown
this.activeConnections.clear();
this.globalConnectionState = types_1.ConnectionState.Disconnected;
}
/**
* Get connection manager status
*/
getStatus() {
return {
activeConnections: this.activeConnections.size,
queuedConnections: this.connectionQueue.length,
circuitBreakerState: this.circuitBreakerState,
failureCount: this.failureCount,
};
}
// =============================================================================
// Connection Lifecycle Management
// =============================================================================
/**
* Create and manage a complete connection lifecycle
*/
async createAndManageConnection(ssml, options) {
const connectionId = options.connectionId; // Use provided connectionId
// Use clientSessionId from options as it's now required.
const localSessionId = options.clientSessionId;
// Initialize streaming coordinator
const coordinator = {
connectionId,
state: types_1.ConnectionState.Connecting,
audioChunks: [],
totalAudioSize: 0,
options,
};
this.activeConnections.set(connectionId, coordinator);
this.activeSessions.set(localSessionId, connectionId);
try {
// Update state
coordinator.state = types_1.ConnectionState.Connecting;
this.globalConnectionState = types_1.ConnectionState.Connecting;
// State coordination happens through service integration
// Initialize storage for this connection
this.storageService.createConnectionBuffer(connectionId);
// Setup audio streaming coordination
await this.setupAudioStreamingCoordination(coordinator);
// Establish connection via Network Service
await this.establishNetworkConnection(connectionId, ssml, options);
coordinator.state = types_1.ConnectionState.Connected;
this.globalConnectionState = types_1.ConnectionState.Connected;
return {
sessionId: localSessionId,
connectionId: connectionId,
};
}
catch (error) {
try {
// If handleConnectionError doesn't throw, the retry was successful or error handled
await this.handleConnectionError(connectionId, error);
// If error handled and connection established, return the IDs
return {
sessionId: localSessionId,
connectionId: connectionId,
};
}
catch (handlerError) {
// If handleConnectionError throws, the error handling failed
this.activeSessions.delete(localSessionId); // Clean up mapping on final failure
throw handlerError;
}
}
}
/**
* Setup audio streaming coordination between services
*/
async setupAudioStreamingCoordination(coordinator) {
// Audio service will be configured when playback starts
// Store audio configuration in coordinator
coordinator.audioConfig = {
connectionId: coordinator.connectionId,
options: coordinator.options,
};
// Setup audio data processing pipeline
this.setupAudioDataPipeline(coordinator);
}
/**
* Setup audio data processing pipeline
*/
setupAudioDataPipeline(coordinator) {
// Audio data will flow: Network Service → Storage Service → Audio Service
// This pipeline is coordinated through handleAudioData method
}
/**
* Establish WebSocket connection via Network Service
*/
async establishNetworkConnection(connectionId, ssml, options) {
// Process synthesis using traditional batch processing approach
const response = await this.networkService.synthesizeText(ssml, options, options.clientSessionId, connectionId);
// Process any word boundaries
for (const boundary of response.boundaries) {
this.handleBoundaryEvent(connectionId, boundary);
}
// After all chunks are collected, trigger batch audio processing
await this.streamAudioToService(connectionId);
}
/**
* Terminate connection and cleanup
*/
async terminateConnection(connectionId) {
const coordinator = this.activeConnections.get(connectionId);
if (!coordinator)
return;
try {
// Stop audio playback
await this.audioService.stop();
// Network Service cleanup happens automatically in synthesize method
// Cleanup storage
this.storageService.cleanupConnection(connectionId);
}
catch (error) {
console.warn(`Error during connection cleanup for ${connectionId}:`, error);
}
finally {
await this.cleanupConnection(connectionId);
}
}
/**
* Cleanup connection resources
*/
async cleanupConnection(connectionId) {
// Remove from active connections
this.activeConnections.delete(connectionId);
// Update state if no more connections
if (this.activeConnections.size === 0) {
this.globalConnectionState = types_1.ConnectionState.Disconnected;
}
// Process queued connections if space available
await this.processConnectionQueue();
}
// =============================================================================
// Audio Data Streaming Coordination
// =============================================================================
/**
* Handle incoming audio data from Network Service
* Coordinates data flow between Network Service, Storage Service, and Audio Service
*/
async handleAudioData(connectionId, audioData) {
const coordinator = this.activeConnections.get(connectionId);
if (!coordinator) {
console.warn(`Received audio data for unknown connection: ${connectionId}`);
return;
}
try {
// Update coordinator tracking
coordinator.audioChunks.push(audioData);
coordinator.totalAudioSize += audioData.length;
// Store audio data in StorageService.
// The actual mechanism for this depends on StorageService's API.
// For now, we assume StorageService is aware of the coordinator.audioChunks
// or there's a direct method like:
// this.storageService.addAudioChunk(connectionId, audioData);
// DO NOT stream to Audio Service for playback on every chunk.
// Playback will be triggered once all chunks are received.
// await this.streamAudioToService(connectionId);
}
catch (error) {
console.error(`Error handling audio data for connection ${connectionId}:`, error);
// For storage errors, just trigger the error callback but don't terminate the connection
// This allows the connection to continue processing other audio chunks
if (coordinator.options.onError) {
coordinator.options.onError(error);
}
}
}
/**
* Store audio data (new helper method)
*/
async storeAudioData(connectionId, audioData) {
const coordinator = this.activeConnections.get(connectionId);
if (!coordinator) {
console.warn(`Attempted to store audio data for unknown connection: ${connectionId}`);
return;
}
try {
// Update coordinator tracking
coordinator.audioChunks.push(audioData);
coordinator.totalAudioSize += audioData.length;
// Store audio data in StorageService for audio playback
await this.storageService.addAudioChunk(connectionId, audioData);
}
catch (error) {
console.error(`Error storing audio data for connection ${connectionId}:`, error);
if (coordinator.options.onError) {
coordinator.options.onError(error);
}
}
}
/**
* Stream audio data to Audio Service
*/
async streamAudioToService(connectionId) {
const coordinator = this.activeConnections.get(connectionId);
if (!coordinator)
return;
// In batch processing mode, we use AudioService.speak() which supports callbacks
// Wrap the user's onDone callback to include connection cleanup
const wrappedOptions = {
...coordinator.options,
onDone: () => {
// Call user's callback first
if (coordinator.options.onDone) {
coordinator.options.onDone();
}
// Then clean up the connection to enable pooling
this.terminateConnection(connectionId).catch((error) => {
console.error(`[ConnectionManager] Failed to cleanup connection ${connectionId}:`, error);
});
},
onError: (error) => {
// Call user's callback first
if (coordinator.options.onError) {
coordinator.options.onError(error);
}
// Clean up connection on error too
this.terminateConnection(connectionId).catch((cleanupError) => {
console.error(`[ConnectionManager] Failed to cleanup connection ${connectionId} after error:`, cleanupError);
});
}
};
// Use the full AudioService.speak() method which handles callbacks properly
await this.audioService.speak(wrappedOptions, connectionId);
}
/**
* Handle boundary events from Network Service
*/
handleBoundaryEvent(connectionId, boundary) {
const coordinator = this.activeConnections.get(connectionId);
if (!coordinator)
return;
// Trigger user callbacks if available
if (coordinator.options.onBoundary) {
coordinator.options.onBoundary(boundary);
}
}
// =============================================================================
// Error Handling and Recovery
// =============================================================================
/**
* Handle connection errors with recovery logic
*/
async handleConnectionError(connectionId, error) {
const coordinator = this.activeConnections.get(connectionId);
if (!coordinator)
return;
console.error(`Connection error for ${connectionId}:`, error);
// Update coordinator state
coordinator.state = types_1.ConnectionState.Error;
// Trigger user error callback
if (coordinator.options.onError) {
coordinator.options.onError(new Error(error.message));
}
// Attempt recovery based on error type
const shouldRetry = this.shouldRetryConnection(error);
// For non-retryable errors, terminate immediately and re-throw
if (!shouldRetry) {
await this.terminateConnection(connectionId);
// Ensure we throw a proper Error instance
if (error instanceof Error) {
throw error;
}
else {
// Convert error object to proper Error instance
const errorInstance = new Error(error.message || "Unknown error");
errorInstance.name = error.name || "Error";
errorInstance.code = error.code;
throw errorInstance;
}
}
// For retryable errors, check circuit breaker and retry count
if (!this.isCircuitClosed()) {
await this.terminateConnection(connectionId);
throw this.createSpeechError("CircuitBreakerOpen", "Circuit breaker is open", "CIRCUIT_BREAKER_OPEN");
}
// Check retry count before attempting retry
const currentRetries = coordinator.retryCount || 0;
const maxRetries = 3;
if (currentRetries >= maxRetries) {
await this.terminateConnection(connectionId);
throw this.createSpeechError("MaxRetriesExceeded", `Maximum retry attempts (${maxRetries}) exceeded`, "MAX_RETRIES_EXCEEDED");
}
// Attempt retry with exponential backoff
try {
await this.retryConnection(connectionId);
}
catch (retryError) {
// Retry failed, terminate and re-throw
await this.terminateConnection(connectionId);
throw retryError;
}
}
/**
* Determine if connection should be retried based on error type
*/
shouldRetryConnection(error) {
const retryableErrors = ["NetworkError", "TimeoutError", "WebSocketError"];
return retryableErrors.includes(error.code?.toString() || "");
}
/**
* Retry connection with exponential backoff
*/
async retryConnection(connectionId) {
const coordinator = this.activeConnections.get(connectionId);
if (!coordinator) {
throw this.createSpeechError("ConnectionNotFound", "Connection not found for retry", "CONNECTION_NOT_FOUND");
}
// Increment retry count
coordinator.retryCount = (coordinator.retryCount || 0) + 1;
// Implement exponential backoff with shorter delays for testing
const retryDelay = Math.min(50 * Math.pow(2, coordinator.retryCount - 1), // Start with 50ms, shorter for tests
1000);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
// Reset coordinator state for retry
coordinator.state = types_1.ConnectionState.Connecting;
coordinator.audioChunks = [];
coordinator.totalAudioSize = 0;
// Attempt to re-establish connection directly through Network Service
const { options: coordinatorOptions } = coordinator; // Renamed to avoid conflict
const ssmlToRetry = coordinatorOptions.ssml || "";
try {
// Re-establish the network connection
await this.establishNetworkConnection(connectionId, ssmlToRetry, coordinatorOptions);
// Update coordinator state on success
coordinator.state = types_1.ConnectionState.Connected;
this.globalConnectionState = types_1.ConnectionState.Connected;
}
catch (error) {
// Mark coordinator as failed and handle the error recursively
coordinator.state = types_1.ConnectionState.Error;
await this.handleConnectionError(connectionId, error);
}
}
// =============================================================================
// Circuit Breaker Pattern Implementation
// =============================================================================
/**
* Check if circuit breaker allows new connections
*/
isCircuitClosed() {
const now = Date.now();
switch (this.circuitBreakerState) {
case CircuitBreakerState.Closed:
return true;
case CircuitBreakerState.Open:
// Check if we should transition to half-open
const recoveryTimeout = this.config.circuitBreaker.recoveryTimeout;
if (now - this.lastFailureTime >= recoveryTimeout) {
this.circuitBreakerState = CircuitBreakerState.HalfOpen;
this.successCount = 0;
return true;
}
return false;
case CircuitBreakerState.HalfOpen:
return true;
default:
return false;
}
}
/**
* Record successful connection
*/
recordSuccess() {
this.successCount++;
if (this.circuitBreakerState === CircuitBreakerState.HalfOpen) {
const testRequestLimit = this.config.circuitBreaker.testRequestLimit;
if (this.successCount >= testRequestLimit) {
this.circuitBreakerState = CircuitBreakerState.Closed;
this.failureCount = 0;
}
}
}
/**
* Record connection failure
*/
recordFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
const failureThreshold = this.config.circuitBreaker.failureThreshold;
if (this.failureCount >= failureThreshold) {
this.circuitBreakerState = CircuitBreakerState.Open;
}
}
// =============================================================================
// Connection Queue Management
// =============================================================================
/**
* Process queued connections when space becomes available
*/
async processConnectionQueue() {
// Don't process queue during shutdown
if (this.isShuttingDown) {
return;
}
while (this.connectionQueue.length > 0 &&
this.activeConnections.size < this.config.maxConnections &&
this.isCircuitClosed()) {
const { options: queuedOptions, resolve, reject, } = this.connectionQueue.shift(); // Renamed to avoid conflict
try {
const ssmlToSynthesize = queuedOptions.ssml || "";
const result = await this.createAndManageConnection(ssmlToSynthesize, queuedOptions);
resolve(result.sessionId);
}
catch (error) {
reject(error);
}
}
}
// =============================================================================
// Utility Methods
// =============================================================================
/**
* Update global connection state based on individual connection states
*/
updateGlobalConnectionState() {
const states = Array.from(this.activeConnections.values()).map((c) => c.state);
// Connection state updates are handled by StateManager through service events
// This method tracks internal state but doesn't update external state
let globalState;
if (states.length === 0) {
globalState = types_1.ConnectionState.Disconnected;
}
else if (states.some((s) => s === types_1.ConnectionState.Error)) {
globalState = types_1.ConnectionState.Error;
}
else if (states.some((s) => s === types_1.ConnectionState.Synthesizing)) {
globalState = types_1.ConnectionState.Synthesizing;
}
else if (states.every((s) => s === types_1.ConnectionState.Connected)) {
globalState = types_1.ConnectionState.Connected;
}
else {
globalState = types_1.ConnectionState.Connecting;
}
// Store global state for internal tracking
this.globalConnectionState = globalState;
}
/**
* Setup event handlers for service coordination
*/
setupEventHandlers() {
// Setup state change listener for service coordination
this.stateManager.addStateChangeListener((event) => {
// Handle state changes for connection management coordination
if (event.type === "connection" || event.type === "application") {
// Update global connection state based on state manager events
// This enables proper service coordination
}
});
// Setup cleanup on app state changes (React Native/Expo compatible)
// Following React Native AppState documentation patterns
if (react_native_1.AppState && !this.appStateHandlerAdded) {
try {
// Subscribe to AppState changes using the documented React Native pattern
// AppState.addEventListener returns a NativeEventSubscription
this.appStateSubscription = react_native_1.AppState.addEventListener("change", (nextAppState) => {
try {
// React Native AppState values: 'active', 'background', 'inactive'
// Stop connections when app goes to background or becomes inactive
// This prevents issues with background execution limits
if (nextAppState === "background" ||
nextAppState === "inactive") {
this.stopAllConnections().catch((error) => {
console.error("Failed to stop connections on app state change:", error);
});
}
}
catch (error) {
console.error("Error in AppState change handler:", error);
}
});
this.appStateHandlerAdded = true;
}
catch (error) {
console.warn("Failed to setup AppState listener:", error);
// Ensure flag is reset if setup fails
this.appStateHandlerAdded = false;
}
}
}
/**
* Create SpeechError object
*/
createSpeechError(name, message, code) {
const error = new Error(message);
error.name = name;
error.code = code;
return error;
}
/**
* Convert Uint8Array to base64 string
*/
uint8ArrayToBase64(uint8Array) {
let binary = "";
const len = uint8Array.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(uint8Array[i]);
}
return btoa(binary);
}
}
exports.ConnectionManager = ConnectionManager;