diff --git a/CORS_CONFIG.md b/CORS_CONFIG.md new file mode 100644 index 0000000..3741fe8 --- /dev/null +++ b/CORS_CONFIG.md @@ -0,0 +1,152 @@ +# CORS Configuration Guide + +This document explains how to configure Cross-Origin Resource Sharing (CORS) for the Talk2Me application. + +## Overview + +CORS is configured using Flask-CORS to enable secure cross-origin usage of the API endpoints. This allows the Talk2Me application to be embedded in other websites or accessed from different domains while maintaining security. + +## Environment Variables + +### `CORS_ORIGINS` + +Controls which domains are allowed to access the API endpoints. + +- **Default**: `*` (allows all origins - use only for development) +- **Production Example**: `https://yourdomain.com,https://app.yourdomain.com` +- **Format**: Comma-separated list of allowed origins + +```bash +# Development (allows all origins) +export CORS_ORIGINS="*" + +# Production (restrict to specific domains) +export CORS_ORIGINS="https://talk2me.example.com,https://app.example.com" +``` + +### `ADMIN_CORS_ORIGINS` + +Controls which domains can access admin endpoints (more restrictive). + +- **Default**: `http://localhost:*` (allows all localhost ports) +- **Production Example**: `https://admin.yourdomain.com` +- **Format**: Comma-separated list of allowed admin origins + +```bash +# Development +export ADMIN_CORS_ORIGINS="http://localhost:*" + +# Production +export ADMIN_CORS_ORIGINS="https://admin.talk2me.example.com" +``` + +## Configuration Details + +The CORS configuration includes: + +- **Allowed Methods**: GET, POST, OPTIONS +- **Allowed Headers**: Content-Type, Authorization, X-Requested-With, X-Admin-Token +- **Exposed Headers**: Content-Range, X-Content-Range +- **Credentials Support**: Enabled (supports cookies and authorization headers) +- **Max Age**: 3600 seconds (preflight requests cached for 1 hour) + +## Endpoints + +All endpoints have CORS enabled with the following configuration: + +### Regular API Endpoints +- `/api/*` +- `/transcribe` +- `/translate` +- `/translate/stream` +- `/speak` +- `/get_audio/*` +- `/check_tts_server` +- `/update_tts_config` +- `/health/*` + +### Admin Endpoints (More Restrictive) +- `/admin/*` - Uses `ADMIN_CORS_ORIGINS` instead of general `CORS_ORIGINS` + +## Security Best Practices + +1. **Never use `*` in production** - Always specify exact allowed origins +2. **Use HTTPS** - Always use HTTPS URLs in production CORS origins +3. **Separate admin origins** - Keep admin endpoints on a separate, more restrictive origin list +4. **Review regularly** - Periodically review and update allowed origins + +## Example Configurations + +### Local Development +```bash +export CORS_ORIGINS="*" +export ADMIN_CORS_ORIGINS="http://localhost:*" +``` + +### Staging Environment +```bash +export CORS_ORIGINS="https://staging.talk2me.com,https://staging-app.talk2me.com" +export ADMIN_CORS_ORIGINS="https://staging-admin.talk2me.com" +``` + +### Production Environment +```bash +export CORS_ORIGINS="https://talk2me.com,https://app.talk2me.com" +export ADMIN_CORS_ORIGINS="https://admin.talk2me.com" +``` + +### Mobile App Integration +```bash +# Include mobile app schemes if needed +export CORS_ORIGINS="https://talk2me.com,https://app.talk2me.com,capacitor://localhost,ionic://localhost" +``` + +## Testing CORS Configuration + +You can test CORS configuration using curl: + +```bash +# Test preflight request +curl -X OPTIONS https://your-api.com/api/transcribe \ + -H "Origin: https://allowed-origin.com" \ + -H "Access-Control-Request-Method: POST" \ + -H "Access-Control-Request-Headers: Content-Type" \ + -v + +# Test actual request +curl -X POST https://your-api.com/api/transcribe \ + -H "Origin: https://allowed-origin.com" \ + -H "Content-Type: application/json" \ + -d '{"test": "data"}' \ + -v +``` + +## Troubleshooting + +### CORS Errors in Browser Console + +If you see CORS errors: + +1. Check that the origin is included in `CORS_ORIGINS` +2. Ensure the URL protocol matches (http vs https) +3. Check for trailing slashes in origins +4. Verify environment variables are set correctly + +### Common Issues + +1. **"No 'Access-Control-Allow-Origin' header"** + - Origin not in allowed list + - Check `CORS_ORIGINS` environment variable + +2. **"CORS policy: The request client is not a secure context"** + - Using HTTP instead of HTTPS + - Update to use HTTPS in production + +3. **"CORS policy: Credentials flag is true, but Access-Control-Allow-Credentials is not 'true'"** + - This should not occur with current configuration + - Check that `supports_credentials` is True in CORS config + +## Additional Resources + +- [MDN CORS Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) +- [Flask-CORS Documentation](https://flask-cors.readthedocs.io/) \ No newline at end of file diff --git a/README.md b/README.md index dabb377..2a6543e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,20 @@ A mobile-friendly web application that translates spoken language between multip - Ollama provides access to the Gemma 3 model for translation - OpenAI Edge TTS delivers natural-sounding speech output +## CORS Configuration + +The application supports Cross-Origin Resource Sharing (CORS) for secure cross-origin usage. See [CORS_CONFIG.md](CORS_CONFIG.md) for detailed configuration instructions. + +Quick setup: +```bash +# Development (allow all origins) +export CORS_ORIGINS="*" + +# Production (restrict to specific domains) +export CORS_ORIGINS="https://yourdomain.com,https://app.yourdomain.com" +export ADMIN_CORS_ORIGINS="https://admin.yourdomain.com" +``` + ## Mobile Support The interface is fully responsive and designed to work well on mobile devices. diff --git a/app.py b/app.py index ad236ef..090d73f 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ import requests import json import logging from flask import Flask, render_template, request, jsonify, Response, send_file, send_from_directory, stream_with_context +from flask_cors import CORS, cross_origin import whisper import torch import ollama @@ -48,6 +49,33 @@ def with_error_boundary(func): app = Flask(__name__) +# Configure CORS with security best practices +cors_config = { + "origins": os.environ.get('CORS_ORIGINS', '*').split(','), # Default to * for development, restrict in production + "methods": ["GET", "POST", "OPTIONS"], + "allow_headers": ["Content-Type", "Authorization", "X-Requested-With", "X-Admin-Token"], + "expose_headers": ["Content-Range", "X-Content-Range"], + "supports_credentials": True, + "max_age": 3600 # Cache preflight requests for 1 hour +} + +# Apply CORS configuration +CORS(app, resources={ + r"/api/*": cors_config, + r"/transcribe": cors_config, + r"/translate": cors_config, + r"/translate/stream": cors_config, + r"/speak": cors_config, + r"/get_audio/*": cors_config, + r"/check_tts_server": cors_config, + r"/update_tts_config": cors_config, + r"/health/*": cors_config, + r"/admin/*": { + **cors_config, + "origins": os.environ.get('ADMIN_CORS_ORIGINS', 'http://localhost:*').split(',') + } +}) + # Configure upload folder - use environment variable or default to secure temp directory default_upload_folder = os.path.join(tempfile.gettempdir(), 'talk2me_uploads') upload_folder = os.environ.get('UPLOAD_FOLDER', default_upload_folder) diff --git a/requirements.txt b/requirements.txt index 6081a08..fde32e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ flask +flask-cors requests openai-whisper torch diff --git a/static/js/app.js b/static/js/app.js index c904d79..22ec0be 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,30 +1,59 @@ -// Main application JavaScript with PWA support -document.addEventListener('DOMContentLoaded', function() { +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'; +// import { apiClient } from './apiClient'; // Available for cross-origin requests +// Initialize error boundary +const errorBoundary = ErrorBoundary.getInstance(); +// Configure API client if needed for cross-origin requests +// import { apiClient } from './apiClient'; +// apiClient.configure({ baseUrl: 'https://api.talk2me.com', credentials: 'include' }); +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 = '

