UNPKG

expo-edge-speech

Version:

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

368 lines (367 loc) 15.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Synthesizer = void 0; const commonUtils_1 = require("../utils/commonUtils"); const ssmlUtils_1 = require("../utils/ssmlUtils"); const state_1 = require("./state"); const audioService_1 = require("../services/audioService"); /** * Main synthesizer that coordinates complete speech synthesis workflow. * Provides expo-speech compatible API while coordinating all internal services. */ class Synthesizer { stateManager; connectionManager; audioService; voiceService; networkService; sessions = new Map(); currentSession = null; constructor(stateManager, connectionManager, audioService, voiceService, networkService) { this.stateManager = stateManager; this.connectionManager = connectionManager; this.audioService = audioService; this.voiceService = voiceService; this.networkService = networkService; this.setupEventHandlers(); } /** * Setup event handlers for service coordination */ setupEventHandlers() { // Register for state change notifications from StateManager this.stateManager.addStateChangeListener((event) => { try { if (event.type === "application" && this.currentSession) { const previousState = this.currentSession.state; // Synchronize currentSession.state with StateManager application state this.currentSession.state = event.newState; console.log(`[Synthesizer] Session state synchronized: ${previousState} → ${event.newState}`); // Validate state synchronization to catch inconsistencies if (event.newState !== this.currentSession.state) { console.warn(`[Synthesizer] State synchronization warning: expected ${event.newState}, got ${this.currentSession.state}`); } } } catch (error) { console.error('[Synthesizer] Error during state synchronization:', error); // Continue execution to prevent critical failures } }); } /** * Main speech synthesis function - expo-speech compatible */ async speak(text, options = {}) { try { // Validate input if (!text || text.trim().length === 0) { throw new Error("Text cannot be empty"); } // Create synthesis session using StateManager to get the authoritative session object const session = await this.stateManager.createSynthesisSession(text, options); // Store this authoritative session locally in the Synthesizer's session map // Note: this.sessions is a Map<string, SynthesisSession> in Synthesizer this.sessions.set(session.id, session); // Update the session to synthesizing state in StateManager await this.stateManager.updateSynthesisSession(session.id, { state: state_1.ApplicationState.Synthesizing, }); // Process the session using the authoritative session object from StateManager // This ensures that processSession receives the session with the correct connectionId await this.processSession(session); } catch (error) { const speechError = { name: "SynthesisError", code: "synthesis_error", message: error instanceof Error ? error.message : "Unknown synthesis error", }; options.onError?.(speechError); // Use proper StateManager session management instead of setState if (this.currentSession) { await this.stateManager.updateSynthesisSession(this.currentSession.id, { state: state_1.ApplicationState.Error, }); } // Always re-throw the error to maintain promise rejection behavior throw error; } } /** * Get available voices - expo-speech compatible */ async getAvailableVoicesAsync() { try { return await this.voiceService.getAvailableVoices(); } catch (error) { console.error("Failed to get available voices:", error); return []; } } /** * Check if currently speaking - expo-speech compatible */ async isSpeakingAsync() { const audioState = this.audioService.currentState; return (audioState === audioService_1.AudioPlaybackState.Playing || audioState === audioService_1.AudioPlaybackState.Loading || (this.currentSession !== null && (this.currentSession.state === state_1.ApplicationState.Synthesizing || this.currentSession.state === state_1.ApplicationState.Playing))); } /** * Stop current speech synthesis and playback */ async stop() { if (this.currentSession) { try { // Use ConnectionManager to properly coordinate session termination // This ensures proper 3-layer architecture compliance and session cleanup await this.connectionManager.stopSynthesis(this.currentSession.id); // Remove current session via StateManager await this.stateManager.removeSynthesisSession(this.currentSession.id); this.currentSession = null; } catch (error) { // If ConnectionManager stop fails, fall back to direct cleanup console.warn("ConnectionManager stop failed, performing direct cleanup:", error); if (this.currentSession) { this.currentSession.state = state_1.ApplicationState.Idle; await this.audioService.stop(); this.currentSession.options.onStopped?.(); await this.stateManager.removeSynthesisSession(this.currentSession.id); this.currentSession = null; } } } // Clear all queued sessions via StateManager const activeSessions = this.stateManager.getActiveSessions(); for (const session of activeSessions) { await this.stateManager.removeSynthesisSession(session.id); } this.sessions.clear(); } /** * Pause current playback */ async pause() { const timestamp = new Date().toISOString(); console.log(`[${timestamp}] [Synthesizer] pause() called - has session: ${!!this.currentSession}, session state: ${this.currentSession?.state}`); if (!this.currentSession) { console.log(`[${timestamp}] [Synthesizer] Pause skipped - no active session`); return; } if (this.currentSession.state === state_1.ApplicationState.Playing) { console.log(`[${timestamp}] [Synthesizer] Pausing session via ConnectionManager`); await this.connectionManager.pauseSynthesis(this.currentSession.id); console.log(`[${timestamp}] [Synthesizer] Session pause completed successfully`); } else if (this.currentSession.state === state_1.ApplicationState.Paused) { console.log(`[${timestamp}] [Synthesizer] Session is already paused - no action needed`); } else { console.log(`[${timestamp}] [Synthesizer] Pause not available - current state is ${this.currentSession.state} (requires Playing state)`); } } /** * Resume paused playback */ async resume() { const timestamp = new Date().toISOString(); console.log(`[${timestamp}] [Synthesizer] resume() called - has session: ${!!this.currentSession}, session state: ${this.currentSession?.state}`); if (!this.currentSession) { console.log(`[${timestamp}] [Synthesizer] Resume skipped - no active session`); return; } if (this.currentSession.state === state_1.ApplicationState.Paused) { console.log(`[${timestamp}] [Synthesizer] Resuming session via ConnectionManager`); await this.connectionManager.resumeSynthesis(this.currentSession.id); console.log(`[${timestamp}] [Synthesizer] Session resume completed successfully`); } else if (this.currentSession.state === state_1.ApplicationState.Playing) { console.log(`[${timestamp}] [Synthesizer] Session is already playing - no action needed`); } else { console.log(`[${timestamp}] [Synthesizer] Resume not available - current state is ${this.currentSession.state} (requires Paused state)`); } } /** * Create a new synthesis session using universal session ID format */ createSession(text, options) { const sessionId = (0, commonUtils_1.generateSessionId)(); const now = new Date(); return { id: sessionId, connectionId: "", // Will be set when establishing connection text, options, state: state_1.ApplicationState.Idle, createdAt: now, lastActivity: now, }; } /** * Process a synthesis session through the complete workflow */ async processSession(session) { try { this.currentSession = session; // Resolve voice for synthesis const voice = await this.resolveVoice(session.options.voice, // User-requested voice identifier (optional) session.options.language); // Generate SSML at Synthesizer level per architecture.md const ssml = (0, ssmlUtils_1.generateSSML)(session.text, { voice: voice.identifier, // Resolved voice identifier rate: session.options.rate, pitch: session.options.pitch, volume: session.options.volume, language: session.options.language || voice.language, }); // Update StateManager with the fact that synthesis has started await this.stateManager.updateSynthesisSession(session.id, { state: state_1.ApplicationState.Synthesizing, }); // Also update the local session object session.state = state_1.ApplicationState.Synthesizing; // Use ConnectionManager to coordinate synthesis per architecture.md // ConnectionManager will handle NetworkService, StorageService, and AudioService coordination const connectionOptions = { voice: voice.identifier, ...session.options, clientSessionId: session.id, connectionId: session.connectionId, }; await this.connectionManager.startSynthesis(ssml, connectionOptions); // The following lines were removed because they caused premature completion // notification. The onDone callback is now correctly handled by the // ConnectionManager and AudioService when playback is actually finished. // This fixes the issue where the "Stop" button was disabled while audio // was still playing. } catch (error) { this.handleSessionError(error, session.id); } } /** * Resolve voice for synthesis */ async resolveVoice(voiceId, language) { try { if (voiceId) { const voice = await this.voiceService.findVoiceByIdentifier(voiceId); if (voice) { return voice; } } // Fallback to language-based selection if (language) { const voices = await this.voiceService.getVoicesByLanguage(language); if (voices.length > 0) { return voices[0]; } } // Final fallback to default voice const defaultVoices = await this.voiceService.getVoicesByLanguage("en-US"); if (defaultVoices.length > 0) { return defaultVoices[0]; } throw new Error("No suitable voice found"); } catch (error) { throw new Error(`Voice resolution failed: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Process boundary events from synthesis */ processBoundaryEvents(session, boundaryData) { if (!session.options.onBoundary) { return; } // Process boundary data and schedule callbacks with proper timing boundaryData.forEach((boundary) => { // The boundary data is already in the correct WordBoundary format // Schedule boundary callback - using charIndex as a rough timing estimate (in ms) const timing = boundary.charIndex * 100; // @FIXME: Adjust timing calculation as needed setTimeout(() => { session.options.onBoundary?.(boundary); }, timing); }); } /** * Handle session completion */ handleSessionComplete(completed) { if (this.currentSession) { this.currentSession.state = completed ? state_1.ApplicationState.Idle : state_1.ApplicationState.Idle; if (completed) { this.currentSession.options.onDone?.(); } else { this.currentSession.options.onStopped?.(); } // Clean up session this.sessions.delete(this.currentSession.id); this.currentSession = null; } // Update state if no more sessions if (this.sessions.size === 0) { // Sessions are tracked by StateManager - it will handle application state updates // through its session management and state coordination mechanisms } } /** * Handle session errors */ handleSessionError(error, sessionId) { const speechError = { name: "SynthesisError", code: "synthesis_error", message: error instanceof Error ? error.message : "Unknown synthesis error", }; if (this.currentSession) { this.currentSession.options?.onError?.(speechError); const idToUpdate = sessionId || this.currentSession.id; this.stateManager.updateSynthesisSession(idToUpdate, { state: state_1.ApplicationState.Error, }); if (this.currentSession.id === idToUpdate) { this.currentSession = null; } } else if (sessionId) { this.stateManager.updateSynthesisSession(sessionId, { state: state_1.ApplicationState.Error, }); } console.error(`Session Error (${sessionId || "current"}):`, speechError); } /** * Get current synthesis status */ getCurrentSession() { return this.currentSession; } /** * Get all active sessions */ getAllSessions() { return Array.from(this.sessions.values()); } /** * Clear all sessions and reset synthesizer */ async reset() { await this.stop(); this.sessions.clear(); this.currentSession = null; // StateManager handles application state updates through the stop() method // which properly cleans up sessions via StateManager.removeSynthesisSession() } } exports.Synthesizer = Synthesizer;