diff --git a/index.html b/index.html index 1ad5ad4..8b76ddc 100644 --- a/index.html +++ b/index.html @@ -43,6 +43,8 @@ + +
@@ -52,15 +54,30 @@
+
Allow microphone access when prompted to use voice input.
+
Select a language and click Speak to use browser or server TTS.
+
Ready
-

Translation

+
+

Translation

+ +
Translation will appear here...
+
+
+
Dev Log
+
(permission-state and debug messages appear here)
+
+ diff --git a/package-lock.json b/package-lock.json index fa868b4..12529de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,38 @@ "dotenv": "^16.0.3", "express": "^4.21.2", "google-tts-api": "^2.0.1" + }, + "devDependencies": { + "puppeteer": "^25.3.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz", + "integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } } }, "node_modules/accepts": { @@ -64,6 +96,32 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -150,6 +208,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -252,6 +342,13 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1638949", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz", + "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -284,6 +381,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -338,6 +442,16 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -480,6 +594,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -669,6 +806,19 @@ "node": ">= 0.10" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -738,6 +888,23 @@ "node": ">= 0.6" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -823,6 +990,46 @@ "node": ">=10" } }, + "node_modules/puppeteer": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.3.0.tgz", + "integrity": "sha512-O1tx8S315aw8eI99HZ5ZNcVEzJ9+jKF//eO5UvfZ3cXJ6okZ5sX3Y50u7DJaM+ewEK4LqXP068tBhfRaWikj+g==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.3.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.3.0.tgz", + "integrity": "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -1021,6 +1228,40 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1043,6 +1284,13 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1069,6 +1317,101 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index dbb83b9..6377b0c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "main": "server.js", "scripts": { - "start": "node server.js" + "start": "node server.js", + "check:permissions": "node scripts/permission-check.js" }, "dependencies": { "axios": "^1.7.7", @@ -12,5 +13,8 @@ "dotenv": "^16.0.3", "express": "^4.21.2", "google-tts-api": "^2.0.1" + }, + "devDependencies": { + "puppeteer": "^25.3.0" } } diff --git a/script.js b/script.js index d9ba028..1e1ae1c 100644 --- a/script.js +++ b/script.js @@ -9,16 +9,19 @@ const getApiUrl = () => { // Or local IP for testing: 'http://192.168.1.100:3000' return localStorage.getItem('apiUrl') || 'http://localhost:3000'; } - return ''; // Use relative URL for web + + if (window.location.protocol === 'file:') { + // If the app is opened directly from the file system, point to the local server. + return 'http://localhost:3000'; + } + + // Use same-origin server URL for web-hosted pages. + return window.location.origin; }; 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:' || @@ -48,11 +51,15 @@ const speakBtn = document.getElementById('speakBtn'); const copyBtn = document.getElementById('copyBtn'); const swapBtn = document.getElementById('swapBtn'); const voiceStatus = document.getElementById('voiceStatus'); +const microphoneHint = document.querySelector('.microphone-hint'); +const ttsNotice = document.getElementById('ttsNotice'); const statusMessage = document.getElementById('statusMessage') || document.getElementById('status'); const charCount = document.getElementById('charCount'); const copyFeedback = document.getElementById('copyFeedback'); +const devLogBody = document.getElementById('devLogBody'); +const clearLogBtn = document.getElementById('clearLogBtn'); -// Language codes for speech recognition and synthesis +// Language codes for speech synthesis const langMap = { 'en': 'en-US', 'es': 'es-ES', @@ -91,12 +98,25 @@ document.addEventListener('DOMContentLoaded', () => { // Check voice input support const voiceSupportAvailable = !!(navigator.mediaDevices?.getUserMedia && window.MediaRecorder); if (!voiceSupportAvailable) { - console.warn('Voice recording not supported in this browser.'); + console.warn('Voice input not supported in this browser.'); if (voiceBtn) { voiceBtn.disabled = true; voiceBtn.title = 'Voice input not supported in this browser'; voiceBtn.style.opacity = '0.5'; } + if (microphoneHint) { + microphoneHint.style.display = 'none'; + } + } else { + if (voiceBtn) { + voiceBtn.disabled = false; + voiceBtn.style.opacity = '1'; + voiceBtn.classList.add('active'); + } + if (microphoneHint) { + microphoneHint.textContent = 'โœ“ Microphone is supported. Click Voice Input and grant microphone access when your browser prompts.'; + microphoneHint.style.display = 'block'; + } } // Check speech synthesis support @@ -111,6 +131,8 @@ document.addEventListener('DOMContentLoaded', () => { loadVoices(); } + verifyServerReachability(); + checkMicrophonePermission(); showStatus('Ready to translate!', 'success'); }); @@ -124,6 +146,7 @@ function setupEventListeners() { bind(sourceText, 'input', handleTextInput); bind(voiceBtn, 'click', handleVoiceInput); + bind(clearLogBtn, 'click', () => { if (devLogBody) devLogBody.innerHTML = ''; }); bind(clearSourceBtn, 'click', clearInput); bind(speakBtn, 'click', speakTranslation); bind(copyBtn, 'click', copyTranslation); @@ -131,47 +154,78 @@ function setupEventListeners() { bind(sourceLang, 'change', translateOnChange); bind(targetLang, 'change', translateOnChange); - if (recognition) { - recognition.addEventListener('start', () => { - voiceBtn.classList.add('listening'); - voiceStatus.textContent = '๐ŸŽค Listening...'; - }); +} - recognition.addEventListener('result', handleSpeechResult); +// Check microphone permission state (if supported) and update UI +async function checkMicrophonePermission() { + if (!navigator.permissions) return; - recognition.addEventListener('end', () => { - voiceBtn.classList.remove('listening'); - voiceStatus.textContent = ''; - }); + try { + const status = await navigator.permissions.query({ name: 'microphone' }); - 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}`; + if (status.state === 'denied') { + if (microphoneHint) { + microphoneHint.innerHTML = '๐Ÿ”’ Microphone blocked for this site. To allow:\nโ€ข Click the lock icon in the address bar โ†’ Site settings โ†’ Microphone โ†’ Allow\nโ€ข Or open this app via http://localhost:3000 and re-grant permissions.' + .replace(/\n/g, '
'); + microphoneHint.style.display = 'block'; } + if (voiceBtn) { + voiceBtn.disabled = false; + voiceBtn.title = 'Microphone permission blocked. Click to retry permission prompt.'; + voiceBtn.classList.add('active'); + } + logDev('Microphone permission state: denied'); + } else if (status.state === 'prompt') { + if (microphoneHint) { + microphoneHint.textContent = 'Click Voice Input and allow microphone access when prompted.'; + microphoneHint.style.display = 'block'; + } + if (voiceBtn) { + voiceBtn.disabled = false; + voiceBtn.classList.add('active'); + } + logDev('Microphone permission state: prompt'); + } else if (status.state === 'granted') { + if (microphoneHint) { + microphoneHint.textContent = 'Microphone access granted. Click Voice Input to start.'; + microphoneHint.style.display = 'block'; + } + if (voiceBtn) { + voiceBtn.disabled = false; + voiceBtn.style.opacity = '1'; + voiceBtn.style.display = ''; + voiceBtn.classList.add('active'); + } + logDev('Microphone permission state: granted'); + } - voiceStatus.textContent = `โŒ ${errorMessage}`; - voiceBtn.classList.remove('listening'); - showStatus(errorMessage, 'error'); - }); + // Re-run when permission state changes + status.onchange = () => { logDev(`Microphone permission changed: ${status.state}`); checkMicrophonePermission(); }; + } catch (e) { + // Some browsers do not support querying microphone via Permissions API + return; + } +} + +async function verifyServerReachability() { + if (!API_URL) { + showStatus('Server URL is not configured.', 'error'); + return; + } + + try { + const response = await fetch(`${API_URL}/api/health`, { cache: 'no-store' }); + if (response.ok) { + const data = await response.json(); + showStatus(`Server reachable: ${data.message}`, 'success'); + logDev(`Server reachable: ${data.message}`); + return; + } + throw new Error(`HTTP ${response.status}`); + } catch (error) { + console.error('Server reachability check failed:', error); + showStatus('Cannot reach the app server. Open the app via http://localhost:3000', 'error'); + logDev(`Server reachability failed: ${error?.message || error}`); } } @@ -252,9 +306,27 @@ async function handleVoiceInput() { return; } - if (!isVoiceInputAllowed() && !isMobileApp()) { - showStatus('Voice input requires a secure context. Please access via https://localhost:3443', 'error'); - return; + // If permissions API is available, check microphone state before prompting + if (navigator.permissions) { + try { + const perm = await navigator.permissions.query({ name: 'microphone' }); + if (perm.state === 'denied') { + const msg = 'Microphone permission is blocked. Open your browser site settings and allow microphone for this site.'; + showStatus(msg, 'error'); + voiceStatus.textContent = 'โŒ Permission blocked'; + if (microphoneHint) { + microphoneHint.innerHTML = '๐Ÿ”’ Microphone blocked. To allow:
โ€ข Click the lock icon in the address bar โ†’ Site settings โ†’ Microphone โ†’ Allow
โ€ข Reload the page and retry.'; + microphoneHint.style.display = 'block'; + } + if (voiceBtn) { + voiceBtn.disabled = false; + voiceBtn.style.opacity = '1'; + } + return; + } + } catch (e) { + // ignore and continue to request permission (some browsers don't support querying) + } } if (isRecording) { @@ -269,6 +341,11 @@ async function handleVoiceInput() { try { audioChunks = []; + voiceStatus.textContent = 'โณ Requesting microphone access...'; + if (microphoneHint) { + microphoneHint.textContent = 'Browser will prompt for microphone access. Click "Allow" to grant permission.'; + microphoneHint.style.display = 'block'; + } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); audioStream = stream; @@ -295,10 +372,29 @@ async function handleVoiceInput() { voiceBtn.classList.add('listening'); voiceStatus.textContent = '๐ŸŽค Listening...'; showStatus('Listening for voice input...', 'success'); + logDev('Started recording, awaiting audio data'); } catch (error) { console.error('Microphone permission denied:', error); - showStatus('Microphone permission required for voice input', 'error'); - voiceStatus.textContent = 'โŒ Microphone access denied'; + const isPermissionError = error?.name === 'NotAllowedError' || error?.name === 'SecurityError'; + + let errorMessage = ''; + let hintMessage = ''; + + if (isPermissionError) { + errorMessage = 'Microphone permission denied. Grant access in your browser settings.'; + hintMessage = '๐Ÿ”’ Microphone blocked. To allow access:\nโ€ข Chrome/Edge/Brave: Click the lock icon โ†’ Microphone โ†’ Allow\nโ€ข Firefox: Preferences โ†’ Privacy โ†’ Permissions โ†’ Microphone โ†’ Allow\nโ€ข Safari: System Preferences โ†’ Security & Privacy โ†’ Microphone โ†’ Allow'; + } else { + errorMessage = 'Microphone access failed. Check browser and OS privacy settings.'; + hintMessage = 'Unable to access microphone. Verify:\nโ€ข Browser has permission to access microphone\nโ€ข OS allows the browser to use the microphone\nโ€ข No other app is using the microphone'; + } + + showStatus(errorMessage, 'error'); + voiceStatus.textContent = `โŒ ${isPermissionError ? 'Permission denied' : 'Microphone error'}`; + if (microphoneHint) { + microphoneHint.innerHTML = hintMessage.replace(/\n/g, '
'); + microphoneHint.style.display = 'block'; + } + logDev(`handleVoiceInput error: ${error?.name || error?.message || error}`); } } @@ -414,8 +510,14 @@ function speakTranslation() { utterance.pitch = 1; utterance.volume = 1; + if (ttsNotice) { + ttsNotice.textContent = remoteTtsLanguages.has(targetLang.value) + ? 'Using server TTS for this language. Make sure the browser can reach the running app server.' + : 'Using browser speech synthesis for this language.'; + } + const preferredVoice = getPreferredVoice(utterance.lang); - const useRemote = remoteTtsLanguages.has(targetLang.value) && !!API_URL; + const useRemote = remoteTtsLanguages.has(targetLang.value) && Boolean(API_URL); if (useRemote) { speakWithRemoteTts(text, targetLang.value, utterance, preferredVoice); @@ -508,6 +610,9 @@ async function speakWithRemoteTts(text, targetLang, utterance, preferredVoice) { await audio.play(); } catch (error) { console.warn('Remote TTS failed, falling back to browser speech:', error); + if (ttsNotice) { + ttsNotice.textContent = 'Remote TTS failed; using browser speech synthesis fallback.'; + } if (preferredVoice) { utterance.voice = preferredVoice; } @@ -629,3 +734,16 @@ console.log('- Voice input (if supported by browser)'); console.log('- Text-to-speech output'); console.log('- Language swapping'); console.log('- Copy to clipboard'); + +// Dev log helper: writes to console and to the on-page `#devLogBody` if present. +function logDev(message) { + const ts = new Date().toISOString(); + const full = `${ts} - ${message}`; + console.log('DEVLOG', full); + if (devLogBody) { + const el = document.createElement('div'); + el.className = 'dev-log-entry'; + el.textContent = full; + devLogBody.prepend(el); + } +} diff --git a/scripts/permission-check.js b/scripts/permission-check.js new file mode 100644 index 0000000..aec5736 --- /dev/null +++ b/scripts/permission-check.js @@ -0,0 +1,62 @@ +const puppeteer = require('puppeteer'); + +async function run() { + const url = process.argv[2] || 'http://localhost:3000'; + console.log('Checking permissions for', url); + + const browser = await puppeteer.launch({ headless: true }); + const context = browser.defaultBrowserContext(); + const page = await browser.newPage(); + + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: 10000 }); + + const getState = async () => { + return await page.evaluate(async () => { + if (!navigator.permissions) return null; + try { + const s = await navigator.permissions.query({ name: 'microphone' }); + return s.state; + } catch (e) { + return null; + } + }); + }; + + const before = await getState(); + console.log('permission state before override:', before); + + // Programmatically grant microphone permission for the origin + try { + await context.overridePermissions(url, ['microphone']); + console.log('overridePermissions: granted microphone'); + } catch (e) { + console.warn('overridePermissions failed:', e.message || e); + } + + await page.reload({ waitUntil: 'networkidle2', timeout: 10000 }); + const after = await getState(); + console.log('permission state after override/reload:', after); + + // Try calling the app's check function if present + const appLog = await page.evaluate(() => { + try { + if (typeof checkMicrophonePermission === 'function') { + checkMicrophonePermission(); + return 'called checkMicrophonePermission()'; + } + return 'no checkMicrophonePermission() function found'; + } catch (e) { + return `error calling app fn: ${e.message || e}`; + } + }); + console.log(appLog); + + } catch (err) { + console.error('Permission check failed:', err.message || err); + } finally { + await browser.close(); + } +} + +run(); diff --git a/server.js b/server.js index 89d261c..92ab9c8 100644 --- a/server.js +++ b/server.js @@ -7,28 +7,51 @@ const googleTTS = require('google-tts-api'); require('dotenv').config(); const HUGGINGFACE_API_TOKEN = process.env.HUGGINGFACEHUB_API_TOKEN || process.env.HF_API_TOKEN; -const huggingFaceLangMap = { - 'en': 'eng', - 'es': 'spa', - 'fr': 'fra', - 'de': 'deu', - 'pt': 'por', - 'it': 'ita', - 'nl': 'nld', - 'ru': 'rus', - 'ar': 'ara', - 'ja': 'jpn', - 'ko': 'kor', - 'zh': 'zho', - 'hi': 'hin', - 'ta': 'tam', - 'te': 'tel', - 'ml': 'mal', - 'pa': 'pan', - 'kn': 'kan', - 'id': 'ind', - 'vi': 'vie', - 'tr': 'tur' +const nllbLangMap = { + 'en': 'eng_Latn', + 'es': 'spa_Latn', + 'fr': 'fra_Latn', + 'de': 'deu_Latn', + 'pt': 'por_Latn', + 'it': 'ita_Latn', + 'nl': 'nld_Latn', + 'ru': 'rus_Cyrl', + 'ar': 'arb_Arab', + 'ja': 'jpn_Jpan', + 'ko': 'kor_Kore', + 'zh': 'zho_Hans', + 'hi': 'hin_Deva', + 'ta': 'tam_Taml', + 'te': 'tel_Telu', + 'ml': 'mal_Mlym', + 'pa': 'pan_Guru', + 'kn': 'kan_Knda', + 'id': 'ind_Latn', + 'vi': 'vie_Latn', + 'tr': 'tur_Latn' +}; +const m2m100LangMap = { + 'en': 'en', + 'es': 'es', + 'fr': 'fr', + 'de': 'de', + 'pt': 'pt', + 'it': 'it', + 'nl': 'nl', + 'ru': 'ru', + 'ar': 'ar', + 'ja': 'ja', + 'ko': 'ko', + 'zh': 'zh', + 'hi': 'hi', + 'ta': 'ta', + 'te': 'te', + 'ml': 'ml', + 'pa': 'pa', + 'kn': 'kn', + 'id': 'id', + 'vi': 'vi', + 'tr': 'tr' }; const app = express(); @@ -77,18 +100,35 @@ const translateViaMyMemory = async (text, sourceLang, targetLang) => { } }; -// Hugging Face translation via M2M100 +// Hugging Face translation via NLLB or M2M100 const translateViaHuggingFace = async (text, sourceLang, targetLang) => { if (!HUGGINGFACE_API_TOKEN) { return null; } - const srcCode = huggingFaceLangMap[sourceLang] || 'eng'; - const tgtCode = huggingFaceLangMap[targetLang] || 'eng'; + const isEnglishCentric = sourceLang === 'en' || targetLang === 'en'; + const m2mSrcCode = m2m100LangMap[sourceLang]; + const m2mTgtCode = m2m100LangMap[targetLang]; + const nllbSrcCode = nllbLangMap[sourceLang]; + const nllbTgtCode = nllbLangMap[targetLang]; + + const useM2M100 = isEnglishCentric && m2mSrcCode && m2mTgtCode; + const model = useM2M100 + ? 'https://api-inference.huggingface.co/models/facebook/m2m100_418M' + : 'https://api-inference.huggingface.co/models/facebook/nllb-200-distilled-600M'; + const srcCode = useM2M100 ? m2mSrcCode : nllbSrcCode; + const tgtCode = useM2M100 ? m2mTgtCode : nllbTgtCode; + + if (!srcCode || !tgtCode) { + return null; + } + + const chosenModel = useM2M100 ? 'facebook/m2m100_418M' : 'facebook/nllb-200-distilled-600M'; + console.log(`Hugging Face translation using ${useM2M100 ? 'M2M100' : 'NLLB'} model: ${chosenModel} (${srcCode} -> ${tgtCode})`); try { const response = await axios.post( - 'https://api-inference.huggingface.co/models/facebook/m2m100_418M', + model, { inputs: text, parameters: { @@ -106,17 +146,16 @@ const translateViaHuggingFace = async (text, sourceLang, targetLang) => { ); const data = response.data; + let resultText = null; if (Array.isArray(data) && data[0]?.generated_text) { - return data[0].generated_text; - } - if (data?.generated_text) { - return data.generated_text; - } - if (typeof data === 'string') { - return data; + resultText = data[0].generated_text; + } else if (data?.generated_text) { + resultText = data.generated_text; + } else if (typeof data === 'string') { + resultText = data; } - return null; + return resultText ? { translatedText: resultText, model: chosenModel } : null; } catch (error) { console.error('Hugging Face translation error:', error.message || error); return null; @@ -325,16 +364,20 @@ app.post('/api/translate', async (req, res) => { console.log(`Translating "${text.substring(0, 50)}..." from ${sourceLang} to ${targetLang}`); // Try Hugging Face first if the key is configured - let translatedText = await translateViaHuggingFace(text, sourceLang, targetLang); + const hfResult = await translateViaHuggingFace(text, sourceLang, targetLang); + let translatedText = hfResult?.translatedText || null; + let translatedWith = hfResult?.model ? `Hugging Face (${hfResult.model})` : null; // Fallback to LibreTranslate if available locally if (!translatedText) { translatedText = await translateViaLibreTranslate(text, sourceLang, targetLang); + translatedWith = translatedText ? 'LibreTranslate' : translatedWith; } // Fallback to MyMemory if other services are unavailable if (!translatedText) { translatedText = await translateViaMyMemory(text, sourceLang, targetLang); + translatedWith = translatedText ? 'MyMemory' : translatedWith; } if (!translatedText) { @@ -344,7 +387,7 @@ app.post('/api/translate', async (req, res) => { }); } - res.json({ translatedText }); + res.json({ translatedText, translatedWith }); } catch (error) { console.error('Translation error:', error); res.status(500).json({ error: 'Internal server error' }); diff --git a/styles.css b/styles.css index 5f5148b..c50ff5a 100644 --- a/styles.css +++ b/styles.css @@ -50,12 +50,89 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } +.swap-btn, +#copyBtn { + align-self: flex-end; + padding: 10px 16px; + background: #0ea5e9; + border-color: transparent; + color: #fff; +} +#copyBtn { + font-size: 0.95rem; +} +button { + cursor: pointer; + background: #2563eb; + color: white; + border: 1px solid transparent; + transition: background 0.2s ease, transform 0.2s ease; +} +button:hover:not(:disabled) { + background: #1d4ed8; + transform: translateY(-1px); +} + +#voiceBtn.active { + background: linear-gradient(180deg,#34d399,#10b981); + border-color: transparent; + box-shadow: 0 10px 30px rgba(16,185,129,0.35); + color: #071025; +} + +#voiceBtn.active:hover:not(:disabled) { + background: linear-gradient(180deg,#6ee7b7,#34d399); +} + +#voiceBtn.listening { + background: linear-gradient(180deg,#f59e0b,#d97706); +} + .panel { background: #1f2937; border-radius: 12px; padding: 16px; margin-top: 16px; } +.panel-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + font-size: 0.95rem; + color: #cbd5e1; + margin-top: 6px; +} +.microphone-hint { + margin-top: 10px; + color: #cbd5e1; + font-size: 0.9rem; + font-style: italic; +} +.tts-notice { + margin-top: 6px; + color: #f8fafc; + font-size: 0.95rem; + padding: 10px 12px; + background: #0f172a; + border: 1px solid #334155; + border-radius: 10px; +} +.voice-status { + color: #a5b4fc; +} +.output-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.copy-feedback { + margin-top: 10px; + color: #4ade80; + min-height: 1.2rem; +} .output #translatedText { min-height: 80px; padding: 12px; @@ -67,3 +144,38 @@ button:disabled { font-size: 0.95rem; color: #93c5fd; } + +.dev-log { + margin-top: 12px; + background: #0b1220; + border: 1px solid #273449; + border-radius: 8px; + color: #cbd5e1; + font-size: 0.85rem; + max-height: 160px; + overflow: hidden; +} +.dev-log-header { + display: flex; + justify-content: space-between; + padding: 8px 10px; + border-bottom: 1px solid #172033; + background: #071025; +} +.dev-log-body { + padding: 8px 10px; + overflow-y: auto; + max-height: 120px; +} +.dev-log-entry { + padding: 4px 0; + border-bottom: 1px dashed rgba(255,255,255,0.03); +} +.clear-log { + background: transparent; + border: 1px solid #334155; + color: #cbd5e1; + padding: 4px 8px; + border-radius: 6px; + font-size: 0.8rem; +}