UNPKG

@varunmhajan/hom-i-voice-ai

Version:

Voice AI utilities for home loan assistance with India-specific formatting

1,397 lines (1,342 loc) 64.1 kB
function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var eventemitter3 = {exports: {}}; (function (module) { var has = Object.prototype.hasOwnProperty , prefix = '~'; /** * Constructor to create a storage for our `EE` objects. * An `Events` instance is a plain object whose properties are event names. * * @constructor * @private */ function Events() {} // // We try to not inherit from `Object.prototype`. In some engines creating an // instance in this way is faster than calling `Object.create(null)` directly. // If `Object.create(null)` is not supported we prefix the event names with a // character to make sure that the built-in object properties are not // overridden or used as an attack vector. // if (Object.create) { Events.prototype = Object.create(null); // // This hack is needed because the `__proto__` property is still inherited in // some old browsers like Android 4, iPhone 5.1, Opera 11 and Safari 5. // if (!new Events().__proto__) prefix = false; } /** * Representation of a single event listener. * * @param {Function} fn The listener function. * @param {*} context The context to invoke the listener with. * @param {Boolean} [once=false] Specify if the listener is a one-time listener. * @constructor * @private */ function EE(fn, context, once) { this.fn = fn; this.context = context; this.once = once || false; } /** * Add a listener for a given event. * * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. * @param {(String|Symbol)} event The event name. * @param {Function} fn The listener function. * @param {*} context The context to invoke the listener with. * @param {Boolean} once Specify if the listener is a one-time listener. * @returns {EventEmitter} * @private */ function addListener(emitter, event, fn, context, once) { if (typeof fn !== 'function') { throw new TypeError('The listener must be a function'); } var listener = new EE(fn, context || emitter, once) , evt = prefix ? prefix + event : event; if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++; else if (!emitter._events[evt].fn) emitter._events[evt].push(listener); else emitter._events[evt] = [emitter._events[evt], listener]; return emitter; } /** * Clear event by name. * * @param {EventEmitter} emitter Reference to the `EventEmitter` instance. * @param {(String|Symbol)} evt The Event name. * @private */ function clearEvent(emitter, evt) { if (--emitter._eventsCount === 0) emitter._events = new Events(); else delete emitter._events[evt]; } /** * Minimal `EventEmitter` interface that is molded against the Node.js * `EventEmitter` interface. * * @constructor * @public */ function EventEmitter() { this._events = new Events(); this._eventsCount = 0; } /** * Return an array listing the events for which the emitter has registered * listeners. * * @returns {Array} * @public */ EventEmitter.prototype.eventNames = function eventNames() { var names = [] , events , name; if (this._eventsCount === 0) return names; for (name in (events = this._events)) { if (has.call(events, name)) names.push(prefix ? name.slice(1) : name); } if (Object.getOwnPropertySymbols) { return names.concat(Object.getOwnPropertySymbols(events)); } return names; }; /** * Return the listeners registered for a given event. * * @param {(String|Symbol)} event The event name. * @returns {Array} The registered listeners. * @public */ EventEmitter.prototype.listeners = function listeners(event) { var evt = prefix ? prefix + event : event , handlers = this._events[evt]; if (!handlers) return []; if (handlers.fn) return [handlers.fn]; for (var i = 0, l = handlers.length, ee = new Array(l); i < l; i++) { ee[i] = handlers[i].fn; } return ee; }; /** * Return the number of listeners listening to a given event. * * @param {(String|Symbol)} event The event name. * @returns {Number} The number of listeners. * @public */ EventEmitter.prototype.listenerCount = function listenerCount(event) { var evt = prefix ? prefix + event : event , listeners = this._events[evt]; if (!listeners) return 0; if (listeners.fn) return 1; return listeners.length; }; /** * Calls each of the listeners registered for a given event. * * @param {(String|Symbol)} event The event name. * @returns {Boolean} `true` if the event had listeners, else `false`. * @public */ EventEmitter.prototype.emit = function emit(event, a1, a2, a3, a4, a5) { var evt = prefix ? prefix + event : event; if (!this._events[evt]) return false; var listeners = this._events[evt] , len = arguments.length , args , i; if (listeners.fn) { if (listeners.once) this.removeListener(event, listeners.fn, undefined, true); switch (len) { case 1: return listeners.fn.call(listeners.context), true; case 2: return listeners.fn.call(listeners.context, a1), true; case 3: return listeners.fn.call(listeners.context, a1, a2), true; case 4: return listeners.fn.call(listeners.context, a1, a2, a3), true; case 5: return listeners.fn.call(listeners.context, a1, a2, a3, a4), true; case 6: return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true; } for (i = 1, args = new Array(len -1); i < len; i++) { args[i - 1] = arguments[i]; } listeners.fn.apply(listeners.context, args); } else { var length = listeners.length , j; for (i = 0; i < length; i++) { if (listeners[i].once) this.removeListener(event, listeners[i].fn, undefined, true); switch (len) { case 1: listeners[i].fn.call(listeners[i].context); break; case 2: listeners[i].fn.call(listeners[i].context, a1); break; case 3: listeners[i].fn.call(listeners[i].context, a1, a2); break; case 4: listeners[i].fn.call(listeners[i].context, a1, a2, a3); break; default: if (!args) for (j = 1, args = new Array(len -1); j < len; j++) { args[j - 1] = arguments[j]; } listeners[i].fn.apply(listeners[i].context, args); } } } return true; }; /** * Add a listener for a given event. * * @param {(String|Symbol)} event The event name. * @param {Function} fn The listener function. * @param {*} [context=this] The context to invoke the listener with. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.on = function on(event, fn, context) { return addListener(this, event, fn, context, false); }; /** * Add a one-time listener for a given event. * * @param {(String|Symbol)} event The event name. * @param {Function} fn The listener function. * @param {*} [context=this] The context to invoke the listener with. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.once = function once(event, fn, context) { return addListener(this, event, fn, context, true); }; /** * Remove the listeners of a given event. * * @param {(String|Symbol)} event The event name. * @param {Function} fn Only remove the listeners that match this function. * @param {*} context Only remove the listeners that have this context. * @param {Boolean} once Only remove one-time listeners. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.removeListener = function removeListener(event, fn, context, once) { var evt = prefix ? prefix + event : event; if (!this._events[evt]) return this; if (!fn) { clearEvent(this, evt); return this; } var listeners = this._events[evt]; if (listeners.fn) { if ( listeners.fn === fn && (!once || listeners.once) && (!context || listeners.context === context) ) { clearEvent(this, evt); } } else { for (var i = 0, events = [], length = listeners.length; i < length; i++) { if ( listeners[i].fn !== fn || (once && !listeners[i].once) || (context && listeners[i].context !== context) ) { events.push(listeners[i]); } } // // Reset the array, or remove it completely if we have no more listeners. // if (events.length) this._events[evt] = events.length === 1 ? events[0] : events; else clearEvent(this, evt); } return this; }; /** * Remove all listeners, or those of the specified event. * * @param {(String|Symbol)} [event] The event name. * @returns {EventEmitter} `this`. * @public */ EventEmitter.prototype.removeAllListeners = function removeAllListeners(event) { var evt; if (event) { evt = prefix ? prefix + event : event; if (this._events[evt]) clearEvent(this, evt); } else { this._events = new Events(); this._eventsCount = 0; } return this; }; // // Alias methods names because people roll like that. // EventEmitter.prototype.off = EventEmitter.prototype.removeListener; EventEmitter.prototype.addListener = EventEmitter.prototype.on; // // Expose the prefix. // EventEmitter.prefixed = prefix; // // Allow `EventEmitter` to be imported as module namespace. // EventEmitter.EventEmitter = EventEmitter; // // Expose the module. // { module.exports = EventEmitter; } } (eventemitter3)); var eventemitter3Exports = eventemitter3.exports; var EventEmitter = /*@__PURE__*/getDefaultExportFromCjs(eventemitter3Exports); /** * Ultra-fast Voice AI Client with minimal latency optimizations */ class VoiceClient extends EventEmitter { constructor(config) { super(); this.conversationHistory = []; this.audioCache = new Map(); this.isProcessing = false; this.requestQueue = []; this.processingQueue = false; // Performance tracking this.performanceMetrics = { averageLatency: 0, requestCount: 0, cacheHitRate: 0, errorRate: 0 }; // Enhanced API key validation if (!config.apiKey) { throw new Error('API key is required for @hom-i/voice-ai package'); } if (typeof config.apiKey !== 'string' || config.apiKey.trim().length === 0) { throw new Error('API key must be a valid non-empty string'); } // Basic format validation - API keys should be at least 16 characters if (config.apiKey.length < 16) { throw new Error('Invalid API key format. Please ensure you have a valid HOM-i Voice AI API key'); } // Check for placeholder/example keys const invalidKeys = ['your-api-key', 'api-key', 'test-key', 'demo-key', 'placeholder']; if (invalidKeys.some(invalid => config.apiKey.toLowerCase().includes(invalid))) { throw new Error('Please replace the placeholder API key with your actual HOM-i Voice AI API key'); } this.config = { apiKey: config.apiKey, baseUrl: config.baseUrl || 'https://chat.orbit.basichomeloan.com', language: config.language || 'en', voiceId: config.voiceId || '2bNrEsM0omyhLiEyOwqY', // Updated to Monika voice audioFormat: config.audioFormat || 'mp3', priority: config.priority || 'speed', mode: config.mode || 'full-voice', enableSTT: config.enableSTT ?? true, enableTTS: config.enableTTS ?? true, autoPlay: config.autoPlay ?? true, enableCompression: config.enableCompression ?? true, enableCaching: config.enableCaching ?? true, chunkSize: config.chunkSize || 4096, maxCacheSize: config.maxCacheSize || 50, timeout: config.timeout || 10000, retryAttempts: config.retryAttempts || 2, ultraFastMode: config.ultraFastMode ?? true, preloadVoices: config.preloadVoices ?? true, enableWebWorkers: config.enableWebWorkers ?? false, // Disabled by default for compatibility }; this.initializeOptimizations(); } async initializeOptimizations() { // Verify API key with backend await this.verifyApiKey(); // Preload voice models if enabled if (this.config.preloadVoices && this.config.ultraFastMode) { this.preloadVoiceModels(); } // Initialize Web Workers if enabled if (this.config.enableWebWorkers && typeof Worker !== 'undefined') { this.initializeWebWorkers(); } this.emit('ready'); } async verifyApiKey() { try { const response = await fetch(`${this.config.baseUrl}/api/voice/verify-key`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: this.config.apiKey }), signal: AbortSignal.timeout(5000) // 5 second timeout }); if (!response.ok) { if (response.status === 401 || response.status === 403) { throw new Error('Invalid API key. Please check your HOM-i Voice AI API credentials'); } throw new Error(`API key verification failed: ${response.statusText}`); } const data = await response.json(); if (!data.success) { throw new Error(data.error || 'API key verification failed'); } this.emit('apiKeyVerified', { valid: true, key: this.config.apiKey.slice(0, 4) + '...' }); } catch (error) { // If verification endpoint doesn't exist, log warning but continue if (error instanceof Error && error.message.includes('404')) { console.warn('@hom-i/voice-ai: API key verification endpoint not available, proceeding without verification'); return; } // For other errors, throw to prevent initialization this.emit('apiKeyVerified', { valid: false, error: error instanceof Error ? error.message : 'Unknown error' }); throw new Error(`API key verification failed: ${error instanceof Error ? error.message : 'Please check your API key'}`); } } async preloadVoiceModels() { try { // Pre-warm the voice API with a minimal request await this.textToSpeech('', { autoPlay: false, enableCaching: false }); } catch { // Ignore preload errors } } initializeWebWorkers() { // TODO: Implement Web Worker for audio processing // This would move audio encoding/decoding off the main thread } /** * Send voice message with ultra-fast processing */ async sendVoiceMessage(input, options = {}) { const startTime = performance.now(); if (this.isProcessing && this.config.ultraFastMode) { // In ultra-fast mode, cancel previous request this.abortController?.abort(); } this.abortController = new AbortController(); this.isProcessing = true; this.emit('processingStarted', { type: 'voice_message' }); try { const mergedConfig = { ...this.config, ...options }; // Check cache first for text inputs if (typeof input === 'string' && this.config.enableCaching) { const cachedResponse = this.getCachedResponse(input, mergedConfig); if (cachedResponse) { this.updatePerformanceMetrics(startTime, true); this.emit('messageReceived', cachedResponse); return cachedResponse; } } const requestBody = await this.prepareRequestBody(input, mergedConfig); const response = await this.makeRequest('/api/voice/simple-voice', requestBody); if (response.success) { this.conversationHistory = response.conversationHistory || []; // Cache the response if (typeof input === 'string' && this.config.enableCaching) { this.cacheResponse(input, mergedConfig, response); } // Auto-play if enabled if (response.audio && mergedConfig.autoPlay) { this.playAudio(response.audio); } this.updatePerformanceMetrics(startTime, false); this.emit('messageReceived', response); return response; } else { throw new Error(response.error || 'API request failed'); } } catch (error) { this.handleError(error); throw error; } finally { this.isProcessing = false; this.emit('processingEnded', { type: 'voice_message' }); } } /** * Send text message optimized for speed */ async sendTextMessage(text, options = {}) { return this.sendVoiceMessage(text, { ...options, enableSTT: false }); } /** * Transcribe audio only (no TTS response) */ async transcribeAudio(audioInput, options = {}) { return this.sendVoiceMessage(audioInput, { ...options, enableTTS: false, mode: 'stt-only' }); } /** * Convert text to speech with caching */ async textToSpeech(text, options = {}) { const mergedConfig = { ...this.config, ...options }; const cacheKey = this.generateCacheKey(text, mergedConfig); // Check cache first if (this.config.enableCaching && this.audioCache.has(cacheKey)) { const cachedAudio = this.audioCache.get(cacheKey); const response = { success: true, audio: cachedAudio, metadata: { processingTime: 0, endpoint: 'cache', cached: true } }; if (mergedConfig.autoPlay) { this.playAudio(cachedAudio); } this.emit('audioFromCache', { text, audio: cachedAudio }); return response; } const response = await this.sendVoiceMessage(text, { ...options, mode: 'tts-only' }); // Cache the result if (response.audio && this.config.enableCaching) { this.audioCache.set(cacheKey, response.audio); this.manageCacheSize(); } return response; } /** * Play audio with optimized loading */ async playAudio(audioData) { return new Promise((resolve, reject) => { try { const audioSrc = audioData.startsWith('data:') ? audioData : `data:audio/${this.config.audioFormat};base64,${audioData}`; const audio = new Audio(audioSrc); // Optimize audio loading audio.preload = 'auto'; audio.crossOrigin = 'anonymous'; audio.onloadstart = () => this.emit('audioLoadStart', audioData); audio.oncanplay = () => this.emit('audioCanPlay', audioData); audio.onplay = () => this.emit('audioPlayStart', audioData); audio.onended = () => { this.emit('audioPlayEnd', audioData); resolve(); }; audio.onerror = (error) => { this.emit('audioError', error); reject(error); }; audio.play().catch(reject); } catch (error) { this.emit('audioError', error); reject(error); } }); } // Request preparation and optimization methods async prepareRequestBody(input, config) { const requestBody = { apiKey: config.apiKey, voiceConfig: { language: config.language, voiceId: config.voiceId, audioFormat: config.audioFormat, priority: config.priority, mode: config.mode, enableSTT: config.enableSTT, enableTTS: config.enableTTS, }, conversationHistory: this.conversationHistory }; // Handle different input types if (input instanceof Blob || input instanceof File) { const base64Audio = await this.blobToBase64(input); requestBody.audioData = { audio: base64Audio, mimeType: input.type }; } else if (input?.audio && input?.mimeType) { requestBody.audioData = input; } else if (typeof input === 'string') { requestBody.message = input; } return requestBody; } async makeRequest(endpoint, body) { const url = `${this.config.baseUrl}${endpoint}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: this.abortController?.signal, // Optimization headers keepalive: true, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response.json(); } // Caching and performance methods generateCacheKey(text, config) { return `${text}_${config.voiceId}_${config.language}_${config.audioFormat}`; } getCachedResponse(text, config) { const cacheKey = this.generateCacheKey(text, config); const cachedAudio = this.audioCache.get(cacheKey); if (cachedAudio) { return { success: true, message: text, audio: cachedAudio, metadata: { processingTime: 0, endpoint: 'cache', cached: true } }; } return null; } cacheResponse(text, config, response) { if (response.audio) { const cacheKey = this.generateCacheKey(text, config); this.audioCache.set(cacheKey, response.audio); this.manageCacheSize(); } } manageCacheSize() { if (this.audioCache.size > this.config.maxCacheSize) { // Remove oldest entries (simple LRU) const entriesToRemove = this.audioCache.size - this.config.maxCacheSize; const entries = Array.from(this.audioCache.entries()); for (let i = 0; i < entriesToRemove; i++) { this.audioCache.delete(entries[i][0]); } } } updatePerformanceMetrics(startTime, fromCache) { const processingTime = performance.now() - startTime; this.performanceMetrics.requestCount++; if (fromCache) { this.performanceMetrics.cacheHitRate = (this.performanceMetrics.cacheHitRate * (this.performanceMetrics.requestCount - 1) + 1) / this.performanceMetrics.requestCount; } else { this.performanceMetrics.averageLatency = (this.performanceMetrics.averageLatency * (this.performanceMetrics.requestCount - 1) + processingTime) / this.performanceMetrics.requestCount; } } handleError(error) { this.performanceMetrics.errorRate = (this.performanceMetrics.errorRate * this.performanceMetrics.requestCount + 1) / (this.performanceMetrics.requestCount + 1); this.emit('error', error); } // Utility methods async blobToBase64(blob) { return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => { const base64 = reader.result.split(',')[1]; resolve(base64); }; reader.readAsDataURL(blob); }); } // Public API methods getConversationHistory() { return [...this.conversationHistory]; } clearConversationHistory() { this.conversationHistory = []; this.emit('conversationCleared'); } clearCache() { this.audioCache.clear(); this.emit('cacheCleared'); } getPerformanceMetrics() { return { ...this.performanceMetrics }; } getStatus() { return { isProcessing: this.isProcessing, cacheSize: this.audioCache.size, conversationLength: this.conversationHistory.length, config: { ...this.config }, performance: this.getPerformanceMetrics() }; } updateConfig(updates) { this.config = { ...this.config, ...updates }; this.emit('configUpdated', this.config); } destroy() { this.abortController?.abort(); this.clearCache(); this.removeAllListeners(); } } /** * Ultra-fast Voice Recorder with advanced VAD and low-latency processing */ class VoiceRecorder extends EventEmitter { constructor(config = {}) { super(); this.mediaRecorder = null; this.mediaStream = null; this.audioContext = null; this.analyser = null; this.scriptProcessor = null; this.recordingState = { isRecording: false, isProcessing: false, voiceActivityDetected: false, audioLevel: 0, recordingDuration: 0, error: null }; this.audioChunks = []; this.recordingStartTime = 0; this.silenceTimer = null; this.vadTimer = null; this.animationFrame = null; // Performance monitoring this.performanceMetrics = { totalRecordings: 0, averageRecordingDuration: 0, vadAccuracy: 0, processingLatency: 0 }; this.config = { // Audio settings sampleRate: config.sampleRate || 44100, channels: config.channels || 1, audioBitsPerSecond: config.audioBitsPerSecond || 128000, mimeType: config.mimeType || 'audio/webm;codecs=opus', // Voice Activity Detection enableVAD: config.enableVAD ?? true, vadThreshold: config.vadThreshold || 0.01, silenceTimeout: config.silenceTimeout || 1500, minRecordingTime: config.minRecordingTime || 500, maxRecordingTime: config.maxRecordingTime || 30000, // Performance optimizations chunkDuration: config.chunkDuration || 100, enableNoiseSuppression: config.enableNoiseSuppression ?? true, enableEchoCancellation: config.enableEchoCancellation ?? true, enableAutoGainControl: config.enableAutoGainControl ?? true, // Ultra-fast mode ultraFastMode: config.ultraFastMode ?? true, enableRealTimeProcessing: config.enableRealTimeProcessing ?? false, bufferSize: config.bufferSize || 4096, }; this.initializeAudioContext(); } async initializeAudioContext() { try { // Create optimized audio context const AudioContextClass = window.AudioContext || window.webkitAudioContext; this.audioContext = new AudioContextClass({ sampleRate: this.config.sampleRate, latencyHint: this.config.ultraFastMode ? 'interactive' : 'balanced', }); // Resume if suspended if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); } this.emit('initialized'); } catch (error) { this.handleError('Failed to initialize audio context', error); } } /** * Start recording with optimized settings */ async startRecording() { if (this.recordingState.isRecording) { throw new Error('Already recording'); } const startTime = performance.now(); try { // Get optimized media stream this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: this.config.sampleRate, channelCount: this.config.channels, echoCancellation: this.config.enableEchoCancellation, noiseSuppression: this.config.enableNoiseSuppression, autoGainControl: this.config.enableAutoGainControl, // Ultra-fast mode optimizations ...(this.config.ultraFastMode && { latency: 0.01, // 10ms latency sampleSize: 16, }) } }); // Setup audio analysis await this.setupAudioAnalysis(); // Setup media recorder with optimized settings this.setupMediaRecorder(); // Start recording this.audioChunks = []; this.recordingStartTime = Date.now(); this.mediaRecorder.start(this.config.chunkDuration); // Update state this.recordingState = { ...this.recordingState, isRecording: true, error: null, recordingDuration: 0 }; // Start VAD if enabled if (this.config.enableVAD) { this.startVoiceActivityDetection(); } // Start duration tracking this.startDurationTracking(); // Track performance const initLatency = performance.now() - startTime; this.updatePerformanceMetrics('initLatency', initLatency); this.emit('recordingStarted', { timestamp: this.recordingStartTime, config: this.config }); } catch (error) { this.handleError('Failed to start recording', error); throw error; } } /** * Stop recording and return audio data */ async stopRecording() { if (!this.recordingState.isRecording) { throw new Error('Not currently recording'); } const stopTime = performance.now(); return new Promise((resolve, reject) => { if (!this.mediaRecorder) { reject(new Error('Media recorder not initialized')); return; } // Setup completion handler const handleStop = () => { const recordingDuration = Date.now() - this.recordingStartTime; // Combine all audio chunks const audioBlob = new Blob(this.audioChunks, { type: this.config.mimeType }); // Update performance metrics this.performanceMetrics.totalRecordings++; this.performanceMetrics.averageRecordingDuration = (this.performanceMetrics.averageRecordingDuration * (this.performanceMetrics.totalRecordings - 1) + recordingDuration) / this.performanceMetrics.totalRecordings; // Cleanup this.cleanup(); // Update state this.recordingState = { ...this.recordingState, isRecording: false, recordingDuration }; const processingTime = performance.now() - stopTime; this.emit('recordingStopped', { audioBlob, duration: recordingDuration, processingTime, chunks: this.audioChunks.length }); resolve(audioBlob); }; // Handle data available this.mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { this.audioChunks.push(event.data); // Emit chunk for real-time processing if enabled if (this.config.enableRealTimeProcessing) { this.emit('audioChunk', { data: event.data, timestamp: Date.now(), duration: this.config.chunkDuration, isLast: false }); } } }; this.mediaRecorder.onstop = handleStop; this.mediaRecorder.onerror = (event) => { this.handleError('Media recorder error', event); reject(new Error('Recording failed')); }; // Stop recording this.mediaRecorder.stop(); // Stop timers this.stopVoiceActivityDetection(); this.stopDurationTracking(); }); } /** * Setup audio analysis for VAD and level monitoring */ async setupAudioAnalysis() { if (!this.audioContext || !this.mediaStream) { throw new Error('Audio context or media stream not available'); } try { // Create analyser for real-time audio analysis this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = this.config.bufferSize; this.analyser.smoothingTimeConstant = 0.1; // Faster response this.analyser.minDecibels = -100; this.analyser.maxDecibels = -10; // Connect media stream to analyser const source = this.audioContext.createMediaStreamSource(this.mediaStream); source.connect(this.analyser); // Setup script processor for real-time processing (if enabled) if (this.config.enableRealTimeProcessing) { this.scriptProcessor = this.audioContext.createScriptProcessor(this.config.bufferSize, this.config.channels, this.config.channels); this.scriptProcessor.onaudioprocess = (event) => { const inputBuffer = event.inputBuffer.getChannelData(0); this.processAudioBuffer(inputBuffer); }; source.connect(this.scriptProcessor); this.scriptProcessor.connect(this.audioContext.destination); } } catch (error) { throw new Error(`Failed to setup audio analysis: ${error}`); } } /** * Setup optimized media recorder */ setupMediaRecorder() { if (!this.mediaStream) { throw new Error('Media stream not available'); } try { // Find the best supported MIME type const mimeType = this.findBestMimeType(); this.mediaRecorder = new MediaRecorder(this.mediaStream, { mimeType, audioBitsPerSecond: this.config.audioBitsPerSecond, }); // Update config with actual MIME type this.config.mimeType = mimeType; } catch (error) { throw new Error(`Failed to create media recorder: ${error}`); } } /** * Find the best supported MIME type for recording */ findBestMimeType() { const mimeTypes = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav' ]; for (const mimeType of mimeTypes) { if (MediaRecorder.isTypeSupported(mimeType)) { return mimeType; } } // Fallback to any supported type return this.config.mimeType; } /** * Voice Activity Detection with optimized algorithms */ startVoiceActivityDetection() { if (!this.analyser) return; const detectVoiceActivity = () => { if (!this.analyser || !this.recordingState.isRecording) return; const dataArray = new Uint8Array(this.analyser.frequencyBinCount); this.analyser.getByteFrequencyData(dataArray); // Calculate RMS (Root Mean Square) for better VAD const sum = dataArray.reduce((acc, value) => acc + value * value, 0); const rms = Math.sqrt(sum / dataArray.length); const normalizedLevel = rms / 255; // Update audio level this.recordingState.audioLevel = normalizedLevel; // Voice activity detection const isVoiceDetected = normalizedLevel > this.config.vadThreshold; const previousVAD = this.recordingState.voiceActivityDetected; this.recordingState.voiceActivityDetected = isVoiceDetected; // Emit level updates this.emit('audioLevel', { level: normalizedLevel, isVoiceDetected, timestamp: Date.now() }); // Handle voice activity changes if (isVoiceDetected && !previousVAD) { this.emit('voiceStart', { timestamp: Date.now() }); this.clearSilenceTimer(); } else if (!isVoiceDetected && previousVAD) { this.emit('voiceEnd', { timestamp: Date.now() }); this.startSilenceTimer(); } // Continue monitoring this.animationFrame = requestAnimationFrame(detectVoiceActivity); }; detectVoiceActivity(); } /** * Stop voice activity detection */ stopVoiceActivityDetection() { if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } this.clearSilenceTimer(); } /** * Start silence timer for auto-stop */ startSilenceTimer() { this.clearSilenceTimer(); this.silenceTimer = setTimeout(async () => { const recordingDuration = Date.now() - this.recordingStartTime; // Only auto-stop if recording is long enough if (recordingDuration >= this.config.minRecordingTime) { this.emit('silenceDetected', { duration: recordingDuration, autoStopping: true }); try { await this.stopRecording(); } catch (error) { this.handleError('Auto-stop failed', error); } } }, this.config.silenceTimeout); } /** * Clear silence timer */ clearSilenceTimer() { if (this.silenceTimer) { clearTimeout(this.silenceTimer); this.silenceTimer = null; } } /** * Start duration tracking with auto-stop */ startDurationTracking() { const updateDuration = () => { if (!this.recordingState.isRecording) return; const duration = Date.now() - this.recordingStartTime; this.recordingState.recordingDuration = duration; // Auto-stop if max duration reached if (duration >= this.config.maxRecordingTime) { this.emit('maxDurationReached', { duration }); this.stopRecording().catch(error => { this.handleError('Auto-stop on max duration failed', error); }); return; } // Continue tracking setTimeout(updateDuration, 100); }; updateDuration(); } /** * Stop duration tracking */ stopDurationTracking() { // Duration tracking stops automatically when recording stops } /** * Process audio buffer for real-time analysis */ processAudioBuffer(buffer) { // Calculate additional metrics const peak = Math.max(...buffer.map(Math.abs)); const energy = buffer.reduce((sum, sample) => sum + sample * sample, 0) / buffer.length; this.emit('audioBuffer', { buffer: buffer.slice(), // Copy for safety peak, energy, timestamp: Date.now() }); } /** * Update performance metrics */ updatePerformanceMetrics(metric, value) { if (metric === 'initLatency') { this.performanceMetrics.processingLatency = (this.performanceMetrics.processingLatency + value) / 2; } } /** * Handle errors consistently */ handleError(message, error) { const errorMessage = error instanceof Error ? error.message : String(error); this.recordingState.error = `${message}: ${errorMessage}`; console.error(`VoiceRecorder Error - ${message}:`, error); this.emit('error', { message, error: errorMessage, timestamp: Date.now() }); } /** * Cleanup resources */ cleanup() { // Stop tracks if (this.mediaStream) { this.mediaStream.getTracks().forEach(track => track.stop()); this.mediaStream = null; } // Cleanup audio nodes if (this.scriptProcessor) { this.scriptProcessor.disconnect(); this.scriptProcessor = null; } if (this.analyser) { this.analyser.disconnect(); this.analyser = null; } // Clear timers this.stopVoiceActivityDetection(); this.clearSilenceTimer(); } // Public API methods getState() { return { ...this.recordingState }; } getConfig() { return { ...this.config }; } getPerformanceMetrics() { return { ...this.performanceMetrics }; } updateConfig(updates) { this.config = { ...this.config, ...updates }; this.emit('configUpdated', this.config); } /** * Check if browser supports recording */ static isSupported() { return !!(typeof navigator !== 'undefined' && navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function' && typeof MediaRecorder !== 'undefined' && (window.location.protocol === 'https:' || window.location.hostname === 'localhost')); } /** * Get supported MIME types */ static getSupportedMimeTypes() { const mimeTypes = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav' ]; return mimeTypes.filter(type => MediaRecorder.isTypeSupported(type)); } /** * Destroy recorder and cleanup all resources */ destroy() { if (this.recordingState.isRecording) { this.stopRecording().catch(() => { // Ignore stop errors during destroy }); } this.cleanup(); if (this.audioContext && this.audioContext.state !== 'closed') { this.audioContext.close(); } this.removeAllListeners(); } } /** * Voice Text Formatting Utilities * Handles content shortening and number formatting for voice interactions */ // Helper function to detect language and style function detectLanguage(text) { // Detect Devanagari characters const hindiRegex = /[\u0900-\u097F]/; const englishRegex = /[a-zA-Z]/; const hasHindi = hindiRegex.test(text); const hasEnglish = englishRegex.test(text); // Check for common Hinglish patterns const hinglishPatterns = [ /\b(acha|achha|kya|hai|hoon|main|meri|tera|uska|yeh|woh|kar|karo|kaise|kahan|kab|kyun|nahi|haan|ji|bhi|toh|phir|abhi|wala|wali|vale)\b/i, /\b(loan|EMI|credit|score|bank|amount|rupees|lakh|crore|property|home|apply|approve|interest|rate)\b/i ]; const hasHinglishPatterns = hinglishPatterns.some(pattern => pattern.test(text)); if (hasHindi && hasEnglish) return 'mixed'; if (hasHindi) return 'hindi'; if (hasHinglishPatterns && hasEnglish) return 'hinglish'; return 'english'; } // Helper function to format currency for voice function formatCurrencyForVoice(text) { // Convert ₹ symbol to "rupees" for voice text = text.replace(/₹\s*(\d+(?:,\d+)*(?:\.\d+)?)/g, (match, amount) => { // Remove commas and parse const numAmount = parseFloat(amount.replace(/,/g, '')); if (numAmount >= 10000000) { // 1 crore const crores = Math.floor(numAmount / 10000000); const remainder = numAmount % 10000000; if (remainder === 0) { return `rupees ${crores} crore`; } else if (remainder >= 100000) { const lakhs = Math.floor(remainder / 100000); return `rupees ${crores} crore ${lakhs} lakh`; } else if (remainder >= 1000) { const thousands = Math.floor(remainder / 1000); return `rupees ${crores} crore ${thousands} thousand`; } else { return `rupees ${crores} crore ${remainder}`; } } else if (numAmount >= 100000) { // 1 lakh const lakhs = Math.floor(numAmount / 100000); const remainder = numAmount % 100000; if (remainder === 0) { return `rupees ${lakhs} lakh`; } else if (remainder >= 1000) { const thousands = Math.floor(remainder / 1000); return `rupees ${lakhs} lakh ${thousands} thousand`; } else { return `rupees ${lakhs} lakh ${remainder}`; } } else if (numAmount >= 1000) { // 1 thousand const thousands = Math.floor(numAmount / 1000); const remainder = numAmount % 1000; if (remainder === 0) { return `rupees ${thousands} thousand`; } else { return `rupees ${thousands} thousand ${remainder}`; } } else { return `rupees ${numAmount}`; } }); // Also handle Rs. and other currency formats text = text.replace(/Rs\.?\s*(\d+(?:,\d+)*(?:\.\d+)?)/g, (match, amount) => { const numAmount = parseFloat(amount.replace(/,/g, '')); if (numAmount >= 10000000) { const crores = Math.floor(numAmount / 10000000); return `rupees ${crores} crore`; } else if (numAmount >= 100000) { const lakhs = Math.floor(numAmount / 100000); return `rupees ${lakhs} lakh`; } else if (numAmount >= 1000) { const thousands = Math.floor(numAmount / 1000); return `rupees ${thousands} thousand`; } else { return `rupees ${numAmount}`; } }); // Handle percentage signs for voice text = text.replace(/(\d+(?:\.\d+)?)\s*%/g, '$1 percent'); // Handle standalone lakh/crore numbers text = text.replace(/(\d+(?:\.\d+)?)\s*lakh/gi, '$1 lakh'); text = text.replace(/(\d+(?:\.\d+)?)\s*crore/gi, '$1 crore'); // Handle EMI amounts (common in voice) text = text.replace(/EMI\s*(?:of|is)?\s*₹\s*(\d+(?:,\d+)*)/g, (match, amount) => { const numAmount = parseFloat(amount.replace(/,/g, '')); if (numAmount >= 100000) { const lakhs = Math.floor(numAmount / 100000); return `EMI of rupees ${lakhs} lakh`; } else if (numAmount >= 1000) { const thousands = Math.floor(numAmount / 1000); return `EMI of rupees ${thousands} thousand`; } else { return `EMI of rupees ${numAmount}`; } }); return text; } // Function to convert digits to words (for project names) function numberToWords(num) { const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; const teens = ['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']; const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']; if (num === 0) return 'zero'; function helper(n) { if (n < 10) return ones[n]; if (n < 20) return teens[n - 10]; if (n < 100) return tens[Math.floor(n / 10)] + (n % 10 > 0 ? ' ' + ones[n % 10] : ''); if (n < 1000) return ones[Math.floor(n / 100)] + ' hundred' + (n % 100 > 0 ? ' ' + helper(n % 100) : ''); // Indian numbering system: thousands, lakhs, crores if (n < 100000) { // Less than 1 lakh return helper(Math.floor(n / 1000)) + ' thousand' + (n % 1000 > 0 ? ' ' + helper(n % 1000) : ''); } if (n < 10000000) { // Less than 1 crore return helper(Math.floor(n / 100000)) + ' lakh' + (n % 100000 > 0 ? ' ' + helper(n % 100000) : ''); } // 1 crore and above return helper(Math.floor(n / 10000000)) + ' crore' + (n % 10000000 > 0 ? ' ' + helper(n % 10000000) : ''); } return helper(num); } // Function to convert individual digits to words (for project names like "101") function digitsToWords(digits) { const digitMap = { '0': 'zero', '1': 'one',