expo-edge-speech
Version:
Text-to-speech library for Expo using Microsoft Edge TTS service
674 lines (673 loc) • 23.8 kB
JavaScript
;
/**
* Provides expo-speech compatible public API using all internal components.
* Implements complete speech synthesis workflow with parameter validation,
* error handling, and backward compatibility.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpeechAPI = exports.maxSpeechInputLength = exports.cleanup = exports.isSpeakingAsync = exports.resume = exports.pause = exports.stop = exports.getAvailableVoicesAsync = exports.speak = exports.configure = void 0;
const constants_1 = require("./constants");
const synthesizer_1 = require("./core/synthesizer");
const connectionManager_1 = require("./core/connectionManager");
const state_1 = require("./core/state");
const audioService_1 = require("./services/audioService");
const networkService_1 = require("./services/networkService");
const storageService_1 = require("./services/storageService");
const voiceService_1 = require("./services/voiceService");
const commonUtils_1 = require("./utils/commonUtils");
/**
* Speech API class providing expo-speech compatible interface
*/
class SpeechAPI {
static instance = null;
static globalConfig = null;
static configurationLocked = false;
synthesizer = null;
voiceService = null;
audioService = null;
connectionManager = null;
initialized = false;
constructor() {
// Initialize services lazily
}
/**
* Get singleton instance of Speech API
*/
static getInstance() {
if (!SpeechAPI.instance) {
SpeechAPI.instance = new SpeechAPI();
}
return SpeechAPI.instance;
}
/**
* Configure Speech API services before initialization
*
* This method allows you to customize all internal services (AudioService, VoiceService,
* NetworkService, StorageService, ConnectionManager) before any Speech API methods are called.
* Configuration must be set before the first call to speak(), getAvailableVoicesAsync(),
* or any other Speech API method.
*
* @param config - Configuration options for all Speech API services
* @throws {Error} If called after Speech API has been initialized
*
* @example
* ```typescript
* import { Speech, SpeechAPIConfig } from 'expo-edge-speech';
*
* // Configure before using any Speech API methods
* Speech.configure({
* network: {
* maxRetries: 3,
* connectionTimeout: 8000,
* enableDebugLogging: true
* },
* connection: {
* maxConnections: 5,
* poolingEnabled: true,
* circuitBreaker: {
* failureThreshold: 3,
* recoveryTimeout: 15000
* }
* },
* audio: {
* loadingTimeout: 6000,
* platformConfig: {
* ios: { playsInSilentModeIOS: true },
* android: { shouldDuckAndroid: true }
* }
* }
* });
*
* // Now use Speech API with custom configuration
* await Speech.speak('Hello, configured world!');
* ```
*/
static configure(config) {
if (SpeechAPI.configurationLocked) {
throw new Error("Speech API configuration cannot be changed after initialization. " +
"Call Speech.configure() before using any other Speech API methods.");
}
if (!config || typeof config !== "object") {
throw new Error("Configuration must be a valid SpeechAPIConfig object");
}
// Store the configuration for use during initialization
SpeechAPI.globalConfig = { ...config };
}
/**
* Initialize all services if not already initialized
*/
async initializeServices() {
if (this.initialized) {
return;
}
try {
// Lock configuration to prevent changes after initialization
SpeechAPI.configurationLocked = true;
// Get global configuration
const config = SpeechAPI.globalConfig;
// Initialize services in dependency order with configuration
const storageService = storageService_1.StorageService.getInstance(config?.storage);
this.audioService = new audioService_1.AudioService(storageService, config?.audio);
const networkService = new networkService_1.NetworkService(storageService, config?.network);
this.voiceService = voiceService_1.VoiceService.getInstance(config?.voice);
const stateManager = new state_1.StateManager(storageService, networkService, this.voiceService, this.audioService, undefined);
// Map SpeechConnectionConfig to ConnectionManagerConfig if provided
let connectionManagerConfig = undefined;
if (config?.connection) {
connectionManagerConfig = {
maxConnections: config.connection.maxConnections,
// Ensure all mapped properties from SpeechConnectionConfig are present
// For example:
// connectionTimeout: config.connection.connectionTimeout,
// poolingEnabled: config.connection.poolingEnabled,
// circuitBreaker: config.connection.circuitBreaker,
// And any other properties ConnectionManagerConfig expects
};
}
this.connectionManager = new connectionManager_1.ConnectionManager(stateManager, networkService, this.audioService, storageService, connectionManagerConfig);
this.synthesizer = new synthesizer_1.Synthesizer(stateManager, this.connectionManager, this.audioService, this.voiceService, networkService);
this.initialized = true;
}
catch (error) {
throw new Error(`Failed to initialize Speech services: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
/**
* Internal speak method that orchestrates speech synthesis.
* Assumes parameters have been validated by the public-facing API.
* @param text The text to speak.
* @param options Validated and normalized speech options.
*/
async speak(text, options) {
try {
await this.initializeServices();
if (!this.synthesizer) {
throw new Error("Synthesizer not initialized");
}
await this.synthesizer.speak(text, options);
}
catch (error) {
const speechError = {
name: "SpeechError",
message: error instanceof Error ? error.message : "Unknown speech error",
code: "SPEECH_ERROR",
};
// Log error for debugging
console.error("Speech error:", speechError);
// Call error callback if provided
if (options.onError) {
if (typeof options.onError === "function") {
options.onError(new Error(speechError.message));
}
}
throw speechError;
}
}
/**
* Get list of all available voices from Microsoft Edge TTS service
*
* @returns Promise that resolves to an array of available voices with metadata
* @throws {Error} If voice service fails to fetch voice list
*
* @example
* ```typescript
* const voices = await Speech.getAvailableVoicesAsync();
* console.log(`Found ${voices.length} voices`);
*
* // Filter by language
* const englishVoices = voices.filter(v => v.language.startsWith('en-'));
*
* // Filter by gender
* const femaleVoices = voices.filter(v => v.gender === 'Female');
*
* // Use a specific voice
* const ariaVoice = voices.find(v => v.identifier === 'en-US-AriaNeural');
* if (ariaVoice) {
* await Speech.speak('Hello!', { voice: ariaVoice.identifier });
* }
* ```
*/
async getAvailableVoicesAsync() {
try {
// Initialize services if needed
await this.initializeServices();
// Ensure voice service is available
if (!this.voiceService) {
throw new Error("Voice service not initialized");
}
// Get voices from voice service
const voices = await this.voiceService.getAvailableVoices();
return voices;
}
catch (error) {
throw new Error(`Failed to get available voices: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
/**
* Stop current speech synthesis and clear any queued speech
*
* @returns Promise that resolves when speech is stopped
* @throws {Error} If stopping speech fails
*
* @example
* ```typescript
* // Start speaking
* Speech.speak('This is a long sentence that we might want to stop...');
*
* // Stop after 2 seconds
* setTimeout(async () => {
* await Speech.stop();
* console.log('Speech stopped');
* }, 2000);
* ```
*/
async stop() {
try {
// Initialize services if needed
await this.initializeServices();
// Ensure synthesizer is available
if (!this.synthesizer) {
throw new Error("Synthesizer not initialized");
}
// Use synthesizer to stop speech
await this.synthesizer.stop();
}
catch (error) {
throw new Error(`Failed to stop speech: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
/**
* Pause current speech synthesis
*
* @returns Promise that resolves when speech is paused
* @throws {Error} If pausing speech fails
*
* @example
* ```typescript
* // Start speaking
* await Speech.speak('This is a long sentence that we can pause and resume.');
*
* // Pause after 2 seconds
* setTimeout(async () => {
* await Speech.pause();
* console.log('Speech paused');
*
* // Resume after another 2 seconds
* setTimeout(async () => {
* await Speech.resume();
* console.log('Speech resumed');
* }, 2000);
* }, 2000);
* ```
*/
async pause() {
try {
// Initialize services if needed
await this.initializeServices();
// Ensure synthesizer is available
if (!this.synthesizer) {
throw new Error("Synthesizer not initialized");
}
// Use synthesizer to pause speech
await this.synthesizer.pause();
}
catch (error) {
throw new Error(`Failed to pause speech: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
/**
* Resume previously paused speech synthesis
*
* @returns Promise that resolves when speech is resumed
* @throws {Error} If resuming speech fails
*
* @example
* ```typescript
* // Pause and resume with user interaction
* let isPaused = false;
*
* await Speech.speak('Click the button to pause or resume this speech.', {
* onStart: () => console.log('Speech started - button will control pause/resume')
* });
*
* // Button click handler
* async function togglePauseResume() {
* if (isPaused) {
* await Speech.resume();
* isPaused = false;
* } else {
* await Speech.pause();
* isPaused = true;
* }
* }
* ```
*/
async resume() {
try {
// Initialize services if needed
await this.initializeServices();
// Ensure synthesizer is available
if (!this.synthesizer) {
throw new Error("Synthesizer not initialized");
}
// Use synthesizer to resume speech
await this.synthesizer.resume();
}
catch (error) {
throw new Error(`Failed to resume speech: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
/**
* Check if speech synthesis is currently active
*
* @returns Promise that resolves to true if speech is currently being synthesized or played, false otherwise
* @throws {Error} If checking speech status fails
*
* @example
* ```typescript
* // Check speaking status
* const isCurrentlySpeaking = await Speech.isSpeakingAsync();
* console.log('Currently speaking:', isCurrentlySpeaking);
*
* // Wait for speech to complete
* await Speech.speak('This will take a few seconds to complete.');
*
* while (await Speech.isSpeakingAsync()) {
* console.log('Still speaking...');
* await new Promise(resolve => setTimeout(resolve, 500));
* }
* console.log('Speech completed!');
*
* // Prevent overlapping speech
* async function safeSpeech(text: string) {
* if (await Speech.isSpeakingAsync()) {
* await Speech.stop();
* }
* await Speech.speak(text);
* }
* ```
*/
async isSpeakingAsync() {
try {
// Initialize services if needed
await this.initializeServices();
// Ensure synthesizer is available
if (!this.synthesizer) {
return false;
}
// Use synthesizer to check speaking state
return await this.synthesizer.isSpeakingAsync();
}
catch {
// Return false on error to match expo-speech behavior
return false;
}
}
/**
* Cleanup all resources and stop services
*
* This method should be called when you're done using the Speech API to prevent
* open handles and ensure proper resource cleanup. It will stop any active speech,
* shutdown connection managers, cleanup storage services, and reset the API state.
*
* @returns Promise that resolves when cleanup is complete
*
* @example
* ```typescript
* // Cleanup when app is closing or component unmounting
* useEffect(() => {
* return () => {
* Speech.cleanup().catch(console.error);
* };
* }, []);
*
* // Manual cleanup
* await Speech.cleanup();
* console.log('All speech resources cleaned up');
* ```
*
* @note This method will log warnings for any cleanup errors but won't throw exceptions
*/
async cleanup() {
try {
// Stop any active speech
if (this.synthesizer) {
await this.synthesizer.stop();
}
// Shutdown connection manager to remove AppState listeners
if (this.connectionManager) {
await this.connectionManager.shutdown();
}
// Cleanup storage service (this stops the cleanup timer)
const storageService = storageService_1.StorageService.getInstance();
storageService.destroy();
this.initialized = false;
this.synthesizer = null;
this.voiceService = null;
this.audioService = null;
this.connectionManager = null;
}
catch (error) {
console.warn("Warning: Error during cleanup:", error);
}
}
/**
* Reset the Speech API state for testing purposes
* @internal Only for testing - not part of public API
*/
static resetForTesting() {
SpeechAPI.configurationLocked = false;
SpeechAPI.globalConfig = {};
SpeechAPI.instance = null;
}
}
exports.SpeechAPI = SpeechAPI;
// ============================================================================
// Public API Exports
// ============================================================================
// Get singleton instance
// const speechAPI = SpeechAPI.getInstance(); // Removed as it's unused
/**
* Configure Speech API services before initialization
*
* This method allows you to customize all internal services (AudioService, VoiceService,
* NetworkService, StorageService, ConnectionManager) before any Speech API methods are called.
* Configuration must be set before the first call to speak(), getAvailableVoicesAsync(),
* or any other Speech API method.
*
* @param config - Configuration options for all Speech API services
* @throws {Error} If called after Speech API has been initialized
*
* @example
* ```typescript
* import { configure, SpeechAPIConfig } from 'expo-edge-speech';
*
* // Configure before using any Speech API methods
* configure({
* network: {
* maxRetries: 3,
* connectionTimeout: 8000,
* enableDebugLogging: true
* },
* connection: {
* maxConnections: 5,
* poolingEnabled: true,
* circuitBreaker: {
* failureThreshold: 3,
* recoveryTimeout: 15000
* }
* },
* audio: {
* loadingTimeout: 6000,
* platformConfig: {
* ios: { playsInSilentModeIOS: true },
* android: { shouldDuckAndroid: true }
* }
* }
* });
* ```
*/
const configure = (config) => {
SpeechAPI.configure(config); // Changed Speech to SpeechAPI
};
exports.configure = configure;
/**
* Speaks the given text with the specified options.
*
* Calling this when another text is being spoken adds an utterance to queue.
* This is the main entry point for text-to-speech functionality.
*
* @param text - The text to be spoken
* @param options - Configuration options for speech synthesis (optional)
*
* @example
* ```typescript
* // Basic usage with default voice
* Speech.speak('Hello, world!');
*
* // With options
* Speech.speak('Hello!', {
* voice: 'en-US-AriaNeural',
* rate: 1.2,
* onDone: () => console.log('Finished speaking')
* });
* ```
*/
const speak = (text, options) => {
const speechOptions = options || {};
try {
// Type check for text parameter
if (typeof text !== "string") {
throw new Error("Text to speak must be a string.");
}
if (!text || text.trim().length === 0) {
throw new Error("Text to speak cannot be empty.");
}
if (text.length > constants_1.MAX_TEXT_LENGTH) {
throw new Error(`Text length (${text.length}) exceeds maximum allowed length (${constants_1.MAX_TEXT_LENGTH})`);
}
// Corrected call to validateSpeechParameters
const paramValidation = (0, commonUtils_1.validateSpeechParameters)(speechOptions);
if (!paramValidation.result.isValid) {
throw new Error(`Invalid speech parameters: ${paramValidation.result.errors.join(", ")}`);
}
const speechInstance = SpeechAPI.getInstance();
// Use normalizedOptions from validation
speechInstance
.speak(text, paramValidation.normalizedOptions)
.catch((error) => {
// SpeechAPI.speak already calls onError and logs the error.
// This catch is for any unhandled promise rejections from the speak call.
console.error("Unhandled promise rejection in speak:", error.message);
});
}
catch (error) {
// Handle synchronous validation errors
console.error("Speech validation error:", error);
if (speechOptions.onError) {
if (typeof speechOptions.onError === "function") {
speechOptions.onError(error);
}
}
// To maintain expo-speech compatibility, we don't re-throw sync errors
// if a callback is provided. But if not, throwing helps debugging.
if (!speechOptions.onError) {
throw error;
}
}
};
exports.speak = speak;
/**
* Get all available voices from Microsoft Edge TTS service
*
* Returns a comprehensive list of all supported voices with their metadata
* including language, gender, and capabilities.
*
* @returns A promise that resolves with an array of available voices.
*
* @example
* ```typescript
* const voices = await Speech.getAvailableVoicesAsync();
* console.log(`Found ${voices.length} voices available`);
*
* // Find English voices
* const englishVoices = voices.filter(v => v.language.startsWith('en-'));
* ```
*/
const getAvailableVoicesAsync = () => {
const speechInstance = SpeechAPI.getInstance(); // Changed Speech to SpeechAPI
return speechInstance.getAvailableVoicesAsync();
};
exports.getAvailableVoicesAsync = getAvailableVoicesAsync;
/**
* Stop current speech synthesis and clear any queued utterances
*
* Interrupts any currently playing speech and removes all pending
* speech from the queue. This provides immediate speech termination.
*
* @returns A promise that resolves when speech is stopped.
*
* @example
* ```typescript
* // Stop speech immediately
* await Speech.stop();
* console.log('All speech stopped and queue cleared');
* ```
*/
const stop = () => {
const speechInstance = SpeechAPI.getInstance(); // Changed Speech to SpeechAPI
return speechInstance.stop();
};
exports.stop = stop;
/**
* Pause current speech synthesis
*
* Temporarily stops speech playback, allowing it to be resumed later.
*
* @returns A promise that resolves when speech is paused.
*
* @example
* ```typescript
* // Pause speech
* await Speech.pause();
* console.log('Speech paused');
* ```
*/
const pause = () => {
const speechInstance = SpeechAPI.getInstance(); // Changed Speech to SpeechAPI
return speechInstance.pause();
};
exports.pause = pause;
/**
* Resume previously paused speech synthesis
*
* Continues playback of speech that was previously paused. If no speech
* was paused, this method does nothing.
*
* @returns A promise that resolves when speech is resumed.
*
* @example
* ```typescript
* // Resume paused speech
* await Speech.resume();
* console.log('Speech resumed');
* ```
*/
const resume = () => {
const speechInstance = SpeechAPI.getInstance(); // Changed Speech to SpeechAPI
return speechInstance.resume();
};
exports.resume = resume;
/**
* Check if the Text-to-Speech service is currently speaking
*
* Determines whether speech synthesis is currently active. Returns true
* if speech is playing or paused, false if no speech is active.
*
* @returns A promise that resolves with a boolean indicating if speech is active.
*
* @note Will return true if speaker is paused
*
* @example
* ```typescript
* const isPlaying = await Speech.isSpeakingAsync();
* if (isPlaying) {
* console.log('Speech is currently active');
* } else {
* console.log('No speech is playing');
* }
* ```
*/
const isSpeakingAsync = () => {
const speechInstance = SpeechAPI.getInstance(); // Changed Speech to SpeechAPI
return speechInstance.isSpeakingAsync();
};
exports.isSpeakingAsync = isSpeakingAsync;
/**
* Cleanup all resources and stop services
*
* Performs comprehensive cleanup of all speech-related resources including
* stopping active speech, shutting down connections, and clearing storage.
* This method should be called to prevent open handles and memory leaks.
*
* @returns A promise that resolves when cleanup is complete.
*
* @example
* ```typescript
* // Cleanup when app closes
* await Speech.cleanup();
* console.log('All resources cleaned up');
* ```
*/
const cleanup = () => {
const speechInstance = SpeechAPI.getInstance(); // Changed Speech to SpeechAPI
return speechInstance.cleanup();
};
exports.cleanup = cleanup;
/**
* Maximum text length for speech input.
* This constant defines the character limit for text input to the speak function.
*/
exports.maxSpeechInputLength = constants_1.MAX_TEXT_LENGTH;
// Make SpeechAPI the default export for compatibility
exports.default = SpeechAPI;