Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Advanced Google TTS Document Reader (BYOK)</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| :root { | |
| --primary-blue: #3B82F6; | |
| --bg-gray: #F9FAFB; | |
| --text-primary: #1F2937; | |
| --border-color: #E5E7EB; | |
| --card-bg: #FFFFFF; | |
| --overlay-bg: rgba(0, 0, 0, 0.5); | |
| } | |
| /* Dark mode variables */ | |
| [data-theme="dark"] { | |
| --bg-gray: #111827; | |
| --text-primary: #F9FAFB; | |
| --border-color: #374151; | |
| --card-bg: #1F2937; | |
| --overlay-bg: rgba(0, 0, 0, 0.7); | |
| } | |
| body { | |
| background-color: var(--bg-gray); | |
| color: var(--text-primary); | |
| transition: background-color 0.3s, color 0.3s; | |
| } | |
| .bg-white { | |
| background-color: var(--card-bg) ; | |
| } | |
| .border-gray-200 { | |
| border-color: var(--border-color) ; | |
| } | |
| .text-gray-800 { | |
| color: var(--text-primary) ; | |
| } | |
| /* Loading overlay */ | |
| .loading-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: var(--overlay-bg); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 9999; | |
| backdrop-filter: blur(4px); | |
| } | |
| .loading-overlay-content { | |
| background-color: var(--card-bg); | |
| padding: 24px; | |
| border-radius: 12px; | |
| text-align: center; | |
| box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); | |
| border: 1px solid var(--border-color); | |
| } | |
| .dropzone { | |
| border: 2px dashed #9CA3AF; | |
| transition: all 0.3s ease; | |
| } | |
| .dropzone.active { | |
| border-color: #3B82F6; | |
| background-color: #EFF6FF; | |
| } | |
| [data-theme="dark"] .dropzone.active { | |
| background-color: rgba(59, 130, 246, 0.1); | |
| } | |
| .document-content { | |
| max-height: 60vh; | |
| overflow-y: auto; | |
| } | |
| .highlight { | |
| background-color: #FEF08A; | |
| transition: background-color 0.2s; | |
| border-radius: 3px; | |
| padding: 0 2px; | |
| } | |
| [data-theme="dark"] .highlight { | |
| background-color: rgba(254, 240, 138, 0.3); | |
| } | |
| .voice-card { | |
| transition: all 0.2s ease; | |
| border: 1px solid var(--border-color); | |
| background-color: var(--card-bg); | |
| } | |
| .voice-card:hover { | |
| border-color: #3B82F6; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| } | |
| .voice-card.selected { | |
| border-color: #3B82F6; | |
| background-color: #EFF6FF; | |
| } | |
| [data-theme="dark"] .voice-card.selected { | |
| background-color: rgba(59, 130, 246, 0.1); | |
| } | |
| .loading-spinner { | |
| border: 3px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top: 3px solid #3B82F6; | |
| width: 20px; | |
| height: 20px; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .toast { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| padding: 12px 24px; | |
| border-radius: 8px; | |
| color: white; | |
| z-index: 1000; | |
| animation: slideIn 0.3s ease; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| } | |
| .toast.success { background-color: #10B981; } | |
| .toast.error { background-color: #EF4444; } | |
| .toast.info { background-color: #3B82F6; } | |
| @keyframes slideIn { | |
| from { transform: translateX(100%); opacity: 0; } | |
| to { transform: translateX(0); opacity: 1; } | |
| } | |
| @keyframes slideOut { | |
| from { transform: translateX(0); opacity: 1; } | |
| to { transform: translateX(100%); opacity: 0; } | |
| } | |
| /* Progress indicator styles */ | |
| #progressContainer { | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #currentPositionMarker { | |
| box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); | |
| } | |
| .reading-highlight { | |
| background-color: rgba(34, 197, 94, 0.2); | |
| border-radius: 3px; | |
| padding: 0 2px; | |
| transition: all 0.2s; | |
| } | |
| /* Bookmark styles */ | |
| .bookmark { | |
| position: relative; | |
| border-left: 4px solid #F59E0B; | |
| background-color: rgba(245, 158, 11, 0.1); | |
| padding-left: 8px; | |
| } | |
| [data-theme="dark"] .bookmark { | |
| background-color: rgba(245, 158, 11, 0.05); | |
| } | |
| .bookmark-icon { | |
| position: absolute; | |
| left: -8px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| color: #F59E0B; | |
| font-size: 12px; | |
| } | |
| /* Synthesis progress bar */ | |
| .synthesis-progress { | |
| width: 100%; | |
| height: 6px; | |
| background-color: var(--border-color); | |
| border-radius: 3px; | |
| overflow: hidden; | |
| } | |
| .synthesis-progress-bar { | |
| height: 100%; | |
| background-color: #3B82F6; | |
| border-radius: 3px; | |
| transition: width 0.3s ease; | |
| } | |
| /* Chunk progress indicators */ | |
| .chunk-progress { | |
| display: flex; | |
| gap: 2px; | |
| margin-top: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .chunk-indicator { | |
| height: 6px; | |
| min-width: 12px; | |
| flex: 1; | |
| background-color: var(--border-color); | |
| border-radius: 3px; | |
| transition: all 0.3s ease; | |
| } | |
| .chunk-indicator.synthesizing { | |
| background-color: #F59E0B; | |
| animation: pulse 1s infinite; | |
| } | |
| .chunk-indicator.completed { | |
| background-color: #10B981; | |
| } | |
| .chunk-indicator.playing { | |
| background-color: #3B82F6; | |
| box-shadow: 0 0 4px rgba(59, 130, 246, 0.5); | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| /* Library styles */ | |
| .library-item { | |
| border: 1px solid var(--border-color); | |
| background-color: var(--card-bg); | |
| border-radius: 8px; | |
| padding: 16px; | |
| transition: all 0.2s; | |
| } | |
| .library-item:hover { | |
| border-color: #3B82F6; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| .library-item.favorited { | |
| border-color: #F59E0B; | |
| background-color: rgba(245, 158, 11, 0.05); | |
| } | |
| /* Modal styles */ | |
| .modal { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: var(--overlay-bg); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| backdrop-filter: blur(4px); | |
| } | |
| .modal-content { | |
| background-color: var(--card-bg); | |
| border-radius: 12px; | |
| padding: 24px; | |
| max-width: 90vw; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); | |
| border: 1px solid var(--border-color); | |
| } | |
| /* Enhanced button states */ | |
| .btn-primary { | |
| background-color: #3B82F6; | |
| color: white; | |
| border: none; | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| transition: all 0.2s; | |
| font-weight: 500; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| background-color: #2563EB; | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| .btn-primary:disabled { | |
| background-color: #9CA3AF; | |
| cursor: not-allowed; | |
| transform: none; | |
| box-shadow: none; | |
| } | |
| /* Status indicator */ | |
| .status-indicator { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 20px; | |
| background-color: var(--card-bg); | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| padding: 12px 16px; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| transition: all 0.3s; | |
| } | |
| .status-indicator.hidden { | |
| transform: translateY(100px); | |
| opacity: 0; | |
| } | |
| /* Notes textarea */ | |
| .notes-textarea { | |
| width: 100%; | |
| min-height: 100px; | |
| padding: 12px; | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| background-color: var(--card-bg); | |
| color: var(--text-primary); | |
| resize: vertical; | |
| } | |
| .notes-textarea:focus { | |
| outline: none; | |
| ring: 2px solid #3B82F6; | |
| border-color: #3B82F6; | |
| } | |
| /* Tags styling */ | |
| .tag { | |
| display: inline-block; | |
| background-color: #3B82F6; | |
| color: white; | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| margin: 2px; | |
| } | |
| .tag.custom { | |
| background-color: #10B981; | |
| } | |
| .tag-input { | |
| display: inline-block; | |
| min-width: 100px; | |
| border: 1px dashed var(--border-color); | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| background: transparent; | |
| } | |
| /* Search and filter */ | |
| .filter-bar { | |
| background-color: var(--card-bg); | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| padding: 16px; | |
| margin-bottom: 16px; | |
| } | |
| /* Accessibility improvements */ | |
| .sr-only { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: nowrap; | |
| border: 0; | |
| } | |
| /* Focus styles */ | |
| button:focus, | |
| input:focus, | |
| select:focus, | |
| textarea:focus { | |
| outline: 2px solid #3B82F6; | |
| outline-offset: 2px; | |
| } | |
| /* Voice status indicators */ | |
| .voice-status { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| padding: 2px 8px; | |
| border-radius: 12px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| } | |
| .voice-status.available { | |
| background-color: #DCFCE7; | |
| color: #166534; | |
| } | |
| .voice-status.loading { | |
| background-color: #FEF3C7; | |
| color: #92400E; | |
| } | |
| .voice-status.error { | |
| background-color: #FEE2E2; | |
| color: #991B1B; | |
| } | |
| [data-theme="dark"] .voice-status.available { | |
| background-color: rgba(22, 101, 52, 0.2); | |
| color: #86EFAC; | |
| } | |
| [data-theme="dark"] .voice-status.loading { | |
| background-color: rgba(146, 64, 14, 0.2); | |
| color: #FCD34D; | |
| } | |
| [data-theme="dark"] .voice-status.error { | |
| background-color: rgba(153, 27, 27, 0.2); | |
| color: #FECACA; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="text-center mb-8 relative"> | |
| <h1 class="text-4xl font-bold text-blue-600 mb-2">Advanced Google TTS Document Reader</h1> | |
| <p class="text-gray-600">Natural HD voices with streaming synthesis and smart library</p> | |
| <!-- Dark Mode Toggle --> | |
| <button id="darkModeToggle" class="absolute top-0 right-0 p-2 text-gray-600 hover:text-gray-800 transition" | |
| title="Toggle Dark Mode" aria-label="Toggle dark mode"> | |
| <i class="fas fa-moon text-xl" aria-hidden="true"></i> | |
| </button> | |
| </header> | |
| <div class="max-w-4xl mx-auto bg-white rounded-xl shadow-md overflow-hidden"> | |
| <!-- API Key Section --> | |
| <div class="bg-blue-50 p-6 border-b border-blue-100"> | |
| <div class="flex flex-col md:flex-row items-center gap-4"> | |
| <div class="flex-1"> | |
| <label for="apiKey" class="block text-sm font-medium text-blue-800 mb-1">Google Cloud API Key</label> | |
| <div class="relative"> | |
| <input type="password" id="apiKey" placeholder="Enter your Google Cloud API key" | |
| aria-describedby="apiKeyHelp" | |
| class="api-key-input w-full px-4 py-2 pr-10 border border-blue-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <button type="button" id="toggleKeyVisibility" | |
| class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600" | |
| aria-label="Toggle API key visibility"> | |
| <i class="fas fa-eye" id="eyeIcon" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <button id="saveKeyBtn" class="btn-primary"> | |
| <i class="fas fa-save mr-2" aria-hidden="true"></i> Save Key | |
| </button> | |
| </div> | |
| <div class="mt-3 text-xs text-blue-600"> | |
| <p class="flex items-center mb-1" id="apiKeyHelp"> | |
| <i class="fas fa-info-circle mr-1" aria-hidden="true"></i> Your API key is stored locally and never sent to our servers. | |
| </p> | |
| <details class="mt-2"> | |
| <summary class="cursor-pointer hover:text-blue-800">Need help getting an API key? (Click to expand)</summary> | |
| <div class="mt-2 text-xs text-gray-600 bg-white p-3 rounded border"> | |
| <p class="font-medium">How to get your Google Cloud API key:</p> | |
| <ol class="mt-1 list-decimal list-inside space-y-1"> | |
| <li>Go to <a href="https://console.cloud.google.com" target="_blank" class="text-blue-600 hover:underline">Google Cloud Console</a></li> | |
| <li>Create a new project or select existing one</li> | |
| <li>Enable the "Cloud Text-to-Speech API"</li> | |
| <li>Go to "Credentials" → "Create Credentials" → "API Key"</li> | |
| <li>Copy the generated API key and paste it above</li> | |
| <li>Optionally, restrict the key to Text-to-Speech API for security</li> | |
| </ol> | |
| <div class="mt-3 p-2 bg-yellow-50 border border-yellow-200 rounded text-yellow-800"> | |
| <p class="font-medium">⚠️ Important:</p> | |
| <ul class="mt-1 list-disc list-inside space-y-1"> | |
| <li>Make sure billing is enabled for your project</li> | |
| <li>Copy ONLY the API key (no extra text)</li> | |
| <li>If restricted, allow this domain: ${window.location.hostname}</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </details> | |
| </div> | |
| </div> | |
| <!-- Input Section --> | |
| <div class="p-6"> | |
| <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> | |
| <button id="uploadBtn" class="btn-primary flex items-center justify-center" aria-label="Upload document file"> | |
| <i class="fas fa-upload mr-2" aria-hidden="true"></i> Upload File | |
| </button> | |
| <button id="pasteBtn" class="btn-primary flex items-center justify-center" aria-label="Paste text from clipboard"> | |
| <i class="fas fa-paste mr-2" aria-hidden="true"></i> Paste Text | |
| </button> | |
| <button id="clearBtn" class="bg-red-500 hover:bg-red-600 text-white py-3 px-4 rounded-lg flex items-center justify-center transition" | |
| aria-label="Clear current document"> | |
| <i class="fas fa-trash mr-2" aria-hidden="true"></i> Clear | |
| </button> | |
| <div id="dropzone" class="dropzone bg-gray-50 py-3 px-4 rounded-lg flex items-center justify-center cursor-pointer" | |
| role="button" tabindex="0" aria-label="Drag and drop file area"> | |
| <div class="text-center"> | |
| <i class="fas fa-file-import text-2xl text-gray-400 mb-1" aria-hidden="true"></i> | |
| <p class="text-gray-500">Drag & Drop File</p> | |
| <p class="text-xs text-gray-400 mt-1">Supports: TXT, PDF, DOC/DOCX, RTF, MD, JSON, HTML, XML, CSV</p> | |
| </div> | |
| </div> | |
| </div> | |
| <input type="file" id="fileInput" class="hidden" accept=".txt,.pdf,.doc,.docx,.odt,.rtf,.md,.markdown,.json,.html,.htm,.xml,.csv,.epub"> | |
| </div> | |
| <!-- Document Display --> | |
| <div class="border-t border-gray-200 p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-xl font-semibold text-gray-800">Document Content</h2> | |
| <div class="flex items-center gap-4"> | |
| <button id="bookmarkBtn" class="text-blue-500 hover:text-blue-700 text-sm flex items-center" | |
| title="Toggle bookmark for current paragraph" aria-label="Toggle bookmark"> | |
| <i class="fas fa-bookmark mr-1" aria-hidden="true"></i> Bookmark | |
| </button> | |
| <div class="text-sm text-gray-500" id="charCount" aria-live="polite">0 characters</div> | |
| </div> | |
| </div> | |
| <div id="documentContent" class="document-content bg-gray-50 p-4 rounded-lg border border-gray-200 min-h-32" | |
| role="textbox" aria-label="Document content" aria-multiline="true" tabindex="0"> | |
| <p class="text-gray-500 italic">Your document content will appear here...</p> | |
| </div> | |
| </div> | |
| <!-- Voice Selection --> | |
| <div class="border-t border-gray-200 p-6 bg-gray-50"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-xl font-semibold text-gray-800">Voice Configuration</h2> | |
| <div class="flex items-center gap-4"> | |
| <div class="voice-status loading" id="voiceStatus" role="status" aria-live="polite"> | |
| <i class="fas fa-spinner fa-spin" aria-hidden="true"></i> | |
| <span>Loading...</span> | |
| </div> | |
| <div class="relative"> | |
| <label for="languageSelect" class="sr-only">Select language</label> | |
| <select id="languageSelect" class="bg-white border border-gray-300 rounded-md py-1 px-3 pr-8 text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"> | |
| <option value="en-US">English (US)</option> | |
| <option value="en-GB">English (UK)</option> | |
| <option value="en-AU">English (Australia)</option> | |
| </select> | |
| <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"> | |
| <i class="fas fa-chevron-down text-xs" aria-hidden="true"></i> | |
| </div> | |
| </div> | |
| <button id="refreshVoicesBtn" class="text-blue-500 hover:text-blue-700 text-sm flex items-center" | |
| aria-label="Refresh available voices"> | |
| <i class="fas fa-sync-alt mr-1" aria-hidden="true"></i> Refresh | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Voice Dropdown --> | |
| <div class="mb-4"> | |
| <label for="voiceSelect" class="block text-sm font-medium text-gray-700 mb-1">Voice Selection</label> | |
| <select id="voiceSelect" class="w-full bg-white border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500" | |
| aria-describedby="voiceHelp"> | |
| <option value="">Select a voice...</option> | |
| </select> | |
| <p id="voiceHelp" class="text-xs text-gray-500 mt-1">Standard voices ($4/million chars) are selected by default</p> | |
| </div> | |
| <!-- Cost Estimator --> | |
| <div id="costEstimator" class="mb-4 p-3 bg-blue-100 border border-blue-200 rounded-lg hidden" role="region" aria-label="Cost estimation"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-calculator text-blue-600 mr-2" aria-hidden="true"></i> | |
| <span class="text-sm font-medium text-blue-800">Estimated Cost:</span> | |
| </div> | |
| <div class="text-right"> | |
| <div class="text-lg font-bold text-blue-900" id="estimatedCost" aria-live="polite">$0.00</div> | |
| <div class="text-xs text-blue-600" id="costDetails">0 characters</div> | |
| </div> | |
| </div> | |
| <div class="mt-2 text-xs text-blue-600 flex justify-between items-center"> | |
| <div> | |
| <span id="voiceType">Standard voice</span> • | |
| <span id="priceRate">$4.00/million chars</span> | |
| </div> | |
| <button id="showCostBreakdown" class="text-blue-500 hover:text-blue-700 underline" | |
| aria-label="Show detailed pricing information"> | |
| <i class="fas fa-info-circle mr-1" aria-hidden="true"></i> | |
| Pricing Info | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Voice Controls --> | |
| <div class="bg-gray-100 p-6"> | |
| <div class="controls flex flex-wrap justify-between items-center gap-4 mb-4"> | |
| <div class="voice-options flex flex-wrap items-center gap-4"> | |
| <div> | |
| <label for="rateSelect" class="block text-sm font-medium text-gray-700 mb-1">Speed</label> | |
| <select id="rateSelect" class="bg-white border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"> | |
| <option value="0.5">0.5x</option> | |
| <option value="0.8">0.8x</option> | |
| <option value="1" selected>1x</option> | |
| <option value="1.2">1.2x</option> | |
| <option value="1.5">1.5x</option> | |
| <option value="2">2x</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="pitchSelect" class="block text-sm font-medium text-gray-700 mb-1">Pitch</label> | |
| <select id="pitchSelect" class="bg-white border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"> | |
| <option value="-20">Low</option> | |
| <option value="0" selected>Normal</option> | |
| <option value="20">High</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="modelSelect" class="block text-sm font-medium text-gray-700 mb-1">Voice Model</label> | |
| <select id="modelSelect" class="bg-white border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500"> | |
| <option value="standard" selected>Standard - $4.00/1M chars (Default, best value)</option> | |
| <option value="wavenet">WaveNet - $16.00/1M chars (High quality)</option> | |
| <option value="neural2">Neural2 - $16.00/1M chars (Enhanced Neural)</option> | |
| <option value="studio">Studio Quality - $160.00/1M chars (Premium voices)</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="control-buttons flex items-center gap-2"> | |
| <button id="synthesizeBtn" class="bg-green-600 hover:bg-green-700 text-white rounded-full w-12 h-12 flex items-center justify-center transition" | |
| title="Generate speech (starts playback when first chunk ready)" aria-label="Generate speech"> | |
| <i class="fas fa-magic" aria-hidden="true"></i> | |
| </button> | |
| <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" | |
| title="Play/Pause" aria-label="Play or pause audio"> | |
| <i class="fas fa-play" aria-hidden="true"></i> | |
| </button> | |
| <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 title="Stop playback" aria-label="Stop audio playback"> | |
| <i class="fas fa-stop" aria-hidden="true"></i> | |
| </button> | |
| <button id="downloadBtn" class="bg-purple-600 hover:bg-purple-700 text-white rounded-full w-12 h-12 flex items-center justify-center transition" | |
| disabled title="Download Audio" aria-label="Download generated audio"> | |
| <i class="fas fa-download" aria-hidden="true"></i> | |
| </button> | |
| <button id="libraryBtn" class="bg-yellow-600 hover:bg-yellow-700 text-white rounded-full w-12 h-12 flex items-center justify-center transition" | |
| title="Audio Library" aria-label="Open audio library"> | |
| <i class="fas fa-music" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Chunk Progress --> | |
| <div id="chunkProgress" class="mb-4 hidden"> | |
| <div class="flex justify-between text-sm text-gray-600 mb-2"> | |
| <span>Synthesis Progress</span> | |
| <span id="chunkStatus">Starting...</span> | |
| </div> | |
| <div class="chunk-progress" id="chunkIndicators"></div> | |
| </div> | |
| <!-- Playback Progress --> | |
| <div class="progress-container"> | |
| <div class="flex justify-between text-sm text-gray-600 mb-1"> | |
| <span id="currentTime" aria-live="polite">0:00</span> | |
| <span id="totalTime">0:00</span> | |
| </div> | |
| <div id="progressContainer" class="w-full bg-gray-200 rounded-full h-4 flex relative cursor-pointer" | |
| role="progressbar" aria-label="Audio playback progress"> | |
| <!-- Progress bar for audio --> | |
| <div id="progressBar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0%"></div> | |
| <!-- Reading progress indicator --> | |
| <div id="readingProgress" class="absolute inset-0 bg-green-500 bg-opacity-30 h-4 rounded-full transition-all duration-300" style="width: 0%"></div> | |
| <!-- Current position marker --> | |
| <div id="currentPositionMarker" class="absolute top-0 w-1 h-4 bg-red-500 transition-all duration-100" style="left: 0%"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Status Indicator --> | |
| <div id="statusIndicator" class="status-indicator hidden" role="status" aria-live="polite"> | |
| <div class="flex items-center"> | |
| <i id="statusIcon" class="fas fa-info-circle mr-2" aria-hidden="true"></i> | |
| <span id="statusText">Ready</span> | |
| </div> | |
| </div> | |
| <!-- Loading Overlay --> | |
| <div id="loadingOverlay" class="loading-overlay hidden" role="dialog" aria-modal="true" aria-label="Loading"> | |
| <div class="loading-overlay-content"> | |
| <div class="loading-spinner mx-auto mb-4" style="width: 40px; height: 40px;"></div> | |
| <h3 class="text-lg font-semibold mb-2" id="loadingTitle">Loading...</h3> | |
| <p class="text-gray-600" id="loadingMessage">Please wait</p> | |
| </div> | |
| </div> | |
| <!-- Enhanced Audio Library Modal with Notes and Filing --> | |
| <div id="libraryModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="libraryModalTitle"> | |
| <div class="modal-content max-w-6xl"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 id="libraryModalTitle" class="text-2xl font-semibold">Audio Library</h2> | |
| <button id="closeLibraryModal" class="text-gray-400 hover:text-gray-600" aria-label="Close library"> | |
| <i class="fas fa-times text-xl" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| <!-- Library Controls --> | |
| <div class="mb-4 flex flex-col md:flex-row gap-4 justify-between items-start"> | |
| <div class="flex items-center gap-2"> | |
| <button id="saveToLibraryBtn" class="btn-primary" disabled aria-label="Save current audio to library"> | |
| <i class="fas fa-save mr-2" aria-hidden="true"></i> Save Current Audio | |
| </button> | |
| <button id="clearLibraryBtn" class="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-lg transition" | |
| aria-label="Clear all library items"> | |
| <i class="fas fa-trash mr-2" aria-hidden="true"></i> Clear Library | |
| </button> | |
| </div> | |
| <div class="text-sm text-gray-500" id="libraryCount" aria-live="polite">0 items</div> | |
| </div> | |
| <!-- Search and Filter Bar --> | |
| <div class="filter-bar"> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| <div> | |
| <label for="librarySearch" class="block text-sm font-medium text-gray-700 mb-1">Search</label> | |
| <input type="text" id="librarySearch" placeholder="Search titles, notes, or text..." | |
| class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label for="voiceFilter" class="block text-sm font-medium text-gray-700 mb-1">Voice Filter</label> | |
| <select id="voiceFilter" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <option value="">All Voices</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="tagFilter" class="block text-sm font-medium text-gray-700 mb-1">Tag Filter</label> | |
| <select id="tagFilter" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <option value="">All Tags</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="mt-3 flex gap-2 flex-wrap"> | |
| <button id="favoritesOnly" class="text-sm bg-yellow-100 hover:bg-yellow-200 px-3 py-1 rounded-md transition"> | |
| <i class="fas fa-star mr-1" aria-hidden="true"></i> Favorites Only | |
| </button> | |
| <button id="sortByDate" class="text-sm bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded-md transition"> | |
| <i class="fas fa-calendar mr-1" aria-hidden="true"></i> Sort by Date | |
| </button> | |
| <button id="sortByDuration" class="text-sm bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded-md transition"> | |
| <i class="fas fa-clock mr-1" aria-hidden="true"></i> Sort by Duration | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Library Content Grid --> | |
| <div id="libraryContent" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-96 overflow-y-auto"> | |
| <div class="text-center py-8 text-gray-500 col-span-full"> | |
| <i class="fas fa-music text-4xl mb-4" aria-hidden="true"></i> | |
| <p>No audio files saved yet</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Notes Modal --> | |
| <div id="notesModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="notesModalTitle"> | |
| <div class="modal-content max-w-2xl"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 id="notesModalTitle" class="text-xl font-semibold">Add Notes & Tags</h2> | |
| <button id="closeNotesModal" class="text-gray-400 hover:text-gray-600" aria-label="Close notes"> | |
| <i class="fas fa-times text-xl" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="itemTitle" class="block text-sm font-medium text-gray-700 mb-1">Title</label> | |
| <input type="text" id="itemTitle" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label for="itemNotes" class="block text-sm font-medium text-gray-700 mb-1">Notes</label> | |
| <textarea id="itemNotes" placeholder="Add notes about this audio file..." | |
| class="notes-textarea"></textarea> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Tags</label> | |
| <div id="tagContainer" class="border border-gray-300 rounded-md p-3 min-h-12"> | |
| <div id="currentTags" class="mb-2"></div> | |
| <input type="text" id="tagInput" placeholder="Add tag..." | |
| class="tag-input w-32 outline-none"> | |
| </div> | |
| <div class="mt-2 text-xs text-gray-500"> | |
| <span class="font-medium">Suggested tags:</span> | |
| <button class="tag-suggestion ml-1" data-tag="important">important</button> | |
| <button class="tag-suggestion" data-tag="draft">draft</button> | |
| <button class="tag-suggestion" data-tag="final">final</button> | |
| <button class="tag-suggestion" data-tag="presentation">presentation</button> | |
| <button class="tag-suggestion" data-tag="personal">personal</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-6 flex justify-end gap-3"> | |
| <button id="cancelNotes" class="bg-gray-300 hover:bg-gray-400 text-gray-700 py-2 px-4 rounded-lg transition"> | |
| Cancel | |
| </button> | |
| <button id="saveNotes" class="btn-primary"> | |
| <i class="fas fa-save mr-2" aria-hidden="true"></i> Save to Library | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Required libraries for document processing --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.21/mammoth.browser.min.js"></script> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Global state variables | |
| let apiKey = localStorage.getItem('googleTTSApiKey') || ''; | |
| let currentText = ''; | |
| let selectedVoice = null; | |
| let availableVoices = []; | |
| let isPlaying = false; | |
| let isPaused = false; | |
| let isSynthesizing = false; | |
| let audioContext = null; | |
| let audioSource = null; | |
| let audioBuffer = null; | |
| let startTime = 0; | |
| let pauseTime = 0; | |
| let currentAudioBlob = null; | |
| let currentReadingPosition = 0; | |
| let estimatedDuration = 0; | |
| let audioLibrary = JSON.parse(localStorage.getItem('audioLibrary') || '[]'); | |
| let bookmarks = JSON.parse(localStorage.getItem('documentBookmarks') || '[]'); | |
| // Chunk processing system | |
| let chunkProcessor = null; | |
| let currentChunks = []; | |
| let completedChunks = []; | |
| let playbackQueue = []; | |
| let isStreamingPlayback = false; | |
| let currentChunkIndex = 0; | |
| const MAX_CHUNK_SIZE = 5000; | |
| const PRICING = { | |
| standard: 4.00, | |
| wavenet: 16.00, | |
| neural2: 16.00, | |
| studio: 160.00 | |
| }; | |
| // Global error handlers | |
| window.addEventListener('error', (e) => { | |
| console.error('Global error caught:', e.error); | |
| console.error('Error stack:', e.error?.stack); | |
| showToast('An unexpected error occurred. Please try again.', 'error'); | |
| hideLoadingOverlay(); | |
| }); | |
| window.addEventListener('unhandledrejection', (e) => { | |
| console.error('Unhandled promise rejection:', e.reason); | |
| showToast('Promise rejection: ' + (e.reason?.message || e.reason), 'error'); | |
| hideLoadingOverlay(); | |
| e.preventDefault(); | |
| }); | |
| // DOM Elements | |
| const apiKeyInput = document.getElementById('apiKey'); | |
| const saveKeyBtn = document.getElementById('saveKeyBtn'); | |
| const uploadBtn = document.getElementById('uploadBtn'); | |
| const pasteBtn = document.getElementById('pasteBtn'); | |
| const clearBtn = document.getElementById('clearBtn'); | |
| const dropzone = document.getElementById('dropzone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const documentContent = document.getElementById('documentContent'); | |
| const charCount = document.getElementById('charCount'); | |
| const bookmarkBtn = document.getElementById('bookmarkBtn'); | |
| const voiceSelect = document.getElementById('voiceSelect'); | |
| const refreshVoicesBtn = document.getElementById('refreshVoicesBtn'); | |
| const languageSelect = document.getElementById('languageSelect'); | |
| const rateSelect = document.getElementById('rateSelect'); | |
| const pitchSelect = document.getElementById('pitchSelect'); | |
| const modelSelect = document.getElementById('modelSelect'); | |
| const synthesizeBtn = document.getElementById('synthesizeBtn'); | |
| const playBtn = document.getElementById('playBtn'); | |
| const stopBtn = document.getElementById('stopBtn'); | |
| const downloadBtn = document.getElementById('downloadBtn'); | |
| const libraryBtn = document.getElementById('libraryBtn'); | |
| const currentTime = document.getElementById('currentTime'); | |
| const totalTime = document.getElementById('totalTime'); | |
| const progressContainer = document.getElementById('progressContainer'); | |
| const progressBar = document.getElementById('progressBar'); | |
| const readingProgress = document.getElementById('readingProgress'); | |
| const currentPositionMarker = document.getElementById('currentPositionMarker'); | |
| const voiceStatus = document.getElementById('voiceStatus'); | |
| const statusIndicator = document.getElementById('statusIndicator'); | |
| const statusIcon = document.getElementById('statusIcon'); | |
| const statusText = document.getElementById('statusText'); | |
| const loadingOverlay = document.getElementById('loadingOverlay'); | |
| const loadingTitle = document.getElementById('loadingTitle'); | |
| const loadingMessage = document.getElementById('loadingMessage'); | |
| const chunkProgress = document.getElementById('chunkProgress'); | |
| const chunkStatus = document.getElementById('chunkStatus'); | |
| const chunkIndicators = document.getElementById('chunkIndicators'); | |
| // Library modal elements | |
| const libraryModal = document.getElementById('libraryModal'); | |
| const closeLibraryModal = document.getElementById('closeLibraryModal'); | |
| const saveToLibraryBtn = document.getElementById('saveToLibraryBtn'); | |
| const clearLibraryBtn = document.getElementById('clearLibraryBtn'); | |
| const libraryContent = document.getElementById('libraryContent'); | |
| const libraryCount = document.getElementById('libraryCount'); | |
| const librarySearch = document.getElementById('librarySearch'); | |
| const voiceFilter = document.getElementById('voiceFilter'); | |
| const tagFilter = document.getElementById('tagFilter'); | |
| const favoritesOnly = document.getElementById('favoritesOnly'); | |
| const sortByDate = document.getElementById('sortByDate'); | |
| const sortByDuration = document.getElementById('sortByDuration'); | |
| // Notes modal elements | |
| const notesModal = document.getElementById('notesModal'); | |
| const closeNotesModal = document.getElementById('closeNotesModal'); | |
| const itemTitle = document.getElementById('itemTitle'); | |
| const itemNotes = document.getElementById('itemNotes'); | |
| const tagContainer = document.getElementById('tagContainer'); | |
| const currentTags = document.getElementById('currentTags'); | |
| const tagInput = document.getElementById('tagInput'); | |
| const cancelNotes = document.getElementById('cancelNotes'); | |
| const saveNotes = document.getElementById('saveNotes'); | |
| // Dark mode elements | |
| const darkModeToggle = document.getElementById('darkModeToggle'); | |
| // Chunk Processor Class | |
| class ChunkProcessor { | |
| constructor() { | |
| this.chunks = []; | |
| this.completedAudio = []; | |
| this.synthesisQueue = []; | |
| this.isProcessing = false; | |
| this.onChunkComplete = null; | |
| this.onAllComplete = null; | |
| this.onProgress = null; | |
| } | |
| async processText(text, voice, settings) { | |
| this.chunks = this.splitTextIntoChunks(text, MAX_CHUNK_SIZE); | |
| this.completedAudio = new Array(this.chunks.length).fill(null); | |
| this.synthesisQueue = this.chunks.map((chunk, index) => ({ chunk, index })); | |
| this.isProcessing = true; | |
| // Update chunk indicators | |
| this.updateChunkIndicators(); | |
| // Process chunks in parallel (limited concurrency) | |
| const concurrentLimit = 3; | |
| const promises = []; | |
| for (let i = 0; i < Math.min(concurrentLimit, this.synthesisQueue.length); i++) { | |
| promises.push(this.processNextChunk(voice, settings)); | |
| } | |
| await Promise.all(promises); | |
| this.isProcessing = false; | |
| if (this.onAllComplete) { | |
| this.onAllComplete(this.completedAudio); | |
| } | |
| } | |
| async processNextChunk(voice, settings) { | |
| while (this.synthesisQueue.length > 0 && this.isProcessing) { | |
| const { chunk, index } = this.synthesisQueue.shift(); | |
| try { | |
| // Update chunk indicator to synthesizing | |
| const indicator = document.querySelectorAll('.chunk-indicator')[index]; | |
| if (indicator) { | |
| indicator.classList.add('synthesizing'); | |
| } | |
| const audioData = await synthesizeSpeech(chunk, voice, settings.rate, settings.pitch, settings.model); | |
| this.completedAudio[index] = { | |
| audioData, | |
| chunk, | |
| index, | |
| timestamp: Date.now() | |
| }; | |
| // Update chunk indicator to completed | |
| if (indicator) { | |
| indicator.classList.remove('synthesizing'); | |
| indicator.classList.add('completed'); | |
| } | |
| // Notify about chunk completion | |
| if (this.onChunkComplete) { | |
| this.onChunkComplete(this.completedAudio[index], index); | |
| } | |
| // Update progress | |
| if (this.onProgress) { | |
| const completed = this.completedAudio.filter(chunk => chunk !== null).length; | |
| this.onProgress(completed, this.chunks.length); | |
| } | |
| } catch (error) { | |
| console.error(`Error processing chunk ${index}:`, error); | |
| showToast(`Chunk ${index + 1} failed: ${error.message}`, 'error'); | |
| // Mark chunk as error | |
| const indicator = document.querySelectorAll('.chunk-indicator')[index]; | |
| if (indicator) { | |
| indicator.classList.remove('synthesizing'); | |
| indicator.style.backgroundColor = '#EF4444'; | |
| } | |
| } | |
| } | |
| } | |
| splitTextIntoChunks(text, maxChunkSize) { | |
| const sentences = text.split(/(?<=[.!?])\s+/); | |
| const chunks = []; | |
| let currentChunk = ''; | |
| for (const sentence of sentences) { | |
| if (currentChunk.length + sentence.length <= maxChunkSize) { | |
| currentChunk += (currentChunk ? ' ' : '') + sentence; | |
| } else { | |
| if (currentChunk) { | |
| chunks.push(currentChunk.trim()); | |
| } | |
| // Handle very long sentences | |
| if (sentence.length > maxChunkSize) { | |
| const words = sentence.split(' '); | |
| let longChunk = ''; | |
| for (const word of words) { | |
| if (longChunk.length + word.length + 1 <= maxChunkSize) { | |
| longChunk += (longChunk ? ' ' : '') + word; | |
| } else { | |
| if (longChunk) chunks.push(longChunk.trim()); | |
| longChunk = word; | |
| } | |
| } | |
| currentChunk = longChunk; | |
| } else { | |
| currentChunk = sentence; | |
| } | |
| } | |
| } | |
| if (currentChunk) { | |
| chunks.push(currentChunk.trim()); | |
| } | |
| return chunks; | |
| } | |
| updateChunkIndicators() { | |
| chunkIndicators.innerHTML = ''; | |
| this.chunks.forEach((_, index) => { | |
| const indicator = document.createElement('div'); | |
| indicator.className = 'chunk-indicator'; | |
| indicator.title = `Chunk ${index + 1}`; | |
| chunkIndicators.appendChild(indicator); | |
| }); | |
| } | |
| stop() { | |
| this.isProcessing = false; | |
| this.synthesisQueue = []; | |
| } | |
| } | |
| // Streaming Audio Player Class | |
| class StreamingAudioPlayer { | |
| constructor() { | |
| this.audioChunks = []; | |
| this.currentChunkIndex = 0; | |
| this.isPlaying = false; | |
| this.isPaused = false; | |
| this.totalDuration = 0; | |
| } | |
| addChunk(audioData, index) { | |
| this.audioChunks[index] = audioData; | |
| // Start playing if this is the first chunk and we're ready | |
| if (index === 0 && !this.isPlaying && isStreamingPlayback) { | |
| this.startPlayback(); | |
| } | |
| } | |
| async startPlayback() { | |
| if (this.audioChunks[0]) { | |
| this.isPlaying = true; | |
| this.currentChunkIndex = 0; | |
| await this.playChunk(0); | |
| } | |
| } | |
| async playChunk(index) { | |
| if (!this.audioChunks[index] || !this.isPlaying) return; | |
| try { | |
| const audioData = this.audioChunks[index].audioData; | |
| const arrayBuffer = Uint8Array.from(atob(audioData), c => c.charCodeAt(0)).buffer; | |
| audioBuffer = await audioContext.decodeAudioData(arrayBuffer); | |
| audioSource = audioContext.createBufferSource(); | |
| audioSource.buffer = audioBuffer; | |
| audioSource.connect(audioContext.destination); | |
| // Update chunk indicator to playing | |
| const indicators = document.querySelectorAll('.chunk-indicator'); | |
| indicators.forEach(ind => ind.classList.remove('playing')); | |
| if (indicators[index]) { | |
| indicators[index].classList.add('playing'); | |
| } | |
| audioSource.start(0); | |
| startTime = audioContext.currentTime; | |
| startProgressTracking(); | |
| audioSource.onended = () => { | |
| if (this.isPlaying && index < this.audioChunks.length - 1) { | |
| this.currentChunkIndex++; | |
| this.playChunk(this.currentChunkIndex); | |
| } else { | |
| this.stopPlayback(); | |
| } | |
| }; | |
| } catch (error) { | |
| console.error('Error playing chunk:', error); | |
| showToast(`Error playing chunk ${index + 1}`, 'error'); | |
| } | |
| } | |
| pause() { | |
| if (audioSource && this.isPlaying && !this.isPaused) { | |
| pauseTime = audioContext.currentTime; | |
| audioSource.stop(); | |
| this.isPaused = true; | |
| } | |
| } | |
| resume() { | |
| if (this.isPaused) { | |
| this.isPaused = false; | |
| this.playChunk(this.currentChunkIndex); | |
| } | |
| } | |
| stopPlayback() { | |
| this.isPlaying = false; | |
| this.isPaused = false; | |
| this.currentChunkIndex = 0; | |
| if (audioSource) { | |
| audioSource.stop(); | |
| } | |
| // Clear playing indicators | |
| document.querySelectorAll('.chunk-indicator').forEach(ind => { | |
| ind.classList.remove('playing'); | |
| }); | |
| stopPlayback(); | |
| } | |
| } | |
| // Initialize streaming player | |
| const streamingPlayer = new StreamingAudioPlayer(); | |
| // Utility function for file size formatting | |
| function formatFileSize(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| } | |
| // Loading overlay functions | |
| function showLoadingOverlay(title = 'Loading...', message = 'Please wait') { | |
| loadingTitle.textContent = title; | |
| loadingMessage.textContent = message; | |
| loadingOverlay.classList.remove('hidden'); | |
| loadingOverlay.setAttribute('aria-busy', 'true'); | |
| } | |
| function hideLoadingOverlay() { | |
| loadingOverlay.classList.add('hidden'); | |
| loadingOverlay.setAttribute('aria-busy', 'false'); | |
| } | |
| // Status indicator functions | |
| function showStatus(text, icon = 'fa-info-circle', type = 'info') { | |
| statusText.textContent = text; | |
| statusIcon.className = `fas ${icon} mr-2`; | |
| statusIndicator.classList.remove('hidden'); | |
| // Auto-hide after 5 seconds for non-persistent status | |
| if (type !== 'persistent') { | |
| setTimeout(() => { | |
| statusIndicator.classList.add('hidden'); | |
| }, 5000); | |
| } | |
| } | |
| function hideStatus() { | |
| statusIndicator.classList.add('hidden'); | |
| } | |
| // Initialize function | |
| function init() { | |
| // Load saved API key | |
| if (apiKey) { | |
| apiKeyInput.value = apiKey; | |
| loadVoices(); | |
| } | |
| // Set PDF.js worker path | |
| if (typeof pdfjsLib !== 'undefined') { | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js'; | |
| } | |
| // Initialize AudioContext on user interaction | |
| document.addEventListener('click', initAudioContext, { once: true }); | |
| // API Key visibility toggle | |
| const toggleKeyVisibility = document.getElementById('toggleKeyVisibility'); | |
| const eyeIcon = document.getElementById('eyeIcon'); | |
| toggleKeyVisibility.addEventListener('click', () => { | |
| if (apiKeyInput.type === 'password') { | |
| apiKeyInput.type = 'text'; | |
| eyeIcon.className = 'fas fa-eye-slash'; | |
| } else { | |
| apiKeyInput.type = 'password'; | |
| eyeIcon.className = 'fas fa-eye'; | |
| } | |
| }); | |
| // Dark mode initialization | |
| initDarkMode(); | |
| // Initialize progress indicator | |
| initProgressIndicator(); | |
| // Initialize library | |
| updateLibraryView(); | |
| // Initialize library filtering | |
| initLibraryFiltering(); | |
| // Initialize notes system | |
| initNotesSystem(); | |
| // Event listeners | |
| modelSelect.addEventListener('change', (e) => { | |
| updateCostEstimator(); | |
| autoSelectVoiceForModel(e.target.value); | |
| }); | |
| rateSelect.addEventListener('change', updateCostEstimator); | |
| pitchSelect.addEventListener('change', updateCostEstimator); | |
| voiceSelect.addEventListener('change', onVoiceSelectionChange); | |
| // Keyboard navigation for dropzone | |
| dropzone.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| fileInput.click(); | |
| } | |
| }); | |
| // Initialize cost breakdown | |
| document.getElementById('showCostBreakdown').addEventListener('click', showCostBreakdownModal); | |
| // Set default status | |
| updateVoiceStatus('loading', 'Loading voices...'); | |
| showStatus('Ready to load documents', 'fa-file-alt'); | |
| } | |
| // Dark Mode Implementation | |
| function initDarkMode() { | |
| const savedTheme = localStorage.getItem('theme'); | |
| const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| if (savedTheme) { | |
| document.documentElement.setAttribute('data-theme', savedTheme); | |
| updateDarkModeIcon(savedTheme === 'dark'); | |
| } else if (systemPrefersDark) { | |
| document.documentElement.setAttribute('data-theme', 'dark'); | |
| updateDarkModeIcon(true); | |
| localStorage.setItem('theme', 'dark'); | |
| } else { | |
| document.documentElement.setAttribute('data-theme', 'light'); | |
| updateDarkModeIcon(false); | |
| localStorage.setItem('theme', 'light'); | |
| } | |
| darkModeToggle.addEventListener('click', toggleDarkMode); | |
| } | |
| function toggleDarkMode() { | |
| const currentTheme = document.documentElement.getAttribute('data-theme'); | |
| const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; | |
| document.documentElement.setAttribute('data-theme', newTheme); | |
| localStorage.setItem('theme', newTheme); | |
| updateDarkModeIcon(newTheme === 'dark'); | |
| showToast(`Switched to ${newTheme} mode`, 'success', 1500); | |
| } | |
| function updateDarkModeIcon(isDark) { | |
| const icon = darkModeToggle.querySelector('i'); | |
| icon.className = isDark ? 'fas fa-sun text-xl' : 'fas fa-moon text-xl'; | |
| darkModeToggle.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode'); | |
| } | |
| // Progress indicator initialization | |
| function initProgressIndicator() { | |
| progressContainer.addEventListener('click', (e) => { | |
| if (!audioSource || !audioBuffer) return; | |
| const rect = progressContainer.getBoundingClientRect(); | |
| const clickX = e.clientX - rect.left; | |
| const percentage = clickX / rect.width; | |
| const newTime = percentage * audioBuffer.duration; | |
| seekToTime(newTime); | |
| }); | |
| } | |
| function seekToTime(time) { | |
| if (!audioSource || !audioBuffer) return; | |
| audioSource.stop(); | |
| audioSource = audioContext.createBufferSource(); | |
| audioSource.buffer = audioBuffer; | |
| audioSource.connect(audioContext.destination); | |
| startTime = audioContext.currentTime - time; | |
| audioSource.start(0, time); | |
| audioSource.onended = () => { | |
| if (isPlaying && !isPaused) { | |
| stopPlayback(); | |
| } | |
| }; | |
| showToast(`Seeked to ${formatTime(Math.floor(time))}`, 'info', 1000); | |
| } | |
| // Voice status management | |
| function updateVoiceStatus(status, message) { | |
| const statusEl = voiceStatus; | |
| const iconEl = statusEl.querySelector('i'); | |
| const textEl = statusEl.querySelector('span'); | |
| statusEl.className = `voice-status ${status}`; | |
| textEl.textContent = message; | |
| switch (status) { | |
| case 'available': | |
| iconEl.className = 'fas fa-check'; | |
| break; | |
| case 'loading': | |
| iconEl.className = 'fas fa-spinner fa-spin'; | |
| break; | |
| case 'error': | |
| iconEl.className = 'fas fa-exclamation-triangle'; | |
| break; | |
| } | |
| } | |
| // Voice selection handling | |
| function onVoiceSelectionChange() { | |
| const selectedVoiceName = voiceSelect.value; | |
| if (selectedVoiceName && availableVoices.length > 0) { | |
| selectedVoice = availableVoices.find(voice => voice.name === selectedVoiceName); | |
| if (selectedVoice) { | |
| updateCostEstimator(); | |
| showToast(`Voice changed to ${selectedVoice.name}`, 'success', 1500); | |
| } | |
| } else { | |
| selectedVoice = null; | |
| } | |
| } | |
| // Auto-select voice based on model | |
| function autoSelectVoiceForModel(model) { | |
| if (!availableVoices.length) return; | |
| let targetVoices = []; | |
| switch (model) { | |
| case 'standard': | |
| targetVoices = availableVoices.filter(voice => | |
| !voice.name.includes('Wavenet') && | |
| !voice.name.includes('Neural2') && | |
| !voice.name.includes('Studio') | |
| ); | |
| break; | |
| case 'wavenet': | |
| targetVoices = availableVoices.filter(voice => voice.name.includes('Wavenet')); | |
| break; | |
| case 'neural2': | |
| targetVoices = availableVoices.filter(voice => voice.name.includes('Neural2')); | |
| break; | |
| case 'studio': | |
| targetVoices = availableVoices.filter(voice => voice.name.includes('Studio')); | |
| break; | |
| } | |
| if (targetVoices.length > 0) { | |
| selectedVoice = targetVoices[0]; | |
| voiceSelect.value = selectedVoice.name; | |
| updateCostEstimator(); | |
| } | |
| } | |
| // Voice type and pricing functions | |
| function getVoiceTypeAndPricing(voiceName) { | |
| const name = voiceName.toLowerCase(); | |
| if (name.includes('studio')) { | |
| return { type: 'studio', name: 'Studio', rate: PRICING.studio }; | |
| } else if (name.includes('wavenet')) { | |
| return { type: 'wavenet', name: 'WaveNet', rate: PRICING.wavenet }; | |
| } else if (name.includes('neural2')) { | |
| return { type: 'neural2', name: 'Neural2', rate: PRICING.neural2 }; | |
| } else { | |
| return { type: 'standard', name: 'Standard', rate: PRICING.standard }; | |
| } | |
| } | |
| function calculateEstimatedCost(textLength, voiceType) { | |
| if (!textLength || !voiceType) return { cost: 0, details: '' }; | |
| const pricePerMillion = voiceType.rate; | |
| const cost = (textLength / 1000000) * pricePerMillion; | |
| return { | |
| cost: cost, | |
| details: `${textLength.toLocaleString()} characters` | |
| }; | |
| } | |
| function updateCostEstimator() { | |
| const costEstimator = document.getElementById('costEstimator'); | |
| const estimatedCost = document.getElementById('estimatedCost'); | |
| const costDetails = document.getElementById('costDetails'); | |
| const voiceTypeEl = document.getElementById('voiceType'); | |
| const priceRateEl = document.getElementById('priceRate'); | |
| if (!selectedVoice || !currentText.trim()) { | |
| costEstimator.classList.add('hidden'); | |
| return; | |
| } | |
| const voiceType = getVoiceTypeAndPricing(selectedVoice.name); | |
| const estimation = calculateEstimatedCost(currentText.length, voiceType); | |
| costEstimator.classList.remove('hidden'); | |
| estimatedCost.textContent = `$${estimation.cost.toFixed(4)}`; | |
| costDetails.textContent = estimation.details; | |
| voiceTypeEl.textContent = `${voiceType.name} voice`; | |
| priceRateEl.textContent = `$${voiceType.rate.toFixed(2)}/million chars`; | |
| // Color code based on cost | |
| if (estimation.cost < 0.10) { | |
| costEstimator.className = 'mb-4 p-3 bg-green-100 border border-green-200 rounded-lg'; | |
| estimatedCost.className = 'text-lg font-bold text-green-900'; | |
| } else if (estimation.cost < 1.00) { | |
| costEstimator.className = 'mb-4 p-3 bg-blue-100 border border-blue-200 rounded-lg'; | |
| estimatedCost.className = 'text-lg font-bold text-blue-900'; | |
| } else if (estimation.cost < 5.00) { | |
| costEstimator.className = 'mb-4 p-3 bg-yellow-100 border border-yellow-200 rounded-lg'; | |
| estimatedCost.className = 'text-lg font-bold text-yellow-900'; | |
| } else { | |
| costEstimator.className = 'mb-4 p-3 bg-red-100 border border-red-200 rounded-lg'; | |
| estimatedCost.className = 'text-lg font-bold text-red-900'; | |
| } | |
| } | |
| // Cost breakdown modal | |
| function showCostBreakdownModal() { | |
| const modal = document.createElement('div'); | |
| modal.className = 'modal'; | |
| modal.setAttribute('role', 'dialog'); | |
| modal.setAttribute('aria-modal', 'true'); | |
| modal.setAttribute('aria-labelledby', 'pricingModalTitle'); | |
| modal.innerHTML = ` | |
| <div class="modal-content max-w-lg"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <h3 id="pricingModalTitle" class="text-lg font-semibold text-gray-900">Google TTS Pricing</h3> | |
| <button class="text-gray-400 hover:text-gray-600" onclick="this.parentElement.parentElement.parentElement.remove()" aria-label="Close pricing information"> | |
| <i class="fas fa-times text-xl" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-3"> | |
| <div class="bg-green-50 p-3 rounded border border-green-200"> | |
| <div class="flex justify-between items-center"> | |
| <span class="font-medium text-green-800">Standard Voices (Default)</span> | |
| <span class="text-green-900 font-bold">$4.00</span> | |
| </div> | |
| <div class="text-sm text-green-600">per million characters</div> | |
| </div> | |
| <div class="bg-blue-50 p-3 rounded border border-blue-200"> | |
| <div class="flex justify-between items-center"> | |
| <span class="font-medium text-blue-800">WaveNet & Neural2</span> | |
| <span class="text-blue-900 font-bold">$16.00</span> | |
| </div> | |
| <div class="text-sm text-blue-600">per million characters</div> | |
| </div> | |
| <div class="bg-purple-50 p-3 rounded border border-purple-200"> | |
| <div class="flex justify-between items-center"> | |
| <span class="font-medium text-purple-800">Studio Quality</span> | |
| <span class="text-purple-900 font-bold">$160.00</span> | |
| </div> | |
| <div class="text-sm text-purple-600">per million characters</div> | |
| </div> | |
| </div> | |
| <div class="mt-4 p-3 bg-gray-50 rounded border"> | |
| <h4 class="font-medium text-gray-800 mb-2">Current Calculation:</h4> | |
| <div class="text-sm text-gray-600"> | |
| ${currentText ? ` | |
| <div>Document: ${currentText.length.toLocaleString()} characters</div> | |
| ${selectedVoice ? ` | |
| <div>Voice: ${getVoiceTypeAndPricing(selectedVoice.name).name}</div> | |
| <div>Rate: $${getVoiceTypeAndPricing(selectedVoice.name).rate.toFixed(2)}/million chars</div> | |
| <div class="font-semibold mt-1">Total: $${calculateEstimatedCost(currentText.length, getVoiceTypeAndPricing(selectedVoice.name)).cost.toFixed(4)}</div> | |
| ` : '<div>No voice selected</div>'} | |
| ` : '<div>No document loaded</div>'} | |
| </div> | |
| </div> | |
| <div class="mt-4 text-xs text-gray-500"> | |
| <p>* Prices are based on Google Cloud Text-to-Speech API pricing.</p> | |
| <p>* Standard voices are recommended for most use cases.</p> | |
| </div> | |
| <div class="mt-6 flex justify-end"> | |
| <button class="btn-primary" onclick="this.parentElement.parentElement.parentElement.remove()"> | |
| Close | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| modal.addEventListener('click', (e) => { | |
| if (e.target === modal) { | |
| modal.remove(); | |
| } | |
| }); | |
| // Focus trap | |
| const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); | |
| const firstElement = focusableElements[0]; | |
| const lastElement = focusableElements[focusableElements.length - 1]; | |
| firstElement.focus(); | |
| modal.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape') { | |
| modal.remove(); | |
| } else if (e.key === 'Tab') { | |
| if (e.shiftKey) { | |
| if (document.activeElement === firstElement) { | |
| e.preventDefault(); | |
| lastElement.focus(); | |
| } | |
| } else { | |
| if (document.activeElement === lastElement) { | |
| e.preventDefault(); | |
| firstElement.focus(); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| function initAudioContext() { | |
| try { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| showToast('Audio system initialized', 'success', 1500); | |
| } catch (e) { | |
| console.error('Web Audio API not supported', e); | |
| showToast('Your browser does not support the Web Audio API. Please use Chrome, Edge, or Firefox.', 'error'); | |
| } | |
| } | |
| // Enhanced toast notifications | |
| function showToast(message, type = 'success', duration = 3000) { | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.setAttribute('role', 'alert'); | |
| toast.setAttribute('aria-live', 'polite'); | |
| const icon = type === 'success' ? 'fa-check-circle' : | |
| type === 'error' ? 'fa-exclamation-circle' : | |
| 'fa-info-circle'; | |
| toast.innerHTML = ` | |
| <div class="flex items-center"> | |
| <i class="fas ${icon} mr-2" aria-hidden="true"></i> | |
| <span>${message}</span> | |
| </div> | |
| `; | |
| document.body.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.animation = 'slideOut 0.3s ease'; | |
| setTimeout(() => toast.remove(), 300); | |
| }, duration); | |
| } | |
| // API Key Management | |
| saveKeyBtn.addEventListener('click', async () => { | |
| const key = apiKeyInput.value ? apiKeyInput.value.trim() : ''; | |
| if (key) { | |
| saveKeyBtn.innerHTML = '<div class="loading-spinner inline-block w-4 h-4 mr-2"></div>Validating...'; | |
| saveKeyBtn.disabled = true; | |
| try { | |
| const cleanApiKey = key.trim(); | |
| if (!cleanApiKey || cleanApiKey.length < 10) { | |
| throw new Error('API key appears to be too short. Please verify you copied the complete key.'); | |
| } | |
| if (!cleanApiKey.match(/^[A-Za-z0-9_-]+$/)) { | |
| throw new Error('API key contains invalid characters. Please copy only the key without any extra text.'); | |
| } | |
| showLoadingOverlay('Validating API Key', 'Testing connection to Google Cloud...'); | |
| // Test API key validity | |
| const response = await fetch(`https://texttospeech.googleapis.com/v1/voices?languageCode=en-US`, { | |
| method: 'GET', | |
| headers: { | |
| 'X-Goog-Api-Key': cleanApiKey, | |
| 'Accept': 'application/json', | |
| }, | |
| mode: 'cors', | |
| cache: 'no-cache' | |
| }); | |
| if (response.ok) { | |
| apiKey = cleanApiKey; | |
| localStorage.setItem('googleTTSApiKey', cleanApiKey); | |
| showToast('API key validated and saved successfully', 'success'); | |
| loadVoices(); | |
| } else { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error(errorData.error?.message || 'API key validation failed. Please check the key and your Google Cloud settings.'); | |
| } | |
| } catch (error) { | |
| console.error('API key validation error:', error); | |
| showToast(`Validation error: ${error.message}`, 'error', 5000); | |
| updateVoiceStatus('error', 'API key validation failed'); | |
| } finally { | |
| saveKeyBtn.innerHTML = '<i class="fas fa-save mr-2"></i> Save Key'; | |
| saveKeyBtn.disabled = false; | |
| hideLoadingOverlay(); | |
| } | |
| } else { | |
| showToast('Please enter a valid API key', 'error'); | |
| } | |
| }); | |
| // Load available voices from Google TTS | |
| async function loadVoices() { | |
| if (!apiKey) { | |
| updateVoiceStatus('error', 'API key required'); | |
| voiceSelect.innerHTML = '<option value="">Enter API key first</option>'; | |
| return; | |
| } | |
| updateVoiceStatus('loading', 'Loading voices...'); | |
| voiceSelect.innerHTML = '<option value="">Loading...</option>'; | |
| try { | |
| const languageCode = languageSelect.value; | |
| showLoadingOverlay('Loading Voices', `Fetching ${languageCode} voices from Google Cloud...`); | |
| const response = await fetch(`https://texttospeech.googleapis.com/v1/voices?languageCode=${languageCode}`, { | |
| method: 'GET', | |
| headers: { | |
| 'X-Goog-Api-Key': apiKey.trim(), | |
| 'Accept': 'application/json', | |
| }, | |
| mode: 'cors', | |
| cache: 'no-cache' | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error(errorData.error?.message || `API error (${response.status}): Failed to load voices`); | |
| } | |
| const data = await response.json(); | |
| if (!data || !data.voices) { | |
| throw new Error('Invalid response from Google TTS API - no voices returned'); | |
| } | |
| // Store all voices and populate dropdown | |
| availableVoices = data.voices.sort((a, b) => a.name.localeCompare(b.name)); | |
| populateVoiceDropdown(availableVoices); | |
| populateVoiceFilter(availableVoices); | |
| // Auto-select standard voice by default | |
| autoSelectVoiceForModel('standard'); | |
| updateVoiceStatus('available', `${availableVoices.length} voices loaded`); | |
| showToast(`Loaded ${availableVoices.length} voices successfully`, 'success'); | |
| showStatus('Voices loaded successfully', 'fa-microphone'); | |
| } catch (error) { | |
| console.error('Error loading voices:', error); | |
| updateVoiceStatus('error', 'Failed to load voices'); | |
| voiceSelect.innerHTML = '<option value="">Error loading voices</option>'; | |
| showToast(`Failed to load voices: ${error.message}`, 'error', 5000); | |
| } finally { | |
| hideLoadingOverlay(); | |
| } | |
| } | |
| // Populate voice dropdown | |
| function populateVoiceDropdown(voices) { | |
| voiceSelect.innerHTML = '<option value="">Select a voice...</option>'; | |
| // Group voices by type for better organization | |
| const groupedVoices = { | |
| standard: [], | |
| wavenet: [], | |
| neural2: [], | |
| studio: [] | |
| }; | |
| voices.forEach(voice => { | |
| const type = getVoiceTypeAndPricing(voice.name).type; | |
| groupedVoices[type].push(voice); | |
| }); | |
| // Add standard voices first (default recommendation) | |
| if (groupedVoices.standard.length > 0) { | |
| const standardGroup = document.createElement('optgroup'); | |
| standardGroup.label = 'Standard Voices (Recommended - $4/1M chars)'; | |
| groupedVoices.standard.forEach(voice => { | |
| const option = document.createElement('option'); | |
| option.value = voice.name; | |
| option.textContent = `${voice.name} (${voice.ssmlGender})`; | |
| standardGroup.appendChild(option); | |
| }); | |
| voiceSelect.appendChild(standardGroup); | |
| } | |
| // Add other voice types | |
| const typeLabels = { | |
| wavenet: 'WaveNet ($16/1M chars)', | |
| neural2: 'Neural2 ($16/1M chars)', | |
| studio: 'Studio Quality ($160/1M chars)' | |
| }; | |
| Object.entries(typeLabels).forEach(([type, label]) => { | |
| if (groupedVoices[type].length > 0) { | |
| const group = document.createElement('optgroup'); | |
| group.label = label; | |
| groupedVoices[type].forEach(voice => { | |
| const option = document.createElement('option'); | |
| option.value = voice.name; | |
| option.textContent = `${voice.name} (${voice.ssmlGender})`; | |
| group.appendChild(option); | |
| }); | |
| voiceSelect.appendChild(group); | |
| } | |
| }); | |
| } | |
| // Populate voice filter for library | |
| function populateVoiceFilter(voices) { | |
| voiceFilter.innerHTML = '<option value="">All Voices</option>'; | |
| const uniqueVoices = [...new Set(voices.map(v => v.name))].sort(); | |
| uniqueVoices.forEach(voice => { | |
| const option = document.createElement('option'); | |
| option.value = voice; | |
| option.textContent = voice; | |
| voiceFilter.appendChild(option); | |
| }); | |
| } | |
| // Enhanced synthesize speech | |
| async function synthesizeSpeech(text, voice, rate = 1, pitch = 0, model = 'standard') { | |
| if (!apiKey) { | |
| throw new Error('API key not provided'); | |
| } | |
| if (!text || !text.trim()) { | |
| throw new Error('No text provided for synthesis'); | |
| } | |
| const cleanApiKey = apiKey.trim(); | |
| let voiceConfig = { | |
| languageCode: voice.languageCodes[0], | |
| name: voice.name, | |
| ssmlGender: voice.ssmlGender | |
| }; | |
| let audioConfig = { | |
| audioEncoding: 'MP3', | |
| speakingRate: Math.max(0.25, Math.min(4.0, rate)), | |
| pitch: Math.max(-20.0, Math.min(20.0, pitch)), | |
| }; | |
| if (model === 'studio' && voice.name.includes('Studio')) { | |
| audioConfig.effectsProfileId = ['telephony-class-application']; | |
| } | |
| const request = { | |
| input: { text: text.trim() }, | |
| voice: voiceConfig, | |
| audioConfig: audioConfig | |
| }; | |
| const voiceType = getVoiceTypeAndPricing(voice.name); | |
| const cost = (text.length / 1000000) * voiceType.rate; | |
| try { | |
| let response; | |
| try { | |
| response = await fetch(`https://texttospeech.googleapis.com/v1/text:synthesize`, { | |
| method: 'POST', | |
| headers: { | |
| 'X-Goog-Api-Key': cleanApiKey, | |
| 'Content-Type': 'application/json', | |
| 'Accept': 'application/json', | |
| }, | |
| mode: 'cors', | |
| cache: 'no-cache', | |
| body: JSON.stringify(request) | |
| }); | |
| } catch (headerError) { | |
| const url = `https://texttospeech.googleapis.com/v1/text:synthesize?key=${encodeURIComponent(cleanApiKey)}`; | |
| response = await fetch(url, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Accept': 'application/json', | |
| }, | |
| mode: 'cors', | |
| cache: 'no-cache', | |
| body: JSON.stringify(request) | |
| }); | |
| } | |
| if (!response.ok) { | |
| let errorMessage = 'Failed to synthesize speech'; | |
| let details = ''; | |
| try { | |
| const errorData = await response.json(); | |
| errorMessage = errorData.error?.message || errorMessage; | |
| details = errorData.error?.details ? ` (${JSON.stringify(errorData.error.details)})` : ''; | |
| } catch (e) { | |
| details = ` (HTTP ${response.status}: ${response.statusText})`; | |
| } | |
| if (response.status === 400) { | |
| throw new Error('Invalid request parameters. Please check your text and settings.' + details); | |
| } else if (response.status === 403) { | |
| throw new Error('Access denied. Please check your API key and quota.' + details); | |
| } else if (response.status === 429) { | |
| throw new Error('Rate limit exceeded. Please wait a moment and try again.' + details); | |
| } else if (response.status >= 500) { | |
| throw new Error('Google Cloud service error. Please try again later.' + details); | |
| } else { | |
| throw new Error(errorMessage + details); | |
| } | |
| } | |
| const data = await response.json(); | |
| if (!data || !data.audioContent) { | |
| throw new Error('Invalid response from Google TTS API - no audio content received'); | |
| } | |
| return data.audioContent; | |
| } catch (error) { | |
| console.error('Synthesis error:', error); | |
| throw error; | |
| } | |
| } | |
| // Start streaming synthesis and playback | |
| async function startStreamingSynthesis() { | |
| if (!selectedVoice) { | |
| showToast('Please select a voice first', 'error'); | |
| return; | |
| } | |
| if (!currentText.trim()) { | |
| showToast('Please add some text to synthesize', 'error'); | |
| return; | |
| } | |
| isSynthesizing = true; | |
| isStreamingPlayback = true; | |
| updatePlaybackButtons(); | |
| // Show chunk progress | |
| chunkProgress.classList.remove('hidden'); | |
| // Initialize chunk processor | |
| chunkProcessor = new ChunkProcessor(); | |
| // Set up callbacks | |
| chunkProcessor.onChunkComplete = (audioChunk, index) => { | |
| // Add to streaming player | |
| streamingPlayer.addChunk(audioChunk, index); | |
| // Update status | |
| const completed = chunkProcessor.completedAudio.filter(chunk => chunk !== null).length; | |
| chunkStatus.textContent = `${completed}/${chunkProcessor.chunks.length} chunks completed`; | |
| }; | |
| chunkProcessor.onProgress = (completed, total) => { | |
| chunkStatus.textContent = `${completed}/${total} chunks completed`; | |
| }; | |
| chunkProcessor.onAllComplete = (allAudio) => { | |
| isSynthesizing = false; | |
| updatePlaybackButtons(); | |
| showToast('All chunks synthesized successfully', 'success'); | |
| showStatus('Speech synthesis complete', 'fa-check'); | |
| // Prepare final audio blob for download | |
| prepareFinalAudioBlob(allAudio); | |
| }; | |
| // Start processing | |
| const settings = { | |
| rate: parseFloat(rateSelect.value), | |
| pitch: parseFloat(pitchSelect.value), | |
| model: modelSelect.value | |
| }; | |
| showStatus('Starting synthesis...', 'fa-magic', 'persistent'); | |
| try { | |
| await chunkProcessor.processText(currentText, selectedVoice, settings); | |
| } catch (error) { | |
| console.error('Error during streaming synthesis:', error); | |
| showToast('Synthesis failed: ' + error.message, 'error'); | |
| stopStreamingSynthesis(); | |
| } | |
| } | |
| function stopStreamingSynthesis() { | |
| if (chunkProcessor) { | |
| chunkProcessor.stop(); | |
| } | |
| streamingPlayer.stopPlayback(); | |
| isSynthesizing = false; | |
| isStreamingPlayback = false; | |
| updatePlaybackButtons(); | |
| chunkProgress.classList.add('hidden'); | |
| showStatus('Synthesis stopped', 'fa-stop'); | |
| } | |
| // Prepare final audio blob from all chunks | |
| async function prepareFinalAudioBlob(audioChunks) { | |
| try { | |
| // For now, use the first chunk as the primary audio | |
| // In a full implementation, you would concatenate all chunks | |
| if (audioChunks.length > 0 && audioChunks[0]) { | |
| const firstAudioData = audioChunks[0].audioData; | |
| const byteCharacters = atob(firstAudioData); | |
| const byteNumbers = new Array(byteCharacters.length); | |
| for (let i = 0; i < byteCharacters.length; i++) { | |
| byteNumbers[i] = byteCharacters.charCodeAt(i); | |
| } | |
| const byteArray = new Uint8Array(byteNumbers); | |
| currentAudioBlob = new Blob([byteArray], { type: 'audio/mpeg' }); | |
| downloadBtn.disabled = false; | |
| saveToLibraryBtn.disabled = false; | |
| } | |
| } catch (error) { | |
| console.error('Error preparing final audio:', error); | |
| showToast('Error preparing audio for download', 'error'); | |
| } | |
| } | |
| // Progress tracking | |
| function startProgressTracking() { | |
| function updateProgress() { | |
| if (!isPlaying || isPaused || !audioSource) return; | |
| const currentTime = audioContext.currentTime - startTime; | |
| const duration = audioBuffer.duration; | |
| const progress = currentTime / duration; | |
| progressBar.style.width = (progress * 100) + '%'; | |
| currentReadingPosition = Math.floor(progress * currentText.length); | |
| const readingProg = currentReadingPosition / currentText.length; | |
| readingProgress.style.width = (readingProg * 100) + '%'; | |
| currentPositionMarker.style.left = (progress * 100) + '%'; | |
| document.getElementById('currentTime').textContent = formatTime(Math.floor(currentTime)); | |
| document.getElementById('totalTime').textContent = formatTime(Math.floor(duration)); | |
| updateReadingHighlight(); | |
| requestAnimationFrame(updateProgress); | |
| } | |
| updateProgress(); | |
| } | |
| function updateReadingHighlight() { | |
| documentContent.querySelectorAll('.reading-highlight').forEach(el => { | |
| el.classList.remove('reading-highlight'); | |
| }); | |
| const paragraphs = Array.from(documentContent.children); | |
| let pos = 0; | |
| for (let i = 0; i < paragraphs.length; i++) { | |
| const p = paragraphs[i]; | |
| const pText = p.textContent; | |
| if (pos <= currentReadingPosition && currentReadingPosition < pos + pText.length) { | |
| p.classList.add('reading-highlight'); | |
| // Scroll to current paragraph if it's not visible | |
| p.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| break; | |
| } | |
| pos += pText.length; | |
| } | |
| } | |
| function formatTime(seconds) { | |
| const minutes = Math.floor(seconds / 60); | |
| const secs = seconds % 60; | |
| return `${minutes}:${secs.toString().padStart(2, '0')}`; | |
| } | |
| // File Handling | |
| uploadBtn.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| handleFile(e.target.files[0]); | |
| } | |
| }); | |
| pasteBtn.addEventListener('click', async () => { | |
| try { | |
| const text = await navigator.clipboard.readText(); | |
| if (text) { | |
| setDocumentContent(text); | |
| showToast('Text pasted successfully', 'success'); | |
| } else { | |
| throw new Error('Clipboard is empty'); | |
| } | |
| } catch (error) { | |
| // Fallback for older browsers | |
| documentContent.innerHTML = '<p class="text-gray-500 italic">Press Ctrl+V (Cmd+V on Mac) to paste your text...</p>'; | |
| documentContent.focus(); | |
| const pasteHandler = (e) => { | |
| e.preventDefault(); | |
| const text = (e.clipboardData || window.clipboardData).getData('text'); | |
| if (text) { | |
| setDocumentContent(text); | |
| showToast('Text pasted successfully', 'success'); | |
| document.removeEventListener('paste', pasteHandler); | |
| } | |
| }; | |
| document.addEventListener('paste', pasteHandler); | |
| showToast('Ready to paste - press Ctrl+V', 'info'); | |
| } | |
| }); | |
| clearBtn.addEventListener('click', () => { | |
| if (currentText.trim()) { | |
| if (confirm('Are you sure you want to clear the current document?')) { | |
| setDocumentContent(''); | |
| showToast('Document cleared', 'success'); | |
| showStatus('Document cleared', 'fa-file-alt'); | |
| } | |
| } else { | |
| showToast('Document is already empty', 'info'); | |
| } | |
| }); | |
| // Drag and Drop | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { | |
| dropzone.addEventListener(eventName, preventDefaults, false); | |
| }); | |
| function preventDefaults(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| ['dragenter', 'dragover'].forEach(eventName => { | |
| dropzone.addEventListener(eventName, highlight, false); | |
| }); | |
| ['dragleave', 'drop'].forEach(eventName => { | |
| dropzone.addEventListener(eventName, unhighlight, false); | |
| }); | |
| function highlight() { | |
| dropzone.classList.add('active'); | |
| } | |
| function unhighlight() { | |
| dropzone.classList.remove('active'); | |
| } | |
| dropzone.addEventListener('drop', (e) => { | |
| const dt = e.dataTransfer; | |
| const file = dt.files[0]; | |
| if (file) { | |
| handleFile(file); | |
| } | |
| }); | |
| // Enhanced file processing | |
| function handleFile(file) { | |
| const maxSize = 50 * 1024 * 1024; // 50MB | |
| if (file.size > maxSize) { | |
| showToast('File too large. Please upload a file smaller than 50MB.', 'error'); | |
| return; | |
| } | |
| const fileExtension = file.name.toLowerCase().split('.').pop(); | |
| showLoadingOverlay('Processing File', `Loading ${file.name}...`); | |
| showStatus(`Processing file: ${file.name}`, 'fa-file-import', 'persistent'); | |
| try { | |
| switch (fileExtension) { | |
| case 'txt': | |
| readTextFile(file); | |
| break; | |
| case 'pdf': | |
| readPDFFile(file); | |
| break; | |
| case 'doc': | |
| case 'docx': | |
| readWordDocument(file); | |
| break; | |
| case 'rtf': | |
| readRTFFile(file); | |
| break; | |
| case 'md': | |
| case 'markdown': | |
| readMarkdownFile(file); | |
| break; | |
| case 'json': | |
| readJSONFile(file); | |
| break; | |
| case 'html': | |
| case 'htm': | |
| readHTMLFile(file); | |
| break; | |
| case 'xml': | |
| readXMLFile(file); | |
| break; | |
| case 'csv': | |
| readCSVFile(file); | |
| break; | |
| default: | |
| if (file.type.startsWith('text/')) { | |
| readTextFile(file); | |
| } else { | |
| throw new Error(`Unsupported file type: .${fileExtension}. Please upload a supported document format.`); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error processing file:', error); | |
| showToast('Error processing file: ' + error.message, 'error'); | |
| hideLoadingOverlay(); | |
| showStatus('File processing failed', 'fa-exclamation-triangle'); | |
| } | |
| } | |
| // File readers (streamlined for space) | |
| function readTextFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| setDocumentContent(e.target.result); | |
| showToast(`Loaded ${formatFileSize(file.size)} text file`, 'success'); | |
| hideLoadingOverlay(); | |
| }; | |
| reader.onerror = () => { | |
| showToast('Error reading text file', 'error'); | |
| hideLoadingOverlay(); | |
| }; | |
| reader.readAsText(file, 'UTF-8'); | |
| } | |
| function readPDFFile(file) { | |
| if (typeof pdfjsLib === 'undefined') { | |
| showToast('PDF.js not loaded. Please refresh the page and try again.', 'error'); | |
| hideLoadingOverlay(); | |
| return; | |
| } | |
| const fileURL = URL.createObjectURL(file); | |
| const loadingTask = pdfjsLib.getDocument({ url: fileURL, verbosity: 0 }); | |
| loadingTask.promise.then(pdf => { | |
| const pagePromises = []; | |
| for (let i = 1; i <= pdf.numPages; i++) { | |
| pagePromises.push(pdf.getPage(i).then(page => | |
| page.getTextContent().then(textContent => | |
| textContent.items.map(item => item.str || '').join(' ') | |
| ) | |
| )); | |
| } | |
| Promise.all(pagePromises).then(pagesText => { | |
| setDocumentContent(pagesText.join('\n\n')); | |
| URL.revokeObjectURL(fileURL); | |
| showToast(`Loaded PDF with ${pdf.numPages} pages`, 'success'); | |
| hideLoadingOverlay(); | |
| }); | |
| }).catch(error => { | |
| console.error('Error loading PDF:', error); | |
| showToast('Failed to load PDF file: ' + error.message, 'error'); | |
| hideLoadingOverlay(); | |
| }); | |
| } | |
| function readWordDocument(file) { | |
| if (typeof mammoth === 'undefined') { | |
| showToast('Mammoth.js not loaded. Please refresh the page and try again.', 'error'); | |
| hideLoadingOverlay(); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = async (e) => { | |
| try { | |
| const result = await mammoth.extractRawText({ arrayBuffer: e.target.result }); | |
| if (result.value && result.value.trim()) { | |
| setDocumentContent(result.value); | |
| showToast(`Loaded Word document: ${file.name}`, 'success'); | |
| } else { | |
| showToast('No text content found in Word document', 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Error reading Word document:', error); | |
| showToast('Error reading Word document', 'error'); | |
| } finally { | |
| hideLoadingOverlay(); | |
| } | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| } | |
| // Additional file readers - simplified for space | |
| function readMarkdownFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| let text = e.target.result; | |
| // Basic Markdown to plain text conversion | |
| text = text.replace(/^#{1,6}\s+/gm, ''); | |
| text = text.replace(/\*\*(.*?)\*\*/g, '$1'); | |
| text = text.replace(/\*(.*?)\*/g, '$1'); | |
| text = text.replace(/`(.*?)`/g, '$1'); | |
| text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); | |
| setDocumentContent(text); | |
| hideLoadingOverlay(); | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function readCSVFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const lines = e.target.result.split('\n').filter(line => line.trim()); | |
| const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, '')); | |
| let text = `CSV Data Summary:\n\nHeaders: ${headers.join(', ')}\n\nTotal rows: ${lines.length - 1}\n\n`; | |
| if (lines.length > 1) { | |
| text += 'First few rows:\n'; | |
| for (let i = 1; i <= Math.min(5, lines.length - 1); i++) { | |
| const values = lines[i].split(',').map(v => v.trim().replace(/"/g, '')); | |
| text += `Row ${i}: ${values.join(', ')}\n`; | |
| } | |
| } | |
| setDocumentContent(text); | |
| hideLoadingOverlay(); | |
| }; | |
| reader.readAsText(file); | |
| } | |
| // Set document content with enhanced features | |
| function setDocumentContent(text) { | |
| currentText = text; | |
| if (!text.trim()) { | |
| documentContent.innerHTML = '<p class="text-gray-500 italic">Your document content will appear here...</p>'; | |
| charCount.textContent = '0 characters'; | |
| updateCostEstimator(); | |
| return; | |
| } | |
| // Split text into paragraphs and create enhanced elements | |
| const paragraphs = text.split('\n').filter(line => line.trim()); | |
| documentContent.innerHTML = ''; | |
| paragraphs.forEach((paragraph, index) => { | |
| const p = document.createElement('p'); | |
| p.textContent = paragraph; | |
| p.classList.add('mb-2', 'leading-relaxed', 'cursor-pointer', 'hover:bg-gray-50', 'transition', 'p-2', 'rounded'); | |
| p.dataset.paragraphIndex = index; | |
| // Add click handler for paragraph selection | |
| p.addEventListener('click', () => { | |
| selectParagraph(p, index); | |
| }); | |
| // Restore bookmark status | |
| if (bookmarks.includes(index)) { | |
| p.classList.add('bookmark'); | |
| const icon = document.createElement('i'); | |
| icon.className = 'fas fa-bookmark bookmark-icon'; | |
| icon.setAttribute('aria-hidden', 'true'); | |
| p.appendChild(icon); | |
| } | |
| documentContent.appendChild(p); | |
| }); | |
| charCount.textContent = `${text.length.toLocaleString()} characters`; | |
| if (text.length > 1000000) { | |
| showToast('Document exceeds 1,000,000 character limit. Some content may be truncated.', 'error'); | |
| } | |
| updateCostEstimator(); | |
| showStatus(`Document loaded - ${text.length.toLocaleString()} characters`, 'fa-file-alt'); | |
| } | |
| // Paragraph selection and bookmark functionality | |
| let selectedParagraph = null; | |
| function selectParagraph(paragraph, index) { | |
| // Remove previous selection | |
| if (selectedParagraph) { | |
| selectedParagraph.classList.remove('border-l-4', 'border-blue-500', 'bg-blue-50'); | |
| if (document.documentElement.getAttribute('data-theme') === 'dark') { | |
| selectedParagraph.classList.remove('bg-blue-900', 'bg-opacity-10'); | |
| } | |
| } | |
| // Add new selection | |
| selectedParagraph = paragraph; | |
| paragraph.classList.add('border-l-4', 'border-blue-500'); | |
| if (document.documentElement.getAttribute('data-theme') === 'dark') { | |
| paragraph.classList.add('bg-blue-900', 'bg-opacity-10'); | |
| } else { | |
| paragraph.classList.add('bg-blue-50'); | |
| } | |
| // Update bookmark button state | |
| bookmarkBtn.innerHTML = bookmarks.includes(index) ? | |
| '<i class="fas fa-bookmark-slash mr-1"></i> Remove Bookmark' : | |
| '<i class="fas fa-bookmark mr-1"></i> Add Bookmark'; | |
| showToast(`Paragraph ${index + 1} selected`, 'info', 1000); | |
| } | |
| // Bookmark management | |
| bookmarkBtn.addEventListener('click', () => { | |
| if (!selectedParagraph) { | |
| showToast('Please click on a paragraph to select it first', 'info'); | |
| return; | |
| } | |
| const index = parseInt(selectedParagraph.dataset.paragraphIndex); | |
| if (bookmarks.includes(index)) { | |
| // Remove bookmark | |
| bookmarks = bookmarks.filter(b => b !== index); | |
| selectedParagraph.classList.remove('bookmark'); | |
| const icon = selectedParagraph.querySelector('.bookmark-icon'); | |
| if (icon) icon.remove(); | |
| showToast('Bookmark removed', 'success'); | |
| } else { | |
| // Add bookmark | |
| bookmarks.push(index); | |
| selectedParagraph.classList.add('bookmark'); | |
| const icon = document.createElement('i'); | |
| icon.className = 'fas fa-bookmark bookmark-icon'; | |
| icon.setAttribute('aria-hidden', 'true'); | |
| selectedParagraph.appendChild(icon); | |
| showToast('Bookmark added', 'success'); | |
| } | |
| localStorage.setItem('documentBookmarks', JSON.stringify(bookmarks)); | |
| // Update button text | |
| bookmarkBtn.innerHTML = bookmarks.includes(index) ? | |
| '<i class="fas fa-bookmark-slash mr-1"></i> Remove Bookmark' : | |
| '<i class="fas fa-bookmark mr-1"></i> Add Bookmark'; | |
| }); | |
| // Playback controls | |
| synthesizeBtn.addEventListener('click', async () => { | |
| await startStreamingSynthesis(); | |
| }); | |
| playBtn.addEventListener('click', async () => { | |
| if (isPaused) { | |
| streamingPlayer.resume(); | |
| isPlaying = true; | |
| isPaused = false; | |
| } else if (isPlaying) { | |
| streamingPlayer.pause(); | |
| isPlaying = false; | |
| isPaused = true; | |
| } else { | |
| // Start new playback or resume streaming | |
| if (streamingPlayer.audioChunks.length > 0) { | |
| await streamingPlayer.startPlayback(); | |
| isPlaying = true; | |
| isPaused = false; | |
| } else if (!isSynthesizing) { | |
| await startStreamingSynthesis(); | |
| } | |
| } | |
| updatePlaybackButtons(); | |
| }); | |
| stopBtn.addEventListener('click', () => { | |
| stopStreamingSynthesis(); | |
| isPlaying = false; | |
| isPaused = false; | |
| updatePlaybackButtons(); | |
| }); | |
| downloadBtn.addEventListener('click', () => { | |
| downloadAudio(); | |
| }); | |
| // Enhanced playback functions | |
| function stopPlayback() { | |
| isPlaying = false; | |
| isPaused = false; | |
| startTime = 0; | |
| pauseTime = 0; | |
| currentReadingPosition = 0; | |
| updatePlaybackButtons(); | |
| // Clear reading highlight | |
| documentContent.querySelectorAll('.reading-highlight').forEach(el => { | |
| el.classList.remove('reading-highlight'); | |
| }); | |
| showStatus('Audio stopped', 'fa-stop'); | |
| } | |
| function updatePlaybackButtons() { | |
| // Synthesize button | |
| synthesizeBtn.disabled = isSynthesizing || !currentText.trim() || !selectedVoice; | |
| // Play/Pause button | |
| if (isPlaying && !isPaused) { | |
| playBtn.innerHTML = '<i class="fas fa-pause"></i>'; | |
| playBtn.setAttribute('aria-label', 'Pause audio'); | |
| playBtn.title = 'Pause'; | |
| } else { | |
| playBtn.innerHTML = '<i class="fas fa-play"></i>'; | |
| playBtn.setAttribute('aria-label', 'Play audio'); | |
| playBtn.title = 'Play'; | |
| } | |
| playBtn.disabled = !currentText.trim() || !selectedVoice; | |
| stopBtn.disabled = !isPlaying && !isSynthesizing; | |
| downloadBtn.disabled = !currentAudioBlob; | |
| saveToLibraryBtn.disabled = !currentAudioBlob; | |
| } | |
| // Download functionality | |
| function downloadAudio() { | |
| if (!currentAudioBlob) { | |
| showToast('No audio available to download', 'error'); | |
| return; | |
| } | |
| try { | |
| const url = URL.createObjectURL(currentAudioBlob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `tts-audio-${new Date().toISOString().split('T')[0]}-${Date.now()}.mp3`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| showToast('Audio downloaded successfully', 'success'); | |
| showStatus('Audio downloaded', 'fa-download'); | |
| } catch (error) { | |
| console.error('Download error:', error); | |
| showToast('Failed to download audio', 'error'); | |
| } | |
| } | |
| // Enhanced Audio Library Management with Notes and Filing | |
| libraryBtn.addEventListener('click', () => { | |
| openLibraryModal(); | |
| }); | |
| closeLibraryModal.addEventListener('click', () => { | |
| closeLibrary(); | |
| }); | |
| saveToLibraryBtn.addEventListener('click', () => { | |
| openNotesModal(); | |
| }); | |
| clearLibraryBtn.addEventListener('click', () => { | |
| if (confirm('Are you sure you want to clear the entire audio library?')) { | |
| clearAudioLibrary(); | |
| } | |
| }); | |
| function openLibraryModal() { | |
| libraryModal.classList.remove('hidden'); | |
| libraryModal.setAttribute('aria-hidden', 'false'); | |
| updateLibraryView(); | |
| populateTagFilter(); | |
| // Focus first element | |
| const firstFocusable = libraryModal.querySelector('button, input, select'); | |
| if (firstFocusable) firstFocusable.focus(); | |
| } | |
| function closeLibrary() { | |
| libraryModal.classList.add('hidden'); | |
| libraryModal.setAttribute('aria-hidden', 'true'); | |
| } | |
| // Initialize library filtering | |
| function initLibraryFiltering() { | |
| let filterTimeout; | |
| librarySearch.addEventListener('input', () => { | |
| clearTimeout(filterTimeout); | |
| filterTimeout = setTimeout(filterLibrary, 300); | |
| }); | |
| voiceFilter.addEventListener('change', filterLibrary); | |
| tagFilter.addEventListener('change', filterLibrary); | |
| favoritesOnly.addEventListener('click', (e) => { | |
| e.target.classList.toggle('bg-yellow-200'); | |
| filterLibrary(); | |
| }); | |
| sortByDate.addEventListener('click', () => { | |
| audioLibrary.sort((a, b) => new Date(b.date) - new Date(a.date)); | |
| updateLibraryView(); | |
| }); | |
| sortByDuration.addEventListener('click', () => { | |
| audioLibrary.sort((a, b) => (b.duration || 0) - (a.duration || 0)); | |
| updateLibraryView(); | |
| }); | |
| } | |
| function filterLibrary() { | |
| const searchTerm = librarySearch.value.toLowerCase(); | |
| const voiceFilter = document.getElementById('voiceFilter').value; | |
| const tagFilter = document.getElementById('tagFilter').value; | |
| const showFavoritesOnly = favoritesOnly.classList.contains('bg-yellow-200'); | |
| let filteredItems = audioLibrary.filter(item => { | |
| // Search filter | |
| const searchMatch = !searchTerm || | |
| item.title.toLowerCase().includes(searchTerm) || | |
| item.notes?.toLowerCase().includes(searchTerm) || | |
| item.text.toLowerCase().includes(searchTerm); | |
| // Voice filter | |
| const voiceMatch = !voiceFilter || item.voice === voiceFilter; | |
| // Tag filter | |
| const tagMatch = !tagFilter || (item.tags && item.tags.includes(tagFilter)); | |
| // Favorites filter | |
| const favoriteMatch = !showFavoritesOnly || item.isFavorite; | |
| return searchMatch && voiceMatch && tagMatch && favoriteMatch; | |
| }); | |
| renderLibraryItems(filteredItems); | |
| } | |
| function updateLibraryView() { | |
| libraryCount.textContent = `${audioLibrary.length} items`; | |
| renderLibraryItems(audioLibrary); | |
| } | |
| function renderLibraryItems(items) { | |
| if (items.length === 0) { | |
| libraryContent.innerHTML = ` | |
| <div class="text-center py-8 text-gray-500 col-span-full"> | |
| <i class="fas fa-music text-4xl mb-4" aria-hidden="true"></i> | |
| <p>No audio files found</p> | |
| <p class="text-sm mt-2">Generate and save audio to build your library</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| libraryContent.innerHTML = ''; | |
| items.forEach(item => { | |
| const itemEl = document.createElement('div'); | |
| itemEl.className = `library-item ${item.isFavorite ? 'favorited' : ''}`; | |
| const tagsHtml = item.tags ? item.tags.map(tag => | |
| `<span class="tag">${tag}</span>` | |
| ).join('') : ''; | |
| itemEl.innerHTML = ` | |
| <div class="flex justify-between items-start mb-3"> | |
| <h3 class="font-medium text-gray-800 text-sm leading-tight flex-1 mr-2">${item.title}</h3> | |
| <div class="flex gap-1"> | |
| <button class="toggle-favorite text-yellow-500 hover:text-yellow-700 p-1" data-id="${item.id}" | |
| title="${item.isFavorite ? 'Remove from favorites' : 'Add to favorites'}" | |
| aria-label="Toggle favorite"> | |
| <i class="fas fa-star${item.isFavorite ? '' : '-o'} text-xs" aria-hidden="true"></i> | |
| </button> | |
| <button class="load-library-item text-blue-500 hover:text-blue-700 p-1" data-id="${item.id}" | |
| title="Load this text" aria-label="Load text to editor"> | |
| <i class="fas fa-upload text-xs" aria-hidden="true"></i> | |
| </button> | |
| <button class="play-library-item text-green-500 hover:text-green-700 p-1" data-id="${item.id}" | |
| title="Play this audio" aria-label="Play audio"> | |
| <i class="fas fa-play text-xs" aria-hidden="true"></i> | |
| </button> | |
| <button class="edit-library-item text-purple-500 hover:text-purple-700 p-1" data-id="${item.id}" | |
| title="Edit notes and tags" aria-label="Edit item"> | |
| <i class="fas fa-edit text-xs" aria-hidden="true"></i> | |
| </button> | |
| <button class="delete-library-item text-red-500 hover:text-red-700 p-1" data-id="${item.id}" | |
| title="Delete this item" aria-label="Delete item"> | |
| <i class="fas fa-trash text-xs" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="text-xs text-gray-600 mb-2"> | |
| <div class="flex justify-between items-center"> | |
| <span><i class="fas fa-microphone mr-1" aria-hidden="true"></i> ${item.voice}</span> | |
| <span><i class="fas fa-clock mr-1" aria-hidden="true"></i> ${new Date(item.date).toLocaleDateString()}</span> | |
| </div> | |
| <div class="mt-1 flex items-center justify-between"> | |
| <span class="bg-gray-100 px-2 py-1 rounded text-xs"> | |
| ${item.settings.rate}x speed, ${item.settings.pitch > 0 ? '+' : ''}${item.settings.pitch} pitch | |
| </span> | |
| ${item.duration ? `<span class="text-xs">${formatTime(Math.floor(item.duration))}</span>` : ''} | |
| </div> | |
| </div> | |
| ${item.notes ? `<div class="text-xs text-gray-700 bg-gray-50 p-2 rounded mb-2">"${item.notes}"</div>` : ''} | |
| <div class="flex flex-wrap gap-1 mb-2"> | |
| ${tagsHtml} | |
| </div> | |
| <p class="text-xs text-gray-500 line-clamp-2">${item.text.substring(0, 100)}...</p> | |
| `; | |
| libraryContent.appendChild(itemEl); | |
| }); | |
| // Add event listeners to library items | |
| libraryContent.addEventListener('click', (e) => { | |
| const loadBtn = e.target.closest('.load-library-item'); | |
| const playBtn = e.target.closest('.play-library-item'); | |
| const editBtn = e.target.closest('.edit-library-item'); | |
| const deleteBtn = e.target.closest('.delete-library-item'); | |
| const favoriteBtn = e.target.closest('.toggle-favorite'); | |
| if (loadBtn) { | |
| const itemId = parseInt(loadBtn.dataset.id); | |
| loadLibraryItem(itemId); | |
| } else if (playBtn) { | |
| const itemId = parseInt(playBtn.dataset.id); | |
| playLibraryItem(itemId); | |
| } else if (editBtn) { | |
| const itemId = parseInt(editBtn.dataset.id); | |
| editLibraryItem(itemId); | |
| } else if (deleteBtn) { | |
| const itemId = parseInt(deleteBtn.dataset.id); | |
| deleteLibraryItem(itemId); | |
| } else if (favoriteBtn) { | |
| const itemId = parseInt(favoriteBtn.dataset.id); | |
| toggleFavorite(itemId); | |
| } | |
| }); | |
| } | |
| function populateTagFilter() { | |
| const allTags = [...new Set(audioLibrary.flatMap(item => item.tags || []))].sort(); | |
| tagFilter.innerHTML = '<option value="">All Tags</option>'; | |
| allTags.forEach(tag => { | |
| const option = document.createElement('option'); | |
| option.value = tag; | |
| option.textContent = tag; | |
| tagFilter.appendChild(option); | |
| }); | |
| } | |
| function loadLibraryItem(itemId) { | |
| const item = audioLibrary.find(i => i.id === itemId); | |
| if (!item) return; | |
| // Load text | |
| setDocumentContent(item.text); | |
| // Load settings | |
| rateSelect.value = item.settings.rate; | |
| pitchSelect.value = item.settings.pitch; | |
| modelSelect.value = item.settings.model; | |
| // Try to select the same voice | |
| if (availableVoices.some(voice => voice.name === item.voice)) { | |
| voiceSelect.value = item.voice; | |
| selectedVoice = availableVoices.find(voice => voice.name === item.voice); | |
| } | |
| updateCostEstimator(); | |
| closeLibrary(); | |
| showToast('Library item loaded', 'success'); | |
| showStatus(`Loaded: ${item.title}`, 'fa-file-import'); | |
| } | |
| function playLibraryItem(itemId) { | |
| // This would play the cached audio if available | |
| // For now, just show a message | |
| showToast('Audio playback from library not implemented in demo', 'info'); | |
| } | |
| function editLibraryItem(itemId) { | |
| const item = audioLibrary.find(i => i.id === itemId); | |
| if (!item) return; | |
| openNotesModal(item); | |
| } | |
| function deleteLibraryItem(itemId) { | |
| if (confirm('Are you sure you want to delete this audio file?')) { | |
| audioLibrary = audioLibrary.filter(item => item.id !== itemId); | |
| localStorage.setItem('audioLibrary', JSON.stringify(audioLibrary)); | |
| updateLibraryView(); | |
| showToast('Library item deleted', 'success'); | |
| } | |
| } | |
| function toggleFavorite(itemId) { | |
| const item = audioLibrary.find(i => i.id === itemId); | |
| if (item) { | |
| item.isFavorite = !item.isFavorite; | |
| localStorage.setItem('audioLibrary', JSON.stringify(audioLibrary)); | |
| updateLibraryView(); | |
| showToast(item.isFavorite ? 'Added to favorites' : 'Removed from favorites', 'success', 1500); | |
| } | |
| } | |
| function clearAudioLibrary() { | |
| audioLibrary = []; | |
| localStorage.setItem('audioLibrary', JSON.stringify([])); | |
| updateLibraryView(); | |
| showToast('Audio library cleared', 'success'); | |
| } | |
| // Notes system initialization | |
| function initNotesSystem() { | |
| closeNotesModal.addEventListener('click', closeNotes); | |
| cancelNotes.addEventListener('click', closeNotes); | |
| saveNotes.addEventListener('click', saveNotesToLibrary); | |
| // Tag input handling | |
| tagInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === 'Tab') { | |
| e.preventDefault(); | |
| addTag(tagInput.value.trim()); | |
| tagInput.value = ''; | |
| } | |
| }); | |
| tagInput.addEventListener('blur', () => { | |
| if (tagInput.value.trim()) { | |
| addTag(tagInput.value.trim()); | |
| tagInput.value = ''; | |
| } | |
| }); | |
| // Suggested tags | |
| document.addEventListener('click', (e) => { | |
| if (e.target.classList.contains('tag-suggestion')) { | |
| addTag(e.target.dataset.tag); | |
| } | |
| }); | |
| } | |
| let currentEditingItem = null; | |
| let currentTags = []; | |
| function openNotesModal(existingItem = null) { | |
| currentEditingItem = existingItem; | |
| notesModal.classList.remove('hidden'); | |
| if (existingItem) { | |
| itemTitle.value = existingItem.title; | |
| itemNotes.value = existingItem.notes || ''; | |
| currentTags = existingItem.tags ? [...existingItem.tags] : []; | |
| } else { | |
| itemTitle.value = currentText.substring(0, 50) + (currentText.length > 50 ? '...' : ''); | |
| itemNotes.value = ''; | |
| currentTags = []; | |
| } | |
| updateTagDisplay(); | |
| itemTitle.focus(); | |
| } | |
| function closeNotes() { | |
| notesModal.classList.add('hidden'); | |
| currentEditingItem = null; | |
| currentTags = []; | |
| } | |
| function addTag(tag) { | |
| if (tag && !currentTags.includes(tag)) { | |
| currentTags.push(tag); | |
| updateTagDisplay(); | |
| } | |
| } | |
| function removeTag(tag) { | |
| currentTags = currentTags.filter(t => t !== tag); | |
| updateTagDisplay(); | |
| } | |
| function updateTagDisplay() { | |
| currentTags.innerHTML = ''; | |
| currentTags.forEach(tag => { | |
| const tagEl = document.createElement('span'); | |
| tagEl.className = 'tag custom'; | |
| tagEl.innerHTML = ` | |
| ${tag} | |
| <button onclick="removeTag('${tag}')" class="ml-1 text-xs hover:text-red-200"> | |
| <i class="fas fa-times" aria-hidden="true"></i> | |
| </button> | |
| `; | |
| currentTags.appendChild(tagEl); | |
| }); | |
| } | |
| function saveNotesToLibrary() { | |
| if (!currentAudioBlob && !currentEditingItem) { | |
| showToast('No audio to save', 'error'); | |
| return; | |
| } | |
| const title = itemTitle.value.trim() || 'Untitled Audio'; | |
| const notes = itemNotes.value.trim(); | |
| if (currentEditingItem) { | |
| // Update existing item | |
| currentEditingItem.title = title; | |
| currentEditingItem.notes = notes; | |
| currentEditingItem.tags = [...currentTags]; | |
| currentEditingItem.lastModified = new Date().toISOString(); | |
| } else { | |
| // Create new item | |
| const libraryItem = { | |
| id: Date.now(), | |
| title: title, | |
| text: currentText, | |
| notes: notes, | |
| tags: [...currentTags], | |
| voice: selectedVoice ? selectedVoice.name : 'Unknown', | |
| settings: { | |
| rate: parseFloat(rateSelect.value), | |
| pitch: parseFloat(pitchSelect.value), | |
| model: modelSelect.value | |
| }, | |
| date: new Date().toISOString(), | |
| size: currentAudioBlob ? currentAudioBlob.size : 0, | |
| duration: audioBuffer ? audioBuffer.duration : null, | |
| isFavorite: false | |
| }; | |
| audioLibrary.push(libraryItem); | |
| } | |
| // Save to localStorage (without audio data for space) | |
| const libraryForStorage = audioLibrary.map(item => ({ | |
| ...item, | |
| audioData: null // Don't store large audio data | |
| })); | |
| localStorage.setItem('audioLibrary', JSON.stringify(libraryForStorage)); | |
| // Limit library size | |
| if (audioLibrary.length > 100) { | |
| audioLibrary = audioLibrary.slice(-100); | |
| } | |
| updateLibraryView(); | |
| closeNotes(); | |
| showToast('Audio saved to library with notes', 'success'); | |
| } | |
| // Make removeTag accessible globally | |
| window.removeTag = removeTag; | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| // Ctrl+Space or Cmd+Space to play/pause | |
| if ((e.ctrlKey || e.metaKey) && e.code === 'Space') { | |
| e.preventDefault(); | |
| playBtn.click(); | |
| } | |
| // Ctrl+Enter or Cmd+Enter to synthesize | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| synthesizeBtn.click(); | |
| } | |
| // Ctrl+S or Cmd+S to save to library | |
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { | |
| e.preventDefault(); | |
| if (currentAudioBlob) { | |
| saveToLibraryBtn.click(); | |
| } | |
| } | |
| // Escape to close modals | |
| if (e.key === 'Escape') { | |
| if (!libraryModal.classList.contains('hidden')) { | |
| closeLibrary(); | |
| } else if (!notesModal.classList.contains('hidden')) { | |
| closeNotes(); | |
| } | |
| } | |
| }); | |
| // Event listeners | |
| refreshVoicesBtn.addEventListener('click', loadVoices); | |
| languageSelect.addEventListener('change', loadVoices); | |
| // Modal click outside to close | |
| libraryModal.addEventListener('click', (e) => { | |
| if (e.target === libraryModal) { | |
| closeLibrary(); | |
| } | |
| }); | |
| notesModal.addEventListener('click', (e) => { | |
| if (e.target === notesModal) { | |
| closeNotes(); | |
| } | |
| }); | |
| // Initialize the application | |
| init(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |