Spaces:
Running
Running
Update index.html
Browse files- index.html +372 -40
index.html
CHANGED
|
@@ -61,6 +61,22 @@
|
|
| 61 |
.api-key-input:focus {
|
| 62 |
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
| 63 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</style>
|
| 65 |
</head>
|
| 66 |
<body class="bg-gray-50 min-h-screen">
|
|
@@ -94,7 +110,7 @@
|
|
| 94 |
<button id="uploadBtn" class="bg-blue-500 hover:bg-blue-600 text-white py-3 px-4 rounded-lg flex items-center justify-center transition">
|
| 95 |
<i class="fas fa-upload mr-2"></i> Upload File
|
| 96 |
</button>
|
| 97 |
-
<button id="pasteBtn" class="bg-green-500 hover:
|
| 98 |
<i class="fas fa-paste mr-2"></i> Paste Text
|
| 99 |
</button>
|
| 100 |
<div id="dropzone" class="dropzone bg-gray-50 py-3 px-4 rounded-lg flex items-center justify-center cursor-pointer">
|
|
@@ -188,10 +204,10 @@
|
|
| 188 |
<button id="playBtn" class="bg-blue-600 hover:bg-blue-700 text-white rounded-full w-12 h-12 flex items-center justify-center transition">
|
| 189 |
<i class="fas fa-play"></i>
|
| 190 |
</button>
|
| 191 |
-
<button id="pauseBtn" class="bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-full w-12 h-12 flex items-center justify-center transition">
|
| 192 |
<i class="fas fa-pause"></i>
|
| 193 |
</button>
|
| 194 |
-
<button id="stopBtn" class="bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-full w-12 h-12 flex items-center justify-center transition">
|
| 195 |
<i class="fas fa-stop"></i>
|
| 196 |
</button>
|
| 197 |
</div>
|
|
@@ -202,8 +218,9 @@
|
|
| 202 |
<span id="currentTime">0:00</span>
|
| 203 |
<span id="totalTime">0:00</span>
|
| 204 |
</div>
|
| 205 |
-
<div id="progressContainer" class="w-full flex">
|
| 206 |
<!-- Progress chunks will be added dynamically -->
|
|
|
|
| 207 |
</div>
|
| 208 |
</div>
|
| 209 |
</div>
|
|
@@ -240,24 +257,25 @@
|
|
| 240 |
const currentTime = document.getElementById('currentTime');
|
| 241 |
const totalTime = document.getElementById('totalTime');
|
| 242 |
const progressContainer = document.getElementById('progressContainer');
|
| 243 |
-
|
|
|
|
| 244 |
// Audio and state variables
|
| 245 |
let audioContext;
|
| 246 |
let audioBuffer;
|
| 247 |
let audioSource;
|
| 248 |
let currentText = '';
|
| 249 |
let isPlaying = false;
|
|
|
|
| 250 |
let selectedVoice = null;
|
| 251 |
let startTime = 0;
|
|
|
|
| 252 |
let estimatedDuration = 0;
|
| 253 |
let currentChunkIndex = 0;
|
| 254 |
const MAX_CHUNK_SIZE = 5000; // Characters per chunk (Google TTS limit)
|
| 255 |
let totalChunks = 0;
|
| 256 |
-
let progressChunks = [];
|
| 257 |
let audioQueue = [];
|
| 258 |
-
let isPaused = false;
|
| 259 |
let apiKey = localStorage.getItem('googleTTSApiKey') || '';
|
| 260 |
-
|
| 261 |
// Initialize
|
| 262 |
function init() {
|
| 263 |
// Load saved API key
|
|
@@ -267,17 +285,41 @@
|
|
| 267 |
}
|
| 268 |
|
| 269 |
// Set PDF.js worker path
|
| 270 |
-
pdfjsLib
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
// Initialize AudioContext
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
try {
|
| 274 |
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 275 |
} catch (e) {
|
| 276 |
console.error('Web Audio API not supported', e);
|
| 277 |
-
|
| 278 |
}
|
| 279 |
}
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
// API Key Management
|
| 282 |
saveKeyBtn.addEventListener('click', () => {
|
| 283 |
const key = apiKeyInput.value.trim();
|
|
@@ -290,7 +332,7 @@
|
|
| 290 |
showToast('Please enter a valid API key', 'error');
|
| 291 |
}
|
| 292 |
});
|
| 293 |
-
|
| 294 |
// Load available voices from Google TTS
|
| 295 |
async function loadVoices() {
|
| 296 |
if (!apiKey) {
|
|
@@ -303,24 +345,30 @@
|
|
| 303 |
`;
|
| 304 |
return;
|
| 305 |
}
|
| 306 |
-
|
| 307 |
voiceGrid.innerHTML = `
|
| 308 |
<div class="col-span-full text-center py-8">
|
| 309 |
<div class="loading-spinner mx-auto"></div>
|
| 310 |
<p class="text-gray-500 mt-2">Loading Google TTS voices...</p>
|
| 311 |
</div>
|
| 312 |
`;
|
| 313 |
-
|
| 314 |
try {
|
| 315 |
const languageCode = languageSelect.value;
|
| 316 |
const response = await fetch(`https://texttospeech.googleapis.com/v1/voices?key=${apiKey}&languageCode=${languageCode}`);
|
| 317 |
|
| 318 |
if (!response.ok) {
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
}
|
| 321 |
|
| 322 |
const data = await response.json();
|
| 323 |
-
renderVoiceOptions(data.voices);
|
| 324 |
} catch (error) {
|
| 325 |
console.error('Error loading voices:', error);
|
| 326 |
voiceGrid.innerHTML = `
|
|
@@ -328,12 +376,16 @@
|
|
| 328 |
<i class="fas fa-exclamation-triangle text-red-400 text-2xl"></i>
|
| 329 |
<p class="text-gray-500 mt-2">Failed to load voices</p>
|
| 330 |
<p class="text-red-500 text-sm mt-1">${error.message}</p>
|
| 331 |
-
<
|
|
|
|
|
|
|
| 332 |
</div>
|
| 333 |
`;
|
|
|
|
|
|
|
| 334 |
}
|
| 335 |
}
|
| 336 |
-
|
| 337 |
function renderVoiceOptions(voices) {
|
| 338 |
voiceGrid.innerHTML = '';
|
| 339 |
|
|
@@ -367,10 +419,10 @@
|
|
| 367 |
let genderIcon = 'fa-user';
|
| 368 |
let genderColor = 'text-gray-500';
|
| 369 |
if (voice.ssmlGender === 'FEMALE') {
|
| 370 |
-
genderIcon = 'fa-
|
| 371 |
genderColor = 'text-pink-500';
|
| 372 |
} else if (voice.ssmlGender === 'MALE') {
|
| 373 |
-
genderIcon = 'fa-
|
| 374 |
genderColor = 'text-blue-500';
|
| 375 |
}
|
| 376 |
|
|
@@ -427,10 +479,12 @@
|
|
| 427 |
});
|
| 428 |
|
| 429 |
// Select first voice by default
|
| 430 |
-
|
| 431 |
-
|
|
|
|
|
|
|
| 432 |
}
|
| 433 |
-
|
| 434 |
// Play a voice sample
|
| 435 |
async function playSample(voice, text) {
|
| 436 |
if (!apiKey) {
|
|
@@ -450,7 +504,82 @@
|
|
| 450 |
showToast('Failed to play sample: ' + error.message, 'error');
|
| 451 |
}
|
| 452 |
}
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
// File Handling
|
| 455 |
uploadBtn.addEventListener('click', () => fileInput.click());
|
| 456 |
|
|
@@ -459,7 +588,7 @@
|
|
| 459 |
handleFile(e.target.files[0]);
|
| 460 |
}
|
| 461 |
});
|
| 462 |
-
|
| 463 |
pasteBtn.addEventListener('click', () => {
|
| 464 |
documentContent.innerHTML = '<p class="text-gray-500 italic">Press Ctrl+V (Cmd+V on Mac) to paste your text...</p>';
|
| 465 |
documentContent.focus();
|
|
@@ -475,33 +604,33 @@
|
|
| 475 |
|
| 476 |
document.addEventListener('paste', pasteHandler);
|
| 477 |
});
|
| 478 |
-
|
| 479 |
// Drag and Drop
|
| 480 |
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
| 481 |
dropzone.addEventListener(eventName, preventDefaults, false);
|
| 482 |
});
|
| 483 |
-
|
| 484 |
function preventDefaults(e) {
|
| 485 |
e.preventDefault();
|
| 486 |
e.stopPropagation();
|
| 487 |
}
|
| 488 |
-
|
| 489 |
['dragenter', 'dragover'].forEach(eventName => {
|
| 490 |
dropzone.addEventListener(eventName, highlight, false);
|
| 491 |
});
|
| 492 |
-
|
| 493 |
['dragleave', 'drop'].forEach(eventName => {
|
| 494 |
dropzone.addEventListener(eventName, unhighlight, false);
|
| 495 |
});
|
| 496 |
-
|
| 497 |
function highlight() {
|
| 498 |
dropzone.classList.add('active');
|
| 499 |
}
|
| 500 |
-
|
| 501 |
function unhighlight() {
|
| 502 |
dropzone.classList.remove('active');
|
| 503 |
}
|
| 504 |
-
|
| 505 |
dropzone.addEventListener('drop', (e) => {
|
| 506 |
const dt = e.dataTransfer;
|
| 507 |
const file = dt.files[0];
|
|
@@ -509,7 +638,7 @@
|
|
| 509 |
handleFile(file);
|
| 510 |
}
|
| 511 |
});
|
| 512 |
-
|
| 513 |
// File processing
|
| 514 |
function handleFile(file) {
|
| 515 |
const fileType = file.type;
|
|
@@ -522,12 +651,12 @@
|
|
| 522 |
} else if (fileType === 'application/msword' ||
|
| 523 |
fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
| 524 |
fileName.endsWith('.doc') || fileName.endsWith('.docx')) {
|
| 525 |
-
|
| 526 |
} else {
|
| 527 |
-
|
| 528 |
}
|
| 529 |
}
|
| 530 |
-
|
| 531 |
function readTextFile(file) {
|
| 532 |
const reader = new FileReader();
|
| 533 |
reader.onload = (e) => {
|
|
@@ -535,11 +664,11 @@
|
|
| 535 |
};
|
| 536 |
reader.readAsText(file);
|
| 537 |
}
|
| 538 |
-
|
| 539 |
function readPDFFile(file) {
|
| 540 |
// Check if PDF.js is available
|
| 541 |
if (typeof pdfjsLib === 'undefined') {
|
| 542 |
-
|
| 543 |
return;
|
| 544 |
}
|
| 545 |
|
|
@@ -562,5 +691,208 @@
|
|
| 562 |
|
| 563 |
Promise.all(pagePromises).then(pagesText => {
|
| 564 |
text = pagesText.join('\n\n');
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
</html>
|
|
|
|
| 61 |
.api-key-input:focus {
|
| 62 |
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
| 63 |
}
|
| 64 |
+
.toast {
|
| 65 |
+
position: fixed;
|
| 66 |
+
top: 20px;
|
| 67 |
+
right: 20px;
|
| 68 |
+
padding: 12px 24px;
|
| 69 |
+
border-radius: 8px;
|
| 70 |
+
color: white;
|
| 71 |
+
z-index: 1000;
|
| 72 |
+
animation: slideIn 0.3s ease;
|
| 73 |
+
}
|
| 74 |
+
.toast.success { background-color: #10B981; }
|
| 75 |
+
.toast.error { background-color: #EF4444; }
|
| 76 |
+
@keyframes slideIn {
|
| 77 |
+
from { transform: translateX(100%); opacity: 0; }
|
| 78 |
+
to { transform: translateX(0); opacity: 1; }
|
| 79 |
+
}
|
| 80 |
</style>
|
| 81 |
</head>
|
| 82 |
<body class="bg-gray-50 min-h-screen">
|
|
|
|
| 110 |
<button id="uploadBtn" class="bg-blue-500 hover:bg-blue-600 text-white py-3 px-4 rounded-lg flex items-center justify-center transition">
|
| 111 |
<i class="fas fa-upload mr-2"></i> Upload File
|
| 112 |
</button>
|
| 113 |
+
<button id="pasteBtn" class="bg-green-500 hover:green-600 text-white py-3 px-4 rounded-lg flex items-center justify-center transition">
|
| 114 |
<i class="fas fa-paste mr-2"></i> Paste Text
|
| 115 |
</button>
|
| 116 |
<div id="dropzone" class="dropzone bg-gray-50 py-3 px-4 rounded-lg flex items-center justify-center cursor-pointer">
|
|
|
|
| 204 |
<button id="playBtn" class="bg-blue-600 hover:bg-blue-700 text-white rounded-full w-12 h-12 flex items-center justify-center transition">
|
| 205 |
<i class="fas fa-play"></i>
|
| 206 |
</button>
|
| 207 |
+
<button id="pauseBtn" class="bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-full w-12 h-12 flex items-center justify-center transition" disabled>
|
| 208 |
<i class="fas fa-pause"></i>
|
| 209 |
</button>
|
| 210 |
+
<button id="stopBtn" class="bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-full w-12 h-12 flex items-center justify-center transition" disabled>
|
| 211 |
<i class="fas fa-stop"></i>
|
| 212 |
</button>
|
| 213 |
</div>
|
|
|
|
| 218 |
<span id="currentTime">0:00</span>
|
| 219 |
<span id="totalTime">0:00</span>
|
| 220 |
</div>
|
| 221 |
+
<div id="progressContainer" class="w-full bg-gray-200 rounded-full h-2 flex">
|
| 222 |
<!-- Progress chunks will be added dynamically -->
|
| 223 |
+
<div id="progressBar" class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
| 224 |
</div>
|
| 225 |
</div>
|
| 226 |
</div>
|
|
|
|
| 257 |
const currentTime = document.getElementById('currentTime');
|
| 258 |
const totalTime = document.getElementById('totalTime');
|
| 259 |
const progressContainer = document.getElementById('progressContainer');
|
| 260 |
+
const progressBar = document.getElementById('progressBar');
|
| 261 |
+
|
| 262 |
// Audio and state variables
|
| 263 |
let audioContext;
|
| 264 |
let audioBuffer;
|
| 265 |
let audioSource;
|
| 266 |
let currentText = '';
|
| 267 |
let isPlaying = false;
|
| 268 |
+
let isPaused = false;
|
| 269 |
let selectedVoice = null;
|
| 270 |
let startTime = 0;
|
| 271 |
+
let pauseTime = 0;
|
| 272 |
let estimatedDuration = 0;
|
| 273 |
let currentChunkIndex = 0;
|
| 274 |
const MAX_CHUNK_SIZE = 5000; // Characters per chunk (Google TTS limit)
|
| 275 |
let totalChunks = 0;
|
|
|
|
| 276 |
let audioQueue = [];
|
|
|
|
| 277 |
let apiKey = localStorage.getItem('googleTTSApiKey') || '';
|
| 278 |
+
|
| 279 |
// Initialize
|
| 280 |
function init() {
|
| 281 |
// Load saved API key
|
|
|
|
| 285 |
}
|
| 286 |
|
| 287 |
// Set PDF.js worker path
|
| 288 |
+
if (typeof pdfjsLib !== 'undefined') {
|
| 289 |
+
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.12.313/pdf.worker.min.js';
|
| 290 |
+
}
|
| 291 |
|
| 292 |
+
// Initialize AudioContext on user interaction
|
| 293 |
+
document.addEventListener('click', initAudioContext, { once: true });
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function initAudioContext() {
|
| 297 |
try {
|
| 298 |
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 299 |
} catch (e) {
|
| 300 |
console.error('Web Audio API not supported', e);
|
| 301 |
+
showToast('Your browser does not support the Web Audio API. Please use Chrome or Edge.', 'error');
|
| 302 |
}
|
| 303 |
}
|
| 304 |
+
|
| 305 |
+
// Toast notifications
|
| 306 |
+
function showToast(message, type = 'success', duration = 3000) {
|
| 307 |
+
const toast = document.createElement('div');
|
| 308 |
+
toast.className = `toast ${type}`;
|
| 309 |
+
toast.textContent = message;
|
| 310 |
+
document.body.appendChild(toast);
|
| 311 |
+
|
| 312 |
+
setTimeout(() => {
|
| 313 |
+
toast.style.animation = 'slideOut 0.3s ease';
|
| 314 |
+
setTimeout(() => toast.remove(), 300);
|
| 315 |
+
}, duration);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Add slideOut animation
|
| 319 |
+
const style = document.createElement('style');
|
| 320 |
+
style.textContent = '@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }';
|
| 321 |
+
document.head.appendChild(style);
|
| 322 |
+
|
| 323 |
// API Key Management
|
| 324 |
saveKeyBtn.addEventListener('click', () => {
|
| 325 |
const key = apiKeyInput.value.trim();
|
|
|
|
| 332 |
showToast('Please enter a valid API key', 'error');
|
| 333 |
}
|
| 334 |
});
|
| 335 |
+
|
| 336 |
// Load available voices from Google TTS
|
| 337 |
async function loadVoices() {
|
| 338 |
if (!apiKey) {
|
|
|
|
| 345 |
`;
|
| 346 |
return;
|
| 347 |
}
|
| 348 |
+
|
| 349 |
voiceGrid.innerHTML = `
|
| 350 |
<div class="col-span-full text-center py-8">
|
| 351 |
<div class="loading-spinner mx-auto"></div>
|
| 352 |
<p class="text-gray-500 mt-2">Loading Google TTS voices...</p>
|
| 353 |
</div>
|
| 354 |
`;
|
| 355 |
+
|
| 356 |
try {
|
| 357 |
const languageCode = languageSelect.value;
|
| 358 |
const response = await fetch(`https://texttospeech.googleapis.com/v1/voices?key=${apiKey}&languageCode=${languageCode}`);
|
| 359 |
|
| 360 |
if (!response.ok) {
|
| 361 |
+
if (response.status === 403) {
|
| 362 |
+
throw new Error('Invalid API key or Text-to-Speech API not enabled');
|
| 363 |
+
} else if (response.status === 400) {
|
| 364 |
+
throw new Error('Invalid request. Check your API key.');
|
| 365 |
+
} else {
|
| 366 |
+
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
| 367 |
+
}
|
| 368 |
}
|
| 369 |
|
| 370 |
const data = await response.json();
|
| 371 |
+
renderVoiceOptions(data.voices || []);
|
| 372 |
} catch (error) {
|
| 373 |
console.error('Error loading voices:', error);
|
| 374 |
voiceGrid.innerHTML = `
|
|
|
|
| 376 |
<i class="fas fa-exclamation-triangle text-red-400 text-2xl"></i>
|
| 377 |
<p class="text-gray-500 mt-2">Failed to load voices</p>
|
| 378 |
<p class="text-red-500 text-sm mt-1">${error.message}</p>
|
| 379 |
+
<button id="retryVoices" class="mt-2 text-blue-500 hover:text-blue-700 text-sm">
|
| 380 |
+
<i class="fas fa-retry mr-1"></i> Retry
|
| 381 |
+
</button>
|
| 382 |
</div>
|
| 383 |
`;
|
| 384 |
+
|
| 385 |
+
document.getElementById('retryVoices')?.addEventListener('click', loadVoices);
|
| 386 |
}
|
| 387 |
}
|
| 388 |
+
|
| 389 |
function renderVoiceOptions(voices) {
|
| 390 |
voiceGrid.innerHTML = '';
|
| 391 |
|
|
|
|
| 419 |
let genderIcon = 'fa-user';
|
| 420 |
let genderColor = 'text-gray-500';
|
| 421 |
if (voice.ssmlGender === 'FEMALE') {
|
| 422 |
+
genderIcon = 'fa-venus';
|
| 423 |
genderColor = 'text-pink-500';
|
| 424 |
} else if (voice.ssmlGender === 'MALE') {
|
| 425 |
+
genderIcon = 'fa-mars';
|
| 426 |
genderColor = 'text-blue-500';
|
| 427 |
}
|
| 428 |
|
|
|
|
| 479 |
});
|
| 480 |
|
| 481 |
// Select first voice by default
|
| 482 |
+
if (naturalVoices.length > 0) {
|
| 483 |
+
const firstCard = voiceGrid.querySelector('.voice-card');
|
| 484 |
+
if (firstCard) firstCard.click();
|
| 485 |
+
}
|
| 486 |
}
|
| 487 |
+
|
| 488 |
// Play a voice sample
|
| 489 |
async function playSample(voice, text) {
|
| 490 |
if (!apiKey) {
|
|
|
|
| 504 |
showToast('Failed to play sample: ' + error.message, 'error');
|
| 505 |
}
|
| 506 |
}
|
| 507 |
+
|
| 508 |
+
// Synthesize speech using Google TTS API
|
| 509 |
+
async function synthesizeSpeech(text, voice, rate = 1, pitch = 0, model = 'standard') {
|
| 510 |
+
if (!apiKey) {
|
| 511 |
+
throw new Error('API key not provided');
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
const request = {
|
| 515 |
+
input: { text: text },
|
| 516 |
+
voice: {
|
| 517 |
+
languageCode: voice.languageCodes[0],
|
| 518 |
+
name: voice.name,
|
| 519 |
+
ssmlGender: voice.ssmlGender
|
| 520 |
+
},
|
| 521 |
+
audioConfig: {
|
| 522 |
+
audioEncoding: 'MP3',
|
| 523 |
+
speakingRate: rate,
|
| 524 |
+
pitch: pitch,
|
| 525 |
+
effectsProfileId: model === 'studio' ? ['telephony-class-application'] : undefined
|
| 526 |
+
}
|
| 527 |
+
};
|
| 528 |
+
|
| 529 |
+
const response = await fetch(`https://texttospeech.googleapis.com/v1/text:synthesize?key=${apiKey}`, {
|
| 530 |
+
method: 'POST',
|
| 531 |
+
headers: {
|
| 532 |
+
'Content-Type': 'application/json',
|
| 533 |
+
},
|
| 534 |
+
body: JSON.stringify(request)
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
if (!response.ok) {
|
| 538 |
+
const errorData = await response.json();
|
| 539 |
+
throw new Error(errorData.error?.message || `HTTP error! status: ${response.status}`);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
const data = await response.json();
|
| 543 |
+
return data.audioContent;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
// Play audio data
|
| 547 |
+
async function playAudioData(audioBase64) {
|
| 548 |
+
if (!audioContext) {
|
| 549 |
+
initAudioContext();
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
try {
|
| 553 |
+
// Decode base64 audio
|
| 554 |
+
const audioData = Uint8Array.from(atob(audioBase64), c => c.charCodeAt(0));
|
| 555 |
+
const audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
|
| 556 |
+
|
| 557 |
+
// Create and play audio source
|
| 558 |
+
audioSource = audioContext.createBufferSource();
|
| 559 |
+
audioSource.buffer = audioBuffer;
|
| 560 |
+
audioSource.connect(audioContext.destination);
|
| 561 |
+
|
| 562 |
+
// Set up playback state
|
| 563 |
+
isPlaying = true;
|
| 564 |
+
isPaused = false;
|
| 565 |
+
updatePlaybackButtons();
|
| 566 |
+
|
| 567 |
+
// Start playback
|
| 568 |
+
audioSource.start(0);
|
| 569 |
+
startTime = audioContext.currentTime;
|
| 570 |
+
|
| 571 |
+
// Handle playback end
|
| 572 |
+
audioSource.onended = () => {
|
| 573 |
+
if (isPlaying && !isPaused) {
|
| 574 |
+
stopPlayback();
|
| 575 |
+
}
|
| 576 |
+
};
|
| 577 |
+
} catch (error) {
|
| 578 |
+
console.error('Error playing audio:', error);
|
| 579 |
+
showToast('Failed to play audio: ' + error.message, 'error');
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
// File Handling
|
| 584 |
uploadBtn.addEventListener('click', () => fileInput.click());
|
| 585 |
|
|
|
|
| 588 |
handleFile(e.target.files[0]);
|
| 589 |
}
|
| 590 |
});
|
| 591 |
+
|
| 592 |
pasteBtn.addEventListener('click', () => {
|
| 593 |
documentContent.innerHTML = '<p class="text-gray-500 italic">Press Ctrl+V (Cmd+V on Mac) to paste your text...</p>';
|
| 594 |
documentContent.focus();
|
|
|
|
| 604 |
|
| 605 |
document.addEventListener('paste', pasteHandler);
|
| 606 |
});
|
| 607 |
+
|
| 608 |
// Drag and Drop
|
| 609 |
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
| 610 |
dropzone.addEventListener(eventName, preventDefaults, false);
|
| 611 |
});
|
| 612 |
+
|
| 613 |
function preventDefaults(e) {
|
| 614 |
e.preventDefault();
|
| 615 |
e.stopPropagation();
|
| 616 |
}
|
| 617 |
+
|
| 618 |
['dragenter', 'dragover'].forEach(eventName => {
|
| 619 |
dropzone.addEventListener(eventName, highlight, false);
|
| 620 |
});
|
| 621 |
+
|
| 622 |
['dragleave', 'drop'].forEach(eventName => {
|
| 623 |
dropzone.addEventListener(eventName, unhighlight, false);
|
| 624 |
});
|
| 625 |
+
|
| 626 |
function highlight() {
|
| 627 |
dropzone.classList.add('active');
|
| 628 |
}
|
| 629 |
+
|
| 630 |
function unhighlight() {
|
| 631 |
dropzone.classList.remove('active');
|
| 632 |
}
|
| 633 |
+
|
| 634 |
dropzone.addEventListener('drop', (e) => {
|
| 635 |
const dt = e.dataTransfer;
|
| 636 |
const file = dt.files[0];
|
|
|
|
| 638 |
handleFile(file);
|
| 639 |
}
|
| 640 |
});
|
| 641 |
+
|
| 642 |
// File processing
|
| 643 |
function handleFile(file) {
|
| 644 |
const fileType = file.type;
|
|
|
|
| 651 |
} else if (fileType === 'application/msword' ||
|
| 652 |
fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
| 653 |
fileName.endsWith('.doc') || fileName.endsWith('.docx')) {
|
| 654 |
+
showToast('Word document support requires conversion to text. Please save as plain text or PDF first.', 'error');
|
| 655 |
} else {
|
| 656 |
+
showToast('Unsupported file type. Please upload a TXT or PDF file.', 'error');
|
| 657 |
}
|
| 658 |
}
|
| 659 |
+
|
| 660 |
function readTextFile(file) {
|
| 661 |
const reader = new FileReader();
|
| 662 |
reader.onload = (e) => {
|
|
|
|
| 664 |
};
|
| 665 |
reader.readAsText(file);
|
| 666 |
}
|
| 667 |
+
|
| 668 |
function readPDFFile(file) {
|
| 669 |
// Check if PDF.js is available
|
| 670 |
if (typeof pdfjsLib === 'undefined') {
|
| 671 |
+
showToast('PDF.js not loaded. PDF support requires PDF.js library.', 'error');
|
| 672 |
return;
|
| 673 |
}
|
| 674 |
|
|
|
|
| 691 |
|
| 692 |
Promise.all(pagePromises).then(pagesText => {
|
| 693 |
text = pagesText.join('\n\n');
|
| 694 |
+
setDocumentContent(text);
|
| 695 |
+
}).catch(error => {
|
| 696 |
+
console.error('Error extracting PDF text:', error);
|
| 697 |
+
showToast('Failed to extract text from PDF', 'error');
|
| 698 |
+
});
|
| 699 |
+
}).catch(error => {
|
| 700 |
+
console.error('Error loading PDF:', error);
|
| 701 |
+
showToast('Failed to load PDF file', 'error');
|
| 702 |
+
});
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
// Set document content
|
| 706 |
+
function setDocumentContent(text) {
|
| 707 |
+
currentText = text;
|
| 708 |
+
documentContent.innerHTML = text.split('\n').map(line => `<p>${line || ' '}</p>`).join('');
|
| 709 |
+
charCount.textContent = `${text.length} characters`;
|
| 710 |
+
|
| 711 |
+
if (text.length > 1000000) {
|
| 712 |
+
showToast('Document exceeds 1,000,000 character limit. Some content may be truncated.', 'error');
|
| 713 |
+
}
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
// Playback controls
|
| 717 |
+
playBtn.addEventListener('click', async () => {
|
| 718 |
+
if (!selectedVoice) {
|
| 719 |
+
showToast('Please select a voice first', 'error');
|
| 720 |
+
return;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
if (!currentText.trim()) {
|
| 724 |
+
showToast('Please add some text to read', 'error');
|
| 725 |
+
return;
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
if (isPaused) {
|
| 729 |
+
// Resume playback
|
| 730 |
+
resumePlayback();
|
| 731 |
+
} else {
|
| 732 |
+
// Start new playback
|
| 733 |
+
await startPlayback();
|
| 734 |
+
}
|
| 735 |
+
});
|
| 736 |
+
|
| 737 |
+
pauseBtn.addEventListener('click', () => {
|
| 738 |
+
if (isPlaying && !isPaused) {
|
| 739 |
+
pausePlayback();
|
| 740 |
+
}
|
| 741 |
+
});
|
| 742 |
+
|
| 743 |
+
stopBtn.addEventListener('click', () => {
|
| 744 |
+
stopPlayback();
|
| 745 |
+
});
|
| 746 |
+
|
| 747 |
+
async function startPlayback() {
|
| 748 |
+
try {
|
| 749 |
+
isPlaying = true;
|
| 750 |
+
isPaused = false;
|
| 751 |
+
updatePlaybackButtons();
|
| 752 |
+
|
| 753 |
+
showToast('Generating speech...', 'success', 1000);
|
| 754 |
+
|
| 755 |
+
// Split text into chunks if needed
|
| 756 |
+
const chunks = splitTextIntoChunks(currentText, MAX_CHUNK_SIZE);
|
| 757 |
+
totalChunks = chunks.length;
|
| 758 |
+
|
| 759 |
+
if (chunks.length > 1) {
|
| 760 |
+
showToast(`Processing ${chunks.length} chunks...`, 'success', 2000);
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
// Process all chunks
|
| 764 |
+
for (let i = 0; i < chunks.length; i++) {
|
| 765 |
+
if (!isPlaying || isPaused) break;
|
| 766 |
+
|
| 767 |
+
currentChunkIndex = i;
|
| 768 |
+
const audioData = await synthesizeSpeech(
|
| 769 |
+
chunks[i],
|
| 770 |
+
selectedVoice,
|
| 771 |
+
parseFloat(rateSelect.value),
|
| 772 |
+
parseFloat(pitchSelect.value),
|
| 773 |
+
modelSelect.value
|
| 774 |
+
);
|
| 775 |
+
|
| 776 |
+
if (i === 0) {
|
| 777 |
+
// Start playing first chunk immediately
|
| 778 |
+
await playAudioData(audioData);
|
| 779 |
+
} else {
|
| 780 |
+
// Queue other chunks
|
| 781 |
+
audioQueue.push(audioData);
|
| 782 |
+
}
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
} catch (error) {
|
| 786 |
+
console.error('Error during playback:', error);
|
| 787 |
+
showToast('Failed to play text: ' + error.message, 'error');
|
| 788 |
+
stopPlayback();
|
| 789 |
+
}
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
function pausePlayback() {
|
| 793 |
+
if (audioSource && isPlaying && !isPaused) {
|
| 794 |
+
audioSource.stop();
|
| 795 |
+
pauseTime = audioContext.currentTime - startTime;
|
| 796 |
+
isPaused = true;
|
| 797 |
+
updatePlaybackButtons();
|
| 798 |
+
}
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
function resumePlayback() {
|
| 802 |
+
if (isPaused && audioBuffer) {
|
| 803 |
+
// Create new source for resume
|
| 804 |
+
audioSource = audioContext.createBufferSource();
|
| 805 |
+
audioSource.buffer = audioBuffer;
|
| 806 |
+
audioSource.connect(audioContext.destination);
|
| 807 |
+
|
| 808 |
+
// Resume from pause point
|
| 809 |
+
audioSource.start(0, pauseTime);
|
| 810 |
+
startTime = audioContext.currentTime - pauseTime;
|
| 811 |
+
isPaused = false;
|
| 812 |
+
updatePlaybackButtons();
|
| 813 |
+
|
| 814 |
+
audioSource.onended = () => {
|
| 815 |
+
if (isPlaying && !isPaused) {
|
| 816 |
+
stopPlayback();
|
| 817 |
+
}
|
| 818 |
+
};
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
function stopPlayback() {
|
| 823 |
+
if (audioSource && isPlaying) {
|
| 824 |
+
audioSource.stop();
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
isPlaying = false;
|
| 828 |
+
isPaused = false;
|
| 829 |
+
startTime = 0;
|
| 830 |
+
pauseTime = 0;
|
| 831 |
+
currentChunkIndex = 0;
|
| 832 |
+
audioQueue = [];
|
| 833 |
+
updatePlaybackButtons();
|
| 834 |
+
updateProgress(0);
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
function updatePlaybackButtons() {
|
| 838 |
+
playBtn.disabled = isPlaying && !isPaused;
|
| 839 |
+
pauseBtn.disabled = !isPlaying || isPaused;
|
| 840 |
+
stopBtn.disabled = !isPlaying;
|
| 841 |
+
|
| 842 |
+
if (isPlaying && !isPaused) {
|
| 843 |
+
playBtn.innerHTML = '<i class="fas fa-pause"></i>';
|
| 844 |
+
} else {
|
| 845 |
+
playBtn.innerHTML = '<i class="fas fa-play"></i>';
|
| 846 |
+
}
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
function updateProgress(progress) {
|
| 850 |
+
progressBar.style.width = (progress * 100) + '%';
|
| 851 |
+
|
| 852 |
+
const currentSeconds = Math.floor(progress * estimatedDuration);
|
| 853 |
+
const totalSeconds = Math.floor(estimatedDuration);
|
| 854 |
+
|
| 855 |
+
currentTime.textContent = formatTime(currentSeconds);
|
| 856 |
+
totalTime.textContent = formatTime(totalSeconds);
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
function formatTime(seconds) {
|
| 860 |
+
const minutes = Math.floor(seconds / 60);
|
| 861 |
+
const secs = seconds % 60;
|
| 862 |
+
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
function splitTextIntoChunks(text, maxChunkSize) {
|
| 866 |
+
const chunks = [];
|
| 867 |
+
const sentences = text.split(/[.!?]+/).filter(s => s.trim());
|
| 868 |
+
|
| 869 |
+
let currentChunk = '';
|
| 870 |
+
|
| 871 |
+
for (const sentence of sentences) {
|
| 872 |
+
if (currentChunk.length + sentence.length <= maxChunkSize) {
|
| 873 |
+
currentChunk += sentence + '.';
|
| 874 |
+
} else {
|
| 875 |
+
if (currentChunk) {
|
| 876 |
+
chunks.push(currentChunk.trim());
|
| 877 |
+
}
|
| 878 |
+
currentChunk = sentence + '.';
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
if (currentChunk) {
|
| 883 |
+
chunks.push(currentChunk.trim());
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
return chunks;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
// Event listeners
|
| 890 |
+
refreshVoicesBtn.addEventListener('click', loadVoices);
|
| 891 |
+
languageSelect.addEventListener('change', loadVoices);
|
| 892 |
+
|
| 893 |
+
// Initialize the application
|
| 894 |
+
init();
|
| 895 |
+
});
|
| 896 |
+
</script>
|
| 897 |
+
</body>
|
| 898 |
</html>
|