@varunmhajan/hom-i-voice-ai
Version:
Voice AI utilities for home loan assistance with India-specific formatting
1,398 lines (1,342 loc) • 64.3 kB
JavaScript
'use strict';
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',