Improve microphone permission UX: add Dev Log, re-request flow, active Voice Input highlighting, and Puppeteer permission-check script

This commit is contained in:
Chandu
2026-07-05 13:21:40 -04:00
parent c0e3aace8e
commit ac34e8f86a
7 changed files with 786 additions and 87 deletions

View File

@@ -43,6 +43,8 @@
<option value="kn">Kannada</option>
</select>
</label>
<button id="swapBtn" class="swap-btn">↔ Swap</button>
</div>
<div class="panel">
@@ -52,15 +54,30 @@
<button id="speakBtn">🔊 Speak</button>
<button id="clearBtn">🧹 Clear</button>
</div>
<div class="microphone-hint">Allow microphone access when prompted to use voice input.</div>
<div id="ttsNotice" class="tts-notice">Select a language and click Speak to use browser or server TTS.</div>
<div class="panel-footer">
<span id="charCount">0</span> characters
<span id="voiceStatus" class="voice-status"></span>
</div>
<div id="status" class="status">Ready</div>
</div>
<div class="panel output">
<h2>Translation</h2>
<div class="output-header">
<h2>Translation</h2>
<button id="copyBtn">📋 Copy</button>
</div>
<div id="translatedText">Translation will appear here...</div>
<div id="copyFeedback" class="copy-feedback"></div>
</div>
</div>
<div id="devLog" class="dev-log">
<div class="dev-log-header">Dev Log <button id="clearLogBtn" class="clear-log">Clear</button></div>
<div id="devLogBody" class="dev-log-body">(permission-state and debug messages appear here)</div>
</div>
<script src="script.js"></script>
</body>
</html>

343
package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

216
script.js
View File

@@ -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, '<br>');
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:<br>• Click the lock icon in the address bar → Site settings → Microphone → Allow<br>• 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, '<br>');
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);
}
}

View File

@@ -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();

115
server.js
View File

@@ -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' });

View File

@@ -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;
}