Files
translation-app/script.js
2026-07-04 19:47:59 -04:00

632 lines
19 KiB
JavaScript

// Mobile Detection
const isMobileApp = () => window.Capacitor !== undefined;
console.log('Mobile App:', isMobileApp());
// API URL Configuration
const getApiUrl = () => {
if (isMobileApp()) {
// For mobile apps, use your deployed API URL
// Or local IP for testing: 'http://192.168.1.100:3000'
return localStorage.getItem('apiUrl') || 'http://localhost:3000';
}
return ''; // Use relative URL for web
};
const API_URL = getApiUrl();
console.log('API URL:', API_URL);
// Initialize Speech Recognition API
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = SpeechRecognition ? new SpeechRecognition() : null;
function isVoiceInputAllowed() {
return window.isSecureContext ||
location.protocol === 'https:' ||
location.hostname === 'localhost' ||
location.hostname === '127.0.0.1' ||
location.hostname.endsWith('.local');
}
// Initialize Speech Synthesis API
const speechSynthesis = window.speechSynthesis;
let availableVoices = [];
let voicesLoaded = false;
let mediaRecorder = null;
let audioStream = null;
let audioChunks = [];
let isRecording = false;
let autoSpeakAfterVoice = false;
// DOM Elements
const sourceText = document.getElementById('sourceText');
const translatedText = document.getElementById('translatedText');
const sourceLang = document.getElementById('sourceLang');
const targetLang = document.getElementById('targetLang');
const voiceBtn = document.getElementById('voiceBtn');
const clearSourceBtn = document.getElementById('clearSourceBtn') || document.getElementById('clearBtn');
const speakBtn = document.getElementById('speakBtn');
const copyBtn = document.getElementById('copyBtn');
const swapBtn = document.getElementById('swapBtn');
const voiceStatus = document.getElementById('voiceStatus');
const statusMessage = document.getElementById('statusMessage') || document.getElementById('status');
const charCount = document.getElementById('charCount');
const copyFeedback = document.getElementById('copyFeedback');
// Language codes for speech recognition and synthesis
const langMap = {
'en': 'en-US',
'es': 'es-ES',
'fr': 'fr-FR',
'de': 'de-DE',
'pt': 'pt-BR',
'it': 'it-IT',
'nl': 'nl-NL',
'ru': 'ru-RU',
'ar': 'ar-SA',
'ja': 'ja-JP',
'ko': 'ko-KR',
'zh': 'zh-CN',
'hi': 'hi-IN',
'ta': 'ta-IN',
'te': 'te-IN',
'ml': 'ml-IN',
'pa': 'pa-IN',
'kn': 'kn-IN',
'id': 'id-ID',
'vi': 'vi-VN',
'tr': 'tr-TR'
};
const remoteTtsLanguages = new Set(['ta', 'te', 'ml', 'pa', 'kn']);
// Initialize
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
// Check for HTTPS/secure-context requirement for voice input
if (!isVoiceInputAllowed() && !isMobileApp()) {
console.warn('Voice input requires a secure context. Access via https://localhost:3443 for full functionality.');
}
// Check voice input support
const voiceSupportAvailable = !!(navigator.mediaDevices?.getUserMedia && window.MediaRecorder);
if (!voiceSupportAvailable) {
console.warn('Voice recording not supported in this browser.');
if (voiceBtn) {
voiceBtn.disabled = true;
voiceBtn.title = 'Voice input not supported in this browser';
voiceBtn.style.opacity = '0.5';
}
}
// Check speech synthesis support
if (!speechSynthesis) {
console.warn('Speech synthesis not supported in this browser.');
if (speakBtn) {
speakBtn.disabled = true;
speakBtn.title = 'Text-to-speech not supported in this browser';
speakBtn.style.opacity = '0.5';
}
} else {
loadVoices();
}
showStatus('Ready to translate!', 'success');
});
// Setup Event Listeners
function setupEventListeners() {
const bind = (element, event, handler) => {
if (element) {
element.addEventListener(event, handler);
}
};
bind(sourceText, 'input', handleTextInput);
bind(voiceBtn, 'click', handleVoiceInput);
bind(clearSourceBtn, 'click', clearInput);
bind(speakBtn, 'click', speakTranslation);
bind(copyBtn, 'click', copyTranslation);
bind(swapBtn, 'click', swapLanguages);
bind(sourceLang, 'change', translateOnChange);
bind(targetLang, 'change', translateOnChange);
if (recognition) {
recognition.addEventListener('start', () => {
voiceBtn.classList.add('listening');
voiceStatus.textContent = '🎤 Listening...';
});
recognition.addEventListener('result', handleSpeechResult);
recognition.addEventListener('end', () => {
voiceBtn.classList.remove('listening');
voiceStatus.textContent = '';
});
recognition.addEventListener('error', (e) => {
console.error('Speech recognition error:', e.error);
let errorMessage = '';
switch(e.error) {
case 'no-speech':
errorMessage = 'No speech detected. Please try again.';
break;
case 'audio-capture':
errorMessage = 'Audio capture failed. Check your microphone.';
break;
case 'not-allowed':
errorMessage = 'Microphone permission denied.';
break;
case 'network':
errorMessage = 'Network error during speech recognition.';
break;
case 'service-not-allowed':
errorMessage = 'Speech recognition service not allowed.';
break;
default:
errorMessage = `Speech recognition error: ${e.error}`;
}
voiceStatus.textContent = `${errorMessage}`;
voiceBtn.classList.remove('listening');
showStatus(errorMessage, 'error');
});
}
}
// Handle Text Input
function handleTextInput(e) {
const text = e.target.value;
if (charCount) {
charCount.textContent = text.length;
}
if (text.trim().length > 0) {
debounceTranslate();
} else {
translatedText.textContent = '';
}
}
// Debounce Translation
let translateTimeout;
function debounceTranslate() {
clearTimeout(translateTimeout);
translateTimeout = setTimeout(() => {
translateText();
}, 500);
}
// Translate Text
async function translateText() {
const text = sourceText.value.trim();
if (!text) return;
const source = sourceLang.value;
const target = targetLang.value;
if (source === target) {
translatedText.textContent = text;
return;
}
try {
showStatus('Translating...', 'loading');
const response = await fetch(`${API_URL}/api/translate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text,
sourceLang: source,
targetLang: target
})
});
if (!response.ok) {
throw new Error('Translation failed');
}
const data = await response.json();
translatedText.textContent = data.translatedText;
showStatus('Translation complete!', 'success');
if (autoSpeakAfterVoice) {
autoSpeakAfterVoice = false;
speakTranslation();
}
} catch (error) {
console.error('Translation error:', error);
// Fallback to client-side translation using simple mapping
translatedText.textContent = text; // Fallback: show original text
showStatus('Using direct translation (server unavailable)', 'error');
}
}
// Handle Voice Input
async function handleVoiceInput() {
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
showStatus('Voice recording is not supported in this browser.', 'error');
return;
}
if (!isVoiceInputAllowed() && !isMobileApp()) {
showStatus('Voice input requires a secure context. Please access via https://localhost:3443', 'error');
return;
}
if (isRecording) {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
isRecording = false;
voiceBtn.classList.remove('listening');
voiceStatus.textContent = '🔄 Processing voice...';
return;
}
try {
audioChunks = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioStream = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
if (audioStream) {
audioStream.getTracks().forEach(track => track.stop());
audioStream = null;
}
await transcribeAudio(blob);
};
mediaRecorder.start();
isRecording = true;
voiceBtn.classList.add('listening');
voiceStatus.textContent = '🎤 Listening...';
showStatus('Listening for voice input...', 'success');
} catch (error) {
console.error('Microphone permission denied:', error);
showStatus('Microphone permission required for voice input', 'error');
voiceStatus.textContent = '❌ Microphone access denied';
}
}
async function transcribeAudio(blob) {
try {
const response = await fetch(`${API_URL}/api/transcribe`, {
method: 'POST',
headers: {
'Content-Type': blob.type || 'audio/webm'
},
body: blob
});
if (!response.ok) {
throw new Error('Transcription failed');
}
const data = await response.json();
const transcript = data.transcript || '';
sourceText.value = transcript;
if (charCount) {
charCount.textContent = transcript.length;
}
if (voiceStatus) {
voiceStatus.textContent = '✓ Voice captured';
}
autoSpeakAfterVoice = true;
await translateText();
} catch (error) {
console.error('Voice transcription error:', error);
showStatus('Voice transcription failed', 'error');
voiceStatus.textContent = '❌ Voice transcription failed';
}
}
function loadVoices() {
if (!speechSynthesis || typeof speechSynthesis.getVoices !== 'function') return;
availableVoices = speechSynthesis.getVoices();
voicesLoaded = availableVoices.length > 0;
if (!voicesLoaded) {
speechSynthesis.onvoiceschanged = () => {
availableVoices = speechSynthesis.getVoices();
voicesLoaded = availableVoices.length > 0;
};
}
}
function getPreferredVoice(languageCode) {
if (!speechSynthesis || !availableVoices.length) return null;
const normalizedCode = languageCode.toLowerCase();
const directMatch = availableVoices.find(voice => voice.lang && voice.lang.toLowerCase() === normalizedCode);
if (directMatch) return directMatch;
const languageMatch = availableVoices.find(voice => voice.lang && voice.lang.toLowerCase().startsWith(normalizedCode));
if (languageMatch) return languageMatch;
return availableVoices.find(voice => voice.localService) || availableVoices[0] || null;
}
// Handle Speech Result
function handleSpeechResult(event) {
let transcript = '';
let isFinal = false;
for (let i = event.resultIndex; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
if (event.results[i].isFinal) {
isFinal = true;
}
}
// Show interim results
sourceText.value = transcript;
if (charCount) {
charCount.textContent = transcript.length;
}
if (isFinal) {
voiceStatus.textContent = '✓ Transcribed successfully';
setTimeout(() => {
voiceStatus.textContent = '';
}, 2000);
translateText();
} else {
voiceStatus.textContent = '🎤 Listening... (interim)';
}
}
// Speak Translation
function speakTranslation() {
if (!speechSynthesis) {
showStatus('Text-to-speech is not supported in this browser', 'error');
return;
}
const text = translatedText.textContent?.trim();
if (!text || text === 'Translation will appear here...') {
showStatus('Nothing to speak', 'error');
return;
}
const speakText = () => {
speechSynthesis.cancel();
speechSynthesis.resume();
const utterance = new SpeechSynthesisUtterance(text.replace(/\s+/g, ' '));
utterance.lang = langMap[targetLang.value] || 'en-US';
utterance.rate = 0.95;
utterance.pitch = 1;
utterance.volume = 1;
const preferredVoice = getPreferredVoice(utterance.lang);
const useRemote = remoteTtsLanguages.has(targetLang.value) && !!API_URL;
if (useRemote) {
speakWithRemoteTts(text, targetLang.value, utterance, preferredVoice);
return;
}
if (preferredVoice) {
utterance.voice = preferredVoice;
}
utterance.onstart = () => {
speakBtn.disabled = true;
showStatus('Speaking...', 'success');
};
utterance.onend = () => {
speakBtn.disabled = false;
showStatus('Done speaking', 'success');
};
utterance.onerror = (e) => {
speakBtn.disabled = false;
showStatus(`Speech error: ${e.error}`, 'error');
};
speechSynthesis.speak(utterance);
};
if (!voicesLoaded && typeof speechSynthesis.getVoices === 'function') {
loadVoices();
window.setTimeout(speakText, 250);
} else {
speakText();
}
}
async function speakWithRemoteTts(text, targetLang, utterance, preferredVoice) {
if (!API_URL) {
showStatus('Cannot generate speech without a server URL', 'error');
return;
}
try {
showStatus('Generating speech...', 'loading');
speakBtn.disabled = true;
const response = await fetch(`${API_URL}/api/speak`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text, targetLang })
});
if (!response.ok) {
throw new Error('Remote speech generation failed');
}
const data = await response.json();
if (!data.audio) {
throw new Error('No audio returned from speech service');
}
const audioType = data.format || 'audio/wav';
const audio = new Audio(`data:${audioType};base64,${data.audio}`);
audio.onended = () => {
speakBtn.disabled = false;
showStatus('Done speaking', 'success');
};
audio.onerror = (e) => {
console.error('Remote audio playback error', e);
if (preferredVoice) {
utterance.voice = preferredVoice;
}
utterance.onstart = () => {
speakBtn.disabled = true;
showStatus('Speaking...', 'success');
};
utterance.onend = () => {
speakBtn.disabled = false;
showStatus('Done speaking', 'success');
};
utterance.onerror = (err) => {
speakBtn.disabled = false;
showStatus(`Speech error: ${err.error}`, 'error');
};
speechSynthesis.speak(utterance);
};
await audio.play();
} catch (error) {
console.warn('Remote TTS failed, falling back to browser speech:', error);
if (preferredVoice) {
utterance.voice = preferredVoice;
}
utterance.onstart = () => {
speakBtn.disabled = true;
showStatus('Speaking...', 'success');
};
utterance.onend = () => {
speakBtn.disabled = false;
showStatus('Done speaking', 'success');
};
utterance.onerror = (err) => {
speakBtn.disabled = false;
showStatus(`Speech error: ${err.error}`, 'error');
};
speechSynthesis.speak(utterance);
}
}
// Copy Translation
function copyTranslation() {
const text = translatedText.textContent;
if (!text || text === 'Translation will appear here...') {
showStatus('Nothing to copy', 'error');
return;
}
navigator.clipboard.writeText(text).then(() => {
if (copyFeedback) {
copyFeedback.textContent = '✓ Copied!';
setTimeout(() => {
copyFeedback.textContent = '';
}, 2000);
}
showStatus('Copied to clipboard', 'success');
}).catch(err => {
showStatus('Failed to copy', 'error');
});
}
// Clear Input
function clearInput() {
sourceText.value = '';
translatedText.textContent = '';
if (charCount) {
charCount.textContent = '0';
}
if (voiceStatus) {
voiceStatus.textContent = '';
}
autoSpeakAfterVoice = false;
showStatus('Cleared', 'success');
}
// Swap Languages
function swapLanguages() {
const temp = sourceLang.value;
sourceLang.value = targetLang.value;
targetLang.value = temp;
const tempText = sourceText.value;
sourceText.value = translatedText.textContent === 'Translation will appear here...'
? ''
: translatedText.textContent;
translatedText.textContent = tempText || '';
if (charCount) {
charCount.textContent = sourceText.value.length;
}
translateOnChange();
}
// Translate on Language Change
function translateOnChange() {
if (sourceText.value.trim().length > 0) {
translateText();
}
}
// Show Status Message
function showStatus(message, type = 'info') {
if (!statusMessage) return;
statusMessage.textContent = message;
statusMessage.className = type;
if (type === 'loading') {
statusMessage.style.opacity = '0.7';
} else {
statusMessage.style.opacity = '1';
}
}
// Fallback: Simple offline translation (for demo purposes)
const translationMap = {
'en-es': {
'hello': 'hola',
'goodbye': 'adiós',
'thank you': 'gracias',
'please': 'por favor',
'yes': 'sí',
'no': 'no'
},
'en-fr': {
'hello': 'bonjour',
'goodbye': 'au revoir',
'thank you': 'merci',
'please': 's\'il vous plaît',
'yes': 'oui',
'no': 'non'
}
};
console.log('✓ Translator app loaded successfully');
console.log('Features available:');
console.log('- Text translation');
console.log('- Voice input (if supported by browser)');
console.log('- Text-to-speech output');
console.log('- Language swapping');
console.log('- Copy to clipboard');