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