import OpenAI from 'openai';
// Initialize OpenAI
const apiKey = import.meta.env.VITE_OPENAI_API_KEY;
const openai = apiKey ? new OpenAI({
apiKey: apiKey,
dangerouslyAllowBrowser: true // Note: In production, this should be done server-side
}) : null;
export interface VoiceServiceConfig {
language?: string; // ISO 639-1 language code (e.g., 'en', 'es', 'fr', 'de', 'ru', 'zh')
voice?: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
speed?: number; // 0.25 to 4.0
}
class VoiceService {
private mediaRecorder: MediaRecorder | null = null;
private audioChunks: Blob[] = [];
private currentAudio: HTMLAudioElement | null = null;
/**
* Check if OpenAI API is available
*/
isAvailable(): boolean {
return !!openai;
}
/**
* Record audio from microphone
*/
async startRecording(): Promise<void> {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Media devices not supported in this browser');
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Use audio/webm for better browser compatibility
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: 'audio/mp4';
this.mediaRecorder = new MediaRecorder(stream, { mimeType });
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.start();
} catch (error) {
console.error('Error accessing microphone:', error);
throw new Error('Failed to access microphone. Please check permissions.');
}
}
/**
* Stop recording and return audio blob
*/
async stopRecording(): Promise<Blob> {
return new Promise((resolve, reject) => {
if (!this.mediaRecorder) {
reject(new Error('No active recording'));
return;
}
this.mediaRecorder.onstop = () => {
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
// Stop all tracks
if (this.mediaRecorder?.stream) {
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
resolve(audioBlob);
};
this.mediaRecorder.stop();
});
}
/**
* Transcribe audio using OpenAI Whisper API
* Supports multiple languages automatically
*/
async transcribeAudio(audioBlob: Blob, config?: VoiceServiceConfig): Promise<string> {
if (!openai) {
throw new Error('OpenAI API key not configured. Please add VITE_OPENAI_API_KEY to your .env file');
}
try {
// Convert blob to File object (required by OpenAI API)
const audioFile = new File([audioBlob], 'audio.webm', { type: audioBlob.type });
const transcription = await openai.audio.transcriptions.create({
file: audioFile,
model: 'whisper-1',
language: config?.language, // Optional: specify language code or let Whisper auto-detect
response_format: 'text',
});
return transcription as unknown as string;
} catch (error) {
console.error('Transcription error:', error);
throw new Error('Failed to transcribe audio. Please try again.');
}
}
/**
* Convert text to speech using OpenAI TTS API
*/
async textToSpeech(text: string, config?: VoiceServiceConfig): Promise<Blob> {
if (!openai) {
throw new Error('OpenAI API key not configured. Please add VITE_OPENAI_API_KEY to your .env file');
}
try {
const response = await openai.audio.speech.create({
model: 'tts-1', // or 'tts-1-hd' for higher quality
voice: config?.voice || 'alloy',
input: text,
speed: config?.speed || 1.0,
});
const audioBlob = await response.blob();
return audioBlob;
} catch (error) {
console.error('Text-to-speech error:', error);
throw new Error('Failed to generate speech. Please try again.');
}
}
/**
* Play audio blob
*/
playAudio(audioBlob: Blob): Promise<void> {
return new Promise((resolve, reject) => {
try {
// Stop any currently playing audio
this.stopAudio();
const audioUrl = URL.createObjectURL(audioBlob);
this.currentAudio = new Audio(audioUrl);
this.currentAudio.onended = () => {
URL.revokeObjectURL(audioUrl);
resolve();
};
this.currentAudio.onerror = () => {
URL.revokeObjectURL(audioUrl);
reject(new Error('Failed to play audio'));
};
this.currentAudio.play();
} catch (error) {
console.error('Audio playback error:', error);
reject(error);
}
});
}
/**
* Stop currently playing audio
*/
stopAudio(): void {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
this.currentAudio = null;
}
}
/**
* Check if audio is currently playing
*/
isPlaying(): boolean {
return this.currentAudio !== null && !this.currentAudio.paused;
}
/**
* Cancel recording
*/
cancelRecording(): void {
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
if (this.mediaRecorder.stream) {
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
}
this.audioChunks = [];
}
}
// Export singleton instance
export const voiceService = new VoiceService();