tts-reader / index.html
Toowired's picture
Update index.html
eff4144 verified
<!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>