tts-reader / index.html
Toowired's picture
Update index.html
eff4144 verified
Raw History Blame Contribute Delete
142 kB
<!DOCTYPE html> <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) !important; } .border-gray-200 { border-color: var(--border-color) !important; } .text-gray-800 { color: var(--text-primary) !important; } /* 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>
<!DOCTYPE html>
<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) !important;
}
.border-gray-200 {
border-color: var(--border-color) !important;
}
.text-gray-800 {
color: var(--text-primary) !important;
}
/* 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);