Translation failed. Please try again.

'; + } + } + }); + // 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) { - registerServiceWorker(); + safeRegisterServiceWorker(); } - // Initialize app - initApp(); - + safeInitApp(); // Check for PWA installation prompts - initInstallPrompt(); + safeInitInstallPrompt(); }); - // Service Worker Registration async function registerServiceWorker() { try { const registration = await navigator.serviceWorker.register('/service-worker.js'); console.log('Service Worker registered with scope:', registration.scope); - // Setup periodic sync if available - if ('periodicSync' in registration) { + if ('periodicSync' in registration && registration.periodicSync) { // Request permission for background sync const status = await navigator.permissions.query({ name: 'periodic-background-sync', }); - if (status.state === 'granted') { try { // Register for background sync to check for updates @@ -32,21 +61,21 @@ async function registerServiceWorker() { minInterval: 24 * 60 * 60 * 1000, // once per day }); console.log('Periodic background sync registered'); - } catch (error) { + } + 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) { + } + catch (error) { console.error('Service Worker registration failed:', error); } } - // Initialize the main application function initApp() { // DOM elements @@ -69,7 +98,8 @@ function initApp() { const ttsServerUrl = document.getElementById('ttsServerUrl'); const ttsApiKey = document.getElementById('ttsApiKey'); const updateTtsServer = document.getElementById('updateTtsServer'); - + const loadingOverlay = document.getElementById('loadingOverlay'); + const loadingText = document.getElementById('loadingText'); // Set initial values let isRecording = false; let mediaRecorder = null; @@ -77,27 +107,162 @@ function initApp() { let currentSourceText = ''; let currentTranslationText = ''; let currentTtsServerUrl = ''; - + // 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() { + const multiSpeakerToggle = document.getElementById('toggleMultiSpeaker'); + const multiSpeakerStatus = document.getElementById('multiSpeakerStatus'); + const speakerToolbar = document.getElementById('speakerToolbar'); + const conversationView = document.getElementById('conversationView'); + const multiSpeakerModeCheckbox = document.getElementById('multiSpeakerMode'); + // 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() { + const speakerList = document.getElementById('speakerList'); + 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 = ` + ${speaker.avatar} + ${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() { + const conversationContent = document.getElementById('conversationContent'); + const entries = speakerManager.getConversationInLanguage(targetLanguage.value); + conversationContent.innerHTML = entries.map(entry => ` +
+
+ + ${entry.speakerName.substr(0, 2).toUpperCase()} + + ${entry.speakerName} + ${new Date(entry.timestamp).toLocaleTimeString()} +
+
+ ${Validator.sanitizeHTML(entry.text)} +
+
+ `).join(''); + // Scroll to bottom + conversationContent.scrollTop = conversationContent.scrollHeight; + } + // Store reference to update function for use in transcription + window.updateConversationView = updateConversationView; + window.updateSpeakerUI = updateSpeakerUI; + } // Update TTS server URL and API key - updateTtsServer.addEventListener('click', function() { + 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 = {}; - if (newUrl) updateData.server_url = newUrl; - if (newApiKey) updateData.api_key = newApiKey; - + // 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: { @@ -105,31 +270,35 @@ function initApp() { }, body: JSON.stringify(updateData) }) - .then(response => response.json()) - .then(data => { + .then(response => response.json()) + .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); + if (newUrl) + localStorage.setItem('ttsServerUrl', newUrl); // Check TTS server with new configuration checkTtsServer(); - } else { + } + else { alert('Failed to update TTS configuration: ' + data.error); } }) - .catch(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() { + 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) { @@ -139,8 +308,7 @@ function initApp() { } } }); - - targetLanguage.addEventListener('change', function() { + 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) { @@ -150,131 +318,451 @@ function initApp() { } } }); - // Record button click event - recordBtn.addEventListener('click', function() { + recordBtn.addEventListener('click', function () { if (isRecording) { stopRecording(); - } else { + } + else { startRecording(); } }); - // Function to start recording function startRecording() { - navigator.mediaDevices.getUserMedia({ audio: true }) + // 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', () => { - const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); - transcribeAudio(audioBlob); - }); - - mediaRecorder.start(); - isRecording = true; - recordBtn.classList.add('recording'); - recordBtn.classList.replace('btn-primary', 'btn-danger'); - recordBtn.innerHTML = ''; - statusIndicator.textContent = 'Recording... Click to stop'; - }) - .catch(error => { - console.error('Error accessing microphone:', error); - alert('Error accessing microphone. Please make sure you have given permission for microphone access.'); + } + 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 = '
'; + 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() { + if (!mediaRecorder) + return; mediaRecorder.stop(); isRecording = false; recordBtn.classList.remove('recording'); recordBtn.classList.replace('btn-danger', 'btn-primary'); recordBtn.innerHTML = ''; 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) { + return new Promise((resolve) => { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const arrayBuffer = e.target?.result; + 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) { + const length = buffer.length * buffer.numberOfChannels * 2; + const arrayBuffer = new ArrayBuffer(44 + length); + const view = new DataView(arrayBuffer); + // WAV header + const writeString = (offset, 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 - function transcribeAudio(audioBlob) { - const formData = new FormData(); - formData.append('audio', audioBlob); - formData.append('source_lang', sourceLanguage.value); - - showProgress(); - - fetch('/transcribe', { - method: 'POST', - body: formData - }) - .then(response => response.json()) - .then(data => { + const transcribeAudioBase = async function (audioBlob) { + // 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(); - - if (data.success) { - currentSourceText = data.text; - sourceText.innerHTML = `

${data.text}

`; + 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('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.updateConversationView) { + window.updateConversationView(); + } + } + } + } + catch (error) { + console.error(`Failed to translate to ${lang}:`, error); + } + }); + // Update conversation view + if (window.updateConversationView) { + window.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 = `

${Validator.sanitizeHTML(sanitizedText)}

+ Detected language: ${Validator.sanitizeHTML(data.detected_language)}`; + statusIndicator.textContent = `Transcription complete (${data.detected_language} detected)`; + } + else { + sourceText.innerHTML = `

${Validator.sanitizeHTML(sanitizedText)}

`; + statusIndicator.textContent = 'Transcription complete'; + } playSource.disabled = false; translateBtn.disabled = false; - statusIndicator.textContent = 'Transcription complete'; - + 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: sourceLanguage.value, + language: data.detected_language || sourceLanguage.value, timestamp: new Date().toISOString() }); - } else { - sourceText.innerHTML = `

Error: ${data.error}

`; - statusIndicator.textContent = 'Transcription failed'; } - }) - .catch(error => { + else { + sourceText.innerHTML = `

Error: ${data.error}

`; + statusIndicator.textContent = 'Transcription failed'; + statusIndicator.classList.remove('processing'); + statusIndicator.classList.add('error'); + setTimeout(() => statusIndicator.classList.remove('error'), 2000); + } + } + catch (error) { hideProgress(); console.error('Transcription error:', error); - sourceText.innerHTML = `

