Features: - Speaker management system with unique IDs and colors - Visual speaker selection with avatars and color coding - Automatic language detection per speaker - Real-time translation for all speakers' languages - Conversation history with speaker attribution - Export conversation as text file - Persistent speaker data in localStorage UI Components: - Speaker toolbar with add/remove controls - Active speaker indicators - Conversation view with color-coded messages - Settings toggle for multi-speaker mode - Mobile-responsive speaker buttons Technical Implementation: - SpeakerManager class handles all speaker operations - Automatic translation to all active languages - Conversation entries with timestamps - Translation caching per language - Clean separation of original vs translated text - Support for up to 8 concurrent speakers User Experience: - Click to switch active speaker - Visual feedback for active speaker - Conversation flows naturally with colors - Export feature for meeting minutes - Clear conversation history option - Seamless single/multi speaker mode switching This enables group conversations where each participant can speak in their native language and see translations in real-time. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1612 lines
68 KiB
TypeScript
1612 lines
68 KiB
TypeScript
// Main application TypeScript with PWA support
|
|
import {
|
|
TranscriptionResponse,
|
|
TranslationResponse,
|
|
TTSResponse,
|
|
TTSServerStatus,
|
|
TTSConfigUpdate,
|
|
TTSConfigResponse,
|
|
TranslationRequest,
|
|
TTSRequest,
|
|
PushPublicKeyResponse,
|
|
TranscriptionRecord,
|
|
TranslationRecord,
|
|
ServiceWorkerRegistrationExtended,
|
|
BeforeInstallPromptEvent
|
|
} from './types';
|
|
import { TranslationCache } from './translationCache';
|
|
import { RequestQueueManager } from './requestQueue';
|
|
import { ErrorBoundary } from './errorBoundary';
|
|
import { Validator } from './validator';
|
|
import { StreamingTranslation } from './streamingTranslation';
|
|
import { PerformanceMonitor } from './performanceMonitor';
|
|
import { SpeakerManager } from './speakerManager';
|
|
|
|
// Initialize error boundary
|
|
const errorBoundary = ErrorBoundary.getInstance();
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Set up global error handler
|
|
errorBoundary.setGlobalErrorHandler((error, errorInfo) => {
|
|
console.error('Global error caught:', error);
|
|
|
|
// Show user-friendly message based on component
|
|
if (errorInfo.component === 'transcription') {
|
|
const statusIndicator = document.getElementById('statusIndicator');
|
|
if (statusIndicator) {
|
|
statusIndicator.textContent = 'Transcription failed. Please try again.';
|
|
statusIndicator.classList.add('text-danger');
|
|
}
|
|
} else if (errorInfo.component === 'translation') {
|
|
const translatedText = document.getElementById('translatedText');
|
|
if (translatedText) {
|
|
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wrap initialization functions with error boundaries
|
|
const safeRegisterServiceWorker = errorBoundary.wrapAsync(
|
|
registerServiceWorker,
|
|
'service-worker',
|
|
async () => console.warn('Service worker registration failed, continuing without PWA features')
|
|
);
|
|
|
|
const safeInitApp = errorBoundary.wrap(
|
|
initApp,
|
|
'app-init',
|
|
() => console.error('App initialization failed')
|
|
);
|
|
|
|
const safeInitInstallPrompt = errorBoundary.wrap(
|
|
initInstallPrompt,
|
|
'install-prompt',
|
|
() => console.warn('Install prompt initialization failed')
|
|
);
|
|
|
|
// Register service worker
|
|
if ('serviceWorker' in navigator) {
|
|
safeRegisterServiceWorker();
|
|
}
|
|
|
|
// Initialize app
|
|
safeInitApp();
|
|
|
|
// Check for PWA installation prompts
|
|
safeInitInstallPrompt();
|
|
});
|
|
|
|
// Service Worker Registration
|
|
async function registerServiceWorker(): Promise<void> {
|
|
try {
|
|
const registration = await navigator.serviceWorker.register('/service-worker.js') as ServiceWorkerRegistrationExtended;
|
|
console.log('Service Worker registered with scope:', registration.scope);
|
|
|
|
// Setup periodic sync if available
|
|
if ('periodicSync' in registration && registration.periodicSync) {
|
|
// Request permission for background sync
|
|
const status = await navigator.permissions.query({
|
|
name: 'periodic-background-sync' as PermissionName,
|
|
});
|
|
|
|
if (status.state === 'granted') {
|
|
try {
|
|
// Register for background sync to check for updates
|
|
await registration.periodicSync.register('translation-updates', {
|
|
minInterval: 24 * 60 * 60 * 1000, // once per day
|
|
});
|
|
console.log('Periodic background sync registered');
|
|
} catch (error) {
|
|
console.error('Periodic background sync could not be registered:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Setup push notification if available
|
|
if ('PushManager' in window) {
|
|
setupPushNotifications(registration);
|
|
}
|
|
} catch (error) {
|
|
console.error('Service Worker registration failed:', error);
|
|
}
|
|
}
|
|
|
|
// Initialize the main application
|
|
function initApp(): void {
|
|
// DOM elements
|
|
const recordBtn = document.getElementById('recordBtn') as HTMLButtonElement;
|
|
const translateBtn = document.getElementById('translateBtn') as HTMLButtonElement;
|
|
const sourceText = document.getElementById('sourceText') as HTMLDivElement;
|
|
const translatedText = document.getElementById('translatedText') as HTMLDivElement;
|
|
const sourceLanguage = document.getElementById('sourceLanguage') as HTMLSelectElement;
|
|
const targetLanguage = document.getElementById('targetLanguage') as HTMLSelectElement;
|
|
const playSource = document.getElementById('playSource') as HTMLButtonElement;
|
|
const playTranslation = document.getElementById('playTranslation') as HTMLButtonElement;
|
|
const clearSource = document.getElementById('clearSource') as HTMLButtonElement;
|
|
const clearTranslation = document.getElementById('clearTranslation') as HTMLButtonElement;
|
|
const statusIndicator = document.getElementById('statusIndicator') as HTMLParagraphElement;
|
|
const progressContainer = document.getElementById('progressContainer') as HTMLDivElement;
|
|
const progressBar = document.getElementById('progressBar') as HTMLDivElement;
|
|
const audioPlayer = document.getElementById('audioPlayer') as HTMLAudioElement;
|
|
const ttsServerAlert = document.getElementById('ttsServerAlert') as HTMLDivElement;
|
|
const ttsServerMessage = document.getElementById('ttsServerMessage') as HTMLSpanElement;
|
|
const ttsServerUrl = document.getElementById('ttsServerUrl') as HTMLInputElement;
|
|
const ttsApiKey = document.getElementById('ttsApiKey') as HTMLInputElement;
|
|
const updateTtsServer = document.getElementById('updateTtsServer') as HTMLButtonElement;
|
|
const loadingOverlay = document.getElementById('loadingOverlay') as HTMLDivElement;
|
|
const loadingText = document.getElementById('loadingText') as HTMLParagraphElement;
|
|
|
|
// Set initial values
|
|
let isRecording: boolean = false;
|
|
let mediaRecorder: MediaRecorder | null = null;
|
|
let audioChunks: Blob[] = [];
|
|
let currentSourceText: string = '';
|
|
let currentTranslationText: string = '';
|
|
let currentTtsServerUrl: string = '';
|
|
|
|
// Performance monitoring
|
|
const performanceMonitor = PerformanceMonitor.getInstance();
|
|
|
|
// Speaker management
|
|
const speakerManager = SpeakerManager.getInstance();
|
|
let multiSpeakerEnabled = false;
|
|
|
|
// Check TTS server status on page load
|
|
checkTtsServer();
|
|
|
|
// Check for saved translations in IndexedDB
|
|
loadSavedTranslations();
|
|
|
|
// Initialize queue status updates
|
|
initQueueStatus();
|
|
|
|
// Start health monitoring
|
|
startHealthMonitoring();
|
|
|
|
// Initialize multi-speaker mode
|
|
initMultiSpeakerMode();
|
|
|
|
// Multi-speaker mode implementation
|
|
function initMultiSpeakerMode(): void {
|
|
const multiSpeakerToggle = document.getElementById('toggleMultiSpeaker') as HTMLButtonElement;
|
|
const multiSpeakerStatus = document.getElementById('multiSpeakerStatus') as HTMLSpanElement;
|
|
const speakerToolbar = document.getElementById('speakerToolbar') as HTMLDivElement;
|
|
const conversationView = document.getElementById('conversationView') as HTMLDivElement;
|
|
const multiSpeakerModeCheckbox = document.getElementById('multiSpeakerMode') as HTMLInputElement;
|
|
|
|
// Load saved preference
|
|
multiSpeakerEnabled = localStorage.getItem('multiSpeakerMode') === 'true';
|
|
if (multiSpeakerModeCheckbox) {
|
|
multiSpeakerModeCheckbox.checked = multiSpeakerEnabled;
|
|
}
|
|
|
|
// Show/hide multi-speaker UI based on setting
|
|
if (multiSpeakerEnabled) {
|
|
speakerToolbar.style.display = 'block';
|
|
conversationView.style.display = 'block';
|
|
multiSpeakerStatus.textContent = 'ON';
|
|
}
|
|
|
|
// Toggle multi-speaker mode
|
|
multiSpeakerToggle?.addEventListener('click', () => {
|
|
multiSpeakerEnabled = !multiSpeakerEnabled;
|
|
multiSpeakerStatus.textContent = multiSpeakerEnabled ? 'ON' : 'OFF';
|
|
|
|
if (multiSpeakerEnabled) {
|
|
speakerToolbar.style.display = 'block';
|
|
conversationView.style.display = 'block';
|
|
|
|
// Add default speaker if none exist
|
|
if (speakerManager.getAllSpeakers().length === 0) {
|
|
const defaultSpeaker = speakerManager.addSpeaker('Speaker 1', sourceLanguage.value);
|
|
speakerManager.setActiveSpeaker(defaultSpeaker.id);
|
|
updateSpeakerUI();
|
|
}
|
|
} else {
|
|
speakerToolbar.style.display = 'none';
|
|
conversationView.style.display = 'none';
|
|
}
|
|
|
|
localStorage.setItem('multiSpeakerMode', multiSpeakerEnabled.toString());
|
|
if (multiSpeakerModeCheckbox) {
|
|
multiSpeakerModeCheckbox.checked = multiSpeakerEnabled;
|
|
}
|
|
});
|
|
|
|
// Add speaker button
|
|
document.getElementById('addSpeakerBtn')?.addEventListener('click', () => {
|
|
const name = prompt('Enter speaker name:');
|
|
if (name) {
|
|
const speaker = speakerManager.addSpeaker(name, sourceLanguage.value);
|
|
speakerManager.setActiveSpeaker(speaker.id);
|
|
updateSpeakerUI();
|
|
}
|
|
});
|
|
|
|
// Update speaker UI
|
|
function updateSpeakerUI(): void {
|
|
const speakerList = document.getElementById('speakerList') as HTMLDivElement;
|
|
speakerList.innerHTML = '';
|
|
|
|
speakerManager.getAllSpeakers().forEach(speaker => {
|
|
const btn = document.createElement('button');
|
|
btn.className = `speaker-button ${speaker.isActive ? 'active' : ''}`;
|
|
btn.style.borderColor = speaker.color;
|
|
btn.style.backgroundColor = speaker.isActive ? speaker.color : 'white';
|
|
btn.style.color = speaker.isActive ? 'white' : speaker.color;
|
|
|
|
btn.innerHTML = `
|
|
<span class="speaker-avatar">${speaker.avatar}</span>
|
|
${speaker.name}
|
|
`;
|
|
|
|
btn.addEventListener('click', () => {
|
|
speakerManager.setActiveSpeaker(speaker.id);
|
|
updateSpeakerUI();
|
|
});
|
|
|
|
speakerList.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
// Export conversation
|
|
document.getElementById('exportConversation')?.addEventListener('click', () => {
|
|
const text = speakerManager.exportConversation(targetLanguage.value);
|
|
const blob = new Blob([text], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `conversation_${new Date().toISOString()}.txt`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// Clear conversation
|
|
document.getElementById('clearConversation')?.addEventListener('click', () => {
|
|
if (confirm('Clear all conversation history?')) {
|
|
speakerManager.clearConversation();
|
|
updateConversationView();
|
|
}
|
|
});
|
|
|
|
// Update conversation view
|
|
function updateConversationView(): void {
|
|
const conversationContent = document.getElementById('conversationContent') as HTMLDivElement;
|
|
const entries = speakerManager.getConversationInLanguage(targetLanguage.value);
|
|
|
|
conversationContent.innerHTML = entries.map(entry => `
|
|
<div class="conversation-entry">
|
|
<div class="conversation-speaker">
|
|
<span class="conversation-speaker-avatar" style="background-color: ${entry.speakerColor}">
|
|
${entry.speakerName.substr(0, 2).toUpperCase()}
|
|
</span>
|
|
<span style="color: ${entry.speakerColor}">${entry.speakerName}</span>
|
|
<span class="conversation-time">${new Date(entry.timestamp).toLocaleTimeString()}</span>
|
|
</div>
|
|
<div class="conversation-text ${!entry.isOriginal ? 'conversation-translation' : ''}">
|
|
${Validator.sanitizeHTML(entry.text)}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Scroll to bottom
|
|
conversationContent.scrollTop = conversationContent.scrollHeight;
|
|
}
|
|
|
|
// Store reference to update function for use in transcription
|
|
(window as any).updateConversationView = updateConversationView;
|
|
(window as any).updateSpeakerUI = updateSpeakerUI;
|
|
}
|
|
|
|
// Update TTS server URL and API key
|
|
updateTtsServer.addEventListener('click', function() {
|
|
const newUrl = ttsServerUrl.value.trim();
|
|
const newApiKey = ttsApiKey.value.trim();
|
|
|
|
if (!newUrl && !newApiKey) {
|
|
alert('Please provide at least one value to update');
|
|
return;
|
|
}
|
|
|
|
const updateData: TTSConfigUpdate = {};
|
|
|
|
// Validate URL
|
|
if (newUrl) {
|
|
const validatedUrl = Validator.validateURL(newUrl);
|
|
if (!validatedUrl) {
|
|
alert('Invalid server URL. Please enter a valid HTTP/HTTPS URL.');
|
|
return;
|
|
}
|
|
updateData.server_url = validatedUrl;
|
|
}
|
|
|
|
// Validate API key
|
|
if (newApiKey) {
|
|
const validatedKey = Validator.validateAPIKey(newApiKey);
|
|
if (!validatedKey) {
|
|
alert('Invalid API key format. API keys should be 20-128 characters and contain only letters, numbers, dashes, and underscores.');
|
|
return;
|
|
}
|
|
updateData.api_key = validatedKey;
|
|
}
|
|
|
|
fetch('/update_tts_config', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(updateData)
|
|
})
|
|
.then(response => response.json() as Promise<TTSConfigResponse>)
|
|
.then(data => {
|
|
if (data.success) {
|
|
statusIndicator.textContent = 'TTS configuration updated';
|
|
// Save URL to localStorage but not the API key for security
|
|
if (newUrl) localStorage.setItem('ttsServerUrl', newUrl);
|
|
// Check TTS server with new configuration
|
|
checkTtsServer();
|
|
} else {
|
|
alert('Failed to update TTS configuration: ' + data.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Failed to update TTS config:', error);
|
|
alert('Failed to update TTS configuration. See console for details.');
|
|
});
|
|
});
|
|
|
|
// Make sure target language is different from source
|
|
if (targetLanguage.options[0].value === sourceLanguage.value) {
|
|
targetLanguage.selectedIndex = 1;
|
|
}
|
|
|
|
// Event listeners for language selection
|
|
sourceLanguage.addEventListener('change', function() {
|
|
// Skip conflict check for auto-detect
|
|
if (sourceLanguage.value === 'auto') {
|
|
return;
|
|
}
|
|
|
|
if (targetLanguage.value === sourceLanguage.value) {
|
|
for (let i = 0; i < targetLanguage.options.length; i++) {
|
|
if (targetLanguage.options[i].value !== sourceLanguage.value) {
|
|
targetLanguage.selectedIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
targetLanguage.addEventListener('change', function() {
|
|
if (targetLanguage.value === sourceLanguage.value) {
|
|
for (let i = 0; i < sourceLanguage.options.length; i++) {
|
|
if (sourceLanguage.options[i].value !== targetLanguage.value) {
|
|
sourceLanguage.selectedIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Record button click event
|
|
recordBtn.addEventListener('click', function() {
|
|
if (isRecording) {
|
|
stopRecording();
|
|
} else {
|
|
startRecording();
|
|
}
|
|
});
|
|
|
|
// Function to start recording
|
|
function startRecording(): void {
|
|
// Request audio with specific constraints for better compression
|
|
const audioConstraints = {
|
|
audio: {
|
|
channelCount: 1, // Mono audio (reduces size by 50%)
|
|
sampleRate: 16000, // Lower sample rate for speech (16kHz is enough for speech)
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
}
|
|
};
|
|
|
|
navigator.mediaDevices.getUserMedia(audioConstraints)
|
|
.then(stream => {
|
|
// Use webm/opus for better compression (if supported)
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus'
|
|
: 'audio/webm';
|
|
|
|
const options = {
|
|
mimeType: mimeType,
|
|
audioBitsPerSecond: 32000 // Low bitrate for speech (32 kbps)
|
|
};
|
|
|
|
try {
|
|
mediaRecorder = new MediaRecorder(stream, options);
|
|
} catch (e) {
|
|
// Fallback to default if options not supported
|
|
console.warn('Compression options not supported, using defaults');
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
}
|
|
|
|
audioChunks = [];
|
|
|
|
mediaRecorder.addEventListener('dataavailable', event => {
|
|
audioChunks.push(event.data);
|
|
});
|
|
|
|
mediaRecorder.addEventListener('stop', async () => {
|
|
// Create blob with appropriate MIME type
|
|
const mimeType = mediaRecorder?.mimeType || 'audio/webm';
|
|
const audioBlob = new Blob(audioChunks, { type: mimeType });
|
|
|
|
// Log compression results
|
|
const sizeInKB = (audioBlob.size / 1024).toFixed(2);
|
|
console.log(`Audio compressed to ${sizeInKB} KB (${mimeType})`);
|
|
|
|
// If the audio is still too large, we can compress it further
|
|
if (audioBlob.size > 500 * 1024) { // If larger than 500KB
|
|
statusIndicator.textContent = 'Compressing audio...';
|
|
const compressedBlob = await compressAudioBlob(audioBlob);
|
|
transcribeAudio(compressedBlob);
|
|
} else {
|
|
transcribeAudio(audioBlob);
|
|
}
|
|
});
|
|
|
|
mediaRecorder.start();
|
|
isRecording = true;
|
|
recordBtn.classList.add('recording');
|
|
recordBtn.classList.replace('btn-primary', 'btn-danger');
|
|
recordBtn.innerHTML = '<div class="recording-wave"><span></span><span></span><span></span><span></span><span></span></div>';
|
|
statusIndicator.textContent = 'Recording... Click to stop';
|
|
statusIndicator.classList.add('processing');
|
|
})
|
|
.catch(error => {
|
|
console.error('Error accessing microphone:', error);
|
|
alert('Error accessing microphone. Please make sure you have given permission for microphone access.');
|
|
});
|
|
}
|
|
|
|
// Function to stop recording
|
|
function stopRecording(): void {
|
|
if (!mediaRecorder) return;
|
|
|
|
mediaRecorder.stop();
|
|
isRecording = false;
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.classList.replace('btn-danger', 'btn-primary');
|
|
recordBtn.innerHTML = '<i class="fas fa-microphone"></i>';
|
|
statusIndicator.textContent = 'Processing audio...';
|
|
statusIndicator.classList.add('processing');
|
|
showLoadingOverlay('Transcribing your speech...');
|
|
|
|
// Stop all audio tracks
|
|
mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
|
}
|
|
|
|
// Function to compress audio blob if needed
|
|
async function compressAudioBlob(blob: Blob): Promise<Blob> {
|
|
return new Promise((resolve) => {
|
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const arrayBuffer = e.target?.result as ArrayBuffer;
|
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
|
|
// Downsample to 16kHz mono
|
|
const offlineContext = new OfflineAudioContext(1, audioBuffer.duration * 16000, 16000);
|
|
const source = offlineContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(offlineContext.destination);
|
|
source.start();
|
|
|
|
const compressedBuffer = await offlineContext.startRendering();
|
|
|
|
// Convert to WAV format
|
|
const wavBlob = audioBufferToWav(compressedBuffer);
|
|
const compressedSizeKB = (wavBlob.size / 1024).toFixed(2);
|
|
console.log(`Further compressed to ${compressedSizeKB} KB`);
|
|
|
|
resolve(wavBlob);
|
|
} catch (error) {
|
|
console.error('Compression failed, using original:', error);
|
|
resolve(blob); // Return original if compression fails
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(blob);
|
|
});
|
|
}
|
|
|
|
// Convert AudioBuffer to WAV format
|
|
function audioBufferToWav(buffer: AudioBuffer): Blob {
|
|
const length = buffer.length * buffer.numberOfChannels * 2;
|
|
const arrayBuffer = new ArrayBuffer(44 + length);
|
|
const view = new DataView(arrayBuffer);
|
|
|
|
// WAV header
|
|
const writeString = (offset: number, string: string) => {
|
|
for (let i = 0; i < string.length; i++) {
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|
}
|
|
};
|
|
|
|
writeString(0, 'RIFF');
|
|
view.setUint32(4, 36 + length, true);
|
|
writeString(8, 'WAVE');
|
|
writeString(12, 'fmt ');
|
|
view.setUint32(16, 16, true);
|
|
view.setUint16(20, 1, true);
|
|
view.setUint16(22, buffer.numberOfChannels, true);
|
|
view.setUint32(24, buffer.sampleRate, true);
|
|
view.setUint32(28, buffer.sampleRate * buffer.numberOfChannels * 2, true);
|
|
view.setUint16(32, buffer.numberOfChannels * 2, true);
|
|
view.setUint16(34, 16, true);
|
|
writeString(36, 'data');
|
|
view.setUint32(40, length, true);
|
|
|
|
// Convert float samples to 16-bit PCM
|
|
let offset = 44;
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
|
const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
|
|
view.setInt16(offset, sample * 0x7FFF, true);
|
|
offset += 2;
|
|
}
|
|
}
|
|
|
|
return new Blob([arrayBuffer], { type: 'audio/wav' });
|
|
}
|
|
|
|
// Function to transcribe audio
|
|
const transcribeAudioBase = async function(audioBlob: Blob): Promise<void> {
|
|
// Validate audio file
|
|
const validation = Validator.validateAudioFile(new File([audioBlob], 'audio.webm', { type: audioBlob.type }));
|
|
if (!validation.valid) {
|
|
statusIndicator.textContent = validation.error || 'Invalid audio file';
|
|
statusIndicator.classList.add('text-danger');
|
|
hideProgress();
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
|
|
// Validate language code
|
|
const validatedLang = Validator.validateLanguageCode(
|
|
sourceLanguage.value,
|
|
Array.from(sourceLanguage.options).map(opt => opt.value)
|
|
);
|
|
|
|
if (!validatedLang && sourceLanguage.value !== 'auto') {
|
|
statusIndicator.textContent = 'Invalid source language selected';
|
|
statusIndicator.classList.add('text-danger');
|
|
hideProgress();
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('audio', audioBlob, Validator.sanitizeFilename('audio.webm'));
|
|
formData.append('source_lang', validatedLang || 'auto');
|
|
|
|
// Log upload size
|
|
const sizeInKB = (audioBlob.size / 1024).toFixed(2);
|
|
console.log(`Uploading ${sizeInKB} KB of audio data`);
|
|
|
|
showProgress();
|
|
|
|
try {
|
|
// Use request queue for throttling
|
|
const queue = RequestQueueManager.getInstance();
|
|
const data = await queue.enqueue<TranscriptionResponse>(
|
|
'transcribe',
|
|
async () => {
|
|
const response = await fetch('/transcribe', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
8 // Higher priority for transcription
|
|
);
|
|
|
|
hideProgress();
|
|
|
|
if (data.success && data.text) {
|
|
// Sanitize the transcribed text
|
|
const sanitizedText = Validator.sanitizeText(data.text);
|
|
currentSourceText = sanitizedText;
|
|
|
|
// Handle multi-speaker mode
|
|
if (multiSpeakerEnabled) {
|
|
const activeSpeaker = speakerManager.getActiveSpeaker();
|
|
if (activeSpeaker) {
|
|
const entry = speakerManager.addConversationEntry(
|
|
activeSpeaker.id,
|
|
sanitizedText,
|
|
data.detected_language || sourceLanguage.value
|
|
);
|
|
|
|
// Auto-translate for all other speakers' languages
|
|
const allLanguages = new Set(speakerManager.getAllSpeakers().map(s => s.language));
|
|
allLanguages.delete(entry.originalLanguage);
|
|
|
|
allLanguages.forEach(async (lang) => {
|
|
try {
|
|
const response = await fetch('/translate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
text: sanitizedText,
|
|
source_lang: entry.originalLanguage,
|
|
target_lang: lang
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
if (result.success && result.translation) {
|
|
speakerManager.addTranslation(entry.id, lang, result.translation);
|
|
if ((window as any).updateConversationView) {
|
|
(window as any).updateConversationView();
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to translate to ${lang}:`, error);
|
|
}
|
|
});
|
|
|
|
// Update conversation view
|
|
if ((window as any).updateConversationView) {
|
|
(window as any).updateConversationView();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle auto-detected language
|
|
if (data.detected_language && sourceLanguage.value === 'auto') {
|
|
// Update the source language selector
|
|
sourceLanguage.value = data.detected_language;
|
|
|
|
// Show detected language info with sanitized HTML
|
|
sourceText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedText)}</p>
|
|
<small class="text-muted">Detected language: ${Validator.sanitizeHTML(data.detected_language)}</small>`;
|
|
|
|
statusIndicator.textContent = `Transcription complete (${data.detected_language} detected)`;
|
|
} else {
|
|
sourceText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedText)}</p>`;
|
|
statusIndicator.textContent = 'Transcription complete';
|
|
}
|
|
|
|
playSource.disabled = false;
|
|
translateBtn.disabled = false;
|
|
statusIndicator.classList.remove('processing');
|
|
statusIndicator.classList.add('success');
|
|
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
|
|
|
|
// Cache the transcription in IndexedDB
|
|
saveToIndexedDB('transcriptions', {
|
|
text: data.text,
|
|
language: data.detected_language || sourceLanguage.value,
|
|
timestamp: new Date().toISOString()
|
|
} as TranscriptionRecord);
|
|
} else {
|
|
sourceText.innerHTML = `<p class="text-danger fade-in">Error: ${data.error}</p>`;
|
|
statusIndicator.textContent = 'Transcription failed';
|
|
statusIndicator.classList.remove('processing');
|
|
statusIndicator.classList.add('error');
|
|
setTimeout(() => statusIndicator.classList.remove('error'), 2000);
|
|
}
|
|
} catch (error: any) {
|
|
hideProgress();
|
|
console.error('Transcription error:', error);
|
|
|
|
if (error.message?.includes('Rate limit')) {
|
|
sourceText.innerHTML = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
|
|
statusIndicator.textContent = 'Rate limit - please wait';
|
|
} else {
|
|
sourceText.innerHTML = `<p class="text-danger">Failed to transcribe audio. Please try again.</p>`;
|
|
statusIndicator.textContent = 'Transcription failed';
|
|
}
|
|
}
|
|
};
|
|
|
|
// Wrap transcribe function with error boundary
|
|
const transcribeAudio = errorBoundary.wrapAsync(
|
|
transcribeAudioBase,
|
|
'transcription',
|
|
async () => {
|
|
hideProgress();
|
|
hideLoadingOverlay();
|
|
sourceText.innerHTML = '<p class="text-danger">Transcription failed. Please try again.</p>';
|
|
statusIndicator.textContent = 'Transcription error - please retry';
|
|
statusIndicator.classList.remove('processing');
|
|
statusIndicator.classList.add('error');
|
|
}
|
|
);
|
|
|
|
// Translate button click event
|
|
translateBtn.addEventListener('click', errorBoundary.wrapAsync(async function() {
|
|
if (!currentSourceText) {
|
|
return;
|
|
}
|
|
|
|
// Check if streaming is enabled
|
|
const streamingEnabled = localStorage.getItem('streamingTranslation') !== 'false';
|
|
|
|
// Check if offline mode is enabled
|
|
const offlineModeEnabled = localStorage.getItem('offlineMode') !== 'false';
|
|
|
|
if (offlineModeEnabled) {
|
|
statusIndicator.textContent = 'Checking cache...';
|
|
statusIndicator.classList.add('processing');
|
|
|
|
// Check cache first
|
|
const cachedTranslation = await TranslationCache.getCachedTranslation(
|
|
currentSourceText,
|
|
sourceLanguage.value,
|
|
targetLanguage.value
|
|
);
|
|
|
|
if (cachedTranslation) {
|
|
// Use cached translation
|
|
console.log('Using cached translation');
|
|
currentTranslationText = cachedTranslation;
|
|
translatedText.innerHTML = `<p class="fade-in">${cachedTranslation} <small class="text-muted">(cached)</small></p>`;
|
|
playTranslation.disabled = false;
|
|
statusIndicator.textContent = 'Translation complete (from cache)';
|
|
statusIndicator.classList.remove('processing');
|
|
statusIndicator.classList.add('success');
|
|
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No cache hit, proceed with API call
|
|
statusIndicator.textContent = 'Translating...';
|
|
|
|
// Use streaming if enabled
|
|
if (streamingEnabled && navigator.onLine) {
|
|
// Clear previous translation
|
|
translatedText.innerHTML = '<p class="fade-in streaming-text"></p>';
|
|
const streamingTextElement = translatedText.querySelector('.streaming-text') as HTMLParagraphElement;
|
|
let accumulatedText = '';
|
|
|
|
// Show minimal loading indicator for streaming
|
|
statusIndicator.classList.add('processing');
|
|
|
|
const streamingTranslation = new StreamingTranslation(
|
|
// onChunk - append text as it arrives
|
|
(chunk: string) => {
|
|
accumulatedText += chunk;
|
|
streamingTextElement.textContent = accumulatedText;
|
|
streamingTextElement.classList.add('streaming-active');
|
|
},
|
|
// onComplete - finalize the translation
|
|
async (fullText: string) => {
|
|
const sanitizedTranslation = Validator.sanitizeText(fullText);
|
|
currentTranslationText = sanitizedTranslation;
|
|
streamingTextElement.textContent = sanitizedTranslation;
|
|
streamingTextElement.classList.remove('streaming-active');
|
|
playTranslation.disabled = false;
|
|
statusIndicator.textContent = 'Translation complete';
|
|
statusIndicator.classList.remove('processing');
|
|
statusIndicator.classList.add('success');
|
|
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
|
|
|
|
// Cache the translation
|
|
if (offlineModeEnabled) {
|
|
await TranslationCache.cacheTranslation(
|
|
currentSourceText,
|
|
sourceLanguage.value,
|
|
sanitizedTranslation,
|
|
targetLanguage.value
|
|
);
|
|
}
|
|
|
|
// Save to history
|
|
saveToIndexedDB('translations', {
|
|
sourceText: currentSourceText,
|
|
sourceLanguage: sourceLanguage.value,
|
|
targetText: sanitizedTranslation,
|
|
targetLanguage: targetLanguage.value,
|
|
timestamp: new Date().toISOString()
|
|
} as TranslationRecord);
|
|
},
|
|
// onError - handle streaming errors
|
|
(error: string) => {
|
|
translatedText.innerHTML = `<p class="text-danger">Error: ${Validator.sanitizeHTML(error)}</p>`;
|
|
statusIndicator.textContent = 'Translation failed';
|
|
statusIndicator.classList.remove('processing');
|
|
statusIndicator.classList.add('error');
|
|
},
|
|
// onStart
|
|
() => {
|
|
console.log('Starting streaming translation');
|
|
}
|
|
);
|
|
|
|
try {
|
|
await streamingTranslation.startStreaming(
|
|
currentSourceText,
|
|
sourceLanguage.value,
|
|
targetLanguage.value,
|
|
true // use streaming
|
|
);
|
|
} catch (error) {
|
|
console.error('Streaming translation failed:', error);
|
|
// Fall back to regular translation is handled internally
|
|
}
|
|
|
|
return; // Exit early for streaming
|
|
}
|
|
|
|
// Regular non-streaming translation
|
|
showProgress();
|
|
showLoadingOverlay('Translating to ' + targetLanguage.value + '...');
|
|
|
|
// Validate input text size
|
|
if (!Validator.validateRequestSize({ text: currentSourceText }, 100)) {
|
|
translatedText.innerHTML = '<p class="text-danger">Text is too long to translate. Please shorten it.</p>';
|
|
statusIndicator.textContent = 'Text too long';
|
|
hideProgress();
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
|
|
// Validate language codes
|
|
const validatedSourceLang = Validator.validateLanguageCode(
|
|
sourceLanguage.value,
|
|
Array.from(sourceLanguage.options).map(opt => opt.value)
|
|
);
|
|
const validatedTargetLang = Validator.validateLanguageCode(
|
|
targetLanguage.value,
|
|
Array.from(targetLanguage.options).map(opt => opt.value)
|
|
);
|
|
|
|
if (!validatedTargetLang) {
|
|
translatedText.innerHTML = '<p class="text-danger">Invalid target language selected</p>';
|
|
statusIndicator.textContent = 'Invalid language';
|
|
hideProgress();
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
|
|
const requestBody: TranslationRequest = {
|
|
text: Validator.sanitizeText(currentSourceText),
|
|
source_lang: validatedSourceLang || 'auto',
|
|
target_lang: validatedTargetLang
|
|
};
|
|
|
|
try {
|
|
// Start performance timing for regular translation
|
|
performanceMonitor.startTimer('regular_translation');
|
|
|
|
// Use request queue for throttling
|
|
const queue = RequestQueueManager.getInstance();
|
|
const data = await queue.enqueue<TranslationResponse>(
|
|
'translate',
|
|
async () => {
|
|
const response = await fetch('/translate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
5 // Normal priority for translation
|
|
);
|
|
|
|
hideProgress();
|
|
|
|
if (data.success && data.translation) {
|
|
// End performance timing
|
|
performanceMonitor.endTimer('regular_translation');
|
|
|
|
// Sanitize the translated text
|
|
const sanitizedTranslation = Validator.sanitizeText(data.translation);
|
|
currentTranslationText = sanitizedTranslation;
|
|
translatedText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedTranslation)}</p>`;
|
|
playTranslation.disabled = false;
|
|
statusIndicator.textContent = 'Translation complete';
|
|
statusIndicator.classList.remove('processing');
|
|
statusIndicator.classList.add('success');
|
|
setTimeout(() => statusIndicator.classList.remove('success'), 2000);
|
|
|
|
// Cache the translation for offline use if enabled
|
|
if (offlineModeEnabled) {
|
|
await TranslationCache.cacheTranslation(
|
|
currentSourceText,
|
|
sourceLanguage.value,
|
|
data.translation,
|
|
targetLanguage.value
|
|
);
|
|
}
|
|
|
|
// Also save to regular history
|
|
saveToIndexedDB('translations', {
|
|
sourceText: currentSourceText,
|
|
sourceLanguage: sourceLanguage.value,
|
|
targetText: data.translation,
|
|
targetLanguage: targetLanguage.value,
|
|
timestamp: new Date().toISOString()
|
|
} as TranslationRecord);
|
|
} else {
|
|
translatedText.innerHTML = `<p class="text-danger">Error: ${data.error}</p>`;
|
|
statusIndicator.textContent = 'Translation failed';
|
|
}
|
|
} catch (error: any) {
|
|
hideProgress();
|
|
console.error('Translation error:', error);
|
|
|
|
if (error.message?.includes('Rate limit')) {
|
|
translatedText.innerHTML = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
|
|
statusIndicator.textContent = 'Rate limit - please wait';
|
|
} else if (!navigator.onLine) {
|
|
statusIndicator.textContent = 'Offline - checking cache...';
|
|
translatedText.innerHTML = `<p class="text-warning">You're offline. Only cached translations are available.</p>`;
|
|
} else {
|
|
translatedText.innerHTML = `<p class="text-danger">Failed to translate. Please try again.</p>`;
|
|
statusIndicator.textContent = 'Translation failed';
|
|
}
|
|
}
|
|
}, 'translation', async () => {
|
|
hideProgress();
|
|
hideLoadingOverlay();
|
|
translatedText.innerHTML = '<p class="text-danger">Translation failed. Please try again.</p>';
|
|
statusIndicator.textContent = 'Translation error - please retry';
|
|
}));
|
|
|
|
// Play source text
|
|
playSource.addEventListener('click', function() {
|
|
if (!currentSourceText) return;
|
|
|
|
playAudio(currentSourceText, sourceLanguage.value);
|
|
statusIndicator.textContent = 'Playing source audio...';
|
|
});
|
|
|
|
// Play translation
|
|
playTranslation.addEventListener('click', function() {
|
|
if (!currentTranslationText) return;
|
|
|
|
playAudio(currentTranslationText, targetLanguage.value);
|
|
statusIndicator.textContent = 'Playing translation audio...';
|
|
});
|
|
|
|
// Function to play audio via TTS
|
|
const playAudioBase = async function(text: string, language: string): Promise<void> {
|
|
showProgress();
|
|
showLoadingOverlay('Generating audio...');
|
|
|
|
const requestBody: TTSRequest = {
|
|
text: text,
|
|
language: language
|
|
};
|
|
|
|
try {
|
|
// Use request queue for throttling
|
|
const queue = RequestQueueManager.getInstance();
|
|
const data = await queue.enqueue<TTSResponse>(
|
|
'tts',
|
|
async () => {
|
|
const response = await fetch('/speak', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
3 // Lower priority for TTS
|
|
);
|
|
|
|
hideProgress();
|
|
|
|
if (data.success && data.audio_url) {
|
|
audioPlayer.src = data.audio_url;
|
|
audioPlayer.onloadeddata = function() {
|
|
hideLoadingOverlay();
|
|
// Show audio playing animation
|
|
const playingAnimation = '<div class="audio-playing"><span></span><span></span><span></span><span></span><span></span></div>';
|
|
statusIndicator.innerHTML = playingAnimation + ' Playing audio...';
|
|
};
|
|
audioPlayer.onended = function() {
|
|
statusIndicator.innerHTML = '';
|
|
statusIndicator.textContent = 'Ready';
|
|
statusIndicator.classList.remove('processing');
|
|
};
|
|
audioPlayer.play();
|
|
} else {
|
|
statusIndicator.textContent = 'TTS failed';
|
|
|
|
// Show TTS server alert with error message
|
|
ttsServerAlert.classList.remove('d-none');
|
|
ttsServerAlert.classList.remove('alert-success');
|
|
ttsServerAlert.classList.add('alert-warning');
|
|
ttsServerMessage.textContent = data.error || 'TTS failed';
|
|
|
|
alert('Failed to play audio: ' + data.error);
|
|
|
|
// Check TTS server status again
|
|
checkTtsServer();
|
|
}
|
|
} catch (error: any) {
|
|
hideProgress();
|
|
console.error('TTS error:', error);
|
|
|
|
if (error.message?.includes('Rate limit')) {
|
|
statusIndicator.textContent = 'Too many requests - please wait';
|
|
alert('Too many requests. Please wait a moment before trying again.');
|
|
} else {
|
|
statusIndicator.textContent = 'TTS failed';
|
|
|
|
// Show TTS server alert
|
|
ttsServerAlert.classList.remove('d-none');
|
|
ttsServerAlert.classList.remove('alert-success');
|
|
ttsServerAlert.classList.add('alert-warning');
|
|
ttsServerMessage.textContent = 'Failed to connect to TTS server';
|
|
}
|
|
}
|
|
};
|
|
|
|
// Wrap playAudio function with error boundary
|
|
const playAudio = errorBoundary.wrapAsync(
|
|
playAudioBase,
|
|
'tts',
|
|
async () => {
|
|
hideProgress();
|
|
hideLoadingOverlay();
|
|
statusIndicator.textContent = 'Audio playback failed';
|
|
alert('Failed to generate audio. Please check your TTS server connection.');
|
|
}
|
|
);
|
|
|
|
// Clear buttons
|
|
clearSource.addEventListener('click', function() {
|
|
sourceText.innerHTML = '<p class="text-muted">Your transcribed text will appear here...</p>';
|
|
currentSourceText = '';
|
|
playSource.disabled = true;
|
|
translateBtn.disabled = true;
|
|
});
|
|
|
|
clearTranslation.addEventListener('click', function() {
|
|
translatedText.innerHTML = '<p class="text-muted">Translation will appear here...</p>';
|
|
currentTranslationText = '';
|
|
playTranslation.disabled = true;
|
|
});
|
|
|
|
// Function to check TTS server status
|
|
function checkTtsServer(): void {
|
|
fetch('/check_tts_server')
|
|
.then(response => response.json() as Promise<TTSServerStatus>)
|
|
.then(data => {
|
|
currentTtsServerUrl = data.url;
|
|
ttsServerUrl.value = currentTtsServerUrl;
|
|
|
|
// Load saved API key if available
|
|
const savedApiKey = localStorage.getItem('ttsApiKeySet');
|
|
if (savedApiKey === 'true') {
|
|
ttsApiKey.placeholder = '••••••• (API key saved)';
|
|
}
|
|
|
|
if (data.status === 'error' || data.status === 'auth_error') {
|
|
ttsServerAlert.classList.remove('d-none');
|
|
ttsServerAlert.classList.remove('alert-success');
|
|
ttsServerAlert.classList.add('alert-warning');
|
|
ttsServerMessage.textContent = data.message;
|
|
|
|
if (data.status === 'auth_error') {
|
|
ttsServerMessage.textContent = 'Authentication error with TTS server. Please check your API key.';
|
|
}
|
|
} else {
|
|
ttsServerAlert.classList.remove('d-none');
|
|
ttsServerAlert.classList.remove('alert-warning');
|
|
ttsServerAlert.classList.add('alert-success');
|
|
ttsServerMessage.textContent = 'TTS server is online and ready.';
|
|
setTimeout(() => {
|
|
ttsServerAlert.classList.add('d-none');
|
|
}, 3000);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Failed to check TTS server:', error);
|
|
ttsServerAlert.classList.remove('d-none');
|
|
ttsServerAlert.classList.remove('alert-success');
|
|
ttsServerAlert.classList.add('alert-warning');
|
|
ttsServerMessage.textContent = 'Failed to check TTS server status.';
|
|
});
|
|
}
|
|
|
|
// Progress indicator functions
|
|
function showProgress(): void {
|
|
progressContainer.classList.remove('d-none');
|
|
let progress = 0;
|
|
const interval = setInterval(() => {
|
|
progress += 5;
|
|
if (progress > 90) {
|
|
clearInterval(interval);
|
|
}
|
|
progressBar.style.width = `${progress}%`;
|
|
}, 100);
|
|
(progressBar as any).dataset.interval = interval.toString();
|
|
}
|
|
|
|
function hideProgress(): void {
|
|
const interval = (progressBar as any).dataset.interval;
|
|
if (interval) {
|
|
clearInterval(Number(interval));
|
|
}
|
|
progressBar.style.width = '100%';
|
|
setTimeout(() => {
|
|
progressContainer.classList.add('d-none');
|
|
progressBar.style.width = '0%';
|
|
}, 500);
|
|
hideLoadingOverlay();
|
|
}
|
|
|
|
function showLoadingOverlay(text: string): void {
|
|
loadingText.textContent = text;
|
|
loadingOverlay.classList.add('active');
|
|
}
|
|
|
|
function hideLoadingOverlay(): void {
|
|
loadingOverlay.classList.remove('active');
|
|
}
|
|
|
|
// Initialize queue status display
|
|
function initQueueStatus(): void {
|
|
const queueStatus = document.getElementById('queueStatus') as HTMLDivElement;
|
|
const queueLength = document.getElementById('queueLength') as HTMLSpanElement;
|
|
const activeRequests = document.getElementById('activeRequests') as HTMLSpanElement;
|
|
|
|
const queue = RequestQueueManager.getInstance();
|
|
|
|
// Update queue status display
|
|
function updateQueueDisplay(): void {
|
|
const status = queue.getStatus();
|
|
|
|
if (status.queueLength > 0 || status.activeRequests > 0) {
|
|
queueStatus.style.display = 'block';
|
|
queueLength.textContent = status.queueLength.toString();
|
|
activeRequests.textContent = status.activeRequests.toString();
|
|
} else {
|
|
queueStatus.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Poll for status updates
|
|
setInterval(updateQueueDisplay, 500);
|
|
|
|
// Initial update
|
|
updateQueueDisplay();
|
|
}
|
|
|
|
// Health monitoring and auto-recovery
|
|
function startHealthMonitoring(): void {
|
|
let consecutiveFailures = 0;
|
|
const maxConsecutiveFailures = 3;
|
|
|
|
async function checkHealth(): Promise<void> {
|
|
try {
|
|
const response = await fetch('/health', {
|
|
method: 'GET',
|
|
signal: AbortSignal.timeout(5000) // 5 second timeout
|
|
});
|
|
|
|
if (response.ok) {
|
|
consecutiveFailures = 0;
|
|
|
|
// Remove any health warning if shown
|
|
const healthWarning = document.getElementById('healthWarning');
|
|
if (healthWarning) {
|
|
healthWarning.style.display = 'none';
|
|
}
|
|
} else {
|
|
handleHealthCheckFailure();
|
|
}
|
|
} catch (error) {
|
|
handleHealthCheckFailure();
|
|
}
|
|
}
|
|
|
|
function handleHealthCheckFailure(): void {
|
|
consecutiveFailures++;
|
|
console.warn(`Health check failed (${consecutiveFailures}/${maxConsecutiveFailures})`);
|
|
|
|
if (consecutiveFailures >= maxConsecutiveFailures) {
|
|
showHealthWarning();
|
|
|
|
// Attempt auto-recovery
|
|
attemptAutoRecovery();
|
|
}
|
|
}
|
|
|
|
function showHealthWarning(): void {
|
|
let healthWarning = document.getElementById('healthWarning');
|
|
if (!healthWarning) {
|
|
healthWarning = document.createElement('div');
|
|
healthWarning.id = 'healthWarning';
|
|
healthWarning.className = 'alert alert-warning alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
|
|
healthWarning.style.zIndex = '9999';
|
|
healthWarning.innerHTML = `
|
|
<i class="fas fa-exclamation-triangle"></i> Service health check failed.
|
|
Some features may be unavailable.
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
`;
|
|
document.body.appendChild(healthWarning);
|
|
}
|
|
healthWarning.style.display = 'block';
|
|
}
|
|
|
|
async function attemptAutoRecovery(): Promise<void> {
|
|
console.log('Attempting auto-recovery...');
|
|
|
|
// Clear any stuck requests in the queue
|
|
const queue = RequestQueueManager.getInstance();
|
|
queue.clearStuckRequests();
|
|
|
|
// Re-check TTS server
|
|
checkTtsServer();
|
|
|
|
// Try to reload service worker if available
|
|
if ('serviceWorker' in navigator) {
|
|
try {
|
|
const registration = await navigator.serviceWorker.getRegistration();
|
|
if (registration) {
|
|
await registration.update();
|
|
console.log('Service worker updated');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update service worker:', error);
|
|
}
|
|
}
|
|
|
|
// Reset failure counter after recovery attempt
|
|
setTimeout(() => {
|
|
consecutiveFailures = 0;
|
|
}, 30000); // Wait 30 seconds before resetting
|
|
}
|
|
|
|
// Check health every 30 seconds
|
|
setInterval(checkHealth, 30000);
|
|
|
|
// Initial health check after 5 seconds
|
|
setTimeout(checkHealth, 5000);
|
|
}
|
|
}
|
|
|
|
|
|
// IndexedDB functions for offline data storage
|
|
function openIndexedDB(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open('VoiceTranslatorDB', 1);
|
|
|
|
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
|
|
// Create stores for transcriptions and translations
|
|
if (!db.objectStoreNames.contains('transcriptions')) {
|
|
db.createObjectStore('transcriptions', { keyPath: 'timestamp' });
|
|
}
|
|
|
|
if (!db.objectStoreNames.contains('translations')) {
|
|
db.createObjectStore('translations', { keyPath: 'timestamp' });
|
|
}
|
|
};
|
|
|
|
request.onsuccess = (event: Event) => {
|
|
resolve((event.target as IDBOpenDBRequest).result);
|
|
};
|
|
|
|
request.onerror = (event: Event) => {
|
|
reject('IndexedDB error: ' + (event.target as IDBOpenDBRequest).error);
|
|
};
|
|
});
|
|
}
|
|
|
|
function saveToIndexedDB(storeName: string, data: TranscriptionRecord | TranslationRecord): void {
|
|
openIndexedDB().then(db => {
|
|
const transaction = db.transaction([storeName], 'readwrite');
|
|
const store = transaction.objectStore(storeName);
|
|
store.add(data);
|
|
}).catch(error => {
|
|
console.error('Error saving to IndexedDB:', error);
|
|
});
|
|
}
|
|
|
|
function loadSavedTranslations(): void {
|
|
openIndexedDB().then(db => {
|
|
const transaction = db.transaction(['translations'], 'readonly');
|
|
const store = transaction.objectStore('translations');
|
|
const request = store.getAll();
|
|
|
|
request.onsuccess = (event: Event) => {
|
|
const translations = (event.target as IDBRequest).result;
|
|
if (translations && translations.length > 0) {
|
|
// Could add a history section or recently used translations
|
|
console.log('Loaded saved translations:', translations.length);
|
|
}
|
|
};
|
|
}).catch(error => {
|
|
console.error('Error loading from IndexedDB:', error);
|
|
});
|
|
}
|
|
|
|
// PWA installation prompt
|
|
function initInstallPrompt(): void {
|
|
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
|
const installButton = document.createElement('button');
|
|
installButton.style.display = 'none';
|
|
installButton.classList.add('btn', 'btn-success', 'fixed-bottom', 'm-3');
|
|
installButton.innerHTML = 'Install Voice Translator <i class="fas fa-download ml-2"></i>';
|
|
document.body.appendChild(installButton);
|
|
|
|
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
|
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
|
e.preventDefault();
|
|
// Stash the event so it can be triggered later
|
|
deferredPrompt = e as BeforeInstallPromptEvent;
|
|
// Update UI to notify the user they can add to home screen
|
|
installButton.style.display = 'block';
|
|
|
|
installButton.addEventListener('click', () => {
|
|
// Hide our user interface that shows our install button
|
|
installButton.style.display = 'none';
|
|
// Show the prompt
|
|
if (deferredPrompt) {
|
|
deferredPrompt.prompt();
|
|
// Wait for the user to respond to the prompt
|
|
deferredPrompt.userChoice.then((choiceResult) => {
|
|
if (choiceResult.outcome === 'accepted') {
|
|
console.log('User accepted the install prompt');
|
|
} else {
|
|
console.log('User dismissed the install prompt');
|
|
}
|
|
deferredPrompt = null;
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Push notification setup
|
|
function setupPushNotifications(swRegistration: ServiceWorkerRegistration): void {
|
|
// Initialize notification UI
|
|
initNotificationUI(swRegistration);
|
|
|
|
// Check saved preference
|
|
const notificationsEnabled = localStorage.getItem('notificationsEnabled');
|
|
|
|
if (notificationsEnabled === 'true' && Notification.permission === 'granted') {
|
|
subscribeToPushManager(swRegistration);
|
|
}
|
|
}
|
|
|
|
function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
|
|
const notificationPrompt = document.getElementById('notificationPrompt') as HTMLDivElement;
|
|
const enableNotificationsBtn = document.getElementById('enableNotifications') as HTMLButtonElement;
|
|
const notificationToggle = document.getElementById('notificationToggle') as HTMLInputElement;
|
|
const saveSettingsBtn = document.getElementById('saveSettings') as HTMLButtonElement;
|
|
|
|
// Check if we should show the prompt
|
|
const notificationsDismissed = localStorage.getItem('notificationsDismissed');
|
|
const notificationsEnabled = localStorage.getItem('notificationsEnabled');
|
|
|
|
if (!notificationsDismissed && !notificationsEnabled && Notification.permission === 'default') {
|
|
// Show toast after 5 seconds
|
|
setTimeout(() => {
|
|
const toast = new (window as any).bootstrap.Toast(notificationPrompt);
|
|
toast.show();
|
|
}, 5000);
|
|
}
|
|
|
|
// Update toggle state
|
|
notificationToggle.checked = notificationsEnabled === 'true';
|
|
|
|
// Enable notifications button
|
|
enableNotificationsBtn?.addEventListener('click', async () => {
|
|
const permission = await Notification.requestPermission();
|
|
if (permission === 'granted') {
|
|
localStorage.setItem('notificationsEnabled', 'true');
|
|
notificationToggle.checked = true;
|
|
await subscribeToPushManager(swRegistration);
|
|
const toast = new (window as any).bootstrap.Toast(notificationPrompt);
|
|
toast.hide();
|
|
// Simple alert for mobile compatibility
|
|
setTimeout(() => {
|
|
alert('Notifications enabled successfully!');
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
// Notification toggle
|
|
notificationToggle?.addEventListener('change', async () => {
|
|
if (notificationToggle.checked) {
|
|
if (Notification.permission === 'default') {
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') {
|
|
notificationToggle.checked = false;
|
|
return;
|
|
}
|
|
}
|
|
localStorage.setItem('notificationsEnabled', 'true');
|
|
await subscribeToPushManager(swRegistration);
|
|
} else {
|
|
localStorage.setItem('notificationsEnabled', 'false');
|
|
await unsubscribeFromPushManager(swRegistration);
|
|
}
|
|
});
|
|
|
|
// Save settings
|
|
saveSettingsBtn?.addEventListener('click', () => {
|
|
const notifyTranscription = (document.getElementById('notifyTranscription') as HTMLInputElement).checked;
|
|
const notifyTranslation = (document.getElementById('notifyTranslation') as HTMLInputElement).checked;
|
|
const notifyErrors = (document.getElementById('notifyErrors') as HTMLInputElement).checked;
|
|
const streamingTranslation = (document.getElementById('streamingTranslation') as HTMLInputElement).checked;
|
|
const multiSpeakerMode = (document.getElementById('multiSpeakerMode') as HTMLInputElement).checked;
|
|
|
|
localStorage.setItem('notifyTranscription', notifyTranscription.toString());
|
|
localStorage.setItem('notifyTranslation', notifyTranslation.toString());
|
|
localStorage.setItem('notifyErrors', notifyErrors.toString());
|
|
localStorage.setItem('streamingTranslation', streamingTranslation.toString());
|
|
localStorage.setItem('multiSpeakerMode', multiSpeakerMode.toString());
|
|
|
|
// Update multi-speaker mode if changed
|
|
const previousMultiSpeakerMode = localStorage.getItem('multiSpeakerMode') === 'true';
|
|
if (multiSpeakerMode !== previousMultiSpeakerMode) {
|
|
window.location.reload(); // Reload to apply changes
|
|
}
|
|
|
|
// Show inline success message
|
|
const saveStatus = document.getElementById('settingsSaveStatus') as HTMLDivElement;
|
|
if (saveStatus) {
|
|
saveStatus.style.display = 'block';
|
|
|
|
// Hide after 2 seconds and close modal
|
|
setTimeout(() => {
|
|
saveStatus.style.display = 'none';
|
|
const modal = (window as any).bootstrap.Modal.getInstance(document.getElementById('settingsModal'));
|
|
modal.hide();
|
|
}, 1500);
|
|
}
|
|
});
|
|
|
|
// Load saved preferences
|
|
const notifyTranscription = document.getElementById('notifyTranscription') as HTMLInputElement;
|
|
const notifyTranslation = document.getElementById('notifyTranslation') as HTMLInputElement;
|
|
const notifyErrors = document.getElementById('notifyErrors') as HTMLInputElement;
|
|
const streamingTranslation = document.getElementById('streamingTranslation') as HTMLInputElement;
|
|
|
|
notifyTranscription.checked = localStorage.getItem('notifyTranscription') !== 'false';
|
|
notifyTranslation.checked = localStorage.getItem('notifyTranslation') !== 'false';
|
|
notifyErrors.checked = localStorage.getItem('notifyErrors') === 'true';
|
|
streamingTranslation.checked = localStorage.getItem('streamingTranslation') !== 'false';
|
|
|
|
// Initialize cache management UI
|
|
initCacheManagement();
|
|
}
|
|
async function initCacheManagement(): Promise<void> {
|
|
const cacheCount = document.getElementById('cacheCount') as HTMLSpanElement;
|
|
const cacheSize = document.getElementById('cacheSize') as HTMLSpanElement;
|
|
const offlineMode = document.getElementById('offlineMode') as HTMLInputElement;
|
|
const clearCacheBtn = document.getElementById('clearCache') as HTMLButtonElement;
|
|
|
|
// Load cache stats
|
|
async function updateCacheStats() {
|
|
const stats = await TranslationCache.getCacheStats();
|
|
cacheCount.textContent = stats.totalEntries.toString();
|
|
cacheSize.textContent = `${(stats.totalSize / 1024).toFixed(1)} KB`;
|
|
}
|
|
|
|
// Initial load
|
|
updateCacheStats();
|
|
|
|
// Load offline mode preference
|
|
offlineMode.checked = localStorage.getItem('offlineMode') !== 'false';
|
|
|
|
// Toggle offline mode
|
|
offlineMode?.addEventListener('change', () => {
|
|
localStorage.setItem('offlineMode', offlineMode.checked.toString());
|
|
});
|
|
|
|
// Clear cache button
|
|
clearCacheBtn?.addEventListener('click', async () => {
|
|
if (confirm('Are you sure you want to clear all cached translations?')) {
|
|
await TranslationCache.clearCache();
|
|
await updateCacheStats();
|
|
alert('Translation cache cleared successfully!');
|
|
}
|
|
});
|
|
|
|
// Update stats when modal is shown
|
|
const settingsModal = document.getElementById('settingsModal');
|
|
settingsModal?.addEventListener('show.bs.modal', updateCacheStats);
|
|
}
|
|
|
|
async function subscribeToPushManager(swRegistration: ServiceWorkerRegistration): Promise<void> {
|
|
try {
|
|
// Get the server's public key
|
|
const response = await fetch('/api/push-public-key');
|
|
const data: PushPublicKeyResponse = await response.json();
|
|
|
|
// Convert the base64 string to Uint8Array
|
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
const base64 = (base64String + padding)
|
|
.replace(/-/g, '+')
|
|
.replace(/_/g, '/');
|
|
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
const convertedVapidKey = urlBase64ToUint8Array(data.publicKey);
|
|
|
|
// Subscribe to push notifications
|
|
const subscription = await swRegistration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: convertedVapidKey
|
|
});
|
|
|
|
// Send the subscription details to the server
|
|
await fetch('/api/push-subscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(subscription)
|
|
});
|
|
|
|
console.log('User is subscribed to push notifications');
|
|
} catch (error) {
|
|
console.error('Failed to subscribe to push notifications:', error);
|
|
}
|
|
}
|
|
|
|
async function unsubscribeFromPushManager(swRegistration: ServiceWorkerRegistration): Promise<void> {
|
|
try {
|
|
const subscription = await swRegistration.pushManager.getSubscription();
|
|
if (subscription) {
|
|
// Unsubscribe from server
|
|
await fetch('/api/push-unsubscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(subscription)
|
|
});
|
|
|
|
// Unsubscribe locally
|
|
await subscription.unsubscribe();
|
|
console.log('User is unsubscribed from push notifications');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to unsubscribe from push notifications:', error);
|
|
}
|
|
} |