UNPKG

expo-edge-speech

Version:

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

331 lines (330 loc) 12.7 kB
"use strict"; /** * Creates voice management and caching service using network and storage services. * Fetches available voices, transforms Edge TTS voice data to expo-speech format, * implements voice list caching with TTL, provides voice search and filtering * functionality, and handles multilingual voice capabilities. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.VoiceService = void 0; const constants_1 = require("../constants"); const storageService_1 = require("./storageService"); const ssmlUtils_1 = require("../utils/ssmlUtils"); /** * Voice management and caching service * Handles voice fetching, transformation, caching, and filtering */ class VoiceService { static instance = null; /** Storage service for caching */ storageService; /** Voice cache */ voiceCache = null; /** Service configuration */ config; /** Debug logging flag */ debugLog; constructor(config) { this.config = { cacheTTL: constants_1.VOICE_CACHING.VOICE_LIST_TTL, enableDebugLogging: false, networkTimeout: 10000, ...config, }; this.debugLog = this.config.enableDebugLogging; this.storageService = storageService_1.StorageService.getInstance(); } /** * Get singleton instance of voice service */ static getInstance(config) { if (!VoiceService.instance) { VoiceService.instance = new VoiceService(config); } return VoiceService.instance; } /** * Get available voices with caching * Fetches from cache if available and not expired, otherwise fetches from network */ async getAvailableVoices() { this.log("Getting available voices..."); // Check cache first if (this.isVoiceCacheValid()) { this.log("Returning cached voices"); return this.voiceCache.voices; } // Fetch from network try { this.log("Fetching voices from network..."); const voices = await this.fetchVoicesFromNetwork(); // Update cache this.updateVoiceCache(voices); return voices; } catch (error) { this.log("Network fetch failed:", error); // Try to return expired cache as fallback if (this.voiceCache) { this.log("Returning expired cache as fallback"); return this.voiceCache.voices; } // No cache available, throw error throw new Error(`Failed to fetch voices: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Search and filter voices */ async getFilteredVoices(filterOptions) { this.log("Filtering voices with options:", filterOptions); const allVoices = await this.getAvailableVoices(); return allVoices.filter((voice) => { // Filter by language if (filterOptions.language) { const voiceLanguage = (0, ssmlUtils_1.extractLanguageFromVoice)(voice.language); const filterLanguage = (0, ssmlUtils_1.extractLanguageFromVoice)(filterOptions.language); if (voiceLanguage !== filterLanguage) { return false; } } // Filter by gender (now available in EdgeSpeechVoice) if (filterOptions.gender) { if (voice.gender.toLowerCase() !== filterOptions.gender.toLowerCase()) { return false; } } // Filter by content categories if (filterOptions.contentCategories && filterOptions.contentCategories.length > 0) { const hasMatchingCategory = filterOptions.contentCategories.some((category) => voice.contentCategories.some((voiceCategory) => voiceCategory.toLowerCase().includes(category.toLowerCase()))); if (!hasMatchingCategory) { return false; } } // Filter by voice personalities if (filterOptions.voicePersonalities && filterOptions.voicePersonalities.length > 0) { const hasMatchingPersonality = filterOptions.voicePersonalities.some((personality) => voice.voicePersonalities.some((voicePersonality) => voicePersonality .toLowerCase() .includes(personality.toLowerCase()))); if (!hasMatchingPersonality) { return false; } } return true; }); } /** * Find voice by identifier */ async findVoiceByIdentifier(identifier) { this.log(`Finding voice by identifier: ${identifier}`); const voices = await this.getAvailableVoices(); return voices.find((voice) => voice.identifier === identifier) || null; } /** * Validate voice selection */ async validateVoiceSelection(voiceIdentifier) { this.log(`Validating voice selection: ${voiceIdentifier}`); const voices = await this.getAvailableVoices(); // Check exact match const exactMatch = voices.find((voice) => voice.identifier === voiceIdentifier); if (exactMatch) { return { isValid: true, voice: exactMatch }; } // Find similar voices for suggestions let suggestions = voices .filter((voice) => voice.identifier .toLowerCase() .includes(voiceIdentifier.toLowerCase()) || voice.name.toLowerCase().includes(voiceIdentifier.toLowerCase())) .slice(0, 5); // Limit to 5 suggestions // If no partial matches found, try to extract language and suggest voices for that language if (suggestions.length === 0) { // Try to extract language from the identifier (e.g., "en-US" from "en-US-InvalidVoice") const languageMatch = voiceIdentifier.match(/^([a-z]{2}-[A-Z]{2})/); if (languageMatch) { const language = languageMatch[1]; suggestions = voices .filter((voice) => voice.language === language) .slice(0, 5); } } return { isValid: false, suggestions, }; } /** * Get voices by language */ async getVoicesByLanguage(language) { this.log(`Getting voices for language: ${language}`); return this.getFilteredVoices({ language }); } /** * Get default voice for language */ async getDefaultVoiceForLanguage(language) { this.log(`Getting default voice for language: ${language}`); const voices = await this.getVoicesByLanguage(language); // Return first available voice for the language, or null if none found return voices.length > 0 ? voices[0] : null; } /** * Refresh voice cache * Forces a fresh fetch from the network */ async refreshVoiceCache() { this.log("Refreshing voice cache..."); // Clear current cache this.voiceCache = null; // Fetch fresh data return this.getAvailableVoices(); } /** * Check if voice cache is valid (exists and not expired) */ isVoiceCacheValid() { if (!this.voiceCache) { return false; } const now = new Date(); const isExpired = now > this.voiceCache.expiresAt; if (isExpired) { this.log("Voice cache expired"); return false; } this.log("Voice cache is valid"); return true; } /** * Update voice cache with new data */ updateVoiceCache(voices) { const now = new Date(); const expiresAt = new Date(now.getTime() + this.config.cacheTTL); this.voiceCache = { voices, timestamp: now, expiresAt, }; this.log(`Voice cache updated with ${voices.length} voices, expires at:`, expiresAt); } /** * Fetch voices from Edge TTS network service */ async fetchVoicesFromNetwork() { this.log(`Fetching voices from: ${constants_1.EDGE_TTS_VOICE_LIST_URL}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.networkTimeout); try { const response = await fetch(constants_1.EDGE_TTS_VOICE_LIST_URL, { method: "GET", headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36 Edg/91.0.864.41", Accept: "application/json", }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } let edgeVoices; try { edgeVoices = await response.json(); } catch (jsonError) { throw new Error(`Failed to parse voice data: ${jsonError instanceof Error ? jsonError.message : "Invalid JSON"}`); } this.log(`Fetched ${edgeVoices.length} voices from Edge TTS`); // Transform Edge TTS voices to expo-speech format return this.transformEdgeVoicesToExpoFormat(edgeVoices); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { throw new Error(`Voice list fetch timeout after ${this.config.networkTimeout}ms`); } throw error; } } /** * Transform Edge TTS voice data to EdgeSpeechVoice format */ transformEdgeVoicesToExpoFormat(edgeVoices) { this.log(`Transforming ${edgeVoices.length} Edge TTS voices to EdgeSpeechVoice format`); return edgeVoices .map((edgeVoice) => { // Use ShortName as identifier (e.g., "en-US-AriaNeural") const identifier = edgeVoice.ShortName || edgeVoice.Name; // Extract friendly name or use ShortName as fallback const name = edgeVoice.FriendlyName || edgeVoice.ShortName || edgeVoice.Name; // Use Locale as language const language = edgeVoice.Locale; // Extract gender (keep capitalized as per Edge TTS API format) const gender = edgeVoice.Gender || "Unknown"; // Extract content categories from VoiceTag (default to empty array if not available) const contentCategories = edgeVoice.VoiceTag?.ContentCategories || []; // Extract voice personalities from VoiceTag (default to empty array if not available) const voicePersonalities = edgeVoice.VoiceTag?.VoicePersonalities || []; return { identifier, name, language, gender, contentCategories, voicePersonalities, }; }) .filter((voice) => { // Filter out any voices with missing required fields return voice.identifier && voice.name && voice.language; }); } /** * Debug logging helper */ log(message, ...args) { if (this.debugLog) { console.log(`[VoiceService] ${message}`, ...args); } } /** * Get service statistics */ getStats() { return { cacheValid: this.isVoiceCacheValid(), cacheExpiration: this.voiceCache?.expiresAt || null, cachedVoiceCount: this.voiceCache?.voices.length || 0, cacheTimestamp: this.voiceCache?.timestamp || null, }; } // =========================================================================== // StateManager Integration Methods // =========================================================================== /** * Initialize the service (for StateManager integration) */ async initialize() { this.log("VoiceService initialized"); // No specific initialization needed for voice service } /** * Cleanup the service (for StateManager integration) */ async cleanup() { this.log("VoiceService cleanup"); // Clear cache to free memory this.voiceCache = null; } } exports.VoiceService = VoiceService; /** * Default voice service instance */ exports.default = VoiceService;