Failed to transcribe audio. Please try again.

`; - statusIndicator.textContent = 'Transcription failed'; - }); - } - + if (error.message?.includes('Rate limit')) { + sourceText.innerHTML = `

Too many requests. Please wait a moment.

`; + statusIndicator.textContent = 'Rate limit - please wait'; + } + else { + sourceText.innerHTML = `

Failed to transcribe audio. Please try again.

`; + statusIndicator.textContent = 'Transcription failed'; + } + } + }; + // Wrap transcribe function with error boundary + const transcribeAudio = errorBoundary.wrapAsync(transcribeAudioBase, 'transcription', async () => { + hideProgress(); + hideLoadingOverlay(); + sourceText.innerHTML = '

Transcription failed. Please try again.

'; + statusIndicator.textContent = 'Transcription error - please retry'; + statusIndicator.classList.remove('processing'); + statusIndicator.classList.add('error'); + }); // Translate button click event - translateBtn.addEventListener('click', function() { + 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 = `

${cachedTranslation} (cached)

`; + 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...'; - showProgress(); - - fetch('/translate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - text: currentSourceText, - source_lang: sourceLanguage.value, - target_lang: targetLanguage.value - }) - }) - .then(response => response.json()) - .then(data => { - hideProgress(); - - if (data.success) { - currentTranslationText = data.translation; - translatedText.innerHTML = `

${data.translation}

