Improve microphone permission UX: add Dev Log, re-request flow, active Voice Input highlighting, and Puppeteer permission-check script
This commit is contained in:
19
index.html
19
index.html
@@ -43,6 +43,8 @@
|
|||||||
<option value="kn">Kannada</option>
|
<option value="kn">Kannada</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button id="swapBtn" class="swap-btn">↔ Swap</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -52,13 +54,28 @@
|
|||||||
<button id="speakBtn">🔊 Speak</button>
|
<button id="speakBtn">🔊 Speak</button>
|
||||||
<button id="clearBtn">🧹 Clear</button>
|
<button id="clearBtn">🧹 Clear</button>
|
||||||
</div>
|
</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 id="status" class="status">Ready</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel output">
|
<div class="panel output">
|
||||||
|
<div class="output-header">
|
||||||
<h2>Translation</h2>
|
<h2>Translation</h2>
|
||||||
<div id="translatedText">Translation will appear here...</div>
|
<button id="copyBtn">📋 Copy</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
|
|||||||
343
package-lock.json
generated
343
package-lock.json
generated
@@ -14,6 +14,38 @@
|
|||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"google-tts-api": "^2.0.1"
|
"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": {
|
"node_modules/accepts": {
|
||||||
@@ -64,6 +96,32 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -150,6 +208,38 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -252,6 +342,13 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -284,6 +381,13 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -338,6 +442,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -480,6 +594,29 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -669,6 +806,19 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -738,6 +888,23 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -823,6 +990,46 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
@@ -1021,6 +1228,40 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -1043,6 +1284,13 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@@ -1069,6 +1317,101 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js"
|
"start": "node server.js",
|
||||||
|
"check:permissions": "node scripts/permission-check.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
@@ -12,5 +13,8 @@
|
|||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"google-tts-api": "^2.0.1"
|
"google-tts-api": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"puppeteer": "^25.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
220
script.js
220
script.js
@@ -9,16 +9,19 @@ const getApiUrl = () => {
|
|||||||
// Or local IP for testing: 'http://192.168.1.100:3000'
|
// Or local IP for testing: 'http://192.168.1.100:3000'
|
||||||
return localStorage.getItem('apiUrl') || 'http://localhost: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();
|
const API_URL = getApiUrl();
|
||||||
console.log('API URL:', API_URL);
|
console.log('API URL:', API_URL);
|
||||||
|
|
||||||
// Initialize Speech Recognition API
|
|
||||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
||||||
const recognition = SpeechRecognition ? new SpeechRecognition() : null;
|
|
||||||
|
|
||||||
function isVoiceInputAllowed() {
|
function isVoiceInputAllowed() {
|
||||||
return window.isSecureContext ||
|
return window.isSecureContext ||
|
||||||
location.protocol === 'https:' ||
|
location.protocol === 'https:' ||
|
||||||
@@ -48,11 +51,15 @@ const speakBtn = document.getElementById('speakBtn');
|
|||||||
const copyBtn = document.getElementById('copyBtn');
|
const copyBtn = document.getElementById('copyBtn');
|
||||||
const swapBtn = document.getElementById('swapBtn');
|
const swapBtn = document.getElementById('swapBtn');
|
||||||
const voiceStatus = document.getElementById('voiceStatus');
|
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 statusMessage = document.getElementById('statusMessage') || document.getElementById('status');
|
||||||
const charCount = document.getElementById('charCount');
|
const charCount = document.getElementById('charCount');
|
||||||
const copyFeedback = document.getElementById('copyFeedback');
|
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 = {
|
const langMap = {
|
||||||
'en': 'en-US',
|
'en': 'en-US',
|
||||||
'es': 'es-ES',
|
'es': 'es-ES',
|
||||||
@@ -91,12 +98,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Check voice input support
|
// Check voice input support
|
||||||
const voiceSupportAvailable = !!(navigator.mediaDevices?.getUserMedia && window.MediaRecorder);
|
const voiceSupportAvailable = !!(navigator.mediaDevices?.getUserMedia && window.MediaRecorder);
|
||||||
if (!voiceSupportAvailable) {
|
if (!voiceSupportAvailable) {
|
||||||
console.warn('Voice recording not supported in this browser.');
|
console.warn('Voice input not supported in this browser.');
|
||||||
if (voiceBtn) {
|
if (voiceBtn) {
|
||||||
voiceBtn.disabled = true;
|
voiceBtn.disabled = true;
|
||||||
voiceBtn.title = 'Voice input not supported in this browser';
|
voiceBtn.title = 'Voice input not supported in this browser';
|
||||||
voiceBtn.style.opacity = '0.5';
|
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
|
// Check speech synthesis support
|
||||||
@@ -111,6 +131,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadVoices();
|
loadVoices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyServerReachability();
|
||||||
|
checkMicrophonePermission();
|
||||||
showStatus('Ready to translate!', 'success');
|
showStatus('Ready to translate!', 'success');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +146,7 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
bind(sourceText, 'input', handleTextInput);
|
bind(sourceText, 'input', handleTextInput);
|
||||||
bind(voiceBtn, 'click', handleVoiceInput);
|
bind(voiceBtn, 'click', handleVoiceInput);
|
||||||
|
bind(clearLogBtn, 'click', () => { if (devLogBody) devLogBody.innerHTML = ''; });
|
||||||
bind(clearSourceBtn, 'click', clearInput);
|
bind(clearSourceBtn, 'click', clearInput);
|
||||||
bind(speakBtn, 'click', speakTranslation);
|
bind(speakBtn, 'click', speakTranslation);
|
||||||
bind(copyBtn, 'click', copyTranslation);
|
bind(copyBtn, 'click', copyTranslation);
|
||||||
@@ -131,47 +154,78 @@ function setupEventListeners() {
|
|||||||
bind(sourceLang, 'change', translateOnChange);
|
bind(sourceLang, 'change', translateOnChange);
|
||||||
bind(targetLang, '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}`;
|
// Check microphone permission state (if supported) and update UI
|
||||||
voiceBtn.classList.remove('listening');
|
async function checkMicrophonePermission() {
|
||||||
showStatus(errorMessage, 'error');
|
if (!navigator.permissions) return;
|
||||||
});
|
|
||||||
|
try {
|
||||||
|
const status = await navigator.permissions.query({ name: 'microphone' });
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,10 +306,28 @@ async function handleVoiceInput() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVoiceInputAllowed() && !isMobileApp()) {
|
// If permissions API is available, check microphone state before prompting
|
||||||
showStatus('Voice input requires a secure context. Please access via https://localhost:3443', 'error');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and continue to request permission (some browsers don't support querying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
@@ -269,6 +341,11 @@ async function handleVoiceInput() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
audioChunks = [];
|
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 });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
audioStream = stream;
|
audioStream = stream;
|
||||||
|
|
||||||
@@ -295,10 +372,29 @@ async function handleVoiceInput() {
|
|||||||
voiceBtn.classList.add('listening');
|
voiceBtn.classList.add('listening');
|
||||||
voiceStatus.textContent = '🎤 Listening...';
|
voiceStatus.textContent = '🎤 Listening...';
|
||||||
showStatus('Listening for voice input...', 'success');
|
showStatus('Listening for voice input...', 'success');
|
||||||
|
logDev('Started recording, awaiting audio data');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Microphone permission denied:', error);
|
console.error('Microphone permission denied:', error);
|
||||||
showStatus('Microphone permission required for voice input', 'error');
|
const isPermissionError = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
|
||||||
voiceStatus.textContent = '❌ Microphone access denied';
|
|
||||||
|
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.pitch = 1;
|
||||||
utterance.volume = 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 preferredVoice = getPreferredVoice(utterance.lang);
|
||||||
const useRemote = remoteTtsLanguages.has(targetLang.value) && !!API_URL;
|
const useRemote = remoteTtsLanguages.has(targetLang.value) && Boolean(API_URL);
|
||||||
|
|
||||||
if (useRemote) {
|
if (useRemote) {
|
||||||
speakWithRemoteTts(text, targetLang.value, utterance, preferredVoice);
|
speakWithRemoteTts(text, targetLang.value, utterance, preferredVoice);
|
||||||
@@ -508,6 +610,9 @@ async function speakWithRemoteTts(text, targetLang, utterance, preferredVoice) {
|
|||||||
await audio.play();
|
await audio.play();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Remote TTS failed, falling back to browser speech:', 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) {
|
if (preferredVoice) {
|
||||||
utterance.voice = preferredVoice;
|
utterance.voice = preferredVoice;
|
||||||
}
|
}
|
||||||
@@ -629,3 +734,16 @@ console.log('- Voice input (if supported by browser)');
|
|||||||
console.log('- Text-to-speech output');
|
console.log('- Text-to-speech output');
|
||||||
console.log('- Language swapping');
|
console.log('- Language swapping');
|
||||||
console.log('- Copy to clipboard');
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
62
scripts/permission-check.js
Normal file
62
scripts/permission-check.js
Normal 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
115
server.js
@@ -7,28 +7,51 @@ const googleTTS = require('google-tts-api');
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const HUGGINGFACE_API_TOKEN = process.env.HUGGINGFACEHUB_API_TOKEN || process.env.HF_API_TOKEN;
|
const HUGGINGFACE_API_TOKEN = process.env.HUGGINGFACEHUB_API_TOKEN || process.env.HF_API_TOKEN;
|
||||||
const huggingFaceLangMap = {
|
const nllbLangMap = {
|
||||||
'en': 'eng',
|
'en': 'eng_Latn',
|
||||||
'es': 'spa',
|
'es': 'spa_Latn',
|
||||||
'fr': 'fra',
|
'fr': 'fra_Latn',
|
||||||
'de': 'deu',
|
'de': 'deu_Latn',
|
||||||
'pt': 'por',
|
'pt': 'por_Latn',
|
||||||
'it': 'ita',
|
'it': 'ita_Latn',
|
||||||
'nl': 'nld',
|
'nl': 'nld_Latn',
|
||||||
'ru': 'rus',
|
'ru': 'rus_Cyrl',
|
||||||
'ar': 'ara',
|
'ar': 'arb_Arab',
|
||||||
'ja': 'jpn',
|
'ja': 'jpn_Jpan',
|
||||||
'ko': 'kor',
|
'ko': 'kor_Kore',
|
||||||
'zh': 'zho',
|
'zh': 'zho_Hans',
|
||||||
'hi': 'hin',
|
'hi': 'hin_Deva',
|
||||||
'ta': 'tam',
|
'ta': 'tam_Taml',
|
||||||
'te': 'tel',
|
'te': 'tel_Telu',
|
||||||
'ml': 'mal',
|
'ml': 'mal_Mlym',
|
||||||
'pa': 'pan',
|
'pa': 'pan_Guru',
|
||||||
'kn': 'kan',
|
'kn': 'kan_Knda',
|
||||||
'id': 'ind',
|
'id': 'ind_Latn',
|
||||||
'vi': 'vie',
|
'vi': 'vie_Latn',
|
||||||
'tr': 'tur'
|
'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();
|
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) => {
|
const translateViaHuggingFace = async (text, sourceLang, targetLang) => {
|
||||||
if (!HUGGINGFACE_API_TOKEN) {
|
if (!HUGGINGFACE_API_TOKEN) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const srcCode = huggingFaceLangMap[sourceLang] || 'eng';
|
const isEnglishCentric = sourceLang === 'en' || targetLang === 'en';
|
||||||
const tgtCode = huggingFaceLangMap[targetLang] || 'eng';
|
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 {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
'https://api-inference.huggingface.co/models/facebook/m2m100_418M',
|
model,
|
||||||
{
|
{
|
||||||
inputs: text,
|
inputs: text,
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -106,17 +146,16 @@ const translateViaHuggingFace = async (text, sourceLang, targetLang) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
let resultText = null;
|
||||||
if (Array.isArray(data) && data[0]?.generated_text) {
|
if (Array.isArray(data) && data[0]?.generated_text) {
|
||||||
return data[0].generated_text;
|
resultText = data[0].generated_text;
|
||||||
}
|
} else if (data?.generated_text) {
|
||||||
if (data?.generated_text) {
|
resultText = data.generated_text;
|
||||||
return data.generated_text;
|
} else if (typeof data === 'string') {
|
||||||
}
|
resultText = data;
|
||||||
if (typeof data === 'string') {
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return resultText ? { translatedText: resultText, model: chosenModel } : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Hugging Face translation error:', error.message || error);
|
console.error('Hugging Face translation error:', error.message || error);
|
||||||
return null;
|
return null;
|
||||||
@@ -325,16 +364,20 @@ app.post('/api/translate', async (req, res) => {
|
|||||||
console.log(`Translating "${text.substring(0, 50)}..." from ${sourceLang} to ${targetLang}`);
|
console.log(`Translating "${text.substring(0, 50)}..." from ${sourceLang} to ${targetLang}`);
|
||||||
|
|
||||||
// Try Hugging Face first if the key is configured
|
// 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
|
// Fallback to LibreTranslate if available locally
|
||||||
if (!translatedText) {
|
if (!translatedText) {
|
||||||
translatedText = await translateViaLibreTranslate(text, sourceLang, targetLang);
|
translatedText = await translateViaLibreTranslate(text, sourceLang, targetLang);
|
||||||
|
translatedWith = translatedText ? 'LibreTranslate' : translatedWith;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to MyMemory if other services are unavailable
|
// Fallback to MyMemory if other services are unavailable
|
||||||
if (!translatedText) {
|
if (!translatedText) {
|
||||||
translatedText = await translateViaMyMemory(text, sourceLang, targetLang);
|
translatedText = await translateViaMyMemory(text, sourceLang, targetLang);
|
||||||
|
translatedWith = translatedText ? 'MyMemory' : translatedWith;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!translatedText) {
|
if (!translatedText) {
|
||||||
@@ -344,7 +387,7 @@ app.post('/api/translate', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ translatedText });
|
res.json({ translatedText, translatedWith });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Translation error:', error);
|
console.error('Translation error:', error);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|||||||
112
styles.css
112
styles.css
@@ -50,12 +50,89 @@ button:disabled {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
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 {
|
.panel {
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-top: 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 {
|
.output #translatedText {
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -67,3 +144,38 @@ button:disabled {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #93c5fd;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user