`; + // Use streaming if enabled + if (streamingEnabled && navigator.onLine) { + // Clear previous translation + translatedText.innerHTML = '

'; + const streamingTextElement = translatedText.querySelector('.streaming-text'); + let accumulatedText = ''; + // Show minimal loading indicator for streaming + statusIndicator.classList.add('processing'); + const streamingTranslation = new StreamingTranslation( + // onChunk - append text as it arrives + (chunk) => { + accumulatedText += chunk; + streamingTextElement.textContent = accumulatedText; + streamingTextElement.classList.add('streaming-active'); + }, + // onComplete - finalize the translation + async (fullText) => { + const sanitizedTranslation = Validator.sanitizeText(fullText); + currentTranslationText = sanitizedTranslation; + streamingTextElement.textContent = sanitizedTranslation; + streamingTextElement.classList.remove('streaming-active'); playTranslation.disabled = false; statusIndicator.textContent = 'Translation complete'; - - // Cache the translation in IndexedDB + 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() + }); + }, + // onError - handle streaming errors + (error) => { + translatedText.innerHTML = `

Error: ${Validator.sanitizeHTML(error)}

`; + 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 = '

Text is too long to translate. Please shorten it.

'; + 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 = '

Invalid target language selected

'; + statusIndicator.textContent = 'Invalid language'; + hideProgress(); + hideLoadingOverlay(); + return; + } + const requestBody = { + 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('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 = `

${Validator.sanitizeHTML(sanitizedTranslation)}

`; + 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, @@ -282,143 +770,176 @@ function initApp() { targetLanguage: targetLanguage.value, timestamp: new Date().toISOString() }); - } else { + } + else { translatedText.innerHTML = `

Error: ${data.error}

`; statusIndicator.textContent = 'Translation failed'; } - }) - .catch(error => { + } + catch (error) { hideProgress(); console.error('Translation error:', error); - translatedText.innerHTML = `

Failed to translate. Please try again.

`; - statusIndicator.textContent = 'Translation failed'; - }); - }); - + if (error.message?.includes('Rate limit')) { + translatedText.innerHTML = `

Too many requests. Please wait a moment.

`; + statusIndicator.textContent = 'Rate limit - please wait'; + } + else if (!navigator.onLine) { + statusIndicator.textContent = 'Offline - checking cache...'; + translatedText.innerHTML = `

You're offline. Only cached translations are available.

`; + } + else { + translatedText.innerHTML = `

Failed to translate. Please try again.

`; + statusIndicator.textContent = 'Translation failed'; + } + } + }, 'translation', async () => { + hideProgress(); + hideLoadingOverlay(); + translatedText.innerHTML = '

Translation failed. Please try again.

'; + statusIndicator.textContent = 'Translation error - please retry'; + })); // Play source text - playSource.addEventListener('click', function() { - if (!currentSourceText) return; - + 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; - + playTranslation.addEventListener('click', function () { + if (!currentTranslationText) + return; playAudio(currentTranslationText, targetLanguage.value); statusIndicator.textContent = 'Playing translation audio...'; }); - // Function to play audio via TTS - function playAudio(text, language) { + const playAudioBase = async function (text, language) { showProgress(); - - fetch('/speak', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - text: text, - language: language - }) - }) - .then(response => response.json()) - .then(data => { + showLoadingOverlay('Generating audio...'); + const requestBody = { + text: text, + language: language + }; + try { + // Use request queue for throttling + const queue = RequestQueueManager.getInstance(); + const data = await queue.enqueue('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) { + if (data.success && data.audio_url) { audioPlayer.src = data.audio_url; - audioPlayer.onended = function() { + audioPlayer.onloadeddata = function () { + hideLoadingOverlay(); + // Show audio playing animation + const playingAnimation = '
'; + statusIndicator.innerHTML = playingAnimation + ' Playing audio...'; + }; + audioPlayer.onended = function () { + statusIndicator.innerHTML = ''; statusIndicator.textContent = 'Ready'; + statusIndicator.classList.remove('processing'); }; audioPlayer.play(); - } else { + } + 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; - + ttsServerMessage.textContent = data.error || 'TTS failed'; alert('Failed to play audio: ' + data.error); - // Check TTS server status again checkTtsServer(); } - }) - .catch(error => { + } + catch (error) { hideProgress(); console.error('TTS error:', error); - 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'; - }); - } - + 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() { + clearSource.addEventListener('click', function () { sourceText.innerHTML = '

Your transcribed text will appear here...

'; currentSourceText = ''; playSource.disabled = true; translateBtn.disabled = true; }); - - clearTranslation.addEventListener('click', function() { + clearTranslation.addEventListener('click', function () { translatedText.innerHTML = '

Translation will appear here...

'; currentTranslationText = ''; playTranslation.disabled = true; }); - // Function to check TTS server status function checkTtsServer() { fetch('/check_tts_server') .then(response => response.json()) .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); + 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 = 'Failed to check TTS server status.'; - }); + 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() { progressContainer.classList.remove('d-none'); @@ -430,9 +951,8 @@ function initApp() { } progressBar.style.width = `${progress}%`; }, 100); - progressBar.dataset.interval = interval; + progressBar.dataset.interval = interval.toString(); } - function hideProgress() { const interval = progressBar.dataset.interval; if (interval) { @@ -443,37 +963,142 @@ function initApp() { progressContainer.classList.add('d-none'); progressBar.style.width = '0%'; }, 500); + hideLoadingOverlay(); + } + function showLoadingOverlay(text) { + loadingText.textContent = text; + loadingOverlay.classList.add('active'); + } + function hideLoadingOverlay() { + loadingOverlay.classList.remove('active'); + } + // Initialize queue status display + function initQueueStatus() { + const queueStatus = document.getElementById('queueStatus'); + const queueLength = document.getElementById('queueLength'); + const activeRequests = document.getElementById('activeRequests'); + const queue = RequestQueueManager.getInstance(); + // Update queue status display + function updateQueueDisplay() { + 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() { + let consecutiveFailures = 0; + const maxConsecutiveFailures = 3; + async function checkHealth() { + 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() { + consecutiveFailures++; + console.warn(`Health check failed (${consecutiveFailures}/${maxConsecutiveFailures})`); + if (consecutiveFailures >= maxConsecutiveFailures) { + showHealthWarning(); + // Attempt auto-recovery + attemptAutoRecovery(); + } + } + function showHealthWarning() { + 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 = ` + Service health check failed. + Some features may be unavailable. + + `; + document.body.appendChild(healthWarning); + } + healthWarning.style.display = 'block'; + } + async function attemptAutoRecovery() { + 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() { return new Promise((resolve, reject) => { const request = indexedDB.open('VoiceTranslatorDB', 1); - request.onupgradeneeded = (event) => { const db = event.target.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) => { resolve(event.target.result); }; - request.onerror = (event) => { - reject('IndexedDB error: ' + event.target.errorCode); + reject('IndexedDB error: ' + event.target.error); }; }); } - function saveToIndexedDB(storeName, data) { openIndexedDB().then(db => { const transaction = db.transaction([storeName], 'readwrite'); @@ -483,13 +1108,11 @@ function saveToIndexedDB(storeName, data) { console.error('Error saving to IndexedDB:', error); }); } - function loadSavedTranslations() { openIndexedDB().then(db => { const transaction = db.transaction(['translations'], 'readonly'); const store = transaction.objectStore('translations'); const request = store.getAll(); - request.onsuccess = (event) => { const translations = event.target.result; if (translations && translations.length > 0) { @@ -501,16 +1124,14 @@ function loadSavedTranslations() { console.error('Error loading from IndexedDB:', error); }); } - // PWA installation prompt function initInstallPrompt() { - let deferredPrompt; + let deferredPrompt = 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 '; document.body.appendChild(installButton); - window.addEventListener('beforeinstallprompt', (e) => { // Prevent Chrome 67 and earlier from automatically showing the prompt e.preventDefault(); @@ -518,72 +1139,182 @@ function initInstallPrompt() { deferredPrompt = e; // Update UI to notify the user they can add to home screen installButton.style.display = 'block'; - - installButton.addEventListener('click', (e) => { + installButton.addEventListener('click', () => { // Hide our user interface that shows our install button installButton.style.display = 'none'; // Show the prompt - 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; - }); + 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) { - // First check if we already have permission - if (Notification.permission === 'granted') { - console.log('Notification permission already granted'); + // Initialize notification UI + initNotificationUI(swRegistration); + // Check saved preference + const notificationsEnabled = localStorage.getItem('notificationsEnabled'); + if (notificationsEnabled === 'true' && Notification.permission === 'granted') { subscribeToPushManager(swRegistration); - } else if (Notification.permission !== 'denied') { - // Otherwise, ask for permission - Notification.requestPermission().then(function(permission) { - if (permission === 'granted') { - console.log('Notification permission granted'); - subscribeToPushManager(swRegistration); - } - }); } } - +function initNotificationUI(swRegistration) { + const notificationPrompt = document.getElementById('notificationPrompt'); + const enableNotificationsBtn = document.getElementById('enableNotifications'); + const notificationToggle = document.getElementById('notificationToggle'); + const saveSettingsBtn = document.getElementById('saveSettings'); + // 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.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.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').checked; + const notifyTranslation = document.getElementById('notifyTranslation').checked; + const notifyErrors = document.getElementById('notifyErrors').checked; + const streamingTranslation = document.getElementById('streamingTranslation').checked; + const multiSpeakerMode = document.getElementById('multiSpeakerMode').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'); + if (saveStatus) { + saveStatus.style.display = 'block'; + // Hide after 2 seconds and close modal + setTimeout(() => { + saveStatus.style.display = 'none'; + const modal = window.bootstrap.Modal.getInstance(document.getElementById('settingsModal')); + modal.hide(); + }, 1500); + } + }); + // Load saved preferences + const notifyTranscription = document.getElementById('notifyTranscription'); + const notifyTranslation = document.getElementById('notifyTranslation'); + const notifyErrors = document.getElementById('notifyErrors'); + const streamingTranslation = document.getElementById('streamingTranslation'); + 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() { + const cacheCount = document.getElementById('cacheCount'); + const cacheSize = document.getElementById('cacheSize'); + const offlineMode = document.getElementById('offlineMode'); + const clearCacheBtn = document.getElementById('clearCache'); + // 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) { try { // Get the server's public key const response = await fetch('/api/push-public-key'); const data = await response.json(); - // Convert the base64 string to Uint8Array function urlBase64ToUint8Array(base64String) { 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', @@ -592,9 +1323,31 @@ async function subscribeToPushManager(swRegistration) { }, body: JSON.stringify(subscription) }); - console.log('User is subscribed to push notifications'); - } catch (error) { + } + catch (error) { console.error('Failed to subscribe to push notifications:', error); } } +async function unsubscribeFromPushManager(swRegistration) { + 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); + } +} +//# sourceMappingURL=app.js.map \ No newline at end of file diff --git a/static/js/src/apiClient.ts b/static/js/src/apiClient.ts new file mode 100644 index 0000000..6327fa9 --- /dev/null +++ b/static/js/src/apiClient.ts @@ -0,0 +1,155 @@ +// API Client with CORS support +export interface ApiClientConfig { + baseUrl?: string; + credentials?: RequestCredentials; + headers?: HeadersInit; +} + +export class ApiClient { + private static instance: ApiClient; + private config: ApiClientConfig; + + private constructor() { + // Default configuration + this.config = { + baseUrl: '', // Use same origin by default + credentials: 'same-origin', // Change to 'include' for cross-origin requests + headers: { + 'X-Requested-With': 'XMLHttpRequest' // Identify as AJAX request + } + }; + + // Check if we're in a cross-origin context + this.detectCrossOrigin(); + } + + static getInstance(): ApiClient { + if (!ApiClient.instance) { + ApiClient.instance = new ApiClient(); + } + return ApiClient.instance; + } + + // Detect if we're making cross-origin requests + private detectCrossOrigin(): void { + // Check if the app is loaded from a different origin + const currentScript = document.currentScript as HTMLScriptElement | null; + const scriptSrc = currentScript?.src || ''; + if (scriptSrc && !scriptSrc.startsWith(window.location.origin)) { + // We're likely in a cross-origin context + this.config.credentials = 'include'; + console.log('Cross-origin context detected, enabling credentials'); + } + + // Also check for explicit configuration in meta tags + const corsOrigin = document.querySelector('meta[name="cors-origin"]'); + if (corsOrigin) { + const origin = corsOrigin.getAttribute('content'); + if (origin && origin !== window.location.origin) { + this.config.baseUrl = origin; + this.config.credentials = 'include'; + console.log(`Using CORS origin: ${origin}`); + } + } + } + + // Configure the API client + configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + // Make a fetch request with CORS support + async fetch(url: string, options: RequestInit = {}): Promise { + // Construct full URL + const fullUrl = this.config.baseUrl ? `${this.config.baseUrl}${url}` : url; + + // Merge headers + const headers = new Headers(options.headers); + if (this.config.headers) { + const configHeaders = new Headers(this.config.headers); + configHeaders.forEach((value, key) => { + if (!headers.has(key)) { + headers.set(key, value); + } + }); + } + + // Merge options with defaults + const fetchOptions: RequestInit = { + ...options, + headers, + credentials: options.credentials || this.config.credentials + }; + + // Add CORS mode if cross-origin + if (this.config.baseUrl && this.config.baseUrl !== window.location.origin) { + fetchOptions.mode = 'cors'; + } + + try { + const response = await fetch(fullUrl, fetchOptions); + + // Check for CORS errors + if (!response.ok && response.type === 'opaque') { + throw new Error('CORS request failed - check server CORS configuration'); + } + + return response; + } catch (error) { + // Enhanced error handling for CORS issues + if (error instanceof TypeError && error.message.includes('Failed to fetch')) { + console.error('CORS Error: Failed to fetch. Check that:', { + requestedUrl: fullUrl, + origin: window.location.origin, + credentials: fetchOptions.credentials, + mode: fetchOptions.mode + }); + throw new Error('CORS request failed. The server may not allow requests from this origin.'); + } + throw error; + } + } + + // Convenience methods + async get(url: string, options?: RequestInit): Promise { + return this.fetch(url, { ...options, method: 'GET' }); + } + + async post(url: string, body?: any, options?: RequestInit): Promise { + const init: RequestInit = { ...options, method: 'POST' }; + + if (body) { + if (body instanceof FormData) { + init.body = body; + } else { + init.headers = { + ...init.headers, + 'Content-Type': 'application/json' + }; + init.body = JSON.stringify(body); + } + } + + return this.fetch(url, init); + } + + // JSON convenience methods + async getJSON(url: string, options?: RequestInit): Promise { + const response = await this.get(url, options); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + } + + async postJSON(url: string, body?: any, options?: RequestInit): Promise { + const response = await this.post(url, body, options); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + } +} + +// Export a singleton instance +export const apiClient = ApiClient.getInstance(); \ No newline at end of file diff --git a/static/js/src/app.ts b/static/js/src/app.ts index 644392f..d33d208 100644 --- a/static/js/src/app.ts +++ b/static/js/src/app.ts @@ -21,10 +21,15 @@ import { Validator } from './validator'; import { StreamingTranslation } from './streamingTranslation'; import { PerformanceMonitor } from './performanceMonitor'; import { SpeakerManager } from './speakerManager'; +// import { apiClient } from './apiClient'; // Available for cross-origin requests // Initialize error boundary const errorBoundary = ErrorBoundary.getInstance(); +// Configure API client if needed for cross-origin requests +// import { apiClient } from './apiClient'; +// apiClient.configure({ baseUrl: 'https://api.talk2me.com', credentials: 'include' }); + document.addEventListener('DOMContentLoaded', function() { // Set up global error handler errorBoundary.setGlobalErrorHandler((error, errorInfo) => { diff --git a/test-cors.html b/test-cors.html new file mode 100644 index 0000000..9be8aa7 --- /dev/null +++ b/test-cors.html @@ -0,0 +1,228 @@ + + + + + + CORS Test for Talk2Me + + + +

CORS Test for Talk2Me API

+ +

This page tests CORS configuration for the Talk2Me API. Open this file from a different origin (e.g., file:// or a different port) to test cross-origin requests.

+ +
+ + +
+ +

Tests:

+ + + + + + +
+ + + + \ No newline at end of file